Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/pull-request-status-check-changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,18 @@ jobs:
- name: Install changelog validator
run: |
# Install Node.js tool for Keep a Changelog validation
npm install -g keep-a-changelog
npm install -g keep-a-changelog@2.8.0

- name: Validate changelog format
run: |
echo "Validating CHANGELOG.md format according to Keep a Changelog..."

# Use keep-a-changelog to validate the changelog format
if ! npx keep-a-changelog CHANGELOG.md > /dev/null 2>&1; then
if ! npx keep-a-changelog@2.8.0 CHANGELOG.md > /dev/null 2>&1; then
echo "❌ CHANGELOG.md is not valid according to Keep a Changelog format"
echo "Please ensure your changelog follows the format at https://keepachangelog.com"
echo "Validation output:"
npx keep-a-changelog CHANGELOG.md
npx keep-a-changelog@2.8.0 CHANGELOG.md
exit 1
fi

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ 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.6.0] - 2026-03-10

### Added
- `BINARY_CONTENT_TYPES` environment variable (record + replay) — comma-separated list of Content-Types whose response bodies should be stored as base64-encoded strings in the `body` field. In replay mode, responses with matching Content-Types are automatically base64-decoded before serving.

## [0.5.1] - 2026-02-20

### Changed
- Increased default maximum request body size to 16MB

Expand Down Expand Up @@ -82,6 +88,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial release

[0.6.0]: https://github.com/gooddata/gooddata-goodmock/compare/v0.5.1...v0.6.0
[0.5.1]: https://github.com/gooddata/gooddata-goodmock/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/gooddata/gooddata-goodmock/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/gooddata/gooddata-goodmock/compare/v0.3.2...v0.4.0
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ goodmock <mode>
| `MAPPINGS_DIR` | _(unset)_ | replay | Directory of JSON mapping files to load on startup |
| `VERBOSE` | _(unset)_ | all | Log all request/response traffic (any value enables) |
| `JSON_CONTENT_TYPES` | _(unset)_ | record | Additional Content-Types to store as structured JSON (see below) |
| `BINARY_CONTENT_TYPES` | _(unset)_ | record | Content-Types to store as base64-encoded strings (comma-separated) |
| `PRESERVE_JSON_KEY_ORDER` | _(unset)_ | record | Preserve original key order in JSON request and response bodies (any value enables) |
| `SORT_ARRAY_MEMBERS` | _(unset)_ | record | Recursively sort JSON array elements by stringified value for deterministic diffs (any value enables) |

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.1
0.6.0
15 changes: 15 additions & 0 deletions internal/common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,18 @@ func ParseJSONContentTypes() []string {
}
return types
}

// ParseBinaryContentTypes returns the list of Content-Types whose response bodies
// should be stored as base64-encoded strings. Empty by default.
func ParseBinaryContentTypes() []string {
var types []string
if env := os.Getenv("BINARY_CONTENT_TYPES"); env != "" {
for _, t := range strings.Split(env, ",") {
t = strings.TrimSpace(t)
if t != "" {
types = append(types, t)
}
}
}
return types
}
2 changes: 1 addition & 1 deletion internal/pureproxy/pureproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type ProxyServer struct {

func NewProxyServer(upstream, proxyHost, refererPath string, verbose bool) *ProxyServer {
return &ProxyServer{
server: server.NewServer(proxyHost, refererPath, verbose),
server: server.NewServer(proxyHost, refererPath, verbose, nil),
upstream: upstream,
client: &fasthttp.Client{},
}
Expand Down
76 changes: 45 additions & 31 deletions internal/record/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package record

import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"goodmock/internal/common"
Expand Down Expand Up @@ -33,26 +34,28 @@ type RecordedExchange struct {

// RecordServer proxies requests to an upstream backend and records exchanges.
type RecordServer struct {
server *types.Server
mu sync.Mutex
exchanges []RecordedExchange
upstream string
client *fasthttp.Client
jsonContentTypes []string
preserveKeyOrder bool
sortArrayMembers bool
server *types.Server
mu sync.Mutex
exchanges []RecordedExchange
upstream string
client *fasthttp.Client
jsonContentTypes []string
binaryContentTypes []string
preserveKeyOrder bool
sortArrayMembers bool
}

// NewRecordServer creates a new recording proxy server.
func NewRecordServer(upstream, proxyHost, refererPath string, verbose bool, jsonContentTypes []string, preserveKeyOrder, sortArrayMembers bool) *RecordServer {
func NewRecordServer(upstream, proxyHost, refererPath string, verbose bool, jsonContentTypes, binaryContentTypes []string, preserveKeyOrder, sortArrayMembers bool) *RecordServer {
return &RecordServer{
server: server.NewServer(proxyHost, refererPath, verbose),
exchanges: make([]RecordedExchange, 0),
upstream: upstream,
client: &fasthttp.Client{},
jsonContentTypes: jsonContentTypes,
preserveKeyOrder: preserveKeyOrder,
sortArrayMembers: sortArrayMembers,
server: server.NewServer(proxyHost, refererPath, verbose, nil),
exchanges: make([]RecordedExchange, 0),
upstream: upstream,
client: &fasthttp.Client{},
jsonContentTypes: jsonContentTypes,
binaryContentTypes: binaryContentTypes,
preserveKeyOrder: preserveKeyOrder,
sortArrayMembers: sortArrayMembers,
}
}

Expand Down Expand Up @@ -208,11 +211,11 @@ func handleSnapshot(rs *RecordServer, ctx *fasthttp.RequestCtx) {
// as [] not null (Cypress spreads this array and null is not iterable)
mappings := make([]types.Mapping, 0)
if snapReq.RepeatsAsScenarios {
if m := exchangesToScenarioMappings(filtered, rs.jsonContentTypes, rs.preserveKeyOrder, rs.sortArrayMembers); m != nil {
if m := exchangesToScenarioMappings(filtered, rs.jsonContentTypes, rs.binaryContentTypes, rs.preserveKeyOrder, rs.sortArrayMembers); m != nil {
mappings = m
}
} else {
if m := exchangesToMappings(filtered, rs.jsonContentTypes, rs.preserveKeyOrder, rs.sortArrayMembers); m != nil {
if m := exchangesToMappings(filtered, rs.jsonContentTypes, rs.binaryContentTypes, rs.preserveKeyOrder, rs.sortArrayMembers); m != nil {
mappings = m
}
}
Expand All @@ -230,7 +233,7 @@ func handleSnapshot(rs *RecordServer, ctx *fasthttp.RequestCtx) {
// exchangesToMappings converts exchanges to mappings, deduplicating by
// method + path + query params + body (keeping the last occurrence).
// This matches WireMock's snapshot behavior with repeatsAsScenarios=false.
func exchangesToMappings(exchanges []RecordedExchange, jsonContentTypes []string, preserveKeyOrder, sortArrayMembers bool) []types.Mapping {
func exchangesToMappings(exchanges []RecordedExchange, jsonContentTypes, binaryContentTypes []string, preserveKeyOrder, sortArrayMembers bool) []types.Mapping {
type dedupEntry struct {
key string
mapping types.Mapping
Expand All @@ -240,7 +243,7 @@ func exchangesToMappings(exchanges []RecordedExchange, jsonContentTypes []string
var entries []dedupEntry

for _, ex := range exchanges {
m := exchangeToMapping(ex, jsonContentTypes, preserveKeyOrder, sortArrayMembers)
m := exchangeToMapping(ex, jsonContentTypes, binaryContentTypes, preserveKeyOrder, sortArrayMembers)
key := deduplicationKey(m)

if idx, exists := seen[key]; exists {
Expand Down Expand Up @@ -286,7 +289,7 @@ func deduplicationKey(m types.Mapping) string {
}

// exchangesToScenarioMappings converts exchanges to mappings, creating scenarios for repeated URLs.
func exchangesToScenarioMappings(exchanges []RecordedExchange, jsonContentTypes []string, preserveKeyOrder, sortArrayMembers bool) []types.Mapping {
func exchangesToScenarioMappings(exchanges []RecordedExchange, jsonContentTypes, binaryContentTypes []string, preserveKeyOrder, sortArrayMembers bool) []types.Mapping {
// Group by URL+method
type group struct {
key string
Expand All @@ -311,12 +314,12 @@ func exchangesToScenarioMappings(exchanges []RecordedExchange, jsonContentTypes
g := groups[key]
if len(g.exchanges) == 1 {
// Single occurrence — no scenario needed
mappings = append(mappings, exchangeToMapping(g.exchanges[0], jsonContentTypes, preserveKeyOrder, sortArrayMembers))
mappings = append(mappings, exchangeToMapping(g.exchanges[0], jsonContentTypes, binaryContentTypes, preserveKeyOrder, sortArrayMembers))
} else {
// Multiple occurrences — create scenario chain
scenarioName := generateMappingName(g.exchanges[0].URL)
for i, ex := range g.exchanges {
m := exchangeToMapping(ex, jsonContentTypes, preserveKeyOrder, sortArrayMembers)
m := exchangeToMapping(ex, jsonContentTypes, binaryContentTypes, preserveKeyOrder, sortArrayMembers)
m.ScenarioName = scenarioName
if i == 0 {
m.RequiredScenarioState = "Started"
Expand Down Expand Up @@ -346,7 +349,7 @@ func sortMappings(mappings []types.Mapping) {
}

// exchangeToMapping converts a recorded exchange to a WireMock mapping.
func exchangeToMapping(ex RecordedExchange, jsonContentTypes []string, preserveKeyOrder, sortArrayMembers bool) types.Mapping {
func exchangeToMapping(ex RecordedExchange, jsonContentTypes, binaryContentTypes []string, preserveKeyOrder, sortArrayMembers bool) types.Mapping {
// Split URL into path and query parameters
rawPath := ex.URL
var queryString string
Expand Down Expand Up @@ -443,8 +446,10 @@ func exchangeToMapping(ex RecordedExchange, jsonContentTypes []string, preserveK
Headers: headers,
}

// Store as structured JSON if Content-Type matches, otherwise as string
if isJSONContentType(ex.RespHeaders, jsonContentTypes) {
// Store as base64 if binary Content-Type, structured JSON if JSON Content-Type, otherwise as string
if isContentType(ex.RespHeaders, binaryContentTypes) {
resp.Body = base64.StdEncoding.EncodeToString(ex.RespBody)
} else if isJSONContentType(ex.RespHeaders, jsonContentTypes) {
if preserveKeyOrder && !sortArrayMembers {
// Use json.RawMessage to preserve original key order from upstream
var raw json.RawMessage
Expand Down Expand Up @@ -476,16 +481,19 @@ func exchangeToMapping(ex RecordedExchange, jsonContentTypes []string, preserveK
}
}

// isJSONContentType checks if the response Content-Type matches any of the given JSON types.
func isJSONContentType(headers map[string][]string, jsonTypes []string) bool {
// isContentType checks if the response Content-Type matches any of the given types.
func isContentType(headers map[string][]string, contentTypes []string) bool {
if len(contentTypes) == 0 {
return false
}
for key, values := range headers {
if !strings.EqualFold(key, "Content-Type") {
continue
}
for _, v := range values {
mediaType := strings.TrimSpace(strings.SplitN(v, ";", 2)[0])
for _, jt := range jsonTypes {
if strings.EqualFold(mediaType, jt) {
for _, ct := range contentTypes {
if strings.EqualFold(mediaType, ct) {
return true
}
}
Expand All @@ -494,6 +502,11 @@ func isJSONContentType(headers map[string][]string, jsonTypes []string) bool {
return false
}

// isJSONContentType checks if the response Content-Type matches any of the given JSON types.
func isJSONContentType(headers map[string][]string, jsonTypes []string) bool {
return isContentType(headers, jsonTypes)
}

// normalizeHeaderName converts a header name to HTTP canonical form (Title-Case),
// matching WireMock's header casing behavior.
func normalizeHeaderName(name string) string {
Expand Down Expand Up @@ -625,9 +638,10 @@ func RunRecord() {

verbose := common.IsVerbose()
jsonContentTypes := common.ParseJSONContentTypes()
binaryContentTypes := common.ParseBinaryContentTypes()
preserveKeyOrder := common.PreserveJSONKeyOrder()
sortArrayMembers := common.SortArrayMembers()
rs := NewRecordServer(upstream, upstream, refererPath, verbose, jsonContentTypes, preserveKeyOrder, sortArrayMembers)
rs := NewRecordServer(upstream, upstream, refererPath, verbose, jsonContentTypes, binaryContentTypes, preserveKeyOrder, sortArrayMembers)

addr := fmt.Sprintf(":%d", port)

Expand Down
55 changes: 49 additions & 6 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package server

import (
"encoding/base64"
"encoding/json"
"fmt"
"goodmock/internal/logging"
Expand All @@ -14,12 +15,13 @@ import (
)

// NewServer creates a new mock server
func NewServer(proxyHost, refererPath string, verbose bool) *types.Server {
func NewServer(proxyHost, refererPath string, verbose bool, binaryContentTypes []string) *types.Server {
return &types.Server{
Mappings: make([]types.Mapping, 0),
ProxyHost: proxyHost,
RefererPath: refererPath,
Verbose: verbose,
Mappings: make([]types.Mapping, 0),
ProxyHost: proxyHost,
RefererPath: refererPath,
Verbose: verbose,
BinaryContentTypes: binaryContentTypes,
}
}

Expand Down Expand Up @@ -113,7 +115,16 @@ func HandleRequest(s *types.Server, ctx *fasthttp.RequestCtx) {
ctx.SetBody(data)
}
} else if m.Response.Body != "" {
ctx.SetBodyString(m.Response.Body)
if isBinaryResponse(m.Response.Headers, s.BinaryContentTypes) {
decoded, err := base64.StdEncoding.DecodeString(m.Response.Body)
if err == nil {
ctx.SetBody(decoded)
} else {
ctx.SetBodyString(m.Response.Body)
}
} else {
ctx.SetBodyString(m.Response.Body)
}
}

if s.Verbose {
Expand Down Expand Up @@ -240,6 +251,38 @@ func LogVerboseRequest(ctx *fasthttp.RequestCtx, method, rawURI string) {
}
}

// isBinaryResponse checks if the response Content-Type matches any of the given binary types.
func isBinaryResponse(headers map[string]any, binaryTypes []string) bool {
if len(binaryTypes) == 0 {
return false
}
for key, value := range headers {
if !strings.EqualFold(key, "Content-Type") {
continue
}
var ct string
switch v := value.(type) {
case string:
ct = v
case []interface{}:
if len(v) > 0 {
if s, ok := v[0].(string); ok {
ct = s
}
}
}
if ct != "" {
mediaType := strings.TrimSpace(strings.SplitN(ct, ";", 2)[0])
for _, bt := range binaryTypes {
if strings.EqualFold(mediaType, bt) {
return true
}
}
}
}
return false
}

func getRequestPattern(m *types.Mapping) string {
if m.Request.URL != "" {
return m.Request.URL
Expand Down
11 changes: 6 additions & 5 deletions internal/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,12 @@ type Response struct {

// Server holds the mock server state
type Server struct {
Mu sync.RWMutex
Mappings []Mapping
ProxyHost string
RefererPath string
Verbose bool
Mu sync.RWMutex
Mappings []Mapping
ProxyHost string
RefererPath string
Verbose bool
BinaryContentTypes []string
}

// MatchResult holds the result of matching a request against a stub
Expand Down
3 changes: 2 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ func runReplay() {
}

verbose := common.IsVerbose()
s := server.NewServer(proxyHost, refererPath, verbose)
binaryContentTypes := common.ParseBinaryContentTypes()
s := server.NewServer(proxyHost, refererPath, verbose, binaryContentTypes)

// Load mappings from MAPPINGS_DIR env if set
mappingsDir := os.Getenv("MAPPINGS_DIR")
Expand Down