From 1648ac6e7bf0f27dc7bca594faa607f5f1e8f6de Mon Sep 17 00:00:00 2001 From: Martin Najemi Date: Wed, 14 Oct 2026 11:08:43 +0200 Subject: [PATCH] chore: Add opt-in unused-stub logging via ?logUnusedMappings Risk: low --- CHANGELOG.md | 10 +++ README.md | 29 +++++- VERSION | 2 +- internal/logging/logging.go | 171 ++++++++++++++++++++++++++++++------ internal/replay/replay.go | 30 +++++++ internal/types/types.go | 1 + 6 files changed, 215 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d59f6ab..735f6a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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.14.0] - 2026-10-14 + +### Added +- Replay mode now tracks which stubs have served at least one request. `POST /__admin/reset` and `POST /__admin/mappings/reset` accept an optional `?logUnusedMappings` query parameter (presence alone enables it, no value needed); when present, every stub that was never matched is logged before the mappings are cleared +- `LogUnused` logging helper prints an unused stub as a two-column WireMock-style table (stub request on the left, stub response on the right), showing whichever of `url`, `urlPath` or `urlPattern` the stub uses, plus query parameter, header and body matchers, and the response status, headers and body + +### Changed +- `POST /__admin/mappings/reset` now logs "All mappings reset", matching `POST /__admin/reset` + ## [0.13.0] - 2026-10-12 ### Added @@ -150,6 +159,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release +[0.14.0]: https://github.com/gooddata/gooddata-goodmock/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/gooddata/gooddata-goodmock/compare/v0.12.0...v0.13.0 [0.12.0]: https://github.com/gooddata/gooddata-goodmock/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/gooddata/gooddata-goodmock/compare/v0.10.0...v0.11.0 diff --git a/README.md b/README.md index c741605..2e3f8ce 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A lightweight, high-performance mock server written in Go, powered by [fasthttp] - Request matching by URL, URL path, URL pattern (regex), query parameters, headers, and JSON body - Runtime mapping management via `/__admin` endpoints - Load mappings from JSON files on startup -- Detailed mismatch logging (WireMock-style diagnostics) +- Detailed mismatch and unused-stub logging (WireMock-style diagnostics) - Minimal Docker image (built from `scratch`) ## Install @@ -152,6 +152,12 @@ GoodMock exposes a subset of the WireMock admin API under `/__admin`: 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). +In replay mode, `POST /__admin/reset` and `POST /__admin/mappings/reset` accept an optional `?logUnusedMappings` query parameter. When present (no value needed), every loaded stub that never matched a request is logged before the mappings are cleared — see [Unused Stub Logging](#unused-stub-logging). + +```bash +curl -X POST "http://localhost:8080/__admin/mappings/reset?logUnusedMappings" +``` + ### Adding a Mapping at Runtime ```bash @@ -312,6 +318,27 @@ Requests are matched against loaded mappings using the following criteria: When no mapping matches, GoodMock returns a `404` with a diagnostic log showing the closest stub and where the mismatch occurred. +### Unused Stub Logging + +Replay mode tracks which stubs have served at least one request. Resetting with `?logUnusedMappings` (see [Admin API](#admin-api)) prints a WireMock-style table for each stub that was never matched, with the stub's request matchers on the left and its response on the right: + +``` + Stub was unused + =============== + +----------------------------------------------------------------------------------------------------------------------- +| Stub request | Stub response | +----------------------------------------------------------------------------------------------------------------------- + | + GET | Status: 200 + [urlPath] /api/v1/entities/workspaces | Header: Content-Type: application/json + Query: size [equalTo 50] | Body [json] {"data":[]} + | +----------------------------------------------------------------------------------------------------------------------- +``` + +This is useful for spotting recorded stubs a test no longer exercises, so they can be pruned from the mapping files. + ## WireMock Compatibility GoodMock's **admin API** (`/__admin` endpoints) is WireMock-compatible — tools like Cypress WireMock integrations work without changes. diff --git a/VERSION b/VERSION index 51de330..0548fb4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.13.0 \ No newline at end of file +0.14.0 \ No newline at end of file diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 5312843..931d292 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -2,17 +2,21 @@ package logging import ( + "encoding/json" "fmt" "goodmock/internal/types" + "sort" "strings" "time" ) -const colWidth = 58 +const fullWidth = 119 +const innerWidth = fullWidth - 4 +const colWidth2 = 58 // LogMismatch outputs a request mismatch in the same format as WireMock func LogMismatch(method, fullURL string, result types.MatchResult) { - separator := strings.Repeat("-", 119) + separator := strings.Repeat("-", fullWidth) timestamp := time.Now().UTC().Format("2006-01-02 15:04:05.000") fmt.Printf("%s \n", timestamp) @@ -20,21 +24,21 @@ func LogMismatch(method, fullURL string, result types.MatchResult) { fmt.Println(" =======================") fmt.Println() fmt.Println(separator) - fmt.Printf("| %-*s | %-*s |\n", colWidth, "Closest stub", colWidth, "Request") + fmt.Printf("| %-*s | %-*s |\n", colWidth2, "Closest stub", colWidth2, "Request") fmt.Println(separator) if result.Mapping != nil { m := result.Mapping // Stub name - fmt.Printf("%-*s |\n", colWidth+1, "") + fmt.Printf("%-*s |\n", colWidth2+1, "") if m.Name != "" { - fmt.Printf("%-*s |\n", colWidth+1, " "+m.Name) + fmt.Printf("%-*s |\n", colWidth2+1, " "+m.Name) } - fmt.Printf("%-*s |\n", colWidth+1, "") + fmt.Printf("%-*s |\n", colWidth2+1, "") // Method - fmt.Printf("%-*s | %s\n", colWidth, " "+m.Request.Method, method) + fmt.Printf("%-*s | %s\n", colWidth2, " "+m.Request.Method, method) // Path comparison expectedPath := m.Request.URL @@ -47,23 +51,23 @@ func LogMismatch(method, fullURL string, result types.MatchResult) { if result.URLMatch { fmt.Printf(" [path] %-*s | %-*s\n", - colWidth-8, truncate(expectedPath, colWidth-8), - colWidth, truncate(fullURL, colWidth)) + colWidth2-8, truncate(expectedPath, colWidth2-8), + colWidth2, truncate(fullURL, colWidth2)) } else { // Split the actual URL across lines if needed - actualTrunc := truncate(fullURL, colWidth-6) + actualTrunc := truncate(fullURL, colWidth2-6) remainder := "" - if len(fullURL) > colWidth-6 { - remainder = fullURL[colWidth-6:] + if len(fullURL) > colWidth2-6 { + remainder = fullURL[colWidth2-6:] } fmt.Printf(" [path] %-*s | %s<<<<< URL does not match\n", - colWidth-8, truncate(expectedPath, colWidth-8), + colWidth2-8, truncate(expectedPath, colWidth2-8), actualTrunc) if remainder != "" { - fmt.Printf(" %-*s | %s\n", colWidth-1, "", remainder) + fmt.Printf(" %-*s | %s\n", colWidth2-1, "", remainder) } } - fmt.Printf("%-*s |\n", colWidth+1, "") + fmt.Printf("%-*s |\n", colWidth2+1, "") // Query parameter diffs for _, diff := range result.QueryDiffs { @@ -75,14 +79,14 @@ func LogMismatch(method, fullURL string, result types.MatchResult) { stubCol := fmt.Sprintf(" Query: %s exactly %s", paramName, expectedVals) if diffType == "not_present" { fmt.Printf("%-*s | %s<<<<< Query is not present\n", - colWidth, truncate(stubCol, colWidth), - strings.Repeat(" ", colWidth-5-len("<<<<< Query is not present")+6)) + colWidth2, truncate(stubCol, colWidth2), + strings.Repeat(" ", colWidth2-5-len("<<<<< Query is not present")+6)) } else { actualVals := parts[3] actualCol := fmt.Sprintf("%s: %s", paramName, actualVals) fmt.Printf("%-*s | %-*s<<<<< Query does not match\n", - colWidth, truncate(stubCol, colWidth), - colWidth-26, truncate(actualCol, colWidth-26)) + colWidth2, truncate(stubCol, colWidth2), + colWidth2-26, truncate(actualCol, colWidth2-26)) } } @@ -96,31 +100,146 @@ func LogMismatch(method, fullURL string, result types.MatchResult) { stubCol := fmt.Sprintf(" Header: %s [equalTo %s]", headerName, expectedVal) if diffType == "not_present" { fmt.Printf("%-*s | %s<<<<< Header is not present\n", - colWidth, truncate(stubCol, colWidth), - strings.Repeat(" ", colWidth-5-len("<<<<< Header is not present")+6)) + colWidth2, truncate(stubCol, colWidth2), + strings.Repeat(" ", colWidth2-5-len("<<<<< Header is not present")+6)) } else { actualVal := parts[3] actualCol := fmt.Sprintf("%s: %s", headerName, actualVal) fmt.Printf("%-*s | %-*s<<<<< Header does not match\n", - colWidth, truncate(stubCol, colWidth), - colWidth-27, truncate(actualCol, colWidth-27)) + colWidth2, truncate(stubCol, colWidth2), + colWidth2-27, truncate(actualCol, colWidth2-27)) } } // Body diff if result.BodyDiff != "" { - fmt.Printf(" %-*s | <<<<< %s\n", colWidth-1, "Body [equalToJson]", result.BodyDiff) + fmt.Printf(" %-*s | <<<<< %s\n", colWidth2-1, "Body [equalToJson]", result.BodyDiff) } } else { fmt.Printf(" No stub found for: %s %s\n", method, fullURL) } - fmt.Printf("%-*s |\n", colWidth+1, "") - fmt.Printf("%-*s |\n", colWidth+1, "") + fmt.Printf("%-*s |\n", colWidth2+1, "") + fmt.Printf("%-*s |\n", colWidth2+1, "") fmt.Println(separator) fmt.Println() } +// LogUnused outputs an unused stub in the same format as WireMock +func LogUnused(mapping types.Mapping) { + separator := strings.Repeat("-", fullWidth) + timestamp := time.Now().UTC().Format("2006-01-02 15:04:05.000") + + fmt.Printf("%s \n", timestamp) + fmt.Println(" Stub was unused") + fmt.Println(" ===============") + fmt.Println() + fmt.Println(separator) + fmt.Printf("| %-*s | %-*s |\n", colWidth2, "Stub request", colWidth2, "Stub response") + fmt.Println(separator) + + left := stubRequestLines(mapping) + right := stubResponseLines(mapping) + rows := max(len(left), len(right)) + + fmt.Printf("%-*s |\n", colWidth2+1, "") + for i := 0; i < rows; i++ { + l, r := "", "" + if i < len(left) { + l = left[i] + } + if i < len(right) { + r = right[i] + } + fmt.Printf("%-*s | %s\n", colWidth2+1, truncate(l, colWidth2+1), truncate(r, colWidth2)) + } + fmt.Printf("%-*s |\n", colWidth2+1, "") + fmt.Println(separator) + fmt.Println() +} + +// stubRequestLines renders the request side of a stub, one line per criterion. +func stubRequestLines(m types.Mapping) []string { + lines := []string{} + if m.Name != "" { + lines = append(lines, " "+m.Name, "") + } + lines = append(lines, " "+m.Request.Method) + + // url matches the full URI, urlPath only the path, urlPattern is a regex on the full URI + switch { + case m.Request.URL != "": + lines = append(lines, " [url] "+m.Request.URL) + case m.Request.URLPath != "": + lines = append(lines, " [urlPath] "+m.Request.URLPath) + case m.Request.URLPattern != "": + lines = append(lines, " [urlPattern] "+m.Request.URLPattern) + default: + lines = append(lines, " [url] ") + } + + for _, name := range sortedKeys(m.Request.QueryParameters) { + matcher := m.Request.QueryParameters[name] + if matcher.EqualTo != "" { + lines = append(lines, fmt.Sprintf(" Query: %s [equalTo %s]", name, matcher.EqualTo)) + continue + } + vals := make([]string, 0, len(matcher.HasExactly)) + for _, v := range matcher.HasExactly { + vals = append(vals, v.EqualTo) + } + lines = append(lines, fmt.Sprintf(" Query: %s exactly %v", name, vals)) + } + + for _, name := range sortedKeys(m.Request.Headers) { + matcher := m.Request.Headers[name] + if matcher.EqualTo != "" { + lines = append(lines, fmt.Sprintf(" Header: %s [equalTo %s]", name, matcher.EqualTo)) + } else { + lines = append(lines, fmt.Sprintf(" Header: %s [contains %s]", name, matcher.Contains)) + } + } + + for _, p := range m.Request.BodyPatterns { + if p.EqualToJSON != nil { + lines = append(lines, " Body [equalToJson] "+string(p.EqualToJSON)) + } + } + return lines +} + +// stubResponseLines renders the response side of a stub. +func stubResponseLines(m types.Mapping) []string { + lines := []string{fmt.Sprintf(" Status: %d", m.Response.Status)} + + for _, name := range sortedKeys(m.Response.Headers) { + lines = append(lines, fmt.Sprintf(" Header: %s: %v", name, m.Response.Headers[name])) + } + + if m.Response.ProxyBaseUrl != "" { + lines = append(lines, " ProxyBaseUrl: "+m.Response.ProxyBaseUrl) + } + + if m.Response.JsonBody != nil { + if data, err := json.Marshal(m.Response.JsonBody); err == nil { + lines = append(lines, " Body [json] "+string(data)) + } + } else if m.Response.Body != "" { + lines = append(lines, " Body "+m.Response.Body) + } + return lines +} + +// sortedKeys returns the keys of a map in sorted order for stable output. +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s diff --git a/internal/replay/replay.go b/internal/replay/replay.go index 40944ad..f01a296 100644 --- a/internal/replay/replay.go +++ b/internal/replay/replay.go @@ -121,6 +121,10 @@ func handleReplayRequest(s *types.Server, ctx *fasthttp.RequestCtx) { } m := result.Mapping + s.Mu.Lock() + m.Matched = true + s.Mu.Unlock() + applyResponseHeaders(ctx, m.Response.Headers) ctx.SetStatusCode(m.Response.Status) @@ -170,6 +174,9 @@ func applyResponseHeaders(ctx *fasthttp.RequestCtx, headers map[string]any) { func handleReplayAdmin(s *types.Server, ctx *fasthttp.RequestCtx, path, method string) { if path == "/__admin/reset" && method == "POST" { + if wantsUnusedLog(ctx) { + logUnusedMappings(s) + } server.ClearMappings(s) log.Println("All mappings reset") ctx.SetStatusCode(fasthttp.StatusOK) @@ -202,7 +209,11 @@ func handleReplayAdmin(s *types.Server, ctx *fasthttp.RequestCtx, path, method s } if path == "/__admin/mappings/reset" && method == "POST" { + if wantsUnusedLog(ctx) { + logUnusedMappings(s) + } server.ClearMappings(s) + log.Println("All mappings reset") ctx.SetStatusCode(fasthttp.StatusOK) return } @@ -211,6 +222,25 @@ func handleReplayAdmin(s *types.Server, ctx *fasthttp.RequestCtx, path, method s server.HandleAdmin(ctx, path, method) } +// wantsUnusedLog reports whether the reset request carries a logUnusedMappings +// query parameter. Presence alone enables it, e.g. /__admin/mappings/reset?logUnusedMappings +func wantsUnusedLog(ctx *fasthttp.RequestCtx) bool { + return ctx.QueryArgs().Has("logUnusedMappings") +} + +// logUnusedMappings logs every loaded stub that no replayed request has matched. +func logUnusedMappings(s *types.Server) { + s.Mu.RLock() + defer s.Mu.RUnlock() + + for i := range s.Mappings { + m := &s.Mappings[i] + if !m.Matched { + logging.LogUnused(*m) + } + } +} + func handleMappings(s *types.Server, ctx *fasthttp.RequestCtx, method string) { switch method { case "POST": diff --git a/internal/types/types.go b/internal/types/types.go index 23ec3dd..e9f61ff 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -19,6 +19,7 @@ type Mapping struct { NewScenarioState string `json:"newScenarioState,omitempty"` Request Request `json:"request"` Response Response `json:"response"` + Matched bool `json:"-"` } // Request represents the request matching criteria