diff --git a/compilers/openapi/e2e/conformance_composition_test.go b/compilers/openapi/e2e/conformance_composition_test.go index 9ce6348bc..5ee2c746a 100644 --- a/compilers/openapi/e2e/conformance_composition_test.go +++ b/compilers/openapi/e2e/conformance_composition_test.go @@ -5,6 +5,7 @@ package e2e_test import ( "encoding/json/jsontext" + "fmt" "slices" "testing" @@ -183,12 +184,11 @@ func assertRequiredMembers(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // assertAllOfOneOfCooccurrence pins both halves of the co-declared composition // rule: docs/ir/models.md#composition-beside-a-union distributes a union whose -// branches all name referents, and the rule -// docs/compilers/openapi.md#union-beside-structure keeps one with an inline -// branch verbatim rather than distributing it halfway. The verbatim half is -// covered over a model body, which owns a node already, and over a scalar one, -// which does not — there the alias hoisted for the union carries what the -// position wrote beside it too. The outside reference to a branch pointer pins +// branches all name referents, an inline one by the node at its own pointer, +// and the rule docs/compilers/openapi.md#union-beside-structure keeps one beside +// a body that is no model verbatim. That half is covered over a scalar body, +// which owns no node: the alias hoisted for the union carries what the position +// wrote beside it too. The outside reference to a branch pointer pins // the third thing: a composed variant is Morphic's own node, so it cannot be // taken by, or take from, a $ref. func assertAllOfOneOfCooccurrence(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { @@ -207,15 +207,20 @@ func assertAllOfOneOfCooccurrence(t *testing.T, doc *ir.Document, _ []ir.Diagnos assert.Equal(t, int64(2), *v.Constraints.MinProps) } - mixed, ok := doc.Types[componentID("MixedKinds")].(*ir.Model) - require.True(t, ok, "the structural body survives") - entry, ok := mixed.Unmodeled["openapi:oneOf"] - require.True(t, ok, "and the union it could not absorb survives beside it") - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + mixed, ok := doc.Types[componentID("MixedKinds")].(*ir.Union) + require.True(t, ok, "an inline branch distributes the body as a $ref branch does") + require.Len(t, mixed.Variants, 2) + for i, branch := range []ir.TypeID{componentID("A"), "t/prim/string"} { + v, ok := doc.Types[mixed.Variants[i].Type.Target].(*ir.Model) + require.True(t, ok, "variant %d composes as a model", i) + require.Len(t, v.Properties, 1, "the body rides on every variant") + require.NotNil(t, v.Base) + assert.Equal(t, branch, v.Base.Target, "the branch is carried as written") + } bounded, ok := doc.Types[componentID("BoundedKinds")].(*ir.Scalar) require.True(t, ok, "a body that is not a model reduces to a shared primitive and hoists an alias") - entry, ok = bounded.Unmodeled["openapi:oneOf"] + entry, ok := bounded.Unmodeled["openapi:oneOf"] require.True(t, ok, "which is the node the kept union sits on") assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) require.NotNil(t, bounded.Constraints, "and the bounds written beside the union sit on it too") @@ -331,6 +336,13 @@ func assertAllOfPositionConstraints(t *testing.T, doc *ir.Document, diags []ir.D // no conjunct can make an object lowers as a use of the conjunct deciding it, at // every kind of position, and one that can stays a composition. func assertAllOfRefinement(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + needle, ok := doc.Types[componentID("Needle")].(*ir.Scalar) + require.True(t, ok, "a union conjunct leaves the meet, not a model") + assert.Equal(t, &ir.TypeRef{Target: "t/prim/string"}, needle.Base) + require.NotNil(t, needle.Constraints) + assert.Equal(t, new(int64(1000)), needle.Constraints.MaxLength) + assert.Empty(t, needle.Unmodeled, "the anyOf says nothing the string does not") + zone, ok := doc.Types[componentID("Zone")].(*ir.Model) require.True(t, ok) props := openapitest.PropsByWire(zone.Properties) @@ -619,28 +631,35 @@ func assertDiscriminatorDefaultMapping(t *testing.T, doc *ir.Document, diags []i } // assertDiscriminatedUnionBesideModel pins where a discriminator goes when its -// union is kept verbatim beside a model. Pet's and Animal's routes are not -// subtypes of them, so pass.Validate would reject the discriminator on the -// model; it moves whole to Unmodeled beside the union. Vehicle's object -// variant holds one the same way. Shape's routes are true allOf subtypes, so -// the model keeps it, and the document validates clean. +// union is kept verbatim beside a model. Pet's routes are not subtypes of it, +// so pass.Validate would reject the discriminator on the model; it moves whole +// to Unmodeled beside the union. Vehicle's object variant holds one the same +// way. Shape's and Animal's routes are true allOf subtypes, Bird's union kept +// beside it rather than distributed, so each model keeps it, and the document +// validates clean. func assertDiscriminatedUnionBesideModel(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { found, err := pass.Validate(t.Context(), doc) require.NoError(t, err) assert.Empty(t, found) - for _, name := range []string{"Pet", "Animal"} { - m, ok := doc.Types[componentID(name)].(*ir.Model) - require.True(t, ok, name) - assert.Nil(t, m.Discriminator, "%s routes to a non-subtype, so the model does not carry it", name) - entry, ok := m.Unmodeled["openapi:discriminator"] - require.True(t, ok, "%s keeps it verbatim instead", name) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.Contains(t, m.Unmodeled, "openapi:oneOf", "beside the union it routes through") - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/"+name+"/discriminator")) - } - _, isUnion := doc.Types[componentID("Bird")].(*ir.Union) - assert.True(t, isUnion, "Bird writes allOf to Animal but lowers to a union, which is no subtype") + pet, ok := doc.Types[componentID("Pet")].(*ir.Model) + require.True(t, ok) + assert.Nil(t, pet.Discriminator, "Pet routes to a non-subtype, so the model does not carry it") + entry, ok := pet.Unmodeled["openapi:discriminator"] + require.True(t, ok, "Pet keeps it verbatim instead") + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.Contains(t, pet.Unmodeled, "openapi:oneOf", "beside the union it routes through") + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/Pet/discriminator")) + + bird, ok := doc.Types[componentID("Bird")].(*ir.Model) + require.True(t, ok, "Bird writes allOf to Animal, whose route keeps it a subtype") + require.NotNil(t, bird.Base) + assert.Equal(t, componentID("Animal"), bird.Base.Target) + assert.Contains(t, bird.Unmodeled, "openapi:oneOf", "its union kept beside it") + animal, ok := doc.Types[componentID("Animal")].(*ir.Model) + require.True(t, ok) + require.NotNil(t, animal.Discriminator, "its route is a subtype, so it stays") + assert.Equal(t, componentID("Bird"), animal.Discriminator.Mapping["bird"]) vehicle, ok := doc.Types[componentID("Vehicle")].(*ir.Union) require.True(t, ok, "a multi-typed body lowers to a union") @@ -721,9 +740,9 @@ func assertNegationNot(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { // assertUnionRestatedBranches holds a union whose branches repeat the body to // the one that writes only `required`: one Model with the union kept under -// validation_only, reported as that and not as a degradation. A branch saying -// more than the body stays degraded -// (docs/compilers/openapi.md#union-beside-structure). +// validation_only, reported as that and not as a degradation. Branches saying +// more than a model body distribute it, and more than one that is no model +// degrade (docs/compilers/openapi.md#union-beside-structure). func assertUnionRestatedBranches(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { for name, keyword := range map[string]string{ "RenderBare": "openapi:oneOf", "Render": "openapi:oneOf", "RenderDocumented": "openapi:anyOf", @@ -743,17 +762,142 @@ func assertUnionRestatedBranches(t *testing.T, doc *ir.Document, diags []ir.Diag require.NotNil(t, composed.Base) assert.Equal(t, componentID("RenderBase"), composed.Base.Target) assert.Equal(t, ir.ReasonValidationOnly, unmodeledEntry(t, composed.Unmodeled, "openapi:oneOf").Reason) - for _, name := range []string{"RenderTagged", "RenderSizes"} { - td, ok := doc.Types[componentID(name)] - require.True(t, ok, "%s lowers", name) - assert.Equal(t, ir.ReasonDegradedLowering, - unmodeledEntry(t, td.Common().Unmodeled, "openapi:oneOf").Reason, "%s", name) - } + _, tagged := doc.Types[componentID("RenderTagged")].(*ir.Union) + assert.True(t, tagged, "RenderTagged distributes its body across its branches") + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.CompositionLowering, "/components/schemas/RenderTagged")) + sizes, ok := doc.Types[componentID("RenderSizes")] + require.True(t, ok, "RenderSizes lowers") + assert.Equal(t, ir.ReasonDegradedLowering, unmodeledEntry(t, sizes.Common().Unmodeled, "openapi:oneOf").Reason) var degraded []string for _, d := range diags { if d.Code == diag.DegradedConstruct { degraded = append(degraded, string(d.Provenance.Pointer)) } } - assert.ElementsMatch(t, []string{"/components/schemas/RenderTagged", "/components/schemas/RenderSizes"}, degraded) + assert.ElementsMatch(t, []string{"/components/schemas/RenderSizes"}, degraded) +} + +// assertUnionRestatedType pins a union beside keywords its branches all say, +// which is the union alone, and one beside a `type` a branch does not say, +// which keeps the body (docs/compilers/openapi.md#union-beside-structure). A +// bare `required` beside object branches composes each. +func assertUnionRestatedType(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + for name, variants := range map[string][]ir.TypeID{ + "Address": {componentID("V4"), componentID("V6")}, + "Ttl": {"t/anon/components/schemas/Ttl/anyOf/0", "t/anon/components/schemas/Ttl/anyOf/1"}, + "Entry": {componentID("Item"), "t/anon/components/schemas/Entry/oneOf/1"}, + } { + u, ok := doc.Types[componentID(name)].(*ir.Union) + require.True(t, ok, "%s is the union alone; got %T", name, doc.Types[componentID(name)]) + require.Len(t, u.Variants, len(variants), name) + for i, v := range variants { + assert.Equal(t, ir.TypeRef{Target: v}, u.Variants[i].Type, "%s variant %d", name, i) + } + assert.Empty(t, u.Unmodeled, "%s keeps nothing", name) + assert.False(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/"+name), name) + } + + maybe, ok := doc.Types[componentID("MaybeAddress")].(*ir.Scalar) + require.True(t, ok, "the union alone collapses to its other branch") + assert.Equal(t, &ir.TypeRef{Target: componentID("V4"), Nullable: true}, maybe.Base) + + target, ok := doc.Types[componentID("Target")].(*ir.Union) + require.True(t, ok) + require.Len(t, target.Variants, 1) + composed, ok := doc.Types[target.Variants[0].Type.Target].(*ir.Model) + require.True(t, ok, "the bare required composes with its branch") + assert.Equal(t, []string{"name"}, composed.Requires) + require.NotNil(t, composed.Base) + assert.Equal(t, componentID("Item"), composed.Base.Target) + + mixed, ok := doc.Types[componentID("Mixed")].(*ir.Scalar) + require.True(t, ok, "a branch the type does not say keeps the body") + assert.Equal(t, ir.ReasonDegradedLowering, unmodeledEntry(t, mixed.Unmodeled, "openapi:oneOf").Reason) +} + +// assertUnionInlineBranches pins a union beside a model body distributed +// across inline branches, each conjoined by the node at its own pointer, which +// an outside $ref finds too, and a bare null branch declining it +// (docs/compilers/openapi.md#union-beside-structure). +func assertUnionInlineBranches(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + found, err := pass.Validate(t.Context(), doc) + require.NoError(t, err) + assert.Empty(t, found) + + for _, name := range []string{"Placement", "Check"} { + u, ok := doc.Types[componentID(name)].(*ir.Union) + require.True(t, ok, "%s is the union, the body distributed across it", name) + require.Len(t, u.Variants, 2) + for i := range u.Variants { + v, ok := doc.Types[u.Variants[i].Type.Target].(*ir.Model) + require.True(t, ok, "%s variant %d is a composed model", name, i) + require.Len(t, v.Properties, 1, "the body rides on %s variant %d", name, i) + require.NotNil(t, v.Base) + branch := ir.TypeID(fmt.Sprintf("t/anon/components/schemas/%s/oneOf/%d", name, i)) + assert.Equal(t, branch, v.Base.Target, "%s variant %d conjoins its branch's node", name, i) + } + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.CompositionLowering, "/components/schemas/"+name)) + } + + region, ok := doc.Types[componentID("Region")].(*ir.Scalar) + require.True(t, ok) + assert.Equal(t, ir.TypeID("t/anon/components/schemas/Placement/oneOf/0"), region.Base.Target, + "the reference gets the branch schema, never the variant composed for it") + + maybe, ok := doc.Types[componentID("MaybePlacement")].(*ir.Model) + require.True(t, ok, "a bare null branch keeps the body") + assert.Equal(t, ir.ReasonDegradedLowering, unmodeledEntry(t, maybe.Unmodeled, "openapi:oneOf").Reason) +} + +// assertAllOfUnionBranch pins a union written as an allOf branch, distributed +// across the rest of the composition as the same union beside the allOf is, +// and one that only narrows the body or sits on a subtype a route names, which +// stays with its branch (docs/compilers/openapi.md#union-beside-structure). +func assertAllOfUnionBranch(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + found, err := pass.Validate(t.Context(), doc) + require.NoError(t, err) + assert.Empty(t, found) + + for name, bases := range map[string][]ir.TypeID{ + "Record": {componentID("WithoutData"), componentID("WithData")}, + "App": {"t/anon/components/schemas/App/allOf/1/oneOf/0", "t/anon/components/schemas/App/allOf/1/oneOf/1"}, + "AppRequest": {"t/anon/components/schemas/AppRequest/allOf/0/oneOf/0", "t/anon/components/schemas/AppRequest/allOf/0/oneOf/1"}, + } { + u, ok := doc.Types[componentID(name)].(*ir.Union) + require.True(t, ok, "%s is the union, the composition distributed across it; got %T", name, doc.Types[componentID(name)]) + require.Len(t, u.Variants, len(bases), name) + for i, base := range bases { + v, ok := doc.Types[u.Variants[i].Type.Target].(*ir.Model) + require.True(t, ok, "%s variant %d is a composed model", name, i) + require.NotNil(t, v.Base, "%s variant %d", name, i) + assert.Equal(t, base, v.Base.Target, "%s variant %d conjoins its branch", name, i) + assert.NotEmpty(t, v.Properties, "%s variant %d carries the shared fields", name, i) + } + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.CompositionLowering, ir.SeverityInfo, "/components/schemas/"+name), + "written on an allOf branch", name) + } + request, ok := doc.Types[componentID("AppRequest")].(*ir.Union) + require.True(t, ok) + for _, v := range request.Variants { + assert.Contains(t, doc.Types[v.Type.Target].Common().Unmodeled, "openapi:allOf/1", + "the union that only narrows stays with its branch on every variant") + } + + upload := openapitest.FirstOp(t, doc.Services[0]).Request.Contents[0] + const file = ir.PropID("p/openapi/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/file") + require.Contains(t, upload.Encoding, file, "the part is keyed by the property every variant holds") + assert.Equal(t, []string{"image/png"}, upload.Encoding[file].ContentTypes) + + message, ok := doc.Types[componentID("Message")].(*ir.Model) + require.True(t, ok, "unions that only narrow leave the composition a model") + assert.Contains(t, message.Unmodeled, "openapi:allOf/0") + assert.Contains(t, message.Unmodeled, "openapi:allOf/1") + + dog, ok := doc.Types[componentID("Dog")].(*ir.Model) + require.True(t, ok, "a subtype a route names stays a model") + require.NotNil(t, dog.Base) + assert.Equal(t, componentID("Pet"), dog.Base.Target) + assert.Equal(t, "dog", dog.DiscriminatorValue) + assert.Contains(t, dog.Unmodeled, "openapi:allOf/1", "its union kept with its branch") } diff --git a/compilers/openapi/e2e/conformance_test.go b/compilers/openapi/e2e/conformance_test.go index 2f2b4454a..5f04597b3 100644 --- a/compilers/openapi/e2e/conformance_test.go +++ b/compilers/openapi/e2e/conformance_test.go @@ -153,6 +153,7 @@ func conformanceCases() []conformanceCase { {"allof-required-only", assertAllOfRequiredOnly}, {"required-members", assertRequiredMembers}, {"allof-oneof-cooccurrence", assertAllOfOneOfCooccurrence}, + {"allof-union-branch", assertAllOfUnionBranch}, {"allof-inline-residue", assertAllOfInlineResidue}, {"allof-nested-inline", assertAllOfNestedInline}, {"allof-ref-branch-siblings", assertAllOfRefBranchSiblings}, @@ -176,6 +177,8 @@ func conformanceCases() []conformanceCase { {"unhomed-keywords", assertUnhomedKeywords}, {"union-beside-ref", assertUnionBesideRef}, {"union-restated-branches", assertUnionRestatedBranches}, + {"union-restated-type", assertUnionRestatedType}, + {"union-inline-branches", assertUnionInlineBranches}, {"codeclared-keywords", assertCoDeclaredKeywords}, {"codeclared-schema-content", assertCoDeclaredSchemaContent}, {"content-several-entries", assertContentSeveralEntries}, diff --git a/compilers/openapi/internal/lowering/promotion.go b/compilers/openapi/internal/lowering/promotion.go index b422f7972..99a366730 100644 --- a/compilers/openapi/internal/lowering/promotion.go +++ b/compilers/openapi/internal/lowering/promotion.go @@ -172,7 +172,7 @@ func DefaultExtensionPromotions() map[ExtensionTarget][]ExtensionSource { // which is what makes "the extension survives its own promotion" structural // instead of a rule each caller has to remember: nothing here can consume an // entry. It takes them at the top level, so a key under a scope or inside a -// verbatim entry reaches no field (GitHub #661, #1220, #1221). +// verbatim entry reaches no field (GitHub #594, #1220, #1221). type Promoter struct { targets map[ExtensionTarget][]ExtensionSource // keys is every extension key targets reads, distinct and sorted. diff --git a/compilers/openapi/internal/operation/multipart.go b/compilers/openapi/internal/operation/multipart.go index 59e1a9fa2..fa9488e44 100644 --- a/compilers/openapi/internal/operation/multipart.go +++ b/compilers/openapi/internal/operation/multipart.go @@ -286,13 +286,29 @@ func partPropID(prop *ir.Property, d ids.Document, wire string, schemaPtr jsonte // partMembers returns, by wire name, the declaration the flattening of the type // body denotes takes for each of its members // (docs/ir/models.md#the-flattening), which is the one an encoding key names: -// what a reader of the body's flattened shape looks up. A body standing for no -// model has none. +// what a reader of the body's flattened shape looks up. A body standing for a +// union, as one a composition is distributed across does, has its variants' +// members, the first variant declaring each naming it (docs/ir/payloads.md). A +// body standing for no model has none. func partMembers(reg ir.TypeRegistry, body ir.TypeID) map[string]*ir.Property { - flat := ir.FlattenedProperties(reg, body) - members := make(map[string]*ir.Property, len(flat.Properties)) - for _, fp := range flat.Properties { - members[fp.Property.EffectiveWireName()] = fp.Property + holders := []ir.TypeID{body} + node := func(id ir.TypeID) (ir.TypeDef, bool) { + td, ok := reg[id] + return td, ok + } + if u, ok := bodyNode(node, body).(*ir.Union); ok && u != nil { + holders = holders[:0] + for _, v := range u.Variants { + holders = append(holders, v.Type.Target) + } + } + members := map[string]*ir.Property{} + for _, holder := range holders { + for _, fp := range ir.FlattenedProperties(reg, holder).Properties { + if name := fp.Property.EffectiveWireName(); members[name] == nil { + members[name] = fp.Property + } + } } return members } @@ -488,25 +504,30 @@ const maxBodyAliasHops = 64 // It reports ok=false for a body that stands for no model at all — a primitive, // an enum, an opaque scalar — where no pointer would name a property either. func bodyModel(node func(ir.TypeID) (ir.TypeDef, bool), body ir.TypeID) (ir.Provenance, bool) { + m, ok := bodyNode(node, body).(*ir.Model) + if !ok || m == nil { + return ir.Provenance{}, false + } + return m.Provenance, true +} + +// bodyNode returns the node body stands for past the alias scalars a +// $ref-with-siblings position hoists, nil where the chain reaches no node or +// runs past maxBodyAliasHops. +func bodyNode(node func(ir.TypeID) (ir.TypeDef, bool), body ir.TypeID) ir.TypeDef { id := body for range maxBodyAliasHops { td, found := node(id) if !found { - return ir.Provenance{}, false + return nil } - switch t := td.(type) { - case *ir.Model: - return t.Provenance, true - case *ir.Scalar: - if t.Base == nil { - return ir.Provenance{}, false - } - id = t.Base.Target - default: - return ir.Provenance{}, false + s, alias := td.(*ir.Scalar) + if !alias || s == nil || s.Base == nil { + return td } + id = s.Base.Target } - return ir.Provenance{}, false + return nil } // bodySchemaPointer returns the document and pointer partEncodings keys diff --git a/compilers/openapi/internal/operation/multipart_internal_test.go b/compilers/openapi/internal/operation/multipart_internal_test.go index b97111eab..bb342b6ed 100644 --- a/compilers/openapi/internal/operation/multipart_internal_test.go +++ b/compilers/openapi/internal/operation/multipart_internal_test.go @@ -186,6 +186,30 @@ func TestPartMembers_DeadEnds(t *testing.T) { assert.Equal(t, file.ID, members["file"].ID) } +// TestPartMembers_AUnionBodyHasItsVariantsMembers pins a body standing for a +// union, as one a composition is distributed across does, through an alias +// too: its members are its variants', the first variant declaring a name naming +// it, so a key names the body's own property on every variant. +func TestPartMembers_AUnionBodyHasItsVariantsMembers(t *testing.T) { + t.Parallel() + shared := ir.Property{ID: "p/body/file", WireName: "file"} + first := ir.Property{ID: "p/one/x", WireName: "x"} + reg := ir.TypeRegistry{ + "t/one": &ir.Model{ID: "t/one", Properties: []ir.Property{shared, first}}, + "t/two": &ir.Model{ID: "t/two", Properties: []ir.Property{shared, {ID: "p/two/x", WireName: "x"}}}, + "t/u": &ir.Union{ID: "t/u", Variants: []ir.Variant{{Type: ir.TypeRef{Target: "t/one"}}, {Type: ir.TypeRef{Target: "t/two"}}}}, + "t/alias": &ir.Scalar{ID: "t/alias", Base: &ir.TypeRef{Target: "t/u"}}, + "t/nil": (*ir.Union)(nil), + } + for _, body := range []ir.TypeID{"t/u", "t/alias"} { + members := partMembers(reg, body) + require.Len(t, members, 2, "%s", body) + assert.Equal(t, shared.ID, members["file"].ID, "%s", body) + assert.Equal(t, first.ID, members["x"].ID, "the first variant declaring x names it: %s", body) + } + assert.Empty(t, partMembers(reg, "t/nil"), "a typed nil union stands for no members") +} + // TestBodyParts_RefHopBound covers the walk's $ref hop bound: past it the walk // stops and says so. func TestBodyParts_RefHopBound(t *testing.T) { diff --git a/compilers/openapi/internal/schema/admission.go b/compilers/openapi/internal/schema/admission.go index d884d681e..1ee4a7414 100644 --- a/compilers/openapi/internal/schema/admission.go +++ b/compilers/openapi/internal/schema/admission.go @@ -604,12 +604,16 @@ func (w *admission) restates(held admitted, from, js *oas3.JSONSchema[oas3.Refer // same JSON, read against one base where it writes a relative $ref: under an // $id naming another URI, the same spelling resolves elsewhere. A copy in // another document is read apart: its $refs resolve against that document, and -// its dialect may differ (docs/compilers/openapi.md#openapi-versions). +// its dialect may differ (docs/compilers/openapi.md#openapi-versions). A schema +// placed nowhere, a nil one among them, is written alike with none. func (w *admission) sameText(a, b *oas3.JSONSchema[oas3.Referenceable]) bool { at, placed := w.c.Bindings().Site(a) bt, placedB := w.c.Bindings().Site(b) + if !placed || !placedB || at.Doc != bt.Doc { + return false + } text := w.text(a) - if !placed || !placedB || at.Doc != bt.Doc || text == nil || !bytes.Equal(text, w.text(b)) { + if text == nil || !bytes.Equal(text, w.text(b)) { return false } // The texts are remembered, so comparing them first leaves a base, which diff --git a/compilers/openapi/internal/schema/allof.go b/compilers/openapi/internal/schema/allof.go index 3e2f7922e..0517f277b 100644 --- a/compilers/openapi/internal/schema/allof.go +++ b/compilers/openapi/internal/schema/allof.go @@ -52,7 +52,7 @@ func lowerAllOf(c lowering.Ctx, ts *compile.Types, depth int, s *oas3.Schema, po if d != nil { m.Discriminator = d } - tag, tagDiags := subtypeDiscriminatorValue(c, s, common.ID, pointer) + tag, _, tagDiags := subtypeDiscriminatorValue(c, s, common.ID, pointer) m.DiscriminatorValue = tag diags = append(diags, tagDiags...) return objectNode(m) @@ -167,6 +167,10 @@ type allOfComposition struct { // position lowers (allOfDecides). speaks []bool rebased []bool + // distributes is the pointer of the branch whose union the position + // distributes across its variants (branchUnion), whose merge consumes it, + // and empty where no branch's is. + distributes jsontext.Pointer } // branchSpeaks reports whether the branch at index i folds at the position. @@ -402,8 +406,10 @@ func preserveUnmergedBranch(c lowering.Ctx, m *ir.Model, comp allOfComposition, return nil // boolean branch; applyFalseBranches handles it. } speaks := comp.branchSpeaks(i) + distributed := comp.distributes != "" && e.pointer == comp.distributes residue := unmergedBranchKeys(bs, func(keyword string) bool { - return mergeConsumes(bs, keyword, e.wrapper) || (speaks && folds(c, bs, keyword, boundHomes{value: true})) + return mergeConsumes(bs, keyword, e.wrapper) || (speaks && folds(c, bs, keyword, boundHomes{value: true})) || + (distributed && declaresUnionKeyword(keyword)) }) if len(residue) == 0 { return nil @@ -518,6 +524,11 @@ func mergeConsumes(bs *oas3.Schema, key string, flattened bool) bool { } } +// declaresUnionKeyword reports whether keyword is oneOf or anyOf. +func declaresUnionKeyword(keyword string) bool { + return keyword == "oneOf" || keyword == "anyOf" +} + // declaresObjectOnly reports whether the branch's declared type is exactly // `object`, which the composed ir.Model already restates so the merge loses // nothing by reading it. A set — `[object, "null"]` included — says more than diff --git a/compilers/openapi/internal/schema/allof_conflicts_test.go b/compilers/openapi/internal/schema/allof_conflicts_test.go index a0f899f2f..b2d3b362c 100644 --- a/compilers/openapi/internal/schema/allof_conflicts_test.go +++ b/compilers/openapi/internal/schema/allof_conflicts_test.go @@ -1490,7 +1490,7 @@ func TestAllOf_AnUntypedReadPassingOverAKeywordKeepsTheConflict(t *testing.T) { ref("NotStr"), ref("ObjOneOf"), ref("ObjAnyOf"), ref("IfObj"), ref("AliasOneOf"), "{$ref: '#/components/schemas/Empty', type: object}", "{$ref: '#/components/schemas/Empty', allOf: [{type: object}]}", - "{enum: [{a: x}]}", "{const: {a: x}}", "{oneOf: [{type: object}, {type: array}]}", + "{enum: [{a: x}]}", "{const: {a: x}}", "{oneOf: [{type: object}, {type: array}], anyOf: [{type: object}]}", "{anyOf: [{type: object}]}", "{not: {type: string}}", "{$dynamicRef: '#/components/schemas/Props'}", "{$recursiveRef: '#'}", "false", ref("Missing"), ref("Refuses"), } { diff --git a/compilers/openapi/internal/schema/branchshape.go b/compilers/openapi/internal/schema/branchshape.go index 1f3c6bc21..80cf08e48 100644 --- a/compilers/openapi/internal/schema/branchshape.go +++ b/compilers/openapi/internal/schema/branchshape.go @@ -75,6 +75,12 @@ func (r *bodyReading) declared() declarationSet { // a reading its budget cut short says nothing, so it counts as one that does, // even where the body's own keywords would settle it (GitHub #1423). func unionBranchesDeclareShape(c lowering.Ctx, s *oas3.Schema) bool { + return setsDeclareShape(c, s, s.GetOneOf(), s.GetAnyOf()) +} + +// setsDeclareShape is unionBranchesDeclareShape for the branches of sets, a +// union on an allOf branch of s among them, judged against s as the body. +func setsDeclareShape(c lowering.Ctx, s *oas3.Schema, sets ...[]*oas3.JSONSchema[oas3.Referenceable]) bool { var r *bodyReading body := func() *bodyReading { if r == nil { @@ -85,7 +91,9 @@ func unionBranchesDeclareShape(c lowering.Ctx, s *oas3.Schema) bool { declares := func(b *oas3.JSONSchema[oas3.Referenceable]) bool { return isRefBranch(b) || branchDeclaresShape(body, b) } - found := slices.ContainsFunc(s.GetOneOf(), declares) || slices.ContainsFunc(s.GetAnyOf(), declares) + found := slices.ContainsFunc(sets, func(set []*oas3.JSONSchema[oas3.Referenceable]) bool { + return slices.ContainsFunc(set, declares) + }) return found || r != nil && r.w.incomplete() } diff --git a/compilers/openapi/internal/schema/census.go b/compilers/openapi/internal/schema/census.go index 4b1f86af5..adc34f3f5 100644 --- a/compilers/openapi/internal/schema/census.go +++ b/compilers/openapi/internal/schema/census.go @@ -329,13 +329,13 @@ func referenceOutcomeKeywords(c lowering.Ctx, s *oas3.Schema, pointer jsontext.P if _, _, expanded := dynamicExpansion(c, s, pointer); expanded { return unhomedKeywords(s, nil, nil), expandedShape } - if hasUnionSiblings(s) { + if hasUnionSiblings(c, s) { return nil, "" } if _, _, collapses := nullUnionCollapse(s, ""); !collapses { return nil, "" } - return unhomedKeywords(s, nil, nil), collapsedShape + return unhomedKeywords(s, nil, branchesSay(c, s)), collapsedShape } // preserveUnhomedKeywords keeps verbatim the census keywords and value @@ -357,7 +357,7 @@ func preserveUnhomedKeywords(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, return id, diags } owns, _ := ts.Lookup(c.IDs().Coordinate(pointer)) - unhomed := unhomedKeywords(s, td, d.skipped) + unhomed := unhomedKeywords(s, td, slices.Concat(d.skipped, d.said)) if owns == id && !constraintsHome(td) { // No alias will be hoisted over a node the position already owns, so the // bounds it wrote reach no Constraints field anywhere. diff --git a/compilers/openapi/internal/schema/discriminator.go b/compilers/openapi/internal/schema/discriminator.go index a560c7630..b3b1be198 100644 --- a/compilers/openapi/internal/schema/discriminator.go +++ b/compilers/openapi/internal/schema/discriminator.go @@ -330,19 +330,19 @@ func propIDByName(m *ir.Model, name string) (ir.PropID, bool) { } // subtypeDiscriminatorValue returns the wire tag this allOf subtype carries in -// its discriminator hierarchy, or "" when no ancestor anchors one +// its hierarchy, false where none routes to it, "" being a tag // (docs/ir/models.md): the mapping key naming the subtype, else its schema name // as declared, not as its escaped pointer token spells it (GitHub #505). An -// inline subtype has no name, so only a mapping entry can tag it (GitHub #517). +// inline subtype has no name, so only a mapping entry tags it (GitHub #517). // -// Every discriminated ancestor is asked, since an intermediate may declare none +// Every discriminated ancestor is asked, as an intermediate may declare none // (GitHub #305). Of several mapping keys for the subtype the smallest in byte -// order is elected, as a mapping is unordered (GitHub #410); the base's +// order is elected, a mapping being unordered (GitHub #410); the base's // Discriminator keeps them all, and an info diagnostic reports it. -func subtypeDiscriminatorValue(c lowering.Ctx, s *oas3.Schema, id ir.TypeID, pointer jsontext.Pointer) (string, []ir.Diagnostic) { +func subtypeDiscriminatorValue(c lowering.Ctx, s *oas3.Schema, id ir.TypeID, pointer jsontext.Pointer) (string, bool, []ir.Diagnostic) { ds := ancestorDiscriminators(c.Bindings(), s) if len(ds) == 0 { - return "", nil + return "", false, nil } for _, d := range ds { tags := mappingTagsFor(c, d, id) @@ -350,9 +350,9 @@ func subtypeDiscriminatorValue(c lowering.Ctx, s *oas3.Schema, id ir.TypeID, poi continue } if len(tags) == 1 { - return tags[0], nil + return tags[0], true, nil } - return tags[0], []ir.Diagnostic{c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, pointer, + return tags[0], true, []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, ", "))} @@ -360,9 +360,9 @@ func subtypeDiscriminatorValue(c lowering.Ctx, s *oas3.Schema, id ir.TypeID, poi // Read the state, not only the name: a top-level entry of another kind, such // as components/x-shared/Cat, has none to read (GitHub #572). if name, state := ids.ComponentSchema(pointer); state == ids.NamedComponent { - return name, nil + return name, true, nil } - return "", nil + return "", false, nil } // mappingTagsFor returns every key d's mapping spells for the type id, sorted diff --git a/compilers/openapi/internal/schema/discriminator_beside_union_test.go b/compilers/openapi/internal/schema/discriminator_beside_union_test.go index 9761d9e56..c17ed4a89 100644 --- a/compilers/openapi/internal/schema/discriminator_beside_union_test.go +++ b/compilers/openapi/internal/schema/discriminator_beside_union_test.go @@ -55,9 +55,6 @@ func TestDiscriminatorBesideUnion_MovesWhenARouteIsNoSubtype(t *testing.T) { {"lowers to an alias over its $ref", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", " Cat: {$ref: '#/components/schemas/Other', allOf: [{$ref: '#/components/schemas/Pet'}]}\n" + " Other: {type: object}\n" + subDog, `"mapping"`}, - {"lowers to a distributed union", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", - " Cat: " + viaPet + ", oneOf: [{$ref: '#/components/schemas/House'}, {$ref: '#/components/schemas/Wild'}]}\n" + - " House: {type: object}\n Wild: {type: object}\n" + subDog, `"mapping"`}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -147,8 +144,10 @@ func TestDiscriminatorBesideUnion_KeepsARoutingMapAModel(t *testing.T) { // TestDiscriminatorBesideUnion_StaysWhenEveryRouteIsASubtype pins the case the // model keeps its discriminator beside a kept union: every route is a subtype, -// at any distance and however it is spelled. A branch no name reaches, inline -// or a position, is no route, so it does not move the discriminator either. +// at any distance and however it is spelled, a route writing a union beside +// its allOf among them, which the route keeps a subtype. A branch no name +// reaches, inline or a position, is no route, so it does not move the +// discriminator either. func TestDiscriminatorBesideUnion_StaysWhenEveryRouteIsASubtype(t *testing.T) { t.Parallel() const grand = " Cat: {allOf: [{$ref: '#/components/schemas/Mid'}], type: object}\n" + @@ -168,6 +167,11 @@ func TestDiscriminatorBesideUnion_StaysWhenEveryRouteIsASubtype(t *testing.T) { {"grandchild", "3.1.0", petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}"), grand}, {"default", "3.2.0", petWith("{propertyName: kind, defaultMapping: '#/components/schemas/Dog'}"), subtypes}, {"branches no name reaches", "3.1.0", unnamed, cat + " Holder: {type: object, properties: {pet: {type: object}}}\n"}, + {"a union beside its allOf, which a route keeps undistributed", "3.1.0", + petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}"), + " Cat: {allOf: [{$ref: '#/components/schemas/Pet'}], " + + "oneOf: [{$ref: '#/components/schemas/House'}, {$ref: '#/components/schemas/Wild'}]}\n" + + " House: {type: object}\n Wild: {type: object}\n" + subDog}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -515,7 +519,7 @@ var degradedShapes = []struct { name string branches string }{ - {"inline branch", " oneOf: [{$ref: '#/components/schemas/Cat'}, {type: object, properties: {kind: {type: string}}}]\n"}, + {"null branch", " oneOf: [{$ref: '#/components/schemas/Cat'}, {type: 'null'}]\n"}, {"oneOf beside anyOf", " oneOf: [{$ref: '#/components/schemas/Cat'}]\n anyOf: [{$ref: '#/components/schemas/Dog'}]\n"}, {"validation-only branches", " oneOf: [{required: [kind]}, {required: [x]}]\n"}, } diff --git a/compilers/openapi/internal/schema/drafts_test.go b/compilers/openapi/internal/schema/drafts_test.go index 4cc9fbec5..205fcbbe7 100644 --- a/compilers/openapi/internal/schema/drafts_test.go +++ b/compilers/openapi/internal/schema/drafts_test.go @@ -112,16 +112,30 @@ func TestRefSite_AKeywordBesideADraftsRefIsWarnedOf(t *testing.T) { // TestUnion_ABranchWritingAListOfItemsRestatesNothing pins that a union branch // writing `items` as a list, a tuple in a draft, restates no element the body -// declares, so the union is kept as a constraint beside the body rather than -// read as validation the body already says. +// declares, so the union is distributed across the body rather than read as +// validation the body already says. func TestUnion_ABranchWritingAListOfItemsRestatesNothing(t *testing.T) { t.Parallel() doc, diags := parseFull(t, openapitest.ComponentSpec(" U:\n "+d4+"\n type: object\n"+ " properties: {p: {type: array, items: {type: string}}}\n"+ " oneOf: [{properties: {p: {items: [{type: integer}]}}}, {properties: {p: {maxItems: 1}}}]\n")) openapitest.RequireNoErrorDiags(t, diags) - u := doc.Types[componentID("U")].Common().Unmodeled - assert.Equal(t, ir.ReasonDegradedLowering, u["openapi:oneOf"].Reason) + assert.Equal(t, readDistributed, unionReading(doc, "U")) +} + +// TestUnion_AListOfItemsBesideTheBranchesIsNotSaid pins that a body writing +// `items` as a list, a tuple in a draft, is said by no branch writing one +// element schema, so the union keeps the body rather than lowering alone and +// dropping the tuple (docs/compilers/openapi.md#union-beside-structure). +func TestUnion_AListOfItemsBesideTheBranchesIsNotSaid(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.ComponentSpec(" U:\n "+d4+"\n type: array\n"+ + " items: [{type: string}, {type: integer}]\n"+ + " anyOf: [{type: array, items: {type: string, format: uuid}}, {type: array, items: {type: string, format: date}}]\n")) + openapitest.RequireNoErrorDiags(t, diags) + assert.Equal(t, string(ir.ReasonDegradedLowering), unionReading(doc, "U")) + _, tuple := typeByName(doc, "U").(*ir.Tuple) + assert.True(t, tuple, "the body keeps its tuple; got %T", typeByName(doc, "U")) } // TestAllOf_ABranchUnderADraftsIDFoldsNothing pins that draft 4's `id` gives an diff --git a/compilers/openapi/internal/schema/dynamicref.go b/compilers/openapi/internal/schema/dynamicref.go index 01159f705..a91452301 100644 --- a/compilers/openapi/internal/schema/dynamicref.go +++ b/compilers/openapi/internal/schema/dynamicref.go @@ -102,14 +102,14 @@ func dynamicRefName(s *oas3.Schema) (name, why string, ok bool) { // any of these makes the reference irreducible; expanding regardless would // assert the target's shape and drop the sibling in silence. // -// It is broader than declaresShape by narrowsInstance's keywords, for the reason -// that predicate records: an expansion takes the target whole, so a keyword it -// cannot carry is lost whether or not it declares a shape by itself. +// It counts narrowingKeywords as well as shapeKeywords, for the reason the +// former record: an expansion takes the target whole, so a keyword it cannot +// carry is lost whether or not it declares a shape by itself. func dynamicRefSiblings(s *oas3.Schema) bool { if s.Ref != nil || len(s.GetOneOf()) > 0 || len(s.GetAnyOf()) > 0 { return true } - return narrowsInstance(s) || declaresShape(s) + return writesSiblingKeyword(s) } // soleAnchorSite returns the one position of c's document declaring the named diff --git a/compilers/openapi/internal/schema/enum_test.go b/compilers/openapi/internal/schema/enum_test.go index 798d71c29..5c6a47f22 100644 --- a/compilers/openapi/internal/schema/enum_test.go +++ b/compilers/openapi/internal/schema/enum_test.go @@ -335,7 +335,7 @@ func TestEnum_EmptyMemberListMatchesNoValue(t *testing.T) { // holding no member and lowering the union alone would state the opposite of // the source. // -// It is a second case rather than a second row because declaresShape and +// It is a second case rather than a second row because shapeKeywords and // composesAsModel test the keyword themselves, through enumWritten, and a union // reaches lower()'s dispatch only through them. func TestEnum_EmptyMemberListIsAUnionSibling(t *testing.T) { diff --git a/compilers/openapi/internal/schema/helpers_test.go b/compilers/openapi/internal/schema/helpers_test.go index 2544706b7..7e820cbb2 100644 --- a/compilers/openapi/internal/schema/helpers_test.go +++ b/compilers/openapi/internal/schema/helpers_test.go @@ -87,6 +87,40 @@ func typeByName(doc *ir.Document, name string) ir.TypeDef { return doc.Types[componentID(name)] } +// The readings unionReading returns, besides the reason a kept union names. +const ( + readAlone = "alone" + readDistributed = "distributed" +) + +// unionReading returns how the component name's oneOf or anyOf, written beside +// keywords of its own, was read (docs/compilers/openapi.md#union-beside-structure): +// readDistributed where its Union's variants are composed models, readAlone +// where it is a Union of the branches themselves, and otherwise the reason the +// union is kept under, oneOf's where both are, "" where there is no such +// component. +func unionReading(doc *ir.Document, name string) string { + td := typeByName(doc, name) + if td == nil { + return "" + } + for _, key := range []string{"openapi:oneOf", "openapi:anyOf"} { + if kept, ok := td.Common().Unmodeled[key]; ok { + return string(kept.Reason) + } + } + u, ok := td.(*ir.Union) + if !ok { + return "" + } + if slices.ContainsFunc(u.Variants, func(v ir.Variant) bool { + return strings.HasPrefix(string(v.Type.Target), "t/composed/") + }) { + return readDistributed + } + return readAlone +} + // conflictDiags returns every conflicting-redeclaration diagnostic in diags. func conflictDiags(diags []ir.Diagnostic) []ir.Diagnostic { var out []ir.Diagnostic diff --git a/compilers/openapi/internal/schema/nulls.go b/compilers/openapi/internal/schema/nulls.go index 8b0041169..efa542350 100644 --- a/compilers/openapi/internal/schema/nulls.go +++ b/compilers/openapi/internal/schema/nulls.go @@ -314,7 +314,7 @@ type valueVerdicts struct { // at each reference would cost the references times the members. func valueNulls(c lowering.Ctx, s *oas3.Schema) valueVerdicts { return lowering.Remembered(c, valueNullsOf{s}, func() valueVerdicts { - return valueVerdicts{constant: constNullVerdict(s), enum: enumNullVerdict(s), union: unionNullVerdict(s)} + return valueVerdicts{constant: constNullVerdict(s), enum: enumNullVerdict(s), union: unionNullVerdict(c, s)} }) } @@ -449,8 +449,8 @@ func isNullValue(node values.Value) bool { // // It never forbids: a union with no null branch says nothing about a null that // a sibling keyword admits, as in `{nullable: true, oneOf: [...]}`. -func unionNullVerdict(s *oas3.Schema) nullVerdict { - if oneOfAnyOfHasNull(s) && !hasUnionSiblings(s) { +func unionNullVerdict(c lowering.Ctx, s *oas3.Schema) nullVerdict { + if oneOfAnyOfHasNull(s) && !hasUnionSiblings(c, s) { return nullAdmitted } return nullSilent diff --git a/compilers/openapi/internal/schema/refinement.go b/compilers/openapi/internal/schema/refinement.go index 9f83f73c8..e2aa1baa5 100644 --- a/compilers/openapi/internal/schema/refinement.go +++ b/compilers/openapi/internal/schema/refinement.go @@ -114,8 +114,8 @@ func decide(c lowering.Ctx, s *oas3.Schema, pointer jsontext.Pointer) decision { // refinableBranches reports whether every branch of comp can be read as a // conjunct of a refinement. A second $ref stays composing: dropping a reference // for another would drop a branch the source wrote -// (docs/ir/models.md#composition-entries). A union on a branch stays too, until -// lowering its branches gives it a home (GitHub #661). +// (docs/ir/models.md#composition-entries). A union on a branch is a conjunct +// admitting what its branches do (inlineConjunct). func refinableBranches(comp allOfComposition) bool { if len(comp.branches) > maxRefinedConjuncts { return false @@ -123,7 +123,7 @@ func refinableBranches(comp allOfComposition) bool { refs := 0 for _, e := range comp.branches { bs := e.schema.GetSchema() - if e.capped || annotation.IsFalseSchema(e.schema) || declaresUnion(bs) || + if e.capped || annotation.IsFalseSchema(e.schema) || declaresDynamicRef(bs) || annotation.DeclaresAny(bs, objectKeywords) { return false } @@ -148,12 +148,25 @@ func (w *admission) conjuncts(s *oas3.Schema, comp allOfComposition) []conjunct case isRefBranch(e.schema): out = append(out, conjunct{branch: e, read: w.whole(e.schema)}) default: - out = append(out, conjunct{branch: e, read: w.written(bs, w.nullable), says: saysKind(bs)}) + out = append(out, conjunct{branch: e, read: inlineConjunct(w, bs), says: saysKind(bs) || declaresUnion(bs)}) } } return out } +// inlineConjunct is what the inline branch bs admits as w reads it: its own +// keywords and the oneOf and anyOf it writes, whose branches name the kinds it +// admits. Its allOf is read apart, since its entries are conjuncts of their own. +func inlineConjunct(w *admission, bs *oas3.Schema) admitted { + read := w.written(bs, w.nullable) + for _, branches := range [][]*oas3.JSONSchema[oas3.Referenceable]{bs.GetOneOf(), bs.GetAnyOf()} { + if len(branches) > 0 { + read = read.and(w.union(branches)) + } + } + return read +} + // saysKind reports whether s writes what kind of value it admits. func saysKind(s *oas3.Schema) bool { return writesType(s) || enumWritten(s) || s.GetConst() != nil @@ -388,16 +401,52 @@ func refinedUnhomed(c lowering.Ctx, r refinement, s *oas3.Schema, typed ir.TypeD } // said reports whether the principal of r already says what s's keyword key -// does: what it admits holds it, or it is a format the value set it lowered to, -// typed, holds in its ValueType (typeValueSet), that format's primitive or a -// narrower one. A type that ValueType holds, its values hold too. +// does: what it admits holds it, a union one of whose branches it restates +// (unionHeld), or it is a format the value set it lowered to, typed, holds in +// its ValueType (typeValueSet), that format's primitive or a narrower one. A +// type that ValueType holds, its values hold too. func said(w *admission, r refinement, typed ir.TypeDef, s *oas3.Schema, key string) bool { - if w.holds(r.held, s, key) { + if w.holds(r.held, s, key) || unionHeld(w, r, s, key) { return true } return typed != nil && key == "format" && formatHome(typed, s) } +// unionHeld reports whether every value r holds satisfies s's oneOf or anyOf, +// the keyword key names: a branch says nothing r's principal does not +// (admission.restates), and for a oneOf one alone, sharing no kind with any +// other, so that exactly one admits each of its values. A branch is compared +// keyword by keyword, as what it admits leaves its bounds and annotations unread, +// and one writing what no reading compares says more. A read cut short says no. +func unionHeld(w *admission, r refinement, s *oas3.Schema, key string) bool { + var branches []*oas3.JSONSchema[oas3.Referenceable] + switch key { + case "oneOf": + branches = s.GetOneOf() + case "anyOf": + branches = s.GetAnyOf() + default: + return false + } + var principal *oas3.JSONSchema[oas3.Referenceable] + if r.principal != nil { + principal = r.principal.schema + } + within, overlaps := 0, 0 + for _, b := range branches { + switch { + case w.restates(r.held, principal, b): + within++ + case r.held.kinds&w.whole(b).kinds != 0: + overlaps++ + } + } + if w.incomplete() || within == 0 { + return false + } + return key == "anyOf" || (within == 1 && overlaps == 0) +} + // recordOn keeps k on the node owner names. func (k outcomeKeeps) recordOn(c lowering.Ctx, ts *compile.Types, owner ir.TypeID, s *oas3.Schema, pointer jsontext.Pointer) []ir.Diagnostic { td, ok, diags := registeredNode(c, ts, owner, pointer) diff --git a/compilers/openapi/internal/schema/refinement_internal_test.go b/compilers/openapi/internal/schema/refinement_internal_test.go index ef5b4a276..b98f78d17 100644 --- a/compilers/openapi/internal/schema/refinement_internal_test.go +++ b/compilers/openapi/internal/schema/refinement_internal_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" yaml "go.yaml.in/yaml/v3" @@ -23,6 +24,26 @@ import ( "github.com/dexpace/morphic/ir" ) +// TestUnionHeld_AReadCutShortSaysNo covers unionHeld's own contract rather than +// its callers', whose refinement declines first wherever a union's read is cut +// short: a branch restating what the position holds says the union only while +// every branch's read finishes. +func TestUnionHeld_AReadCutShortSaysNo(t *testing.T) { + t.Parallel() + js := &oas3.JSONSchema[oas3.Referenceable]{} + valErrs, err := marshaller.Unmarshal(t.Context(), strings.NewReader("{anyOf: [{type: string}, {type: integer}]}"), js) + require.NoError(t, err) + require.Empty(t, valErrs, "the fixture parses cleanly") + l, _ := LoweredFor(t, openapitest.ComponentSpecVer("3.1.0", " A: {type: string}\n")) + r := refinement{held: admitted{kinds: kindString}} + + whole := newAdmission(l.Ctx) + assert.True(t, unionHeld(&whole, r, js.GetSchema(), "anyOf"), "read whole, the string branch says the union") + cut := newAdmission(l.Ctx) + require.True(t, cut.reads.Spend(maxAdmissionReads), "the budget holds what is spent") + assert.False(t, unionHeld(&cut, r, js.GetSchema(), "anyOf"), "the integer branch's read runs past it") +} + // TestOutcomeKeeps_ReportsANodeTheRegistryLacks pins the one way recordOn keeps // nothing: the node it was handed is not in the registry, a compiler bug no source // provokes, which it reports rather than dropping what it holds. diff --git a/compilers/openapi/internal/schema/refinement_test.go b/compilers/openapi/internal/schema/refinement_test.go index e127c9d29..bdb958026 100644 --- a/compilers/openapi/internal/schema/refinement_test.go +++ b/compilers/openapi/internal/schema/refinement_test.go @@ -188,6 +188,99 @@ func TestRefinement_PrimitiveBranchesLowerToTheirMeet(t *testing.T) { } } +// TestRefinement_AUnionBranchIsAConjunct pins a oneOf or anyOf written as an +// allOf branch where the conjuncts admit no object, read as +// docs/compilers/openapi.md#allof-refinement reads any conjunct. Beside a +// narrower one it lowers to the meet, at a property as at a component, saying +// nothing more where a branch says nothing that conjunct does not and, for a +// oneOf, shares a kind with no other; a branch's bound, pattern or annotation +// says more, so it stays with its branch. Where it is the narrowest, it is the +// principal, and where it shares no value with the rest, the allOf composes. +func TestRefinement_AUnionBranchIsAConjunct(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.ComponentSpecVer("3.0.3", refinementTargets+` Value: + allOf: + - anyOf: [{type: string}, {type: number}, {type: boolean}] + - {type: string, maxLength: 1000} + ValueString: {type: string, maxLength: 1000} + ByRef: {allOf: [{oneOf: [{type: integer}, {$ref: '#/components/schemas/Owner'}, {type: string}]}, {$ref: '#/components/schemas/Str'}]} + TwoMatch: {allOf: [{oneOf: [{type: string}, {type: string, maxLength: 3}]}, {type: string, format: uuid}]} + BothWithin: {allOf: [{oneOf: [{type: string}, {type: string, format: uuid}]}, {type: string, format: uuid}]} + FormatNotHeld: {allOf: [{anyOf: [{type: string, format: uuid}, {type: integer}]}, {type: string}]} + Overlapping: {allOf: [{oneOf: [{type: string}, {type: string, format: date}]}, {$ref: '#/components/schemas/Str'}]} + Disjoint: {allOf: [{anyOf: [{type: string}, {type: integer}]}, {type: boolean}]} + Widest: {allOf: [{anyOf: [{type: string}, {type: integer}]}, {description: either}]} + Bounded: {allOf: [{anyOf: [{type: string, maxLength: 5}, {type: integer}]}, {type: string, maxLength: 1000}]} + BoundedOne: {allOf: [{oneOf: [{type: string, maxLength: 5}, {type: integer}]}, {type: string, maxLength: 1000}]} + Patterned: {allOf: [{anyOf: [{type: string, pattern: '^a'}, {type: integer}]}, {type: string, maxLength: 1000}]} + Described: {allOf: [{anyOf: [{type: string, description: a word}, {type: integer}]}, {type: string, maxLength: 1000}]} + BesideDecides: {type: string, maxLength: 1000, allOf: [{anyOf: [{type: string}, {type: integer}]}]} + SameText: {allOf: [{oneOf: [{type: string, maxLength: 5}, {type: integer}]}, {type: string, maxLength: 5}]} + SameRef: {allOf: [{oneOf: [{$ref: '#/components/schemas/Str'}, {type: integer}]}, {$ref: '#/components/schemas/Str'}]} + Holder: + type: object + properties: + value: + allOf: + - anyOf: [{type: string}, {type: number}, {type: boolean}] + - {type: string, maxLength: 1000} +`)) + openapitest.RequireNoErrorDiags(t, diags) + + value, ok := typeByName(doc, "Value").(*ir.Scalar) + require.True(t, ok, "Value is a scalar, not %T", typeByName(doc, "Value")) + want, ok := typeByName(doc, "ValueString").(*ir.Scalar) + require.True(t, ok) + assert.Equal(t, want.Base, value.Base, "Value lowers as ValueString does") + assert.Equal(t, want.Constraints, value.Constraints) + assert.Empty(t, value.Unmodeled, "the anyOf says nothing the string does not") + for _, at := range []string{"/components/schemas/Value", "/components/schemas/Value/allOf/0", "/components/schemas/Value/allOf/1"} { + assert.False(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, at), at) + } + + field := propertyOf(t, doc, "Holder", "value") + assert.Equal(t, ir.TypeID("t/prim/string"), field.Type.Target, "a property lowers to the meet too") + require.NotNil(t, field.Constraints) + assert.Equal(t, int64(1000), *field.Constraints.MaxLength) + assert.Empty(t, field.Unmodeled) + + beside, ok := typeByName(doc, "BesideDecides").(*ir.Scalar) + require.True(t, ok, "BesideDecides is a scalar, not %T", typeByName(doc, "BesideDecides")) + assert.Equal(t, ir.TypeID("t/prim/string"), beside.Base.Target, "the keywords beside the allOf decide it") + assert.Empty(t, beside.Unmodeled, "with no principal branch, the anyOf is said all the same") + + for _, name := range []string{"SameText", "SameRef"} { + assert.Empty(t, typeByName(doc, name).Common().Unmodeled, "%s: a branch written as the principal says nothing more", name) + assert.False(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/"+name+"/allOf/0"), name) + } + + byRef, ok := typeByName(doc, "ByRef").(*ir.Scalar) + require.True(t, ok, "ByRef is a use of Str, not %T", typeByName(doc, "ByRef")) + assert.Equal(t, componentID("Str"), byRef.Base.Target) + assert.Empty(t, byRef.Unmodeled, "Str lies within one branch and shares no kind with another") + + for name, key := range map[string]string{ + "TwoMatch": "openapi:allOf/0", "BothWithin": "openapi:allOf/0", "FormatNotHeld": "openapi:allOf/0", + "Overlapping": "openapi:allOf/0", "Bounded": "openapi:allOf/0", "BoundedOne": "openapi:allOf/0", + "Patterned": "openapi:allOf/0", "Described": "openapi:allOf/0", + } { + kept := typeByName(doc, name).Common().Unmodeled[key] + assert.Equal(t, ir.ReasonDegradedLowering, kept.Reason, "%s keeps the union, which says more", name) + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/"+name+"/allOf/0"), name) + } + + _, composes := typeByName(doc, "Disjoint").(*ir.Model) + assert.True(t, composes, "a union sharing no value with the rest leaves the allOf a composition") + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/Disjoint/allOf/1")) + + widest, ok := typeByName(doc, "Widest").(*ir.Scalar) + require.True(t, ok, "Widest is an alias over its principal, not %T", typeByName(doc, "Widest")) + u, ok := doc.Types[widest.Base.Target].(*ir.Union) + require.True(t, ok, "the union branch is the principal") + assert.Len(t, u.Variants, 2) + assert.Equal(t, "either", widest.Docs.Description) +} + // TestRefinement_DisjointConjunctsStayAComposition pins what GitHub #1043 keeps: // no value satisfies both branches, so the warnings stand on the model. func TestRefinement_DisjointConjunctsStayAComposition(t *testing.T) { @@ -471,9 +564,6 @@ func TestRefinement_ReachesNoFurtherThanItCanRead(t *testing.T) { schemas: " S: {allOf: [{$ref: '#/components/schemas/R'}]}\n R: {allOf: [{$ref: '#/components/schemas/S'}]}\n", }, "an unresolved $ref": {schemas: " S: {allOf: [{$ref: '#/components/schemas/Gone'}, {type: string}]}\n", errs: true}, - "a union on a branch": { - schemas: " S: {allOf: [{$ref: '#/components/schemas/Str'}, {oneOf: [{minLength: 1}, {maxLength: 2}]}]}\n", - }, "a union beside": { schemas: " S: {allOf: [{$ref: '#/components/schemas/Str'}], oneOf: [{minLength: 1}, {maxLength: 2}]}\n", }, diff --git a/compilers/openapi/internal/schema/schema.go b/compilers/openapi/internal/schema/schema.go index 2bedb3fd3..b7569a13f 100644 --- a/compilers/openapi/internal/schema/schema.go +++ b/compilers/openapi/internal/schema/schema.go @@ -238,13 +238,18 @@ func lowerSchemaBody(c lowering.Ctx, ts *compile.Types, depth int, schema *oas3. "$dynamicRef expanded to %q, the one matching $dynamicAnchor in this document", target)}, nullDiags...) } if len(schema.GetOneOf()) > 0 || len(schema.GetAnyOf()) > 0 { - if hasUnionSiblings(schema) { + if hasUnionSiblings(c, schema) { id, unionDiags := lowerCoDeclaredUnion(c, ts, depth, schema, pointer) ref, nullDiags := schemaNullRef(c, id, schema, siteAt(pointer)) return ref, append(unionDiags, nullDiags...) } return lowerOneOfAnyOf(c, ts, depth, schema, pointer) } + if u, ok := branchUnion(c, schema, pointer); ok { + id, unionDiags := lowerDistributedUnion(c, ts, depth, schema, u, pointer) + ref, nullDiags := schemaNullRef(c, id, schema, siteAt(pointer)) + return ref, append(unionDiags, nullDiags...) + } id, bodyDiags := lower(c, ts, depth, schema, pointer) ref, nullDiags := schemaNullRef(c, id, schema, siteAt(pointer)) return ref, append(bodyDiags, nullDiags...) @@ -375,10 +380,13 @@ func enumWritten(s *oas3.Schema) bool { // dispatch records how lower() resolved a schema's competing keyword families: // the one it lowered, and the ones it passed over. won is "" when the schema -// declares none of them and the type set decides the lowering instead. +// declares none of them and the type set decides the lowering instead. said +// names the keywords a union's branches all say already (branchesSay), which +// the position consumes though its node has no field for them. type dispatch struct { won string skipped []string + said []string } // besideAllOf returns the election lower() makes of the keywords beside s's @@ -487,37 +495,60 @@ func effectiveTypes(s *oas3.Schema) []oas3.SchemaType { return out } -// declaresShape reports whether a schema declares data shape — a type, a -// property set, a value set, composition, or a catch-all — rather than only -// narrowing what an already-declared shape accepts. The narrowing keywords are -// narrowsInstance's, which hasUnionSiblings and dynamicRefSiblings add to it -// (docs/compilers/openapi.md#union-beside-structure). -func declaresShape(s *oas3.Schema) bool { - if props := s.GetProperties(); props != nil && props.Len() > 0 { - return true - } - if s.GetConst() != nil || enumWritten(s) || len(s.GetAllOf()) > 0 { - return true - } - if annotation.Written(s.GetAdditionalProperties()) { - return true - } - if pp := s.GetPatternProperties(); pp != nil && pp.Len() > 0 { - return true +// siblingKeyword is a keyword whose presence beside a union or a $dynamicRef +// leaves something for the position to carry, with how a schema writes it. +type siblingKeyword struct { + key string + writes func(*oas3.Schema) bool +} + +// shapeKeywords are the keywords that declare data shape — a type, a property +// set, a value set, composition, or a catch-all — rather than only narrowing +// what an already-declared shape accepts (docs/compilers/openapi.md#union-beside-structure). +var shapeKeywords = []siblingKeyword{ + {"properties", func(s *oas3.Schema) bool { props := s.GetProperties(); return props != nil && props.Len() > 0 }}, + {"const", func(s *oas3.Schema) bool { return s.GetConst() != nil }}, + {"enum", enumWritten}, + {"allOf", func(s *oas3.Schema) bool { return len(s.GetAllOf()) > 0 }}, + {"additionalProperties", func(s *oas3.Schema) bool { return annotation.Written(s.GetAdditionalProperties()) }}, + {"patternProperties", func(s *oas3.Schema) bool { pp := s.GetPatternProperties(); return pp != nil && pp.Len() > 0 }}, + {"type", func(s *oas3.Schema) bool { return len(effectiveTypes(s)) > 0 }}, +} + +// narrowingKeywords are the keywords that constrain an instance without +// declaring a shape to build. They sit on the narrowing side of shapeKeywords' +// line, and that line is about what to *build*. Wherever the question is +// instead whether a lowering can carry what the position wrote, the distinction +// does not apply: an ir.Union has no element type, so +// `{oneOf: [...], items: {...}}` loses the `items` as surely as it would lose a +// co-declared property set. An `items` list narrows too, read off the raw node, +// since nothing documents what the parser's model holds there. +var narrowingKeywords = []siblingKeyword{ + {"required", func(s *oas3.Schema) bool { return len(s.GetRequired()) > 0 }}, + {"items", func(s *oas3.Schema) bool { + _, listed := listedItems(s) + return listed || annotation.Written(s.GetItems()) + }}, + {"prefixItems", func(s *oas3.Schema) bool { return len(s.GetPrefixItems()) > 0 }}, +} + +// siblingKeywords returns the keywords of shapeKeywords and narrowingKeywords +// that s writes, in that order. +func siblingKeywords(s *oas3.Schema) []string { + var out []string + for _, set := range [...][]siblingKeyword{shapeKeywords, narrowingKeywords} { + for _, k := range set { + if k.writes(s) { + out = append(out, k.key) + } + } } - return len(effectiveTypes(s)) > 0 + return out } -// narrowsInstance reports whether s writes a keyword that constrains an instance -// without declaring a shape to build: `required`, `items`, `prefixItems`. -// -// These sit on the narrowing side of declaresShape's line, and that line is about -// what to *build*. Wherever the question is instead whether a lowering can carry -// what the position wrote, the distinction does not apply: an ir.Union has no -// element type, so `{oneOf: [...], items: {...}}` loses the `items` as surely as -// it would lose a co-declared property set. An `items` list narrows too, read -// off the raw node, since nothing documents what the parser's model holds there. -func narrowsInstance(s *oas3.Schema) bool { - _, listed := listedItems(s) - return len(s.GetRequired()) > 0 || listed || annotation.Written(s.GetItems()) || len(s.GetPrefixItems()) > 0 +// writesSiblingKeyword reports whether siblingKeywords(s) holds any keyword, +// without building the list, as most schemas a reader asks of write one. +func writesSiblingKeyword(s *oas3.Schema) bool { + writes := func(k siblingKeyword) bool { return k.writes(s) } + return slices.ContainsFunc(shapeKeywords, writes) || slices.ContainsFunc(narrowingKeywords, writes) } diff --git a/compilers/openapi/internal/schema/union.go b/compilers/openapi/internal/schema/union.go index 765a3b311..c93c8cbbe 100644 --- a/compilers/openapi/internal/schema/union.go +++ b/compilers/openapi/internal/schema/union.go @@ -40,7 +40,7 @@ func lowerOneOfAnyOf(c lowering.Ctx, ts *compile.Types, depth int, s *oas3.Schem } var diags []ir.Diagnostic tid := internNode(c, ts, pointer, func(common ir.TypeCommon) ir.TypeDef { - def, unionDiags := buildUnion(c, ts, s, common, pointer, + def, unionDiags := buildUnion(c, ts, s, unionAt{holder: s, at: pointer}, common, pointer, func(b *oas3.JSONSchema[oas3.Referenceable], vptr jsontext.Pointer) ir.TypeRef { ref, refDiags := Ref(c, ts, depth, b, vptr) diags = append(diags, refDiags...) @@ -49,7 +49,7 @@ func lowerOneOfAnyOf(c lowering.Ctx, ts *compile.Types, depth int, s *oas3.Schem diags = append(diags, unionDiags...) return def }) - owner, unhomedDiags := preserveUnhomedKeywords(c, ts, s, pointer, tid, dispatch{}) + owner, unhomedDiags := preserveUnhomedKeywords(c, ts, s, pointer, tid, dispatch{said: branchesSay(c, s)}) ref, nullDiags := schemaNullRef(c, owner, s, siteAt(pointer)) return ref, slices.Concat(diags, unhomedDiags, nullDiags) } @@ -254,39 +254,110 @@ func preserveUnusedCombinator(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, w // stripping, variant hints, exclusivity, pointer derivation. type variantTypeFunc func(b *oas3.JSONSchema[oas3.Referenceable], vptr jsontext.Pointer) ir.TypeRef -// buildUnion assembles the Union node for a oneOf/anyOf schema, attaching a -// discriminator when one is declared. common is already built by the caller +// buildUnion assembles the Union node for the union u of the schema s, attaching +// a discriminator when s declares one. common is already built by the caller // (internNode), so buildUnion needs no hint of its own to build one. // // The variants are lowered before the discriminator, so its mapping resolves // knowing what each variant names (readsThrough). A union the discriminator // routes is WireTagged, an anyOf as much as a oneOf: the tag property's value // names its variant (docs/ir/unions.md). -func buildUnion(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, common ir.TypeCommon, pointer jsontext.Pointer, variantType variantTypeFunc) (ir.TypeDef, []ir.Diagnostic) { - branches, key, exclusive := unionBranches(s) - diags := preserveUnusedCombinator(c, &common.Unmodeled, s, key, pointer) +func buildUnion(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, u unionAt, common ir.TypeCommon, pointer jsontext.Pointer, + variantType variantTypeFunc, +) (ir.TypeDef, []ir.Diagnostic) { + branches, key, exclusive := unionBranches(u.holder) + diags := preserveUnusedCombinator(c, &common.Unmodeled, u.holder, key, u.at) variants := make([]ir.Variant, 0, len(branches)) for i, b := range branches { if isNullSchema(b) { continue // null branches lift to the enclosing ref's Nullable bit } - vptr := pointer + ids.Ptr(key, strconv.Itoa(i)) + vptr := u.at + ids.Ptr(key, strconv.Itoa(i)) t := variantType(b, vptr) variants = append(variants, ir.Variant{ Name: compile.NamingHint(variantHint(c, ts, b, vptr, t)), Type: t, }) } - u := &ir.Union{ + node := &ir.Union{ TypeCommon: common, Variants: variants, Exclusive: exclusive, Closed: true, } disc, discDiags := lowerDiscriminator(c, ts, s, nil, variants, pointer) - u.Discriminator = disc - u.WireTagged = disc != nil - return u, append(diags, discDiags...) + node.Discriminator = disc + node.WireTagged = disc != nil + return node, append(diags, discDiags...) +} + +// unionAt is a union a position lowers: the oneOf or anyOf, whichever +// unionBranches elects, that holder writes at the pointer at. holder is the +// schema written at the position, or an inline branch of its allOf, which JSON +// Schema conjoins with the rest of the composition as it would one written +// beside it (docs/compilers/openapi.md#union-beside-structure). +type unionAt struct { + holder *oas3.Schema + at jsontext.Pointer +} + +// branchUnionOf keys what branchUnion read of one schema. +type branchUnionOf struct { + schema *oas3.Schema +} + +// branchUnionIndex is where in a schema's flattened allOf the branch whose +// union it distributes is, where one is. +type branchUnionIndex struct { + index int + ok bool +} + +// branchUnion returns the union an inline branch of s's allOf writes that s, +// written at pointer, distributes across the rest of its composition, as it +// would the same union written beside the allOf +// (docs/compilers/openapi.md#union-beside-structure). Every reader of the +// position asks, so it is read once a run. +func branchUnion(c lowering.Ctx, s *oas3.Schema, pointer jsontext.Pointer) (unionAt, bool) { + at := lowering.Remembered(c, branchUnionOf{s}, func() branchUnionIndex { return findBranchUnion(c, s) }) + if !at.ok { + return unionAt{}, false + } + e := allOfBranches(s, pointer).branches[at.index] + return unionAt{holder: e.schema.GetSchema(), at: e.pointer}, true +} + +// findBranchUnion is branchUnion's reading of s: where s lowers its allOf, +// composing, not refining, beside no discriminator of its own, the one branch +// whose union declares shape against s, where that branch lies within the +// flattening depth and classifyBranchSet distributes its union. Two such unions +// leave none, either dropping the other, and one declaring no shape stays +// residue. A $ref site or a union beside the allOf lowers first +// (lowerSchemaBody). +// +// Nor below a discriminated ancestor, whose route may name s (routedSubtype): +// the readers of s share one reading, with no position to tell an inline s no +// route names (GitHub #1695). +func findBranchUnion(c lowering.Ctx, s *oas3.Schema) branchUnionIndex { + if s.GetDiscriminator() != nil || dispatchOf(s).won != "allOf" { + return branchUnionIndex{} + } + branches := allOfBranches(s, "").branches + index, count := 0, 0 + for i, e := range branches { + bs := e.schema.GetSchema() + if isRefBranch(e.schema) || !declaresUnion(bs) || !setsDeclareShape(c, s, bs.GetOneOf(), bs.GetAnyOf()) { + continue + } + index, count = i, count+1 + } + if count != 1 || branches[index].capped || classifyBranchSet(c, branches[index].schema.GetSchema()) != unionDistributed { + return branchUnionIndex{} + } + if _, refines := refinementOf(c, s, ""); refines || len(ancestorDiscriminators(c.Bindings(), s)) > 0 { + return branchUnionIndex{} + } + return branchUnionIndex{index: index, ok: true} } // variantHint is the hint the union variant for the branch b written at vptr, @@ -369,9 +440,85 @@ func typesDisjoint(types []oas3.SchemaType) bool { // a keyword that narrows an instance without declaring one. When it does, the // union alone cannot represent the schema, so the sibling body must be lowered // too rather than dropped (invariant 2, -// docs/ir/models.md#composition-beside-a-union). -func hasUnionSiblings(s *oas3.Schema) bool { - return narrowsInstance(s) || declaresShape(s) +// docs/ir/models.md#composition-beside-a-union), unless the branches declare +// shape and say every one of those keywords already (branchesSay): the schema +// then means the union alone. Branches declaring none narrow the body, which +// stays validation logic beside it whatever they repeat. A discriminator beside +// them keeps the body, whose routes may be its subtypes (GitHub #594). +func hasUnionSiblings(c lowering.Ctx, s *oas3.Schema) bool { + if !declaresUnion(s) || s.GetDiscriminator() != nil { + return writesSiblingKeyword(s) // no branches say what it writes + } + siblings := siblingKeywords(s) + if len(siblings) == 0 { + return false + } + said := branchesSay(c, s) + if slices.ContainsFunc(siblings, func(k string) bool { return !slices.Contains(said, k) }) { + return true + } + return !unionBranchesDeclareShape(c, s) +} + +// restatableKeywords are the keywords beside a union that its branches can say +// in full, read as admission reads what a schema admits. +var restatableKeywords = []string{"type", "format", "enum", "const", "items"} + +// branchesSayOf keys what branchesSay read of one schema. +type branchesSayOf struct { + schema *oas3.Schema +} + +// branchesSay returns the keywords of restatableKeywords s writes beside its +// oneOf and anyOf that every branch of both already says: a `type` naming each +// kind a branch admits, null included, and a `format`, an `enum`, a `const` or +// an `items` each branch is held to (admission.holds). Such a keyword adds +// nothing to the union, so the position reads as if it were not written +// (docs/compilers/openapi.md#union-beside-structure). A read cut short says +// none, and so does a schema writing no union, of which "every branch" would +// say anything. Every reader of the position asks, so it is read once a run. +func branchesSay(c lowering.Ctx, s *oas3.Schema) []string { + return lowering.Remembered(c, branchesSayOf{s}, func() []string { + branches := slices.Concat(s.GetOneOf(), s.GetAnyOf()) + if len(branches) == 0 { + return nil + } + w := newAdmission(c) + reads := make([]admitted, len(branches)) + for i, b := range branches { + reads[i] = w.whole(b) + } + if w.incomplete() { + return nil + } + var out []string + for _, key := range restatableKeywords { + if annotation.RawPropertyNode(s, key) == nil { + continue + } + if !slices.ContainsFunc(reads, func(r admitted) bool { return !branchSays(&w, r, s, key) }) { + out = append(out, key) + } + } + return out + }) +} + +// branchSays reports whether a branch admitting branch, as w reads it, says all +// that s's keyword key does. A `type` is compared with null in it, which +// admission.holds leaves to the nulls reader: a union lowered alone lifts a null +// branch onto the position, so a `type` leaving null out says more than a branch +// admitting it. An `items` list says what no single schema does. +func branchSays(w *admission, branch admitted, s *oas3.Schema, key string) bool { + switch key { + case "type": + return branch.kinds&^writtenKinds(s, w.nullable) == 0 + case "items": + _, listed := listedItems(s) + return !listed && w.holds(branch, s, key) + default: + return w.holds(branch, s, key) + } } // unionLowering names how a oneOf/anyOf co-declared with structural keywords is @@ -396,17 +543,21 @@ const ( // only one of the two can become the value, so distributing either drops the // other. unionBothCombinators - // unionInlineBranch marks a union with a branch written inline rather than as - // a $ref, so it names no node for Base/Mixins to point at. - unionInlineBranch - // unionUnresolvedBranch marks a union whose every branch is a $ref but at - // least one names nothing this compilation can resolve — a target in a - // document it did not read, an undeclared component, a pointer addressing no - // schema. + // unionUnresolvedBranch marks a union with a $ref branch naming nothing this + // compilation can resolve — a target in a document it did not read, an + // undeclared component, a pointer addressing no schema. unionUnresolvedBranch + // unionNullBranch marks a union with a bare `type: null` branch, which + // buildUnion lifts onto the position's Nullable rather than conjoining it, so + // no variant could say whether the body admits the null. + unionNullBranch // unionDiscriminated marks a union whose branches a declared discriminator // binds by name, which distribution would break. unionDiscriminated + // unionRoutedSubtype marks a body a discriminator on an ancestor routes to + // (routedSubtype). The route names the type the body lowers to, which as a + // union would be no subtype of that ancestor (GitHub #1694). + unionRoutedSubtype ) // classifyUnionSiblings picks the lowering for a schema whose oneOf/anyOf sits @@ -419,26 +570,50 @@ const ( // // A declared discriminator rules distribution out: its mapping names the branch // schemas, which distribution replaces with synthesized composed models -// (pass/validate's discriminator-missing-variant rule is the mirror). The rule -// docs/compilers/openapi.md#union-beside-structure enumerates this and the -// other residual shapes. -func classifyUnionSiblings(c lowering.Ctx, s *oas3.Schema) unionLowering { +// (pass/validate's discriminator-missing-variant rule is the mirror). So does +// one on an ancestor routing to s, written at pointer (routedSubtype). +// docs/compilers/openapi.md#union-beside-structure enumerates the shapes. +func classifyUnionSiblings(c lowering.Ctx, s *oas3.Schema, pointer jsontext.Pointer) unionLowering { if !unionBranchesDeclareShape(c, s) { return unionValidationOnly } - if !composesAsModel(s) { + if !composesAsModel(c, s) { return unionUncomposableBody } - if len(s.GetOneOf()) > 0 && len(s.GetAnyOf()) > 0 { + if outcome := classifyBranchSet(c, s); outcome != unionDistributed { + return outcome + } + if routedSubtype(c, s, pointer) { + return unionRoutedSubtype + } + return unionDistributed +} + +// routedSubtype reports whether a discriminator on an ancestor of s, written at +// pointer, routes to s as its subtype: by a mapping key or by its name as a +// component, the tag its lowering takes (subtypeDiscriminatorValue). That +// lowering reports the tag's own diagnostics, so this reading drops them. +func routedSubtype(c lowering.Ctx, s *oas3.Schema, pointer jsontext.Pointer) bool { + _, routed, _ := subtypeDiscriminatorValue(c, s, c.IDs().ForPointer(pointer), pointer) + return routed +} + +// classifyBranchSet is the part of classifyUnionSiblings that reads the union +// holder writes rather than the body: whichever of a union beside the body and +// one on a branch of its allOf the body distributes (branchUnion), it does so +// only where this says unionDistributed. +func classifyBranchSet(c lowering.Ctx, holder *oas3.Schema) unionLowering { + if len(holder.GetOneOf()) > 0 && len(holder.GetAnyOf()) > 0 { return unionBothCombinators } - if branches, _, _ := unionBranches(s); slices.ContainsFunc(branches, isInlineBranch) { - return unionInlineBranch + branches, _, _ := unionBranches(holder) + if slices.ContainsFunc(branches, isNullSchema) { + return unionNullBranch } - if !branchesNameReferents(c, s) { + if !branchesNameReferents(c, holder) { return unionUnresolvedBranch } - if s.GetDiscriminator() != nil { + if holder.GetDiscriminator() != nil { return unionDiscriminated } return unionDistributed @@ -447,8 +622,10 @@ func classifyUnionSiblings(c lowering.Ctx, s *oas3.Schema) unionLowering { // composesAsModel reports whether the structural siblings lower to a Model, // which is the only node with composition fields for a union to distribute // across. It mirrors lower's dispatch: const and enum win over everything, then -// allOf, then a declared object type or a bare property set. -func composesAsModel(s *oas3.Schema) bool { +// allOf, then a declared object type or a bare property set. A bare `required` +// is one too where every branch admits only an object, the one instance it +// constrains, or null, which it leaves alone (branchesAdmitOnlyObjects). +func composesAsModel(c lowering.Ctx, s *oas3.Schema) bool { if s.GetConst() != nil || enumWritten(s) { return false } @@ -456,28 +633,38 @@ func composesAsModel(s *oas3.Schema) bool { return true } types := effectiveTypes(s) - if len(types) == 1 && types[0] == oas3.SchemaTypeObject { + if len(types) > 0 { + return len(types) == 1 && types[0] == oas3.SchemaTypeObject + } + if props := s.GetProperties(); props != nil && props.Len() > 0 { return true } - props := s.GetProperties() - return len(types) == 0 && props != nil && props.Len() > 0 + return slices.Equal(siblingKeywords(s), []string{"required"}) && branchesAdmitOnlyObjects(c, s) +} + +// branchesAdmitOnlyObjects reports whether each branch of the combinator +// unionBranches elects admits an object or null and nothing else, read as +// admission reads it. A read cut short says no. +func branchesAdmitOnlyObjects(c lowering.Ctx, s *oas3.Schema) bool { + branches, _, _ := unionBranches(s) + w := newAdmission(c) + objectsOnly := !slices.ContainsFunc(branches, func(b *oas3.JSONSchema[oas3.Referenceable]) bool { + return w.whole(b).kinds&^(kindObject|kindNull) != 0 + }) + return objectsOnly && !w.incomplete() } // branchesNameReferents reports whether every union branch can be conjoined with -// the sibling model. Base and Mixins conjoin *by reference*, so a branch -// qualifies exactly when it names a schema this compilation resolves; an inline -// branch (ruled out before this) could only be merged in keyword by keyword, and -// an unresolvable $ref names nothing to merge either way. The test is over the +// the sibling model. Base and Mixins conjoin *by reference*, so a $ref branch +// qualifies exactly when it names a schema this compilation resolves, and an +// inline one does as the node hoisted at its own pointer. The test is over the // whole union because a half-distributed one would leave variants disagreeing // about whether they carry the body, with nothing in the IR saying which. func branchesNameReferents(c lowering.Ctx, s *oas3.Schema) bool { branches, _, _ := unionBranches(s) - for _, b := range branches { - if !c.RefScope().NamesReferent(b) { - return false - } - } - return true + return !slices.ContainsFunc(branches, func(b *oas3.JSONSchema[oas3.Referenceable]) bool { + return isRefBranch(b) && !c.RefScope().NamesReferent(b) + }) } // coDeclaredUnionWhyPrefix opens every lowerCoDeclaredUnion why string: at @@ -497,9 +684,9 @@ func lowerCoDeclaredUnion(c lowering.Ctx, ts *compile.Types, depth int, s *oas3. } return lowerBesideUnmodeledUnion(c, ts, depth, s, pointer, reason, why) } - switch classifyUnionSiblings(c, s) { + switch classifyUnionSiblings(c, s, pointer) { case unionDistributed: - return lowerDistributedUnion(c, ts, depth, s, pointer) + return lowerDistributedUnion(c, ts, depth, s, unionAt{holder: s, at: pointer}, pointer) case unionValidationOnly: return beside(ir.ReasonValidationOnly, "") case unionUncomposableBody: @@ -508,13 +695,16 @@ func lowerCoDeclaredUnion(c lowering.Ctx, ts *compile.Types, depth int, s *oas3. case unionBothCombinators: return beside(ir.ReasonDegradedLowering, "oneOf and anyOf are both declared, so distributing either would drop the other") - case unionInlineBranch: - return beside(ir.ReasonDegradedLowering, - "a branch is written inline, so it names no referent to conjoin the body with") case unionUnresolvedBranch: id, diags := beside(ir.ReasonDegradedLowering, "a branch's $ref names no referent this compilation resolves") return id, append(diagUnresolvedBranches(c, s, pointer), diags...) + case unionNullBranch: + return beside(ir.ReasonDegradedLowering, + "a branch is a bare null, which the union lifts onto the position, so no variant can conjoin the body with it") + case unionRoutedSubtype: + return beside(ir.ReasonDegradedLowering, + "a discriminator on an ancestor routes to this schema as its subtype, which distributing it into a union would undo") default: // unionDiscriminated return beside(ir.ReasonDegradedLowering, "a declared discriminator binds the branches by name, which distributing them would break") @@ -532,7 +722,7 @@ func diagUnresolvedBranches(c lowering.Ctx, s *oas3.Schema, pointer jsontext.Poi branches, key, _ := unionBranches(s) var diags []ir.Diagnostic for i, b := range branches { - if c.RefScope().NamesReferent(b) || onLoop(c, b) { + if !isRefBranch(b) || c.RefScope().NamesReferent(b) || onLoop(c, b) { continue } diags = append(diags, c.DiagAt(ir.SeverityError, diag.UnresolvedRef, @@ -608,18 +798,22 @@ func declaresUnion(s *oas3.Schema) bool { const refSiteUnionWhy = "oneOf/anyOf beside a $ref conjoin with it rather than composing into a structural body, so there is nothing at this position to distribute the union's composition across" // lowerDistributedUnion emits the Union that is the schema's value, distributing -// the sibling composition across its variants: `S ∧ (X | Y)` becomes +// the sibling composition across the variants of u: `S ∧ (X | Y)` becomes // `(S ∧ X) | (S ∧ Y)`. Each variant is a Model classifying S's Base/Mixins and // own properties (docs/ir/models.md#composition-beside-a-union) alongside its // branch, so the composition is carried on every variant rather than merged -// into one or dropped. -func lowerDistributedUnion(c lowering.Ctx, ts *compile.Types, depth int, s *oas3.Schema, pointer jsontext.Pointer) (ir.TypeID, []ir.Diagnostic) { +// into one or dropped. A union an allOf branch writes is the branch's to give, +// not residue each variant keeps (allOfComposition.distributes). +func lowerDistributedUnion(c lowering.Ctx, ts *compile.Types, depth int, s *oas3.Schema, u unionAt, pointer jsontext.Pointer) (ir.TypeID, []ir.Diagnostic) { var diags []ir.Diagnostic id := internNode(c, ts, pointer, func(common ir.TypeCommon) ir.TypeDef { body := composedBody{schema: s, comp: readAllOf(c, s, pointer), - voices: positionVoices(c, ts, s, pointer, ir.TypeRef{}), id: common.ID} + voices: positionVoices(c, ts, s, pointer, ir.TypeRef{})} + if u.holder != s { + body.comp.distributes = u.at + } diags = append(diags, diagDisjointConjuncts(c, body.comp, composedShape)...) - def, unionDiags := buildUnion(c, ts, s, common, pointer, + def, unionDiags := buildUnion(c, ts, s, u, common, pointer, func(b *oas3.JSONSchema[oas3.Referenceable], vptr jsontext.Pointer) ir.TypeRef { ref, variantDiags := composedVariant(c, ts, depth, body, b, vptr) diags = append(diags, variantDiags...) @@ -629,19 +823,20 @@ func lowerDistributedUnion(c lowering.Ctx, ts *compile.Types, depth int, s *oas3 return def }) diags = append(diags, preserveVariantsUnhomed(c, ts, s, id, distributedShape, nil, pointer)...) + where := "co-declared with structural keywords" + if u.holder != s { + where = "written on an allOf branch conjoins with the rest of the composition" + } return id, append(diags, c.DiagAt(ir.SeverityInfo, diag.CompositionLowering, pointer, - "oneOf/anyOf co-declared with structural keywords; the composition is distributed across the union variants")) + "oneOf/anyOf %s; the composition is distributed across the union variants", where)) } // composedBody is the schema every distributed variant carries: the enclosing -// schema, where it is written, and the identity of the Union it lowered to — -// which is the node a base's discriminator mapping names, since the source -// declares one schema, not one per branch. +// schema and where it is written. type composedBody struct { schema *oas3.Schema comp allOfComposition // schema's flattened allOf, built once for every variant voices []annotation.Voice // what comp's folding branches say of the position, read once too - id ir.TypeID } // composedVariant synthesizes the Model for one distributed variant: the @@ -708,12 +903,9 @@ func buildComposedVariant(c lowering.Ctx, ts *compile.Types, depth int, body com diags = append(diags, fillAdditional(c, ts, depth, m, body.schema, body.comp.pointer)...) diags = append(diags, applyFalseBranches(c, m, body.schema, body.comp)...) conjoinBranch(m, branch) - // 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. - tag, tagDiags := subtypeDiscriminatorValue(c, body.schema, body.id, body.comp.pointer) - m.DiscriminatorValue = tag - diags = append(diags, tagDiags...) + // No variant carries a DiscriminatorValue: a schema a discriminated + // ancestor routes to is never distributed (unionRoutedSubtype), and one + // declaring a discriminator of its own is not either (unionDiscriminated). return m, diags } diff --git a/compilers/openapi/internal/schema/union_beside_test.go b/compilers/openapi/internal/schema/union_beside_test.go new file mode 100644 index 000000000..c98e99e12 --- /dev/null +++ b/compilers/openapi/internal/schema/union_beside_test.go @@ -0,0 +1,475 @@ +// Copyright (c) 2026 dexpace. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package schema_test + +import ( + "encoding/json/jsontext" + "fmt" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/compilers/openapi/internal/schema" + "github.com/dexpace/morphic/ir" +) + +// TestOneOf_CoDeclaredKeywordsEveryBranchSaysLeaveTheUnionAlone pins the body +// that adds nothing to its branches (docs/compilers/openapi.md#union-beside-structure): +// a `type` naming every kind they admit, and a `format`, `enum`, `const` or +// `items` each is held to, leave the union alone, lowered as its bare spelling +// is, keeping nothing and reporting nothing. A `format` the branches do not all +// say stays beside the union, and a body keyword they do not all say keeps the +// body: a null the `type` leaves out, a branch writing no `type`, branches +// declaring no shape and a discriminator beside them each keep it. +func TestOneOf_CoDeclaredKeywordsEveryBranchSaysLeaveTheUnionAlone(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpecVer("3.1.0", ` V4: {type: string, format: ipv4} + V6: {type: string, format: ipv6} + A: {type: object, properties: {a: {type: string}}} + Bare: {anyOf: [{$ref: '#/components/schemas/V4'}, {$ref: '#/components/schemas/V6'}]} + Typed: {type: string, anyOf: [{$ref: '#/components/schemas/V4'}, {$ref: '#/components/schemas/V6'}]} + Formatted: {type: string, format: ipv4, anyOf: [{$ref: '#/components/schemas/V4'}, {type: string, format: ipv4, maxLength: 15}]} + Valued: {enum: [a, b, c], oneOf: [{type: string, enum: [a]}, {type: string, enum: [b, c]}]} + CA: {type: string, const: a} + EA: {type: string, enum: [a], description: an a} + Constant: {const: a, anyOf: [{$ref: '#/components/schemas/CA'}, {$ref: '#/components/schemas/EA'}]} + Listed: + type: array + items: {type: string} + anyOf: [{type: array, items: {type: string, format: uuid}}, {type: array, items: {type: string, format: date}}] + Objects: {type: object, oneOf: [{$ref: '#/components/schemas/A'}, {type: object, properties: {b: {type: string}}}]} + NullAdmitted: {type: [string, 'null'], oneOf: [{$ref: '#/components/schemas/V4'}, {type: 'null'}]} + FormatNotSaid: {type: string, format: ipv4, anyOf: [{type: string}, {$ref: '#/components/schemas/V4'}]} + NullLeftOut: {type: string, oneOf: [{$ref: '#/components/schemas/V4'}, {type: 'null'}]} + Untyped: {type: string, anyOf: [{format: ipv4}, {format: ipv6}]} + Shapeless: {type: string, oneOf: [{type: string, pattern: '^a'}, {type: string, pattern: '^b'}]} + Discriminated: + type: object + discriminator: {propertyName: kind} + oneOf: [{$ref: '#/components/schemas/A'}] +`)) + openapitest.RequireNoErrorDiags(t, diags) + + bare, ok := typeByName(doc, "Bare").(*ir.Union) + require.True(t, ok) + typed, ok := typeByName(doc, "Typed").(*ir.Union) + require.True(t, ok, "a type every branch says leaves the union alone") + assert.Equal(t, bare.Variants, typed.Variants, "as its bare spelling lowers") + for _, name := range []string{"Typed", "Formatted", "Valued", "Constant", "Listed", "Objects"} { + u, ok := typeByName(doc, name).(*ir.Union) + require.True(t, ok, "%s lowers to its union; got %T", name, typeByName(doc, name)) + assert.Empty(t, u.Unmodeled, "%s keeps nothing: each keyword beside it is said", name) + assert.False(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/"+name), name) + assert.False(t, openapitest.HasDiagCodeAt(diags, diag.CompositionLowering, "/components/schemas/"+name), name) + } + + alias, ok := typeByName(doc, "NullAdmitted").(*ir.Scalar) + require.True(t, ok, "the union alone collapses to its one other branch") + assert.Equal(t, &ir.TypeRef{Target: componentID("V4"), Nullable: true}, alias.Base) + + formatNotSaid, ok := typeByName(doc, "FormatNotSaid").(*ir.Union) + require.True(t, ok, "the type is said, so the union is the position") + assert.Contains(t, formatNotSaid.Unmodeled, "openapi:format", "and the format no branch but one says stays beside it") + + for name, reading := range map[string]ir.UnmodeledReason{ + "NullLeftOut": ir.ReasonDegradedLowering, "Untyped": ir.ReasonDegradedLowering, + "Shapeless": ir.ReasonValidationOnly, "Discriminated": ir.ReasonDegradedLowering, + } { + assert.Equal(t, string(reading), unionReading(doc, name), "%s keeps its body", name) + } +} + +// TestOneOf_CoDeclaredInlineBranchIsConjoinedByItsNode pins that a union +// beside a model body distributes across an inline branch as across a $ref one +// (docs/compilers/openapi.md#union-beside-structure): the branch lowers at its +// own pointer, which an outside $ref to it finds too, and each variant composes +// the body with that node, carried as written, a branch admitting no object and +// one redeclaring a property of the body among them. A bare null branch still +// declines. +func TestOneOf_CoDeclaredInlineBranchIsConjoinedByItsNode(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.ComponentSpecVer("3.1.0", ` Shape: + type: object + properties: {id: {type: string}, kind: {type: string}} + oneOf: + - {type: object, properties: {radius: {type: number}}} + - {properties: {kind: {type: string, enum: [square]}, side: {type: number}}, required: [side]} + - {type: string} + Outside: {$ref: '#/components/schemas/Shape/oneOf/0'} + WithNull: + type: object + properties: {id: {type: string}} + oneOf: [{type: object, properties: {radius: {type: number}}}, {type: 'null'}] +`)) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, validated(t, doc), "the document must validate") + + shape, ok := typeByName(doc, "Shape").(*ir.Union) + require.True(t, ok, "the union is the value") + require.Len(t, shape.Variants, 3) + branches := []ir.TypeID{ + "t/anon/components/schemas/Shape/oneOf/0", "t/anon/components/schemas/Shape/oneOf/1", "t/prim/string", + } + for i, branch := range branches { + v, ok := doc.Types[shape.Variants[i].Type.Target].(*ir.Model) + require.True(t, ok, "variant %d is a composed model", i) + require.Len(t, v.Properties, 2, "the body rides on variant %d", i) + assert.Equal(t, ir.PropID("p/openapi/components/schemas/Shape/properties/kind"), v.Properties[1].ID, + "under the one identity the body declares it with") + require.NotNil(t, v.Base) + assert.Equal(t, branch, v.Base.Target, "variant %d conjoins its branch", i) + } + redeclaring, ok := doc.Types[branches[1]].(*ir.Model) + require.True(t, ok, "the branch redeclaring kind is a model of its own") + assert.Equal(t, "kind", redeclaring.Properties[0].WireName) + + outside, ok := typeByName(doc, "Outside").(*ir.Scalar) + require.True(t, ok) + assert.Equal(t, branches[0], outside.Base.Target, "an outside $ref finds the branch node, not the variant") + + assert.Equal(t, string(ir.ReasonDegradedLowering), unionReading(doc, "WithNull")) + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/WithNull"), + "a branch is a bare null") +} + +// TestOneOf_CoDeclaredBareRequiredConjoinsObjectBranches pins a body writing +// only `required` beside branches that admit only an object or null: each +// variant requires the names of its branch, which keeps its null, and a branch +// that may be no object, a keyword beside `required` or a branch read past its +// bounds leaves the body nothing to compose +// (docs/compilers/openapi.md#union-beside-structure). +func TestOneOf_CoDeclaredBareRequiredConjoinsObjectBranches(t *testing.T) { + t.Parallel() + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpecVer("3.1.0", ` A: {type: object, properties: {a: {type: string}}} + Required: {required: [a, b], oneOf: [{$ref: '#/components/schemas/A'}]} + Nullable: + required: [a] + anyOf: [{$ref: '#/components/schemas/A'}, {type: [object, 'null'], properties: {c: {type: string}}}] + BesideString: {required: [a], oneOf: [{$ref: '#/components/schemas/A'}, {type: string}]} + BesideItems: {required: [a], items: {type: string}, oneOf: [{$ref: '#/components/schemas/A'}]} + CutShort: {required: [a], oneOf: [{$ref: '#/components/schemas/A'}, {type: object, allOf: [{$ref: '#/components/schemas/D0'}]}]} +`+deepChain(300))) + openapitest.RequireNoErrorDiags(t, diags) + + required, ok := typeByName(doc, "Required").(*ir.Union) + require.True(t, ok) + require.Len(t, required.Variants, 1) + v, ok := doc.Types[required.Variants[0].Type.Target].(*ir.Model) + require.True(t, ok) + assert.Equal(t, []string{"a", "b"}, v.Requires) + require.NotNil(t, v.Base) + assert.Equal(t, componentID("A"), v.Base.Target) + + nullable, ok := typeByName(doc, "Nullable").(*ir.Union) + require.True(t, ok) + require.Len(t, nullable.Variants, 2) + assert.False(t, nullable.Variants[0].Type.Nullable) + assert.True(t, nullable.Variants[1].Type.Nullable, "a required list leaves the branch's null alone") + + for _, name := range []string{"BesideString", "BesideItems", "CutShort"} { + assert.Equal(t, string(ir.ReasonDegradedLowering), unionReading(doc, name), name) + assert.True(t, slices.ContainsFunc(diags, func(d ir.Diagnostic) bool { + return d.Provenance.Pointer == jsontext.Pointer("/components/schemas/"+name) && + strings.Contains(d.Message, "the body is not a model") + }), "%s: %+v", name, diags) + } +} + +// deepChain declares D0 to Dn, each an allOf naming the next and Dn an object, +// a chain whose read runs past what one reading of a schema may read. +func deepChain(n int) string { + var b strings.Builder + for i := range n { + fmt.Fprintf(&b, " D%d: {allOf: [{$ref: '#/components/schemas/D%d'}]}\n", i, i+1) + } + fmt.Fprintf(&b, " D%d: {type: object}\n", n) + return b.String() +} + +// TestAllOf_AUnionBranchDistributesAsBesideTheAllOf pins a oneOf or anyOf +// written as an inline allOf branch, which distributes as the same union beside +// the allOf does (docs/compilers/openapi.md#union-beside-structure): the +// variants match the beside spelling's, inline branches conjoined by their own +// nodes, the branch keeps nothing, another branch folds onto the union, and an +// unexpanded $dynamicRef stays beside it. One declaring no shape, one of two +// that do, one beside the schema's own union or discriminator, one distribution +// declines, one past the flattening depth and one beside a branch's $ref, which +// it conjoins with, stay with their branch; an enum beside the allOf decides. +func TestAllOf_AUnionBranchDistributesAsBesideTheAllOf(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.ComponentSpecVer("3.1.0", ` A: {type: object, properties: {a: {type: string}}} + B: {type: object, properties: {b: {type: string}}} + Branch: + allOf: + - {type: object, anyOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}]} + - {type: object, required: [id], properties: {id: {type: string}}} + Beside: + type: object + required: [id] + properties: {id: {type: string}} + anyOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] + Inline: + allOf: + - oneOf: [{type: object, properties: {x: {type: string}}}, {type: object, properties: {y: {type: string}}}] + - {properties: {enabled: {type: boolean}}} + WithShapeless: + allOf: + - oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] + - anyOf: [{required: [a]}, {required: [b]}] + - {type: object, properties: {c: {type: string}}} + Outside: {$ref: '#/components/schemas/Inline/allOf/0/oneOf/1'} + Shapeless: + allOf: + - anyOf: [{required: [p]}, {required: [q]}] + - {type: object, properties: {p: {type: string}, q: {type: string}}} + Two: + allOf: + - oneOf: [{$ref: '#/components/schemas/A'}] + - anyOf: [{$ref: '#/components/schemas/B'}] + - {type: object, properties: {c: {type: string}}} + BesideToo: + allOf: + - oneOf: [{$ref: '#/components/schemas/A'}] + - {type: object, properties: {c: {type: string}}} + anyOf: [{$ref: '#/components/schemas/B'}] + NullBranch: + allOf: + - oneOf: [{$ref: '#/components/schemas/A'}, {type: 'null'}] + - {type: object, properties: {c: {type: string}}} + Annotated: + allOf: + - oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] + - {description: mine, properties: {c: {type: string}}} + AnnotatedBeside: + allOf: + - {description: mine, properties: {c: {type: string}}} + oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] + Node: {$dynamicAnchor: node, type: object, properties: {n: {type: string}}} + DynamicRef: + $dynamicRef: '#node' + allOf: + - oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] + - {type: object, properties: {c: {type: string}}} + RefBranch: + allOf: + - {$ref: '#/components/schemas/A', oneOf: [{$ref: '#/components/schemas/B'}]} + - {type: object, properties: {c: {type: string}}} + DiscriminatedBranch: + allOf: + - oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] + discriminator: {propertyName: a} + - {type: object, properties: {c: {type: string}}} + OwnDiscriminator: + discriminator: {propertyName: c} + allOf: + - oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] + - {type: object, properties: {c: {type: string}}} + EnumBeside: + type: string + enum: [x, y] + allOf: [{oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}]}] + Capped: + allOf: + - `+strings.Repeat("{allOf: [", schema.MaxAllOfFlattenDepth)+ + `{allOf: [{type: object}], oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}]}`+ + strings.Repeat("]}", schema.MaxAllOfFlattenDepth)+` + - {type: object, properties: {c: {type: string}}} +`)) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, validated(t, doc), "the document must validate") + + branch, beside := variantModels(t, doc, "Branch"), variantModels(t, doc, "Beside") + require.Len(t, branch, 2) + for i := range branch { + require.NotNil(t, branch[i].Base) + assert.Equal(t, beside[i].Base, branch[i].Base, "variant %d conjoins the branch the beside spelling's does", i) + require.Len(t, branch[i].Properties, 1) + assert.Equal(t, "id", branch[i].Properties[0].WireName) + assert.True(t, branch[i].Properties[0].Required) + assert.Empty(t, branch[i].Unmodeled, "the branch keeps nothing: its union is the variants' to give") + } + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.CompositionLowering, ir.SeverityInfo, "/components/schemas/Branch"), + "written on an allOf branch") + + inline := variantModels(t, doc, "Inline") + require.Len(t, inline, 2) + for i, v := range inline { + require.NotNil(t, v.Base) + assert.Equal(t, ir.TypeID(fmt.Sprintf("t/anon/components/schemas/Inline/allOf/0/oneOf/%d", i)), v.Base.Target) + } + outside, ok := typeByName(doc, "Outside").(*ir.Scalar) + require.True(t, ok) + assert.Equal(t, inline[1].Base.Target, outside.Base.Target, "an outside $ref finds the branch node") + + for _, v := range variantModels(t, doc, "WithShapeless") { + assert.Contains(t, v.Unmodeled, "openapi:allOf/1", "the union declaring no shape stays with its branch") + assert.NotContains(t, v.Unmodeled, "openapi:allOf/0") + } + + capped := "openapi:" + strings.TrimPrefix(strings.Repeat("/allOf/0", schema.MaxAllOfFlattenDepth+1), "/") + for name, kept := range map[string][]string{ + "Shapeless": {"openapi:allOf/0"}, + "Two": {"openapi:allOf/0", "openapi:allOf/1"}, + "NullBranch": {"openapi:allOf/0"}, + "RefBranch": nil, + "DiscriminatedBranch": {"openapi:allOf/0"}, + "OwnDiscriminator": {"openapi:allOf/0"}, + "Capped": {capped}, + } { + m, ok := typeByName(doc, name).(*ir.Model) + require.True(t, ok, "%s stays a model; got %T", name, typeByName(doc, name)) + for _, key := range kept { + assert.Contains(t, m.Unmodeled, key, "%s keeps its union with its branch", name) + } + } + enum, ok := typeByName(doc, "EnumBeside").(*ir.Enum) + require.True(t, ok, "the enum beside the allOf decides it; got %T", typeByName(doc, "EnumBeside")) + assert.Contains(t, enum.Unmodeled, "openapi:allOf") + annotated, ok := typeByName(doc, "Annotated").(*ir.Union) + require.True(t, ok) + assert.Equal(t, "mine", annotated.Docs.Description, "another branch folds onto the union, as beside the allOf") + besideDocs := typeByName(doc, "AnnotatedBeside").Common().Docs + assert.Equal(t, besideDocs, annotated.Docs) + for _, v := range variantModels(t, doc, "Annotated") { + assert.Empty(t, v.Unmodeled, "a branch folding whole is kept on no variant") + } + dynamic, ok := typeByName(doc, "DynamicRef").(*ir.Union) + require.True(t, ok, "beside a $dynamicRef it keeps unexpanded, the branch union distributes as beside the allOf") + assert.Contains(t, dynamic.Unmodeled, "openapi:$dynamicRef") + for _, v := range variantModels(t, doc, "BesideToo") { + assert.Contains(t, v.Unmodeled, "openapi:allOf/0", "beside its own union, a branch's stays with the branch") + } +} + +// TestUnion_ASubtypeADiscriminatorRoutesToStaysASubtype pins a union beside or +// on an allOf branch of a schema a discriminated ancestor routes to, by a +// mapping key or its name as a component +// (docs/compilers/openapi.md#union-beside-structure). Distributed, the schema +// would be a union, no subtype of the ancestor, so it stays a tagged model +// under it, its union kept verbatim, and the document validates, a subtype of +// it included. An inline schema no route names distributes beside the allOf, +// its variants untagged, and stays whole on its branch; a mapping key routes to +// one, "" among them. +func TestUnion_ASubtypeADiscriminatorRoutesToStaysASubtype(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.ComponentSpecVer("3.1.0", ` Pet: + type: object + required: [kind] + properties: {kind: {type: string}} + discriminator: + propertyName: kind + mapping: + dog: '#/components/schemas/Dog' + bird: '#/components/schemas/Bird' + '': '#/components/schemas/Holder/properties/hamster' + Small: {type: object, properties: {size: {type: integer}}} + Big: {type: object, properties: {weight: {type: integer}}} + Dog: + allOf: [{$ref: '#/components/schemas/Pet'}] + oneOf: [{$ref: '#/components/schemas/Small'}, {$ref: '#/components/schemas/Big'}] + Cat: + allOf: [{$ref: '#/components/schemas/Pet'}] + oneOf: [{properties: {meow: {type: string}}}, {properties: {purr: {type: string}}}] + Bird: + allOf: + - $ref: '#/components/schemas/Pet' + - oneOf: [{$ref: '#/components/schemas/Small'}, {$ref: '#/components/schemas/Big'}] + Fish: + allOf: + - $ref: '#/components/schemas/Pet' + - anyOf: [{properties: {fins: {type: integer}}}, {$ref: '#/components/schemas/Big'}] + Kennel: + allOf: [{$ref: '#/components/schemas/Dog'}, {properties: {name: {type: string}}}] + Holder: + type: object + properties: + beside: + allOf: [{$ref: '#/components/schemas/Pet'}] + oneOf: [{$ref: '#/components/schemas/Small'}, {$ref: '#/components/schemas/Big'}] + branch: + allOf: + - $ref: '#/components/schemas/Pet' + - oneOf: [{$ref: '#/components/schemas/Small'}, {$ref: '#/components/schemas/Big'}] + hamster: + allOf: [{$ref: '#/components/schemas/Pet'}] + oneOf: [{$ref: '#/components/schemas/Small'}, {$ref: '#/components/schemas/Big'}] +`)) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, validated(t, doc), "every route names a subtype, so the document validates") + + for name, tc := range map[string]struct{ tag, kept string }{ + "Dog": {"dog", "openapi:oneOf"}, "Cat": {"Cat", "openapi:oneOf"}, + "Bird": {"bird", "openapi:allOf/1"}, "Fish": {"Fish", "openapi:allOf/1"}, + } { + m, ok := typeByName(doc, name).(*ir.Model) + require.True(t, ok, "%s stays a model; got %T", name, typeByName(doc, name)) + require.NotNil(t, m.Base, name) + assert.Equal(t, componentID("Pet"), m.Base.Target, "%s is a subtype of Pet", name) + assert.Equal(t, tc.tag, m.DiscriminatorValue, name) + assert.Equal(t, ir.ReasonDegradedLowering, m.Unmodeled[tc.kept].Reason, "%s keeps its union", name) + } + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/Dog"), + "a discriminator on an ancestor routes to this schema as its subtype") + assert.True(t, ir.IsSubtype(doc, componentID("Kennel"), componentID("Pet")), "through Dog") + + beside, ok := doc.Types["t/anon/components/schemas/Holder/properties/beside"].(*ir.Union) + require.True(t, ok, "an inline schema no route names distributes beside the allOf") + for _, v := range beside.Variants { + m, ok := doc.Types[v.Type.Target].(*ir.Model) + require.True(t, ok) + assert.Empty(t, m.DiscriminatorValue, "and no variant is tagged") + } + _, ok = doc.Types["t/anon/components/schemas/Holder/properties/branch"].(*ir.Model) + assert.True(t, ok, "and stays a model on its branch") + hamster, ok := doc.Types["t/anon/components/schemas/Holder/properties/hamster"].(*ir.Model) + require.True(t, ok, "one a mapping key routes to stays a model, the key \"\" among them") + assert.Contains(t, hamster.Unmodeled, "openapi:oneOf") +} + +// TestOneOf_CoDeclaredTypeReadsNullableByItsDialect pins the null a `type` +// beside a union is compared with (docs/compilers/openapi.md#union-beside-structure): +// a `nullable: true` beside it adds null where the dialect defines the keyword, +// so in 3.0 the type says a branch admitting null and the union is the schema, +// and in 3.1 it adds nothing, so the body is kept beside the union. +func TestOneOf_CoDeclaredTypeReadsNullableByItsDialect(t *testing.T) { + t.Parallel() + for version, tc := range map[string]struct { + nullString string + alone bool + }{ + "3.0.3": {"{type: string, nullable: true}", true}, + "3.1.0": {"{type: [string, 'null']}", false}, + } { + doc, diags := schema.LowerSpec(t, openapitest.ComponentSpecVer(version, ` V4: {type: string, format: ipv4} + N: `+tc.nullString+` + S: {type: string, nullable: true, oneOf: [{$ref: '#/components/schemas/V4'}, {$ref: '#/components/schemas/N'}]} +`)) + openapitest.RequireNoErrorDiags(t, diags) + _, alone := typeByName(doc, "S").(*ir.Union) + assert.Equal(t, tc.alone, alone, "%s: S lowers to the union alone", version) + } +} + +// variantModels returns the composed models the component name's Union +// distributes its body across, in variant order. +func variantModels(t *testing.T, doc *ir.Document, name string) []*ir.Model { + t.Helper() + u, ok := typeByName(doc, name).(*ir.Union) + require.True(t, ok, "%s is a union; got %T", name, typeByName(doc, name)) + out := make([]*ir.Model, 0, len(u.Variants)) + for _, v := range u.Variants { + m, ok := doc.Types[v.Type.Target].(*ir.Model) + require.True(t, ok, "%s variant %s is a composed model", name, v.Type.Target) + out = append(out, m) + } + return out +} diff --git a/compilers/openapi/internal/schema/union_internal_test.go b/compilers/openapi/internal/schema/union_internal_test.go index f293ec510..f8e0c32c2 100644 --- a/compilers/openapi/internal/schema/union_internal_test.go +++ b/compilers/openapi/internal/schema/union_internal_test.go @@ -4,11 +4,14 @@ package schema import ( + "strings" "testing" "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" soa "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/ir" @@ -28,6 +31,20 @@ func TestAllNullUnion_NoBranchesFalse(t *testing.T) { assert.False(t, allNullUnion(&oas3.Schema{}), "no oneOf/anyOf branches admits nothing to be null-only") } +// TestBranchesSay_NoBranchesSayNothing covers the guard's own contract rather +// than its callers', which ask only of a schema writing a union: over no +// branches, "every branch says it" holds of each keyword the schema writes, +// which a reader consuming what the branches say would then drop. +func TestBranchesSay_NoBranchesSayNothing(t *testing.T) { + t.Parallel() + js := &oas3.JSONSchema[oas3.Referenceable]{} + valErrs, err := marshaller.Unmarshal(t.Context(), strings.NewReader("{type: string, format: uuid, enum: [a]}"), js) + require.NoError(t, err) + require.Empty(t, valErrs, "the fixture parses cleanly") + l := NewRawLowerer(&soa.OpenAPI{}) + assert.Empty(t, branchesSay(l.Ctx, js.GetSchema()), "no branch says a keyword the schema writes") +} + func TestPreserveUnionSiblings_MissingNode(t *testing.T) { t.Parallel() l := NewRawLowerer(&soa.OpenAPI{}) diff --git a/compilers/openapi/internal/schema/union_oneof_test.go b/compilers/openapi/internal/schema/union_oneof_test.go index 85819ef3f..6abf3614a 100644 --- a/compilers/openapi/internal/schema/union_oneof_test.go +++ b/compilers/openapi/internal/schema/union_oneof_test.go @@ -259,7 +259,7 @@ func TestOneOf_CoDeclaredVariantNotStolenByRefToBranch(t *testing.T) { // the whole compiled document: the same components in two declaration orders // compile to the same IR. Each case permutes a site the distribution shares // with something outside it: an outside $ref aimed at either union branch, and -// the discriminated base the variants take their tag from. +// the discriminated base whose route keeps the schema its subtype. // // These cases permute only components that emit no diagnostics, since // diagnostics are appended in traversal order and so differ in order even when @@ -289,35 +289,12 @@ func TestOneOf_CoDeclaredDistributionIsOrderIndependent(t *testing.T) { } } -// TestOneOf_CoDeclaredVariantCarriesDiscriminatorValue pins the tag the variants -// inherit. The enclosing schema is an allOf subtype of a discriminated base, so -// every variant is written on the wire with that subtype's tag — and the tag -// comes from the base's mapping entry for the *enclosing* schema, since that is -// the node the mapping names. -func TestOneOf_CoDeclaredVariantCarriesDiscriminatorValue(t *testing.T) { - t.Parallel() - doc, diags := schema.LowerSpec(t, comboDiscriminatedSpec(true)) - openapitest.RequireNoErrorDiags(t, diags) - - u, ok := typeByName(doc, "Combo").(*ir.Union) - require.True(t, ok) - require.Len(t, u.Variants, 2) - for i := range u.Variants { - v, ok := doc.Types[u.Variants[i].Type.Target].(*ir.Model) - require.True(t, ok) - assert.Equal(t, "combo", v.DiscriminatorValue, - "variant %d carries the mapping key naming the enclosing schema, not its own hint", i) - } -} - // TestOneOf_InlineCoDeclaredDistributionHasNoImplicitDiscriminatorValue extends -// GitHub #517 to buildComposedVariant, the union-branch caller of -// subtypeDiscriminatorValue: the enclosing schema of this co-declared oneOf is -// hoisted inline (a property, not a component), so it has no schema name and -// its distributed variants get no implicit tag either — the same rule the allOf -// case pins, at the other of the two call sites the fix touches. Before the fix -// both variants took the property key "Combo" as their tag, a value the document -// never assigned and one a component named Combo would also answer to. +// GitHub #517 to a union beside an allOf subtype: the enclosing schema is +// hoisted inline (a property, not a component), so it has no name a route could +// answer to, and it distributes (routedSubtype) with no variant tagged. The +// property key "Combo" as a tag is a value the document never assigned, and one +// a component named Combo would also answer to. func TestOneOf_InlineCoDeclaredDistributionHasNoImplicitDiscriminatorValue(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(` Pet: @@ -411,13 +388,13 @@ func TestOneOf_CoDeclaredNonModelBranchIsCarriedAsWritten(t *testing.T) { // TestOneOf_CoDeclaredUnresolvableBranchIsNotDistributed covers the branches // that are $ref-shaped but name nothing this compilation can resolve. Each // would otherwise half-distribute — the variant carrying the body while its -// branch vanished — so all of them keep the verbatim lowering, which loses -// nothing, and each still reports the broken reference. +// branch vanished — so all of them keep the verbatim lowering, and each still +// reports the broken reference, an inline branch beside it reporting none. On +// an allOf branch, such a union stays with its branch. // // It drives the lowering alone (lowerSpecOnly), so the report checked is the // lowering's own, at the branch pointer, with the reason the bindings give. A -// full compile -// keeps the load phase's report of the same $ref instead; see +// full compile keeps the load phase's report of the same $ref instead; see // TestCompile_AResolutionFailureIsReportedOnceAtItsRef. func TestOneOf_CoDeclaredUnresolvableBranchIsNotDistributed(t *testing.T) { t.Parallel() @@ -438,14 +415,24 @@ func TestOneOf_CoDeclaredUnresolvableBranchIsNotDistributed(t *testing.T) { type: object properties: {kind: {type: string}} oneOf: [{$ref: '#/components/schemas/A/properties/missing'}] + BesideInline: + type: object + properties: {kind: {type: string}} + oneOf: [{$ref: '#/components/schemas/Ghost'}, {type: object, properties: {b: {type: string}}}] + OnABranch: + allOf: + - oneOf: [{$ref: '#/components/schemas/Ghost'}, {$ref: '#/components/schemas/A'}] + - {type: object, properties: {kind: {type: string}}} `) doc, diags := lowerSpecOnly(t, spec) + ghost := `"#/components/schemas/Ghost" is unresolved: it names nothing in the document it points into` for name, why := range map[string]string{ - "Undeclared": `"#/components/schemas/Ghost" is unresolved: it names nothing in the document it points into`, + "Undeclared": ghost, "CrossDocument": `"other.yaml#/components/schemas/A" is unresolved: it names another document, and external references are not read`, "EmptyRef": `"" is unresolved: it names a position that holds no schema`, "NoSuchPointer": `"#/components/schemas/A/properties/missing" is unresolved: it names nothing in the document it points into`, + "BesideInline": ghost, } { m, ok := typeByName(doc, name).(*ir.Model) require.True(t, ok, "%s keeps its structural body rather than becoming a union", name) @@ -460,15 +447,22 @@ func TestOneOf_CoDeclaredUnresolvableBranchIsNotDistributed(t *testing.T) { openapitest.DiagMessageAt(t, diags, diag.UnresolvedRef, ir.SeverityError, "/components/schemas/"+name+"/oneOf/0"), name) } + beside := []jsontext.Pointer{"/components/schemas/Undeclared/oneOf/1", "/components/schemas/BesideInline/oneOf/1"} for _, d := range diags { - assert.NotEqual(t, jsontext.Pointer("/components/schemas/Undeclared/oneOf/1"), d.Provenance.Pointer, - "the branch beside the broken one resolves, so nothing is reported against it") + assert.NotContains(t, beside, d.Provenance.Pointer, + "the branch beside the broken one is no broken reference, so nothing is reported against it") } + + onABranch, ok := typeByName(doc, "OnABranch").(*ir.Model) + require.True(t, ok, "a branch's union naming nothing distributes nowhere; got %T", typeByName(doc, "OnABranch")) + assert.Contains(t, onABranch.Unmodeled, "openapi:allOf/0", "the union stays whole with its branch") } -// TestOneOf_CoDeclaredNotDistributedReasons pins the five shapes distribution +// TestOneOf_CoDeclaredNotDistributedReasons pins the shapes distribution // declines, each kept verbatim under ReasonDegradedLowering with its own reason -// in the diagnostic (docs/compilers/openapi.md#union-beside-structure). +// in the diagnostic (docs/compilers/openapi.md#union-beside-structure), but two: +// TestOneOf_CoDeclaredUnresolvableBranchIsNotDistributed and +// TestUnion_ASubtypeADiscriminatorRoutesToStaysASubtype pin those. // Half-distributing any of them would leave a Union whose variants disagree // about whether they carry the body. The messages are asserted, not just the // code: the reasons are the only thing telling the otherwise identical @@ -479,10 +473,6 @@ func TestOneOf_CoDeclaredNotDistributedReasons(t *testing.T) { NotAModel: const: fixed oneOf: [{$ref: '#/components/schemas/A'}] - InlineBranch: - type: object - properties: {kind: {type: string}} - oneOf: [{$ref: '#/components/schemas/A'}, {type: string}] NullBranch: properties: {kind: {type: string}} oneOf: [{$ref: '#/components/schemas/A'}, {type: 'null'}] @@ -502,8 +492,7 @@ func TestOneOf_CoDeclaredNotDistributedReasons(t *testing.T) { for name, reason := range map[string]string{ "NotAModel": "the body is not a model, so it carries no composition to distribute into", - "InlineBranch": "a branch is written inline, so it names no referent to conjoin the body with", - "NullBranch": "a branch is written inline, so it names no referent to conjoin the body with", + "NullBranch": "a branch is a bare null, which the union lifts onto the position, so no variant can conjoin the body with it", "BothCombinators": "oneOf and anyOf are both declared, so distributing either would drop the other", "Discriminated": "a declared discriminator binds the branches by name, which distributing them would break", } { @@ -519,15 +508,15 @@ func TestOneOf_CoDeclaredNotDistributedReasons(t *testing.T) { assert.True(t, ok, "the second combinator is kept too, which is why neither is distributed") nullBranch := typeByName(doc, "NullBranch").(*ir.Model) assert.Contains(t, string(nullBranch.Unmodeled["openapi:oneOf"].Value), `"null"`, - "a null branch is written inline, so it blocks distribution rather than lifting to Nullable") - // The sixth is settling's, once the registry is whole: Discriminated's + "a bare null branch blocks distribution rather than lifting to Nullable") + // One more is settling's, once the registry is whole: Discriminated's // discriminator routes to no subtype of its model, so it stays beside the // union it was written for. assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/Discriminated/discriminator"), "not a subtype of the model holding it") - assert.Equal(t, 6, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), + assert.Equal(t, 5, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), "each declined shape is reported once, and the kept discriminator once; got %+v", diags) } @@ -982,7 +971,7 @@ func TestOneOf_CoDeclaredRestatingBranchDeclaresNoShape(t *testing.T) { } // TestOneOf_CoDeclaredBranchSayingMoreDeclaresShape holds a branch that says -// something of shape the body does not to the degraded lowering, and pins each +// something of shape the body does not to distribution, and pins each // way of doing so a looser comparison would read as a restatement: a type, a // value set, a format, an element or a referent the body's declarations do not // hold, a property none of them declares, or a keyword declaring shape, such as @@ -1182,20 +1171,17 @@ func TestOneOf_CoDeclaredBranchSayingMoreDeclaresShape(t *testing.T) { doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) - entry, ok := typeByName(doc, "S").Common().Unmodeled[tc.keyword] - require.True(t, ok, "the union is kept verbatim") - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/S"), - "a branch is written inline") + assert.Equal(t, readDistributed, unionReading(doc, "S")) + assert.False(t, openapitest.HasDiagCodeAt(diags, diag.ValidationOnlyKeyword, "/components/schemas/S"), + "%+v", diags) }) } } // TestOneOf_CoDeclaredRestatementReadsReferencesAgainstTheirBase holds a // property written alike to a declaration the body composes from under another -// $id to the degraded lowering: `node.json` there names another schema, so S's -// branch says what Spelled's spells out. +// $id to distribution: `node.json` there names another schema, so S's branch +// says what Spelled's spells out. func TestOneOf_CoDeclaredRestatementReadsReferencesAgainstTheirBase(t *testing.T) { t.Parallel() doc, diags := schema.LowerSpec(t, openapitest.ComponentSpecVer("3.1.0", ` A: {$id: 'https://ex.test/a/node.json', type: string} @@ -1214,17 +1200,20 @@ func TestOneOf_CoDeclaredRestatementReadsReferencesAgainstTheirBase(t *testing.T `)) openapitest.RequireNoErrorDiags(t, diags) for _, name := range []string{"S", "Spelled"} { - entry, ok := typeByName(doc, name).Common().Unmodeled["openapi:oneOf"] - require.True(t, ok, "%s: the union is kept verbatim", name) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason, name) + assert.Equal(t, readDistributed, unionReading(doc, name), name) } } // TestOneOf_CoDeclaredBranchSayingMoreBesideANonModel holds a branch that -// writes a keyword declaring shape the body does not hold to the degraded -// lowering, where the body is no model to distribute into. +// writes a keyword declaring shape the body does not hold, where the body is no +// model to distribute into, to the union alone where every branch says the +// body's `type` and to the degraded lowering where a bare branch does not. func TestOneOf_CoDeclaredBranchSayingMoreBesideANonModel(t *testing.T) { t.Parallel() + alone := map[string]bool{ + "ItemsBesideTheType": true, "FormatBesideTheType": true, "ItemsBesideANot": true, + "FormatBesideANot": true, "ConstBesideANot": true, + } for name, schemaText := range map[string]string{ "ItemsBesideTheType": ` type: array oneOf: @@ -1263,9 +1252,11 @@ func TestOneOf_CoDeclaredBranchSayingMoreBesideANonModel(t *testing.T) { doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) - entry, ok := typeByName(doc, "S").Common().Unmodeled["openapi:oneOf"] - require.True(t, ok, "the union is kept verbatim") - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + if alone[name] { + assert.Equal(t, readAlone, unionReading(doc, "S")) + return + } + assert.Equal(t, string(ir.ReasonDegradedLowering), unionReading(doc, "S")) assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/S"), "the body is not a model") @@ -1296,7 +1287,7 @@ func TestOneOf_CoDeclaredBranchKeywordIsJudgedAloneAsBesideTheType(t *testing.T) {keyword: "x-note", write: "x-note: n", schemas: objectSchema}, {keyword: "unknownKeyword", write: "unknownKeyword: 1", schemas: objectSchema}, } - reasons := map[bool]ir.UnmodeledReason{false: ir.ReasonValidationOnly, true: ir.ReasonDegradedLowering} + readings := map[bool]string{false: string(ir.ReasonValidationOnly), true: readDistributed} keywords := slices.DeleteFunc(append(vocabularyCases(), cases...), func(tc vocabCase) bool { return tc.keyword == "type" // what a branch repeats the body with }) @@ -1304,16 +1295,14 @@ func TestOneOf_CoDeclaredBranchKeywordIsJudgedAloneAsBesideTheType(t *testing.T) t.Run(tc.keyword, func(t *testing.T) { t.Parallel() _, others, _ := strings.Cut(tc.schemas, "\n") - want := reasons[slices.Contains(declaresShape, tc.keyword)] + want := readings[slices.Contains(declaresShape, tc.keyword)] for _, branch := range []string{"{" + tc.write + "}", "{type: object, " + tc.write + "}"} { spec := openapitest.ComponentSpecVer("3.1.0", others+" S:\n"+ " type: object\n properties: {p: {type: string}}\n"+ " oneOf: ["+branch+", {required: [p]}]\n") doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) - entry, ok := typeByName(doc, "S").Common().Unmodeled["openapi:oneOf"] - require.True(t, ok, "%s: the union is kept verbatim", branch) - assert.Equal(t, want, entry.Reason, "%s", branch) + assert.Equal(t, want, unionReading(doc, "S"), "%s", branch) } }) } @@ -1348,63 +1337,64 @@ func TestOneOf_CoDeclaredUnionReadPastItsBudgetDeclaresShape(t *testing.T) { for i := range 18 { elsewhere += fmt.Sprintf("{properties: {z%d: {type: string}}}, ", i) } + validation := string(ir.ReasonValidationOnly) for name, tc := range map[string]struct { schema string - reason ir.UnmodeledReason + want string why string }{ "ConjunctsPastTheReadBudget": { " type: object\n properties: {a: {type: string}}\n" + conjuncts + " oneOf: [{type: object, required: [a]}]\n", - ir.ReasonDegradedLowering, "a branch is written inline", + readDistributed, "", }, "TypeAgainstAValueSetPastTheMemberBudget": { " type: string\n enum: [" + strings.Join(members, ", ") + "]\n" + " oneOf: [{type: integer}]\n", - ir.ReasonDegradedLowering, "the body is not a model", + string(ir.ReasonDegradedLowering), "the body is not a model", }, "PropertyNestingNothingReadsNoNestedDeclarations": { " type: object\n properties: {a: {type: string}}\n allOf: [" + elsewhere + strings.Repeat("{properties: {a: {type: string}}}, ", 61) + "{properties: {a: {type: string}}}]\n" + " oneOf: [{required: [a], properties: {a: {type: string, minLength: 1}}}]\n", - ir.ReasonValidationOnly, "", + validation, "", }, "TypeReadsNoDeclarations": { " type: object\n properties: {a: {type: string}}\n allOf: [" + strings.Repeat("{description: c}, ", 199) + "{description: c}]\n" + " oneOf: [{type: object, required: [a]}]\n", - ir.ReasonValidationOnly, "", + validation, "", }, "BareBranchLeavesTheBodyUnread": { " type: object\n properties: {a: {type: string}}\n" + conjuncts + " oneOf: [{required: [a]}]\n", - ir.ReasonValidationOnly, "", + validation, "", }, "BranchesRestatingOnePropertyReadItOnce": { " type: object\n properties: {a: {type: string}}\n" + " oneOf: [" + onePlace + "{required: [a]}]\n", - ir.ReasonValidationOnly, "", + validation, "", }, "BranchesRestatingOneNestedPropertyReadItOnce": { " type: object\n properties: {o: {type: object, properties: {b: {type: string}}}}\n" + " oneOf: [" + oneNestedPlace + "{required: [o]}]\n", - ir.ReasonValidationOnly, "", + validation, "", }, "BooleanPropertiesReadNoDeclarations": { " type: object\n properties: {" + declared + "a: {type: string}}\n" + " oneOf: [{properties: {" + booleans + "a: true}}, {required: [a]}]\n", - ir.ReasonValidationOnly, "", + validation, "", }, "ABranchValueSetWithinTheMemberBudget": { " type: object\n properties: {a: {enum: [m0]}}\n" + " oneOf: [{required: [a], properties: {a: {enum: [" + halfMembers + "]}}}]\n", - ir.ReasonValidationOnly, "", + validation, "", }, "BranchValueSetsPastTheMemberBudgetTogether": { " type: object\n properties: {a: {enum: [m0]}}\n" + " oneOf: [{required: [a], properties: {a: {enum: [" + halfMembers + "]}}}, " + "{properties: {a: {enum: [" + halfMembers + "]}}}]\n", - ir.ReasonDegradedLowering, "a branch is written inline", + readDistributed, "", }, } { t.Run(name, func(t *testing.T) { @@ -1412,9 +1402,7 @@ func TestOneOf_CoDeclaredUnionReadPastItsBudgetDeclaresShape(t *testing.T) { doc, diags := schema.LowerSpec(t, openapitest.ComponentSpecVer("3.1.0", " S:\n"+tc.schema)) openapitest.RequireNoErrorDiags(t, diags) - entry, ok := typeByName(doc, "S").Common().Unmodeled["openapi:oneOf"] - require.True(t, ok, "the union is kept verbatim") - assert.Equal(t, tc.reason, entry.Reason) + assert.Equal(t, tc.want, unionReading(doc, "S")) if tc.why != "" { assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/S"), @@ -1447,7 +1435,7 @@ func TestOneOf_CoDeclaredShapelessBranchesAreValidationLogicAtEachPosition(t *te "{required: [a], properties: {a: {type: string}}}, " + "{required: [b], properties: {b: {type: string, description: d}}}]}", diag.ValidationOnlyKeyword}, "Items": {"{" + body + "oneOf: [{required: [a], items: {type: string}}, {required: [b]}]}", - diag.DegradedConstruct}, + diag.CompositionLowering}, } for name, u := range unions { for _, version := range []string{"3.0.3", "3.1.0", "3.2.0"} { @@ -1525,10 +1513,10 @@ func comboRefStealSpec(branch int, outsiderFirst bool) string { return openapitest.ComponentSpec(combo + outsider) } -// comboDiscriminatedSpec is the co-declared union of a discriminated Base with -// A|B. Every variant is written on the wire with the enclosing schema's tag, -// which the base's mapping supplies, so the base and the schemas naming it are -// permutable against each other — components lower in source order. +// comboDiscriminatedSpec is the union A|B co-declared with an allOf subtype of +// a discriminated Base, whose mapping routes to it and so keeps it a subtype, +// and the base and the schemas naming it are permutable against each other — +// components lower in source order. func comboDiscriminatedSpec(baseFirst bool) string { base := ` Base: type: object diff --git a/compilers/openapi/internal/schema/union_test.go b/compilers/openapi/internal/schema/union_test.go index 324daedd4..d26100588 100644 --- a/compilers/openapi/internal/schema/union_test.go +++ b/compilers/openapi/internal/schema/union_test.go @@ -641,12 +641,16 @@ func TestLower_AllOfWithOneOfKeepsBoth(t *testing.T) { `) doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) - m, ok := doc.Types[componentID("Combo")].(*ir.Model) - require.True(t, ok, "allOf composition survives (Model), oneOf preserved raw") - require.NotNil(t, m.Base, "the allOf $ref becomes Base") - assert.Equal(t, componentID("Base"), m.Base.Target) - _, ok = m.Unmodeled["openapi:oneOf"] - assert.True(t, ok, "the oneOf is kept verbatim under Unmodeled") + u, ok := doc.Types[componentID("Combo")].(*ir.Union) + require.True(t, ok, "the oneOf is the value, the composition distributed across it") + require.Len(t, u.Variants, 2) + for i, prim := range []ir.TypeID{"t/prim/string", "t/prim/integer"} { + m, ok := doc.Types[u.Variants[i].Type.Target].(*ir.Model) + require.True(t, ok, "variant %d is a composed model", i) + require.NotNil(t, m.Base, "the allOf $ref becomes Base") + assert.Equal(t, componentID("Base"), m.Base.Target) + assert.Equal(t, []ir.TypeRef{{Target: prim}}, m.Mixins, "the branch is carried as written") + } } func TestLower_MultiTypeUnion(t *testing.T) { @@ -730,7 +734,10 @@ func TestLower_UnionWithStructuralSiblingVariants(t *testing.T) { `) doc, diags := schema.LowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) - for _, name := range []string{"A", "B", "C", "D"} { + // A's body is a model, so its union is distributed across it; the others' + // is none, so each keeps its union verbatim. + assert.Equal(t, readDistributed, unionReading(doc, "A")) + for _, name := range []string{"B", "C", "D"} { td := typeByName(doc, name) require.NotNil(t, td, "type %s present", name) c := td.Common() diff --git a/compilers/openapi/internal/schema/voices.go b/compilers/openapi/internal/schema/voices.go index a7e5dfe3c..0b21b5133 100644 --- a/compilers/openapi/internal/schema/voices.go +++ b/compilers/openapi/internal/schema/voices.go @@ -107,9 +107,9 @@ func allOfMayRefine(s *oas3.Schema) bool { // there, an enum or a const beside it, is one of its conjuncts. An allOf that // value set beats otherwise stays verbatim // (docs/compilers/openapi.md#competing-keywords). A union beside the composing -// allOf leaves it deciding, composed into each variant the union distributes -// across or into the Model keeping the union verbatim, and so does a $dynamicRef -// it keeps from expanding. +// allOf or on one of its branches leaves it deciding, composed into each variant +// the union distributes across or into the Model keeping the union verbatim, and +// so does a $dynamicRef it keeps from expanding. func allOfDecides(c lowering.Ctx, s *oas3.Schema) bool { if !allOfBesideNoRef(s) { return false diff --git a/docs/compilers/openapi.md b/docs/compilers/openapi.md index c517e0a25..c08525813 100644 --- a/docs/compilers/openapi.md +++ b/docs/compilers/openapi.md @@ -1297,7 +1297,7 @@ be, with nothing lost, where: 2. it writes more than 64 conjuncts, which the election below compares pairwise; 3. a conjunct is `false` ([`allof-false-branch`](#allof-false-branch)), or nests past the flattening depth ([`allof-classification`](#allof-classification)); -4. `oneOf` or `anyOf` is written on a branch or beside the `allOf` +4. `oneOf` or `anyOf` is written beside the `allOf` ([`union-beside-structure`](#union-beside-structure)); 5. two conjuncts are `$ref`s: both keep composing, since keeping one reference typed would mean dropping the other ([models](../ir/models.md#composition-entries)); @@ -1370,7 +1370,17 @@ more is kept: a branch whole under `openapi:allOf/` (`degraded_lowering`), a branch or at the position. A `$ref` a value set beats is kept the same way, and its target still speaks for the position ([`ref-use-site`](#ref-use-site)), its `default` only where the value set takes it, and still lowers, though nothing else names it, as a position no component holds lowers -for a `$ref`. +for a `$ref`. A `oneOf` or `anyOf` written on a branch is a conjunct admitting what any of its +branches admits, naming the kinds they name, so it may be the principal, lowering at its own pointer +as an inline branch does. The principal says the union where one of its branches says nothing the +principal does not, each keyword the branch writes compared as one written beside the `allOf` is, +and a `oneOf` only where that branch also shares no kind with another, so that exactly one admits +each value the position holds: +`{allOf: [{anyOf: [{type: string}, {type: number}]}, {type: string, maxLength: 1000}]}` lowers as +`{type: string, maxLength: 1000}` does, keeping nothing. A branch writing a bound, a `pattern` or an +annotation says more than the principal, so +`{allOf: [{anyOf: [{type: string, maxLength: 5}, {type: integer}]}, {type: string, maxLength: 1000}]}` +keeps the union with its branch. Over a target that admits no object, `{allOf: [{$ref: T}], K}` lowers as 3.1's `{$ref: T, K}` where `K` is annotations and value bounds. The two part on a structural keyword, which a `$ref` site keeps @@ -1416,10 +1426,10 @@ keeping such an `allOf` a decorator for emitters to collapse is weighed in `schema.TestRefinement_PastItsConjunctBoundStaysAComposition`, `schema.TestRefinement_ACappedBranchStaysAComposition`, `schema.TestRefinement_RawTextIsComparedAgainstOneBase`, + `schema.TestRefinement_AUnionBranchIsAConjunct`, `e2e.TestExternalContent_ARefinementReadsACopyInAnotherDocumentApart` -- Status: partly built: a `oneOf` or `anyOf` on a branch or beside the `allOf` stays a composition - (#661), and a structural keyword beside a 3.1 `$ref` keeps what the same keyword here consumes - (#1067). +- Status: partly built: a structural keyword beside a 3.1 `$ref` keeps what the same keyword here + consumes (#1067). ### `allof-beside-ref` @@ -1438,8 +1448,9 @@ hold ([`shape-applicator`](#shape-applicator)) keeps the `allOf` verbatim under A branch written inline rather than as a `$ref` owns no node, so composition has nowhere to point ([models](../ir/models.md#composition)) and the branch is merged into the composing model in place. That merge consumes the branch's `properties`, its `required` list, a `type` that is exactly -`object`, which the composed `Model` already restates, and, for an entry flattened under -[`allof-classification`](#allof-classification), its `allOf`. +`object`, which the composed `Model` already restates, for an entry flattened under +[`allof-classification`](#allof-classification), its `allOf`, and the `oneOf` or `anyOf` the +position distributes across its variants ([`union-beside-structure`](#union-beside-structure)). JSON Schema conjoins an `allOf`'s keywords with those beside it, so what a branch writes for the position reads as written beside the `allOf`, in a composition and in a refinement @@ -1459,9 +1470,10 @@ a `uniqueItems` of `false` or a count past 64 bits, never folds. A `oneOf`, an ` and changes nothing else here: where the `allOf` composes, its branches fold into the composition as they do with none of them there, beside a `$dynamicRef` it keeps from expanding ([`dynamic-ref`](#dynamic-ref)) or a union kept verbatim -([`union-beside-structure`](#union-beside-structure)). Where the union distributes, a branch's -annotations and what a use binds reach the `Union` and its carriers, and a value bound each variant, -as the same keyword beside the `allOf` does, and a branch with no residue is kept on no variant +([`union-beside-structure`](#union-beside-structure)). Where a union beside the `allOf` or on one of +its branches distributes, a branch's annotations and what a use binds reach the `Union` and its +carriers, and a value bound each variant, as the same keyword beside the `allOf` does, and a branch +with no residue is kept on no variant ([models](../ir/models.md#composition-beside-a-union)). Folding needs no precedence, because a branch that disagrees with any conjunct on one of these, the @@ -2193,25 +2205,50 @@ rule: the body is composed into each variant, and the result is an ordinary `Uni `Model`s, reported as an info `openapi/composition-lowering` at the schema, since nothing is lost but the IR's shape no longer mirrors the source's, bar one thing: a `false` in the `allOf` states that the composition admits nothing, which every composed variant then holds only as the closed -model [`allof-false-branch`](#allof-false-branch) gives it, with the branch kept on each. Five -shapes distribution cannot reach: +model [`allof-false-branch`](#allof-false-branch) gives it, with the branch kept on each. A +variant conjoins its branch by reference: a `$ref` branch's target, through the alias what is +written beside the `$ref` hoists, and an inline branch's own node, hoisted at its pointer as any +inline schema is ([`inline-hoisting`](#inline-hoisting)), which an outside `$ref` to that pointer +finds too. The referent is carried as written, a branch admitting no object among them +([models](../ir/models.md#composition-entries)). A bare `required` is a body too where every +branch admits only an object or null, the values it constrains or leaves alone, so each variant +requires those names of its branch. + +A `oneOf` or `anyOf` written as an inline branch of the schema's `allOf` conjoins with the rest of +the composition as one written beside the `allOf` does, so it distributes the same way, where the +schema writes no union and no `discriminator` of its own, its `allOf` composes rather than refines +([`allof-refinement`](#allof-refinement)), and the union is the one branch union whose branches +declare shape (below). Its variants take their IDs from the branch's pointers, its `$ref` and inline +branches conjoin as above, and the branch keeps nothing of the union, what else it writes merging as +any branch does ([`allof-inline-residue`](#allof-inline-residue)). A union on a branch that is one +of the shapes below, one of two branch unions declaring shape, distributing either of which would +drop the other, or one on a branch nested past the flattening depth stays whole with its branch, and +so does one whose branches declare no shape, on each variant where another union distributes. Below +a discriminated ancestor (shape 6) a union on a branch stays whole even at a position no route +names: which positions a route names is read from the position, and every reader of the schema +shares one reading of it, which has none. Distribution cannot reach these shapes: 1. the structural body is not a `Model` (a `const`, an `enum`, a scalar, or a bare - `required`/`items`/`prefixItems`/`additionalProperties`/`patternProperties` with neither - `properties` nor a `type`), so there are no composition fields for the variants to carry; -2. a branch is written inline rather than as a `$ref`, so it names no node for `Base`/`Mixins` to - point at — a `type: null` branch included; -3. a branch *is* a `$ref` but names no referent the compilation resolves — a cross-document target - it does not intern, an undeclared component, a pointer addressing no schema. The branch is - unusable for the same reason an inline one is, so it declines the same way rather than composing - a variant whose branch quietly went missing; + `items`/`prefixItems`/`additionalProperties`/`patternProperties`, or `required` beside a branch + that may be no object, with neither `properties` nor a `type`), so there are no composition + fields for the variants to carry; +2. a branch is a bare `type: null`, which a union lifts onto the position's `Nullable` rather than + conjoining it ([`union-null-variant`](#union-null-variant)), so no variant could say whether the + body admits the null; +3. a branch is a `$ref` that names no referent the compilation resolves — a cross-document target + it does not intern, an undeclared component, a pointer addressing no schema — so it declines + rather than composing a variant whose branch quietly went missing; 4. a `discriminator` is declared, binding the branch schemas by name; -5. `oneOf` and `anyOf` are both declared, so distributing one would drop the other. +5. `oneOf` and `anyOf` are both declared, so distributing one would drop the other; +6. a `discriminator` on an `allOf` ancestor routes to the schema, by a `mapping` key or by its name + as a component ([`discriminator-value`](#discriminator-value)): the route names what the schema + lowers to, which as a `Union` would be no subtype of that ancestor (`ir.IsSubtype`, what + `pass/validate` holds a mapping to), so it stays a `Model` under it, tagged. -In all five, whatever structural body the schema declares is lowered — `properties` or a declared +In each, whatever structural body the schema declares is lowered — `properties` or a declared `type` yields a `Model` or typed node — and the union branches are kept verbatim in `Unmodeled["openapi:oneOf"]` / `["openapi:anyOf"]` (`degraded_lowering`), reported as an info -`openapi/degraded-construct` at the schema naming which of the five applied; in case 3 the broken +`openapi/degraded-construct` at the schema naming which applied; in case 3 the broken reference is reported as well, as an `openapi/unresolved-ref` error. Case 1's body-less sibling leaves nothing to build: it reduces to `any`, and the sibling itself is kept by [`shape-applicator`](#shape-applicator) rather than dropped, so the union's diagnostic and the @@ -2221,7 +2258,22 @@ the lowered type does not say. The degradation is that the generated SDK is *und admits values the union would reject — which is why the branches must survive for a validating emitter to reach. -A `discriminator` beside any of the five whose body lowers to a `Model` stays on that `Model` only +A keyword beside the union that every branch already says adds nothing to it: a `type` naming each +kind a branch admits, null included, and a `format`, an `enum`, a `const` or an `items` each branch +is held to, read as [`allof-refinement`](#allof-refinement) reads what a conjunct admits and within +its bounds, a read cut short saying none. An `items` written as a list, a draft's tuple, is said by +no branch. Where the branches declare shape (below) and say every +keyword that makes a body, the schema means the union alone, and it lowers as if those keywords +were not written: `{type: string, anyOf: [{$ref: V4}, {$ref: V6}]}` as `{anyOf: [{$ref: V4}, +{$ref: V6}]}`, and `{type: object, oneOf: [{$ref: A}, {$ref: B}]}` as the union of `A` and `B`, not +a union of models composed over each. A `nullable` beside such a union reads as it reads beside +any union ([`nullable`](#nullable)), and a keyword the branches do not all say, a bound or a +`format` among them, is kept as the census keeps it beside a union +([`shape-applicator`](#shape-applicator)). Branches declaring no shape only narrow the body, which +stays validation logic beside it whatever they repeat, and a `discriminator` beside the union keeps +the body, whose routes may be its subtypes. + +A `discriminator` beside any of these shapes whose body lowers to a `Model` stays on that `Model` only when every type it routes to is a subtype of it at any depth (`ir.IsSubtype`, the rule `pass/validate` holds a model discriminator to): each `mapping` target, the `defaultMapping`, and each component a `$ref` branch names, which it routes to by name — `oneOf`'s branches where `oneOf` @@ -2237,10 +2289,10 @@ a body the move leaves holding a map and nothing else lowers to that map's `MapT decides once every node is interned (`compile.Settlement`), because neither the walk nor the source schemas can answer it: a subtype declared after the `Model` has no `Base` while the `Model` lowers, and a schema whose `allOf` names the `Model` may still lower to no subtype — an `enum`, a `const`, -an alias over a `$ref` beside it, or a union its `allOf` is distributed across. +or an alias over a `$ref` beside it. A union whose branches declare no shape at all (`oneOf: [{required: [a]}, {required: [b]}]`) is not -a degradation and is not one of the five: it narrows the body without reshaping it, which is +a degradation and is not one of these shapes: it narrows the body without reshaping it, which is validation logic, so it is kept under `validation_only` with the keyword family of [boundaries](../ir/boundaries.md#validation-only-constructs) and reported as an info `openapi/validation-only-keyword` at the schema ([`validation-only`](#validation-only)). A `$ref` @@ -2290,7 +2342,7 @@ the body's own `type`, so repeating the body never changes a verdict: `discriminator`, `readOnly`, `writeOnly`, `default`, `xml`, the content vocabulary, `$id`, `$schema`, `$vocabulary` and any keyword of no vocabulary. -A branch declaring shape falls to the five shapes above. The branches and the body are read within +A branch declaring shape falls to the shapes above. The branches and the body are read within the bounds of [`allof-refinement`](#allof-refinement), which a union's branches share, an `enum` a branch writes counting its members, and the body only as far as they need: a branch writing only keywords point 1 names reads none of it, the declarations of what a schema nests are read only @@ -2299,10 +2351,18 @@ however many branches restate it. A read that does not finish says nothing, so i as one whose branches declare shape. - Cases: `allof-oneof-cooccurrence`, `discriminated-union-beside-model`, `unhomed-keywords`, - `keywords-at-every-outcome`, `union-restated-branches` + `keywords-at-every-outcome`, `union-restated-branches`, `union-restated-type`, + `union-inline-branches`, `allof-union-branch` - Codes: `openapi/composition-lowering`, `openapi/degraded-construct`, `openapi/unresolved-ref`, `openapi/validation-only-keyword` - Tests: `schema.TestOneOf_CoDeclaredNotDistributedReasons`, + `schema.TestOneOf_CoDeclaredKeywordsEveryBranchSaysLeaveTheUnionAlone`, + `schema.TestOneOf_CoDeclaredTypeReadsNullableByItsDialect`, + `schema.TestOneOf_CoDeclaredInlineBranchIsConjoinedByItsNode`, + `schema.TestOneOf_CoDeclaredBareRequiredConjoinsObjectBranches`, + `schema.TestUnion_AListOfItemsBesideTheBranchesIsNotSaid`, + `schema.TestAllOf_AUnionBranchDistributesAsBesideTheAllOf`, + `schema.TestUnion_ASubtypeADiscriminatorRoutesToStaysASubtype`, `schema.TestOneOf_CoDeclaredUnresolvableBranchIsNotDistributed`, `schema.TestDiscriminatorBesideUnion_LeavesAMapWhenItMoves`, `schema.TestOneOf_CoDeclaredRestatingBranchDeclaresNoShape`, @@ -2312,11 +2372,12 @@ as one whose branches declare shape. `schema.TestOneOf_CoDeclaredUnionReadPastItsBudgetDeclaresShape`, `schema.TestOneOf_CoDeclaredRestatementReadsReferencesAgainstTheirBase`, `schema.TestOneOf_CoDeclaredShapelessBranchesAreValidationLogicAtEachPosition` -- Status: partly built: a multi-typed base's discriminator routes to no subtype (#853), and a union +- Status: partly built: a multi-typed base's discriminator routes to no subtype (#853), a union beside a composition the bounds cut short degrades even where each branch restates only what the - body itself writes (#1423). Open: keeping a discriminated `oneOf` a union when structural keywords - sit beside it (#594), and distributing a union's inline branches when properties sit beside it - (#661). + body itself writes (#1423), a subtype a discriminator routes to keeps its union verbatim (#1694), + and a union on a branch below a discriminated base stays whole where no route names the schema + (#1695). Open: keeping a discriminated `oneOf` a union when structural keywords sit beside it + (#594). ### `union-beside-ref` @@ -2415,8 +2476,10 @@ reference, and where several keys name it [`discriminator`](#discriminator) elec name as declared (`Cat/Dog`, not its pointer token) if it is a component schema, OpenAPI's implicit mapping. A subtype that is no component schema — inline as a property or a union branch, or under an extension — has no name to answer to, so only a mapping entry tags it, and a chain with no -discriminated ancestor carries no tag however deep. A variant of a distributed `oneOf`/`anyOf` -([`union-beside-structure`](#union-beside-structure)) carries the tag of the schema enclosing it. +discriminated ancestor carries no tag however deep. A subtype a discriminator routes to is never +distributed across a union written beside its `allOf` or on one of its branches +([`union-beside-structure`](#union-beside-structure)), since a union would be no subtype, so no +variant of a distributed union carries a tag. - Cases: `discriminator-transitive`, `discriminator-inline-subtype` - Tests: `schema.TestAllOf_DiscriminatorSubtypeValue`, @@ -4144,8 +4207,8 @@ where one reading could override what the other reports. content entry, kept under a scope, reaches no field (#1221); a key both a parameter or a header and its schema write shares one entry, so the object's is all that is read (#396); a schema a use names between it and the end of its chain lends no key, as it lends no keyword (#1029); a key inside a - `oneOf` kept verbatim beside structure waits for those branches to lower (#661); and a mapping - naming a target nothing reads is accepted in silence (#1281). + `oneOf` kept verbatim beside a discriminated body waits for those branches to lower (#594); and a + mapping naming a target nothing reads is accepted in silence (#1281). ### `mutually-exclusive-parameters` diff --git a/docs/ir/boundaries.md b/docs/ir/boundaries.md index b37ad06aa..38bad959b 100644 --- a/docs/ir/boundaries.md +++ b/docs/ir/boundaries.md @@ -117,9 +117,10 @@ simply absent from the IR and preserve nothing. artifact given IR-wide status against invariant 9. TypeSpec's `&` is *model-only* (`ir-spec-matrix.md`'s Intersection row records it as `⚠ & (model is)`) and so is already covered by `Base`/`Mixins` ([models](./models.md#composition)); Smithy, GraphQL, Protobuf and Erlang/OTP - cannot express it at all. The shape is instead handled where it arises: distributed across the - union's variants per [models](./models.md#composition-beside-a-union) when every branch names a - referent, and kept verbatim under `ReasonDegradedLowering` in the five residual shapes + cannot express it at all. The shape is instead handled where it arises: read as the union alone + where the structural keywords say nothing its branches do not, distributed across the union's + variants per [models](./models.md#composition-beside-a-union) when every branch names a + referent, and kept verbatim under `ReasonDegradedLowering` in the residual shapes [`union-beside-structure`](../compilers/openapi.md#union-beside-structure) lists. A second format that declares a genuine intersection — not a model-only `&` — is what would reopen this. diff --git a/docs/ir/models.md b/docs/ir/models.md index 47c7bc70c..56ebb1bbb 100644 --- a/docs/ir/models.md +++ b/docs/ir/models.md @@ -180,9 +180,10 @@ declares and the composition entries that name no model. Its rules: order: a `Positional` model's elements follow their `WireID`. - *Addressing a member.* A `PropID` naming a model's member, an encoding key in `Content.Encoding` ([payloads](./payloads.md)) or a discriminator's `Property`, names the declaration the flattening - of that model takes, the content's type for an encoding key, so a consumer finds it on the member - itself. The OpenAPI compiler keys a part through the flattening, and tags a discriminator by an - own property, which the flattening takes. `pass.Validate` holds each only to a property the model + of that model takes, the content's type for an encoding key, or the first variant declaring the + part where that type is a union, so a consumer finds it on the member itself. The OpenAPI compiler + keys a part through the flattening, and tags a discriminator by an own property, which the + flattening takes. `pass.Validate` holds each only to a property the model exposes, not yet to the one it takes, since a flattening per model it asks about grows quadratically down a deep hierarchy, which its cost tests refuse. - *Cycles and cost.* Each model and alias scalar is read once, so a cyclic composition is read to @@ -239,8 +240,8 @@ A `Model` says its value is an object, and `allOf: [{type: integer}, {type: inte admits none, so it is no composition. An `allOf` no conjunct can make an object lowers to one use of the conjunct that decides it, short of the cases the OpenAPI compiler's rule [`allof-refinement`](../compilers/openapi.md#allof-refinement) keeps composing, two `$ref`s or a union -on a branch among them: a reference to its `$ref`, a node at an inline branch's own pointer, and an -alias only where the position writes what no carrier holds. +beside the `allOf` among them: a reference to its `$ref`, a node at an inline branch's own pointer, +and an alias only where the position writes what no carrier holds. At `cloudflare/api-schemas` @`319dc756` that is 517 of the 3,654 positions lowering an `allOf`, 408 of them a single `$ref` entry. @@ -302,9 +303,14 @@ Giving them the branch pointer's own ID would put two different types on one poi pointer from the other. That makes the IR depend on declaration order and can produce exactly the `Union` the next paragraph forbids. -Distribution needs a referent per branch, because `Base`/`Mixins` conjoin by reference — a branch -must be a `$ref`, and that `$ref` must resolve. A union that cannot supply a referent for every -branch is not distributed *at all* — never halfway, which would leave a `Union` whose variants -disagree about whether they carry `S` with nothing in the IR saying which. Those cases degrade -instead, enumerated by the OpenAPI compiler's rule +Distribution needs a referent per branch, because `Base`/`Mixins` conjoin by reference: a `$ref` +branch's target, which must resolve, or the node an inline branch lowers to at its own pointer, +which is no synthesized node and so takes that pointer's ID. A union that cannot supply a referent +for every branch is not distributed *at all* — never halfway, which would leave a `Union` whose +variants disagree about whether they carry `S` with nothing in the IR saying which. Those cases +degrade instead, enumerated by the OpenAPI compiler's rule [`union-beside-structure`](../compilers/openapi.md#union-beside-structure). + +Where `S` only restates what every branch already is, a `type: object` beside branches that are +all objects among it, the conjunction is the union itself, so the node is a `Union` of the +branches with nothing composed over them. diff --git a/docs/ir/payloads.md b/docs/ir/payloads.md index 84e705440..6152270cd 100644 --- a/docs/ir/payloads.md +++ b/docs/ir/payloads.md @@ -42,7 +42,9 @@ type Content struct { // part property's PropID — a position inside the body model, so // `pass` resolves a key against that model rather than a // registry; of a part several declarations name, the one the - // model's flattening takes (docs/ir/models.md) + // model's flattening takes (docs/ir/models.md). A body that is a + // union, as one a composition is distributed across is, has its + // variants' parts, a part keyed by the first variant declaring it File *FileInfo // body is a file upload/download (TypeSpec file bodies, binary // payloads): how it travels, while Type stays what the source // declares of it, so nothing that declaration says is lost diff --git a/docs/ir/version-history.md b/docs/ir/version-history.md index 979b39af4..0a820d6c5 100644 --- a/docs/ir/version-history.md +++ b/docs/ir/version-history.md @@ -388,3 +388,15 @@ it sees. Changes made together in one bump are listed together under it. union or a `$dynamicRef` kept verbatim they reach the `Model`, as in a composition with neither beside it. A consumer pinned to 0.37.0 that read the branch under that key no longer finds it, and refuses the document as another generation's. +- **0.39.0** — no shape change. The OpenAPI compiler lowers four spellings of a union it had kept + verbatim under `Unmodeled`: one on an inline branch of a composing `allOf`, distributed across the + rest of the composition as one beside the `allOf` is, and one on a branch of an `allOf` no object + satisfies, read as a conjunct, where it kept the branch under `openapi:allOf/`; one beside a + model with inline branches, each conjoined by the node it lowers to at its own pointer, and one + beside keywords its every branch already says, lowered as the union alone, where it kept the + branches under `openapi:oneOf` or `openapi:anyOf` + ([`union-beside-structure`](../compilers/openapi.md#union-beside-structure)). `Content.Encoding` + on a body that is a union, as a distributed one is, keys each part by the property of the first + variant declaring it, where its keys were defined only against a body model and `pass.Validate` + refused them. A consumer pinned to 0.38.0 that read such a union under those keys no longer finds + it there, and one looking such an encoding key up among the union's own members finds none. diff --git a/docs/reference-learnings.md b/docs/reference-learnings.md index 5ca50796a..a4804dd2b 100644 --- a/docs/reference-learnings.md +++ b/docs/reference-learnings.md @@ -555,6 +555,11 @@ combinator. Two lossless representations, in preference order: > shape. Half-distributing was rejected outright: it yields a `Union` whose variants disagree about > whether they carry the body, with nothing in the IR recording which. > +> That constraint has since lifted. A composed variant takes an ID in a namespace of its own +> ([identity](./ir/identity.md#the-id-grammar)), so the branch pointer is free for the branch's own +> node, and an inline branch is conjoined by the node hoisted there, as a `$ref` branch is by its +> target. Nothing is merged keyword by keyword, and a bare `null` branch alone still declines. +> > The general lesson is about this document rather than this section: it was written from studying > four reference implementations, which is exactly the kind of source that gets trusted without > re-derivation. It was right in direction and wrong in one of its two prescriptions, and only diff --git a/internal/archtest/callshape_test.go b/internal/archtest/callshape_test.go index 5a4ad04ce..2a30a291e 100644 --- a/internal/archtest/callshape_test.go +++ b/internal/archtest/callshape_test.go @@ -63,7 +63,7 @@ var callShapeBaseline = map[string]map[string]int{ "compilers/openapi/internal/lowering": { // Promoter.Referent and Remembered each call the function they are handed // before they return: immediate callbacks. - "compilers/openapi/internal/schema": 9, + "compilers/openapi/internal/schema": 11, }, "compilers/openapi/internal/merge": { // What a fold asks the walk that declared the field, through merge.Walk: diff --git a/internal/archtest/recursion_test.go b/internal/archtest/recursion_test.go index bb1249c8a..3c4786505 100644 --- a/internal/archtest/recursion_test.go +++ b/internal/archtest/recursion_test.go @@ -448,9 +448,9 @@ func TestLoweringCallGraph_RecordsAHandoffAsAnEdge(t *testing.T) { graph := loweringCallGraph(t) handoffs := map[string]string{ - "classifyUnionSiblings": "isInlineBranch", - "unionBranchesDeclareShape": "branchDeclaresShape", - "oneOfAnyOfHasNull": "isNullSchema", + "classifyBranchSet": "isNullSchema", + "setsDeclareShape": "branchDeclaresShape", + "oneOfAnyOfHasNull": "isNullSchema", } for from, to := range handoffs { require.Contains(t, graph, from, "the graph no longer holds %q", from) diff --git a/ir/document.go b/ir/document.go index 6db9566ec..21720ea97 100644 --- a/ir/document.go +++ b/ir/document.go @@ -12,7 +12,7 @@ package ir // docs/ir/ir-version.md holds that policy, docs/ir/version-history.md what each // bump changed, and TestIRVersion_PinsTheEncodedShape and // TestIRVersion_PinsTheNames hold it to the shape and the names. -const IRVersion = "0.38.0" +const IRVersion = "0.39.0" // CompatibleVersion reports whether a document stamped version can be read by // this build: exact equality with IRVersion, per docs/ir/ir-version.md. A diff --git a/ir/irversion_test.go b/ir/irversion_test.go index fe74e7be9..cd4cb7df5 100644 --- a/ir/irversion_test.go +++ b/ir/irversion_test.go @@ -96,6 +96,9 @@ var generations = []generation{ {version: "0.38.0", format: 1, shape: "371d8ded3a52ab677a8318b202ab570ce495fb8c335b703477b00643a473275a", names: "5506159ecd463736fdbbad0c4cabfad3fd50b48509b46fbea55862e82eca9675", meaning: "an inline branch of an allOf beside a oneOf, an anyOf or a $dynamicRef folds into the composition: what it says of the position reaches the Union a distributed union lowers to and its carriers, and a value bound each variant"}, + {version: "0.39.0", format: 1, shape: "371d8ded3a52ab677a8318b202ab570ce495fb8c335b703477b00643a473275a", + names: "5506159ecd463736fdbbad0c4cabfad3fd50b48509b46fbea55862e82eca9675", + meaning: "a union the OpenAPI compiler kept verbatim on an allOf branch, beside a model with inline branches or beside keywords its branches say lowers typed, and an encoding key on a union body names a part of its variants, the first variant declaring it"}, } // shapeGolden holds the description the newest row's shape hashes, so a change diff --git a/pass/validate_payloads.go b/pass/validate_payloads.go index f6b4891e1..9a934b38b 100644 --- a/pass/validate_payloads.go +++ b/pass/validate_payloads.go @@ -28,7 +28,7 @@ func checkEncodingKeys(doc *ir.Document, facts *irfacts.Index) []ir.Diagnostic { var diags []ir.Diagnostic var keys []ir.PropID // every content's keys are sorted in this one list forEachPayload(doc, func(site payloadSite) { - diags = appendEncodingKeyDiags(diags, facts, site, &keys) + diags = appendEncodingKeyDiags(diags, doc.Types, facts, site, &keys) }) return diags } @@ -144,9 +144,11 @@ func siteOf(p ir.Payload, owner, field string, index int, request bool) payloadS // A content whose type no entry declares exposes no part, so each of its keys // is reported beside checkDanglingRefs's finding on the type. The claims // differ, as with checkMapping. -func appendEncodingKeyDiags(dst []ir.Diagnostic, facts *irfacts.Index, site payloadSite, keys *[]ir.PropID) []ir.Diagnostic { +func appendEncodingKeyDiags(dst []ir.Diagnostic, types ir.TypeRegistry, facts *irfacts.Index, site payloadSite, + keys *[]ir.PropID, +) []ir.Diagnostic { for i, c := range site.contents { - dst = appendUnknownPartDiags(dst, facts, c, site, i, keys) + dst = appendUnknownPartDiags(dst, types, facts, c, site, i, keys) } return dst } @@ -159,8 +161,8 @@ func appendEncodingKeyDiags(dst []ir.Diagnostic, facts *irfacts.Index, site payl // type's flattening overrides passes, though docs/ir/models.md#the-flattening // has it name the one taken: flattening every type asked about grows // quadratically down a deep hierarchy. -func appendUnknownPartDiags(dst []ir.Diagnostic, facts *irfacts.Index, c ir.Content, site payloadSite, i int, - keys *[]ir.PropID, +func appendUnknownPartDiags(dst []ir.Diagnostic, types ir.TypeRegistry, facts *irfacts.Index, c ir.Content, + site payloadSite, i int, keys *[]ir.PropID, ) []ir.Diagnostic { *keys = (*keys)[:0] for key := range c.Encoding { @@ -168,7 +170,7 @@ func appendUnknownPartDiags(dst []ir.Diagnostic, facts *irfacts.Index, c ir.Cont } slices.Sort(*keys) for _, key := range *keys { - if facts.Exposes(c.Type.Target, key) { + if partNamed(types, facts, c.Type.Target, key) { continue } at := site.where() + "/contents/" + strconv.Itoa(i) + "/encoding/" + string(key) @@ -178,3 +180,34 @@ func appendUnknownPartDiags(dst []ir.Diagnostic, facts *irfacts.Index, c ir.Cont } return dst } + +// partNamed reports whether key names a part of a body typed target: a +// property the type exposes, or one a variant of the union it stands for +// exposes, as a composition distributed across a union puts the body's +// properties on every variant (docs/ir/payloads.md). +func partNamed(types ir.TypeRegistry, facts *irfacts.Index, target ir.TypeID, key ir.PropID) bool { + if facts.Exposes(target, key) { + return true + } + u := unionThrough(types, target) + return u != nil && slices.ContainsFunc(u.Variants, func(v ir.Variant) bool { return facts.Exposes(v.Type.Target, key) }) +} + +// unionThrough returns the union target stands for, read through a chain of +// alias scalars, and nil where it stands for none. The IR permits a cyclic +// chain, so a visited set ends the walk at the first type it meets twice. +func unionThrough(types ir.TypeRegistry, target ir.TypeID) *ir.Union { + seen := map[ir.TypeID]bool{} + for !seen[target] { + seen[target] = true + if u, ok := types[target].(*ir.Union); ok { + return u + } + base, alias := ir.AliasBase(types, target) + if !alias { + return nil + } + target = base + } + return nil +} diff --git a/pass/validate_payloads_test.go b/pass/validate_payloads_test.go index 6c0501bd4..eb7a1195a 100644 --- a/pass/validate_payloads_test.go +++ b/pass/validate_payloads_test.go @@ -290,6 +290,49 @@ func TestValidate_EncodingKeyThroughCyclicAlias(t *testing.T) { assert.Empty(t, withCode(validated(t, doc), "ir/encoding-key-unknown-property")) } +// TestValidate_EncodingKeyThroughAUnionBody pins a body that is a union, as one +// a composition is distributed across is: a key naming a property a variant +// exposes, the body's own on every variant or a branch's on one, names a part, +// read through an alias over the union too, and one no variant exposes names +// none, nor one on an alias chain closing on itself before any union +// (docs/ir/payloads.md). +func TestValidate_EncodingKeyThroughAUnionBody(t *testing.T) { + t.Parallel() + for name, tc := range map[string]struct { + target ir.TypeID + key ir.PropID + parts bool + }{ + "the body's own, on each variant": {"t/u", "p/m/a", true}, + "a branch's, on one variant": {"t/u", "p/other/b", true}, + "through an alias over the union": {"t/alias", "p/other/b", true}, + "one no variant exposes": {"t/u", "p/nowhere", false}, + "an alias cycle reaching no union": {"t/loop/a", "p/nowhere", false}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + doc := validDoc() + doc.Types["t/other"] = &ir.Model{ID: "t/other", Base: &ir.TypeRef{Target: "t/m"}, + Properties: []ir.Property{{ID: "p/other/b", WireName: "b", Type: ir.TypeRef{Target: "t/prim/string"}}}} + doc.Types["t/u"] = &ir.Union{ID: "t/u", Closed: true, + Variants: []ir.Variant{{Type: ir.TypeRef{Target: "t/m"}}, {Type: ir.TypeRef{Target: "t/other"}}}} + doc.Types["t/alias"] = &ir.Scalar{ID: "t/alias", Base: &ir.TypeRef{Target: "t/u"}} + doc.Types["t/loop/a"] = &ir.Scalar{ID: "t/loop/a", Base: &ir.TypeRef{Target: "t/loop/b"}} + doc.Types["t/loop/b"] = &ir.Scalar{ID: "t/loop/b", Base: &ir.TypeRef{Target: "t/loop/a"}} + c := requestContent(doc) + c.Type = ir.TypeRef{Target: tc.target} + c.Encoding = map[ir.PropID]ir.PartEncoding{tc.key: {Multi: true}} + + found := withCode(validated(t, doc), "ir/encoding-key-unknown-property") + if tc.parts { + assert.Empty(t, found) + return + } + assert.Len(t, found, 1) + }) + } +} + // TestValidate_EncodingKeyOnTypeWithoutParts covers the targets that expose no // part at all: a type that is no model, an opaque alias standing for nothing, a // registry entry holding a typed nil — which must be reported rather than diff --git a/testdata/conformance/openapi/allof-oneof-cooccurrence.golden.json b/testdata/conformance/openapi/allof-oneof-cooccurrence.golden.json index 6354b7681..cb04ec7b0 100644 --- a/testdata/conformance/openapi/allof-oneof-cooccurrence.golden.json +++ b/testdata/conformance/openapi/allof-oneof-cooccurrence.golden.json @@ -158,6 +158,106 @@ "positional": false, "inputOnly": false }, + "t/composed/components/schemas/MixedKinds/oneOf/0": { + "kind": "model", + "id": "t/composed/components/schemas/MixedKinds/oneOf/0", + "name": { + "hint": "mixed_kinds_a" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/MixedKinds/oneOf/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/MixedKinds/properties/kind", + "name": { + "source": "kind", + "canonical": "kind" + }, + "wireName": "kind", + "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/MixedKinds/properties/kind" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/A", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/components/schemas/MixedKinds/oneOf/1": { + "kind": "model", + "id": "t/composed/components/schemas/MixedKinds/oneOf/1", + "name": { + "hint": "mixed_kinds_variant_1" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/MixedKinds/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/MixedKinds/properties/kind", + "name": { + "source": "kind", + "canonical": "kind" + }, + "wireName": "kind", + "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/MixedKinds/properties/kind" + } + } + ], + "base": { + "target": "t/prim/string", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, "t/openapi/components/schemas/A": { "kind": "model", "id": "t/openapi/components/schemas/A", @@ -471,7 +571,7 @@ "inputOnly": false }, "t/openapi/components/schemas/MixedKinds": { - "kind": "model", + "kind": "union", "id": "t/openapi/components/schemas/MixedKinds", "name": { "source": "MixedKinds", @@ -480,58 +580,35 @@ "anonymous": false, "docs": {}, "sensitive": false, - "unmodeled": { - "openapi:oneOf": { - "reason": "degraded_lowering", - "value": [ - { - "$ref": "#/components/schemas/A" - }, - { - "type": "string" - } - ], - "provenance": { - "source": 0, - "pointer": "/components/schemas/MixedKinds/oneOf" - } - } - }, "provenance": { "source": 0, "pointer": "/components/schemas/MixedKinds" }, - "properties": [ + "variants": [ { - "id": "p/openapi/components/schemas/MixedKinds/properties/kind", "name": { - "source": "kind", - "canonical": "kind" + "hint": "a" }, - "wireName": "kind", "type": { - "target": "t/prim/string", + "target": "t/composed/components/schemas/MixedKinds/oneOf/0", "nullable": false }, - "required": false, - "clientOptional": false, - "defaultAdded": false, - "visibility": { - "none": false + "docs": {} + }, + { + "name": { + "hint": "variant_1" }, - "flatten": false, - "eventHeader": false, - "eventPayload": false, - "secret": false, - "docs": {}, - "provenance": { - "source": 0, - "pointer": "/components/schemas/MixedKinds/properties/kind" - } + "type": { + "target": "t/composed/components/schemas/MixedKinds/oneOf/1", + "nullable": false + }, + "docs": {} } ], - "abstract": false, - "positional": false, + "exclusive": true, + "wireTagged": false, + "closed": true, "inputOnly": false }, "t/openapi/components/schemas/Outsider": { @@ -631,8 +708,8 @@ }, { "severity": "info", - "code": "openapi/degraded-construct", - "message": "oneOf/anyOf co-declared with structural keywords intersects with them, and a branch is written inline, so it names no referent to conjoin the body with; union branches kept verbatim under Unmodeled", + "code": "openapi/composition-lowering", + "message": "oneOf/anyOf co-declared with structural keywords; the composition is distributed across the union variants", "provenance": { "source": 0, "pointer": "/components/schemas/MixedKinds" @@ -652,7 +729,7 @@ { "format": "openapi@3.1", "path": "allof-oneof-cooccurrence.yaml", - "hash": "ebfa72a094c91f97d3919297a5f3cc83845bd9044735b9e52d2085f975b4549a" + "hash": "1486e94170f16b4d73de46cf2421584dc29e33c8753d8ecd8a3d0dcb27e3f491" } ] } diff --git a/testdata/conformance/openapi/allof-oneof-cooccurrence.yaml b/testdata/conformance/openapi/allof-oneof-cooccurrence.yaml index 4306e5688..ce9c88f2a 100644 --- a/testdata/conformance/openapi/allof-oneof-cooccurrence.yaml +++ b/testdata/conformance/openapi/allof-oneof-cooccurrence.yaml @@ -26,8 +26,9 @@ components: - {$ref: '#/components/schemas/A'} - {$ref: '#/components/schemas/B'} minProperties: 2 - # An inline branch names no referent to conjoin Base with, so this union is - # kept verbatim instead of half-distributed (docs/ir/boundaries.md#degraded-constructs). + # An inline branch is hoisted at its own pointer and conjoined as a $ref + # branch is, carried as written though it admits no object + # (docs/compilers/openapi.md#union-beside-structure). MixedKinds: type: object properties: diff --git a/testdata/conformance/openapi/allof-refinement.golden.json b/testdata/conformance/openapi/allof-refinement.golden.json index 0b63c2cc7..e0d92c716 100644 --- a/testdata/conformance/openapi/allof-refinement.golden.json +++ b/testdata/conformance/openapi/allof-refinement.golden.json @@ -641,6 +641,29 @@ "closed": true, "flags": false }, + "t/openapi/components/schemas/Needle": { + "kind": "scalar", + "id": "t/openapi/components/schemas/Needle", + "name": { + "source": "Needle", + "canonical": "needle" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Needle" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "constraints": { + "maxLength": 1000, + "uniqueItems": false + } + }, "t/openapi/components/schemas/Owner": { "kind": "model", "id": "t/openapi/components/schemas/Owner", @@ -1359,7 +1382,7 @@ { "format": "openapi@3.0", "path": "allof-refinement.yaml", - "hash": "5569e428abcc60c76b996f40ab43601babeab29830f408bac74a815ea387f82e" + "hash": "c8cb5cd5feaff1c79343a10847b912ea47e8801a0ff4fcb6bd2acd95bb6e016b" } ] } diff --git a/testdata/conformance/openapi/allof-refinement.yaml b/testdata/conformance/openapi/allof-refinement.yaml index 1f6678d05..314269e5d 100644 --- a/testdata/conformance/openapi/allof-refinement.yaml +++ b/testdata/conformance/openapi/allof-refinement.yaml @@ -142,3 +142,9 @@ components: Tags: {type: array, items: {type: string}} Mode: {type: string, enum: [get, set], description: What to do} Blob: {type: string, format: binary} + # A union on a branch is a conjunct admitting what its branches do, so this + # is the string the second branch says, and the anyOf adds nothing to it. + Needle: + allOf: + - anyOf: [{type: string}, {type: number}, {type: boolean}] + - {type: string, maxLength: 1000} diff --git a/testdata/conformance/openapi/allof-union-branch.golden.json b/testdata/conformance/openapi/allof-union-branch.golden.json new file mode 100644 index 000000000..7c418ed26 --- /dev/null +++ b/testdata/conformance/openapi/allof-union-branch.golden.json @@ -0,0 +1,1568 @@ +{ + "idSpaces": { + "auth": [ + "openapi", + "unresolved" + ], + "e": [ + "anon", + "composed", + "openapi" + ], + "g": [ + "default", + "path-prefix", + "tags", + "webhooks" + ], + "op": [ + "openapi" + ], + "p": [ + "composed", + "openapi" + ], + "param": [ + "openapi" + ], + "s": [ + "openapi" + ], + "server": [ + "default", + "openapi" + ], + "t": [ + "anon", + "composed", + "openapi" + ] + }, + "name": "AllOfUnionBranch", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "AllOfUnionBranch", + "canonical": "all_of_union_branch" + }, + "docs": {}, + "groups": [ + { + "id": "g/default", + "name": { + "hint": "default" + }, + "docs": {}, + "operations": [ + { + "id": "op/openapi/paths/~1upload/post", + "name": { + "source": "upload", + "canonical": "upload" + }, + "docs": {}, + "request": { + "contents": [ + { + "mediaType": "multipart/form-data", + "type": { + "target": "t/anon/paths/~1upload/post/requestBody/content/multipart~1form-data/schema", + "nullable": false + }, + "encoding": { + "p/openapi/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/file": { + "inferred": "binary-media-type", + "contentTypes": [ + "image/png" + ], + "multi": false, + "filename": true, + "allowReserved": false + } + } + } + ], + "required": false + }, + "responses": [ + { + "name": { + "hint": "204" + }, + "conditions": { + "statusCodes": [ + { + "from": 204, + "to": 204 + } + ] + }, + "docs": { + "description": "Uploaded" + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "POST", + "uriTemplate": "/upload", + "requestContentTypes": [ + "multipart/form-data" + ], + "sharedRoute": false, + "checksumRequired": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1upload/post" + } + } + ] + } + ], + "servers": [ + "server/default" + ], + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/anon/components/schemas/App/allOf/1/oneOf/0": { + "kind": "model", + "id": "t/anon/components/schemas/App/allOf/1/oneOf/0", + "name": { + "hint": "variant_0" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/App/allOf/1/oneOf/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/App/allOf/1/oneOf/0/properties/account_app_id", + "name": { + "source": "account_app_id", + "canonical": "account_app_id" + }, + "wireName": "account_app_id", + "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/App/allOf/1/oneOf/0/properties/account_app_id" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/components/schemas/App/allOf/1/oneOf/1": { + "kind": "model", + "id": "t/anon/components/schemas/App/allOf/1/oneOf/1", + "name": { + "hint": "variant_1" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/App/allOf/1/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/App/allOf/1/oneOf/1/properties/managed_app_id", + "name": { + "source": "managed_app_id", + "canonical": "managed_app_id" + }, + "wireName": "managed_app_id", + "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/App/allOf/1/oneOf/1/properties/managed_app_id" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/components/schemas/AppRequest/allOf/0/oneOf/0": { + "kind": "model", + "id": "t/anon/components/schemas/AppRequest/allOf/0/oneOf/0", + "name": { + "hint": "variant_0" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/AppRequest/allOf/0/oneOf/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/AppRequest/allOf/0/oneOf/0/properties/account_app_id", + "name": { + "source": "account_app_id", + "canonical": "account_app_id" + }, + "wireName": "account_app_id", + "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/AppRequest/allOf/0/oneOf/0/properties/account_app_id" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/components/schemas/AppRequest/allOf/0/oneOf/1": { + "kind": "model", + "id": "t/anon/components/schemas/AppRequest/allOf/0/oneOf/1", + "name": { + "hint": "variant_1" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/AppRequest/allOf/0/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/AppRequest/allOf/0/oneOf/1/properties/managed_app_id", + "name": { + "source": "managed_app_id", + "canonical": "managed_app_id" + }, + "wireName": "managed_app_id", + "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/AppRequest/allOf/0/oneOf/1/properties/managed_app_id" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/components/schemas/WithoutData/properties/data": { + "kind": "model", + "id": "t/anon/components/schemas/WithoutData/properties/data", + "name": { + "hint": "data" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/WithoutData/properties/data" + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/paths/~1upload/post/requestBody/content/multipart~1form-data/schema": { + "kind": "union", + "id": "t/anon/paths/~1upload/post/requestBody/content/multipart~1form-data/schema", + "name": { + "hint": "upload_request" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/paths/~1upload/post/requestBody/content/multipart~1form-data/schema" + }, + "variants": [ + { + "name": { + "hint": "without_data" + }, + "type": { + "target": "t/composed/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/1/oneOf/0", + "nullable": false + }, + "docs": {} + }, + { + "name": { + "hint": "with_data" + }, + "type": { + "target": "t/composed/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/1/oneOf/1", + "nullable": false + }, + "docs": {} + } + ], + "exclusive": true, + "wireTagged": false, + "closed": true, + "inputOnly": false + }, + "t/anon/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/file": { + "kind": "scalar", + "id": "t/anon/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/file", + "name": { + "hint": "file" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/file" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "encoding": { + "mediaType": "image/png" + } + }, + "t/composed/components/schemas/App/allOf/1/oneOf/0": { + "kind": "model", + "id": "t/composed/components/schemas/App/allOf/1/oneOf/0", + "name": { + "hint": "app_variant_0" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/App/allOf/1/oneOf/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/App/allOf/0/properties/id", + "name": { + "source": "id", + "canonical": "id" + }, + "wireName": "id", + "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/App/allOf/0/properties/id" + } + } + ], + "base": { + "target": "t/anon/components/schemas/App/allOf/1/oneOf/0", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/components/schemas/App/allOf/1/oneOf/1": { + "kind": "model", + "id": "t/composed/components/schemas/App/allOf/1/oneOf/1", + "name": { + "hint": "app_variant_1" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/App/allOf/1/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/App/allOf/0/properties/id", + "name": { + "source": "id", + "canonical": "id" + }, + "wireName": "id", + "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/App/allOf/0/properties/id" + } + } + ], + "base": { + "target": "t/anon/components/schemas/App/allOf/1/oneOf/1", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/components/schemas/AppRequest/allOf/0/oneOf/0": { + "kind": "model", + "id": "t/composed/components/schemas/AppRequest/allOf/0/oneOf/0", + "name": { + "hint": "app_request_variant_0" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:allOf/1": { + "reason": "degraded_lowering", + "value": { + "anyOf": [ + { + "required": [ + "breakout" + ], + "type": "object" + }, + { + "required": [ + "priority" + ], + "type": "object" + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/AppRequest/allOf/1" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/AppRequest/allOf/0/oneOf/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/AppRequest/allOf/2/properties/breakout", + "name": { + "source": "breakout", + "canonical": "breakout" + }, + "wireName": "breakout", + "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/AppRequest/allOf/2/properties/breakout" + } + }, + { + "id": "p/openapi/components/schemas/AppRequest/allOf/2/properties/priority", + "name": { + "source": "priority", + "canonical": "priority" + }, + "wireName": "priority", + "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/AppRequest/allOf/2/properties/priority" + } + } + ], + "base": { + "target": "t/anon/components/schemas/AppRequest/allOf/0/oneOf/0", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/components/schemas/AppRequest/allOf/0/oneOf/1": { + "kind": "model", + "id": "t/composed/components/schemas/AppRequest/allOf/0/oneOf/1", + "name": { + "hint": "app_request_variant_1" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:allOf/1": { + "reason": "degraded_lowering", + "value": { + "anyOf": [ + { + "required": [ + "breakout" + ], + "type": "object" + }, + { + "required": [ + "priority" + ], + "type": "object" + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/AppRequest/allOf/1" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/AppRequest/allOf/0/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/AppRequest/allOf/2/properties/breakout", + "name": { + "source": "breakout", + "canonical": "breakout" + }, + "wireName": "breakout", + "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/AppRequest/allOf/2/properties/breakout" + } + }, + { + "id": "p/openapi/components/schemas/AppRequest/allOf/2/properties/priority", + "name": { + "source": "priority", + "canonical": "priority" + }, + "wireName": "priority", + "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/AppRequest/allOf/2/properties/priority" + } + } + ], + "base": { + "target": "t/anon/components/schemas/AppRequest/allOf/0/oneOf/1", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/components/schemas/Record/allOf/0/anyOf/0": { + "kind": "model", + "id": "t/composed/components/schemas/Record/allOf/0/anyOf/0", + "name": { + "hint": "record_without_data" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Record/allOf/0/anyOf/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Record/allOf/1/properties/id", + "name": { + "source": "id", + "canonical": "id" + }, + "wireName": "id", + "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/Record/allOf/1/properties/id" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/WithoutData", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/components/schemas/Record/allOf/0/anyOf/1": { + "kind": "model", + "id": "t/composed/components/schemas/Record/allOf/0/anyOf/1", + "name": { + "hint": "record_with_data" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Record/allOf/0/anyOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Record/allOf/1/properties/id", + "name": { + "source": "id", + "canonical": "id" + }, + "wireName": "id", + "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/Record/allOf/1/properties/id" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/WithData", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/1/oneOf/0": { + "kind": "model", + "id": "t/composed/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/1/oneOf/0", + "name": { + "hint": "upload_request_without_data" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/1/oneOf/0" + }, + "properties": [ + { + "id": "p/openapi/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/file", + "name": { + "source": "file", + "canonical": "file" + }, + "wireName": "file", + "type": { + "target": "t/anon/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/file", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/file" + } + }, + { + "id": "p/openapi/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/note", + "name": { + "source": "note", + "canonical": "note" + }, + "wireName": "note", + "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": "/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/note" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/WithoutData", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/1/oneOf/1": { + "kind": "model", + "id": "t/composed/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/1/oneOf/1", + "name": { + "hint": "upload_request_with_data" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/1/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/file", + "name": { + "source": "file", + "canonical": "file" + }, + "wireName": "file", + "type": { + "target": "t/anon/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/file", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/file" + } + }, + { + "id": "p/openapi/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/note", + "name": { + "source": "note", + "canonical": "note" + }, + "wireName": "note", + "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": "/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/allOf/0/properties/note" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/WithData", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/App": { + "kind": "union", + "id": "t/openapi/components/schemas/App", + "name": { + "source": "App", + "canonical": "app" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/App" + }, + "variants": [ + { + "name": { + "hint": "variant_0" + }, + "type": { + "target": "t/composed/components/schemas/App/allOf/1/oneOf/0", + "nullable": false + }, + "docs": {} + }, + { + "name": { + "hint": "variant_1" + }, + "type": { + "target": "t/composed/components/schemas/App/allOf/1/oneOf/1", + "nullable": false + }, + "docs": {} + } + ], + "exclusive": true, + "wireTagged": false, + "closed": true, + "inputOnly": false + }, + "t/openapi/components/schemas/AppRequest": { + "kind": "union", + "id": "t/openapi/components/schemas/AppRequest", + "name": { + "source": "AppRequest", + "canonical": "app_request" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/AppRequest" + }, + "variants": [ + { + "name": { + "hint": "variant_0" + }, + "type": { + "target": "t/composed/components/schemas/AppRequest/allOf/0/oneOf/0", + "nullable": false + }, + "docs": {} + }, + { + "name": { + "hint": "variant_1" + }, + "type": { + "target": "t/composed/components/schemas/AppRequest/allOf/0/oneOf/1", + "nullable": false + }, + "docs": {} + } + ], + "exclusive": true, + "wireTagged": false, + "closed": true, + "inputOnly": false + }, + "t/openapi/components/schemas/Dog": { + "kind": "model", + "id": "t/openapi/components/schemas/Dog", + "name": { + "source": "Dog", + "canonical": "dog" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:allOf/1": { + "reason": "degraded_lowering", + "value": { + "oneOf": [ + { + "$ref": "#/components/schemas/WithoutData" + }, + { + "$ref": "#/components/schemas/WithData" + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Dog/allOf/1" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Dog" + }, + "base": { + "target": "t/openapi/components/schemas/Pet", + "nullable": false + }, + "abstract": false, + "positional": false, + "discriminatorValue": "dog", + "inputOnly": false + }, + "t/openapi/components/schemas/Message": { + "kind": "model", + "id": "t/openapi/components/schemas/Message", + "name": { + "source": "Message", + "canonical": "message" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:allOf/0": { + "reason": "degraded_lowering", + "value": { + "anyOf": [ + { + "required": [ + "to" + ], + "type": "object" + }, + { + "required": [ + "cc" + ], + "type": "object" + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Message/allOf/0" + } + }, + "openapi:allOf/1": { + "reason": "degraded_lowering", + "value": { + "anyOf": [ + { + "required": [ + "text" + ], + "type": "object" + }, + { + "required": [ + "html" + ], + "type": "object" + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Message/allOf/1" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Message" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Message/properties/to", + "name": { + "source": "to", + "canonical": "to" + }, + "wireName": "to", + "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/Message/properties/to" + } + }, + { + "id": "p/openapi/components/schemas/Message/properties/cc", + "name": { + "source": "cc", + "canonical": "cc" + }, + "wireName": "cc", + "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/Message/properties/cc" + } + }, + { + "id": "p/openapi/components/schemas/Message/properties/text", + "name": { + "source": "text", + "canonical": "text" + }, + "wireName": "text", + "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/Message/properties/text" + } + }, + { + "id": "p/openapi/components/schemas/Message/properties/html", + "name": { + "source": "html", + "canonical": "html" + }, + "wireName": "html", + "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/Message/properties/html" + } + } + ], + "abstract": false, + "positional": false, + "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/kind", + "name": { + "source": "kind", + "canonical": "kind" + }, + "wireName": "kind", + "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/kind" + } + } + ], + "abstract": false, + "positional": false, + "discriminator": { + "property": "p/openapi/components/schemas/Pet/properties/kind", + "mapping": { + "dog": "t/openapi/components/schemas/Dog" + }, + "inferred": false + }, + "inputOnly": false + }, + "t/openapi/components/schemas/Record": { + "kind": "union", + "id": "t/openapi/components/schemas/Record", + "name": { + "source": "Record", + "canonical": "record" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Record" + }, + "variants": [ + { + "name": { + "hint": "without_data" + }, + "type": { + "target": "t/composed/components/schemas/Record/allOf/0/anyOf/0", + "nullable": false + }, + "docs": {} + }, + { + "name": { + "hint": "with_data" + }, + "type": { + "target": "t/composed/components/schemas/Record/allOf/0/anyOf/1", + "nullable": false + }, + "docs": {} + } + ], + "exclusive": false, + "wireTagged": false, + "closed": true, + "inputOnly": false + }, + "t/openapi/components/schemas/WithData": { + "kind": "model", + "id": "t/openapi/components/schemas/WithData", + "name": { + "source": "WithData", + "canonical": "with_data" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/WithData" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/WithData/properties/content", + "name": { + "source": "content", + "canonical": "content" + }, + "wireName": "content", + "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/WithData/properties/content" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/WithoutData": { + "kind": "model", + "id": "t/openapi/components/schemas/WithoutData", + "name": { + "source": "WithoutData", + "canonical": "without_data" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/WithoutData" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/WithoutData/properties/data", + "name": { + "source": "data", + "canonical": "data" + }, + "wireName": "data", + "type": { + "target": "t/anon/components/schemas/WithoutData/properties/data", + "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/WithoutData/properties/data" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/prim/integer": { + "kind": "primitive", + "id": "t/prim/integer", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "integer" + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "string" + } + }, + "servers": { + "server/default": { + "id": "server/default", + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {}, + "provenance": { + "source": 0 + } + } + }, + "diagnostics": [ + { + "severity": "info", + "code": "openapi/composition-lowering", + "message": "oneOf/anyOf written on an allOf branch conjoins with the rest of the composition; the composition is distributed across the union variants", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Record" + } + }, + { + "severity": "info", + "code": "openapi/composition-lowering", + "message": "oneOf/anyOf written on an allOf branch conjoins with the rest of the composition; the composition is distributed across the union variants", + "provenance": { + "source": 0, + "pointer": "/components/schemas/App" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "inline allOf branch is merged in place, and nothing reads the rest of it; the branch (anyOf) is kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/AppRequest/allOf/1" + } + }, + { + "severity": "info", + "code": "openapi/composition-lowering", + "message": "oneOf/anyOf written on an allOf branch conjoins with the rest of the composition; the composition is distributed across the union variants", + "provenance": { + "source": 0, + "pointer": "/components/schemas/AppRequest" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "inline allOf branch is merged in place, and nothing reads the rest of it; the branch (anyOf) is kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Message/allOf/0" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "inline allOf branch is merged in place, and nothing reads the rest of it; the branch (anyOf) is kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Message/allOf/1" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "inline allOf branch is merged in place, and nothing reads the rest of it; the branch (oneOf) is kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Dog/allOf/1" + } + }, + { + "severity": "info", + "code": "openapi/composition-lowering", + "message": "oneOf/anyOf written on an allOf branch conjoins with the rest of the composition; the composition is distributed across the union variants", + "provenance": { + "source": 0, + "pointer": "/paths/~1upload/post/requestBody/content/multipart~1form-data/schema" + } + } + ], + "sources": [ + { + "format": "openapi@3.1", + "path": "allof-union-branch.yaml", + "hash": "7f14b278a682f3536dff0c0a0a399957341a04dfecd912ff598e38614df8ba0c" + } + ] +} diff --git a/testdata/conformance/openapi/allof-union-branch.yaml b/testdata/conformance/openapi/allof-union-branch.yaml new file mode 100644 index 000000000..daf9a4469 --- /dev/null +++ b/testdata/conformance/openapi/allof-union-branch.yaml @@ -0,0 +1,89 @@ +openapi: 3.1.0 +info: {title: AllOfUnionBranch, version: "1.0.0"} +paths: + /upload: + post: + operationId: upload + requestBody: + content: + # A form body distributed across a union keys each part by the + # property its variants hold. + multipart/form-data: + schema: + allOf: + - type: object + properties: {file: {type: string, contentMediaType: image/png}, note: {type: string}} + - oneOf: + - $ref: '#/components/schemas/WithoutData' + - $ref: '#/components/schemas/WithData' + encoding: + file: {contentType: image/png} + responses: + "204": {description: Uploaded} +components: + schemas: + WithoutData: {type: object, properties: {data: {type: object}}} + WithData: {type: object, properties: {content: {type: string}}} + # A union written as an allOf branch conjoins with the rest of the + # composition, so it distributes as the same union written beside the allOf + # does, each variant carrying the record's common fields. + Record: + type: object + required: [id] + allOf: + - type: object + anyOf: + - $ref: '#/components/schemas/WithoutData' + - $ref: '#/components/schemas/WithData' + - type: object + properties: {id: {type: string}} + # Inline branches lower at their own pointers, and each variant composes + # the shared fields with one of them. + App: + type: object + allOf: + - type: object + required: [id] + properties: {id: {type: string}} + - oneOf: + - type: object + required: [account_app_id] + properties: {account_app_id: {type: string}} + - type: object + required: [managed_app_id] + properties: {managed_app_id: {type: string}} + # Of two union branches, one only narrows the body: it stays with its + # branch on each variant, and the other distributes. + AppRequest: + type: object + allOf: + - oneOf: + - {type: object, required: [account_app_id], properties: {account_app_id: {type: string}}} + - {type: object, required: [managed_app_id], properties: {managed_app_id: {type: string}}} + - anyOf: [{type: object, required: [breakout]}, {type: object, required: [priority]}] + - type: object + properties: {breakout: {type: string}, priority: {type: integer}} + # Unions that only narrow the body distribute nothing: the composition + # stays a model, each kept with its branch. + Message: + type: object + properties: {to: {type: string}, cc: {type: string}, text: {type: string}, html: {type: string}} + allOf: + - anyOf: [{type: object, required: [to]}, {type: object, required: [cc]}] + - anyOf: [{type: object, required: [text]}, {type: object, required: [html]}] + # A subtype a discriminated base routes to stays its subtype, its union kept + # with its branch: distributed, it would be a union, which no route names + # as a subtype. + Pet: + type: object + required: [kind] + properties: {kind: {type: string}} + discriminator: + propertyName: kind + mapping: {dog: '#/components/schemas/Dog'} + Dog: + allOf: + - $ref: '#/components/schemas/Pet' + - oneOf: + - $ref: '#/components/schemas/WithoutData' + - $ref: '#/components/schemas/WithData' diff --git a/testdata/conformance/openapi/discriminated-union-beside-model.golden.json b/testdata/conformance/openapi/discriminated-union-beside-model.golden.json index 3b387ed6e..e78ebcc09 100644 --- a/testdata/conformance/openapi/discriminated-union-beside-model.golden.json +++ b/testdata/conformance/openapi/discriminated-union-beside-model.golden.json @@ -104,62 +104,6 @@ "positional": false, "inputOnly": false }, - "t/composed/components/schemas/Bird/oneOf/0": { - "kind": "model", - "id": "t/composed/components/schemas/Bird/oneOf/0", - "name": { - "hint": "bird_finch" - }, - "anonymous": true, - "docs": {}, - "sensitive": false, - "provenance": { - "source": 0, - "pointer": "/components/schemas/Bird/oneOf/0" - }, - "base": { - "target": "t/openapi/components/schemas/Animal", - "nullable": false - }, - "mixins": [ - { - "target": "t/openapi/components/schemas/Finch", - "nullable": false - } - ], - "abstract": false, - "positional": false, - "discriminatorValue": "bird", - "inputOnly": false - }, - "t/composed/components/schemas/Bird/oneOf/1": { - "kind": "model", - "id": "t/composed/components/schemas/Bird/oneOf/1", - "name": { - "hint": "bird_crow" - }, - "anonymous": true, - "docs": {}, - "sensitive": false, - "provenance": { - "source": 0, - "pointer": "/components/schemas/Bird/oneOf/1" - }, - "base": { - "target": "t/openapi/components/schemas/Animal", - "nullable": false - }, - "mixins": [ - { - "target": "t/openapi/components/schemas/Crow", - "nullable": false - } - ], - "abstract": false, - "positional": false, - "discriminatorValue": "bird", - "inputOnly": false - }, "t/openapi/components/schemas/Animal": { "kind": "model", "id": "t/openapi/components/schemas/Animal", @@ -171,19 +115,6 @@ "docs": {}, "sensitive": false, "unmodeled": { - "openapi:discriminator": { - "reason": "degraded_lowering", - "value": { - "mapping": { - "bird": "#/components/schemas/Bird" - }, - "propertyName": "kind" - }, - "provenance": { - "source": 0, - "pointer": "/components/schemas/Animal/discriminator" - } - }, "openapi:oneOf": { "reason": "degraded_lowering", "value": [ @@ -203,10 +134,17 @@ }, "abstract": false, "positional": false, + "discriminator": { + "propertyName": "kind", + "mapping": { + "bird": "t/openapi/components/schemas/Bird" + }, + "inferred": false + }, "inputOnly": false }, "t/openapi/components/schemas/Bird": { - "kind": "union", + "kind": "model", "id": "t/openapi/components/schemas/Bird", "name": { "source": "Bird", @@ -215,35 +153,34 @@ "anonymous": false, "docs": {}, "sensitive": false, + "unmodeled": { + "openapi:oneOf": { + "reason": "degraded_lowering", + "value": [ + { + "$ref": "#/components/schemas/Finch" + }, + { + "$ref": "#/components/schemas/Crow" + } + ], + "provenance": { + "source": 0, + "pointer": "/components/schemas/Bird/oneOf" + } + } + }, "provenance": { "source": 0, "pointer": "/components/schemas/Bird" }, - "variants": [ - { - "name": { - "hint": "finch" - }, - "type": { - "target": "t/composed/components/schemas/Bird/oneOf/0", - "nullable": false - }, - "docs": {} - }, - { - "name": { - "hint": "crow" - }, - "type": { - "target": "t/composed/components/schemas/Bird/oneOf/1", - "nullable": false - }, - "docs": {} - } - ], - "exclusive": true, - "wireTagged": false, - "closed": true, + "base": { + "target": "t/openapi/components/schemas/Animal", + "nullable": false + }, + "abstract": false, + "positional": false, + "discriminatorValue": "bird", "inputOnly": false }, "t/openapi/components/schemas/Car": { @@ -889,8 +826,8 @@ }, { "severity": "info", - "code": "openapi/composition-lowering", - "message": "oneOf/anyOf co-declared with structural keywords; the composition is distributed across the union variants", + "code": "openapi/degraded-construct", + "message": "oneOf/anyOf co-declared with structural keywords intersects with them, and a discriminator on an ancestor routes to this schema as its subtype, which distributing it into a union would undo; union branches kept verbatim under Unmodeled", "provenance": { "source": 0, "pointer": "/components/schemas/Bird" @@ -914,15 +851,6 @@ "pointer": "/components/schemas/Pet/discriminator" } }, - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "the discriminator routes to a type that is not a subtype of the model holding it, so it cannot stay there; kept verbatim under Unmodeled beside the union it was written for", - "provenance": { - "source": 0, - "pointer": "/components/schemas/Animal/discriminator" - } - }, { "severity": "info", "code": "openapi/degraded-construct", @@ -937,7 +865,7 @@ { "format": "openapi@3.1", "path": "discriminated-union-beside-model.yaml", - "hash": "8c05073b0a8e6b6b0228065b686a7597f365d83e0dc21692dbfbd73f55732408" + "hash": "bd31f4167741e7973492f23ccd5a8e06bed7744d1c3dbb49cb764f68e0dcdcfa" } ] } diff --git a/testdata/conformance/openapi/discriminated-union-beside-model.yaml b/testdata/conformance/openapi/discriminated-union-beside-model.yaml index 381685c6d..23f2a9d11 100644 --- a/testdata/conformance/openapi/discriminated-union-beside-model.yaml +++ b/testdata/conformance/openapi/discriminated-union-beside-model.yaml @@ -51,9 +51,10 @@ components: - type: object properties: side: {type: number} - # Bird writes allOf to Animal, but also a oneOf the allOf is distributed - # across, so Bird lowers to a union and is no subtype of Animal. Only the - # lowered document can tell, so the discriminator is kept beside the union. + # Bird writes allOf to Animal and a oneOf beside it. Animal's mapping routes + # to Bird, so the union is kept beside it rather than distributed, which + # would make Bird a union and no subtype, and Animal keeps its + # discriminator. Animal: type: object discriminator: diff --git a/testdata/conformance/openapi/matrix.md b/testdata/conformance/openapi/matrix.md index 8dc443df6..11c328152 100644 --- a/testdata/conformance/openapi/matrix.md +++ b/testdata/conformance/openapi/matrix.md @@ -31,7 +31,8 @@ witnessing it. ### `untagged-unions` - Witnesses: `allof-oneof-cooccurrence`, `allof-ref-branch-siblings`, `union-beside-ref`, - `anyof-untagged`, `union-decorated-branch`, `type-array-numeric` + `anyof-untagged`, `union-decorated-branch`, `type-array-numeric`, `union-restated-type`, + `union-inline-branches`, `allof-union-branch` ### `intersection` @@ -39,7 +40,7 @@ witnessing it. `allof-oneof-cooccurrence`, `allof-inline-residue`, `allof-nested-inline`, `allof-ref-branch-siblings`, `allof-boolean-branch`, `allof-narrowing-redeclaration`, `allof-redeclared-models`, `allof-redeclared-bounds`, `allof-position-constraints`, - `allof-refinement`, `codeclared-keywords` + `allof-refinement`, `codeclared-keywords`, `allof-union-branch` ### `negation` diff --git a/testdata/conformance/openapi/union-inline-branches.golden.json b/testdata/conformance/openapi/union-inline-branches.golden.json new file mode 100644 index 000000000..90ac3b333 --- /dev/null +++ b/testdata/conformance/openapi/union-inline-branches.golden.json @@ -0,0 +1,795 @@ +{ + "idSpaces": { + "auth": [ + "openapi", + "unresolved" + ], + "e": [ + "anon", + "composed", + "openapi" + ], + "g": [ + "default", + "path-prefix", + "tags", + "webhooks" + ], + "op": [ + "openapi" + ], + "p": [ + "composed", + "openapi" + ], + "param": [ + "openapi" + ], + "s": [ + "openapi" + ], + "server": [ + "default", + "openapi" + ], + "t": [ + "anon", + "composed", + "openapi" + ] + }, + "name": "UnionInlineBranches", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "UnionInlineBranches", + "canonical": "union_inline_branches" + }, + "docs": {}, + "servers": [ + "server/default" + ], + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/anon/components/schemas/Check/oneOf/0": { + "kind": "model", + "id": "t/anon/components/schemas/Check/oneOf/0", + "name": { + "hint": "variant_0" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Check/oneOf/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Check/oneOf/0/properties/transferable", + "name": { + "source": "transferable", + "canonical": "transferable" + }, + "wireName": "transferable", + "type": { + "target": "t/anon/components/schemas/Check/oneOf/0/properties/transferable", + "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/Check/oneOf/0/properties/transferable" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/components/schemas/Check/oneOf/0/properties/transferable": { + "kind": "enum", + "id": "t/anon/components/schemas/Check/oneOf/0/properties/transferable", + "name": { + "hint": "transferable" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Check/oneOf/0/properties/transferable" + }, + "valueType": "bool", + "members": [ + { + "id": "e/anon/components/schemas/Check/oneOf/0/properties/transferable/b:true", + "name": { + "source": "true", + "canonical": "true" + }, + "value": { + "kind": "bool", + "bool": true + }, + "docs": {} + } + ], + "closed": true, + "flags": false + }, + "t/anon/components/schemas/Check/oneOf/1": { + "kind": "model", + "id": "t/anon/components/schemas/Check/oneOf/1", + "name": { + "hint": "variant_1" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Check/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Check/oneOf/1/properties/transferable", + "name": { + "source": "transferable", + "canonical": "transferable" + }, + "wireName": "transferable", + "type": { + "target": "t/anon/components/schemas/Check/oneOf/1/properties/transferable", + "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/Check/oneOf/1/properties/transferable" + } + }, + { + "id": "p/openapi/components/schemas/Check/oneOf/1/properties/reasons", + "name": { + "source": "reasons", + "canonical": "reasons" + }, + "wireName": "reasons", + "type": { + "target": "t/anon/components/schemas/Check/oneOf/1/properties/reasons", + "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/Check/oneOf/1/properties/reasons" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/components/schemas/Check/oneOf/1/properties/reasons": { + "kind": "list", + "id": "t/anon/components/schemas/Check/oneOf/1/properties/reasons", + "name": { + "hint": "reasons" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Check/oneOf/1/properties/reasons" + }, + "elem": { + "target": "t/prim/string", + "nullable": false + } + }, + "t/anon/components/schemas/Check/oneOf/1/properties/transferable": { + "kind": "enum", + "id": "t/anon/components/schemas/Check/oneOf/1/properties/transferable", + "name": { + "hint": "transferable" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Check/oneOf/1/properties/transferable" + }, + "valueType": "bool", + "members": [ + { + "id": "e/anon/components/schemas/Check/oneOf/1/properties/transferable/b:false", + "name": { + "source": "false", + "canonical": "false" + }, + "value": { + "kind": "bool" + }, + "docs": {} + } + ], + "closed": true, + "flags": false + }, + "t/anon/components/schemas/Placement/oneOf/0": { + "kind": "model", + "id": "t/anon/components/schemas/Placement/oneOf/0", + "name": { + "hint": "variant_0" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Placement/oneOf/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Placement/oneOf/0/properties/region", + "name": { + "source": "region", + "canonical": "region" + }, + "wireName": "region", + "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/Placement/oneOf/0/properties/region" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/components/schemas/Placement/oneOf/1": { + "kind": "model", + "id": "t/anon/components/schemas/Placement/oneOf/1", + "name": { + "hint": "variant_1" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Placement/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Placement/oneOf/1/properties/hostname", + "name": { + "source": "hostname", + "canonical": "hostname" + }, + "wireName": "hostname", + "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/Placement/oneOf/1/properties/hostname" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/components/schemas/Check/oneOf/0": { + "kind": "model", + "id": "t/composed/components/schemas/Check/oneOf/0", + "name": { + "hint": "check_variant_0" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Check/oneOf/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Check/properties/transferable", + "name": { + "source": "transferable", + "canonical": "transferable" + }, + "wireName": "transferable", + "type": { + "target": "t/prim/bool", + "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/Check/properties/transferable" + } + } + ], + "base": { + "target": "t/anon/components/schemas/Check/oneOf/0", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/components/schemas/Check/oneOf/1": { + "kind": "model", + "id": "t/composed/components/schemas/Check/oneOf/1", + "name": { + "hint": "check_variant_1" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Check/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Check/properties/transferable", + "name": { + "source": "transferable", + "canonical": "transferable" + }, + "wireName": "transferable", + "type": { + "target": "t/prim/bool", + "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/Check/properties/transferable" + } + } + ], + "base": { + "target": "t/anon/components/schemas/Check/oneOf/1", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/components/schemas/Placement/oneOf/0": { + "kind": "model", + "id": "t/composed/components/schemas/Placement/oneOf/0", + "name": { + "hint": "placement_variant_0" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Placement/oneOf/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Placement/properties/status", + "name": { + "source": "status", + "canonical": "status" + }, + "wireName": "status", + "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/Placement/properties/status" + } + } + ], + "base": { + "target": "t/anon/components/schemas/Placement/oneOf/0", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/components/schemas/Placement/oneOf/1": { + "kind": "model", + "id": "t/composed/components/schemas/Placement/oneOf/1", + "name": { + "hint": "placement_variant_1" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Placement/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Placement/properties/status", + "name": { + "source": "status", + "canonical": "status" + }, + "wireName": "status", + "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/Placement/properties/status" + } + } + ], + "base": { + "target": "t/anon/components/schemas/Placement/oneOf/1", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Check": { + "kind": "union", + "id": "t/openapi/components/schemas/Check", + "name": { + "source": "Check", + "canonical": "check" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Check" + }, + "variants": [ + { + "name": { + "hint": "variant_0" + }, + "type": { + "target": "t/composed/components/schemas/Check/oneOf/0", + "nullable": false + }, + "docs": {} + }, + { + "name": { + "hint": "variant_1" + }, + "type": { + "target": "t/composed/components/schemas/Check/oneOf/1", + "nullable": false + }, + "docs": {} + } + ], + "exclusive": true, + "wireTagged": false, + "closed": true, + "inputOnly": false + }, + "t/openapi/components/schemas/MaybePlacement": { + "kind": "model", + "id": "t/openapi/components/schemas/MaybePlacement", + "name": { + "source": "MaybePlacement", + "canonical": "maybe_placement" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:oneOf": { + "reason": "degraded_lowering", + "value": [ + { + "properties": { + "region": { + "type": "string" + } + }, + "type": "object" + }, + { + "type": "null" + } + ], + "provenance": { + "source": 0, + "pointer": "/components/schemas/MaybePlacement/oneOf" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/MaybePlacement" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/MaybePlacement/properties/status", + "name": { + "source": "status", + "canonical": "status" + }, + "wireName": "status", + "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/MaybePlacement/properties/status" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Placement": { + "kind": "union", + "id": "t/openapi/components/schemas/Placement", + "name": { + "source": "Placement", + "canonical": "placement" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Placement" + }, + "variants": [ + { + "name": { + "hint": "variant_0" + }, + "type": { + "target": "t/composed/components/schemas/Placement/oneOf/0", + "nullable": false + }, + "docs": {} + }, + { + "name": { + "hint": "variant_1" + }, + "type": { + "target": "t/composed/components/schemas/Placement/oneOf/1", + "nullable": false + }, + "docs": {} + } + ], + "exclusive": true, + "wireTagged": false, + "closed": true, + "inputOnly": false + }, + "t/openapi/components/schemas/Region": { + "kind": "scalar", + "id": "t/openapi/components/schemas/Region", + "name": { + "source": "Region", + "canonical": "region" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Region" + }, + "base": { + "target": "t/anon/components/schemas/Placement/oneOf/0", + "nullable": false + } + }, + "t/prim/bool": { + "kind": "primitive", + "id": "t/prim/bool", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "bool" + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "string" + } + }, + "servers": { + "server/default": { + "id": "server/default", + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {}, + "provenance": { + "source": 0 + } + } + }, + "diagnostics": [ + { + "severity": "info", + "code": "openapi/composition-lowering", + "message": "oneOf/anyOf co-declared with structural keywords; the composition is distributed across the union variants", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Placement" + } + }, + { + "severity": "info", + "code": "openapi/composition-lowering", + "message": "oneOf/anyOf co-declared with structural keywords; the composition is distributed across the union variants", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Check" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "oneOf/anyOf co-declared with structural keywords intersects with them, and a branch is a bare null, which the union lifts onto the position, so no variant can conjoin the body with it; union branches kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/MaybePlacement" + } + } + ], + "sources": [ + { + "format": "openapi@3.1", + "path": "union-inline-branches.yaml", + "hash": "fa60446d65f01fb8f4a3f516d34ea82587a0e9f8b7c190daa9287de3cb631deb" + } + ] +} diff --git a/testdata/conformance/openapi/union-inline-branches.yaml b/testdata/conformance/openapi/union-inline-branches.yaml new file mode 100644 index 000000000..045dd4602 --- /dev/null +++ b/testdata/conformance/openapi/union-inline-branches.yaml @@ -0,0 +1,39 @@ +openapi: 3.1.0 +info: {title: UnionInlineBranches, version: "1.0.0"} +paths: {} +components: + schemas: + # Shared properties beside inline branches: each branch lowers at its own + # pointer, and each variant composes the body with it. + Placement: + type: object + properties: + status: {type: string} + oneOf: + - type: object + required: [region] + properties: {region: {type: string}} + - type: object + required: [hostname] + properties: {hostname: {type: string}} + # A branch may redeclare a property of the body; its node keeps its own. + Check: + type: object + required: [transferable] + properties: + transferable: {type: boolean} + oneOf: + - properties: {transferable: {type: boolean, enum: [true]}} + - properties: {transferable: {type: boolean, enum: [false]}, reasons: {type: array, items: {type: string}}} + # An outside reference to a branch pointer finds the branch node, never the + # variant composed for it. + Region: {$ref: '#/components/schemas/Placement/oneOf/0'} + # A bare null branch declines distribution: the union is kept beside the + # body. + MaybePlacement: + type: object + properties: + status: {type: string} + oneOf: + - {type: object, properties: {region: {type: string}}} + - {type: 'null'} diff --git a/testdata/conformance/openapi/union-restated-branches.golden.json b/testdata/conformance/openapi/union-restated-branches.golden.json index 227be1fa5..3b395a27d 100644 --- a/testdata/conformance/openapi/union-restated-branches.golden.json +++ b/testdata/conformance/openapi/union-restated-branches.golden.json @@ -151,6 +151,236 @@ "positional": false, "inputOnly": false }, + "t/anon/components/schemas/RenderTagged/oneOf/0": { + "kind": "model", + "id": "t/anon/components/schemas/RenderTagged/oneOf/0", + "name": { + "hint": "variant_0" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/RenderTagged/oneOf/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/RenderTagged/oneOf/0/properties/kind", + "name": { + "source": "kind", + "canonical": "kind" + }, + "wireName": "kind", + "type": { + "target": "t/anon/components/schemas/RenderTagged/oneOf/0/properties/kind", + "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/RenderTagged/oneOf/0/properties/kind" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/components/schemas/RenderTagged/oneOf/0/properties/kind": { + "kind": "literal", + "id": "t/anon/components/schemas/RenderTagged/oneOf/0/properties/kind", + "name": { + "hint": "kind" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/RenderTagged/oneOf/0/properties/kind" + }, + "value": { + "kind": "string", + "str": "html" + }, + "valueType": "string" + }, + "t/anon/components/schemas/RenderTagged/oneOf/1": { + "kind": "model", + "id": "t/anon/components/schemas/RenderTagged/oneOf/1", + "name": { + "hint": "variant_1" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/RenderTagged/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/RenderTagged/oneOf/1/properties/kind", + "name": { + "source": "kind", + "canonical": "kind" + }, + "wireName": "kind", + "type": { + "target": "t/anon/components/schemas/RenderTagged/oneOf/1/properties/kind", + "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/RenderTagged/oneOf/1/properties/kind" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/components/schemas/RenderTagged/oneOf/1/properties/kind": { + "kind": "literal", + "id": "t/anon/components/schemas/RenderTagged/oneOf/1/properties/kind", + "name": { + "hint": "kind" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/RenderTagged/oneOf/1/properties/kind" + }, + "value": { + "kind": "string", + "str": "url" + }, + "valueType": "string" + }, + "t/composed/components/schemas/RenderTagged/oneOf/0": { + "kind": "model", + "id": "t/composed/components/schemas/RenderTagged/oneOf/0", + "name": { + "hint": "render_tagged_variant_0" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/RenderTagged/oneOf/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/RenderTagged/properties/kind", + "name": { + "source": "kind", + "canonical": "kind" + }, + "wireName": "kind", + "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/RenderTagged/properties/kind" + } + } + ], + "base": { + "target": "t/anon/components/schemas/RenderTagged/oneOf/0", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/components/schemas/RenderTagged/oneOf/1": { + "kind": "model", + "id": "t/composed/components/schemas/RenderTagged/oneOf/1", + "name": { + "hint": "render_tagged_variant_1" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/RenderTagged/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/RenderTagged/properties/kind", + "name": { + "source": "kind", + "canonical": "kind" + }, + "wireName": "kind", + "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/RenderTagged/properties/kind" + } + } + ], + "base": { + "target": "t/anon/components/schemas/RenderTagged/oneOf/1", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, "t/openapi/components/schemas/Render": { "kind": "model", "id": "t/openapi/components/schemas/Render", @@ -1220,7 +1450,7 @@ } }, "t/openapi/components/schemas/RenderTagged": { - "kind": "model", + "kind": "union", "id": "t/openapi/components/schemas/RenderTagged", "name": { "source": "RenderTagged", @@ -1229,68 +1459,35 @@ "anonymous": false, "docs": {}, "sensitive": false, - "unmodeled": { - "openapi:oneOf": { - "reason": "degraded_lowering", - "value": [ - { - "properties": { - "kind": { - "const": "html", - "type": "string" - } - } - }, - { - "properties": { - "kind": { - "const": "url", - "type": "string" - } - } - } - ], - "provenance": { - "source": 0, - "pointer": "/components/schemas/RenderTagged/oneOf" - } - } - }, "provenance": { "source": 0, "pointer": "/components/schemas/RenderTagged" }, - "properties": [ + "variants": [ { - "id": "p/openapi/components/schemas/RenderTagged/properties/kind", "name": { - "source": "kind", - "canonical": "kind" + "hint": "variant_0" }, - "wireName": "kind", "type": { - "target": "t/prim/string", + "target": "t/composed/components/schemas/RenderTagged/oneOf/0", "nullable": false }, - "required": false, - "clientOptional": false, - "defaultAdded": false, - "visibility": { - "none": false + "docs": {} + }, + { + "name": { + "hint": "variant_1" }, - "flatten": false, - "eventHeader": false, - "eventPayload": false, - "secret": false, - "docs": {}, - "provenance": { - "source": 0, - "pointer": "/components/schemas/RenderTagged/properties/kind" - } + "type": { + "target": "t/composed/components/schemas/RenderTagged/oneOf/1", + "nullable": false + }, + "docs": {} } ], - "abstract": false, - "positional": false, + "exclusive": true, + "wireTagged": false, + "closed": true, "inputOnly": false }, "t/prim/any": { @@ -1382,8 +1579,8 @@ }, { "severity": "info", - "code": "openapi/degraded-construct", - "message": "oneOf/anyOf co-declared with structural keywords intersects with them, and a branch is written inline, so it names no referent to conjoin the body with; union branches kept verbatim under Unmodeled", + "code": "openapi/composition-lowering", + "message": "oneOf/anyOf co-declared with structural keywords; the composition is distributed across the union variants", "provenance": { "source": 0, "pointer": "/components/schemas/RenderTagged" @@ -1448,7 +1645,7 @@ { "format": "openapi@3.1", "path": "union-restated-branches.yaml", - "hash": "f74c1f501d4d3f5691439b4e32a4555cee72b48d14b73d89bf005580fdc8538e" + "hash": "bdb364a53332b36797d472784209bdce952911ba466d02a563b9262020cc2858" } ] } diff --git a/testdata/conformance/openapi/union-restated-branches.yaml b/testdata/conformance/openapi/union-restated-branches.yaml index ee5f4f77b..5afbd8593 100644 --- a/testdata/conformance/openapi/union-restated-branches.yaml +++ b/testdata/conformance/openapi/union-restated-branches.yaml @@ -45,7 +45,7 @@ components: properties: {html: {type: string}, url: {type: string}} not: {required: [url], type: object, properties: {url: {type: string}}} # A const says more than the type beside it, so these branches pick - # variants and the union degrades as any inline branch does. + # variants, and the body is distributed across them. RenderTagged: type: object properties: {kind: {type: string}} diff --git a/testdata/conformance/openapi/union-restated-type.golden.json b/testdata/conformance/openapi/union-restated-type.golden.json new file mode 100644 index 000000000..b8a3ae495 --- /dev/null +++ b/testdata/conformance/openapi/union-restated-type.golden.json @@ -0,0 +1,550 @@ +{ + "idSpaces": { + "auth": [ + "openapi", + "unresolved" + ], + "e": [ + "anon", + "composed", + "openapi" + ], + "g": [ + "default", + "path-prefix", + "tags", + "webhooks" + ], + "op": [ + "openapi" + ], + "p": [ + "composed", + "openapi" + ], + "param": [ + "openapi" + ], + "s": [ + "openapi" + ], + "server": [ + "default", + "openapi" + ], + "t": [ + "anon", + "composed", + "openapi" + ] + }, + "name": "UnionRestatedType", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "UnionRestatedType", + "canonical": "union_restated_type" + }, + "docs": {}, + "servers": [ + "server/default" + ], + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/anon/components/schemas/Entry/oneOf/1": { + "kind": "model", + "id": "t/anon/components/schemas/Entry/oneOf/1", + "name": { + "hint": "variant_1" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Entry/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Entry/oneOf/1/properties/id", + "name": { + "source": "id", + "canonical": "id" + }, + "wireName": "id", + "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/Entry/oneOf/1/properties/id" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/components/schemas/Ttl/anyOf/0": { + "kind": "scalar", + "id": "t/anon/components/schemas/Ttl/anyOf/0", + "name": { + "hint": "variant_0" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Ttl/anyOf/0" + }, + "base": { + "target": "t/prim/number", + "nullable": false + }, + "constraints": { + "min": "30", + "max": "86400", + "uniqueItems": false + } + }, + "t/anon/components/schemas/Ttl/anyOf/1": { + "kind": "enum", + "id": "t/anon/components/schemas/Ttl/anyOf/1", + "name": { + "hint": "variant_1" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Ttl/anyOf/1" + }, + "valueType": "number", + "members": [ + { + "id": "e/anon/components/schemas/Ttl/anyOf/1/n:1e0", + "name": { + "source": "1", + "canonical": "1" + }, + "value": { + "kind": "number", + "num": "1" + }, + "docs": {} + } + ], + "closed": true, + "flags": false + }, + "t/composed/components/schemas/Target/oneOf/0": { + "kind": "model", + "id": "t/composed/components/schemas/Target/oneOf/0", + "name": { + "hint": "target_item" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Target/oneOf/0" + }, + "requires": [ + "name" + ], + "base": { + "target": "t/openapi/components/schemas/Item", + "nullable": false + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Address": { + "kind": "union", + "id": "t/openapi/components/schemas/Address", + "name": { + "source": "Address", + "canonical": "address" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Address" + }, + "variants": [ + { + "name": { + "hint": "v_4" + }, + "type": { + "target": "t/openapi/components/schemas/V4", + "nullable": false + }, + "docs": {} + }, + { + "name": { + "hint": "v_6" + }, + "type": { + "target": "t/openapi/components/schemas/V6", + "nullable": false + }, + "docs": {} + } + ], + "exclusive": false, + "wireTagged": false, + "closed": true, + "inputOnly": false + }, + "t/openapi/components/schemas/Entry": { + "kind": "union", + "id": "t/openapi/components/schemas/Entry", + "name": { + "source": "Entry", + "canonical": "entry" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Entry" + }, + "variants": [ + { + "name": { + "hint": "item" + }, + "type": { + "target": "t/openapi/components/schemas/Item", + "nullable": false + }, + "docs": {} + }, + { + "name": { + "hint": "variant_1" + }, + "type": { + "target": "t/anon/components/schemas/Entry/oneOf/1", + "nullable": false + }, + "docs": {} + } + ], + "exclusive": true, + "wireTagged": false, + "closed": true, + "inputOnly": false + }, + "t/openapi/components/schemas/Item": { + "kind": "model", + "id": "t/openapi/components/schemas/Item", + "name": { + "source": "Item", + "canonical": "item" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Item" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Item/properties/name", + "name": { + "source": "name", + "canonical": "name" + }, + "wireName": "name", + "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/Item/properties/name" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/MaybeAddress": { + "kind": "scalar", + "id": "t/openapi/components/schemas/MaybeAddress", + "name": { + "source": "MaybeAddress", + "canonical": "maybe_address" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/MaybeAddress" + }, + "base": { + "target": "t/openapi/components/schemas/V4", + "nullable": true + } + }, + "t/openapi/components/schemas/Mixed": { + "kind": "scalar", + "id": "t/openapi/components/schemas/Mixed", + "name": { + "source": "Mixed", + "canonical": "mixed" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:oneOf": { + "reason": "degraded_lowering", + "value": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "provenance": { + "source": 0, + "pointer": "/components/schemas/Mixed/oneOf" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Mixed" + }, + "base": { + "target": "t/prim/string", + "nullable": false + } + }, + "t/openapi/components/schemas/Target": { + "kind": "union", + "id": "t/openapi/components/schemas/Target", + "name": { + "source": "Target", + "canonical": "target" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Target" + }, + "variants": [ + { + "name": { + "hint": "item" + }, + "type": { + "target": "t/composed/components/schemas/Target/oneOf/0", + "nullable": false + }, + "docs": {} + } + ], + "exclusive": true, + "wireTagged": false, + "closed": true, + "inputOnly": false + }, + "t/openapi/components/schemas/Ttl": { + "kind": "union", + "id": "t/openapi/components/schemas/Ttl", + "name": { + "source": "Ttl", + "canonical": "ttl" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Ttl" + }, + "variants": [ + { + "name": { + "hint": "variant_0" + }, + "type": { + "target": "t/anon/components/schemas/Ttl/anyOf/0", + "nullable": false + }, + "docs": {} + }, + { + "name": { + "hint": "variant_1" + }, + "type": { + "target": "t/anon/components/schemas/Ttl/anyOf/1", + "nullable": false + }, + "docs": {} + } + ], + "exclusive": false, + "wireTagged": false, + "closed": true, + "inputOnly": false + }, + "t/openapi/components/schemas/V4": { + "kind": "scalar", + "id": "t/openapi/components/schemas/V4", + "name": { + "source": "V4", + "canonical": "v_4" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/V4" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "encoding": { + "name": "ipv4" + } + }, + "t/openapi/components/schemas/V6": { + "kind": "scalar", + "id": "t/openapi/components/schemas/V6", + "name": { + "source": "V6", + "canonical": "v_6" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/V6" + }, + "base": { + "target": "t/prim/string", + "nullable": false + }, + "encoding": { + "name": "ipv6" + } + }, + "t/prim/number": { + "kind": "primitive", + "id": "t/prim/number", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "number" + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "string" + } + }, + "servers": { + "server/default": { + "id": "server/default", + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {}, + "provenance": { + "source": 0 + } + } + }, + "diagnostics": [ + { + "severity": "info", + "code": "openapi/composition-lowering", + "message": "oneOf/anyOf co-declared with structural keywords; the composition is distributed across the union variants", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Target" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "oneOf/anyOf co-declared with structural keywords intersects with them, and the body is not a model, so it carries no composition to distribute into; union branches kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Mixed" + } + } + ], + "sources": [ + { + "format": "openapi@3.1", + "path": "union-restated-type.yaml", + "hash": "63434fb749c19f6842af4e30afbf55a8112f3622dfebdaaeb6a3b78ce328af30" + } + ] +} diff --git a/testdata/conformance/openapi/union-restated-type.yaml b/testdata/conformance/openapi/union-restated-type.yaml new file mode 100644 index 000000000..0788d16c5 --- /dev/null +++ b/testdata/conformance/openapi/union-restated-type.yaml @@ -0,0 +1,46 @@ +openapi: 3.1.0 +info: {title: UnionRestatedType, version: "1.0.0"} +paths: {} +components: + schemas: + V4: {type: string, format: ipv4} + V6: {type: string, format: ipv6} + # Every branch is a string, so the `type` beside them says nothing they do + # not, and the schema is the union alone. + Address: + type: string + anyOf: + - $ref: '#/components/schemas/V4' + - $ref: '#/components/schemas/V6' + # The same with inline branches, a range and a value. + Ttl: + type: number + anyOf: + - {type: number, minimum: 30, maximum: 86400} + - {type: number, enum: [1]} + # A type admitting null beside a branch that is null: the union alone, which + # collapses to its other branch, nullable. + MaybeAddress: + type: [string, 'null'] + oneOf: + - $ref: '#/components/schemas/V4' + - {type: 'null'} + Item: {type: object, properties: {name: {type: string}}} + # `type: object` beside branches that are all objects is the union of them, + # not a union of models composed over each. + Entry: + type: object + oneOf: + - $ref: '#/components/schemas/Item' + - {type: object, properties: {id: {type: string}}} + # A bare `required` beside object branches composes each with the names it + # requires. + Target: + required: [name] + oneOf: + - $ref: '#/components/schemas/Item' + # A branch with a type of its own beside `type: string` keeps the body: the + # string is the meet, and the union is kept beside it. + Mixed: + type: string + oneOf: [{type: string}, {type: number}]