From d3ae53dfbf4e7907bcc118c4c270e6a8c8b95783 Mon Sep 17 00:00:00 2001 From: Oscar Blanco Castan Date: Sun, 1 Mar 2026 19:21:50 +0100 Subject: [PATCH] feat: merge duplicate status code responses into oneOf When two responses share the same HTTP status code and content type, the second silently overwrites the first. Fix this by grouping duplicate responses and combining them via jsonschema.OneOf(). Co-Authored-By: Claude Opus 4.6 --- operation.go | 57 ++++++++++++++++++- router_test.go | 19 +++++++ .../duplicate_status_code_responses_3.yaml | 32 +++++++++++ .../duplicate_status_code_responses_31.yaml | 32 +++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 testdata/duplicate_status_code_responses_3.yaml create mode 100644 testdata/duplicate_status_code_responses_31.yaml diff --git a/operation.go b/operation.go index 720d593..cea5051 100644 --- a/operation.go +++ b/operation.go @@ -8,6 +8,7 @@ import ( "github.com/oaswrap/spec/internal/debuglog" specopenapi "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" + "github.com/swaggest/jsonschema-go" "github.com/swaggest/openapi-go" "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/openapi-go/openapi31" @@ -76,7 +77,7 @@ func (oc *operationContextImpl) build() openapi.OperationContext { logger.LogOp(method, path, "add request", value) } - for _, resp := range cfg.Responses { + for _, resp := range mergeResponses(cfg.Responses) { opts, value := oc.buildResponseOpts(resp) oc.op.AddRespStructure(resp.Structure, opts...) logger.LogOp(method, path, "add response", value) @@ -85,6 +86,60 @@ func (oc *operationContextImpl) build() openapi.OperationContext { return oc.op } +// responseKey identifies a unique response slot by HTTP status and content type. +type responseKey struct { + httpStatus int + contentType string +} + +// mergeResponses groups responses by (HTTPStatus, ContentType) and combines +// duplicates into a single response using jsonschema.OneOf. +func mergeResponses(responses []*specopenapi.ContentUnit) []*specopenapi.ContentUnit { + if len(responses) <= 1 { + return responses + } + + type group struct { + key responseKey + items []*specopenapi.ContentUnit + } + + var order []responseKey + groups := make(map[responseKey]*group) + + for _, resp := range responses { + k := responseKey{httpStatus: resp.HTTPStatus, contentType: resp.ContentType} + g, exists := groups[k] + if !exists { + g = &group{key: k} + groups[k] = g + order = append(order, k) + } + g.items = append(g.items, resp) + } + + result := make([]*specopenapi.ContentUnit, 0, len(order)) + for _, k := range order { + g := groups[k] + if len(g.items) == 1 { + result = append(result, g.items[0]) + continue + } + + // Merge multiple structures into oneOf. + structures := make([]interface{}, 0, len(g.items)) + for _, item := range g.items { + structures = append(structures, item.Structure) + } + + merged := *g.items[0] // copy first entry for description, status, etc. + merged.Structure = jsonschema.OneOf(structures...) + result = append(result, &merged) + } + + return result +} + func stringMapToEncodingMap3(enc map[string]string) map[string]openapi3.Encoding { res := map[string]openapi3.Encoding{} for k, v := range enc { diff --git a/router_test.go b/router_test.go index 69c81b1..191be70 100644 --- a/router_test.go +++ b/router_test.go @@ -606,6 +606,25 @@ func TestRouter(t *testing.T) { ) }, }, + { + name: "Duplicate Status Code Responses", + golden: "duplicate_status_code_responses", + setup: func(r spec.Router) { + type SuccessA struct { + Message string `json:"message"` + } + type SuccessB struct { + Count int `json:"count"` + } + r.Get("/mixed", + option.OperationID("getMixed"), + option.Summary("Get Mixed Responses"), + option.Description("Returns one of two possible response shapes."), + option.Response(200, new(SuccessA)), + option.Response(200, new(SuccessB)), + ) + }, + }, { name: "Server Variables", golden: "server_variables", diff --git a/testdata/duplicate_status_code_responses_3.yaml b/testdata/duplicate_status_code_responses_3.yaml new file mode 100644 index 0000000..8ca12d2 --- /dev/null +++ b/testdata/duplicate_status_code_responses_3.yaml @@ -0,0 +1,32 @@ +openapi: 3.0.3 +info: + description: This is the API documentation for Duplicate Status Code Responses + title: 'API Doc: Duplicate Status Code Responses' + version: 1.0.0 +paths: + /mixed: + get: + description: Returns one of two possible response shapes. + operationId: getMixed + responses: + "200": + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/SpecTestSuccessA' + - $ref: '#/components/schemas/SpecTestSuccessB' + description: OK + summary: Get Mixed Responses +components: + schemas: + SpecTestSuccessA: + properties: + message: + type: string + type: object + SpecTestSuccessB: + properties: + count: + type: integer + type: object diff --git a/testdata/duplicate_status_code_responses_31.yaml b/testdata/duplicate_status_code_responses_31.yaml new file mode 100644 index 0000000..24c7670 --- /dev/null +++ b/testdata/duplicate_status_code_responses_31.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + description: This is the API documentation for Duplicate Status Code Responses + title: 'API Doc: Duplicate Status Code Responses' + version: 1.0.0 +paths: + /mixed: + get: + description: Returns one of two possible response shapes. + operationId: getMixed + responses: + "200": + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/SpecTestSuccessA' + - $ref: '#/components/schemas/SpecTestSuccessB' + description: OK + summary: Get Mixed Responses +components: + schemas: + SpecTestSuccessA: + properties: + message: + type: string + type: object + SpecTestSuccessB: + properties: + count: + type: integer + type: object