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
29 changes: 29 additions & 0 deletions internal/test/aggregates/allof/allof_new_merge.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions internal/test/aggregates/allof/allof_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"reflect"
"testing"

openapi_types "github.com/oapi-codegen/runtime/types"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -82,3 +83,28 @@ func TestIssue1219(t *testing.T) {
_, exist = reflect.TypeOf(MergeWithoutWithout{}).FieldByName("AdditionalProperties")
assert.False(t, exist)
}

// issue #2524: a type or format declared by only one allOf member must
// propagate to the merged schema instead of being silently dropped (type) or
// erroring (format), and member order must not change the generated shape.
// The checks are compile-time: each pair of order-reversed schemas must lower
// to the same alias.
func TestIssue2524(t *testing.T) {
// A typeless properties-only member alongside `type: string` generates a
// string (properties don't constrain non-object instances), not a struct.
// The conversions fail to compile if either shape regresses to a struct.
assert.IsType(t, "", NewMergeTypeFromSecondMember("s"))
assert.IsType(t, "", NewMergeTypeFromFirstMember("s"))

// A 3.1 multi-type union member lowers to `any` in both orders.
var union1 NewMergeUnionTypeFromSecondMember = "either a string"
var union2 NewMergeUnionTypeFromFirstMember = 1.5
assert.NotNil(t, union1)
assert.NotNil(t, union2)

// The decorator idiom over a format-carrying scalar propagates the format
// (previously a hard "can not merge incompatible formats" error). The
// composite literals fail to compile if the aliases regress to string.
assert.IsType(t, openapi_types.UUID{}, NewMergeFormatFromRef{})
assert.IsType(t, openapi_types.UUID{}, NewMergeFormatFromRefReversed{})
}
53 changes: 53 additions & 0 deletions internal/test/aggregates/allof/spec_new_merge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,56 @@ components:
required: [ additionalProperty ]
allOf:
- $ref: "#/components/schemas/NewMergePerson"
# issue #2524: a type declared by only one allOf member must propagate to
# the merged schema regardless of member order. Previously the merge kept
# the first member's (absent) type, so the pairs below generated different
# Go shapes depending on ordering.
NewMergeTypeFromSecondMember:
description: |
The type constraint comes from the second member; the first only
contributes properties (which don't constrain non-object instances).
Must generate a string, not a struct.
allOf:
- properties:
name:
type: string
- type: string
NewMergeTypeFromFirstMember:
description: Member order reversed; must generate the same shape.
allOf:
- type: string
- properties:
name:
type: string
NewMergeUnionTypeFromSecondMember:
description: |
A 3.1 multi-type union declared by the second member. Unions lower to
`any`; the point is that both orderings agree instead of one silently
generating a struct.
allOf:
- properties:
name:
type: string
- type: [ string, number ]
NewMergeUnionTypeFromFirstMember:
description: Member order reversed; must generate the same shape.
allOf:
- type: [ string, number ]
- properties:
name:
type: string
# issue #2524 (format half): a format declared by only one member must
# propagate rather than error, which is what the allOf decorator idiom
# produces over a $ref to a format-carrying scalar.
NewMergeFormatFromRef:
allOf:
- $ref: "#/components/schemas/NewMergeUuid"
- description: decorator member with no format of its own
NewMergeFormatFromRefReversed:
description: Decorator first; type and format both come from the second member.
allOf:
- description: decorator member with no format of its own
- $ref: "#/components/schemas/NewMergeUuid"
NewMergeUuid:
type: string
format: uuid
25 changes: 21 additions & 4 deletions pkg/codegen/merge_schemas.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,15 +292,31 @@ func mergeOpenapiSchemas(s1, s2 openapi3.Schema, allOf bool, seenSchemaRef map[s
result.AnyOf = anyOf
result.AllOf = append(s1.AllOf, s2.AllOf...)

// Type conflicts are an error only when both members declare a type.
// When exactly one declares one, it propagates: a typeless member
// contributes its other constraints (properties, required, ...) without
// erasing the sibling's type. Taking s1.Type unconditionally here used
// to silently drop s2's type, making the generated shape depend on
// allOf member order (issue #2524).
if s1.Type.Slice() != nil && s2.Type.Slice() != nil && !equalTypes(s1.Type, s2.Type) {
return openapi3.Schema{}, fmt.Errorf("can not merge incompatible types: %v, %v", s1.Type.Slice(), s2.Type.Slice())
}
result.Type = s1.Type
if result.Type.Slice() == nil {
result.Type = s2.Type
}

if s1.Format != s2.Format {
// Format follows the same rule: error only when both members declare
// a format and they differ. Erroring on the set-vs-unset case made the
// allOf decorator idiom (e.g. $ref + nullable, issue #1898) fail for
// refs to format-carrying scalars.
if s1.Format != "" && s2.Format != "" && s1.Format != s2.Format {
return openapi3.Schema{}, errors.New("can not merge incompatible formats")
}
result.Format = s1.Format
if result.Format == "" {
result.Format = s2.Format
}

// For Enums, do we union, or intersect? This is a bit vague. I choose
// to be more permissive and union.
Expand Down Expand Up @@ -351,9 +367,10 @@ func mergeOpenapiSchemas(s1, s2 openapi3.Schema, allOf bool, seenSchemaRef map[s
// -> error from
// equalTypes
//
// Because result.Type was already assigned (line 232) and carries any
// "null" entry forward, the merged result is correctly nullable in 3.1
// without needing to touch result.Nullable. The result.Nullable copy
// Because result.Type was already assigned above and carries any
// "null" entry forward — from whichever member declared a type — the
// merged result is correctly nullable in 3.1 without needing to touch
// result.Nullable. The result.Nullable copy
// below is a no-op in 3.1 (s1.Nullable is always false there) but kept
// for 3.0 correctness, where Nullable is the only nullability carrier.
//
Expand Down
120 changes: 120 additions & 0 deletions pkg/codegen/merge_schemas_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,126 @@ func TestMergeOpenapiSchemas_DiscriminatorPropagation(t *testing.T) {
})
}

// TestMergeOpenapiSchemas_TypePropagation covers the one-sided type rule
// (issue #2524): a type declared by only one allOf member propagates to the
// merged result regardless of member order, while two members declaring
// different types remain an error.
func TestMergeOpenapiSchemas_TypePropagation(t *testing.T) {
stringType := &openapi3.Types{"string"}
numberType := &openapi3.Types{"number"}
unionType := &openapi3.Types{"string", "number"}

t.Run("type on s2 only propagates", func(t *testing.T) {
s1 := openapi3.Schema{}
s2 := openapi3.Schema{Type: stringType}

result, err := mergeOpenapiSchemas(s1, s2, true, make(map[string]bool))
require.NoError(t, err)
assert.Equal(t, stringType, result.Type)
})

t.Run("type on s1 only propagates", func(t *testing.T) {
s1 := openapi3.Schema{Type: stringType}
s2 := openapi3.Schema{}

result, err := mergeOpenapiSchemas(s1, s2, true, make(map[string]bool))
require.NoError(t, err)
assert.Equal(t, stringType, result.Type)
})

t.Run("multi-type union on s2 only propagates", func(t *testing.T) {
s1 := openapi3.Schema{
Properties: openapi3.Schemas{
"name": openapi3.NewSchemaRef("", openapi3.NewStringSchema()),
},
}
s2 := openapi3.Schema{Type: unionType}

result, err := mergeOpenapiSchemas(s1, s2, true, make(map[string]bool))
require.NoError(t, err)
assert.Equal(t, unionType, result.Type)
})

t.Run("equal types on both members merge", func(t *testing.T) {
s1 := openapi3.Schema{Type: stringType}
s2 := openapi3.Schema{Type: stringType}

result, err := mergeOpenapiSchemas(s1, s2, true, make(map[string]bool))
require.NoError(t, err)
assert.Equal(t, stringType, result.Type)
})

t.Run("different types on both members error", func(t *testing.T) {
s1 := openapi3.Schema{Type: stringType}
s2 := openapi3.Schema{Type: numberType}

_, err := mergeOpenapiSchemas(s1, s2, true, make(map[string]bool))
require.Error(t, err)
assert.Contains(t, err.Error(), "incompatible types")
})

t.Run("neither member typed stays typeless", func(t *testing.T) {
s1 := openapi3.Schema{}
s2 := openapi3.Schema{}

result, err := mergeOpenapiSchemas(s1, s2, true, make(map[string]bool))
require.NoError(t, err)
assert.Nil(t, result.Type.Slice())
})
}

// TestMergeOpenapiSchemas_FormatPropagation covers the same one-sided rule
// for format: a format declared by only one member propagates instead of
// erroring, which is what the allOf decorator idiom over format-carrying
// scalars produces (e.g. $ref to {type: string, format: uuid} + nullable).
func TestMergeOpenapiSchemas_FormatPropagation(t *testing.T) {
t.Run("format on s2 only propagates", func(t *testing.T) {
s1 := openapi3.Schema{}
s2 := openapi3.Schema{Format: "uuid"}

result, err := mergeOpenapiSchemas(s1, s2, true, make(map[string]bool))
require.NoError(t, err)
assert.Equal(t, "uuid", result.Format)
})

t.Run("format on s1 only propagates", func(t *testing.T) {
s1 := openapi3.Schema{Format: "uuid"}
s2 := openapi3.Schema{}

result, err := mergeOpenapiSchemas(s1, s2, true, make(map[string]bool))
require.NoError(t, err)
assert.Equal(t, "uuid", result.Format)
})

t.Run("equal formats merge", func(t *testing.T) {
s1 := openapi3.Schema{Format: "uuid"}
s2 := openapi3.Schema{Format: "uuid"}

result, err := mergeOpenapiSchemas(s1, s2, true, make(map[string]bool))
require.NoError(t, err)
assert.Equal(t, "uuid", result.Format)
})

t.Run("different formats error", func(t *testing.T) {
s1 := openapi3.Schema{Format: "uuid"}
s2 := openapi3.Schema{Format: "date-time"}

_, err := mergeOpenapiSchemas(s1, s2, true, make(map[string]bool))
require.Error(t, err)
assert.Contains(t, err.Error(), "incompatible formats")
})

t.Run("nullable decorator over format-carrying scalar merges", func(t *testing.T) {
s1 := openapi3.Schema{Type: &openapi3.Types{"string"}, Format: "uuid"}
s2 := openapi3.Schema{Nullable: true}

result, err := mergeOpenapiSchemas(s1, s2, true, make(map[string]bool))
require.NoError(t, err)
assert.Equal(t, "uuid", result.Format)
assert.True(t, result.Nullable)
})
}

// TestMergeOpenapiSchemas_NullableUnion covers the OpenAPI 3.0 idiom of
// decorating a $ref with `nullable: true` via allOf (issue #1898). Members
// disagreeing on nullability must merge (union) rather than error.
Expand Down