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}