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
45 changes: 45 additions & 0 deletions compilers/openapi/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ func conformanceCases() []conformanceCase {
{"allof-boolean-branch", assertAllOfBooleanBranch},
{"oneof-discriminated", assertOneOfDiscriminated},
{"discriminator-inheritance", assertDiscriminatorInheritance},
{"discriminator-transitive", assertDiscriminatorTransitive},
{"discriminator-default-mapping", assertDiscriminatorDefaultMapping},
{"unhomed-keywords", assertUnhomedKeywords},
{"codeclared-keywords", assertCoDeclaredKeywords},
Expand Down Expand Up @@ -750,6 +751,50 @@ func assertDiscriminatorInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagn
}
}

// assertDiscriminatorTransitive covers a hierarchy deeper than the two levels
// discriminator-inheritance reaches: the subtype's own allOf branch names an
// intermediate schema that declares no discriminator, so the tag value can only
// come from an ancestor further up (GitHub #305).
//
// Depth is the whole point, so what each subtype composes is pinned beside its
// tag: Puppy and Whelp answer to the hierarchy while naming a base that anchors
// none. The undiscriminated chain beside them is the boundary — depth on its own
// must not manufacture a value.
//
// The spec's comment says why the mapping-key spelling at depth is pinned in the
// compiler's own tests rather than here.
func assertDiscriminatorTransitive(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
pet, ok := doc.Types[namedID("Pet")].(*ir.Model)
require.True(t, ok, "the root of the hierarchy is a Model")
require.NotNil(t, pet.Discriminator)
assert.Equal(t, namedID("Dog"), pet.Discriminator.Mapping["dog"])

// base is the schema each subtype's own allOf branch names, which is the only
// thing a one-hop reading of the chain could see.
for _, tc := range []struct{ name, base, value string }{
{"Dog", "Pet", "dog"},
{"Puppy", "Dog", "Puppy"},
{"Whelp", "Puppy", "Whelp"},
} {
sub, ok := doc.Types[namedID(tc.name)].(*ir.Model)
require.True(t, ok, "%s composes as a Model", tc.name)
require.NotNil(t, sub.Base, "%s composes a base", tc.name)
assert.Equal(t, namedID(tc.base), sub.Base.Target,
"%s composes %s, not the schema anchoring the hierarchy", tc.name, tc.base)
assert.Equal(t, tc.value, sub.DiscriminatorValue,
"%s answers to the tag its ancestor spells for it", tc.name)
assert.Nil(t, sub.Discriminator, "a subtype does not restate the hierarchy's discriminator")
}

for _, name := range []string{"Shrub", "Sapling"} {
sub, ok := doc.Types[namedID(name)].(*ir.Model)
require.True(t, ok, "%s composes as a Model", name)
require.NotNil(t, sub.Base, "%s composes a base", name)
assert.Empty(t, sub.DiscriminatorValue,
"%s has no discriminated ancestor, so walking the chain finds no tag", name)
}
}

// 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
117 changes: 88 additions & 29 deletions compilers/openapi/internal/schema/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,55 +387,114 @@ func isInlineBranch(b *oas3.JSONSchema[oas3.Referenceable]) bool {
// refTargetHasDiscriminator reports whether a $ref branch resolves to a schema
// that carries a discriminator (it anchors a polymorphic hierarchy).
func refTargetHasDiscriminator(b *oas3.JSONSchema[oas3.Referenceable]) bool {
target := refBranchTarget(b)
return target != nil && target.GetDiscriminator() != nil
}

// refBranchTarget returns the schema a composition branch resolves to, or nil
// when the branch is inline, names nothing this compilation resolved, or names a
// bare boolean schema (which has no *oas3.Schema of its own).
func refBranchTarget(b *oas3.JSONSchema[oas3.Referenceable]) *oas3.Schema {
if !isRefBranch(b) {
return nil
}
resolved := b.GetResolvedSchema()
if resolved == nil {
return false
return nil
}
s := resolved.GetSchema()
return s != nil && s.GetDiscriminator() != nil
return resolved.GetSchema()
}

// subtypeDiscriminatorValue returns the wire tag value this allOf subtype
// carries within its base's discriminator hierarchy, or "" when no allOf base
// anchors one. Per ir-design §4.3 the value is the base mapping key that points
// at this subtype, falling back to the subtype's own schema name (OpenAPI's
// carries within its discriminator hierarchy, or "" when no ancestor of it
// anchors one. Per ir-design §4.3 the value is the mapping key that points at
// this subtype, falling back to the subtype's own schema name (OpenAPI's
// implicit mapping) when the mapping omits it.
//
// Every discriminated ancestor is asked, not only the immediate base: a
// hierarchy deeper than two levels composes an intermediate schema that declares
// 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 {
d := baseBranchDiscriminator(s.GetAllOf())
if d == nil {
ds := ancestorDiscriminators(s)
if len(ds) == 0 {
return ""
}
if m := d.GetMapping(); m != nil {
for tag, target := range m.All() {
if tid, ok := mappingTargetID(c, ts, target); ok && tid == id {
return tag
}
for _, d := range ds {
if tag, ok := mappingTagFor(c, ts, d, id); ok {
return tag
}
}
return refLastSegment(pointer)
}

// baseBranchDiscriminator returns the discriminator declared on the resolved
// target of the allOf base branch (the $ref anchoring the hierarchy), or nil
// when no ref branch carries one.
func baseBranchDiscriminator(branches []*oas3.JSONSchema[oas3.Referenceable]) *oas3.Discriminator {
for _, b := range branches {
if !isRefBranch(b) {
continue
// 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) {
m := d.GetMapping()
if m == nil {
return "", false
}
for tag, target := range m.All() {
if tid, ok := mappingTargetID(c, ts, target); ok && tid == id {
return tag, true
}
resolved := b.GetResolvedSchema()
if resolved == nil {
continue
}
return "", false
}

// maxDiscriminatorAncestorDepth bounds how many composition levels
// ancestorDiscriminators climbs (styleguide bounded-everything rule). The visited
// set below is what makes the walk terminate; this is the explicit limit, set far
// beyond any hierarchy a source document plausibly declares.
const maxDiscriminatorAncestorDepth = 256

// ancestorDiscriminators returns the discriminators declared on s's composition
// ancestors, nearest first: the resolved targets of s's own $ref branches in
// source order, then those targets' $ref branches, and so on.
//
// Level by level rather than chain by chain, so "nearest" means fewest hops —
// which is what decides between two ancestors whose mappings both name the same
// subtype. The visited set is load-bearing, not defensive: a cyclic composition
// (`A: allOf [$ref B]`, `B: allOf [$ref A]`) compiles without a diagnostic and
// reaches here, and a walk over one must terminate rather than spin.
func ancestorDiscriminators(s *oas3.Schema) []*oas3.Discriminator {
var out []*oas3.Discriminator
visited := make(map[*oas3.Schema]bool)
level := []*oas3.Schema{s}
for depth := 0; depth < maxDiscriminatorAncestorDepth && len(level) > 0; depth++ {
next := make([]*oas3.Schema, 0, len(level))
for _, cur := range level {
next = append(next, unvisitedRefTargets(cur, visited)...)
}
rs := resolved.GetSchema()
if rs == nil {
continue
for _, target := range next {
if d := target.GetDiscriminator(); d != nil {
out = append(out, d)
}
}
if d := rs.GetDiscriminator(); d != nil {
return d
level = next
}
return out
}

// unvisitedRefTargets returns the schemas s's $ref composition branches resolve
// to, in source order, skipping those already seen and marking those it returns.
// Marking as it enqueues rather than when the walk reaches them is what keeps a
// schema two branches both name from being queued, and walked, twice.
func unvisitedRefTargets(s *oas3.Schema, visited map[*oas3.Schema]bool) []*oas3.Schema {
var out []*oas3.Schema
for _, b := range s.GetAllOf() {
target := refBranchTarget(b)
if target == nil || visited[target] {
continue
}
visited[target] = true
out = append(out, target)
}
return nil
return out
}

// lowerOneOfAnyOf lowers a oneOf/anyOf schema. A two-variant {X, null} set
Expand Down
Loading
Loading