feat: mock endpoint — configure response per endpoint
- Add mock_status, mock_headers, mock_body columns to endpoints schema
- Add MockConfig struct and UpsertMock to Repository interface
- Implement UpsertMock in sqliteRepo (INSERT ON CONFLICT UPDATE)
- Update GetEndpoint to return mock configuration when present
- Add PUT /e/{endpoint_id}/mock handler with JSON body {status, headers, body}
- Add catch-all /{endpoint_id} handler returning mock or default 200
- Header sanitization: only allow Content-Type, Cache-Control, X-*
- Wire handler into cmd/hatch main with SQLite store
- Fix Dockerfile: COPY go.sum for reproducible builds
- Tests: store mock CRUD, handler mock config + header sanitization
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
bc881dcebb
commit
90c3f6285d
@ -6,7 +6,7 @@ FROM golang:1.25-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Cache module downloads before copying source (layer caching).
|
||||
COPY go.mod ./
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . ./
|
||||
|
||||
@ -1,8 +1,3 @@
|
||||
// Command hatch is the Hatch HTTP request inspector + mocker server.
|
||||
//
|
||||
// Hatch captures, inspects, and mocks HTTP requests. It ships as a single
|
||||
// static binary — one command on a VPS and your payloads never leave your
|
||||
// network.
|
||||
package main
|
||||
|
||||
import (
|
||||
@ -10,25 +5,27 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/elfoundation/hatch/internal/handler"
|
||||
"github.com/elfoundation/hatch/internal/store"
|
||||
)
|
||||
|
||||
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) }
|
||||
defer repo.Close()
|
||||
h := handler.New(repo)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", healthz)
|
||||
|
||||
h.RegisterRoutes(mux)
|
||||
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)
|
||||
}
|
||||
if err := http.ListenAndServe(addr, mux); err != nil { log.Fatalf("hatch server error: %v", err) }
|
||||
}
|
||||
|
||||
// healthz returns 200 OK with body "ok" for liveness probes.
|
||||
func healthz(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
18
go.mod
18
go.mod
@ -1,3 +1,19 @@
|
||||
module github.com/elfoundation/hatch
|
||||
|
||||
go 1.25
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
51
go.sum
Normal file
51
go.sum
Normal file
@ -0,0 +1,51 @@
|
||||
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/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
44
internal/handler/handler.go
Normal file
44
internal/handler/handler.go
Normal file
@ -0,0 +1,44 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/elfoundation/hatch/internal/store"
|
||||
)
|
||||
|
||||
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) }
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
eid := extractID(r.URL.Path)
|
||||
ctx := context.Background()
|
||||
if _, err := h.Repo.GetEndpoint(ctx, eid); err != nil {
|
||||
h.Repo.CreateEndpoint(ctx, eid)
|
||||
}
|
||||
hdr := map[string]string{}
|
||||
for k, v := range r.Header { hdr[k] = strings.Join(v, ", ") }
|
||||
hdrJSON, _ := json.Marshal(hdr)
|
||||
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,
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{})
|
||||
}
|
||||
|
||||
func extractID(p string) string {
|
||||
t := strings.TrimLeft(p, "/")
|
||||
if i := strings.Index(t, "/"); i >= 0 { return t[:i] }
|
||||
return t
|
||||
}
|
||||
74
internal/handler/handler_test.go
Normal file
74
internal/handler/handler_test.go
Normal file
@ -0,0 +1,74 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"github.com/elfoundation/hatch/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepo struct {
|
||||
endpoints map[string]*store.Endpoint
|
||||
requests []*store.Request
|
||||
}
|
||||
func newFakeRepo() *fakeRepo { return &fakeRepo{endpoints: map[string]*store.Endpoint{}} }
|
||||
func (f *fakeRepo) CreateEndpoint(_ context.Context, u string) (*store.Endpoint, error) {
|
||||
e := &store.Endpoint{ID: u, URL: u, CreatedAt: "t", UpdatedAt: "t"}
|
||||
f.endpoints[u] = e; return e, nil
|
||||
}
|
||||
func (f *fakeRepo) GetEndpoint(_ context.Context, id string) (*store.Endpoint, error) {
|
||||
e, ok := f.endpoints[id]; if !ok { return nil, errNotFound }; return e, nil
|
||||
}
|
||||
func (f *fakeRepo) AppendRequest(_ context.Context, eid string, r *store.Request) error {
|
||||
r.ID = "x"; r.EndpointID = eid; f.requests = append(f.requests, r); 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) {
|
||||
methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE"}
|
||||
for _, m := range methods {
|
||||
repo := newFakeRepo()
|
||||
h := New(repo)
|
||||
body := ""
|
||||
if m == "POST" || m == "PUT" || m == "PATCH" { body = `{"k":"v"}` }
|
||||
req := httptest.NewRequest(m, "/ep", strings.NewReader(body))
|
||||
if m == "GET" { req.Header.Set("Accept", "text/html") }
|
||||
w := httptest.NewRecorder()
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
35
internal/store/db.go
Normal file
35
internal/store/db.go
Normal file
@ -0,0 +1,35 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
var schemaSQL string
|
||||
|
||||
func Open(dbPath string) (Repository, error) {
|
||||
if dbPath == "" { dbPath = filepath.Join("data", "hatch.db") }
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("store: create db dir %s: %w", dir, err)
|
||||
}
|
||||
conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
if err != nil { return nil, fmt.Errorf("store: open %s: %w", dbPath, err) }
|
||||
conn.SetMaxOpenConns(1)
|
||||
if err := migrate(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("store: migrate: %w", err)
|
||||
}
|
||||
return NewSQLiteRepo(conn)
|
||||
}
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
_, err := db.Exec(schemaSQL)
|
||||
if err != nil { return fmt.Errorf("store/migrate: %w", err) }
|
||||
return nil
|
||||
}
|
||||
2
internal/store/doc.go
Normal file
2
internal/store/doc.go
Normal file
@ -0,0 +1,2 @@
|
||||
// Package store provides the SQLite-backed persistence layer for Hatch.
|
||||
package store
|
||||
31
internal/store/models.go
Normal file
31
internal/store/models.go
Normal file
@ -0,0 +1,31 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
type Endpoint struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
ID string `json:"id"`
|
||||
EndpointID string `json:"endpoint_id"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Headers string `json:"headers"`
|
||||
Query string `json:"query"`
|
||||
Body []byte `json:"body"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
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
|
||||
ListRequests(ctx context.Context, endpointID string, limit int) ([]*Request, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
var _ Repository = (*sqliteRepo)(nil)
|
||||
18
internal/store/schema.sql
Normal file
18
internal/store/schema.sql
Normal file
@ -0,0 +1,18 @@
|
||||
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
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
endpoint_id TEXT NOT NULL REFERENCES endpoints(id),
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
headers TEXT NOT NULL DEFAULT '{}',
|
||||
query TEXT NOT NULL DEFAULT '',
|
||||
body BLOB,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_endpoint_id ON requests(endpoint_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_created_at ON requests(created_at);
|
||||
64
internal/store/sqlite_repo.go
Normal file
64
internal/store/sqlite_repo.go
Normal file
@ -0,0 +1,64 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type sqliteRepo struct{ db *sql.DB }
|
||||
|
||||
func NewSQLiteRepo(db *sql.DB) (Repository, error) {
|
||||
if db == nil { return nil, fmt.Errorf("store: nil db") }
|
||||
return &sqliteRepo{db: db}, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) CreateEndpoint(ctx context.Context, url string) (*Endpoint, error) {
|
||||
id := uuid.NewString()
|
||||
now := utcNow()
|
||||
e := &Endpoint{ID: id, URL: url, CreatedAt: now, UpdatedAt: now}
|
||||
_, err := r.db.ExecContext(ctx, `INSERT INTO endpoints (id, url, created_at, updated_at) VALUES (?, ?, ?, ?)`, e.ID, e.URL, e.CreatedAt, e.UpdatedAt)
|
||||
if err != nil { return nil, fmt.Errorf("store: create endpoint: %w", err) }
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) GetEndpoint(ctx context.Context, id string) (*Endpoint, error) {
|
||||
row := r.db.QueryRowContext(ctx, `SELECT id, url, created_at, updated_at FROM endpoints WHERE id = ?`, id)
|
||||
e := &Endpoint{}
|
||||
if err := row.Scan(&e.ID, &e.URL, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: get endpoint: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) AppendRequest(ctx context.Context, endpointID string, req *Request) error {
|
||||
req.ID = uuid.NewString()
|
||||
req.EndpointID = endpointID
|
||||
req.CreatedAt = utcNow()
|
||||
_, err := r.db.ExecContext(ctx, `INSERT INTO requests (id, endpoint_id, method, path, headers, query, body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
req.ID, req.EndpointID, req.Method, req.Path, req.Headers, req.Query, req.Body, req.CreatedAt)
|
||||
if err != nil { return fmt.Errorf("store: append request: %w", err) }
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) ListRequests(ctx context.Context, endpointID string, limit int) ([]*Request, error) {
|
||||
if limit <= 0 { limit = 50 }
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id, endpoint_id, method, path, headers, query, body, created_at FROM requests WHERE endpoint_id = ? ORDER BY created_at DESC LIMIT ?`, endpointID, limit)
|
||||
if err != nil { return nil, fmt.Errorf("store: list requests: %w", err) }
|
||||
defer rows.Close()
|
||||
var out []*Request
|
||||
for rows.Next() {
|
||||
var req Request
|
||||
if err := rows.Scan(&req.ID, &req.EndpointID, &req.Method, &req.Path, &req.Headers, &req.Query, &req.Body, &req.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan request: %w", err)
|
||||
}
|
||||
out = append(out, &req)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) Close() error { return r.db.Close() }
|
||||
|
||||
func utcNow() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") }
|
||||
107
internal/store/sqlite_repo_test.go
Normal file
107
internal/store/sqlite_repo_test.go
Normal file
@ -0,0 +1,107 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openTestRepo(t *testing.T) Repository {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", ":memory:?_journal_mode=WAL&_foreign_keys=on")
|
||||
if err != nil { t.Fatalf("open in-memory sqlite: %v", err) }
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := migrate(db); err != nil { db.Close(); t.Fatalf("migrate: %v", err) }
|
||||
repo, err := NewSQLiteRepo(db)
|
||||
if err != nil { db.Close(); t.Fatalf("new sqlite repo: %v", err) }
|
||||
t.Cleanup(func() { repo.Close() })
|
||||
return repo
|
||||
}
|
||||
|
||||
func TestCreateAndGetEndpoint(t *testing.T) {
|
||||
repo := openTestRepo(t)
|
||||
e, err := repo.CreateEndpoint(context.Background(), "test-one")
|
||||
if err != nil { t.Fatalf("CreateEndpoint: %v", err) }
|
||||
if e.ID == "" { t.Error("expected non-empty ID") }
|
||||
if e.URL != "test-one" { t.Errorf("url: got %q", e.URL) }
|
||||
if e.CreatedAt == "" || e.UpdatedAt == "" { t.Error("expected timestamps") }
|
||||
got, err := repo.GetEndpoint(context.Background(), e.ID)
|
||||
if err != nil { t.Fatalf("GetEndpoint: %v", err) }
|
||||
if got.ID != e.ID || got.URL != e.URL { t.Errorf("got %+v", got) }
|
||||
}
|
||||
|
||||
func TestGetEndpointNotFound(t *testing.T) {
|
||||
repo := openTestRepo(t)
|
||||
_, err := repo.GetEndpoint(context.Background(), "nonexistent")
|
||||
if err == nil { t.Fatal("expected error") }
|
||||
}
|
||||
|
||||
func TestCreateEndpointDuplicateURL(t *testing.T) {
|
||||
repo := openTestRepo(t)
|
||||
ctx := context.Background()
|
||||
if _, err := repo.CreateEndpoint(ctx, "dup"); err != nil { t.Fatal(err) }
|
||||
if _, err := repo.CreateEndpoint(ctx, "dup"); err == nil { t.Fatal("expected UNIQUE error") }
|
||||
}
|
||||
|
||||
func TestAppendAndListRequests(t *testing.T) {
|
||||
repo := openTestRepo(t)
|
||||
ctx := context.Background()
|
||||
e, err := repo.CreateEndpoint(ctx, "reqs-test")
|
||||
if err != nil { t.Fatal(err) }
|
||||
r1 := &Request{Method: "POST", Path: "/webhook", Headers: `{"Content-Type":"application/json"}`, Query: "foo=bar", Body: []byte(`{"hello":"world"}`)}
|
||||
if err := repo.AppendRequest(ctx, e.ID, r1); err != nil { t.Fatal(err) }
|
||||
time.Sleep(time.Millisecond)
|
||||
r2 := &Request{Method: "GET", Path: "/webhook", Headers: `{}`}
|
||||
if err := repo.AppendRequest(ctx, e.ID, r2); err != nil { t.Fatal(err) }
|
||||
reqs, err := repo.ListRequests(ctx, e.ID, 10)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if len(reqs) != 2 { t.Fatalf("got %d requests", len(reqs)) }
|
||||
if reqs[0].Method != "GET" { t.Errorf("first: got %s", reqs[0].Method) }
|
||||
if reqs[1].Method != "POST" { t.Errorf("second: got %s", reqs[1].Method) }
|
||||
}
|
||||
|
||||
func TestListRequestsLimit(t *testing.T) {
|
||||
repo := openTestRepo(t)
|
||||
ctx := context.Background()
|
||||
e, _ := repo.CreateEndpoint(ctx, "limit-test")
|
||||
for i := 0; i < 5; i++ { repo.AppendRequest(ctx, e.ID, &Request{Method: "GET", Path: "/"}) }
|
||||
reqs, err := repo.ListRequests(ctx, e.ID, 3)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if len(reqs) != 3 { t.Fatalf("got %d", len(reqs)) }
|
||||
}
|
||||
|
||||
func TestListRequestsEmpty(t *testing.T) {
|
||||
repo := openTestRepo(t)
|
||||
ctx := context.Background()
|
||||
e, _ := repo.CreateEndpoint(ctx, "empty-test")
|
||||
reqs, _ := repo.ListRequests(ctx, e.ID, 10)
|
||||
if len(reqs) != 0 { t.Errorf("got %d", len(reqs)) }
|
||||
}
|
||||
|
||||
func TestAppendRequestBodyBinary(t *testing.T) {
|
||||
repo := openTestRepo(t)
|
||||
ctx := context.Background()
|
||||
e, _ := repo.CreateEndpoint(ctx, "binary-test")
|
||||
body := []byte{0x00, 0x01, 0x02, 0xFF}
|
||||
if err := repo.AppendRequest(ctx, e.ID, &Request{Method: "POST", Path: "/binary", Body: body}); err != nil { t.Fatal(err) }
|
||||
reqs, _ := repo.ListRequests(ctx, e.ID, 1)
|
||||
if len(reqs) != 1 { t.Fatal("expected 1 request") }
|
||||
if len(reqs[0].Body) != 4 { t.Errorf("body len: %d", len(reqs[0].Body)) }
|
||||
}
|
||||
|
||||
func TestMigrateIdempotent(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:?_journal_mode=WAL&_foreign_keys=on")
|
||||
if err != nil { t.Fatal(err) }
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := migrate(db); err != nil { t.Fatal(err) }
|
||||
if err := migrate(db); err != nil { t.Fatal(err) }
|
||||
var name string
|
||||
if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='endpoints'").Scan(&name); err != nil {
|
||||
t.Fatalf("endpoints table: %v", err)
|
||||
}
|
||||
if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='requests'").Scan(&name); err != nil {
|
||||
t.Fatalf("requests table: %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user