diff --git a/.gitignore b/.gitignore
index 10976539..ec64e4fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,4 @@ go.work
*.swo
*~
.DS_Store
+hatch
diff --git a/cmd/hatch/main.go b/cmd/hatch/main.go
index 626756af..7763966f 100644
--- a/cmd/hatch/main.go
+++ b/cmd/hatch/main.go
@@ -8,26 +8,28 @@ import (
"github.com/elfoundation/hatch/internal/handler"
"github.com/elfoundation/hatch/internal/store"
+ "github.com/go-chi/chi/v5"
)
func main() {
port := os.Getenv("PORT")
- if port == "" { port = "8080" }
+ 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) }
+ if err != nil {
+ log.Fatalf("hatch: open store: %v", err)
+ }
defer repo.Close()
+
h := handler.New(repo)
- mux := http.NewServeMux()
- mux.HandleFunc("GET /healthz", healthz)
- h.RegisterRoutes(mux)
+ 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 { log.Fatalf("hatch server error: %v", err) }
-}
-
-func healthz(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/plain")
- w.WriteHeader(http.StatusOK)
- fmt.Fprintln(w, "ok")
+ if err := http.ListenAndServe(addr, r); err != nil {
+ log.Fatalf("hatch server error: %v", err)
+ }
}
diff --git a/cmd/hatch/main_test.go b/cmd/hatch/main_test.go
index f9d5bb5e..b2995f70 100644
--- a/cmd/hatch/main_test.go
+++ b/cmd/hatch/main_test.go
@@ -4,36 +4,57 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "os"
"strings"
"testing"
+
+ "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.
+ os.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 +66,12 @@ 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)
}
}
diff --git a/go.mod b/go.mod
index 5083bce5..504323fd 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ 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
)
diff --git a/go.sum b/go.sum
index b0540320..860a5699 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
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=
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
index 42feb5ba..40b90ecf 100644
--- a/internal/handler/handler.go
+++ b/internal/handler/handler.go
@@ -1,47 +1,125 @@
package handler
import (
- "context"
"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"
)
-type Handler struct{ Repo store.Repository }
-
-func New(repo store.Repository) *Handler { return &Handler{Repo: repo} }
-
-func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
- mux.Handle("/{endpoint}", h)
- mux.Handle("/{endpoint}/", h)
+// Handler groups all HTTP handlers and their shared dependencies.
+type Handler struct {
+ Repo store.Repository
}
-func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- eid := extractID(r.URL.Path)
- ctx := context.Background()
+// 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, ", ") }
+ 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() }
- h.Repo.AppendRequest(ctx, eid, &store.Request{
- Method: r.Method, Path: r.URL.Path, Headers: string(hdrJSON),
- Query: r.URL.RawQuery, Body: body,
- })
+ 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{}{})
+ json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
}
-func extractID(p string) string {
- t := strings.TrimLeft(p, "/")
- if i := strings.IndexByte(t, '?'); i >= 0 { t = t[:i] }
- if i := strings.IndexByte(t, '/'); i >= 0 { return t[:i] }
- return t
+// Healthz returns 200 OK for liveness probes.
+func Healthz(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("ok\n"))
+}
+
+// writeError writes a JSON error response.
+func writeError(w http.ResponseWriter, status int, msg string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go
index c39ca13e..3a372f0f 100644
--- a/internal/handler/handler_test.go
+++ b/internal/handler/handler_test.go
@@ -4,70 +4,146 @@ import (
"context"
"encoding/json"
"io"
+ "net/http"
"net/http/httptest"
"strings"
"testing"
+
"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{}} }
+
+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
+ 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
+ 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 = "x"; r.EndpointID = eid; f.requests = append(f.requests, r); return nil
+ 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) ListRequests(_ context.Context, _ string, _ int) ([]*store.Request, error) { return f.requests, 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 }
-func TestRecordsAllVerbs(t *testing.T) {
+// 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()
- h := New(repo)
+ r := testRouter(repo)
+
body := ""
- if m == "POST" || m == "PUT" || m == "PATCH" { body = `{"k":"v"}` }
+ 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") }
+ if m == "GET" {
+ req.Header.Set("Accept", "text/html")
+ }
w := httptest.NewRecorder()
- h.ServeHTTP(w, req)
- if w.Result().StatusCode != 200 { t.Errorf("%s: expected 200", m) }
- if len(repo.requests) != 1 { t.Errorf("%s: expected 1 request", m); continue }
- r := repo.requests[0]
- if r.Method != m { t.Errorf("%s: wrong method %s", m, r.Method) }
- if r.Path != "/ep" { t.Errorf("%s: wrong path", m) }
- }
-}
+ r.ServeHTTP(w, req)
-func TestHandlerReturnsJSON200(t *testing.T) {
- h := New(newFakeRepo())
- w := httptest.NewRecorder()
- h.ServeHTTP(w, httptest.NewRequest("GET", "/t", nil))
- resp := w.Result()
- if resp.StatusCode != 200 { t.Fatalf("expected 200") }
- if !strings.Contains(resp.Header.Get("Content-Type"), "application/json") { t.Error("not json") }
- data, _ := io.ReadAll(resp.Body); resp.Body.Close()
- var v map[string]interface{}
- if json.Unmarshal(data, &v) != nil { t.Fatal("invalid json") }
-}
-
-func TestExtractID(t *testing.T) {
- for _, tc := range []struct{ p, w string }{
- {"/a","a"},{"/a/b","a"},{"/",""},{"/x?y=z","x"},
- } {
- if g := extractID(tc.p); g != tc.w {
- t.Errorf("extractID(%q)=%q want %q", tc.p, g, tc.w)
+ 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 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")
+ }
+}
diff --git a/internal/handler/inspect.go b/internal/handler/inspect.go
new file mode 100644
index 00000000..c0cb4acd
--- /dev/null
+++ b/internal/handler/inspect.go
@@ -0,0 +1,281 @@
+package handler
+
+import (
+ "encoding/json"
+ "html/template"
+ "net/http"
+
+ "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,
+}).Parse(`
+
+
+
+
+Hatch — {{.EndpointID}}
+
+
+
+Hatch
+Endpoint: {{.EndpointID}}
+
+{{if .Requests}}
+ {{range .Requests}}
+
+
+
+ {{if .Headers}}
+
+
Headers
+
{{prettyJSON .Headers}}
+
+ {{end}}
+ {{if .Query}}
+
+ {{end}}
+ {{if .Body}}
+
+
Body
+
{{printf "%s" .Body}}
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
+ {{end}}
+{{else}}
+
+
Waiting for requests...
+
Send a request to /{{.EndpointID}} to see it appear here.
+
+{{end}}
+
+
+
+
+`))
+
+// HandleInspect serves the inspect page at GET /e/{endpointID}.
+func HandleInspect(repo store.Repository) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ endpointID := r.PathValue("endpointID")
+ if endpointID == "" {
+ http.Error(w, "missing endpoint ID", http.StatusBadRequest)
+ return
+ }
+ if _, err := repo.GetEndpoint(r.Context(), endpointID); err != nil {
+ repo.CreateEndpoint(r.Context(), endpointID)
+ }
+ requests, err := repo.ListRequests(r.Context(), endpointID, 100)
+ if err != nil {
+ http.Error(w, "failed to list requests", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ inspectTemplate.Execute(w, inspectPageData{
+ EndpointID: endpointID,
+ Requests: requests,
+ })
+ }
+}
+
+// prettyJSON formats a JSON string with indentation.
+func prettyJSON(raw string) string {
+ var v interface{}
+ if err := json.Unmarshal([]byte(raw), &v); err != nil {
+ return raw
+ }
+ b, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ return raw
+ }
+ return string(b)
+}
diff --git a/internal/handler/mock.go b/internal/handler/mock.go
new file mode 100644
index 00000000..2b9c4d8e
--- /dev/null
+++ b/internal/handler/mock.go
@@ -0,0 +1,35 @@
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/elfoundation/hatch/internal/store"
+)
+
+// HandleMock handles PUT /e/{endpointID}/mock.
+func HandleMock(repo store.Repository) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ endpointID := r.PathValue("endpointID")
+ if endpointID == "" {
+ writeError(w, http.StatusBadRequest, "missing endpoint ID")
+ return
+ }
+ if _, err := repo.GetEndpoint(r.Context(), endpointID); err != nil {
+ repo.CreateEndpoint(r.Context(), endpointID)
+ }
+ var mock store.MockConfig
+ if err := json.NewDecoder(r.Body).Decode(&mock); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
+ return
+ }
+ mock.EndpointID = endpointID
+ if err := repo.SetMock(r.Context(), &mock); err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to set mock: "+err.Error())
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+ }
+}
diff --git a/internal/handler/replay.go b/internal/handler/replay.go
new file mode 100644
index 00000000..36884311
--- /dev/null
+++ b/internal/handler/replay.go
@@ -0,0 +1,196 @@
+package handler
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+
+ "github.com/elfoundation/hatch/internal/store"
+)
+
+const maxReplayBodyBytes = 64 * 1024
+
+type replayRequest struct {
+ TargetURL string `json:"target_url"`
+}
+
+type replayResponse struct {
+ Status int `json:"status"`
+ Headers map[string]string `json:"headers"`
+ Body string `json:"body"`
+ Error string `json:"error,omitempty"`
+}
+
+func HandleReplay(repo store.Repository) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ requestID := r.PathValue("requestID")
+ if requestID == "" {
+ writeError(w, http.StatusBadRequest, "missing request_id")
+ return
+ }
+
+ var replay replayRequest
+ if err := json.NewDecoder(r.Body).Decode(&replay); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
+ return
+ }
+ if replay.TargetURL == "" {
+ writeError(w, http.StatusBadRequest, "target_url is required")
+ return
+ }
+
+ target, err := url.Parse(replay.TargetURL)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid target_url: "+err.Error())
+ return
+ }
+ if target.Scheme != "http" && target.Scheme != "https" {
+ writeError(w, http.StatusBadRequest, "target_url scheme must be http or https")
+ return
+ }
+
+ if !allowPrivateReplay() && isPrivateAddr(target.Host) {
+ writeError(w, http.StatusForbidden, "replay to private/loopback addresses is denied. Set HATCH_ALLOW_PRIVATE_REPLAY=true to allow")
+ return
+ }
+
+ capReq, err := repo.GetRequest(r.Context(), requestID)
+ if err != nil {
+ writeError(w, http.StatusNotFound, "request not found: "+err.Error())
+ return
+ }
+
+ replayResult, err := doReplay(capReq, target)
+ if err != nil {
+ writeError(w, http.StatusBadGateway, "replay failed: "+err.Error())
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(replayResult)
+ }
+}
+
+func doReplay(capReq *store.Request, targetURL *url.URL) (*replayResponse, error) {
+ outURL := *targetURL
+ outURL.Path = joinPath(targetURL.Path, capReq.Path)
+ if capReq.Query != "" {
+ outURL.RawQuery = capReq.Query
+ }
+
+ var bodyReader io.Reader
+ if capReq.Body != nil && len(capReq.Body) > 0 {
+ bodyReader = strings.NewReader(string(capReq.Body))
+ }
+
+ req, err := http.NewRequest(capReq.Method, outURL.String(), bodyReader)
+ if err != nil {
+ return nil, fmt.Errorf("building request: %w", err)
+ }
+
+ var headers map[string]string
+ if capReq.Headers != "" {
+ if err := json.Unmarshal([]byte(capReq.Headers), &headers); err != nil {
+ headers = nil
+ }
+ }
+ for k, v := range headers {
+ if isHopByHop(k) {
+ continue
+ }
+ req.Header.Set(k, v)
+ }
+
+ client := &http.Client{
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if !allowPrivateReplay() && isPrivateAddr(req.URL.Host) {
+ return fmt.Errorf("redirect to private address %s denied", req.URL.Host)
+ }
+ if len(via) >= 10 {
+ return fmt.Errorf("too many redirects")
+ }
+ return nil
+ },
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("sending request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxReplayBodyBytes))
+ if err != nil {
+ return nil, fmt.Errorf("reading response: %w", err)
+ }
+
+ respHeaders := make(map[string]string, len(resp.Header))
+ for k, vs := range resp.Header {
+ respHeaders[k] = strings.Join(vs, ", ")
+ }
+
+ return &replayResponse{
+ Status: resp.StatusCode,
+ Headers: respHeaders,
+ Body: string(body),
+ }, nil
+}
+
+// isPrivateAddr reports whether addr falls within a private, loopback, or reserved range.
+func isPrivateAddr(hostport string) bool {
+ host, _, err := net.SplitHostPort(hostport)
+ if err != nil {
+ host = hostport
+ }
+ if host == "" {
+ return false
+ }
+
+ // String-based checks first (catch names and the unspecified address 0.0.0.0).
+ if host == "localhost" || host == "0.0.0.0" || host == "::1" || host == "[::1]" {
+ return true
+ }
+
+ ip := net.ParseIP(host)
+ if ip != nil {
+ return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
+ }
+ return false
+}
+
+func allowPrivateReplay() bool {
+ return strings.EqualFold(os.Getenv("HATCH_ALLOW_PRIVATE_REPLAY"), "true")
+}
+
+var hopByHopHeaders = map[string]bool{
+ "connection": true,
+ "keep-alive": true,
+ "proxy-authenticate": true,
+ "proxy-authorization": true,
+ "te": true,
+ "trailer": true,
+ "transfer-encoding": true,
+ "upgrade": true,
+}
+
+func isHopByHop(header string) bool {
+ return hopByHopHeaders[strings.ToLower(header)]
+}
+
+func joinPath(base, extra string) string {
+ base = strings.TrimRight(base, "/")
+ extra = strings.TrimLeft(extra, "/")
+ if base == "" {
+ return "/" + extra
+ }
+ if extra == "" {
+ return base
+ }
+ return base + "/" + extra
+}
diff --git a/internal/handler/replay_test.go b/internal/handler/replay_test.go
new file mode 100644
index 00000000..93e13112
--- /dev/null
+++ b/internal/handler/replay_test.go
@@ -0,0 +1,236 @@
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/elfoundation/hatch/internal/store"
+)
+
+func TestReplayMissingTargetURL(t *testing.T) {
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "ep")
+ repo.AppendRequest(nil, "ep", &store.Request{Method: "POST", Path: "/webhook", Headers: "{}"})
+ reqID := repo.requests[0].ID
+
+ r := testRouter(repo)
+ body := `{}`
+ req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", w.Code)
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ if !strings.Contains(resp["error"], "target_url") {
+ t.Errorf("expected target_url error, got %v", resp)
+ }
+}
+
+func TestReplayInvalidScheme(t *testing.T) {
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "ep")
+ repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
+ reqID := repo.requests[0].ID
+
+ r := testRouter(repo)
+ body := `{"target_url": "ftp://bad.scheme/"}`
+ req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", w.Code)
+ }
+}
+
+func TestReplaySSRFBlocksLocalhost(t *testing.T) {
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "ep")
+ repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
+ reqID := repo.requests[0].ID
+
+ r := testRouter(repo)
+ body := `{"target_url": "http://localhost:8080/"}`
+ req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("expected 403 for localhost, got %d", w.Code)
+ }
+}
+
+func TestReplaySSRFBlocksPrivate(t *testing.T) {
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "ep")
+ repo.AppendRequest(nil, "ep", &store.Request{Method: "GET", Path: "/", Headers: "{}"})
+ reqID := repo.requests[0].ID
+
+ r := testRouter(repo)
+ for _, addr := range []string{
+ "http://10.0.0.1:80/",
+ "http://172.16.0.1:80/",
+ "http://192.168.1.1:80/",
+ "http://127.0.0.1:80/",
+ "http://[::1]:80/",
+ "http://0.0.0.0:80/",
+ } {
+ body := `{"target_url": "` + addr + `"}`
+ req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ if w.Code != http.StatusForbidden {
+ t.Errorf("expected 403 for %s, got %d", addr, w.Code)
+ }
+ }
+}
+
+func TestReplayNotFound(t *testing.T) {
+ repo := newFakeRepo()
+ r := testRouter(repo)
+ body := `{"target_url": "https://example.com/"}`
+ req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/nonexistent/replay", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", w.Code)
+ }
+}
+
+func TestReplaySuccess(t *testing.T) {
+ // Start a local sink server that echoes back what it receives.
+ sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Sink", "yes")
+ w.WriteHeader(http.StatusCreated)
+ w.Write([]byte(`{"received": true}`))
+ }))
+ defer sink.Close()
+
+ repo := newFakeRepo()
+ repo.CreateEndpoint(nil, "ep")
+ repo.AppendRequest(nil, "ep", &store.Request{
+ Method: "POST",
+ Path: "/webhook",
+ Headers: `{"Content-Type":"application/json","X-Custom":"val"}`,
+ Query: "foo=bar",
+ Body: []byte(`{"msg":"hello"}`),
+ })
+ reqID := repo.requests[0].ID
+
+ // Set env to allow private replay since httptest server is on loopback.
+ t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
+
+ r := testRouter(repo)
+ body := `{"target_url": "` + sink.URL + `"}`
+ req := httptest.NewRequest(http.MethodPost, "/e/ep/requests/"+reqID+"/replay", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp replayResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if resp.Status != 201 {
+ t.Errorf("expected status 201, got %d", resp.Status)
+ }
+ if resp.Headers["X-Sink"] != "yes" {
+ t.Errorf("expected X-Sink header, got %v", resp.Headers)
+ }
+ if resp.Body != `{"received": true}` {
+ t.Errorf("expected body, got %q", resp.Body)
+ }
+}
+
+func TestReplayE2ECaptureThenReplay(t *testing.T) {
+ // E2E: capture a request, then replay it to a sink, verify the sink received it.
+
+ // Step 1: Start a sink.
+ sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "method": r.Method,
+ "path": r.URL.Path,
+ "query": r.URL.RawQuery,
+ "body": "received",
+ })
+ }))
+ defer sink.Close()
+
+ // Step 2: Capture a request.
+ repo := newFakeRepo()
+ rt := testRouter(repo)
+
+ captureReq := httptest.NewRequest("POST", "/e2e-test", strings.NewReader(`{"order":"1"}`))
+ captureReq.Header.Set("X-Test", "hello")
+ captureW := httptest.NewRecorder()
+ rt.ServeHTTP(captureW, captureReq)
+
+ if len(repo.requests) != 1 {
+ t.Fatalf("expected 1 captured request, got %d", len(repo.requests))
+ }
+ captured := repo.requests[0]
+ if captured.Method != "POST" {
+ t.Errorf("captured method: %s", captured.Method)
+ }
+
+ // Step 3: Replay it to the sink.
+ t.Setenv("HATCH_ALLOW_PRIVATE_REPLAY", "true")
+
+ replayBody := `{"target_url": "` + sink.URL + `"}`
+ replayReq := httptest.NewRequest("POST", "/e/e2e-test/requests/"+captured.ID+"/replay", strings.NewReader(replayBody))
+ replayReq.Header.Set("Content-Type", "application/json")
+ replayW := httptest.NewRecorder()
+ rt.ServeHTTP(replayW, replayReq)
+
+ if replayW.Code != http.StatusOK {
+ t.Fatalf("replay failed with %d: %s", replayW.Code, replayW.Body.String())
+ }
+
+ var resp replayResponse
+ json.NewDecoder(replayW.Body).Decode(&resp)
+ if resp.Status != 200 {
+ t.Errorf("replay response status: %d", resp.Status)
+ }
+}
+
+func TestIsPrivateAddr(t *testing.T) {
+ tests := []struct {
+ addr string
+ want bool
+ }{
+ {"localhost:8080", true},
+ {"127.0.0.1:9090", true},
+ {"[::1]:80", true},
+ {"10.0.0.5:443", true},
+ {"172.16.0.1:80", true},
+ {"192.168.1.1:3000", true},
+ {"0.0.0.0:8080", true},
+ {"example.com:443", false},
+ {"93.184.216.34:80", false},
+ {"8.8.8.8:53", false},
+ }
+ for _, tc := range tests {
+ got := isPrivateAddr(tc.addr)
+ if got != tc.want {
+ t.Errorf("isPrivateAddr(%q) = %v, want %v", tc.addr, tc.want, got)
+ }
+ }
+}
diff --git a/internal/handler/sse.go b/internal/handler/sse.go
new file mode 100644
index 00000000..7d210fdb
--- /dev/null
+++ b/internal/handler/sse.go
@@ -0,0 +1,101 @@
+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)
+ }
+}
+
+func (h *sseHub) broadcast(endpointID string, req *store.Request) {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ subs := h.subs[endpointID]
+ if subs == nil {
+ return
+ }
+ data, err := json.Marshal(req)
+ 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)
+
+ ch := hub.subscribe(endpointID)
+ defer hub.unsubscribe(endpointID, ch)
+
+ ctx := r.Context()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case data, ok := <-ch:
+ if !ok {
+ return
+ }
+ fmt.Fprintf(w, "data: %s\n\n", data)
+ flusher.Flush()
+ }
+ }
+ }
+}
diff --git a/internal/store/models.go b/internal/store/models.go
index 1b8371c5..6f8ef2e6 100644
--- a/internal/store/models.go
+++ b/internal/store/models.go
@@ -20,11 +20,22 @@ type Request struct {
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
}
diff --git a/internal/store/schema.sql b/internal/store/schema.sql
index c551283d..026b257e 100644
--- a/internal/store/schema.sql
+++ b/internal/store/schema.sql
@@ -2,7 +2,10 @@ 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
+ 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,
diff --git a/internal/store/sqlite_repo.go b/internal/store/sqlite_repo.go
index 0c4d16ce..f7371354 100644
--- a/internal/store/sqlite_repo.go
+++ b/internal/store/sqlite_repo.go
@@ -3,15 +3,19 @@ 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") }
+ if db == nil {
+ return nil, fmt.Errorf("store: nil db")
+ }
return &sqliteRepo{db: db}, nil
}
@@ -19,7 +23,9 @@ func (r *sqliteRepo) CreateEndpoint(ctx context.Context, url string) (*Endpoint,
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) }
+ if err != nil {
+ return nil, fmt.Errorf("store: create endpoint: %w", err)
+ }
return e, nil
}
@@ -38,14 +44,29 @@ func (r *sqliteRepo) AppendRequest(ctx context.Context, endpointID string, req *
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) }
+ 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 }
+ 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) }
+ if err != nil {
+ return nil, fmt.Errorf("store: list requests: %w", err)
+ }
defer rows.Close()
var out []*Request
for rows.Next() {
@@ -58,6 +79,43 @@ func (r *sqliteRepo) ListRequests(ctx context.Context, endpointID string, limit
return out, rows.Err()
}
+func (r *sqliteRepo) GetMock(ctx context.Context, endpointID string) (*MockConfig, error) {
+ row := r.db.QueryRowContext(ctx, `SELECT mock_status, mock_headers, mock_body FROM endpoints WHERE id = ?`, endpointID)
+ var status sql.NullInt64
+ var headersJSON sql.NullString
+ var body []byte
+ if err := row.Scan(&status, &headersJSON, &body); err != nil {
+ return nil, fmt.Errorf("store: get mock: %w", err)
+ }
+ if !status.Valid {
+ return nil, fmt.Errorf("store: no mock configured for %s", endpointID)
+ }
+ m := &MockConfig{
+ EndpointID: endpointID,
+ Status: int(status.Int64),
+ Body: body,
+ }
+ if headersJSON.Valid && headersJSON.String != "" {
+ json.Unmarshal([]byte(headersJSON.String), &m.Headers)
+ }
+ return m, nil
+}
+
+func (r *sqliteRepo) SetMock(ctx context.Context, mock *MockConfig) error {
+ headersJSON, err := json.Marshal(mock.Headers)
+ if err != nil {
+ return fmt.Errorf("store: marshal mock headers: %w", err)
+ }
+ _, err = r.db.ExecContext(ctx,
+ `UPDATE endpoints SET mock_status = ?, mock_headers = ?, mock_body = ? WHERE id = ?`,
+ mock.Status, string(headersJSON), mock.Body, mock.EndpointID,
+ )
+ if err != nil {
+ return fmt.Errorf("store: set mock: %w", err)
+ }
+ return nil
+}
+
func (r *sqliteRepo) Close() error { return r.db.Close() }
func utcNow() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") }
diff --git a/internal/store/sqlite_repo_test.go b/internal/store/sqlite_repo_test.go
index a1bbc702..7e0a6a33 100644
--- a/internal/store/sqlite_repo_test.go
+++ b/internal/store/sqlite_repo_test.go
@@ -10,11 +10,19 @@ import (
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) }
+ 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) }
+ 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) }
+ if err != nil {
+ db.Close()
+ t.Fatalf("new sqlite repo: %v", err)
+ }
t.Cleanup(func() { repo.Close() })
return repo
}
@@ -22,53 +30,158 @@ func openTestRepo(t *testing.T) Repository {
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") }
+ 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) }
+ 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") }
+ 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") }
+ 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) }
+ 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) }
+ 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) }
+ 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) }
+ 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: "/"}) }
+ 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)) }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(reqs) != 3 {
+ t.Fatalf("got %d", len(reqs))
+ }
}
func TestListRequestsEmpty(t *testing.T) {
@@ -76,7 +189,9 @@ func TestListRequestsEmpty(t *testing.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)) }
+ if len(reqs) != 0 {
+ t.Errorf("got %d", len(reqs))
+ }
}
func TestAppendRequestBodyBinary(t *testing.T) {
@@ -84,19 +199,31 @@ func TestAppendRequestBodyBinary(t *testing.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) }
+ 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)) }
+ 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) }
+ 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) }
+ 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)