From c49b3e80f9b6dcb78da5459fc1921fe835050799 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 9 Aug 2026 06:46:27 +0300 Subject: [PATCH] fix(compilers/openapi): find a subtype's tag past its parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model.DiscriminatorValue was stamped only when the subtype's own allOf $ref branch resolved to a schema declaring the discriminator. In a hierarchy more than two levels deep that branch names an intermediate schema, which declares no discriminator of its own, so the mapping key the discriminated ancestor spells for the subtype was dropped — silently, since the composition chain itself survived and nothing diagnosed the missing tag. subtypeDiscriminatorValue now asks every discriminated ancestor rather than the immediate base, returning the key of the nearest one whose mapping names this subtype and falling back to OpenAPI's implicit schema name when no mapping does. That is the rule ir-design.md §4.3 already states, applied along the chain instead of at one hop; a subtype with no discriminated ancestor at any depth still carries no value. The walk is level by level, so "nearest" is by distance rather than by which chain was walked first. It carries a visited set because a cyclic composition compiles without a diagnostic and so reaches it, plus an explicit depth cap per the bounded-everything rule. The corpus fixture witnesses the walk through the implicit-name spelling. The mapping-key spelling at depth is pinned in the compiler's own tests instead, because pass.Validate still refuses a transitive mapping target as a missing variant (#52) and every corpus spec has to clear that sweep; the fixture and the test both say so. --- compilers/openapi/conformance_test.go | 45 ++ compilers/openapi/internal/schema/compose.go | 117 +++-- .../openapi/internal/schema/compose_test.go | 186 ++++++++ .../discriminator-transitive.golden.json | 434 ++++++++++++++++++ .../openapi/discriminator-transitive.yaml | 61 +++ 5 files changed, 814 insertions(+), 29 deletions(-) create mode 100644 testdata/conformance/openapi/discriminator-transitive.golden.json create mode 100644 testdata/conformance/openapi/discriminator-transitive.yaml diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 17ae7d9..f4ea871 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -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}, @@ -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 diff --git a/compilers/openapi/internal/schema/compose.go b/compilers/openapi/internal/schema/compose.go index e4d93ed..aaff02d 100644 --- a/compilers/openapi/internal/schema/compose.go +++ b/compilers/openapi/internal/schema/compose.go @@ -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 diff --git a/compilers/openapi/internal/schema/compose_test.go b/compilers/openapi/internal/schema/compose_test.go index a44e5d0..5caca90 100644 --- a/compilers/openapi/internal/schema/compose_test.go +++ b/compilers/openapi/internal/schema/compose_test.go @@ -2,6 +2,7 @@ package schema_test import ( "fmt" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -1291,6 +1292,191 @@ func TestAllOf_DiscriminatorHierarchy(t *testing.T) { assert.Equal(t, "Dog", dog.DiscriminatorValue, "falls back to schema name") } +// TestAllOf_DiscriminatorValueFromDistantMapping pins the mapping-key spelling of +// a tag at depth: the root names a grandchild explicitly, and the grandchild's own +// branch is an intermediate that declares no discriminator, so the key is only +// reachable by walking past the immediate parent (GitHub #305). +// +// This lives here rather than in the conformance corpus because pass.Validate +// still rejects a transitive mapping target as a missing variant (GitHub #52), +// and every corpus spec has to clear that sweep. The corpus witnesses the same +// walk through the implicit-name spelling, which needs no mapping entry. +func TestAllOf_DiscriminatorValueFromDistantMapping(t *testing.T) { + t.Parallel() + spec := componentSpec(` Pet: + type: object + required: [petType] + properties: {petType: {type: string}} + discriminator: + propertyName: petType + mapping: + dog: '#/components/schemas/Dog' + puppy: '#/components/schemas/Puppy' + Dog: + allOf: + - {$ref: '#/components/schemas/Pet'} + properties: {bark: {type: boolean}} + Puppy: + allOf: + - {$ref: '#/components/schemas/Dog'} + properties: {weeks: {type: integer}} +`) + doc, diags := lowerSpec(t, spec) + requireNoErrorDiags(t, diags) + + dog := typeByName(doc, "Dog").(*ir.Model) + assert.Equal(t, "dog", dog.DiscriminatorValue, "the direct child takes its own mapping key") + + puppy := typeByName(doc, "Puppy").(*ir.Model) + require.NotNil(t, puppy.Base) + assert.Equal(t, componentID("Dog"), puppy.Base.Target, + "Puppy composes Dog, which declares no discriminator of its own") + assert.Equal(t, "puppy", puppy.DiscriminatorValue, + "the key Pet's mapping spells for Puppy survives the intermediate") +} + +// TestAllOf_DiscriminatorValueFromNearestAncestor pins which of two discriminated +// ancestors supplies the tag when both mappings name the same subtype. The walk +// is level by level so the answer is the nearer one, rather than whichever chain +// happened to be walked first — a subtype's own hierarchy outranks one it reaches +// only through a longer composition. +func TestAllOf_DiscriminatorValueFromNearestAncestor(t *testing.T) { + t.Parallel() + spec := componentSpec(` Far: + type: object + properties: {k: {type: string}} + discriminator: + propertyName: k + mapping: {far: '#/components/schemas/Leaf'} + Mid: + allOf: + - {$ref: '#/components/schemas/Far'} + properties: {m: {type: string}} + Near: + type: object + properties: {n: {type: string}} + discriminator: + propertyName: n + mapping: {near: '#/components/schemas/Leaf'} + Leaf: + allOf: + - {$ref: '#/components/schemas/Mid'} + - {$ref: '#/components/schemas/Near'} + properties: {l: {type: string}} +`) + doc, diags := lowerSpec(t, spec) + requireNoErrorDiags(t, diags) + + leaf := typeByName(doc, "Leaf").(*ir.Model) + assert.Equal(t, "near", leaf.DiscriminatorValue, + "Near is one hop up and Far two, so Near's mapping key wins") +} + +// TestAllOf_DiscriminatorValueUndiscriminatedChain is the boundary of the +// ancestor walk: composing at any depth is not on its own a reason to carry a +// tag. Without this, a walk that fell back to the implicit schema name whenever +// it found a base would stamp every composed model in the document. +func TestAllOf_DiscriminatorValueUndiscriminatedChain(t *testing.T) { + t.Parallel() + spec := componentSpec(` Plant: + type: object + properties: {stem: {type: string}} + Shrub: + allOf: + - {$ref: '#/components/schemas/Plant'} + properties: {twigs: {type: integer}} + Sapling: + allOf: + - {$ref: '#/components/schemas/Shrub'} + properties: {rings: {type: integer}} +`) + doc, diags := lowerSpec(t, spec) + requireNoErrorDiags(t, diags) + + for _, name := range []string{"Shrub", "Sapling"} { + m := typeByName(doc, name).(*ir.Model) + require.NotNil(t, m.Base, "%s composes a base", name) + assert.Empty(t, m.DiscriminatorValue, + "%s has no discriminated ancestor at any depth", name) + } +} + +// TestAllOf_DiscriminatorValueCyclicComposition drives the ancestor walk over a +// composition cycle. Nothing upstream refuses one — this spec compiles without an +// error diagnostic — so the walk meets it, and the visited set rather than the +// depth cap is what has to stop it. +// +// The fan-out is what makes that a real claim: every schema composes every other, +// so a walk that re-entered a schema it had already left would branch five ways +// per level for the length of the cap instead of terminating. +func TestAllOf_DiscriminatorValueCyclicComposition(t *testing.T) { + t.Parallel() + const n = 6 + var b strings.Builder + for i := range n { + refs := make([]string, 0, n-1) + for j := range n { + if j != i { + refs = append(refs, fmt.Sprintf("{$ref: '#/components/schemas/X%d'}", j)) + } + } + fmt.Fprintf(&b, " X%d:\n allOf: [%s]\n properties: {p%d: {type: string}}\n", + i, strings.Join(refs, ", "), i) + } + doc, diags := lowerSpec(t, componentSpec(b.String())) + requireNoErrorDiags(t, diags) + + for i := range n { + name := fmt.Sprintf("X%d", i) + m, ok := typeByName(doc, name).(*ir.Model) + require.True(t, ok, "%s lowers to a Model", name) + assert.Empty(t, m.DiscriminatorValue, "%s has no discriminated ancestor", name) + } +} + +// TestAllOf_DiscriminatorValueChainDeeperThanCap pins the ancestor walk's cap as +// behaviour rather than as a comment. A chain longer than the cap stops being +// searched, so the tag the root would have supplied is not stamped — stated here +// so the limit is a decision on record and a chain that grows past it is a +// failing test rather than a silent reversion to the bug this walk fixed. +// +// The shallower half is the control: the same construction one level inside the +// cap does carry the tag, which is what says the deep case failed on the cap and +// not on the construction. +func TestAllOf_DiscriminatorValueChainDeeperThanCap(t *testing.T) { + t.Parallel() + // maxDiscriminatorAncestorDepth is unexported; 256 is its committed value, and + // the two cases below straddle it. + const ancestorCap = 256 + for _, tc := range []struct { + name string + links int + want string + }{ + {"within the cap", ancestorCap - 1, fmt.Sprintf("S%d", ancestorCap-2)}, + {"beyond the cap", ancestorCap + 4, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var b strings.Builder + b.WriteString(" Root:\n type: object\n properties: {k: {type: string}}\n" + + " discriminator: {propertyName: k}\n") + prev := "Root" + for i := range tc.links { + fmt.Fprintf(&b, " S%d:\n allOf: [{$ref: '#/components/schemas/%s'}]\n"+ + " properties: {p%d: {type: string}}\n", i, prev, i) + prev = fmt.Sprintf("S%d", i) + } + doc, diags := lowerSpec(t, componentSpec(b.String())) + requireNoErrorDiags(t, diags) + + leaf := typeByName(doc, prev).(*ir.Model) + assert.Equal(t, tc.want, leaf.DiscriminatorValue, + "%s is %d links below the discriminated root", prev, tc.links) + }) + } +} + func TestModelDiscriminator_UndeclaredPropertyAndBadMapping(t *testing.T) { t.Parallel() spec := componentSpec(` Vehicle: diff --git a/testdata/conformance/openapi/discriminator-transitive.golden.json b/testdata/conformance/openapi/discriminator-transitive.golden.json new file mode 100644 index 0000000..dbd157e --- /dev/null +++ b/testdata/conformance/openapi/discriminator-transitive.golden.json @@ -0,0 +1,434 @@ +{ + "irVersion": "0.3.0", + "name": "DiscriminatorTransitive", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "DiscriminatorTransitive", + "canonical": "discriminator_transitive" + }, + "docs": {}, + "auth": null, + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/openapi/components/schemas/Dog": { + "kind": "model", + "id": "t/openapi/components/schemas/Dog", + "name": { + "source": "Dog", + "canonical": "dog" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Dog" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Dog/properties/bark", + "name": { + "source": "bark", + "canonical": "bark" + }, + "wireName": "bark", + "type": { + "target": "t/prim/bool", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Dog/properties/bark" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/Pet", + "nullable": false + }, + "abstract": false, + "positional": false, + "discriminatorValue": "dog", + "inputOnly": false + }, + "t/openapi/components/schemas/Pet": { + "kind": "model", + "id": "t/openapi/components/schemas/Pet", + "name": { + "source": "Pet", + "canonical": "pet" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Pet" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Pet/properties/petType", + "name": { + "source": "petType", + "canonical": "pet_type" + }, + "wireName": "petType", + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": true, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Pet/properties/petType" + } + } + ], + "abstract": false, + "positional": false, + "discriminator": { + "property": "p/openapi/components/schemas/Pet/properties/petType", + "mapping": { + "dog": "t/openapi/components/schemas/Dog" + }, + "inferred": false + }, + "inputOnly": false + }, + "t/openapi/components/schemas/Plant": { + "kind": "model", + "id": "t/openapi/components/schemas/Plant", + "name": { + "source": "Plant", + "canonical": "plant" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Plant" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Plant/properties/stem", + "name": { + "source": "stem", + "canonical": "stem" + }, + "wireName": "stem", + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Plant/properties/stem" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Puppy": { + "kind": "model", + "id": "t/openapi/components/schemas/Puppy", + "name": { + "source": "Puppy", + "canonical": "puppy" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Puppy" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Puppy/properties/weeks", + "name": { + "source": "weeks", + "canonical": "weeks" + }, + "wireName": "weeks", + "type": { + "target": "t/prim/integer", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Puppy/properties/weeks" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/Dog", + "nullable": false + }, + "abstract": false, + "positional": false, + "discriminatorValue": "Puppy", + "inputOnly": false + }, + "t/openapi/components/schemas/Sapling": { + "kind": "model", + "id": "t/openapi/components/schemas/Sapling", + "name": { + "source": "Sapling", + "canonical": "sapling" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Sapling" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Sapling/properties/rings", + "name": { + "source": "rings", + "canonical": "rings" + }, + "wireName": "rings", + "type": { + "target": "t/prim/integer", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Sapling/properties/rings" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/Shrub", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Shrub": { + "kind": "model", + "id": "t/openapi/components/schemas/Shrub", + "name": { + "source": "Shrub", + "canonical": "shrub" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Shrub" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Shrub/properties/twigs", + "name": { + "source": "twigs", + "canonical": "twigs" + }, + "wireName": "twigs", + "type": { + "target": "t/prim/integer", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Shrub/properties/twigs" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/Plant", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Whelp": { + "kind": "model", + "id": "t/openapi/components/schemas/Whelp", + "name": { + "source": "Whelp", + "canonical": "whelp" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Whelp" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Whelp/properties/days", + "name": { + "source": "days", + "canonical": "days" + }, + "wireName": "days", + "type": { + "target": "t/prim/integer", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Whelp/properties/days" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/Puppy", + "nullable": false + }, + "abstract": false, + "positional": false, + "discriminatorValue": "Whelp", + "inputOnly": false + }, + "t/prim/bool": { + "kind": "primitive", + "id": "t/prim/bool", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0 + }, + "prim": "bool" + }, + "t/prim/integer": { + "kind": "primitive", + "id": "t/prim/integer", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0 + }, + "prim": "integer" + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0 + }, + "prim": "string" + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {}, + "auth": null + } + ], + "sources": [ + { + "format": "openapi@3.1", + "path": "discriminator-transitive.yaml", + "hash": "73dfc5377dfac9f15536492db87cfe99e964a50fa80dc4292eee80c487cf955c" + } + ] +} diff --git a/testdata/conformance/openapi/discriminator-transitive.yaml b/testdata/conformance/openapi/discriminator-transitive.yaml new file mode 100644 index 0000000..e72b7a0 --- /dev/null +++ b/testdata/conformance/openapi/discriminator-transitive.yaml @@ -0,0 +1,61 @@ +openapi: 3.1.0 +info: {title: DiscriminatorTransitive, version: "1.0.0"} +paths: {} +components: + schemas: + # The root anchors the hierarchy; discriminator-inheritance covers it one + # level deep, and what this spec adds is depth. + # + # The mapping names only the direct child. Naming a grandchild is legal + # OpenAPI and the compiler reads it — TestAllOf_DiscriminatorValueFromDistant + # Mapping in compilers/openapi/internal/schema pins that — but pass.Validate + # still refuses a transitive mapping target as a missing variant (GitHub #52), + # and every corpus spec has to clear that sweep. So the mapping-key spelling + # is witnessed in the compiler's own tests and the implicit-name spelling + # here; both need the ancestor walk, and neither survives reading one hop. + Pet: + type: object + required: [petType] + properties: + petType: {type: string} + discriminator: + propertyName: petType + mapping: + dog: '#/components/schemas/Dog' + # One level down and named by the mapping: the tag is on the branch's own + # target, so this much a one-hop reading already found. + Dog: + allOf: + - {$ref: '#/components/schemas/Pet'} + properties: + bark: {type: boolean} + # Two levels down. Its branch is Dog, which declares no discriminator of its + # own, so the hierarchy is only visible past the immediate parent — and with + # it the implicit schema name this subtype answers to. + Puppy: + allOf: + - {$ref: '#/components/schemas/Dog'} + properties: + weeks: {type: integer} + # Three levels down: one intermediate is not a special case of two. + Whelp: + allOf: + - {$ref: '#/components/schemas/Puppy'} + properties: + days: {type: integer} + # An equally deep chain with no discriminator above it anywhere: depth alone + # must not manufacture a tag value. + Plant: + type: object + properties: + stem: {type: string} + Shrub: + allOf: + - {$ref: '#/components/schemas/Plant'} + properties: + twigs: {type: integer} + Sapling: + allOf: + - {$ref: '#/components/schemas/Shrub'} + properties: + rings: {type: integer}