Skip to content
Open
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
22 changes: 22 additions & 0 deletions compilers/openapi/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ func conformanceCases() []conformanceCase {
{"discriminator-inheritance", assertDiscriminatorInheritance, []string{"tagged-unions", "inheritance"}},
{"discriminator-default-mapping", assertDiscriminatorDefaultMapping, []string{"tagged-unions"}},
{"discriminator-transitive", assertDiscriminatorTransitive, []string{"tagged-unions", "inheritance"}},
{"discriminator-alias-mapping", assertDiscriminatorAliasMapping, []string{"tagged-unions"}},
{"unhomed-keywords", assertUnhomedKeywords, nil},
{"codeclared-keywords", assertCoDeclaredKeywords, []string{"intersection", "literal-types", "enums-string"}},
{"codeclared-schema-content", assertCoDeclaredSchemaContent, nil},
Expand Down Expand Up @@ -929,6 +930,27 @@ func assertDiscriminatorTransitive(t *testing.T, doc *ir.Document, _ []ir.Diagno
}
}

// assertDiscriminatorAliasMapping covers a mapping that spells two keys for one
// subtype (GitHub #410). Model.DiscriminatorValue holds one value and a mapping
// is unordered, so the subtype takes the smallest key in byte order whichever
// was written first — the fixture writes the larger first — while the base's
// mapping keeps both, and the election is reported at the subtype as
// information since nothing is lost.
func assertDiscriminatorAliasMapping(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) {
pet, ok := doc.Types[namedID("Pet")].(*ir.Model)
require.True(t, ok, "the base is a Model")
require.NotNil(t, pet.Discriminator)
assert.Equal(t, map[string]ir.TypeID{"alpha": namedID("Dog"), "zulu": namedID("Dog")},
pet.Discriminator.Mapping, "the base keeps every key")

dog, ok := doc.Types[namedID("Dog")].(*ir.Model)
require.True(t, ok, "Dog composes as a Model")
assert.Equal(t, "alpha", dog.DiscriminatorValue,
"the smallest key in byte order, not the first written")
assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/Dog"),
"the election is reported at the subtype: %+v", diags)
}

// assertDiscriminatorDefaultMapping pins Discriminator.Default, whose only source
// is the 3.2 discriminator.defaultMapping — and with it that a 3.2-only schema
// keyword compiles without an error diagnostic (GitHub #146). The library checks
Expand Down
53 changes: 38 additions & 15 deletions compilers/openapi/internal/schema/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ func lowerAllOf(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth i
if d != nil {
m.Discriminator = d
}
m.DiscriminatorValue = subtypeDiscriminatorValue(c, ts, s, common.ID, pointer)
tag, tagDiags := subtypeDiscriminatorValue(c, ts, s, common.ID, pointer)
m.DiscriminatorValue = tag
diags = append(diags, tagDiags...)
return m
})
return id, diags
Expand Down Expand Up @@ -429,34 +431,53 @@ func refBranchTarget(b *oas3.JSONSchema[oas3.Referenceable]) *oas3.Schema {
// no discriminator of its own, and reading one hop found nothing there and
// dropped the key the ancestor spells for this subtype without a word
// (GitHub #305).
func subtypeDiscriminatorValue(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, id ir.TypeID, pointer string) string {
//
// A mapping may spell several keys for one subtype — alias tags — and the field
// holds one. The smallest key in byte order is elected, because a mapping is
// unordered and the first key written is not a property of the document
// (GitHub #410); the base's Discriminator keeps every key, so the election
// narrows what this field shows and loses nothing, and it is reported as such.
func subtypeDiscriminatorValue(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, id ir.TypeID, pointer string) (string, []ir.Diagnostic) {
ds := ancestorDiscriminators(s)
if len(ds) == 0 {
return ""
return "", nil
}
for _, d := range ds {
if tag, ok := mappingTagFor(c, ts, d, id); ok {
return tag
tags := mappingTagsFor(c, ts, d, id)
if len(tags) == 0 {
continue
}
if len(tags) == 1 {
return tags[0], nil
}
return tags[0], []ir.Diagnostic{c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, pointer,
"the discriminator mapping names this schema under %d keys (%s); "+
"discriminatorValue holds the smallest in byte order, and the base's mapping keeps them all",
len(tags), strings.Join(tags, ", "))}
}
return refLastSegment(pointer)
return refLastSegment(pointer), nil
}

// mappingTagFor returns the key d's mapping spells for the type id, and whether
// the mapping names it at all. The two answers are distinct: a mapping that
// names no target for id leaves the caller to fall back to the implicit name,
// which an empty key would be indistinguishable from.
func mappingTagFor(c lowering.Ctx, ts *compile.Types, d *oas3.Discriminator, id ir.TypeID) (string, bool) {
// mappingTagsFor returns every key d's mapping spells for the type id, sorted
// in byte order, or nothing when the mapping does not name it. An empty result
// is what sends the caller to the implicit name, and it is distinct from a
// mapping that names id under the empty key, which returns that one key.
//
// Sorted rather than in source order because the caller elects the first, and
// a mapping's key order is not a property of the document it is written in.
func mappingTagsFor(c lowering.Ctx, ts *compile.Types, d *oas3.Discriminator, id ir.TypeID) []string {
m := d.GetMapping()
if m == nil {
return "", false
return nil
}
var tags []string
for tag, target := range m.All() {
if tid, ok := mappingTargetID(c, ts, target); ok && tid == id {
return tag, true
tags = append(tags, tag)
}
}
return "", false
slices.Sort(tags)
return tags
}

// maxDiscriminatorAncestorDepth bounds how many composition levels
Expand Down Expand Up @@ -917,7 +938,9 @@ func buildComposedVariant(c lowering.Ctx, ts *compile.Types, anchors *AnchorInde
// The tag is the enclosing schema's: it is what a base's mapping names, and
// the variants are its lowering. No discriminator of its own can be declared
// here — a schema that declares one is never distributed.
m.DiscriminatorValue = subtypeDiscriminatorValue(c, ts, body.schema, body.id, body.pointer)
tag, tagDiags := subtypeDiscriminatorValue(c, ts, body.schema, body.id, body.pointer)
m.DiscriminatorValue = tag
diags = append(diags, tagDiags...)
return m, diags
}

Expand Down
51 changes: 51 additions & 0 deletions compilers/openapi/internal/schema/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2734,3 +2734,54 @@ func TestUnionCombinators_KeepingIsOrderIndependent(t *testing.T) {
"declaring the reference before or after the union must not change the IR")
assert.Empty(t, cmp.Diff(first.Types, last.Types), "nor any name hint in the registry")
}

// TestAllOf_DiscriminatorAliasTagIsOrderInvariant pins that a subtype two mapping
// keys name takes the same tag whichever key is written first (GitHub #410).
// Model.DiscriminatorValue holds one value, and a mapping is unordered, so the
// key it holds must be chosen by a rule the source order cannot reach: the
// smallest in byte order. The base keeps every key, so nothing is lost, and the
// election is reported so a reader knows the subtype's tag set is wider than
// the field shows.
func TestAllOf_DiscriminatorAliasTagIsOrderInvariant(t *testing.T) {
t.Parallel()
const base = ` Pet:
type: object
required: [k]
properties: {k: {type: string}}
discriminator:
propertyName: k
mapping:
`
const sub = ` Dog:
allOf: [{$ref: '#/components/schemas/Pet'}]
properties: {bark: {type: boolean}}
`
const alpha = " alpha: '#/components/schemas/Dog'\n"
const zulu = " zulu: '#/components/schemas/Dog'\n"

for _, tc := range []struct{ name, mapping string }{
{"zulu first", zulu + alpha},
{"alpha first", alpha + zulu},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
doc, diags := lowerSpec(t, openapitest.ComponentSpec(base+tc.mapping+sub))
openapitest.RequireNoErrorDiags(t, diags)

dog, ok := doc.Types[componentID("Dog")].(*ir.Model)
require.True(t, ok, "Dog should be a model")
assert.Equal(t, "alpha", dog.DiscriminatorValue,
"the smallest key in byte order, whichever was written first")

pet, ok := doc.Types[componentID("Pet")].(*ir.Model)
require.True(t, ok, "Pet should be a model")
assert.Equal(t, map[string]ir.TypeID{"alpha": componentID("Dog"), "zulu": componentID("Dog")},
pet.Discriminator.Mapping, "the base keeps both keys")

assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/Dog"),
"the election is reported at the subtype: %+v", diags)
assert.True(t, openapitest.HasDiagAt(diags, diag.DegradedConstruct, ir.SeverityInfo),
"as information, since the base's mapping loses nothing")
})
}
}
Loading
Loading