From 10689b9611acf35919f8f832edd30edd73925478 Mon Sep 17 00:00:00 2001 From: Martin Najemi Date: Sun, 31 May 2026 13:56:05 +0100 Subject: [PATCH] chore: Add multiple exchange buckets Risk: low --- CHANGELOG.md | 12 ++++ README.md | 62 ++++++++++++++---- VERSION | 2 +- internal/record/record.go | 134 +++++++++++++++++++++++++++++++------- 4 files changed, 173 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a8531..58c8619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.0] - 2026-05-31 + +### Added +- Record mode now stores exchanges in named **buckets** instead of a single shared pool, enabling per-test (not just per-spec) recording without stash/re-import +- `X-GOODMOCK-BUCKET` request header routes an individual proxied (recorded) request to a specific bucket; the header is stripped before the request is proxied upstream and is never stored in the mapping +- `POST /__admin/mappings/bucket/{name}` (or JSON body `{"bucket": "..."}`) sets the current cursor bucket used when no bucket is given explicitly +- `/__admin/recordings/snapshot`, `/__admin/reset`, `/__admin/mappings/reset`, and `DELETE /__admin/requests` now accept an optional `/{bucket}` path suffix + +### Changed +- Bucket resolution precedence for admin operations: explicit `/{bucket}` path param → current cursor bucket. The `X-GOODMOCK-BUCKET` header is only honored on proxied requests, never on admin endpoints +- When no bucket is specified, record-mode admin operations now act on the current cursor bucket (defaulting to `default`) rather than a single global pool + ## [0.11.0] - 2026-04-02 ### Changed diff --git a/README.md b/README.md index 8636c46..2041f42 100644 --- a/README.md +++ b/README.md @@ -111,20 +111,23 @@ Each file should contain a `mappings` array: GoodMock exposes a subset of the WireMock admin API under `/__admin`: -| Method | Endpoint | Description | -|----------|--------------------------------|----------------------------------------| -| `GET` | `/__admin` | Health check | -| `GET` | `/__admin/health` | Health check | -| `GET` | `/__admin/mappings` | List all loaded mappings | -| `POST` | `/__admin/mappings` | Add a single mapping | -| `DELETE` | `/__admin/mappings` | Delete all mappings | -| `POST` | `/__admin/mappings/import` | Import a batch of mappings | -| `POST` | `/__admin/mappings/reset` | Reset all mappings | -| `POST` | `/__admin/reset` | Reset all mappings | -| `POST` | `/__admin/settings` | Acknowledge settings (no-op) | -| `POST` | `/__admin/scenarios/reset` | Reset scenarios (no-op) | -| `DELETE` | `/__admin/requests` | Clear request log / recordings | -| `POST` | `/__admin/recordings/snapshot` | Export recorded mappings (record mode) | +| Method | Endpoint | Description | +|----------|-----------------------------------|------------------------------------------------| +| `GET` | `/__admin` | Health check | +| `GET` | `/__admin/health` | Health check | +| `GET` | `/__admin/mappings` | List all loaded mappings | +| `POST` | `/__admin/mappings` | Add a single mapping | +| `DELETE` | `/__admin/mappings` | Delete all mappings | +| `POST` | `/__admin/mappings/import` | Import a batch of mappings | +| `POST` | `/__admin/mappings/reset` | Reset all mappings | +| `POST` | `/__admin/reset` | Reset all mappings | +| `POST` | `/__admin/settings` | Acknowledge settings (no-op) | +| `POST` | `/__admin/scenarios/reset` | Reset scenarios (no-op) | +| `DELETE` | `/__admin/requests` | Clear request log / recordings | +| `POST` | `/__admin/recordings/snapshot` | Export recorded mappings (record mode) | +| `POST` | `/__admin/mappings/bucket/{name}` | Set the current recording bucket (record mode) | + +In record mode, the recording endpoints (`/__admin/recordings/snapshot`, `/__admin/reset`, `/__admin/mappings/reset`, `DELETE /__admin/requests`) accept an optional `/{bucket}` path suffix to target a specific bucket — see [Recording Buckets](#recording-buckets). ### Adding a Mapping at Runtime @@ -169,6 +172,37 @@ The snapshot endpoint supports: - `repeatsAsScenarios` — when `true`, creates scenario-based mappings for repeated URLs - `persist` — accepted but ignored (mappings are always returned in the response) +### Recording Buckets + +Record mode stores exchanges in named **buckets** rather than a single shared pool. This lets you carve a single recording session into independent groups — for example, recording mappings per individual test instead of per spec file. + +There are two ways to choose a bucket: + +- **Current cursor bucket** — set with `POST /__admin/mappings/bucket/{name}` (or a JSON body `{"bucket": "..."}`). All subsequent recording operations that don't name a bucket use this one. Defaults to `default`. +- **Per-request override** — send the `X-GOODMOCK-BUCKET` header on a proxied request to record that single exchange into the named bucket, regardless of the current cursor. The header is stripped before the request is forwarded upstream and is never stored in the mapping. + +The recording admin endpoints take an optional `/{bucket}` path suffix; when omitted they act on the current cursor bucket: + +```bash +# Route to bucket "test-2" for the next group of requests +curl -X POST http://localhost:8080/__admin/mappings/bucket/test-2 + +# Snapshot (and drain) a specific bucket +curl -X POST http://localhost:8080/__admin/recordings/snapshot/test-2 \ + -H "Content-Type: application/json" \ + -d '{"persist": false, "repeatsAsScenarios": false}' + +# Reset (clear) a specific bucket +curl -X POST http://localhost:8080/__admin/mappings/reset/test-2 + +# Record a one-off request into bucket "test-2" via header +curl http://localhost:8080/api/users -H "X-GOODMOCK-BUCKET: test-2" +``` + +> **Note:** `X-GOODMOCK-BUCKET` is only honored on proxied (recorded) requests. Admin endpoints resolve the bucket from the `/{bucket}` path suffix, falling back to the current cursor bucket — they ignore the header. +> +> Recording is **serial** — a single recording session at a time. Buckets isolate groups *within* that session; they do not make concurrent recording safe. + ## Proxy Mode In proxy mode, GoodMock forwards all requests to the upstream backend (`PROXY_HOST`) and returns responses to the client — without recording any exchanges. The same header transformations and response filtering (gzip decompression, `X-GDC*`/`Date` stripping) apply as in record mode. diff --git a/VERSION b/VERSION index d9df1bb..d33c3a2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.11.0 +0.12.0 \ No newline at end of file diff --git a/internal/record/record.go b/internal/record/record.go index c674570..de3ab84 100644 --- a/internal/record/record.go +++ b/internal/record/record.go @@ -36,7 +36,8 @@ type RecordedExchange struct { type RecordServer struct { server *types.Server mu sync.Mutex - exchanges []RecordedExchange + exchangeBuckets map[string][]RecordedExchange + currentBucket string upstream string client *fasthttp.Client jsonContentTypes []string @@ -49,7 +50,8 @@ type RecordServer struct { func NewRecordServer(upstream, proxyHost, refererPath string, verbose bool, jsonContentTypes, binaryContentTypes []string, preserveKeyOrder, sortArrayMembers bool) *RecordServer { return &RecordServer{ server: server.NewServer(proxyHost, refererPath, verbose, nil), - exchanges: make([]RecordedExchange, 0), + exchangeBuckets: map[string][]RecordedExchange{}, + currentBucket: "default", upstream: upstream, client: &fasthttp.Client{}, jsonContentTypes: jsonContentTypes, @@ -59,6 +61,52 @@ func NewRecordServer(upstream, proxyHost, refererPath string, verbose bool, json } } +// bucketHeader lets a single proxied request target a specific recording bucket, +// overriding the current cursor bucket. Stripped before the request is proxied upstream. +const bucketHeader = "X-GOODMOCK-BUCKET" + +// currentBucketName returns the current cursor bucket under lock. +func currentBucketName(rs *RecordServer) string { + rs.mu.Lock() + defer rs.mu.Unlock() + return rs.currentBucket +} + +// setCurrentBucket updates the cursor bucket used when no bucket is given explicitly. +func setCurrentBucket(rs *RecordServer, name string) { + rs.mu.Lock() + rs.currentBucket = name + rs.mu.Unlock() +} + +// resolveBucket returns explicit when non-empty, otherwise the current cursor bucket. +func resolveBucket(rs *RecordServer, explicit string) string { + if explicit != "" { + return explicit + } + return currentBucketName(rs) +} + +// isPathOrSub reports whether path equals prefix or is a sub-path (prefix + "/..."). +func isPathOrSub(path, prefix string) bool { + return path == prefix || strings.HasPrefix(path, prefix+"/") +} + +// bucketFromPath returns the trailing segment after prefix, or "" if there is none. +func bucketFromPath(path, prefix string) string { + if strings.HasPrefix(path, prefix+"/") { + return strings.TrimPrefix(path, prefix+"/") + } + return "" +} + +// adminBucket resolves the bucket for an admin op: explicit path param wins, +// otherwise the current cursor bucket. The X-GOODMOCK-BUCKET header is only +// honored on proxied (recorded) requests, never on admin endpoints. +func adminBucket(rs *RecordServer, path, prefix string) string { + return resolveBucket(rs, bucketFromPath(path, prefix)) +} + // handleRecordRequest routes admin requests locally, then proxies+records everything else. func handleRecordRequest(rs *RecordServer, ctx *fasthttp.RequestCtx) { rawURI := string(ctx.RequestURI()) @@ -87,6 +135,11 @@ func handleRecordRequest(rs *RecordServer, ctx *fasthttp.RequestCtx) { // proxyAndRecord forwards the request to upstream, records the exchange, and returns the response. func proxyAndRecord(rs *RecordServer, ctx *fasthttp.RequestCtx) { + // Resolve the target bucket from the X-GOODMOCK-BUCKET header (falling back to the + // current cursor bucket), then strip the header so it never reaches the upstream. + bucket := resolveBucket(rs, string(ctx.Request.Header.Peek(bucketHeader))) + ctx.Request.Header.Del(bucketHeader) + status, respHeaders, body, err := proxy.ProxyRequest(rs.client, rs.upstream, ctx) if err != nil { log.Printf("Proxy error: %v", err) @@ -111,7 +164,7 @@ func proxyAndRecord(rs *RecordServer, ctx *fasthttp.RequestCtx) { } rs.mu.Lock() - rs.exchanges = append(rs.exchanges, exchange) + rs.exchangeBuckets[bucket] = append(rs.exchangeBuckets[bucket], exchange) rs.mu.Unlock() // Send response back to client, filtering headers @@ -143,30 +196,43 @@ func proxyAndRecord(rs *RecordServer, ctx *fasthttp.RequestCtx) { } } -func clearExchanges(rs *RecordServer) { +func clearExchanges(rs *RecordServer, bucket string) { rs.mu.Lock() - rs.exchanges = make([]RecordedExchange, 0) + delete(rs.exchangeBuckets, bucket) rs.mu.Unlock() } func handleRecordAdmin(rs *RecordServer, ctx *fasthttp.RequestCtx, path, method string) { - // Snapshot is record-mode specific - if path == "/__admin/recordings/snapshot" && method == "POST" { - handleSnapshot(rs, ctx) + // Set the current cursor bucket: POST /__admin/mappings/bucket[/{name}] + if method == "POST" && isPathOrSub(path, "/__admin/mappings/bucket") { + handleSetBucket(rs, ctx, path) + return + } + + // Snapshot is record-mode specific: POST /__admin/recordings/snapshot[/{bucket}] + if method == "POST" && isPathOrSub(path, "/__admin/recordings/snapshot") { + bucket := adminBucket(rs, path, "/__admin/recordings/snapshot") + handleSnapshot(rs, ctx, bucket) return } - // Reset clears recordings - if (path == "/__admin/reset" || path == "/__admin/mappings/reset") && method == "POST" { - clearExchanges(rs) - log.Println("All recordings reset") + // Reset clears a bucket's recordings: POST /__admin/reset[/{bucket}] or /__admin/mappings/reset[/{bucket}] + if method == "POST" && (isPathOrSub(path, "/__admin/mappings/reset") || isPathOrSub(path, "/__admin/reset")) { + prefix := "/__admin/reset" + if isPathOrSub(path, "/__admin/mappings/reset") { + prefix = "/__admin/mappings/reset" + } + bucket := adminBucket(rs, path, prefix) + clearExchanges(rs, bucket) + log.Printf("Recordings reset for bucket %q", bucket) ctx.SetStatusCode(fasthttp.StatusOK) return } - // DELETE requests clears recordings - if path == "/__admin/requests" && method == "DELETE" { - clearExchanges(rs) + // DELETE requests clears a bucket's recordings: DELETE /__admin/requests[/{bucket}] + if method == "DELETE" && isPathOrSub(path, "/__admin/requests") { + bucket := adminBucket(rs, path, "/__admin/requests") + clearExchanges(rs, bucket) ctx.SetStatusCode(fasthttp.StatusOK) return } @@ -175,6 +241,29 @@ func handleRecordAdmin(rs *RecordServer, ctx *fasthttp.RequestCtx, path, method server.HandleAdmin(ctx, path, method) } +// handleSetBucket sets the current cursor bucket from the path segment +// (POST /__admin/mappings/bucket/{name}) or a JSON body {"bucket": "..."}. +func handleSetBucket(rs *RecordServer, ctx *fasthttp.RequestCtx, path string) { + name := bucketFromPath(path, "/__admin/mappings/bucket") + if name == "" { + var body struct { + Bucket string `json:"bucket"` + } + json.Unmarshal(ctx.PostBody(), &body) + name = body.Bucket + } + if name == "" { + ctx.SetStatusCode(fasthttp.StatusBadRequest) + ctx.SetBodyString(`{"error": "bucket name required"}`) + return + } + setCurrentBucket(rs, name) + log.Printf("Current bucket set to %q", name) + ctx.Response.Header.Set("Content-Type", "application/json") + ctx.SetStatusCode(fasthttp.StatusOK) + ctx.SetBodyString(fmt.Sprintf(`{"bucket": %q}`, name)) +} + // SnapshotRequest represents the body of a POST /__admin/recordings/snapshot request. type SnapshotRequest struct { Filters struct { @@ -184,28 +273,29 @@ type SnapshotRequest struct { RepeatsAsScenarios bool `json:"repeatsAsScenarios"` } -func handleSnapshot(rs *RecordServer, ctx *fasthttp.RequestCtx) { +func handleSnapshot(rs *RecordServer, ctx *fasthttp.RequestCtx, bucket string) { var snapReq SnapshotRequest json.Unmarshal(ctx.PostBody(), &snapReq) rs.mu.Lock() - // Filter by URL pattern and remove matched exchanges from the pool + // Filter by URL pattern and remove matched exchanges from the bucket + exchanges := rs.exchangeBuckets[bucket] var filtered []RecordedExchange var remaining []RecordedExchange if snapReq.Filters.URLPattern != "" { matcher := compileURLMatcher(snapReq.Filters.URLPattern) - for _, ex := range rs.exchanges { + for _, ex := range exchanges { if matcher(ex.URL) { filtered = append(filtered, ex) } else { remaining = append(remaining, ex) } } - rs.exchanges = remaining + rs.exchangeBuckets[bucket] = remaining } else { - filtered = make([]RecordedExchange, len(rs.exchanges)) - copy(filtered, rs.exchanges) - rs.exchanges = make([]RecordedExchange, 0) + filtered = make([]RecordedExchange, len(exchanges)) + copy(filtered, exchanges) + delete(rs.exchangeBuckets, bucket) } rs.mu.Unlock()