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>
11
.gitignore
vendored
@ -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
|
||||
|
||||
BIN
brand/banner/readme-banner.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
70
brand/banner/readme-banner.svg
Normal file
|
After Width: | Height: | Size: 999 KiB |
BIN
brand/banner/readme-banner@2x.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
brand/favicon/favicon-16.png
Normal file
|
After Width: | Height: | Size: 364 B |
BIN
brand/favicon/favicon-192.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
brand/favicon/favicon-32.png
Normal file
|
After Width: | Height: | Size: 691 B |
BIN
brand/favicon/favicon-48.png
Normal file
|
After Width: | Height: | Size: 786 B |
BIN
brand/favicon/favicon-512.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
brand/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
brand/mark/mark-1024.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
brand/mark/mark-128.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
brand/mark/mark-16.png
Normal file
|
After Width: | Height: | Size: 364 B |
BIN
brand/mark/mark-192.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
brand/mark/mark-256.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
brand/mark/mark-32.png
Normal file
|
After Width: | Height: | Size: 691 B |
BIN
brand/mark/mark-48.png
Normal file
|
After Width: | Height: | Size: 786 B |
BIN
brand/mark/mark-512.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
brand/mark/mark-64.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
17
brand/mark/mark-dark.svg
Normal 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 |
17
brand/mark/mark-on-dark.svg
Normal 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 |
17
brand/mark/mark-on-light.svg
Normal 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
@ -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
|
After Width: | Height: | Size: 64 KiB |
69
brand/og/og.svg
Normal file
|
After Width: | Height: | Size: 999 KiB |
BIN
brand/og/og@2x.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
brand/wordmark/wordmark-1200x360.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
brand/wordmark/wordmark-200x60.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
brand/wordmark/wordmark-400x120.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
brand/wordmark/wordmark-800x240.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
25
brand/wordmark/wordmark-dark.svg
Normal file
|
After Width: | Height: | Size: 426 KiB |
25
brand/wordmark/wordmark-on-dark.svg
Normal file
|
After Width: | Height: | Size: 426 KiB |
25
brand/wordmark/wordmark-on-light.svg
Normal file
|
After Width: | Height: | Size: 426 KiB |
25
brand/wordmark/wordmark.svg
Normal file
|
After Width: | Height: | Size: 426 KiB |
@ -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")
|
||||
}
|
||||
|
||||
@ -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, "<!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
@ -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
@ -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
|
||||
)
|
||||
|
||||
53
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=
|
||||
125
internal/handler/handler.go
Normal 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})
|
||||
}
|
||||
442
internal/handler/handler_test.go
Normal 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
@ -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">↺ 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 + '\')">↺ 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
@ -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
@ -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
|
||||
}
|
||||
236
internal/handler/replay_test.go
Normal 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
@ -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
@ -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
@ -0,0 +1,2 @@
|
||||
// Package store provides the SQLite-backed persistence layer for Hatch.
|
||||
package store
|
||||
42
internal/store/models.go
Normal 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
@ -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);
|
||||
121
internal/store/sqlite_repo.go
Normal 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") }
|
||||
234
internal/store/sqlite_repo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||