Skip to content

Commit de41c20

Browse files
committed
chore: Add BINARY_CONTENT_TYPES env to support binary responses
Risk: low
1 parent e9ef34c commit de41c20

9 files changed

Lines changed: 126 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.6.0] - 2026-03-10
9+
10+
### Added
11+
- `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.
12+
813
## [0.5.1] - 2026-02-20
914
### Changed
1015
- Increased default maximum request body size to 16MB
@@ -82,6 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8287
### Added
8388
- Initial release
8489

90+
[0.6.0]: https://github.com/gooddata/gooddata-goodmock/compare/v0.5.1...v0.6.0
8591
[0.5.1]: https://github.com/gooddata/gooddata-goodmock/compare/v0.5.0...v0.5.1
8692
[0.5.0]: https://github.com/gooddata/gooddata-goodmock/compare/v0.4.0...v0.5.0
8793
[0.4.0]: https://github.com/gooddata/gooddata-goodmock/compare/v0.3.2...v0.4.0

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ goodmock <mode>
5454
| `MAPPINGS_DIR` | _(unset)_ | replay | Directory of JSON mapping files to load on startup |
5555
| `VERBOSE` | _(unset)_ | all | Log all request/response traffic (any value enables) |
5656
| `JSON_CONTENT_TYPES` | _(unset)_ | record | Additional Content-Types to store as structured JSON (see below) |
57+
| `BINARY_CONTENT_TYPES` | _(unset)_ | record | Content-Types to store as base64-encoded strings (comma-separated) |
5758
| `PRESERVE_JSON_KEY_ORDER` | _(unset)_ | record | Preserve original key order in JSON request and response bodies (any value enables) |
5859
| `SORT_ARRAY_MEMBERS` | _(unset)_ | record | Recursively sort JSON array elements by stringified value for deterministic diffs (any value enables) |
5960

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.5.1
1+
0.6.0

internal/common/common.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,18 @@ func ParseJSONContentTypes() []string {
4747
}
4848
return types
4949
}
50+
51+
// ParseBinaryContentTypes returns the list of Content-Types whose response bodies
52+
// should be stored as base64-encoded strings. Empty by default.
53+
func ParseBinaryContentTypes() []string {
54+
var types []string
55+
if env := os.Getenv("BINARY_CONTENT_TYPES"); env != "" {
56+
for _, t := range strings.Split(env, ",") {
57+
t = strings.TrimSpace(t)
58+
if t != "" {
59+
types = append(types, t)
60+
}
61+
}
62+
}
63+
return types
64+
}

internal/pureproxy/pureproxy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ type ProxyServer struct {
2323

2424
func NewProxyServer(upstream, proxyHost, refererPath string, verbose bool) *ProxyServer {
2525
return &ProxyServer{
26-
server: server.NewServer(proxyHost, refererPath, verbose),
26+
server: server.NewServer(proxyHost, refererPath, verbose, nil),
2727
upstream: upstream,
2828
client: &fasthttp.Client{},
2929
}

internal/record/record.go

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package record
33

44
import (
55
"bytes"
6+
"encoding/base64"
67
"encoding/json"
78
"fmt"
89
"goodmock/internal/common"
@@ -33,26 +34,28 @@ type RecordedExchange struct {
3334

3435
// RecordServer proxies requests to an upstream backend and records exchanges.
3536
type RecordServer struct {
36-
server *types.Server
37-
mu sync.Mutex
38-
exchanges []RecordedExchange
39-
upstream string
40-
client *fasthttp.Client
41-
jsonContentTypes []string
42-
preserveKeyOrder bool
43-
sortArrayMembers bool
37+
server *types.Server
38+
mu sync.Mutex
39+
exchanges []RecordedExchange
40+
upstream string
41+
client *fasthttp.Client
42+
jsonContentTypes []string
43+
binaryContentTypes []string
44+
preserveKeyOrder bool
45+
sortArrayMembers bool
4446
}
4547

4648
// NewRecordServer creates a new recording proxy server.
47-
func NewRecordServer(upstream, proxyHost, refererPath string, verbose bool, jsonContentTypes []string, preserveKeyOrder, sortArrayMembers bool) *RecordServer {
49+
func NewRecordServer(upstream, proxyHost, refererPath string, verbose bool, jsonContentTypes, binaryContentTypes []string, preserveKeyOrder, sortArrayMembers bool) *RecordServer {
4850
return &RecordServer{
49-
server: server.NewServer(proxyHost, refererPath, verbose),
50-
exchanges: make([]RecordedExchange, 0),
51-
upstream: upstream,
52-
client: &fasthttp.Client{},
53-
jsonContentTypes: jsonContentTypes,
54-
preserveKeyOrder: preserveKeyOrder,
55-
sortArrayMembers: sortArrayMembers,
51+
server: server.NewServer(proxyHost, refererPath, verbose, nil),
52+
exchanges: make([]RecordedExchange, 0),
53+
upstream: upstream,
54+
client: &fasthttp.Client{},
55+
jsonContentTypes: jsonContentTypes,
56+
binaryContentTypes: binaryContentTypes,
57+
preserveKeyOrder: preserveKeyOrder,
58+
sortArrayMembers: sortArrayMembers,
5659
}
5760
}
5861

@@ -208,11 +211,11 @@ func handleSnapshot(rs *RecordServer, ctx *fasthttp.RequestCtx) {
208211
// as [] not null (Cypress spreads this array and null is not iterable)
209212
mappings := make([]types.Mapping, 0)
210213
if snapReq.RepeatsAsScenarios {
211-
if m := exchangesToScenarioMappings(filtered, rs.jsonContentTypes, rs.preserveKeyOrder, rs.sortArrayMembers); m != nil {
214+
if m := exchangesToScenarioMappings(filtered, rs.jsonContentTypes, rs.binaryContentTypes, rs.preserveKeyOrder, rs.sortArrayMembers); m != nil {
212215
mappings = m
213216
}
214217
} else {
215-
if m := exchangesToMappings(filtered, rs.jsonContentTypes, rs.preserveKeyOrder, rs.sortArrayMembers); m != nil {
218+
if m := exchangesToMappings(filtered, rs.jsonContentTypes, rs.binaryContentTypes, rs.preserveKeyOrder, rs.sortArrayMembers); m != nil {
216219
mappings = m
217220
}
218221
}
@@ -230,7 +233,7 @@ func handleSnapshot(rs *RecordServer, ctx *fasthttp.RequestCtx) {
230233
// exchangesToMappings converts exchanges to mappings, deduplicating by
231234
// method + path + query params + body (keeping the last occurrence).
232235
// This matches WireMock's snapshot behavior with repeatsAsScenarios=false.
233-
func exchangesToMappings(exchanges []RecordedExchange, jsonContentTypes []string, preserveKeyOrder, sortArrayMembers bool) []types.Mapping {
236+
func exchangesToMappings(exchanges []RecordedExchange, jsonContentTypes, binaryContentTypes []string, preserveKeyOrder, sortArrayMembers bool) []types.Mapping {
234237
type dedupEntry struct {
235238
key string
236239
mapping types.Mapping
@@ -240,7 +243,7 @@ func exchangesToMappings(exchanges []RecordedExchange, jsonContentTypes []string
240243
var entries []dedupEntry
241244

242245
for _, ex := range exchanges {
243-
m := exchangeToMapping(ex, jsonContentTypes, preserveKeyOrder, sortArrayMembers)
246+
m := exchangeToMapping(ex, jsonContentTypes, binaryContentTypes, preserveKeyOrder, sortArrayMembers)
244247
key := deduplicationKey(m)
245248

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

288291
// exchangesToScenarioMappings converts exchanges to mappings, creating scenarios for repeated URLs.
289-
func exchangesToScenarioMappings(exchanges []RecordedExchange, jsonContentTypes []string, preserveKeyOrder, sortArrayMembers bool) []types.Mapping {
292+
func exchangesToScenarioMappings(exchanges []RecordedExchange, jsonContentTypes, binaryContentTypes []string, preserveKeyOrder, sortArrayMembers bool) []types.Mapping {
290293
// Group by URL+method
291294
type group struct {
292295
key string
@@ -311,12 +314,12 @@ func exchangesToScenarioMappings(exchanges []RecordedExchange, jsonContentTypes
311314
g := groups[key]
312315
if len(g.exchanges) == 1 {
313316
// Single occurrence — no scenario needed
314-
mappings = append(mappings, exchangeToMapping(g.exchanges[0], jsonContentTypes, preserveKeyOrder, sortArrayMembers))
317+
mappings = append(mappings, exchangeToMapping(g.exchanges[0], jsonContentTypes, binaryContentTypes, preserveKeyOrder, sortArrayMembers))
315318
} else {
316319
// Multiple occurrences — create scenario chain
317320
scenarioName := generateMappingName(g.exchanges[0].URL)
318321
for i, ex := range g.exchanges {
319-
m := exchangeToMapping(ex, jsonContentTypes, preserveKeyOrder, sortArrayMembers)
322+
m := exchangeToMapping(ex, jsonContentTypes, binaryContentTypes, preserveKeyOrder, sortArrayMembers)
320323
m.ScenarioName = scenarioName
321324
if i == 0 {
322325
m.RequiredScenarioState = "Started"
@@ -346,7 +349,7 @@ func sortMappings(mappings []types.Mapping) {
346349
}
347350

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

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

479-
// isJSONContentType checks if the response Content-Type matches any of the given JSON types.
480-
func isJSONContentType(headers map[string][]string, jsonTypes []string) bool {
484+
// isContentType checks if the response Content-Type matches any of the given types.
485+
func isContentType(headers map[string][]string, contentTypes []string) bool {
486+
if len(contentTypes) == 0 {
487+
return false
488+
}
481489
for key, values := range headers {
482490
if !strings.EqualFold(key, "Content-Type") {
483491
continue
484492
}
485493
for _, v := range values {
486494
mediaType := strings.TrimSpace(strings.SplitN(v, ";", 2)[0])
487-
for _, jt := range jsonTypes {
488-
if strings.EqualFold(mediaType, jt) {
495+
for _, ct := range contentTypes {
496+
if strings.EqualFold(mediaType, ct) {
489497
return true
490498
}
491499
}
@@ -494,6 +502,11 @@ func isJSONContentType(headers map[string][]string, jsonTypes []string) bool {
494502
return false
495503
}
496504

505+
// isJSONContentType checks if the response Content-Type matches any of the given JSON types.
506+
func isJSONContentType(headers map[string][]string, jsonTypes []string) bool {
507+
return isContentType(headers, jsonTypes)
508+
}
509+
497510
// normalizeHeaderName converts a header name to HTTP canonical form (Title-Case),
498511
// matching WireMock's header casing behavior.
499512
func normalizeHeaderName(name string) string {
@@ -625,9 +638,10 @@ func RunRecord() {
625638

626639
verbose := common.IsVerbose()
627640
jsonContentTypes := common.ParseJSONContentTypes()
641+
binaryContentTypes := common.ParseBinaryContentTypes()
628642
preserveKeyOrder := common.PreserveJSONKeyOrder()
629643
sortArrayMembers := common.SortArrayMembers()
630-
rs := NewRecordServer(upstream, upstream, refererPath, verbose, jsonContentTypes, preserveKeyOrder, sortArrayMembers)
644+
rs := NewRecordServer(upstream, upstream, refererPath, verbose, jsonContentTypes, binaryContentTypes, preserveKeyOrder, sortArrayMembers)
631645

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

internal/server/server.go

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
package server
33

44
import (
5+
"encoding/base64"
56
"encoding/json"
67
"fmt"
78
"goodmock/internal/logging"
@@ -14,12 +15,13 @@ import (
1415
)
1516

1617
// NewServer creates a new mock server
17-
func NewServer(proxyHost, refererPath string, verbose bool) *types.Server {
18+
func NewServer(proxyHost, refererPath string, verbose bool, binaryContentTypes []string) *types.Server {
1819
return &types.Server{
19-
Mappings: make([]types.Mapping, 0),
20-
ProxyHost: proxyHost,
21-
RefererPath: refererPath,
22-
Verbose: verbose,
20+
Mappings: make([]types.Mapping, 0),
21+
ProxyHost: proxyHost,
22+
RefererPath: refererPath,
23+
Verbose: verbose,
24+
BinaryContentTypes: binaryContentTypes,
2325
}
2426
}
2527

@@ -113,7 +115,16 @@ func HandleRequest(s *types.Server, ctx *fasthttp.RequestCtx) {
113115
ctx.SetBody(data)
114116
}
115117
} else if m.Response.Body != "" {
116-
ctx.SetBodyString(m.Response.Body)
118+
if isBinaryResponse(m.Response.Headers, s.BinaryContentTypes) {
119+
decoded, err := base64.StdEncoding.DecodeString(m.Response.Body)
120+
if err == nil {
121+
ctx.SetBody(decoded)
122+
} else {
123+
ctx.SetBodyString(m.Response.Body)
124+
}
125+
} else {
126+
ctx.SetBodyString(m.Response.Body)
127+
}
117128
}
118129

119130
if s.Verbose {
@@ -240,6 +251,38 @@ func LogVerboseRequest(ctx *fasthttp.RequestCtx, method, rawURI string) {
240251
}
241252
}
242253

254+
// isBinaryResponse checks if the response Content-Type matches any of the given binary types.
255+
func isBinaryResponse(headers map[string]any, binaryTypes []string) bool {
256+
if len(binaryTypes) == 0 {
257+
return false
258+
}
259+
for key, value := range headers {
260+
if !strings.EqualFold(key, "Content-Type") {
261+
continue
262+
}
263+
var ct string
264+
switch v := value.(type) {
265+
case string:
266+
ct = v
267+
case []interface{}:
268+
if len(v) > 0 {
269+
if s, ok := v[0].(string); ok {
270+
ct = s
271+
}
272+
}
273+
}
274+
if ct != "" {
275+
mediaType := strings.TrimSpace(strings.SplitN(ct, ";", 2)[0])
276+
for _, bt := range binaryTypes {
277+
if strings.EqualFold(mediaType, bt) {
278+
return true
279+
}
280+
}
281+
}
282+
}
283+
return false
284+
}
285+
243286
func getRequestPattern(m *types.Mapping) string {
244287
if m.Request.URL != "" {
245288
return m.Request.URL

internal/types/types.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,12 @@ type Response struct {
6767

6868
// Server holds the mock server state
6969
type Server struct {
70-
Mu sync.RWMutex
71-
Mappings []Mapping
72-
ProxyHost string
73-
RefererPath string
74-
Verbose bool
70+
Mu sync.RWMutex
71+
Mappings []Mapping
72+
ProxyHost string
73+
RefererPath string
74+
Verbose bool
75+
BinaryContentTypes []string
7576
}
7677

7778
// MatchResult holds the result of matching a request against a stub

main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ func runReplay() {
5151
}
5252

5353
verbose := common.IsVerbose()
54-
s := server.NewServer(proxyHost, refererPath, verbose)
54+
binaryContentTypes := common.ParseBinaryContentTypes()
55+
s := server.NewServer(proxyHost, refererPath, verbose, binaryContentTypes)
5556

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

0 commit comments

Comments
 (0)