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 <noreply@paperclip.ing>
This commit is contained in:
parent
abf70715ad
commit
dc0394cada
335
internal/handler/api_v1.go
Normal file
335
internal/handler/api_v1.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
182
internal/handler/api_v1_test.go
Normal file
182
internal/handler/api_v1_test.go
Normal file
@ -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{}
|
||||||
@ -29,6 +29,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
// Health check.
|
// Health check.
|
||||||
r.Get("/healthz", Healthz)
|
r.Get("/healthz", Healthz)
|
||||||
|
|
||||||
|
// JSON API v1 routes.
|
||||||
|
h.RegisterV1Routes(r)
|
||||||
|
|
||||||
// Inspect page: server-rendered request list.
|
// Inspect page: server-rendered request list.
|
||||||
r.Get("/e/{endpointID}", HandleInspect(h.Repo))
|
r.Get("/e/{endpointID}", HandleInspect(h.Repo))
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
func (f *fakeRepo) ListRequests(_ context.Context, _ string, _ int) ([]*store.Request, error) {
|
||||||
return f.requests, nil
|
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) {
|
func (f *fakeRepo) GetMock(_ context.Context, endpointID string) (*store.MockConfig, error) {
|
||||||
m, ok := f.mocks[endpointID]
|
m, ok := f.mocks[endpointID]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@ -34,6 +34,7 @@ type Repository interface {
|
|||||||
AppendRequest(ctx context.Context, endpointID string, req *Request) error
|
AppendRequest(ctx context.Context, endpointID string, req *Request) error
|
||||||
GetRequest(ctx context.Context, id string) (*Request, error)
|
GetRequest(ctx context.Context, id string) (*Request, error)
|
||||||
ListRequests(ctx context.Context, endpointID string, limit int) ([]*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)
|
GetMock(ctx context.Context, endpointID string) (*MockConfig, error)
|
||||||
SetMock(ctx context.Context, mock *MockConfig) error
|
SetMock(ctx context.Context, mock *MockConfig) error
|
||||||
Close() error
|
Close() error
|
||||||
|
|||||||
@ -116,6 +116,27 @@ func (r *sqliteRepo) SetMock(ctx context.Context, mock *MockConfig) error {
|
|||||||
return nil
|
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 (r *sqliteRepo) Close() error { return r.db.Close() }
|
||||||
|
|
||||||
func utcNow() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") }
|
func utcNow() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user