chore: initial commit of Go API gateway
CI / Test (push) Canceled after 0s
CI / Vet (push) Canceled after 0s
CI / Build (push) Canceled after 0s
CI / Security (govulncheck) (push) Canceled after 0s

Baseline for the Kong replacement on api.riotpiao.com. Brings the working
tree under version control for the first time: gateway source, the task
board that drives the agent runs, test fixtures, and K8s manifests.

Anchor the gateway ignore rule to the repo root. Unanchored, "gateway"
also matched the cmd/gateway/ source directory, so the program entrypoint
was excluded from every commit.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Story Crater Bot
2026-08-19 20:54:34 -07:00
co-authored by Claude Opus 5
commit 058f11cf2b
109 changed files with 8992 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
// Package testsupport provides the local-development stub that every upstream
// in testdata/config/harness.yaml targets. It exists so tests under
// internal/proxy (future) and here can verify streaming semantics without a
// Kubernetes cluster or real credentials.
//
// The harness binds ONE http.Server on 127.0.0.1:9080 by default — this matches
// the addresses baked into testdata/config/harness.yaml so that fixture is valid
// before any code runs, and no network traffic leaves localhost during verification.
package testsupport
import (
"fmt"
"net"
"net/http"
"os"
"sync"
)
const defaultLocalPort = "9080"
// DefaultConfigPath is the committed fixture that points every upstream at the
// same local port; clients load it via config.LoadRoutesFromFile. Tests can
// override the path if they want a different schema.
const DefaultConfigPath = "testdata/config/harness.yaml"
// Snapshot describes a running stub server.
type Snapshot struct {
BaseURL string
Client *http.Client
}
// BaseAddr returns the host:port form of the bound address, no scheme.
func (s *Snapshot) BaseAddr() string {
return s.BaseURL[len("http://"):]
}
// URL joins the stub base URL with one of the Path* constants.
func (s *Snapshot) URL(path string) string {
return s.BaseURL + path
}
// harness is the singleton that owns the stub server for a single process.
type harness struct {
mu sync.Mutex
srv *http.Server // nil when not running; set exactly once per Close/Start cycle
addr string // bound address, valid only while srv != nil
}
// global is the singleton used by tests. A fresh server is bound lazily on
// first Start() and shared thereafter for the lifetime of the harness process
// (usually a single TestMain run).
var global = &harness{}
// Start binds the stub server if it is not already running and returns a
// Snapshot describing it. It is safe to call from multiple tests; the second
// and later calls return the already-bound server.
func Start() (*Snapshot, error) { return global.Start() }
// Close stops the stub server. Safe to call multiple times.
func Close() { global.Close() }
func (h *harness) Start() (*Snapshot, error) {
h.mu.Lock()
defer h.mu.Unlock()
if h.srv != nil {
return h.snapshotLocked(), nil
}
// Listen separately from Serve. srv.ListenAndServe would block until
// shutdown, so Start could never return; binding first also guarantees the
// port is accepting connections by the time the caller gets the Snapshot.
ln, err := net.Listen("tcp", defaultStubAddr())
if err != nil {
return nil, fmt.Errorf("bind stub server at %s: %w", defaultStubAddr(), err)
}
srv := &http.Server{Handler: newStubHandler()}
h.srv = srv
h.addr = ln.Addr().String()
// Serve always returns a non-nil error; after Close that error is
// ErrServerClosed, which is the expected path and not worth reporting.
go func() { _ = srv.Serve(ln) }()
return h.snapshotLocked(), nil
}
// snapshotLocked builds a Snapshot for the running server. Caller holds h.mu.
func (h *harness) snapshotLocked() *Snapshot {
return &Snapshot{BaseURL: "http://" + h.addr, Client: http.DefaultClient}
}
// Close stops the underlying stub server. Safe to call on any harness instance
// or multiple times — it is idempotent within a process.
func (h *harness) Close() {
h.mu.Lock()
defer h.mu.Unlock()
if h.srv == nil {
return
}
s := h.srv
h.srv = nil
h.addr = ""
_ = s.Close() // best-effort shutdown; test output not dependent on it
}
// defaultStubAddr constructs "127.0.0.1:<port>" honoring HARNESS_STUB_PORT if
// set, falling back to 9080 — which matches every address in the committed YAML
// fixture. Set only when you need parallel test runs within a single process;
// otherwise leave unset.
func defaultStubAddr() string {
port := os.Getenv("HARNESS_STUB_PORT")
if port == "" {
port = defaultLocalPort
}
return "127.0.0.1:" + port
}
+135
View File
@@ -0,0 +1,135 @@
package testsupport
import (
"bufio"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
// startForTest binds the stub on an ephemeral port so the suite does not fight
// with anything already holding 9080, and fails fast if Start blocks — the
// original harness called ListenAndServe inline and never returned.
func startForTest(t *testing.T) *Snapshot {
t.Helper()
t.Setenv("HARNESS_STUB_PORT", "0")
type result struct {
snap *Snapshot
err error
}
done := make(chan result, 1)
go func() {
snap, err := Start()
done <- result{snap, err}
}()
select {
case r := <-done:
if r.err != nil {
t.Fatalf("Start() error: %v", r.err)
}
t.Cleanup(Close)
return r.snap
case <-time.After(5 * time.Second):
t.Fatal("Start() did not return within 5s — it is blocking instead of serving in the background")
return nil
}
}
func TestStartServesFixedJSON(t *testing.T) {
snap := startForTest(t)
resp, err := snap.Client.Get(snap.URL(PathJSON))
if err != nil {
t.Fatalf("GET %s: %v", PathJSON, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if got := resp.Header.Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want %q", got, "application/json")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if want := `{"status":"ok"}`; strings.TrimSpace(string(body)) != want {
t.Errorf("body = %q, want %q", strings.TrimSpace(string(body)), want)
}
}
func TestSSEArrivesIncrementally(t *testing.T) {
snap := startForTest(t)
resp, err := snap.Client.Get(snap.URL(PathSSE))
if err != nil {
t.Fatalf("GET %s: %v", PathSSE, err)
}
defer func() { _ = resp.Body.Close() }()
// The first event must be readable before the handler has written the last
// one. If the response were buffered to completion, this read would only
// unblock after every token plus every gap had elapsed.
deadline := time.Now().Add(time.Duration(len(SSETokens)) * SSEGap)
reader := bufio.NewReader(resp.Body)
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read first event: %v", err)
}
if time.Now().After(deadline) {
t.Error("first SSE event arrived only after the whole stream was written; not incremental")
}
if want := "data: " + SSETokens[0]; strings.TrimSpace(line) != want {
t.Errorf("first event = %q, want %q", strings.TrimSpace(line), want)
}
rest, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read remaining events: %v", err)
}
if !strings.Contains(string(rest), "data: [DONE]") {
t.Error("stream did not end with the [DONE] sentinel")
}
}
func TestStartIsIdempotent(t *testing.T) {
first := startForTest(t)
second, err := Start()
if err != nil {
t.Fatalf("second Start() error: %v", err)
}
if first.BaseURL != second.BaseURL {
t.Errorf("second Start() bound a different address: %q vs %q", second.BaseURL, first.BaseURL)
}
}
func TestHarnessFixtureLoadsAndStaysOnLoopback(t *testing.T) {
// DefaultConfigPath is relative to the repo root; tests run in the package
// directory, so walk back up to it.
routes, err := config.LoadRoutesFromFile("../../" + DefaultConfigPath)
if err != nil {
t.Fatalf("LoadRoutesFromFile(%s): %v", DefaultConfigPath, err)
}
if len(routes) == 0 {
t.Fatal("fixture declared no routes")
}
for name, route := range routes {
if !strings.HasPrefix(route.Upstream.Address, "127.0.0.1:") {
t.Errorf("route %q upstream %q is not on loopback", name, route.Upstream.Address)
}
if route.Upstream.AuthRequired {
t.Errorf("route %q requires auth; the harness must run with no credentials", name)
}
}
}
+123
View File
@@ -0,0 +1,123 @@
package testsupport
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
// Stub response paths. testdata/config/harness.yaml points every upstream at the
// one stub server, so the response shape is selected by path, not by port.
const (
PathJSON = "/stub/json"
PathSSE = "/stub/sse"
PathChunked = "/stub/chunked"
PathSlow = "/stub/slow"
)
// SSEGap is the pause between SSE events. It exists so a test can observe that
// chunks arrive incrementally rather than all at once at the end.
const SSEGap = 20 * time.Millisecond
// SlowDelay is how long PathSlow waits before writing anything.
const SlowDelay = 250 * time.Millisecond
// SSETokens are the tokens PathSSE emits, one event per token, followed by the
// [DONE] sentinel that OpenAI-shaped clients expect.
var SSETokens = []string{"Hello", " ", "world", "!"}
// ChunkedBodies are the pieces PathChunked writes, each flushed separately so
// the response goes out with Transfer-Encoding: chunked.
var ChunkedBodies = []string{"first\n", "second\n", "third\n"}
// newStubHandler builds the mux served by the harness. Every handler writes a
// deterministic body so tests can assert on exact bytes.
func newStubHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(PathJSON, stubJSON)
mux.HandleFunc(PathSSE, stubSSE)
mux.HandleFunc(PathChunked, stubChunked)
mux.HandleFunc(PathSlow, stubSlow)
return mux
}
// stubJSON serves a fixed JSON body.
func stubJSON(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// Encode failure here means the client hung up mid-write; the connection is
// already gone, so there is nothing to report and no header left to change.
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// stubSSE streams one event per token, flushing after each. It returns early
// when the client disconnects, so a mid-response disconnect test can assert on
// how many tokens the upstream actually managed to emit.
func stubSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
if err := rc.Flush(); err != nil {
return
}
for _, tok := range SSETokens {
select {
case <-r.Context().Done():
return
case <-time.After(SSEGap):
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", tok); err != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
}
if _, err := fmt.Fprint(w, "data: [DONE]\n\n"); err != nil {
return
}
_ = rc.Flush()
}
// stubChunked writes several pieces with a flush between each, producing a
// chunked transfer with no Content-Length.
func stubChunked(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
for _, body := range ChunkedBodies {
select {
case <-r.Context().Done():
return
case <-time.After(SSEGap):
}
if _, err := fmt.Fprint(w, body); err != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
}
}
// stubSlow waits SlowDelay before responding at all, for timeout tests.
func stubSlow(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
return
case <-time.After(SlowDelay):
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, "slow\n")
}