Skip to content
Closed
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
136 changes: 136 additions & 0 deletions internal/test/components/components.gen.go

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

18 changes: 18 additions & 0 deletions internal/test/components/components.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,24 @@ components:
required:
- type
additionalProperties: true
OneOfObject14:
description: oneOf with discriminator where multiple values map to the same schema (tests deterministic generation)
type: object
properties:
category:
type: string
oneOf:
- $ref: '#/components/schemas/OneOfVariant4'
- $ref: '#/components/schemas/OneOfVariant5'
discriminator:
propertyName: category
mapping:
type_a: '#/components/schemas/OneOfVariant4'
type_b: '#/components/schemas/OneOfVariant4'
type_x: '#/components/schemas/OneOfVariant5'
type_y: '#/components/schemas/OneOfVariant5'
required:
- category
AnyOfObject1:
description: simple anyOf case
anyOf:
Expand Down
93 changes: 93 additions & 0 deletions internal/test/components/components_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,96 @@ func TestMarshalWhenNoUnionValueSet(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, expected, string(bytes))
}

func TestOneOfWithDiscriminator_MultipleMappingsToSameSchema(t *testing.T) {
// This test ensures deterministic code generation when multiple discriminator
// values map to the same schema type (e.g., "type_a" and "type_b" both map to OneOfVariant4).
// The bug was that map iteration order in Go is random, causing generated code to vary.
// This test verifies:
// 1. All discriminator values work correctly in ValueByDiscriminator switch
// 2. From* methods consistently use the first alphabetically sorted discriminator value
// 3. Generated code is deterministic

const variant4a = `{"category": "type_a", "discriminator": "type_a", "name": "test_a"}`
const variant4b = `{"category": "type_b", "discriminator": "type_b", "name": "test_b"}`
const variant5x = `{"category": "type_x", "discriminator": "type_x", "id": 100}`
const variant5y = `{"category": "type_y", "discriminator": "type_y", "id": 200}`

// Test all four discriminator values can be unmarshaled and retrieved
testCases := []struct {
name string
json string
expectedCategory string
expectedVariant interface{}
}{
{
name: "type_a maps to OneOfVariant4",
json: variant4a,
expectedCategory: "type_a",
expectedVariant: OneOfVariant4{Discriminator: "type_a", Name: "test_a"},
},
{
name: "type_b also maps to OneOfVariant4",
json: variant4b,
expectedCategory: "type_b",
expectedVariant: OneOfVariant4{Discriminator: "type_b", Name: "test_b"},
},
{
name: "type_x maps to OneOfVariant5",
json: variant5x,
expectedCategory: "type_x",
expectedVariant: OneOfVariant5{Discriminator: "type_x", Id: 100},
},
{
name: "type_y also maps to OneOfVariant5",
json: variant5y,
expectedCategory: "type_y",
expectedVariant: OneOfVariant5{Discriminator: "type_y", Id: 200},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var dst OneOfObject14
err := json.Unmarshal([]byte(tc.json), &dst)
require.NoError(t, err)

discriminator, err := dst.Discriminator()
require.NoError(t, err)
assert.Equal(t, tc.expectedCategory, discriminator)

value, err := dst.ValueByDiscriminator()
require.NoError(t, err)
assert.Equal(t, tc.expectedVariant, value)
})
}

// Test From* methods use the first alphabetically sorted discriminator value
// When multiple values map to the same schema, the generated code should consistently
// use the first one alphabetically (type_a for OneOfVariant4, type_x for OneOfVariant5)
t.Run("FromOneOfVariant4 uses first alphabetical discriminator", func(t *testing.T) {
var dst OneOfObject14
err := dst.FromOneOfVariant4(OneOfVariant4{Discriminator: "unused", Name: "test"})
require.NoError(t, err)

// Should set category to "type_a" (first alphabetically of [type_a, type_b])
assert.Equal(t, "type_a", dst.Category)

marshaled, err := json.Marshal(dst)
require.NoError(t, err)
assertJsonEqual(t, []byte(`{"category":"type_a","discriminator":"unused","name":"test"}`), marshaled)
})

t.Run("FromOneOfVariant5 uses first alphabetical discriminator", func(t *testing.T) {
var dst OneOfObject14
err := dst.FromOneOfVariant5(OneOfVariant5{Discriminator: "unused", Id: 42})
require.NoError(t, err)

// Should set category to "type_x" (first alphabetically of [type_x, type_y])
assert.Equal(t, "type_x", dst.Category)

marshaled, err := json.Marshal(dst)
require.NoError(t, err)
assertJsonEqual(t, []byte(`{"category":"type_x","discriminator":"unused","id":42}`), marshaled)
})
}
39 changes: 35 additions & 4 deletions pkg/codegen/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package codegen
import (
"errors"
"fmt"
"sort"
"strings"

"github.com/getkin/kin-openapi/openapi3"
Expand Down Expand Up @@ -229,6 +230,17 @@ func (d *Discriminator) PropertyName() string {
return SchemaNameToTypeName(d.Property)
}

// SortedMappingKeys returns the discriminator mapping keys in sorted order
// to ensure deterministic code generation
func (d *Discriminator) SortedMappingKeys() []string {
keys := make([]string, 0, len(d.Mapping))
for k := range d.Mapping {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

// UnionElement describe union element, based on prefix externalRef\d+ and real ref name from external schema.
type UnionElement string

Expand Down Expand Up @@ -895,12 +907,20 @@ func generateUnion(outSchema *Schema, elements openapi3.SchemaRefs, discriminato
}

// Explicit mapping.
// Need to process in sorted order for deterministic output
var mapped bool
for k, v := range discriminator.Mapping {
sortedKeys := make([]string, 0, len(discriminator.Mapping))
for k := range discriminator.Mapping {
sortedKeys = append(sortedKeys, k)
}
sort.Strings(sortedKeys)

for _, k := range sortedKeys {
v := discriminator.Mapping[k]
if v == element.Ref {
outSchema.Discriminator.Mapping[k] = elementSchema.GoType
mapped = true
break
// Do NOT break - multiple keys can map to the same ref
}
}
// Implicit mapping.
Expand All @@ -911,8 +931,19 @@ func generateUnion(outSchema *Schema, elements openapi3.SchemaRefs, discriminato
outSchema.UnionElements = append(outSchema.UnionElements, UnionElement(elementSchema.GoType))
}

if (outSchema.Discriminator != nil) && len(outSchema.Discriminator.Mapping) != len(elements) {
return errors.New("discriminator: not all schemas were mapped")
// Validate that all union elements have at least one discriminator mapping
if outSchema.Discriminator != nil {
// Build set of mapped types
mappedTypes := make(map[string]bool)
for _, goType := range outSchema.Discriminator.Mapping {
mappedTypes[goType] = true
}
// Check all union elements are mapped
for _, element := range outSchema.UnionElements {
if !mappedTypes[string(element)] {
return fmt.Errorf("discriminator: union element %s is not mapped", element)
}
}
}

return nil
Expand Down
17 changes: 12 additions & 5 deletions pkg/codegen/templates/union.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
// From{{ .Method }} overwrites any union data inside the {{$typeName}} as the provided {{.}}
func (t *{{$typeName}}) From{{ .Method }} (v {{.}}) error {
{{if $discriminator -}}
{{range $value, $type := $discriminator.Mapping -}}
{{if eq $type $element -}}
{{$matched := false -}}
{{range $value := $discriminator.SortedMappingKeys -}}
{{$type := index $discriminator.Mapping $value -}}
{{if and (eq $type $element) (not $matched) -}}
{{$hasProperty := false -}}
{{range $properties -}}
{{if eq .GoFieldName $discriminator.PropertyName -}}
Expand All @@ -24,6 +26,7 @@
{{end -}}
{{end -}}
{{if not $hasProperty}}v.{{$discriminator.PropertyName}} = "{{$value}}"{{end}}
{{$matched = true -}}
{{end -}}
{{end -}}
{{end -}}
Expand All @@ -35,8 +38,10 @@
// Merge{{ .Method }} performs a merge with any union data inside the {{$typeName}}, using the provided {{.}}
func (t *{{$typeName}}) Merge{{ .Method }} (v {{.}}) error {
{{if $discriminator -}}
{{range $value, $type := $discriminator.Mapping -}}
{{if eq $type $element -}}
{{$matched := false -}}
{{range $value := $discriminator.SortedMappingKeys -}}
{{$type := index $discriminator.Mapping $value -}}
{{if and (eq $type $element) (not $matched) -}}
{{$hasProperty := false -}}
{{range $properties -}}
{{if eq .GoFieldName $discriminator.PropertyName -}}
Expand All @@ -45,6 +50,7 @@
{{end -}}
{{end -}}
{{if not $hasProperty}}v.{{$discriminator.PropertyName}} = "{{$value}}"{{end}}
{{$matched = true -}}
{{end -}}
{{end -}}
{{end -}}
Expand Down Expand Up @@ -75,7 +81,8 @@
return nil, err
}
switch discriminator{
{{range $value, $type := $discriminator.Mapping -}}
{{range $value := $discriminator.SortedMappingKeys -}}
{{$type := index $discriminator.Mapping $value -}}
case "{{$value}}":
return t.As{{$type}}()
{{end -}}
Expand Down