Merge engineer/hatch-replay into cto/ELF-15-hatch-stack-migration

Integrate the full Hatch v0.1 product (capture, inspect, SSE, mock, replay,
SQLite store, E2E smoke test) with the migration branch's docs/license
realignment (Apache-2.0 LICENSE, ADR-0002, clean README layout).

This produces a single launch-ready HEAD where the three Show HN demo bullets
are true and the repo carries an Apache-2.0 license.

Conflict resolution:
- cmd/hatch/main.go, cmd/hatch/main_test.go, go.mod: took replay (integrated
  chi router + handler routes + deps). Migration's foundation-only main.go
  and dep-less go.mod are superseded.
- .gitignore: took replay (narrowed /hatch to avoid matching cmd/hatch/).
- README.md: took migration (clean Repository Layout, Apache-2.0 license
  section). Replay's stale "Proprietary — All rights reserved" + apps/packages
  layout dropped.
- go.sum: auto-merged.
- brand/, internal/, docs/adrs/0002: brought in from replay / migration as
  non-conflicting additions.

Verified on merged HEAD:
- go test ./... — all pass
- go test ./cmd/hatch -run TestSmokeE2E — all 7 phases pass
- docker compose up --build — container runs; healthz/capture/inspect/mock
  all 200; mock returns configured status + body + content-type

CTO engineering sign-off for ELF-57: GO on this HEAD.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO 2026-06-23 03:56:30 +02:00
commit 9c0057a1b0
52 changed files with 2911 additions and 42 deletions

11
.gitignore vendored
View File

@ -8,16 +8,9 @@
*.out *.out
go.work go.work
# Build artifacts # Build
/bin /bin
/dist /dist
/hatch
/hatch-*
bin/
*.db
*.db-journal
*.db-wal
*.db-shm
# IDE # IDE
.idea/ .idea/
@ -26,3 +19,5 @@ bin/
*.swo *.swo
*~ *~
.DS_Store .DS_Store
/hatch
hatch.exe

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 999 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
brand/favicon/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
brand/mark/mark-1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
brand/mark/mark-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
brand/mark/mark-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

BIN
brand/mark/mark-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
brand/mark/mark-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
brand/mark/mark-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

BIN
brand/mark/mark-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

BIN
brand/mark/mark-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
brand/mark/mark-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

17
brand/mark/mark-dark.svg Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000" height="1000" role="img" aria-label="Hatch mark">
<g>
<!-- Rounded square body -->
<rect x="100" y="100" width="800" height="800" rx="192" ry="192"
fill="none" stroke="#FAFAF9" stroke-width="88" stroke-linejoin="round"/>
<!-- The slot: a horizontal bar -->
<line x1="276" y1="460" x2="596" y2="460"
stroke="#FAFAF9" stroke-width="61.599999999999994" stroke-linecap="round"/>
<!-- The request: a small filled square above the slot -->
<rect x="436" y="244" width="128" height="128" fill="#FAFAF9"/>
<!-- The stem connecting the request to the slot -->
<line x1="500" y1="372" x2="500" y2="460"
stroke="#FAFAF9" stroke-width="46.199999999999996" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 866 B

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000" height="1000" role="img" aria-label="Hatch mark">
<rect width="1000" height="1000" fill="#0A0A0B"/>
<g>
<!-- Rounded square body -->
<rect x="100" y="100" width="800" height="800" rx="192" ry="192"
fill="none" stroke="#FAFAF9" stroke-width="88" stroke-linejoin="round"/>
<!-- The slot: a horizontal bar -->
<line x1="276" y1="460" x2="596" y2="460"
stroke="#FAFAF9" stroke-width="61.599999999999994" stroke-linecap="round"/>
<!-- The request: a small filled square above the slot -->
<rect x="436" y="244" width="128" height="128" fill="#FAFAF9"/>
<!-- The stem connecting the request to the slot -->
<line x1="500" y1="372" x2="500" y2="460"
stroke="#FAFAF9" stroke-width="46.199999999999996" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 915 B

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000" height="1000" role="img" aria-label="Hatch mark">
<rect width="1000" height="1000" fill="#FAFAF9"/>
<g>
<!-- Rounded square body -->
<rect x="100" y="100" width="800" height="800" rx="192" ry="192"
fill="none" stroke="#0A0A0B" stroke-width="88" stroke-linejoin="round"/>
<!-- The slot: a horizontal bar -->
<line x1="276" y1="460" x2="596" y2="460"
stroke="#0A0A0B" stroke-width="61.599999999999994" stroke-linecap="round"/>
<!-- The request: a small filled square above the slot -->
<rect x="436" y="244" width="128" height="128" fill="#0A0A0B"/>
<!-- The stem connecting the request to the slot -->
<line x1="500" y1="372" x2="500" y2="460"
stroke="#0A0A0B" stroke-width="46.199999999999996" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 915 B

17
brand/mark/mark.svg Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000" height="1000" role="img" aria-label="Hatch mark">
<g>
<!-- Rounded square body -->
<rect x="100" y="100" width="800" height="800" rx="192" ry="192"
fill="none" stroke="#0A0A0B" stroke-width="88" stroke-linejoin="round"/>
<!-- The slot: a horizontal bar -->
<line x1="276" y1="460" x2="596" y2="460"
stroke="#0A0A0B" stroke-width="61.599999999999994" stroke-linecap="round"/>
<!-- The request: a small filled square above the slot -->
<rect x="436" y="244" width="128" height="128" fill="#0A0A0B"/>
<!-- The stem connecting the request to the slot -->
<line x1="500" y1="372" x2="500" y2="460"
stroke="#0A0A0B" stroke-width="46.199999999999996" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 866 B

BIN
brand/og/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

69
brand/og/og.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 999 KiB

BIN
brand/og/og@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 426 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 426 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 426 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 426 KiB

View File

@ -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 package main
import ( import (
@ -10,6 +5,10 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"github.com/elfoundation/hatch/internal/handler"
"github.com/elfoundation/hatch/internal/store"
"github.com/go-chi/chi/v5"
) )
func main() { func main() {
@ -17,20 +16,20 @@ func main() {
if port == "" { if port == "" {
port = "8080" 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() h := handler.New(repo)
mux.HandleFunc("GET /healthz", healthz) r := chi.NewRouter()
h.RegisterRoutes(r)
addr := fmt.Sprintf(":%s", port) addr := fmt.Sprintf(":%s", port)
log.Printf("hatch starting on %s", addr) 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) 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")
}

View File

@ -1,39 +1,63 @@
package main package main
import ( import (
"context"
"encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "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) req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
r.ServeHTTP(w, req)
healthz(w, req) if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", w.Code)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", resp.StatusCode)
} }
body, _ := io.ReadAll(w.Result().Body)
body, err := io.ReadAll(resp.Body) w.Result().Body.Close()
resp.Body.Close()
if err != nil {
t.Fatalf("failed to read response body: %v", err)
}
got := strings.TrimSpace(string(body)) got := strings.TrimSpace(string(body))
if got != "ok" { if got != "ok" {
t.Fatalf("expected body 'ok', got '%s'", got) t.Fatalf("expected body 'ok', got %q", got)
} }
} }
func TestHealthzSmoke(t *testing.T) { func TestHealthzSmoke(t *testing.T) {
// Smoke test: boot the server on a random port, hit /healthz, verify 200 + "ok". // Smoke: boot server on random port, hit /healthz.
srv := httptest.NewServer(http.HandlerFunc(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() defer srv.Close()
resp, err := http.Get(srv.URL + "/healthz") resp, err := http.Get(srv.URL + "/healthz")
@ -45,14 +69,388 @@ func TestHealthzSmoke(t *testing.T) {
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", resp.StatusCode) t.Fatalf("expected 200 OK, got %d", resp.StatusCode)
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
t.Fatalf("failed to read response body: %v", err) t.Fatalf("failed to read response body: %v", err)
} }
got := strings.TrimSpace(string(body)) got := strings.TrimSpace(string(body))
if got != "ok" { 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, "<!DOCTYPE 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)
}

142
docs/brand/usage.md Normal file
View File

@ -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
<picture>
<source media="(prefers-color-scheme: dark)" srcset="brand/wordmark/wordmark-dark.svg">
<img src="brand/wordmark/wordmark.svg" alt="Hatch" width="200">
</picture>
```
## 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.

17
go.mod
View File

@ -1,3 +1,20 @@
module github.com/elfoundation/hatch module github.com/elfoundation/hatch
go 1.25.0 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
)

53
go.sum
View File

@ -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=

125
internal/handler/handler.go Normal file
View File

@ -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})
}

View File

@ -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)
}
}

344
internal/handler/inspect.go Normal file
View File

@ -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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hatch {{.EndpointID}}</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 960px; margin: 0 auto; padding: 1rem; }
h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
.endpoint-url { color: #666; font-family: monospace; font-size: 0.85rem; margin-bottom: 1.5rem; word-break: break-all; }
.request { border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 1rem; overflow: hidden; }
.request-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1rem; background: #f5f5f5; cursor: pointer; user-select: none; }
.request-header:hover { background: #eee; }
@media (prefers-color-scheme: dark) {
.request { border-color: #333; }
.request-header { background: #1a1a1a; }
.request-header:hover { background: #222; }
.endpoint-url { color: #aaa; }
}
.method { font-weight: 700; font-size: 0.8rem; padding: 2px 8px; border-radius: 4px; }
.method.GET { background: #d4edda; color: #155724; }
.method.POST { background: #cce5ff; color: #004085; }
.method.PUT { background: #fff3cd; color: #856404; }
.method.PATCH { background: #fff3cd; color: #856404; }
.method.DELETE { background: #f8d7da; color: #721c24; }
.path { font-family: monospace; font-size: 0.9rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.time { color: #999; font-size: 0.8rem; white-space: nowrap; }
.request-body { padding: 1rem; display: none; }
.request-body.open { display: block; }
.section { margin-bottom: 0.75rem; }
.section-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: #888; margin-bottom: 0.25rem; }
pre { background: #f8f8f8; border: 1px solid #e0e0e0; border-radius: 4px; padding: 0.75rem; font-size: 0.8rem; overflow-x: auto; margin: 0; white-space: pre-wrap; word-break: break-all; }
@media (prefers-color-scheme: dark) { pre { background: #111; border-color: #333; } }
.empty { text-align: center; color: #999; padding: 3rem 0; }
.empty h2 { font-size: 1rem; margin-bottom: 0.5rem; }
.empty code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
@media (prefers-color-scheme: dark) { .empty code { background: #222; } }
.replay-btn { margin-left: auto; padding: 4px 12px; font-size: 0.8rem; border: 1px solid #007bff; background: #007bff; color: #fff; border-radius: 4px; cursor: pointer; }
.replay-btn:hover { background: #0056b3; }
.replay-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.replay-panel { display: none; padding: 1rem; border-top: 1px solid #e0e0e0; }
@media (prefers-color-scheme: dark) { .replay-panel { border-color: #333; } }
.replay-panel.open { display: block; }
.replay-url-input { width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 0.85rem; box-sizing: border-box; margin-bottom: 0.5rem; }
.replay-url-input:focus { outline: none; border-color: #007bff; box-shadow: 0 0 0 2px rgba(0,123,255,0.25); }
.replay-actions { display: flex; gap: 0.5rem; align-items: center; }
.replay-send-btn { padding: 6px 16px; border: none; background: #28a745; color: #fff; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
.replay-send-btn:hover { background: #218838; }
.replay-send-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.replay-cancel-btn { padding: 6px 12px; border: 1px solid #ccc; background: transparent; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
.replay-result { margin-top: 0.75rem; }
.replay-error { color: #dc3545; font-size: 0.85rem; }
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; margin-right: 0.5rem; }
.status-2xx { background: #d4edda; color: #155724; }
.status-3xx { background: #fff3cd; color: #856404; }
.status-4xx { background: #f8d7da; color: #721c24; }
.status-5xx { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<h1>Hatch</h1>
<div class="endpoint-url">Endpoint: <strong>{{.EndpointID}}</strong></div>
{{if .Requests}}
{{range .Requests}}
<div class="request" id="req-{{.ID}}">
<div class="request-header" onclick="toggleRequest('{{.ID}}')">
<span class="method {{.Method}}">{{.Method}}</span>
<span class="path">{{.Path}}</span>
<span class="time" title="{{.CreatedAt}}">{{formatTime .CreatedAt}}</span>
<button class="replay-btn" onclick="event.stopPropagation(); openReplay('{{.ID}}')" title="Replay this request">&#x21BA; Replay</button>
</div>
<div class="request-body" id="body-{{.ID}}">
{{if .Headers}}
<div class="section">
<div class="section-label">Headers</div>
<pre>{{prettyJSON .Headers}}</pre>
</div>
{{end}}
{{if .Query}}
<div class="section">
<div class="section-label">Query</div>
<pre>{{.Query}}</pre>
</div>
{{end}}
{{if .Body}}
<div class="section">
<div class="section-label">Body</div>
<pre>{{printf "%s" .Body}}</pre>
</div>
{{end}}
</div>
<div class="replay-panel" id="replay-{{.ID}}">
<input type="text" class="replay-url-input" id="replay-url-{{.ID}}" placeholder="https://myapp.local/webhook" value="">
<div class="replay-actions">
<button class="replay-send-btn" onclick="doReplay('{{.ID}}')">Send</button>
<button class="replay-cancel-btn" onclick="closeReplay('{{.ID}}')">Cancel</button>
<span class="replay-error" id="replay-error-{{.ID}}"></span>
</div>
<div class="replay-result" id="replay-result-{{.ID}}"></div>
</div>
</div>
{{end}}
{{else}}
<div class="empty">
<h2>Waiting for requests...</h2>
<p>Send a request to <code>/{{.EndpointID}}</code> to see it appear here.</p>
</div>
{{end}}
<script>
function toggleRequest(id) {
document.getElementById('body-' + id).classList.toggle('open');
}
function openReplay(id) {
document.querySelectorAll('.replay-panel.open').forEach(function(p) { p.classList.remove('open'); });
document.getElementById('replay-' + id).classList.add('open');
var input = document.getElementById('replay-url-' + id);
input.focus();
if (!input.value) input.value = 'http://localhost:8080';
document.getElementById('replay-result-' + id).innerHTML = '';
document.getElementById('replay-error-' + id).textContent = '';
}
function closeReplay(id) {
document.getElementById('replay-' + id).classList.remove('open');
}
function doReplay(id) {
var targetUrl = document.getElementById('replay-url-' + id).value.trim();
if (!targetUrl) {
document.getElementById('replay-error-' + id).textContent = 'Please enter a target URL.';
return;
}
var sendBtn = document.querySelector('#replay-' + id + ' .replay-send-btn');
var errorEl = document.getElementById('replay-error-' + id);
var resultEl = document.getElementById('replay-result-' + id);
var endpointId = window.location.pathname.replace(/^\/e\//, '');
sendBtn.disabled = true;
errorEl.textContent = '';
resultEl.innerHTML = '<em>Sending...</em>';
fetch('/e/' + endpointId + '/requests/' + id + '/replay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_url: targetUrl })
}).then(function(resp) {
return resp.json().then(function(data) { return { status: resp.status, data: data }; });
}).then(function(result) {
if (result.data.error) {
errorEl.textContent = result.data.error;
resultEl.innerHTML = '';
} else {
renderReplayResult(resultEl, result.data);
}
}).catch(function(err) {
errorEl.textContent = 'Network error: ' + err.message;
resultEl.innerHTML = '';
}).finally(function() { sendBtn.disabled = false; });
}
function renderReplayResult(el, data) {
var sc = 'status-2xx';
if (data.status >= 300 && data.status < 400) sc = 'status-3xx';
else if (data.status >= 400 && data.status < 500) sc = 'status-4xx';
else if (data.status >= 500) sc = 'status-5xx';
var html = '<div class="section"><div class="section-label">Response</div>';
html += '<span class="status-badge ' + sc + '">' + data.status + '</span></div>';
if (data.headers && Object.keys(data.headers).length > 0) {
html += '<div class="section"><div class="section-label">Response Headers</div><pre>';
for (var h in data.headers) { html += escapeHtml(h) + ': ' + escapeHtml(data.headers[h]) + '\n'; }
html += '</pre></div>';
}
if (data.body) {
html += '<div class="section"><div class="section-label">Response Body</div><pre>' + escapeHtml(data.body) + '</pre></div>';
}
el.innerHTML = html;
}
function formatTimeStr(ts) {
try { var d = new Date(ts); if (isNaN(d.getTime())) return ts; } catch(_) { return ts; }
var now = new Date();
var diff = Math.floor((now - d) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
if (diff < 604800) return Math.floor(diff / 86400) + 'd ago';
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
}
function buildRequestBody(req) {
var html = '';
if (req.headers) {
var h = req.headers;
if (typeof h === 'string') { try { h = JSON.parse(h); } catch(_) { h = null; } }
if (h && Object.keys(h).length > 0) {
html += '<div class="section"><div class="section-label">Headers</div><pre>' + escapeHtml(JSON.stringify(h, null, 2)) + '</pre></div>';
}
}
if (req.query) {
html += '<div class="section"><div class="section-label">Query</div><pre>' + escapeHtml(req.query) + '</pre></div>';
}
if (req.body) {
var b = typeof req.body === 'string' ? req.body : '';
if (b) {
html += '<div class="section"><div class="section-label">Body</div><pre>' + escapeHtml(b) + '</pre></div>';
}
}
return html;
}
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
// Live updates via EventSource
(function() {
var endpointId = window.location.pathname.replace(/^\/e\//, '');
if (!endpointId) return;
var es = new EventSource('/e/' + endpointId + '/events');
es.onmessage = function(e) {
try {
var req = JSON.parse(e.data);
var empty = document.querySelector('.empty');
if (empty) empty.remove();
var div = document.createElement('div');
div.className = 'request';
div.id = 'req-' + req.id;
div.innerHTML = '<div class="request-header" onclick="toggleRequest(\'' + req.id + '\')">' +
'<span class="method ' + req.method + '">' + req.method + '</span>' +
'<span class="path">' + req.path + '</span>' +
'<span class="time" title="' + req.created_at + '">' + formatTimeStr(req.created_at) + '</span>' +
'<button class="replay-btn" onclick="event.stopPropagation(); openReplay(\'' + req.id + '\')">&#x21BA; Replay</button>' +
'</div>' +
'<div class="request-body" id="body-' + req.id + '">' + buildRequestBody(req) + '</div>' +
'<div class="replay-panel" id="replay-' + req.id + '">' +
'<input type="text" class="replay-url-input" id="replay-url-' + req.id + '" placeholder="https://myapp.local/webhook" value="">' +
'<div class="replay-actions">' +
'<button class="replay-send-btn" onclick="doReplay(\'' + req.id + '\')">Send</button>' +
'<button class="replay-cancel-btn" onclick="closeReplay(\'' + req.id + '\')">Cancel</button>' +
'<span class="replay-error" id="replay-error-' + req.id + '"></span></div>' +
'<div class="replay-result" id="replay-result-' + req.id + '"></div></div>';
var ref = document.querySelector('.endpoint-url');
ref.insertAdjacentElement('afterend', div);
} catch(_) {}
};
})();
</script>
</body>
</html>
`))
// 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")
}
}

35
internal/handler/mock.go Normal file
View File

@ -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"})
}
}

196
internal/handler/replay.go Normal file
View File

@ -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
}

View File

@ -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)
}
}
}

125
internal/handler/sse.go Normal file
View File

@ -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()
}
}
}
}

35
internal/store/db.go Normal file
View File

@ -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
}

2
internal/store/doc.go Normal file
View File

@ -0,0 +1,2 @@
// Package store provides the SQLite-backed persistence layer for Hatch.
package store

42
internal/store/models.go Normal file
View File

@ -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)

21
internal/store/schema.sql Normal file
View File

@ -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);

View File

@ -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") }

View File

@ -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)
}
}