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
602 changes: 602 additions & 0 deletions internal/test/issues/issue-2389/api.gen.go

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions internal/test/issues/issue-2389/cfg.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=../../../../configuration-schema.json
package: issue2389
output: api.gen.go
generate:
models: true
client: true
3 changes: 3 additions & 0 deletions internal/test/issues/issue-2389/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package issue2389

//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=cfg.yaml spec.yaml
57 changes: 57 additions & 0 deletions internal/test/issues/issue-2389/issue_2389_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package issue2389

import (
"testing"

"github.com/stretchr/testify/assert"
)

// When a components/responses entry exposes the same schema under more than one
// content type (here application/json + application/xml), the client response
// wrapper grows one field per content type (JSON500, XML500). The regression in
// issue #2389 typed those fields as undefined per-content-type names
// (*ErrorResponseApplicationJSON / *ErrorResponseApplicationXML), so the
// generated package failed to compile. Both fields must instead point at the
// single declared component type, ErrorResponse.
//
// This test exists primarily to prove the generated package compiles; the
// assignments below would not type-check if either field had a different (or
// undefined) type.
func TestResponseComponentMultipleContentTypesShareDeclaredType(t *testing.T) {
body := &ErrorResponse{}

resp := GetThingResponse{
JSON500: body,
XML500: body,
}

assert.Same(t, resp.JSON500, resp.XML500)
}

// A component response only declares a Go type for its JSON content, so an
// XML-only component response has no declared base type. The wrapper field must
// point at the XML content's own schema type (XmlError); pointing at the
// component base type would reference an undeclared name and fail to compile.
func TestResponseComponentXMLOnlyUsesContentSchemaType(t *testing.T) {
resp := GetXMLOnlyResponse{
XML500: &XmlError{},
}

assert.NotNil(t, resp.XML500)
}

// When the JSON and XML content of a component response resolve to different
// schemas, the two wrapper fields must keep distinct types: JSON uses the
// declared component type (MixedError) and XML keeps its own schema type
// (XmlError). The regression shared the JSON type for both, which would
// silently decode XML into the JSON-shaped type. These statements would not
// type-check if the fields shared a type.
func TestResponseComponentMixedDifferingSchemasKeepDistinctTypes(t *testing.T) {
resp := GetMixedResponse{
JSON500: &MixedError{},
XML500: &XmlError{},
}

assert.NotNil(t, resp.JSON500)
assert.NotNil(t, resp.XML500)
}
98 changes: 98 additions & 0 deletions internal/test/issues/issue-2389/spec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
openapi: "3.0.0"
info:
title: issue-2389
version: 1.0.0
paths:
/thing:
get:
operationId: getThing
responses:
"200":
description: ok
content:
application/json:
schema:
type: string
"500":
$ref: '#/components/responses/error'
/xml-only:
get:
operationId: getXMLOnly
responses:
"200":
description: ok
content:
application/json:
schema:
type: string
"500":
$ref: '#/components/responses/xmlOnlyError'
/mixed:
get:
operationId: getMixed
responses:
"200":
description: ok
content:
application/json:
schema:
type: string
"500":
$ref: '#/components/responses/mixedError'
components:
schemas:
errorResponse:
type: object
properties:
code:
type: integer
format: int32
message:
type: string
jsonError:
type: object
properties:
code:
type: integer
xmlError:
type: object
properties:
reason:
type: string
responses:
error:
description: Error response
x-go-name: ErrorResponse
# A single response component exposing the same schema under more than
# one content type. Both content types must reference the one declared
# Go type (ErrorResponse); the regression generated undefined
# per-content-type names (ErrorResponseApplicationJSON / ...XML).
content:
application/json:
schema:
$ref: '#/components/schemas/errorResponse'
application/xml:
schema:
$ref: '#/components/schemas/errorResponse'
xmlOnlyError:
description: XML-only response component
# No JSON content. GenerateTypesForResponses only declares a Go type for
# JSON media, so the wrapper field must point at the XML content's own
# schema type (XmlError), not the (never-declared) component base type.
content:
application/xml:
schema:
$ref: '#/components/schemas/xmlError'
mixedError:
description: JSON and XML content with different schemas
# The JSON and XML content types resolve to different schemas. The JSON
# field uses the declared component type; the XML field must keep its own
# schema type (XmlError) rather than silently decoding XML into the JSON
# type.
content:
application/json:
schema:
$ref: '#/components/schemas/jsonError'
application/xml:
schema:
$ref: '#/components/schemas/xmlError'
18 changes: 6 additions & 12 deletions pkg/codegen/codegen.go
Original file line number Diff line number Diff line change
Expand Up @@ -1094,15 +1094,9 @@ func GenerateTypesForResponses(t *template.Template, responses openapi3.Response
// handle media types that conform to JSON. Other responses should
// simply be specified as strings or byte arrays.
response := responseOrRef.Value
content := response.Content

jsonCount := 0
for mediaType := range response.Content {
if util.IsMediaTypeJson(mediaType) {
jsonCount++
}
}

SortedMapKeys := SortedMapKeys(response.Content)
SortedMapKeys := SortedMapKeys(content)
for _, mediaType := range SortedMapKeys {
response := response.Content[mediaType]
if !util.IsMediaTypeJson(mediaType) {
Expand All @@ -1121,8 +1115,8 @@ func GenerateTypesForResponses(t *template.Template, responses openapi3.Response
// TODO: revisit this at the next major version change —
// always include the media type in the schema path.
schemaPath := []string{responseName}
if jsonCount > 1 && globalState.options.OutputOptions.ResolveTypeNameCollisions {
schemaPath = append(schemaPath, mediaTypeToCamelCase(mediaType))
if suffix := responseMediaTypeSuffix(content, mediaType); suffix != "" && globalState.options.OutputOptions.ResolveTypeNameCollisions {
schemaPath = append(schemaPath, suffix)
}
goType, err := GenerateGoSchema(response.Schema, schemaPath)
if err != nil {
Expand Down Expand Up @@ -1153,8 +1147,8 @@ func GenerateTypesForResponses(t *template.Template, responses openapi3.Response
typeDef.TypeName = SchemaNameToTypeName(refType)
}

if jsonCount > 1 {
typeDef.TypeName = typeDef.TypeName + mediaTypeToCamelCase(mediaType)
if suffix := responseMediaTypeSuffix(content, mediaType); suffix != "" {
typeDef.TypeName = typeDef.TypeName + suffix
}

types = append(types, typeDef)
Expand Down
50 changes: 39 additions & 11 deletions pkg/codegen/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,33 @@ func (o *OperationDefinition) DeprecationComment() string {
return DeprecationComment(reason)
}

// responseMediaTypeSuffix returns the media-type discriminator suffix (e.g.
// "ApplicationJSON") that must be appended to the Go type name generated for a
// single content entry of a response, or "" when the base name is used as-is.
//
// A suffix is required only when one response carries more than one
// JSON-compatible content type, and only the JSON entries receive one: non-JSON
// media types (XML, YAML, ...) never get a dedicated generated type, so they
// reuse the base name. Both the type declaration (GenerateTypesForResponses) and
// the client response-wrapper field types (GetResponseTypeDefinitions) must make
// this identical decision — otherwise the wrapper references per-content-type
// type names that were never declared (see issue #2389).
func responseMediaTypeSuffix(content openapi3.Content, mediaType string) string {
if !util.IsMediaTypeJson(mediaType) {
return ""
}
jsonCount := 0
for mt := range content {
if util.IsMediaTypeJson(mt) {
jsonCount++
}
}
if jsonCount <= 1 {
return ""
}
return mediaTypeToCamelCase(mediaType)
}

// GetResponseTypeDefinitions produces a list of type definitions for a given Operation for the response
// types which we know how to parse. These will be turned into fields on a
// response object for automatic deserialization of responses in the generated
Expand All @@ -582,13 +609,6 @@ func (o *OperationDefinition) GetResponseTypeDefinitions() ([]ResponseTypeDefini

// We can only generate a type if we have a value:
if responseRef.Value != nil {
supportedCount := 0
for mediaType := range responseRef.Value.Content {
if isMediaTypeSupported(mediaType) {
supportedCount++
}
}

sortedContentKeys := SortedMapKeys(responseRef.Value.Content)
for _, contentTypeName := range sortedContentKeys {
contentType := responseRef.Value.Content[contentTypeName]
Expand Down Expand Up @@ -662,16 +682,24 @@ func (o *OperationDefinition) GetResponseTypeDefinitions() ([]ResponseTypeDefini
ContentTypeName: contentTypeName,
AdditionalTypeDefinitions: responseSchema.GetAdditionalTypeDefs(),
}
if IsGoTypeReference(responseRef.Ref) {
// A component response only declares a Go type for its JSON
// content (GenerateTypesForResponses skips non-JSON media).
// Point the wrapper field at that declared component type for
// JSON content only; non-JSON content keeps the type derived
// from its own schema above, so it neither references an
// undeclared base type (e.g. an XML-only component response)
// nor silently decodes into the JSON type when the JSON and
// non-JSON schemas differ.
if IsGoTypeReference(responseRef.Ref) && util.IsMediaTypeJson(contentTypeName) {
refType, err := RefPathToGoType(responseRef.Ref)
if err != nil {
return nil, fmt.Errorf("error dereferencing response Ref: %w", err)
}
if supportedCount > 1 {
if suffix := responseMediaTypeSuffix(responseRef.Value.Content, contentTypeName); suffix != "" {
if resolved := resolvedNameForRefPath(responseRef.Ref, contentTypeName); resolved != "" {
refType = resolved + mediaTypeToCamelCase(contentTypeName)
refType = resolved + suffix
} else {
refType += mediaTypeToCamelCase(contentTypeName)
refType += suffix
}
}
td.Schema.RefType = refType
Comment thread
mromaszewicz marked this conversation as resolved.
Expand Down
22 changes: 0 additions & 22 deletions pkg/codegen/template_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,28 +47,6 @@ var (
titleCaser = cases.Title(language.English)
)

// isMediaTypeSupported reports whether code generation produces a typed
// body for this media type. Today this is the closed set of JSON / YAML /
// XML variants the response and request templates know how to handle —
// see the typeName switch in GetResponseTypeDefinitions and the body
// definition switch in GenerateBodyDefinitions. A future configuration
// option is intended to let users extend this list.
func isMediaTypeSupported(mediaType string) bool {
switch {
case slices.Contains(contentTypesHalJSON, mediaType):
return true
case slices.Contains(contentTypesJSON, mediaType):
return true
case util.IsMediaTypeJson(mediaType):
return true
case slices.Contains(contentTypesYAML, mediaType):
return true
case slices.Contains(contentTypesXML, mediaType):
return true
}
return false
}

// genParamArgs takes an array of Parameter definition, and generates a valid
// Go parameter declaration from them, eg:
// ", foo int, bar string, baz float32". The preceding comma is there to save
Expand Down