From dc0394cada876427d93e996baafb811787eb1608 Mon Sep 17 00:00:00 2001 From: SoftwareEngineer Date: Tue, 23 Jun 2026 09:53:13 +0200 Subject: [PATCH] Implement JSON API layer for Hatch (ELF-160) Add v1 JSON API endpoints alongside existing HTML endpoints: - POST /v1/endpoints/{id}/requests: Capture JSON request - GET /v1/endpoints/{id}/requests: List or search requests - POST /v1/endpoints/{id}/requests/{reqId}/replay: Replay captured request - PUT /v1/endpoints/{id}/mock: Set mock configuration - GET /v1/endpoints/{id}/openapi.json: OpenAPI 3.1 specification Add SearchRequests to Repository interface and implement simple LIKE search. Write integration tests for all v1 endpoints. Ensure backward compatibility with existing HTML endpoints. Co-Authored-By: Paperclip --- internal/handler/api_v1.go | 335 +++++++++++++++++++++++++++++++ internal/handler/api_v1_test.go | 182 +++++++++++++++++ internal/handler/handler.go | 3 + internal/handler/handler_test.go | 19 ++ internal/store/models.go | 1 + internal/store/sqlite_repo.go | 21 ++ 6 files changed, 561 insertions(+) create mode 100644 internal/handler/api_v1.go create mode 100644 internal/handler/api_v1_test.go diff --git a/internal/handler/api_v1.go b/internal/handler/api_v1.go new file mode 100644 index 00000000..ee735fdc --- /dev/null +++ b/internal/handler/api_v1.go @@ -0,0 +1,335 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/elfoundation/hatch/internal/store" + "github.com/go-chi/chi/v5" +) + +// RegisterV1Routes mounts the JSON API v1 routes on the given router. +func (h *Handler) RegisterV1Routes(r chi.Router) { + r.Route("/v1", func(r chi.Router) { + r.Route("/endpoints/{endpointID}", func(r chi.Router) { + // Capture request (JSON body) + r.Post("/requests", h.v1CaptureRequest) + // List or search requests + r.Get("/requests", h.v1ListRequests) + // Replay a specific request + r.Post("/requests/{requestID}/replay", h.v1ReplayRequest) + // Mock configuration + r.Put("/mock", h.v1SetMock) + // OpenAPI spec + r.Get("/openapi.json", h.v1OpenAPI) + }) + }) +} + +// v1CaptureRequest handles POST /v1/endpoints/{endpointID}/requests +func (h *Handler) v1CaptureRequest(w http.ResponseWriter, r *http.Request) { + endpointID := chi.URLParam(r, "endpointID") + if endpointID == "" { + 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, endpointID); err != nil { + h.Repo.CreateEndpoint(ctx, endpointID) + } + + // Parse JSON body representing a request. + var incoming struct { + Method string `json:"method"` + Path string `json:"path"` + Headers map[string]string `json:"headers"` + Query string `json:"query"` + Body string `json:"body"` + } + if err := json.NewDecoder(r.Body).Decode(&incoming); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return + } + if incoming.Method == "" { + incoming.Method = "POST" // default + } + if incoming.Path == "" { + incoming.Path = "/" + } + + // Convert headers map to JSON string. + headersJSON := "{}" + if incoming.Headers != nil { + b, _ := json.Marshal(incoming.Headers) + headersJSON = string(b) + } + + // Store the request. + req := &store.Request{ + Method: incoming.Method, + Path: incoming.Path, + Headers: headersJSON, + Query: incoming.Query, + Body: []byte(incoming.Body), + } + if err := h.Repo.AppendRequest(ctx, endpointID, req); err != nil { + writeError(w, http.StatusInternalServerError, "failed to store request") + return + } + + // Broadcast via SSE. + broadcastRequest(endpointID, req) + + // Return the created request (with ID and timestamp). + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(req) +} + +// v1ListRequests handles GET /v1/endpoints/{endpointID}/requests +func (h *Handler) v1ListRequests(w http.ResponseWriter, r *http.Request) { + endpointID := chi.URLParam(r, "endpointID") + if endpointID == "" { + writeError(w, http.StatusBadRequest, "missing endpoint ID") + return + } + ctx := r.Context() + + // Ensure endpoint exists (or create). + if _, err := h.Repo.GetEndpoint(ctx, endpointID); err != nil { + h.Repo.CreateEndpoint(ctx, endpointID) + } + + // Parse query parameters. + limitStr := r.URL.Query().Get("limit") + limit := 100 + if limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + } + query := r.URL.Query().Get("q") + + var requests []*store.Request + var err error + if query != "" { + requests, err = h.Repo.SearchRequests(ctx, endpointID, query, limit) + } else { + requests, err = h.Repo.ListRequests(ctx, endpointID, limit) + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list requests") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(requests) +} + +// v1ReplayRequest handles POST /v1/endpoints/{endpointID}/requests/{requestID}/replay +func (h *Handler) v1ReplayRequest(w http.ResponseWriter, r *http.Request) { + // Reuse the existing replay logic but with JSON response. + // We'll just call the same handler as the HTML endpoint. + HandleReplay(h.Repo)(w, r) +} + +// v1SetMock handles PUT /v1/endpoints/{endpointID}/mock +func (h *Handler) v1SetMock(w http.ResponseWriter, r *http.Request) { + // Reuse the existing mock handler. + HandleMock(h.Repo)(w, r) +} + +// v1OpenAPI returns the OpenAPI 3.1 specification for the v1 API. +func (h *Handler) v1OpenAPI(w http.ResponseWriter, r *http.Request) { + spec := map[string]interface{}{ + "openapi": "3.1.0", + "info": map[string]interface{}{ + "title": "Hatch API", + "version": "v1", + "description": "JSON API for Hatch webhook capture, inspection, replay, and mocking.", + }, + "servers": []map[string]string{ + {"url": "/"}, + }, + "paths": map[string]interface{}{ + "/v1/endpoints/{endpointID}/requests": map[string]interface{}{ + "post": map[string]interface{}{ + "summary": "Capture a request", + "operationId": "captureRequest", + "parameters": []map[string]interface{}{ + {"name": "endpointID", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}}, + }, + "requestBody": map[string]interface{}{ + "required": true, + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "$ref": "#/components/schemas/IncomingRequest", + }, + }, + }, + }, + "responses": map[string]interface{}{ + "201": map[string]interface{}{ + "description": "Request captured", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "$ref": "#/components/schemas/StoredRequest", + }, + }, + }, + }, + }, + }, + "get": map[string]interface{}{ + "summary": "List or search requests", + "operationId": "listRequests", + "parameters": []map[string]interface{}{ + {"name": "endpointID", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}}, + {"name": "limit", "in": "query", "schema": map[string]interface{}{"type": "integer", "default": 100}}, + {"name": "q", "in": "query", "schema": map[string]interface{}{"type": "string"}, "description": "Full-text search query"}, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "List of requests", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "$ref": "#/components/schemas/StoredRequest", + }, + }, + }, + }, + }, + }, + }, + }, + "/v1/endpoints/{endpointID}/requests/{requestID}/replay": map[string]interface{}{ + "post": map[string]interface{}{ + "summary": "Replay a captured request", + "operationId": "replayRequest", + "parameters": []map[string]interface{}{ + {"name": "endpointID", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}}, + {"name": "requestID", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}}, + }, + "requestBody": map[string]interface{}{ + "required": true, + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "$ref": "#/components/schemas/ReplayRequest", + }, + }, + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Replay result", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "$ref": "#/components/schemas/ReplayResponse", + }, + }, + }, + }, + }, + }, + }, + "/v1/endpoints/{endpointID}/mock": map[string]interface{}{ + "put": map[string]interface{}{ + "summary": "Set mock configuration", + "operationId": "setMock", + "parameters": []map[string]interface{}{ + {"name": "endpointID", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}}, + }, + "requestBody": map[string]interface{}{ + "required": true, + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "$ref": "#/components/schemas/MockConfig", + }, + }, + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Mock set", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "status": map[string]interface{}{"type": "string"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "components": map[string]interface{}{ + "schemas": map[string]interface{}{ + "IncomingRequest": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "method": map[string]interface{}{"type": "string", "default": "POST"}, + "path": map[string]interface{}{"type": "string", "default": "/"}, + "headers": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "string"}}, + "query": map[string]interface{}{"type": "string"}, + "body": map[string]interface{}{"type": "string"}, + }, + }, + "StoredRequest": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string"}, + "endpoint_id": map[string]interface{}{"type": "string"}, + "method": map[string]interface{}{"type": "string"}, + "path": map[string]interface{}{"type": "string"}, + "headers": map[string]interface{}{"type": "string"}, + "query": map[string]interface{}{"type": "string"}, + "body": map[string]interface{}{"type": "string", "format": "byte"}, + "created_at": map[string]interface{}{"type": "string", "format": "date-time"}, + }, + }, + "ReplayRequest": map[string]interface{}{ + "type": "object", + "required": []string{"target_url"}, + "properties": map[string]interface{}{ + "target_url": map[string]interface{}{"type": "string", "format": "uri"}, + }, + }, + "ReplayResponse": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "status": map[string]interface{}{"type": "integer"}, + "headers": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "string"}}, + "body": map[string]interface{}{"type": "string"}, + "error": map[string]interface{}{"type": "string"}, + }, + }, + "MockConfig": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "status": map[string]interface{}{"type": "integer"}, + "headers": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "string"}}, + "body": map[string]interface{}{"type": "string", "format": "byte"}, + }, + }, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(spec) +} \ No newline at end of file diff --git a/internal/handler/api_v1_test.go b/internal/handler/api_v1_test.go new file mode 100644 index 00000000..6978f080 --- /dev/null +++ b/internal/handler/api_v1_test.go @@ -0,0 +1,182 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/elfoundation/hatch/internal/store" +) + +func TestV1CaptureRequest(t *testing.T) { + repo := newFakeRepo() + r := testRouter(repo) + + body := `{"method":"POST","path":"/test","headers":{"X-Custom":"value"},"query":"foo=bar","body":"hello"}` + req := httptest.NewRequest(http.MethodPost, "/v1/endpoints/test-ep/requests", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201 Created, got %d: %s", w.Code, w.Body.String()) + } + // Verify response JSON contains the request. + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON response: %v", err) + } + if resp["method"] != "POST" { + t.Errorf("expected method POST, got %v", resp["method"]) + } + if resp["path"] != "/test" { + t.Errorf("expected path /test, got %v", resp["path"]) + } + // Verify request stored in repo. + if len(repo.requests) != 1 { + t.Fatalf("expected 1 request stored, got %d", len(repo.requests)) + } + if repo.requests[0].Headers != `{"X-Custom":"value"}` { + t.Errorf("expected headers JSON, got %q", repo.requests[0].Headers) + } +} + +func TestV1ListRequests(t *testing.T) { + repo := newFakeRepo() + repo.CreateEndpoint(nil, "list-ep") + // Add a few requests. + repo.AppendRequest(nil, "list-ep", &store.Request{Method: "GET", Path: "/a"}) + repo.AppendRequest(nil, "list-ep", &store.Request{Method: "POST", Path: "/b"}) + repo.AppendRequest(nil, "list-ep", &store.Request{Method: "PUT", Path: "/c"}) + + r := testRouter(repo) + req := httptest.NewRequest(http.MethodGet, "/v1/endpoints/list-ep/requests", nil) + 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()) + } + var list []map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("invalid JSON array: %v", err) + } + if len(list) != 3 { + t.Errorf("expected 3 requests, got %d", len(list)) + } + // Verify all methods present. + methods := make(map[string]bool) + for _, r := range list { + methods[r["method"].(string)] = true + } + if !methods["GET"] || !methods["POST"] || !methods["PUT"] { + t.Errorf("expected GET, POST, PUT, got %v", methods) + } +} + +func TestV1SearchRequests(t *testing.T) { + repo := newFakeRepo() + repo.CreateEndpoint(nil, "search-ep") + repo.AppendRequest(nil, "search-ep", &store.Request{Method: "GET", Path: "/users", Headers: `{"Accept":"json"}`}) + repo.AppendRequest(nil, "search-ep", &store.Request{Method: "POST", Path: "/orders", Body: []byte(`{"item":"apple"}`)}) + repo.AppendRequest(nil, "search-ep", &store.Request{Method: "DELETE", Path: "/users/123"}) + + r := testRouter(repo) + + // Search for "users". + req := httptest.NewRequest(http.MethodGet, "/v1/endpoints/search-ep/requests?q=users", nil) + 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()) + } + var list []map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("invalid JSON array: %v", err) + } + if len(list) != 2 { + t.Errorf("expected 2 requests matching 'users', got %d", len(list)) + } + // Verify the matched methods. + methods := make(map[string]bool) + for _, r := range list { + methods[r["method"].(string)] = true + } + if !methods["GET"] || !methods["DELETE"] { + t.Errorf("expected GET and DELETE, got %v", methods) + } +} + +func TestV1MockSet(t *testing.T) { + repo := newFakeRepo() + repo.CreateEndpoint(nil, "mock-ep") + r := testRouter(repo) + + mockBody := `{"status":201,"headers":{"X-Mock":"true"},"body":"bW9ja2Vk"}` + req := httptest.NewRequest(http.MethodPut, "/v1/endpoints/mock-ep/mock", bytes.NewBufferString(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 mock 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-Mock"] != "true" { + t.Errorf("expected X-Mock header, got %v", mock.Headers) + } +} + +func TestV1OpenAPI(t *testing.T) { + repo := newFakeRepo() + r := testRouter(repo) + req := httptest.NewRequest(http.MethodGet, "/v1/endpoints/test/openapi.json", nil) + 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()) + } + ct := w.Header().Get("Content-Type") + if ct != "application/json" { + t.Errorf("expected application/json, got %q", ct) + } + var spec map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if spec["openapi"] != "3.1.0" { + t.Errorf("expected openapi 3.1.0, got %v", spec["openapi"]) + } + if spec["info"].(map[string]interface{})["title"] != "Hatch API" { + t.Errorf("expected title 'Hatch API', got %v", spec["info"]) + } + // Verify paths exist. + paths, ok := spec["paths"].(map[string]interface{}) + if !ok { + t.Fatal("missing paths") + } + if _, ok := paths["/v1/endpoints/{endpointID}/requests"]; !ok { + t.Error("missing capture/list path") + } + if _, ok := paths["/v1/endpoints/{endpointID}/requests/{requestID}/replay"]; !ok { + t.Error("missing replay path") + } + if _, ok := paths["/v1/endpoints/{endpointID}/mock"]; !ok { + t.Error("missing mock path") + } +} + +// Import store package for the fakeRepo usage (already imported via handler_test.go) +// We just need to ensure the test compiles. +var _ = store.Request{} \ No newline at end of file diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 40b90ecf..d3cf1051 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -29,6 +29,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) { // Health check. r.Get("/healthz", Healthz) + // JSON API v1 routes. + h.RegisterV1Routes(r) + // Inspect page: server-rendered request list. r.Get("/e/{endpointID}", HandleInspect(h.Repo)) diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index d11f69e4..5bbe533c 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -57,6 +57,25 @@ func (f *fakeRepo) GetRequest(_ context.Context, id string) (*store.Request, err func (f *fakeRepo) ListRequests(_ context.Context, _ string, _ int) ([]*store.Request, error) { return f.requests, nil } +func (f *fakeRepo) SearchRequests(_ context.Context, _ string, query string, limit int) ([]*store.Request, error) { + if query == "" { + return f.requests, nil + } + var matched []*store.Request + for _, r := range f.requests { + if strings.Contains(r.Method, query) || + strings.Contains(r.Path, query) || + strings.Contains(r.Headers, query) || + strings.Contains(r.Query, query) || + strings.Contains(string(r.Body), query) { + matched = append(matched, r) + if limit > 0 && len(matched) >= limit { + break + } + } + } + return matched, nil +} func (f *fakeRepo) GetMock(_ context.Context, endpointID string) (*store.MockConfig, error) { m, ok := f.mocks[endpointID] if !ok { diff --git a/internal/store/models.go b/internal/store/models.go index 6f8ef2e6..6db5a25f 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -34,6 +34,7 @@ type Repository interface { 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) + SearchRequests(ctx context.Context, endpointID string, query 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/sqlite_repo.go b/internal/store/sqlite_repo.go index f7371354..18d49dac 100644 --- a/internal/store/sqlite_repo.go +++ b/internal/store/sqlite_repo.go @@ -116,6 +116,27 @@ func (r *sqliteRepo) SetMock(ctx context.Context, mock *MockConfig) error { return nil } +func (r *sqliteRepo) SearchRequests(ctx context.Context, endpointID string, query string, limit int) ([]*Request, error) { + if limit <= 0 { + limit = 50 + } + // Simple LIKE search across text fields. Convert body to text. + rows, err := r.db.QueryContext(ctx, `SELECT id, endpoint_id, method, path, headers, query, body, created_at FROM requests WHERE endpoint_id = ? AND (method LIKE ? OR path LIKE ? OR headers LIKE ? OR query LIKE ? OR CAST(body AS TEXT) LIKE ?) ORDER BY created_at DESC LIMIT ?`, endpointID, "%"+query+"%", "%"+query+"%", "%"+query+"%", "%"+query+"%", "%"+query+"%", limit) + if err != nil { + return nil, fmt.Errorf("store: search 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) Close() error { return r.db.Close() } func utcNow() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") }