diff --git a/.gitignore b/.gitignore
index 425aea20..3747f69c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,16 +8,9 @@
*.out
go.work
-# Build artifacts
+# Build
/bin
/dist
-/hatch
-/hatch-*
-bin/
-*.db
-*.db-journal
-*.db-wal
-*.db-shm
# IDE
.idea/
@@ -26,3 +19,5 @@ bin/
*.swo
*~
.DS_Store
+/hatch
+hatch.exe
diff --git a/brand/banner/readme-banner.png b/brand/banner/readme-banner.png
new file mode 100644
index 00000000..c4091e62
Binary files /dev/null and b/brand/banner/readme-banner.png differ
diff --git a/brand/banner/readme-banner.svg b/brand/banner/readme-banner.svg
new file mode 100644
index 00000000..9ec41db3
--- /dev/null
+++ b/brand/banner/readme-banner.svg
@@ -0,0 +1,70 @@
+
+
diff --git a/brand/banner/readme-banner@2x.png b/brand/banner/readme-banner@2x.png
new file mode 100644
index 00000000..ed1d613e
Binary files /dev/null and b/brand/banner/readme-banner@2x.png differ
diff --git a/brand/favicon/favicon-16.png b/brand/favicon/favicon-16.png
new file mode 100644
index 00000000..f1b88871
Binary files /dev/null and b/brand/favicon/favicon-16.png differ
diff --git a/brand/favicon/favicon-192.png b/brand/favicon/favicon-192.png
new file mode 100644
index 00000000..d531ad34
Binary files /dev/null and b/brand/favicon/favicon-192.png differ
diff --git a/brand/favicon/favicon-32.png b/brand/favicon/favicon-32.png
new file mode 100644
index 00000000..ac18d17d
Binary files /dev/null and b/brand/favicon/favicon-32.png differ
diff --git a/brand/favicon/favicon-48.png b/brand/favicon/favicon-48.png
new file mode 100644
index 00000000..2ed62cb2
Binary files /dev/null and b/brand/favicon/favicon-48.png differ
diff --git a/brand/favicon/favicon-512.png b/brand/favicon/favicon-512.png
new file mode 100644
index 00000000..15182def
Binary files /dev/null and b/brand/favicon/favicon-512.png differ
diff --git a/brand/favicon/favicon.ico b/brand/favicon/favicon.ico
new file mode 100644
index 00000000..0d92607a
Binary files /dev/null and b/brand/favicon/favicon.ico differ
diff --git a/brand/mark/mark-1024.png b/brand/mark/mark-1024.png
new file mode 100644
index 00000000..ef463946
Binary files /dev/null and b/brand/mark/mark-1024.png differ
diff --git a/brand/mark/mark-128.png b/brand/mark/mark-128.png
new file mode 100644
index 00000000..28177cc5
Binary files /dev/null and b/brand/mark/mark-128.png differ
diff --git a/brand/mark/mark-16.png b/brand/mark/mark-16.png
new file mode 100644
index 00000000..f1b88871
Binary files /dev/null and b/brand/mark/mark-16.png differ
diff --git a/brand/mark/mark-192.png b/brand/mark/mark-192.png
new file mode 100644
index 00000000..d531ad34
Binary files /dev/null and b/brand/mark/mark-192.png differ
diff --git a/brand/mark/mark-256.png b/brand/mark/mark-256.png
new file mode 100644
index 00000000..258d57ac
Binary files /dev/null and b/brand/mark/mark-256.png differ
diff --git a/brand/mark/mark-32.png b/brand/mark/mark-32.png
new file mode 100644
index 00000000..ac18d17d
Binary files /dev/null and b/brand/mark/mark-32.png differ
diff --git a/brand/mark/mark-48.png b/brand/mark/mark-48.png
new file mode 100644
index 00000000..2ed62cb2
Binary files /dev/null and b/brand/mark/mark-48.png differ
diff --git a/brand/mark/mark-512.png b/brand/mark/mark-512.png
new file mode 100644
index 00000000..15182def
Binary files /dev/null and b/brand/mark/mark-512.png differ
diff --git a/brand/mark/mark-64.png b/brand/mark/mark-64.png
new file mode 100644
index 00000000..27879df2
Binary files /dev/null and b/brand/mark/mark-64.png differ
diff --git a/brand/mark/mark-dark.svg b/brand/mark/mark-dark.svg
new file mode 100644
index 00000000..6a8bd213
--- /dev/null
+++ b/brand/mark/mark-dark.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/brand/mark/mark-on-dark.svg b/brand/mark/mark-on-dark.svg
new file mode 100644
index 00000000..bade22f9
--- /dev/null
+++ b/brand/mark/mark-on-dark.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/brand/mark/mark-on-light.svg b/brand/mark/mark-on-light.svg
new file mode 100644
index 00000000..04835eb6
--- /dev/null
+++ b/brand/mark/mark-on-light.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/brand/mark/mark.svg b/brand/mark/mark.svg
new file mode 100644
index 00000000..7b93d442
--- /dev/null
+++ b/brand/mark/mark.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/brand/og/og.png b/brand/og/og.png
new file mode 100644
index 00000000..d7603e0a
Binary files /dev/null and b/brand/og/og.png differ
diff --git a/brand/og/og.svg b/brand/og/og.svg
new file mode 100644
index 00000000..9503c94a
--- /dev/null
+++ b/brand/og/og.svg
@@ -0,0 +1,69 @@
+
+
diff --git a/brand/og/og@2x.png b/brand/og/og@2x.png
new file mode 100644
index 00000000..cdf2d022
Binary files /dev/null and b/brand/og/og@2x.png differ
diff --git a/brand/wordmark/wordmark-1200x360.png b/brand/wordmark/wordmark-1200x360.png
new file mode 100644
index 00000000..332be82a
Binary files /dev/null and b/brand/wordmark/wordmark-1200x360.png differ
diff --git a/brand/wordmark/wordmark-200x60.png b/brand/wordmark/wordmark-200x60.png
new file mode 100644
index 00000000..22a99028
Binary files /dev/null and b/brand/wordmark/wordmark-200x60.png differ
diff --git a/brand/wordmark/wordmark-400x120.png b/brand/wordmark/wordmark-400x120.png
new file mode 100644
index 00000000..462ab5ac
Binary files /dev/null and b/brand/wordmark/wordmark-400x120.png differ
diff --git a/brand/wordmark/wordmark-800x240.png b/brand/wordmark/wordmark-800x240.png
new file mode 100644
index 00000000..09ed777d
Binary files /dev/null and b/brand/wordmark/wordmark-800x240.png differ
diff --git a/brand/wordmark/wordmark-dark.svg b/brand/wordmark/wordmark-dark.svg
new file mode 100644
index 00000000..4216a271
--- /dev/null
+++ b/brand/wordmark/wordmark-dark.svg
@@ -0,0 +1,25 @@
+
+
diff --git a/brand/wordmark/wordmark-on-dark.svg b/brand/wordmark/wordmark-on-dark.svg
new file mode 100644
index 00000000..63506937
--- /dev/null
+++ b/brand/wordmark/wordmark-on-dark.svg
@@ -0,0 +1,25 @@
+
+
diff --git a/brand/wordmark/wordmark-on-light.svg b/brand/wordmark/wordmark-on-light.svg
new file mode 100644
index 00000000..ea031c89
--- /dev/null
+++ b/brand/wordmark/wordmark-on-light.svg
@@ -0,0 +1,25 @@
+
+
diff --git a/brand/wordmark/wordmark.svg b/brand/wordmark/wordmark.svg
new file mode 100644
index 00000000..8456389d
--- /dev/null
+++ b/brand/wordmark/wordmark.svg
@@ -0,0 +1,25 @@
+
+
diff --git a/cmd/hatch/main.go b/cmd/hatch/main.go
index 493c035b..7763966f 100644
--- a/cmd/hatch/main.go
+++ b/cmd/hatch/main.go
@@ -1,8 +1,3 @@
-// Command hatch is the Hatch HTTP request inspector + mocker server.
-//
-// Hatch captures, inspects, and mocks HTTP requests. It ships as a single
-// static binary — one command on a VPS and your payloads never leave your
-// network.
package main
import (
@@ -10,6 +5,10 @@ import (
"log"
"net/http"
"os"
+
+ "github.com/elfoundation/hatch/internal/handler"
+ "github.com/elfoundation/hatch/internal/store"
+ "github.com/go-chi/chi/v5"
)
func main() {
@@ -17,20 +16,20 @@ func main() {
if port == "" {
port = "8080"
}
+ dbPath := os.Getenv("HATCH_DB_PATH")
+ repo, err := store.Open(dbPath)
+ if err != nil {
+ log.Fatalf("hatch: open store: %v", err)
+ }
+ defer repo.Close()
- mux := http.NewServeMux()
- mux.HandleFunc("GET /healthz", healthz)
+ h := handler.New(repo)
+ r := chi.NewRouter()
+ h.RegisterRoutes(r)
addr := fmt.Sprintf(":%s", port)
log.Printf("hatch starting on %s", addr)
- if err := http.ListenAndServe(addr, mux); err != nil {
+ if err := http.ListenAndServe(addr, r); err != nil {
log.Fatalf("hatch server error: %v", err)
}
}
-
-// healthz returns 200 OK with body "ok" for liveness probes.
-func healthz(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/plain")
- w.WriteHeader(http.StatusOK)
- fmt.Fprintln(w, "ok")
-}
diff --git a/cmd/hatch/main_test.go b/cmd/hatch/main_test.go
index f9d5bb5e..94bcbb22 100644
--- a/cmd/hatch/main_test.go
+++ b/cmd/hatch/main_test.go
@@ -1,39 +1,63 @@
package main
import (
+ "context"
+ "encoding/json"
+ "fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
+ "time"
+
+ "github.com/elfoundation/hatch/internal/handler"
+ "github.com/elfoundation/hatch/internal/store"
+ "github.com/go-chi/chi/v5"
)
-func TestHealthz(t *testing.T) {
+func TestHealthzViaRouter(t *testing.T) {
+ repo, err := store.Open(":memory:")
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ defer repo.Close()
+
+ r := chi.NewRouter()
+ h := handler.New(repo)
+ h.RegisterRoutes(r)
+
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
- healthz(w, req)
-
- resp := w.Result()
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("expected 200 OK, got %d", resp.StatusCode)
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200 OK, got %d", w.Code)
}
-
- body, err := io.ReadAll(resp.Body)
- resp.Body.Close()
- if err != nil {
- t.Fatalf("failed to read response body: %v", err)
- }
-
+ body, _ := io.ReadAll(w.Result().Body)
+ w.Result().Body.Close()
got := strings.TrimSpace(string(body))
if got != "ok" {
- t.Fatalf("expected body 'ok', got '%s'", got)
+ t.Fatalf("expected body 'ok', got %q", got)
}
}
func TestHealthzSmoke(t *testing.T) {
- // Smoke test: boot the server on a random port, hit /healthz, verify 200 + "ok".
- srv := httptest.NewServer(http.HandlerFunc(healthz))
+ // Smoke: boot server on random port, hit /healthz.
+ // Use :memory: store so no filesystem dependency.
+ t.Setenv("HATCH_DB_PATH", ":memory:")
+
+ repo, err := store.Open(":memory:")
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ defer repo.Close()
+
+ r := chi.NewRouter()
+ h := handler.New(repo)
+ h.RegisterRoutes(r)
+
+ srv := httptest.NewServer(r)
defer srv.Close()
resp, err := http.Get(srv.URL + "/healthz")
@@ -45,14 +69,388 @@ func TestHealthzSmoke(t *testing.T) {
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", resp.StatusCode)
}
-
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response body: %v", err)
}
-
got := strings.TrimSpace(string(body))
if got != "ok" {
- t.Fatalf("expected body 'ok', got '%s'", got)
+ t.Fatalf("expected body 'ok', got %q", got)
}
}
+
+// TestSmokeE2E is the acceptance gate: one command to prove the whole product works.
+// It starts a real server, exercises every feature (capture, inspect, SSE, mock,
+// replay), and verifies the full flow end-to-end.
+//
+// Run: go test ./cmd/hatch -run TestSmokeE2E -v
+func TestSmokeE2E(t *testing.T) {
+ // ---- Phase 0: Start server ----
+ repo, err := store.Open(":memory:")
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ defer repo.Close()
+
+ r := chi.NewRouter()
+ h := handler.New(repo)
+ h.RegisterRoutes(r)
+
+ srv := httptest.NewServer(r)
+ defer srv.Close()
+
+ client := &http.Client{Timeout: 10 * time.Second}
+
+ // ---- Phase 1: Health check ----
+ t.Run("healthz", func(t *testing.T) {
+ resp, err := client.Get(srv.URL + "/healthz")
+ if err != nil {
+ t.Fatalf("GET /healthz: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ if strings.TrimSpace(string(body)) != "ok" {
+ t.Fatalf("expected 'ok', got %q", string(body))
+ }
+ })
+
+ // ---- Phase 2: Capture requests (all HTTP methods) ----
+ t.Run("capture", func(t *testing.T) {
+ methods := []struct {
+ method string
+ body string
+ }{
+ {"GET", ""},
+ {"POST", `{"order":"latte"}`},
+ {"PUT", `{"status":"updated"}`},
+ {"PATCH", `{"field":"partial"}`},
+ {"DELETE", ""},
+ }
+ for _, m := range methods {
+ var body io.Reader
+ if m.body != "" {
+ body = strings.NewReader(m.body)
+ }
+ req, _ := http.NewRequest(m.method, srv.URL+"/capture-test", body)
+ if m.body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ req.Header.Set("X-Custom", "smoke-"+m.method)
+
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("%s /capture-test: %v", m.method, err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("%s: expected 200, got %d", m.method, resp.StatusCode)
+ }
+ }
+
+ // Verify all 5 requests are in the store.
+ reqs, err := repo.ListRequests(context.Background(), "capture-test", 10)
+ if err != nil {
+ t.Fatalf("list requests: %v", err)
+ }
+ if len(reqs) != 5 {
+ t.Fatalf("expected 5 captured requests, got %d", len(reqs))
+ }
+ })
+
+ // ---- Phase 3: Inspect page (HTML rendering) ----
+ t.Run("inspect", func(t *testing.T) {
+ resp, err := client.Get(srv.URL + "/e/capture-test")
+ if err != nil {
+ t.Fatalf("GET /e/capture-test: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
+ }
+ ct := resp.Header.Get("Content-Type")
+ if !strings.Contains(ct, "text/html") {
+ t.Fatalf("expected text/html, got %q", ct)
+ }
+
+ body, _ := io.ReadAll(resp.Body)
+ html := string(body)
+
+ // Page structure.
+ if !strings.Contains(html, "") {
+ t.Error("missing doctype")
+ }
+ if !strings.Contains(html, "capture-test") {
+ t.Error("missing endpoint ID")
+ }
+
+ // Request cards.
+ if !strings.Contains(html, "DELETE") {
+ t.Error("missing DELETE method badge")
+ }
+ if !strings.Contains(html, "POST") {
+ t.Error("missing POST method badge")
+ }
+
+ // Replay UI.
+ if !strings.Contains(html, "Replay") {
+ t.Error("missing Replay button")
+ }
+
+ // SSE live-update script.
+ if !strings.Contains(html, "EventSource") {
+ t.Error("missing EventSource script")
+ }
+
+ // Empty-state endpoint should show the hint.
+ resp2, err := client.Get(srv.URL + "/e/nonexistent")
+ if err != nil {
+ t.Fatalf("GET /e/nonexistent: %v", err)
+ }
+ defer resp2.Body.Close()
+ body2, _ := io.ReadAll(resp2.Body)
+ if !strings.Contains(string(body2), "Waiting for requests") {
+ t.Error("missing empty state for unused endpoint")
+ }
+ })
+
+ // ---- Phase 4: SSE stream receives live events ----
+ t.Run("sse", func(t *testing.T) {
+ // Use separate transports to avoid connection-pool issues with the long-lived SSE connection.
+ sseClient := &http.Client{
+ Transport: &http.Transport{DisableKeepAlives: true},
+ Timeout: 5 * time.Second,
+ }
+ captureClient := &http.Client{
+ Transport: &http.Transport{DisableKeepAlives: true},
+ Timeout: 5 * time.Second,
+ }
+
+ // Subscribe to SSE.
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ sseReq, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/e/sse-ep/events", nil)
+ sseResp, err := sseClient.Do(sseReq)
+ if err != nil {
+ t.Fatalf("SSE request: %v", err)
+ }
+ defer sseResp.Body.Close()
+
+ if sseResp.StatusCode != http.StatusOK {
+ t.Fatalf("SSE returned %d", sseResp.StatusCode)
+ }
+ ct := sseResp.Header.Get("Content-Type")
+ if !strings.HasPrefix(ct, "text/event-stream") {
+ t.Fatalf("SSE Content-Type: %q", ct)
+ }
+
+ // Capture a request on a goroutine; read SSE on main.
+ eventCh := make(chan string, 1)
+ go func() {
+ buf := make([]byte, 4096)
+ n, _ := sseResp.Body.Read(buf)
+ eventCh <- string(buf[:n])
+ }()
+
+ time.Sleep(20 * time.Millisecond) // let SSE subscription register
+
+ resp, err := captureClient.Post(srv.URL+"/sse-ep", "application/json", strings.NewReader(`{"sse":true}`))
+ if err != nil {
+ t.Fatalf("capture: %v", err)
+ }
+ resp.Body.Close()
+
+ select {
+ case event := <-eventCh:
+ if !strings.Contains(event, "data:") {
+ t.Fatalf("SSE event missing 'data:' prefix: %q", event)
+ }
+ // Body value is JSON-escaped in the SSE stream (e.g. "body":"{\"sse\":true}").
+ // Check for the content values rather than the exact escaped form.
+ if !strings.Contains(event, `sse`) || !strings.Contains(event, `true`) {
+ t.Fatalf("SSE event missing body content: %q", event)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("timeout waiting for SSE event")
+ }
+ })
+
+ // ---- Phase 5: Mock configuration and response ----
+ t.Run("mock", func(t *testing.T) {
+ // Set a mock on a fresh endpoint.
+ mockBody := `{"status":418,"headers":{"X-Teapot":"true"},"body":"eyJicmV3aW5nIjp0cnVlfQ=="}`
+
+ resp, err := client.Post(
+ srv.URL+"/e/mock-ep/mock",
+ "application/json",
+ strings.NewReader(mockBody),
+ )
+ if err != nil {
+ t.Fatalf("PUT /e/mock-ep/mock: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ // PUT is registered as PUT, but we use POST here — let's use PUT.
+ // The route is PUT /e/{endpointID}/mock
+ }
+
+ // Use proper PUT method.
+ req, _ := http.NewRequest(http.MethodPut, srv.URL+"/e/mock-ep-2/mock", strings.NewReader(mockBody))
+ req.Header.Set("Content-Type", "application/json")
+ resp2, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("PUT /e/mock-ep-2/mock: %v", err)
+ }
+ resp2.Body.Close()
+ if resp2.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200 OK from mock set, got %d: %s", resp2.StatusCode, readBody(resp2))
+ }
+
+ // Verify mock is stored.
+ mock, err := repo.GetMock(context.Background(), "mock-ep-2")
+ if err != nil {
+ t.Fatalf("mock not stored: %v", err)
+ }
+ if mock.Status != 418 {
+ t.Errorf("expected status 418, got %d", mock.Status)
+ }
+ if mock.Headers["X-Teapot"] != "true" {
+ t.Errorf("expected X-Teapot header, got %v", mock.Headers)
+ }
+
+ // Capture a request — should return the mock response.
+ resp3, err := client.Post(srv.URL+"/mock-ep-2", "application/json", strings.NewReader(`{"order":"earl grey"}`))
+ if err != nil {
+ t.Fatalf("capture on mocked endpoint: %v", err)
+ }
+ defer resp3.Body.Close()
+ if resp3.StatusCode != 418 {
+ t.Fatalf("expected mock status 418, got %d", resp3.StatusCode)
+ }
+ if resp3.Header.Get("X-Teapot") != "true" {
+ t.Errorf("expected X-Teapot: true, got %q", resp3.Header.Get("X-Teapot"))
+ }
+ body, _ := io.ReadAll(resp3.Body)
+ // Body is base64-decoded by Go's json decoder ([]byte field).
+ if strings.TrimSpace(string(body)) != `{"brewing":true}` {
+ t.Errorf("expected mock body, got %q", string(body))
+ }
+ })
+
+ // ---- Phase 6: Replay ----
+ t.Run("replay", func(t *testing.T) {
+ // Start a sink server that echoes back what it receives.
+ sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Sink", "yes")
+ w.WriteHeader(http.StatusCreated)
+ fmt.Fprintf(w, `{"echo":{"method":%q,"path":%q}}`, r.Method, r.URL.Path)
+ }))
+ defer sink.Close()
+
+ // Allow private replay so we can hit the loopback sink.
+ t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
+
+ // Capture a request to replay later.
+ captureResp, err := client.Post(srv.URL+"/replay-ep", "application/json", strings.NewReader(`{"msg":"replay me"}`))
+ if err != nil {
+ t.Fatalf("capture for replay: %v", err)
+ }
+ captureResp.Body.Close()
+
+ // Get the captured request ID from the store.
+ reqs, _ := repo.ListRequests(context.Background(), "replay-ep", 1)
+ if len(reqs) != 1 {
+ t.Fatalf("expected 1 captured request, got %d", len(reqs))
+ }
+ reqID := reqs[0].ID
+
+ // Replay to the sink.
+ replayPayload := fmt.Sprintf(`{"target_url":%q}`, sink.URL)
+ replayResp, err := client.Post(
+ srv.URL+"/e/replay-ep/requests/"+reqID+"/replay",
+ "application/json",
+ strings.NewReader(replayPayload),
+ )
+ if err != nil {
+ t.Fatalf("replay request: %v", err)
+ }
+ defer replayResp.Body.Close()
+ if replayResp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(replayResp.Body)
+ t.Fatalf("replay returned %d: %s", replayResp.StatusCode, string(body))
+ }
+
+ var result struct {
+ Status int `json:"status"`
+ Headers map[string]string `json:"headers"`
+ Body string `json:"body"`
+ }
+ json.NewDecoder(replayResp.Body).Decode(&result)
+ if result.Status != 201 {
+ t.Errorf("expected replay sink status 201, got %d", result.Status)
+ }
+ if result.Headers["X-Sink"] != "yes" {
+ t.Errorf("expected X-Sink: yes, got %q", result.Headers["X-Sink"])
+ }
+ })
+
+ // ---- Phase 7: Edge cases ----
+ t.Run("edge", func(t *testing.T) {
+ // Capture with query params.
+ resp, err := client.Get(srv.URL + "/query-ep?foo=bar&baz=qux")
+ if err != nil {
+ t.Fatalf("capture with query: %v", err)
+ }
+ resp.Body.Close()
+
+ reqs, _ := repo.ListRequests(context.Background(), "query-ep", 1)
+ if len(reqs) != 1 {
+ t.Fatalf("expected 1 request, got %d", len(reqs))
+ }
+ if reqs[0].Query != "foo=bar&baz=qux" {
+ t.Errorf("expected query 'foo=bar&baz=qux', got %q", reqs[0].Query)
+ }
+
+ // Capture with binary body.
+ binaryBody := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE}
+ resp2, err := client.Post(srv.URL+"/binary-ep", "application/octet-stream", strings.NewReader(string(binaryBody)))
+ if err != nil {
+ t.Fatalf("binary capture: %v", err)
+ }
+ resp2.Body.Close()
+
+ reqs2, _ := repo.ListRequests(context.Background(), "binary-ep", 1)
+ if len(reqs2) != 1 {
+ t.Fatalf("expected 1 binary request, got %d", len(reqs2))
+ }
+ if len(reqs2[0].Body) != len(binaryBody) {
+ t.Errorf("expected body len %d, got %d", len(binaryBody), len(reqs2[0].Body))
+ }
+
+ // SSRF protection: replay to localhost is denied.
+ replayPayload := `{"target_url":"http://localhost:9999/"}`
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/e/query-ep/requests/"+reqs[0].ID+"/replay", strings.NewReader(replayPayload))
+ req.Header.Set("Content-Type", "application/json")
+ resp3, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("SSRF test: %v", err)
+ }
+ resp3.Body.Close()
+ if resp3.StatusCode != http.StatusForbidden {
+ t.Errorf("expected 403 for localhost replay, got %d", resp3.StatusCode)
+ }
+ })
+
+ t.Log("E2E smoke test complete — all phases passed")
+}
+
+func readBody(resp *http.Response) string {
+ if resp.Body == nil {
+ return ""
+ }
+ b, _ := io.ReadAll(resp.Body)
+ return string(b)
+}
diff --git a/docs/brand/usage.md b/docs/brand/usage.md
new file mode 100644
index 00000000..c2c0d0c5
--- /dev/null
+++ b/docs/brand/usage.md
@@ -0,0 +1,142 @@
+# Hatch brand usage
+
+> One-pager. Read this before you put the wordmark anywhere.
+
+## Files in this kit
+
+```
+brand/
+├── wordmark/
+│ ├── wordmark.svg # master wordmark (text-only, with embedded font)
+│ ├── wordmark-dark.svg # dark variant (paper on transparent)
+│ ├── wordmark-on-light.svg # paper-on-warm-white background
+│ ├── wordmark-on-dark.svg # paper-on-ink background
+│ └── wordmark-*.png # 200x60, 400x120, 800x240, 1200x360
+├── mark/
+│ ├── mark.svg # the icon alone (no text)
+│ ├── mark-on-light.svg
+│ ├── mark-on-dark.svg
+│ └── mark-{16,32,48,64,128,192,256,512,1024}.png
+├── favicon/
+│ ├── favicon.ico # 16+32+48 multi-size ico
+│ └── favicon-{16,32,48,192,512}.png
+├── banner/
+│ ├── readme-banner.png # 1280x640
+│ └── readme-banner@2x.png # 2560x1280
+└── og/
+ ├── og.png # 1200x630
+ └── og@2x.png # 2400x1260
+```
+
+## What goes where
+
+| Surface | Use | File |
+| ---------------------------------------- | -------------------------------- | -------------------------------------- |
+| Favicon (browser tab, GitHub avatar) | The **mark**, not the wordmark | `brand/favicon/favicon.ico` |
+| Apple touch icon / PWA icon | The mark, 512px | `brand/favicon/favicon-512.png` |
+| README header | The **wordmark** (200x60+) | `brand/wordmark/wordmark-200x60.png` |
+| GitHub social preview / blog / link card | The **OG image** (1200x630) | `brand/og/og.png` |
+| Big surfaces, slides, README top | The **README banner** (1280x640) | `brand/banner/readme-banner.png` |
+
+For the favicon, always use `mark.svg` or one of the favicon PNGs — never the full
+wordmark. The wordmark does not read at 16x16.
+
+## Clear space
+
+Treat the mark as a square and the wordmark as the mark + the word. Clear space
+is **1× the cap-height of the "h" in "hatch"** on every side.
+
+- Wordmark: at least one "h"-height of empty space on top, bottom, left, and right
+- Mark: at least one "h"-height on every side
+
+```
+ ┌──────────────────────────────┐
+ │ │ ← 1× clear space
+ │ ┌──┐ hatch │
+ │ │ │ │
+ │ └──┘ │
+ │ │ ← 1× clear space
+ └──────────────────────────────┘
+```
+
+## Minimum size
+
+- Wordmark: **120px wide** is the minimum. Below that, switch to the mark.
+- Mark: **16px** is the minimum (we ship a tuned 16px favicon).
+
+## Color
+
+The brand has **one active color, one neutral, and one accent**. That's it.
+
+| Token | Hex | Use |
+| -------- | --------- | ------------------------------------------------- |
+| Ink | `#0A0A0B` | Wordmark, primary type, dark surfaces |
+| Paper | `#FAFAF9` | Light surfaces, type on dark |
+| Accent | `#F59E0B` | The "captured" signal — one place per composition |
+
+Neutral scale (for docs and UIs, not the wordmark): `#71717A` `#A1A1AA` `#D4D4D8` `#E4E4E7` `#F4F4F5`.
+
+**Don't pick colors outside the kit.** If you need another color, ask the Head of
+Marketing; do not invent one.
+
+## Light and dark mode
+
+The wordmark and the mark are **single-color**. Pick the right variant for the
+surface — never change the color of the wordmark to fit a theme.
+
+- **On a light surface** (≥ 4.5:1 contrast): use `wordmark.svg` (ink) or `mark.svg`
+- **On a dark surface**: use `wordmark-dark.svg` (paper) or `mark-dark.svg`
+- **Need a background tile?** Use `wordmark-on-light.svg` or `wordmark-on-dark.svg`
+
+For GitHub READMEs, GitHub auto-switches images in dark mode only if you use
+the `#gh-dark-mode-only` / `#gh-light-mode-only` MediaFragments trick:
+
+```markdown
+
+
+
+
+```
+
+## Typography (display + body)
+
+The wordmark uses **Inter Bold**. The brand voice guide and READMEs use:
+
+- **Display / headings:** Inter (Bold or SemiBold)
+- **Code / mono:** JetBrains Mono (Regular or Medium)
+
+Both are OFL-licensed and on the system. Don't substitute a different sans or mono
+in brand surfaces without sign-off.
+
+## Do not
+
+- **Don't stretch.** Always use the mark/wordmark at its native aspect ratio. If
+ you need it bigger, scale proportionally.
+- **Don't recolor.** The wordmark is ink or paper — period. No brand-color,
+ gradient, rainbow, or AI-painted versions.
+- **Don't add effects.** No drop shadows, no glows, no outlines, no bevel, no
+ inner shadows. The mark is a flat shape.
+- **Don't rotate.** The hatch opens at the top. Don't tilt the mark.
+- **Don't rearrange the wordmark.** The mark always sits to the **left** of the
+ wordmark, with a 0.22×-mark-width gap. No stacking, no right-alignment.
+- **Don't put the wordmark on a busy image.** The wordmark needs contrast. On a
+ photo or a complex background, use a solid color tile behind it.
+- **Don't use the wordmark in body copy.** The wordmark is for headers, banners,
+ and brand surfaces only. Body type stays in Inter.
+- **Don't use the favicon as a profile picture.** Use the 512px PNG (`mark-512.png`)
+ for that, and round the corners if you want — but the square mark is the
+ canonical shape.
+
+## Licensing & attribution
+
+- **Inter** — SIL Open Font License 1.1 (Inter by Rasmus Andersson, rsms.me)
+- **JetBrains Mono** — SIL Open Font License 1.1 (JetBrains)
+- The mark and wordmark are the property of El Foundation. Use them to talk about
+ Hatch. Do not register them as a trademark or use them to sell a competing
+ product.
+
+## Need something new?
+
+If a new surface needs a brand asset that isn't in the kit (a slide template, a
+business card, a T-shirt, an icon variant), open an issue assigned to the Brand
+Designer. Do not stretch, recolor, or rearrange this kit to make it work.
diff --git a/go.mod b/go.mod
index a0431d6b..504323fd 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,20 @@
module github.com/elfoundation/hatch
go 1.25.0
+
+require (
+ github.com/go-chi/chi/v5 v5.3.0
+ github.com/google/uuid v1.6.0
+ modernc.org/sqlite v1.53.0
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.44.0 // indirect
+ modernc.org/libc v1.73.4 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+)
diff --git a/go.sum b/go.sum
index e69de29b..860a5699 100644
--- a/go.sum
+++ b/go.sum
@@ -0,0 +1,53 @@
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
+github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
+golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
+golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
+modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
+modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
+modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
+modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
+modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
+modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
new file mode 100644
index 00000000..40b90ecf
--- /dev/null
+++ b/internal/handler/handler.go
@@ -0,0 +1,125 @@
+package handler
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/elfoundation/hatch/internal/store"
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+)
+
+// Handler groups all HTTP handlers and their shared dependencies.
+type Handler struct {
+ Repo store.Repository
+}
+
+// New creates a new Handler with the given store.
+func New(repo store.Repository) *Handler {
+ return &Handler{Repo: repo}
+}
+
+// RegisterRoutes mounts all routes on the given chi router.
+func (h *Handler) RegisterRoutes(r chi.Router) {
+ r.Use(middleware.Logger)
+ r.Use(middleware.Recoverer)
+
+ // Health check.
+ r.Get("/healthz", Healthz)
+
+ // Inspect page: server-rendered request list.
+ r.Get("/e/{endpointID}", HandleInspect(h.Repo))
+
+ // SSE stream for live updates.
+ r.Get("/e/{endpointID}/events", HandleSSE(h.Repo))
+
+ // Mock configuration.
+ r.Put("/e/{endpointID}/mock", HandleMock(h.Repo))
+
+ // Replay: one-click re-send of a captured request.
+ r.Post("/e/{endpointID}/requests/{requestID}/replay", HandleReplay(h.Repo))
+
+ // Capture webhook: any method on /{endpointID} (wildcard, must be last).
+ r.HandleFunc("/{endpointID}", h.capture)
+ r.HandleFunc("/{endpointID}/*", h.capture)
+}
+
+// capture handles incoming webhook captures on /{endpointID}.
+func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
+ eid := r.PathValue("endpointID")
+ if eid == "" {
+ writeError(w, http.StatusBadRequest, "missing endpoint ID")
+ return
+ }
+
+ ctx := r.Context()
+
+ // Auto-create endpoint if it doesn't exist.
+ if _, err := h.Repo.GetEndpoint(ctx, eid); err != nil {
+ h.Repo.CreateEndpoint(ctx, eid)
+ }
+
+ // Collect headers as JSON.
+ hdr := map[string]string{}
+ for k, v := range r.Header {
+ hdr[k] = strings.Join(v, ", ")
+ }
+ hdrJSON, _ := json.Marshal(hdr)
+
+ // Read body.
+ var body []byte
+ if r.Body != nil {
+ body, _ = io.ReadAll(r.Body)
+ r.Body.Close()
+ }
+
+ // Store the request.
+ req := &store.Request{
+ Method: r.Method,
+ Path: r.URL.Path,
+ Headers: string(hdrJSON),
+ Query: r.URL.RawQuery,
+ Body: body,
+ }
+ if err := h.Repo.AppendRequest(ctx, eid, req); err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to store request")
+ return
+ }
+
+ // Broadcast via SSE.
+ broadcastRequest(eid, req)
+
+ // Look up mock response for this endpoint.
+ mock, err := h.Repo.GetMock(ctx, eid)
+ if err == nil && mock != nil {
+ for k, v := range mock.Headers {
+ w.Header().Set(k, v)
+ }
+ w.WriteHeader(mock.Status)
+ if mock.Body != nil {
+ w.Write(mock.Body)
+ }
+ return
+ }
+
+ // Default: return 200 OK with empty JSON.
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
+}
+
+// Healthz returns 200 OK for liveness probes.
+func Healthz(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("ok\n"))
+}
+
+// writeError writes a JSON error response.
+func writeError(w http.ResponseWriter, status int, msg string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(map[string]string{"error": msg})
+}
diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go
new file mode 100644
index 00000000..d11f69e4
--- /dev/null
+++ b/internal/handler/handler_test.go
@@ -0,0 +1,442 @@
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/elfoundation/hatch/internal/store"
+ "github.com/go-chi/chi/v5"
+)
+
+// fakeRepo implements store.Repository for tests.
+type fakeRepo struct {
+ endpoints map[string]*store.Endpoint
+ requests []*store.Request
+ mocks map[string]*store.MockConfig
+}
+
+func newFakeRepo() *fakeRepo {
+ return &fakeRepo{
+ endpoints: map[string]*store.Endpoint{},
+ mocks: map[string]*store.MockConfig{},
+ }
+}
+
+func (f *fakeRepo) CreateEndpoint(_ context.Context, u string) (*store.Endpoint, error) {
+ e := &store.Endpoint{ID: u, URL: u, CreatedAt: "t", UpdatedAt: "t"}
+ f.endpoints[u] = e
+ return e, nil
+}
+func (f *fakeRepo) GetEndpoint(_ context.Context, id string) (*store.Endpoint, error) {
+ e, ok := f.endpoints[id]
+ if !ok {
+ return nil, errNotFound
+ }
+ return e, nil
+}
+func (f *fakeRepo) AppendRequest(_ context.Context, eid string, r *store.Request) error {
+ r.ID = "req-" + string(rune(len(f.requests)+'0'))
+ r.EndpointID = eid
+ f.requests = append(f.requests, r)
+ return nil
+}
+func (f *fakeRepo) GetRequest(_ context.Context, id string) (*store.Request, error) {
+ for _, r := range f.requests {
+ if r.ID == id {
+ return r, nil
+ }
+ }
+ return nil, errNotFound
+}
+func (f *fakeRepo) ListRequests(_ context.Context, _ string, _ int) ([]*store.Request, error) {
+ return f.requests, nil
+}
+func (f *fakeRepo) GetMock(_ context.Context, endpointID string) (*store.MockConfig, error) {
+ m, ok := f.mocks[endpointID]
+ if !ok {
+ return nil, errNotFound
+ }
+ return m, nil
+}
+func (f *fakeRepo) SetMock(_ context.Context, mock *store.MockConfig) error {
+ f.mocks[mock.EndpointID] = mock
+ return nil
+}
+func (f *fakeRepo) Close() error { return nil }
+
+var errNotFound = &se{"nf"}
+
+type se struct{ m string }
+
+func (e *se) Error() string { return e.m }
+
+// testRouter creates a chi router with all routes registered using a fake repo.
+func testRouter(repo store.Repository) chi.Router {
+ r := chi.NewRouter()
+ h := New(repo)
+ h.RegisterRoutes(r)
+ return r
+}
+
+func TestHealthz(t *testing.T) {
+ r := testRouter(newFakeRepo())
+ req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+ body := strings.TrimSpace(w.Body.String())
+ if body != "ok" {
+ t.Fatalf("expected 'ok', got %q", body)
+ }
+}
+
+func TestCaptureRecordsAllVerbs(t *testing.T) {
+ methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE"}
+ for _, m := range methods {
+ repo := newFakeRepo()
+ r := testRouter(repo)
+
+ body := ""
+ if m == "POST" || m == "PUT" || m == "PATCH" {
+ body = `{"k":"v"}`
+ }
+ req := httptest.NewRequest(m, "/ep", strings.NewReader(body))
+ if m == "GET" {
+ req.Header.Set("Accept", "text/html")
+ }
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != 200 {
+ t.Errorf("%s: expected 200, got %d", m, w.Code)
+ }
+ if len(repo.requests) != 1 {
+ t.Errorf("%s: expected 1 request, got %d", m, len(repo.requests))
+ continue
+ }
+ reqCaptured := repo.requests[0]
+ if reqCaptured.Method != m {
+ t.Errorf("%s: wrong method %s", m, reqCaptured.Method)
+ }
+ }
+}
+
+func TestSSEStreamReceivesEventOnCapture(t *testing.T) {
+ repo := newFakeRepo()
+ srv := httptest.NewServer(testRouter(repo))
+ defer srv.Close()
+
+ // Use separate clients with separate transports to avoid
+ // connection-pool contention with the long-lived SSE connection.
+ sseClient := &http.Client{
+ Transport: &http.Transport{DisableKeepAlives: true},
+ }
+ captureClient := &http.Client{
+ Transport: &http.Transport{DisableKeepAlives: true},
+ }
+
+ // Subscribe to SSE with a cancellable context.
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ sseReq, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/e/ep/events", nil)
+ if err != nil {
+ t.Fatalf("SSE request create failed: %v", err)
+ }
+ sseResp, err := sseClient.Do(sseReq)
+ if err != nil {
+ t.Fatalf("SSE GET failed: %v", err)
+ }
+ defer sseResp.Body.Close()
+
+ if sseResp.StatusCode != http.StatusOK {
+ t.Fatalf("SSE returned %d", sseResp.StatusCode)
+ }
+ ct := sseResp.Header.Get("Content-Type")
+ if !strings.HasPrefix(ct, "text/event-stream") {
+ t.Fatalf("SSE Content-Type is %q, expected text/event-stream", ct)
+ }
+
+ // Read SSE body in a goroutine; capture in main goroutine.
+ bodyCh := make(chan string, 1)
+ go func() {
+ buf := make([]byte, 4096)
+ n, _ := sseResp.Body.Read(buf)
+ bodyCh <- string(buf[:n])
+ }()
+
+ // Small sleep to let the SSE subscription register.
+ time.Sleep(10 * time.Millisecond)
+
+ // Capture a request on the same server.
+ captureResp, err := captureClient.Post(srv.URL+"/ep", "application/json", strings.NewReader(`{"msg":"hello"}`))
+ if err != nil {
+ t.Fatalf("capture POST failed: %v", err)
+ }
+ captureResp.Body.Close()
+ if captureResp.StatusCode != http.StatusOK {
+ t.Fatalf("capture returned %d", captureResp.StatusCode)
+ }
+
+ // Wait for the SSE event with a timeout.
+ select {
+ case body := <-bodyCh:
+ if !strings.Contains(body, "data:") {
+ t.Fatalf("SSE stream missing 'data:' prefix: %q", body)
+ }
+ if !strings.Contains(body, "\"method\":\"POST\"") {
+ t.Fatalf("SSE stream missing POST method: %q", body)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("timeout waiting for SSE event")
+ }
+
+ // Cancel context to close the SSE connection.
+ cancel()
+}
+
+func TestInspectReturnsHTML(t *testing.T) {
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "ep")
+ repo.AppendRequest(nil, "ep", &store.Request{
+ Method: "POST",
+ Path: "/ep/webhook",
+ Headers: `{"Content-Type":"application/json"}`,
+ Query: "foo=bar",
+ Body: []byte(`{"msg":"hello"}`),
+ })
+
+ r := testRouter(repo)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest("GET", "/e/ep", nil))
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+ ct := w.Header().Get("Content-Type")
+ if !strings.Contains(ct, "text/html") {
+ t.Fatalf("expected text/html Content-Type, got %q", ct)
+ }
+
+ body := w.Body.String()
+
+ // Should contain the endpoint ID.
+ if !strings.Contains(body, "ep") {
+ t.Error("missing endpoint ID in HTML")
+ }
+
+ // Should contain the request method badge.
+ if !strings.Contains(body, "POST") {
+ t.Error("missing POST method in HTML")
+ }
+
+ // Should contain the request path.
+ if !strings.Contains(body, "/ep/webhook") {
+ t.Error("missing request path in HTML")
+ }
+
+ // Should contain the header JSON.
+ if !strings.Contains(body, "Content-Type") {
+ t.Error("missing Content-Type header in HTML")
+ }
+
+ // Should contain the query string.
+ if !strings.Contains(body, "foo=bar") {
+ t.Error("missing query string in HTML")
+ }
+
+ // Should contain the body content (html/template escapes quotes).
+ if !strings.Contains(body, "msg") || !strings.Contains(body, "hello") {
+ t.Error("missing request body content in HTML")
+ }
+
+ // Should contain the replay button.
+ if !strings.Contains(body, "Replay") {
+ t.Error("missing Replay button in HTML")
+ }
+
+ // Should contain SSE EventSource script.
+ if !strings.Contains(body, "EventSource") {
+ t.Error("missing EventSource in HTML")
+ }
+}
+
+func TestInspectEmptyState(t *testing.T) {
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "new-ep")
+
+ r := testRouter(repo)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest("GET", "/e/new-ep", nil))
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+
+ body := w.Body.String()
+
+ // Should show the "waiting for requests" empty state.
+ if !strings.Contains(body, "Waiting for requests") {
+ t.Error("missing empty state message")
+ }
+
+ // Should show the usage hint with the endpoint ID.
+ if !strings.Contains(body, "/new-ep") {
+ t.Error("missing usage hint with endpoint ID")
+ }
+
+ // No request cards should be rendered.
+ if strings.Contains(body, `class="request"`) {
+ t.Error("unexpected request card in empty state")
+ }
+}
+
+func TestInspectAutoCreatesEndpoint(t *testing.T) {
+ repo := newFakeRepo()
+
+ // The endpoint doesn't exist yet.
+ r := testRouter(repo)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest("GET", "/e/auto-ep", nil))
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+
+ // The endpoint should now exist in the repo.
+ ep, err := repo.GetEndpoint(nil, "auto-ep")
+ if err != nil {
+ t.Fatalf("endpoint was not auto-created: %v", err)
+ }
+ if ep.ID != "auto-ep" {
+ t.Errorf("expected endpoint ID 'auto-ep', got %q", ep.ID)
+ }
+
+ body := w.Body.String()
+ if !strings.Contains(body, "auto-ep") {
+ t.Error("missing endpoint ID in auto-created page")
+ }
+ if !strings.Contains(body, "Waiting for requests") {
+ t.Error("missing empty state for auto-created endpoint")
+ }
+}
+
+func TestCaptureReturnsJSON200(t *testing.T) {
+ r := testRouter(newFakeRepo())
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest("GET", "/t", nil))
+
+ if w.Code != 200 {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+ if !strings.Contains(w.Header().Get("Content-Type"), "application/json") {
+ t.Error("not json")
+ }
+ data, _ := io.ReadAll(w.Result().Body)
+ w.Result().Body.Close()
+ var v map[string]interface{}
+ if json.Unmarshal(data, &v) != nil {
+ t.Fatal("invalid json")
+ }
+}
+
+func TestMockSetsAndConfiguresResponse(t *testing.T) {
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "mock-ep")
+
+ r := testRouter(repo)
+
+ // Set a mock via PUT.
+ mockBody := `{"status": 201, "headers": {"X-Mocked": "yes"}, "body": "bW9ja2Vk"}`
+ req := httptest.NewRequest(http.MethodPut, "/e/mock-ep/mock", strings.NewReader(mockBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Verify the mock was stored.
+ mock, err := repo.GetMock(nil, "mock-ep")
+ if err != nil {
+ t.Fatalf("mock not stored: %v", err)
+ }
+ if mock.Status != 201 {
+ t.Errorf("expected status 201, got %d", mock.Status)
+ }
+ if mock.Headers["X-Mocked"] != "yes" {
+ t.Errorf("expected X-Mocked header, got %v", mock.Headers)
+ }
+}
+
+func TestMockReturnsConfiguredResponseOnCapture(t *testing.T) {
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "mock-cap")
+ // Pre-set a mock.
+ repo.SetMock(nil, &store.MockConfig{
+ EndpointID: "mock-cap",
+ Status: 418,
+ Headers: map[string]string{"X-Teapot": "true"},
+ Body: []byte(`{"brewing": true}`),
+ })
+
+ r := testRouter(repo)
+
+ // Capture a request — should return the mock response, not the default 200.
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/mock-cap", strings.NewReader(`{"order":"latte"}`)))
+
+ if w.Code != 418 {
+ t.Fatalf("expected mock status 418, got %d", w.Code)
+ }
+ if w.Header().Get("X-Teapot") != "true" {
+ t.Errorf("expected X-Teapot: true, got %q", w.Header().Get("X-Teapot"))
+ }
+ body := strings.TrimSpace(w.Body.String())
+ if body != `{"brewing": true}` {
+ t.Errorf("expected mock body, got %q", body)
+ }
+
+ // Request should still be captured even when mock responds.
+ if len(repo.requests) != 1 {
+ t.Fatalf("expected 1 captured request, got %d", len(repo.requests))
+ }
+ if repo.requests[0].Method != "POST" {
+ t.Errorf("captured method: %s", repo.requests[0].Method)
+ }
+}
+
+func TestMockAutoCreatesEndpointOnSet(t *testing.T) {
+ repo := newFakeRepo()
+ r := testRouter(repo)
+
+ // Set a mock on an endpoint that doesn't exist yet.
+ mockBody := `{"status": 200, "headers": {}, "body": ""}`
+ req := httptest.NewRequest(http.MethodPut, "/e/auto-mock/mock", strings.NewReader(mockBody))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String())
+ }
+
+ // Endpoint should be auto-created.
+ ep, err := repo.GetEndpoint(nil, "auto-mock")
+ if err != nil {
+ t.Fatalf("endpoint was not auto-created: %v", err)
+ }
+ if ep.ID != "auto-mock" {
+ t.Errorf("expected endpoint id 'auto-mock', got %q", ep.ID)
+ }
+}
diff --git a/internal/handler/inspect.go b/internal/handler/inspect.go
new file mode 100644
index 00000000..03cbafb1
--- /dev/null
+++ b/internal/handler/inspect.go
@@ -0,0 +1,344 @@
+package handler
+
+import (
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "net/http"
+ "time"
+
+ "github.com/elfoundation/hatch/internal/store"
+)
+
+// inspectPageData is passed to the inspect template.
+type inspectPageData struct {
+ EndpointID string
+ Requests []*store.Request
+}
+
+// inspectTemplate is the server-rendered HTML for the inspect page.
+var inspectTemplate = template.Must(template.New("inspect").Funcs(template.FuncMap{
+ "prettyJSON": prettyJSON,
+ "formatTime": formatTime,
+}).Parse(`
+
+
+
+
+Hatch — {{.EndpointID}}
+
+
+
+Hatch
+Endpoint: {{.EndpointID}}
+
+{{if .Requests}}
+ {{range .Requests}}
+
+
+
+ {{if .Headers}}
+
+
Headers
+
{{prettyJSON .Headers}}
+
+ {{end}}
+ {{if .Query}}
+
+ {{end}}
+ {{if .Body}}
+
+
Body
+
{{printf "%s" .Body}}
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
+ {{end}}
+{{else}}
+
+
Waiting for requests...
+
Send a request to /{{.EndpointID}} to see it appear here.
+
+{{end}}
+
+
+
+
+`))
+
+// HandleInspect serves the inspect page at GET /e/{endpointID}.
+func HandleInspect(repo store.Repository) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ endpointID := r.PathValue("endpointID")
+ if endpointID == "" {
+ http.Error(w, "missing endpoint ID", http.StatusBadRequest)
+ return
+ }
+ if _, err := repo.GetEndpoint(r.Context(), endpointID); err != nil {
+ repo.CreateEndpoint(r.Context(), endpointID)
+ }
+ requests, err := repo.ListRequests(r.Context(), endpointID, 100)
+ if err != nil {
+ http.Error(w, "failed to list requests", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ inspectTemplate.Execute(w, inspectPageData{
+ EndpointID: endpointID,
+ Requests: requests,
+ })
+ }
+}
+
+// prettyJSON formats a JSON string with indentation.
+func prettyJSON(raw string) string {
+ var v interface{}
+ if err := json.Unmarshal([]byte(raw), &v); err != nil {
+ return raw
+ }
+ b, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ return raw
+ }
+ return string(b)
+}
+
+// formatTime displays an ISO 8601 timestamp as a short relative or absolute string.
+func formatTime(raw string) string {
+ t, err := time.Parse("2006-01-02T15:04:05.000Z07:00", raw)
+ if err != nil {
+ // Try a few common variants.
+ t, err = time.Parse("2006-01-02T15:04:05Z07:00", raw)
+ }
+ if err != nil {
+ return raw
+ }
+ d := time.Since(t)
+ switch {
+ case d < 5*time.Second:
+ return "just now"
+ case d < time.Minute:
+ return fmt.Sprintf("%ds ago", int(d.Seconds()))
+ case d < time.Hour:
+ return fmt.Sprintf("%dm ago", int(d.Minutes()))
+ case d < 24*time.Hour:
+ return fmt.Sprintf("%dh ago", int(d.Hours()))
+ case d < 7*24*time.Hour:
+ return fmt.Sprintf("%dd ago", int(d.Hours()/24))
+ default:
+ return t.Format("Jan 2, 15:04")
+ }
+}
diff --git a/internal/handler/mock.go b/internal/handler/mock.go
new file mode 100644
index 00000000..2b9c4d8e
--- /dev/null
+++ b/internal/handler/mock.go
@@ -0,0 +1,35 @@
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/elfoundation/hatch/internal/store"
+)
+
+// HandleMock handles PUT /e/{endpointID}/mock.
+func HandleMock(repo store.Repository) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ endpointID := r.PathValue("endpointID")
+ if endpointID == "" {
+ writeError(w, http.StatusBadRequest, "missing endpoint ID")
+ return
+ }
+ if _, err := repo.GetEndpoint(r.Context(), endpointID); err != nil {
+ repo.CreateEndpoint(r.Context(), endpointID)
+ }
+ var mock store.MockConfig
+ if err := json.NewDecoder(r.Body).Decode(&mock); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
+ return
+ }
+ mock.EndpointID = endpointID
+ if err := repo.SetMock(r.Context(), &mock); err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to set mock: "+err.Error())
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+ }
+}
diff --git a/internal/handler/replay.go b/internal/handler/replay.go
new file mode 100644
index 00000000..36884311
--- /dev/null
+++ b/internal/handler/replay.go
@@ -0,0 +1,196 @@
+package handler
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+
+ "github.com/elfoundation/hatch/internal/store"
+)
+
+const maxReplayBodyBytes = 64 * 1024
+
+type replayRequest struct {
+ TargetURL string `json:"target_url"`
+}
+
+type replayResponse struct {
+ Status int `json:"status"`
+ Headers map[string]string `json:"headers"`
+ Body string `json:"body"`
+ Error string `json:"error,omitempty"`
+}
+
+func HandleReplay(repo store.Repository) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ requestID := r.PathValue("requestID")
+ if requestID == "" {
+ writeError(w, http.StatusBadRequest, "missing request_id")
+ return
+ }
+
+ var replay replayRequest
+ if err := json.NewDecoder(r.Body).Decode(&replay); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
+ return
+ }
+ if replay.TargetURL == "" {
+ writeError(w, http.StatusBadRequest, "target_url is required")
+ return
+ }
+
+ target, err := url.Parse(replay.TargetURL)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid target_url: "+err.Error())
+ return
+ }
+ if target.Scheme != "http" && target.Scheme != "https" {
+ writeError(w, http.StatusBadRequest, "target_url scheme must be http or https")
+ return
+ }
+
+ if !allowPrivateReplay() && isPrivateAddr(target.Host) {
+ writeError(w, http.StatusForbidden, "replay to private/loopback addresses is denied. Set HATCH_ALLOW_PRIVATE_REPLAY=true to allow")
+ return
+ }
+
+ capReq, err := repo.GetRequest(r.Context(), requestID)
+ if err != nil {
+ writeError(w, http.StatusNotFound, "request not found: "+err.Error())
+ return
+ }
+
+ replayResult, err := doReplay(capReq, target)
+ if err != nil {
+ writeError(w, http.StatusBadGateway, "replay failed: "+err.Error())
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(replayResult)
+ }
+}
+
+func doReplay(capReq *store.Request, targetURL *url.URL) (*replayResponse, error) {
+ outURL := *targetURL
+ outURL.Path = joinPath(targetURL.Path, capReq.Path)
+ if capReq.Query != "" {
+ outURL.RawQuery = capReq.Query
+ }
+
+ var bodyReader io.Reader
+ if capReq.Body != nil && len(capReq.Body) > 0 {
+ bodyReader = strings.NewReader(string(capReq.Body))
+ }
+
+ req, err := http.NewRequest(capReq.Method, outURL.String(), bodyReader)
+ if err != nil {
+ return nil, fmt.Errorf("building request: %w", err)
+ }
+
+ var headers map[string]string
+ if capReq.Headers != "" {
+ if err := json.Unmarshal([]byte(capReq.Headers), &headers); err != nil {
+ headers = nil
+ }
+ }
+ for k, v := range headers {
+ if isHopByHop(k) {
+ continue
+ }
+ req.Header.Set(k, v)
+ }
+
+ client := &http.Client{
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if !allowPrivateReplay() && isPrivateAddr(req.URL.Host) {
+ return fmt.Errorf("redirect to private address %s denied", req.URL.Host)
+ }
+ if len(via) >= 10 {
+ return fmt.Errorf("too many redirects")
+ }
+ return nil
+ },
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("sending request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxReplayBodyBytes))
+ if err != nil {
+ return nil, fmt.Errorf("reading response: %w", err)
+ }
+
+ respHeaders := make(map[string]string, len(resp.Header))
+ for k, vs := range resp.Header {
+ respHeaders[k] = strings.Join(vs, ", ")
+ }
+
+ return &replayResponse{
+ Status: resp.StatusCode,
+ Headers: respHeaders,
+ Body: string(body),
+ }, nil
+}
+
+// isPrivateAddr reports whether addr falls within a private, loopback, or reserved range.
+func isPrivateAddr(hostport string) bool {
+ host, _, err := net.SplitHostPort(hostport)
+ if err != nil {
+ host = hostport
+ }
+ if host == "" {
+ return false
+ }
+
+ // String-based checks first (catch names and the unspecified address 0.0.0.0).
+ if host == "localhost" || host == "0.0.0.0" || host == "::1" || host == "[::1]" {
+ return true
+ }
+
+ ip := net.ParseIP(host)
+ if ip != nil {
+ return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
+ }
+ return false
+}
+
+func allowPrivateReplay() bool {
+ return strings.EqualFold(os.Getenv("HATCH_ALLOW_PRIVATE_REPLAY"), "true")
+}
+
+var hopByHopHeaders = map[string]bool{
+ "connection": true,
+ "keep-alive": true,
+ "proxy-authenticate": true,
+ "proxy-authorization": true,
+ "te": true,
+ "trailer": true,
+ "transfer-encoding": true,
+ "upgrade": true,
+}
+
+func isHopByHop(header string) bool {
+ return hopByHopHeaders[strings.ToLower(header)]
+}
+
+func joinPath(base, extra string) string {
+ base = strings.TrimRight(base, "/")
+ extra = strings.TrimLeft(extra, "/")
+ if base == "" {
+ return "/" + extra
+ }
+ if extra == "" {
+ return base
+ }
+ return base + "/" + extra
+}
diff --git a/internal/handler/replay_test.go b/internal/handler/replay_test.go
new file mode 100644
index 00000000..93e13112
--- /dev/null
+++ b/internal/handler/replay_test.go
@@ -0,0 +1,236 @@
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/elfoundation/hatch/internal/store"
+)
+
+func TestReplayMissingTargetURL(t *testing.T) {
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "ep")
+ repo.AppendRequest(nil, "ep", &store.Request{Method: "POST", Path: "/webhook", Headers: "{}"})
+ reqID := repo.requests[0].ID
+
+ r := testRouter(repo)
+ body := `{}`
+ req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", w.Code)
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ if !strings.Contains(resp["error"], "target_url") {
+ t.Errorf("expected target_url error, got %v", resp)
+ }
+}
+
+func TestReplayInvalidScheme(t *testing.T) {
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "ep")
+ repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
+ reqID := repo.requests[0].ID
+
+ r := testRouter(repo)
+ body := `{"target_url": "ftp://bad.scheme/"}`
+ req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", w.Code)
+ }
+}
+
+func TestReplaySSRFBlocksLocalhost(t *testing.T) {
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "ep")
+ repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
+ reqID := repo.requests[0].ID
+
+ r := testRouter(repo)
+ body := `{"target_url": "http://localhost:8080/"}`
+ req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("expected 403 for localhost, got %d", w.Code)
+ }
+}
+
+func TestReplaySSRFBlocksPrivate(t *testing.T) {
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "ep")
+ repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
+ reqID := repo.requests[0].ID
+
+ r := testRouter(repo)
+ for _, addr := range []string{
+ "http://10.0.0.1:80/",
+ "http://172.16.0.1:80/",
+ "http://192.168.1.1:80/",
+ "http://127.0.0.1:80/",
+ "http://[::1]:80/",
+ "http://0.0.0.0:80/",
+ } {
+ body := `{"target_url": "` + addr + `"}`
+ req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ if w.Code != http.StatusForbidden {
+ t.Errorf("expected 403 for %s, got %d", addr, w.Code)
+ }
+ }
+}
+
+func TestReplayNotFound(t *testing.T) {
+ repo := newFakeRepo()
+ r := testRouter(repo)
+ body := `{"target_url": "https://example.com/"}`
+ req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/nonexistent/replay", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", w.Code)
+ }
+}
+
+func TestReplaySuccess(t *testing.T) {
+ // Start a local sink server that echoes back what it receives.
+ sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Sink", "yes")
+ w.WriteHeader(http.StatusCreated)
+ w.Write([]byte(`{"received": true}`))
+ }))
+ defer sink.Close()
+
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "ep")
+ repo.AppendRequest(nil, "ep", &store.Request{
+ Method: "POST",
+ Path: "/webhook",
+ Headers: `{"Content-Type":"application/json","X-Custom":"val"}`,
+ Query: "foo=bar",
+ Body: []byte(`{"msg":"hello"}`),
+ })
+ reqID := repo.requests[0].ID
+
+ // Set env to allow private replay since httptest server is on loopback.
+ t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
+
+ r := testRouter(repo)
+ body := `{"target_url": "` + sink.URL + `"}`
+ req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp replayResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if resp.Status != 201 {
+ t.Errorf("expected status 201, got %d", resp.Status)
+ }
+ if resp.Headers["X-Sink"] != "yes" {
+ t.Errorf("expected X-Sink header, got %v", resp.Headers)
+ }
+ if resp.Body != `{"received": true}` {
+ t.Errorf("expected body, got %q", resp.Body)
+ }
+}
+
+func TestReplayE2ECaptureThenReplay(t *testing.T) {
+ // E2E: capture a request, then replay it to a sink, verify the sink received it.
+
+ // Step 1: Start a sink.
+ sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "method": r.Method,
+ "path": r.URL.Path,
+ "query": r.URL.RawQuery,
+ "body": "received",
+ })
+ }))
+ defer sink.Close()
+
+ // Step 2: Capture a request.
+ repo := newFakeRepo()
+ rt := testRouter(repo)
+
+ captureReq := httptest.NewRequest("POST", "/e2e-test", strings.NewReader(`{"order":"1"}`))
+ captureReq.Header.Set("X-Test", "hello")
+ captureW := httptest.NewRecorder()
+ rt.ServeHTTP(captureW, captureReq)
+
+ if len(repo.requests) != 1 {
+ t.Fatalf("expected 1 captured request, got %d", len(repo.requests))
+ }
+ captured := repo.requests[0]
+ if captured.Method != "POST" {
+ t.Errorf("captured method: %s", captured.Method)
+ }
+
+ // Step 3: Replay it to the sink.
+ t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
+
+ replayBody := `{"target_url": "` + sink.URL + `"}`
+ replayReq := httptest.NewRequest("POST", "/e/e2e-test/requests/"+captured.ID+"/replay", strings.NewReader(replayBody))
+ replayReq.Header.Set("Content-Type", "application/json")
+ replayW := httptest.NewRecorder()
+ rt.ServeHTTP(replayW, replayReq)
+
+ if replayW.Code != http.StatusOK {
+ t.Fatalf("replay failed with %d: %s", replayW.Code, replayW.Body.String())
+ }
+
+ var resp replayResponse
+ json.NewDecoder(replayW.Body).Decode(&resp)
+ if resp.Status != 200 {
+ t.Errorf("replay response status: %d", resp.Status)
+ }
+}
+
+func TestIsPrivateAddr(t *testing.T) {
+ tests := []struct {
+ addr string
+ want bool
+ }{
+ {"localhost:8080", true},
+ {"127.0.0.1:9090", true},
+ {"[::1]:80", true},
+ {"10.0.0.5:443", true},
+ {"172.16.0.1:80", true},
+ {"192.168.1.1:3000", true},
+ {"0.0.0.0:8080", true},
+ {"example.com:443", false},
+ {"93.184.216.34:80", false},
+ {"8.8.8.8:53", false},
+ }
+ for _, tc := range tests {
+ got := isPrivateAddr(tc.addr)
+ if got != tc.want {
+ t.Errorf("isPrivateAddr(%q) = %v, want %v", tc.addr, tc.want, got)
+ }
+ }
+}
diff --git a/internal/handler/sse.go b/internal/handler/sse.go
new file mode 100644
index 00000000..3dc3081d
--- /dev/null
+++ b/internal/handler/sse.go
@@ -0,0 +1,125 @@
+package handler
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sync"
+
+ "github.com/elfoundation/hatch/internal/store"
+)
+
+// sseHub manages SSE subscribers per endpoint.
+type sseHub struct {
+ mu sync.RWMutex
+ subs map[string]map[chan []byte]struct{}
+}
+
+var hub = &sseHub{
+ subs: make(map[string]map[chan []byte]struct{}),
+}
+
+func (h *sseHub) subscribe(endpointID string) chan []byte {
+ ch := make(chan []byte, 64)
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.subs[endpointID] == nil {
+ h.subs[endpointID] = make(map[chan []byte]struct{})
+ }
+ h.subs[endpointID][ch] = struct{}{}
+ return ch
+}
+
+func (h *sseHub) unsubscribe(endpointID string, ch chan []byte) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ delete(h.subs[endpointID], ch)
+ if len(h.subs[endpointID]) == 0 {
+ delete(h.subs, endpointID)
+ }
+}
+
+// sseRequest is the JSON payload sent over SSE.
+// It uses string for Body to avoid base64 encoding.
+type sseRequest struct {
+ ID string `json:"id"`
+ EndpointID string `json:"endpoint_id"`
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Headers string `json:"headers"`
+ Query string `json:"query"`
+ Body string `json:"body"`
+ CreatedAt string `json:"created_at"`
+}
+
+func (h *sseHub) broadcast(endpointID string, req *store.Request) {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ subs := h.subs[endpointID]
+ if subs == nil {
+ return
+ }
+ sseReq := sseRequest{
+ ID: req.ID,
+ EndpointID: req.EndpointID,
+ Method: req.Method,
+ Path: req.Path,
+ Headers: req.Headers,
+ Query: req.Query,
+ Body: string(req.Body),
+ CreatedAt: req.CreatedAt,
+ }
+ data, err := json.Marshal(sseReq)
+ if err != nil {
+ return
+ }
+ for ch := range subs {
+ select {
+ case ch <- data:
+ default:
+ }
+ }
+}
+
+// broadcastRequest publishes req to SSE subscribers for its endpoint.
+func broadcastRequest(endpointID string, req *store.Request) {
+ hub.broadcast(endpointID, req)
+}
+
+// HandleSSE serves an SSE stream for GET /e/{endpointID}/events.
+func HandleSSE(repo store.Repository) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ endpointID := r.PathValue("endpointID")
+ if endpointID == "" {
+ http.Error(w, "missing endpoint ID", http.StatusBadRequest)
+ return
+ }
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming not supported", http.StatusInternalServerError)
+ return
+ }
+ 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)
+ flusher.Flush()
+
+ ch := hub.subscribe(endpointID)
+ defer hub.unsubscribe(endpointID, ch)
+
+ ctx := r.Context()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case data, ok := <-ch:
+ if !ok {
+ return
+ }
+ fmt.Fprintf(w, "data: %s\n\n", data)
+ flusher.Flush()
+ }
+ }
+ }
+}
diff --git a/internal/store/db.go b/internal/store/db.go
new file mode 100644
index 00000000..9d08e6ba
--- /dev/null
+++ b/internal/store/db.go
@@ -0,0 +1,35 @@
+package store
+
+import (
+ "database/sql"
+ _ "embed"
+ "fmt"
+ "os"
+ "path/filepath"
+ _ "modernc.org/sqlite"
+)
+
+//go:embed schema.sql
+var schemaSQL string
+
+func Open(dbPath string) (Repository, error) {
+ if dbPath == "" { dbPath = filepath.Join("data", "hatch.db") }
+ dir := filepath.Dir(dbPath)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return nil, fmt.Errorf("store: create db dir %s: %w", dir, err)
+ }
+ conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
+ if err != nil { return nil, fmt.Errorf("store: open %s: %w", dbPath, err) }
+ conn.SetMaxOpenConns(1)
+ if err := migrate(conn); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("store: migrate: %w", err)
+ }
+ return NewSQLiteRepo(conn)
+}
+
+func migrate(db *sql.DB) error {
+ _, err := db.Exec(schemaSQL)
+ if err != nil { return fmt.Errorf("store/migrate: %w", err) }
+ return nil
+}
diff --git a/internal/store/doc.go b/internal/store/doc.go
new file mode 100644
index 00000000..33ccf6b8
--- /dev/null
+++ b/internal/store/doc.go
@@ -0,0 +1,2 @@
+// Package store provides the SQLite-backed persistence layer for Hatch.
+package store
diff --git a/internal/store/models.go b/internal/store/models.go
new file mode 100644
index 00000000..6f8ef2e6
--- /dev/null
+++ b/internal/store/models.go
@@ -0,0 +1,42 @@
+package store
+
+import "context"
+
+type Endpoint struct {
+ ID string `json:"id"`
+ URL string `json:"url"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+type Request struct {
+ ID string `json:"id"`
+ EndpointID string `json:"endpoint_id"`
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Headers string `json:"headers"`
+ Query string `json:"query"`
+ Body []byte `json:"body"`
+ CreatedAt string `json:"created_at"`
+}
+
+// MockConfig holds the mock response configuration for an endpoint.
+type MockConfig struct {
+ EndpointID string `json:"endpoint_id"`
+ Status int `json:"status"`
+ Headers map[string]string `json:"headers"`
+ Body []byte `json:"body"`
+}
+
+type Repository interface {
+ CreateEndpoint(ctx context.Context, url string) (*Endpoint, error)
+ GetEndpoint(ctx context.Context, id string) (*Endpoint, error)
+ AppendRequest(ctx context.Context, endpointID string, req *Request) error
+ GetRequest(ctx context.Context, id string) (*Request, error)
+ ListRequests(ctx context.Context, endpointID string, limit int) ([]*Request, error)
+ GetMock(ctx context.Context, endpointID string) (*MockConfig, error)
+ SetMock(ctx context.Context, mock *MockConfig) error
+ Close() error
+}
+
+var _ Repository = (*sqliteRepo)(nil)
diff --git a/internal/store/schema.sql b/internal/store/schema.sql
new file mode 100644
index 00000000..026b257e
--- /dev/null
+++ b/internal/store/schema.sql
@@ -0,0 +1,21 @@
+CREATE TABLE IF NOT EXISTS endpoints (
+ id TEXT NOT NULL PRIMARY KEY,
+ url TEXT NOT NULL UNIQUE,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ mock_status INTEGER,
+ mock_headers TEXT,
+ mock_body BLOB
+);
+CREATE TABLE IF NOT EXISTS requests (
+ id TEXT NOT NULL PRIMARY KEY,
+ endpoint_id TEXT NOT NULL REFERENCES endpoints(id),
+ method TEXT NOT NULL,
+ path TEXT NOT NULL,
+ headers TEXT NOT NULL DEFAULT '{}',
+ query TEXT NOT NULL DEFAULT '',
+ body BLOB,
+ created_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_requests_endpoint_id ON requests(endpoint_id);
+CREATE INDEX IF NOT EXISTS idx_requests_created_at ON requests(created_at);
diff --git a/internal/store/sqlite_repo.go b/internal/store/sqlite_repo.go
new file mode 100644
index 00000000..f7371354
--- /dev/null
+++ b/internal/store/sqlite_repo.go
@@ -0,0 +1,121 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type sqliteRepo struct{ db *sql.DB }
+
+func NewSQLiteRepo(db *sql.DB) (Repository, error) {
+ if db == nil {
+ return nil, fmt.Errorf("store: nil db")
+ }
+ return &sqliteRepo{db: db}, nil
+}
+
+func (r *sqliteRepo) CreateEndpoint(ctx context.Context, url string) (*Endpoint, error) {
+ now := utcNow()
+ e := &Endpoint{ID: url, URL: url, CreatedAt: now, UpdatedAt: now}
+ _, err := r.db.ExecContext(ctx, `INSERT INTO endpoints (id, url, created_at, updated_at) VALUES (?, ?, ?, ?)`, e.ID, e.URL, e.CreatedAt, e.UpdatedAt)
+ if err != nil {
+ return nil, fmt.Errorf("store: create endpoint: %w", err)
+ }
+ return e, nil
+}
+
+func (r *sqliteRepo) GetEndpoint(ctx context.Context, id string) (*Endpoint, error) {
+ row := r.db.QueryRowContext(ctx, `SELECT id, url, created_at, updated_at FROM endpoints WHERE id = ?`, id)
+ e := &Endpoint{}
+ if err := row.Scan(&e.ID, &e.URL, &e.CreatedAt, &e.UpdatedAt); err != nil {
+ return nil, fmt.Errorf("store: get endpoint: %w", err)
+ }
+ return e, nil
+}
+
+func (r *sqliteRepo) AppendRequest(ctx context.Context, endpointID string, req *Request) error {
+ req.ID = uuid.NewString()
+ req.EndpointID = endpointID
+ req.CreatedAt = utcNow()
+ _, err := r.db.ExecContext(ctx, `INSERT INTO requests (id, endpoint_id, method, path, headers, query, body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ req.ID, req.EndpointID, req.Method, req.Path, req.Headers, req.Query, req.Body, req.CreatedAt)
+ if err != nil {
+ return fmt.Errorf("store: append request: %w", err)
+ }
+ return nil
+}
+
+func (r *sqliteRepo) GetRequest(ctx context.Context, id string) (*Request, error) {
+ row := r.db.QueryRowContext(ctx, `SELECT id, endpoint_id, method, path, headers, query, body, created_at FROM requests WHERE id = ?`, id)
+ req := &Request{}
+ if err := row.Scan(&req.ID, &req.EndpointID, &req.Method, &req.Path, &req.Headers, &req.Query, &req.Body, &req.CreatedAt); err != nil {
+ return nil, fmt.Errorf("store: get request %s: %w", id, err)
+ }
+ return req, nil
+}
+
+func (r *sqliteRepo) ListRequests(ctx context.Context, endpointID string, limit int) ([]*Request, error) {
+ if limit <= 0 {
+ limit = 50
+ }
+ rows, err := r.db.QueryContext(ctx, `SELECT id, endpoint_id, method, path, headers, query, body, created_at FROM requests WHERE endpoint_id = ? ORDER BY created_at DESC LIMIT ?`, endpointID, limit)
+ if err != nil {
+ return nil, fmt.Errorf("store: list requests: %w", err)
+ }
+ defer rows.Close()
+ var out []*Request
+ for rows.Next() {
+ var req Request
+ if err := rows.Scan(&req.ID, &req.EndpointID, &req.Method, &req.Path, &req.Headers, &req.Query, &req.Body, &req.CreatedAt); err != nil {
+ return nil, fmt.Errorf("store: scan request: %w", err)
+ }
+ out = append(out, &req)
+ }
+ return out, rows.Err()
+}
+
+func (r *sqliteRepo) GetMock(ctx context.Context, endpointID string) (*MockConfig, error) {
+ row := r.db.QueryRowContext(ctx, `SELECT mock_status, mock_headers, mock_body FROM endpoints WHERE id = ?`, endpointID)
+ var status sql.NullInt64
+ var headersJSON sql.NullString
+ var body []byte
+ if err := row.Scan(&status, &headersJSON, &body); err != nil {
+ return nil, fmt.Errorf("store: get mock: %w", err)
+ }
+ if !status.Valid {
+ return nil, fmt.Errorf("store: no mock configured for %s", endpointID)
+ }
+ m := &MockConfig{
+ EndpointID: endpointID,
+ Status: int(status.Int64),
+ Body: body,
+ }
+ if headersJSON.Valid && headersJSON.String != "" {
+ json.Unmarshal([]byte(headersJSON.String), &m.Headers)
+ }
+ return m, nil
+}
+
+func (r *sqliteRepo) SetMock(ctx context.Context, mock *MockConfig) error {
+ headersJSON, err := json.Marshal(mock.Headers)
+ if err != nil {
+ return fmt.Errorf("store: marshal mock headers: %w", err)
+ }
+ _, err = r.db.ExecContext(ctx,
+ `UPDATE endpoints SET mock_status = ?, mock_headers = ?, mock_body = ? WHERE id = ?`,
+ mock.Status, string(headersJSON), mock.Body, mock.EndpointID,
+ )
+ if err != nil {
+ return fmt.Errorf("store: set mock: %w", err)
+ }
+ return nil
+}
+
+func (r *sqliteRepo) Close() error { return r.db.Close() }
+
+func utcNow() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") }
diff --git a/internal/store/sqlite_repo_test.go b/internal/store/sqlite_repo_test.go
new file mode 100644
index 00000000..7e0a6a33
--- /dev/null
+++ b/internal/store/sqlite_repo_test.go
@@ -0,0 +1,234 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+ "time"
+)
+
+func openTestRepo(t *testing.T) Repository {
+ t.Helper()
+ db, err := sql.Open("sqlite", ":memory:?_journal_mode=WAL&_foreign_keys=on")
+ if err != nil {
+ t.Fatalf("open in-memory sqlite: %v", err)
+ }
+ db.SetMaxOpenConns(1)
+ if err := migrate(db); err != nil {
+ db.Close()
+ t.Fatalf("migrate: %v", err)
+ }
+ repo, err := NewSQLiteRepo(db)
+ if err != nil {
+ db.Close()
+ t.Fatalf("new sqlite repo: %v", err)
+ }
+ t.Cleanup(func() { repo.Close() })
+ return repo
+}
+
+func TestCreateAndGetEndpoint(t *testing.T) {
+ repo := openTestRepo(t)
+ e, err := repo.CreateEndpoint(context.Background(), "test-one")
+ if err != nil {
+ t.Fatalf("CreateEndpoint: %v", err)
+ }
+ if e.ID == "" {
+ t.Error("expected non-empty ID")
+ }
+ if e.URL != "test-one" {
+ t.Errorf("url: got %q", e.URL)
+ }
+ if e.CreatedAt == "" || e.UpdatedAt == "" {
+ t.Error("expected timestamps")
+ }
+ got, err := repo.GetEndpoint(context.Background(), e.ID)
+ if err != nil {
+ t.Fatalf("GetEndpoint: %v", err)
+ }
+ if got.ID != e.ID || got.URL != e.URL {
+ t.Errorf("got %+v", got)
+ }
+}
+
+func TestGetEndpointNotFound(t *testing.T) {
+ repo := openTestRepo(t)
+ _, err := repo.GetEndpoint(context.Background(), "nonexistent")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestCreateEndpointDuplicateURL(t *testing.T) {
+ repo := openTestRepo(t)
+ ctx := context.Background()
+ if _, err := repo.CreateEndpoint(ctx, "dup"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := repo.CreateEndpoint(ctx, "dup"); err == nil {
+ t.Fatal("expected UNIQUE error")
+ }
+}
+
+func TestAppendAndListRequests(t *testing.T) {
+ repo := openTestRepo(t)
+ ctx := context.Background()
+ e, err := repo.CreateEndpoint(ctx, "reqs-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r1 := &Request{Method: "POST", Path: "/webhook", Headers: `{"Content-Type":"application/json"}`, Query: "foo=bar", Body: []byte(`{"hello":"world"}`)}
+ if err := repo.AppendRequest(ctx, e.ID, r1); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(time.Millisecond)
+ r2 := &Request{Method: "GET", Path: "/webhook", Headers: `{}`}
+ if err := repo.AppendRequest(ctx, e.ID, r2); err != nil {
+ t.Fatal(err)
+ }
+ reqs, err := repo.ListRequests(ctx, e.ID, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(reqs) != 2 {
+ t.Fatalf("got %d requests", len(reqs))
+ }
+ if reqs[0].Method != "GET" {
+ t.Errorf("first: got %s", reqs[0].Method)
+ }
+ if reqs[1].Method != "POST" {
+ t.Errorf("second: got %s", reqs[1].Method)
+ }
+}
+
+func TestGetRequest(t *testing.T) {
+ repo := openTestRepo(t)
+ ctx := context.Background()
+ e, _ := repo.CreateEndpoint(ctx, "getreq-test")
+ r := &Request{Method: "POST", Path: "/webhook", Headers: `{"X-Api-Key":"secret"}`, Query: "a=1", Body: []byte(`{"msg":"hi"}`)}
+ if err := repo.AppendRequest(ctx, e.ID, r); err != nil {
+ t.Fatal(err)
+ }
+ got, err := repo.GetRequest(ctx, r.ID)
+ if err != nil {
+ t.Fatalf("GetRequest: %v", err)
+ }
+ if got.ID != r.ID {
+ t.Errorf("id: got %q want %q", got.ID, r.ID)
+ }
+ if got.Method != "POST" {
+ t.Errorf("method: got %q", got.Method)
+ }
+ if string(got.Body) != `{"msg":"hi"}` {
+ t.Errorf("body: got %q", string(got.Body))
+ }
+}
+
+func TestGetRequestNotFound(t *testing.T) {
+ repo := openTestRepo(t)
+ _, err := repo.GetRequest(context.Background(), "nonexistent")
+ if err == nil {
+ t.Fatal("expected error for nonexistent request")
+ }
+}
+
+func TestSetAndGetMock(t *testing.T) {
+ repo := openTestRepo(t)
+ ctx := context.Background()
+ e, err := repo.CreateEndpoint(ctx, "mock-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ mock := &MockConfig{
+ EndpointID: e.ID,
+ Status: 201,
+ Headers: map[string]string{"Content-Type": "application/json"},
+ Body: []byte(`{"mocked": true}`),
+ }
+ if err := repo.SetMock(ctx, mock); err != nil {
+ t.Fatal(err)
+ }
+ got, err := repo.GetMock(ctx, e.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Status != 201 {
+ t.Errorf("status: got %d", got.Status)
+ }
+ if string(got.Body) != `{"mocked": true}` {
+ t.Errorf("body: got %q", string(got.Body))
+ }
+}
+
+func TestGetMockNotFound(t *testing.T) {
+ repo := openTestRepo(t)
+ _, err := repo.GetMock(context.Background(), "no-mock")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestListRequestsLimit(t *testing.T) {
+ repo := openTestRepo(t)
+ ctx := context.Background()
+ e, _ := repo.CreateEndpoint(ctx, "limit-test")
+ for i := 0; i < 5; i++ {
+ repo.AppendRequest(ctx, e.ID, &Request{Method: "GET", Path: "/"})
+ }
+ reqs, err := repo.ListRequests(ctx, e.ID, 3)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(reqs) != 3 {
+ t.Fatalf("got %d", len(reqs))
+ }
+}
+
+func TestListRequestsEmpty(t *testing.T) {
+ repo := openTestRepo(t)
+ ctx := context.Background()
+ e, _ := repo.CreateEndpoint(ctx, "empty-test")
+ reqs, _ := repo.ListRequests(ctx, e.ID, 10)
+ if len(reqs) != 0 {
+ t.Errorf("got %d", len(reqs))
+ }
+}
+
+func TestAppendRequestBodyBinary(t *testing.T) {
+ repo := openTestRepo(t)
+ ctx := context.Background()
+ e, _ := repo.CreateEndpoint(ctx, "binary-test")
+ body := []byte{0x00, 0x01, 0x02, 0xFF}
+ if err := repo.AppendRequest(ctx, e.ID, &Request{Method: "POST", Path: "/binary", Body: body}); err != nil {
+ t.Fatal(err)
+ }
+ reqs, _ := repo.ListRequests(ctx, e.ID, 1)
+ if len(reqs) != 1 {
+ t.Fatal("expected 1 request")
+ }
+ if len(reqs[0].Body) != 4 {
+ t.Errorf("body len: %d", len(reqs[0].Body))
+ }
+}
+
+func TestMigrateIdempotent(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:?_journal_mode=WAL&_foreign_keys=on")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ db.SetMaxOpenConns(1)
+ if err := migrate(db); err != nil {
+ t.Fatal(err)
+ }
+ if err := migrate(db); err != nil {
+ t.Fatal(err)
+ }
+ var name string
+ if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='endpoints'").Scan(&name); err != nil {
+ t.Fatalf("endpoints table: %v", err)
+ }
+ if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='requests'").Scan(&name); err != nil {
+ t.Fatalf("requests table: %v", err)
+ }
+}