diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 17ae7d9..976fc95 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -153,6 +153,7 @@ func conformanceCases() []conformanceCase { {"discriminator-default-mapping", assertDiscriminatorDefaultMapping}, {"unhomed-keywords", assertUnhomedKeywords}, {"codeclared-keywords", assertCoDeclaredKeywords}, + {"codeclared-schema-content", assertCoDeclaredSchemaContent}, {"anyof-untagged", assertAnyOfUntagged}, {"negation-not", assertNegationNot}, {"dependent-required", assertDependentRequired}, diff --git a/compilers/openapi/conformance_unmodeled_test.go b/compilers/openapi/conformance_unmodeled_test.go index 7244a8c..2f3bdd4 100644 --- a/compilers/openapi/conformance_unmodeled_test.go +++ b/compilers/openapi/conformance_unmodeled_test.go @@ -7,6 +7,7 @@ package openapi_test // external test package — exercises only the public API import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -457,3 +458,51 @@ func assertKeptRaw(t *testing.T, p ir.Unmodeled, key, want string) { assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) assert.JSONEq(t, want, string(entry.Value)) } + +// assertCoDeclaredSchemaContent covers the election at the other pair of +// positions: a parameter and a header may state their type as `schema` or as +// `content`, and OpenAPI forbids both. `content` is elected at both — it names a +// media type the IR models, which the schema spelling has none of — and the +// passed-over schema is kept verbatim rather than dropped, which is what the two +// positions each did in silence, in opposite directions (GitHub #320). +func assertCoDeclaredSchemaContent(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + op, ok := opByName(doc, "getX") + require.True(t, ok) + require.Len(t, op.Bindings.HTTP, 1) + binding := indexByParam(op.Bindings.HTTP[0].ParamBindings) + + base := "/paths/~1x/get/parameters/" + for i, name := range []string{"p", "q"} { + param, found := paramByName(op, name) + require.True(t, found, "parameter %s", name) + assert.Equal(t, ir.TypeID("t/prim/string"), param.Type.Target, + "parameter %s takes its type from the elected content entry, not from the schema", name) + assert.Equal(t, "application/json", binding[name].ContentType, + "and the media type that entry names reaches the binding") + assertKeptRaw(t, param.Unmodeled, "openapi:schema", `{"type":"integer"}`) + assert.Equal(t, []ir.Severity{ir.SeverityWarning}, + diagsAt(diags, "openapi/degraded-construct", fmt.Sprintf("%s%d/schema", base, i)), + "parameter %s announces the spelling it passed over, at that spelling's own node", name) + } + + require.Len(t, op.Responses, 1) + header, ok := headerByWire(op.Responses[0].Headers, "X-H") + require.True(t, ok) + assert.Equal(t, ir.TypeID("t/prim/string"), header.Type.Target, + "the header elects content too: one order, not one per position") + require.NotNil(t, header.Encoding) + assert.Equal(t, "application/json", header.Encoding.MediaType) + assertKeptRaw(t, header.Unmodeled, "openapi:schema", `{"type":"integer"}`) + assert.Equal(t, []ir.Severity{ir.SeverityWarning}, + diagsAt(diags, "openapi/degraded-construct", "/paths/~1x/get/responses/200/headers/X-H/schema")) +} + +// indexByParam indexes HTTP parameter bindings by the logical parameter they +// bind. +func indexByParam(bindings []ir.HTTPParamBinding) map[string]ir.HTTPParamBinding { + out := make(map[string]ir.HTTPParamBinding, len(bindings)) + for _, b := range bindings { + out[b.Param] = b + } + return out +} diff --git a/compilers/openapi/internal/operation/content.go b/compilers/openapi/internal/operation/content.go index c202731..5226c85 100644 --- a/compilers/openapi/internal/operation/content.go +++ b/compilers/openapi/internal/operation/content.go @@ -392,8 +392,8 @@ func reservedHeaderEntryDiag(c lowering.Ctx, name, hptr string) []ir.Diagnostic // and ir.Property has a field for each, so the header path had no reason to drop // them (GitHub #116). func lowerHeader(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, h *soa.Header, name, hptr, hdecl string) (ir.Property, []ir.Diagnostic) { - js, schemaPtr, mediaType, diags := headerSchema(c, h, hdecl) - headerType, headerDiags := schema.CarriedRef(c, ts, anchors, schema.TopLevelDepth, js, schemaPtr, ids.DeclarationHint(hdecl, name)) + elected, diags := electTypeSpelling(c, h.GetSchema(), h.GetContent(), h.GetRootNode(), hdecl) + headerType, headerDiags := schema.CarriedRef(c, ts, anchors, schema.TopLevelDepth, elected.js, elected.pointer, ids.DeclarationHint(hdecl, name)) diags = append(diags, headerDiags...) p := ir.Property{ ID: ids.Prop(hptr), @@ -402,15 +402,16 @@ func lowerHeader(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, Type: headerType, Required: h.GetRequired(), Provenance: c.ProvenanceAt(hptr), + Unmodeled: elected.unmodeled, } - if mediaType != "" { + if elected.mediaType != "" { // The media type a content-style header serializes its value in, which is // what ir.Encoding.MediaType holds. Nothing else on this path writes // Property.Encoding, so the content spelling loses nothing the schema // spelling keeps. - p.Encoding = &ir.Encoding{MediaType: mediaType} + p.Encoding = &ir.Encoding{MediaType: elected.mediaType} } - diags = append(diags, schema.FillPropertyDetail(c, ts, anchors, &p, js, schemaPtr)...) + diags = append(diags, schema.FillPropertyDetail(c, ts, anchors, &p, elected.js, elected.pointer)...) diags = append(diags, applyHeaderAnnotations(c, &p, h, hdecl)...) return p, append(diags, preserveHeaderSerialization(c, &p, h, hdecl)...) } @@ -443,24 +444,91 @@ func preserveHeaderSerialization(c lowering.Ctx, p *ir.Property, h *soa.Header, return diags } -// headerSchema returns the schema a header declares, the pointer that schema sits -// at, and the media type serializing it — empty for the schema spelling. +// typeSpelling is how a parameter or header stated its type: the schema node, +// the pointer that node sits at, the media type serializing it — empty for the +// `schema` spelling — and whatever the election passed over, for the carrier at +// this position to merge onto its own Unmodeled. +type typeSpelling struct { + js *oas3.JSONSchema[oas3.Referenceable] + pointer string + mediaType string + unmodeled ir.Unmodeled +} + +// electTypeSpelling picks the spelling a parameter or header states its type +// with, and keeps the other verbatim where the document writes both. root is the +// declaring object's node and at is the pointer it sits at. // -// OpenAPI lets a header state its type as either `schema` or a `content` map -// holding exactly one entry, and only the first spelling was read: a -// content-style header lowered as if it had no schema at all, discarding its -// type, its constraints and its xml hints together and without a diagnostic -// (GitHub #139). The parameter path already read both (fillParamType), which is -// why request headers never showed the defect. -func headerSchema(c lowering.Ctx, h *soa.Header, hdecl string) (*oas3.JSONSchema[oas3.Referenceable], string, string, []ir.Diagnostic) { - if js := h.GetSchema(); js != nil { - return js, hdecl + ids.Ptr("schema"), "", nil - } - mt, media, ok, diags := singleContentEntry(c, h.GetContent(), hdecl) - if !ok { - return nil, hdecl + ids.Ptr("schema"), "", diags +// OpenAPI says a parameter — and a header, which follows the parameter rules — +// MUST contain either a `schema` property or a `content` property, but not both. +// Neither position can lower both, since ir.Parameter and ir.Property each hold +// one type, so a document writing both needs the election §4.8 already applies +// to competing keywords elsewhere (schema.dispatchOf): one form lowers and every +// passed-over one is kept verbatim beside it rather than dropped. +// +// `content` wins because it is the more expressive of the two. A media-type +// entry carries a schema *and* the media type serializing it, and both have IR +// homes at these positions — HTTPParamBinding.ContentType and +// Property.Encoding.MediaType — so electing it leaves nothing modelled behind, +// where electing `schema` would push a declared wire fact the IR does model into +// an opaque Unmodeled payload. The specification is no help in choosing: 3.1 +// names `schema` first in the very sentence forbidding both and 3.2 names +// `content` first, and a prohibition states no precedence in either order. +// +// The two positions used to disagree, and only one of the orders was a decision: +// fillParamType read `content` first from the start, while the header path read +// `schema` first because it read nothing else until a content arm was appended +// below it (GitHub #139). One order now governs both (GitHub #320). +func electTypeSpelling(c lowering.Ctx, js *oas3.JSONSchema[oas3.Referenceable], + content *sequencedmap.Map[string, *soa.MediaType], root *yaml.Node, at string, +) (typeSpelling, []ir.Diagnostic) { + // A content parameter or header declares exactly one media type; + // singleContentEntry takes it and reports a document that declares more, + // rather than dropping the extras in silence (GitHub #139). + mt, media, ok, diags := singleContentEntry(c, content, at) + if ok { + elected := typeSpelling{ + js: media.GetSchema(), + pointer: at + ids.Ptr("content", mt, "schema"), + mediaType: mt, + } + diags = append(diags, passedOverSpelling(c, &elected.unmodeled, root, "schema", "content", at)...) + return elected, diags + } + elected := typeSpelling{js: js, pointer: at + ids.Ptr("schema")} + if js == nil { + // Neither spelling states a type — a header carrying only a description, + // or a `content` map naming no usable entry — so there is no winner, and + // nothing was passed over for one. + return elected, diags + } + diags = append(diags, passedOverSpelling(c, &elected.unmodeled, root, "content", "schema", at)...) + return elected, diags +} + +// passedOverSpelling keeps verbatim the spelling the election passed over and +// reports it once, naming both. A document that wrote only the elected one +// records nothing and says nothing: RawChildNode returns nil for an absent +// keyword and PreserveNode keeps nothing for a nil node. +// +// ReasonDegradedLowering, as recordSkippedFamilies uses for the keyword families +// its own election passes over — the position lowered to one of two co-declared +// forms with the other kept beside it. Warning rather than the info announcing a +// conjunction JSON Schema allows, because this is one OpenAPI forbids: the same +// severity singleContentEntry reports a content map of more than one entry at, +// for the same reason. Not an error, since the document lowers as well as an +// election can make it and harness.Check stops at the first error diagnostic, +// which would hide every later finding in the same spec. +func passedOverSpelling(c lowering.Ctx, u *ir.Unmodeled, root *yaml.Node, passed, elected, at string) []ir.Diagnostic { + pointer := at + ids.Ptr(passed) + kept, diags := schema.PreserveNode(c, u, "openapi:"+passed, + annotation.RawChildNode(root, passed), ir.ReasonDegradedLowering, pointer) + if !kept { + return diags } - return media.GetSchema(), hdecl + ids.Ptr("content", mt, "schema"), mt, diags + return append(diags, c.DiagAt(ir.SeverityWarning, diag.DegradedConstruct, pointer, + "a parameter or header declares either schema or content, not both; this one declares "+ + "both, so it lowered as its %s, with %s kept verbatim under Unmodeled", elected, passed)) } // singleContentEntry returns the one entry a content-style header or parameter diff --git a/compilers/openapi/internal/operation/content_test.go b/compilers/openapi/internal/operation/content_test.go index 4273d5d..4294976 100644 --- a/compilers/openapi/internal/operation/content_test.go +++ b/compilers/openapi/internal/operation/content_test.go @@ -1251,10 +1251,10 @@ func TestSingleContentEntry_OneEntryIsSilent(t *testing.T) { "one media type is the legal spelling: %+v", diags) } -// TestHeaderSchema_NeitherSpelling covers a header that declares no type at all. -// It is legal — a header may carry only a description — and must lower to the top -// type without reporting a loss, since nothing was written to lose. -func TestHeaderSchema_NeitherSpelling(t *testing.T) { +// TestElectTypeSpelling_NeitherSpelling covers a header that declares no type at +// all. It is legal — a header may carry only a description — and must lower to +// the top type without reporting a loss, since nothing was written to lose. +func TestElectTypeSpelling_NeitherSpelling(t *testing.T) { t.Parallel() doc, diags := parseFull(t, pathsSpec(" /x:\n get:\n operationId: untypedHeader\n responses:\n"+ " \"200\":\n description: ok\n headers:\n"+ @@ -1413,3 +1413,191 @@ func TestExample_ExternalValueOnlyIsCarried(t *testing.T) { assert.Equal(t, "https://e.example/one.json", examples[0].ExternalURL) assert.Nil(t, examples[0].Value, "and carries no inline value") } + +// TestElectTypeSpelling_CoDeclaredSpellingsElectContent covers the invalid +// document OpenAPI forbids at the two positions one rule governs: a parameter, +// and a header which follows the parameter rules, declaring `schema` and +// `content` together. Each holds one type, so one spelling is elected and the +// other kept verbatim beside it — where both positions used to take one in +// silence, and in opposite directions (GitHub #320). +func TestElectTypeSpelling_CoDeclaredSpellingsElectContent(t *testing.T) { + t.Parallel() + tests := []struct { + name string + spec string + at string + elected func(*testing.T, *ir.Document) (ir.TypeID, string, ir.Unmodeled) + }{ + { + name: "operation parameter", + spec: pathsSpec(` /x: + get: + operationId: getX + parameters: + - name: p + in: query + schema: {type: integer} + content: + application/json: {schema: {type: string}} + responses: {"200": {description: ok}} +`), + at: "/paths/~1x/get/parameters/0", + elected: electedParam, + }, + { + name: "response header", + spec: pathsSpec(` /x: + get: + operationId: getX + responses: + "200": + description: ok + headers: + X-H: + schema: {type: integer} + content: + application/json: {schema: {type: string}} +`), + at: "/paths/~1x/get/responses/200/headers/X-H", + elected: electedHeader, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, tc.spec) + requireNoErrorDiags(t, diags) + target, mediaType, unmodeled := tc.elected(t, doc) + + assert.Equal(t, ir.TypeID("t/prim/string"), target, + "the content entry's schema is the type, not the schema written beside it") + assert.Equal(t, "application/json", mediaType, + "and the media type it names reaches the field that models it") + + entry, ok := unmodeled["openapi:schema"] + require.True(t, ok, "the passed-over schema is kept verbatim; got %v", unmodeled) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.JSONEq(t, `{"type":"integer"}`, string(entry.Value)) + assert.Equal(t, tc.at+"/schema", entry.Provenance.Pointer, + "located at the keyword itself, not at the object that carried it") + + msg := diagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, tc.at+"/schema") + assert.Contains(t, msg, "lowered as its content", "the message names the elected spelling") + assert.Contains(t, msg, "with schema kept verbatim", "and the one it passed over") + }) + } +} + +// electedParam reads what the election left on the operation's single +// parameter: its type, the media type on its binding, and its Unmodeled. +func electedParam(t *testing.T, doc *ir.Document) (ir.TypeID, string, ir.Unmodeled) { + t.Helper() + op := findOp(t, doc, "getX") + require.Len(t, op.Params, 1) + require.Len(t, op.Bindings.HTTP, 1) + require.Len(t, op.Bindings.HTTP[0].ParamBindings, 1) + return op.Params[0].Type.Target, op.Bindings.HTTP[0].ParamBindings[0].ContentType, op.Params[0].Unmodeled +} + +// electedHeader reads the same three things off the operation's single response +// header, where the media type has a different home. +func electedHeader(t *testing.T, doc *ir.Document) (ir.TypeID, string, ir.Unmodeled) { + t.Helper() + responses := findOp(t, doc, "getX").Responses + require.Len(t, responses, 1) + headers := responses[0].Headers + require.Len(t, headers, 1) + require.NotNil(t, headers[0].Encoding, "a content-style header records its media type") + return headers[0].Type.Target, headers[0].Encoding.MediaType, headers[0].Unmodeled +} + +// TestElectTypeSpelling_SoleSpellingReportsNothing is the control. A position +// that writes one spelling has no election to make, so it must keep nothing and +// say nothing — otherwise every well-formed parameter and header in every +// document would carry a residue entry and a warning. +func TestElectTypeSpelling_SoleSpellingReportsNothing(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, pathsSpec(` /x: + get: + operationId: getX + parameters: + - name: bySchema + in: query + schema: {type: string} + - name: byContent + in: query + content: + application/json: {schema: {type: string}} + responses: + "200": + description: ok + headers: + X-Schema: {schema: {type: string}} + X-Content: + content: + application/json: {schema: {type: string}} +`)) + requireNoErrorDiags(t, diags) + assert.Equal(t, 0, countDiagsAt(diags, diag.DegradedConstruct, ir.SeverityWarning), + "one spelling is the legal form, so there is no election to announce: %+v", diags) + + op := findOp(t, doc, "getX") + require.Len(t, op.Params, 2) + for _, p := range op.Params { + assertNoPassedOverSpelling(t, p.Unmodeled, p.Name.Source) + } + require.Len(t, op.Responses, 1) + require.Len(t, op.Responses[0].Headers, 2) + for _, h := range op.Responses[0].Headers { + assertNoPassedOverSpelling(t, h.Unmodeled, h.WireName) + } +} + +// assertNoPassedOverSpelling requires that neither spelling was kept verbatim at +// a position that declared only one of them. +func assertNoPassedOverSpelling(t *testing.T, u ir.Unmodeled, at string) { + t.Helper() + assert.NotContains(t, u, "openapi:schema", "%s kept a schema it never passed over", at) + assert.NotContains(t, u, "openapi:content", "%s kept a content map it never passed over", at) +} + +// TestElectTypeSpelling_UnusableContentElectsSchemaAndKeepsIt covers the other +// direction. A `content` map yielding no entry states no type, so the schema is +// elected instead — but the document declared both spellings either way, and the +// one passed over is kept and named exactly as it is when content wins. +// +// The library's validator reports the empty map beside this, which is the same +// invalidity seen from its angle rather than a second finding: Compile lowers the +// whole document rather than stopping at it, so the assertions below are reached. +func TestElectTypeSpelling_UnusableContentElectsSchemaAndKeepsIt(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, pathsSpec(` /x: + get: + operationId: getX + parameters: + - name: p + in: query + schema: {type: integer} + content: {} + responses: {"200": {description: ok}} +`)) + op := findOp(t, doc, "getX") + require.Len(t, op.Params, 1) + assert.Equal(t, ir.TypeID("t/prim/integer"), op.Params[0].Type.Target, + "the schema is the type, since the content map names none") + require.Len(t, op.Bindings.HTTP, 1) + require.Len(t, op.Bindings.HTTP[0].ParamBindings, 1) + assert.Empty(t, op.Bindings.HTTP[0].ParamBindings[0].ContentType, + "and there is no media type to record") + + at := "/paths/~1x/get/parameters/0/content" + entry, ok := op.Params[0].Unmodeled["openapi:content"] + require.True(t, ok, "the passed-over content map is kept verbatim; got %v", op.Params[0].Unmodeled) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.JSONEq(t, `{}`, string(entry.Value)) + assert.Equal(t, at, entry.Provenance.Pointer) + + msg := diagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, at) + assert.Contains(t, msg, "lowered as its schema", "the message names the elected spelling") + assert.Contains(t, msg, "with content kept verbatim", "and the one it passed over") +} diff --git a/compilers/openapi/internal/operation/params.go b/compilers/openapi/internal/operation/params.go index e0875ee..fb1905e 100644 --- a/compilers/openapi/internal/operation/params.go +++ b/compilers/openapi/internal/operation/params.go @@ -90,28 +90,19 @@ func reservedHeaderParamDiag(c lowering.Ctx, name string, in soa.ParameterIn, pp return nil } -// fillParamType lowers a parameter's type from either its schema or, for a -// content-style parameter, its single media-type entry (recording the media -// type on the binding). Constraints come from that same schema position; -// the default comes from it too, falling back to its $ref target (§14). +// fillParamType lowers a parameter's type from the spelling electTypeSpelling +// elects — its schema, or the single media-type entry of a content-style +// parameter, whose media type goes on the binding. Constraints come from that +// same schema position; the default comes from it too, falling back to its $ref +// target (§14). func fillParamType(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, param *ir.Parameter, binding *ir.HTTPParamBinding, p *soa.Parameter, pptr, name string) []ir.Diagnostic { - // A content parameter declares exactly one media type; singleContentEntry - // takes it and reports a document that declares more, rather than dropping the - // extras in the silence the header spelling was fixed out of (GitHub #139). - mt, media, ok, diags := singleContentEntry(c, p.GetContent(), pptr) - if ok { - schemaPtr := pptr + ids.Ptr("content", mt, "schema") - contentType, contentDiags := schema.CarriedRef(c, ts, anchors, schema.TopLevelDepth, media.GetSchema(), schemaPtr, name) - diags = append(diags, contentDiags...) - param.Type = contentType - binding.ContentType = mt - return append(diags, fillParamSchema(c, ts, param, media.GetSchema(), schemaPtr)...) - } - schemaPtr := pptr + ids.Ptr("schema") - paramType, paramDiags := schema.CarriedRef(c, ts, anchors, schema.TopLevelDepth, p.GetSchema(), schemaPtr, name) - diags = append(diags, paramDiags...) + elected, diags := electTypeSpelling(c, p.GetSchema(), p.GetContent(), p.GetRootNode(), pptr) + paramType, typeDiags := schema.CarriedRef(c, ts, anchors, schema.TopLevelDepth, elected.js, elected.pointer, name) + diags = append(diags, typeDiags...) param.Type = paramType - return append(diags, fillParamSchema(c, ts, param, p.GetSchema(), schemaPtr)...) + param.Unmodeled = annotation.MergeUnmodeled(param.Unmodeled, elected.unmodeled) + binding.ContentType = elected.mediaType + return append(diags, fillParamSchema(c, ts, param, elected.js, elected.pointer)...) } // fillParamSchema reads a parameter schema's default value and scalar diff --git a/docs/ir-design.md b/docs/ir-design.md index a72ceea..7527fe4 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1698,7 +1698,7 @@ How each format's distinctive concepts land in the IR (full details live with ea | Format | Lowering highlights | |---|---| -| **OpenAPI 3.x** | components/schemas → registry (IDs from pointers); inline schemas hoisted with hints; `allOf` → Base/Mixins per §4.3; `oneOf`/`anyOf` → Union (Exclusive bit), null-variant → Nullable ref, co-declared with structural keywords → the composition distributed across the variants per §4.3, or — for the five shapes that cannot be distributed — structural body + verbatim union per §4.8 (branches that declare no shape at all are `validation_only` per §4.7); competing keywords at one position — `const`/`enum`/`allOf`, elected in that order, and `oneOf` beside `anyOf`, where oneOf wins — lower as the elected keyword with every passed-over one verbatim Unmodeled (`degraded_lowering`) at its own pointer per §4.8, and a `{X, null}` oneOf beside an anyOf stays a Union rather than collapsing to a nullable ref; `discriminator` → Discriminator (3.2 `defaultMapping` → Discriminator.Default); `nullable`/type-arrays → Nullable; readOnly/writeOnly → Visibility and schema-level `default` → the referencing Property/Parameter's Default: both bind a *use* of the type rather than the type, so a declaration-site one is pushed down to referencing properties with use-site precedence and the declaration keeps its own copy verbatim (`no_ir_home` Unmodeled + diagnostic) — which is what a component nothing references would otherwise lose silently; `additionalProperties: false` → Additional=closed, `unevaluatedProperties: false` → closed_after_composition, `minProperties`/`maxProperties` → Model.Constraints (the property set's cardinality, as against Additional's openness); parameters → Params + HTTPBinding locations w/ style/explode, `allowEmptyValue` → Parameter.Unmodeled (`no_ir_home`: HTTPParamBinding holds its neighbours but not this one), a header parameter named Accept/Content-Type/Authorization lowered as declared + `reserved-header-name` warning (OpenAPI says such a definition SHALL be ignored; dropping declared content is an emitter's call, not a compiler's), 3.2 `in: querystring` → querystring location; requestBody/responses all content types → Payload.Contents, a non-required requestBody → Payload.Unmodeled (`no_ir_home`, presence is the IR's only body optionality); 3.2 `itemSchema` → Content.Item and `itemEncoding` → Content.ItemEncoding — except beside a positional `prefixEncoding`, where both go to Content.Unmodeled (`no_ir_home`) because a single every-item encoding cannot state ordinals; per-status responses/default → Conditions + ranges, error-response `headers` and its `content` map whatever its arity → ErrorCase.Unmodeled (`no_ir_home`, ErrorCase has neither field: it holds one TypeRef and no media type, so one entry loses the key it was written under just as several lose all but the first); response/encoding header `style` and `explode` → Property.Unmodeled (`no_ir_home`, ir.Property has neither field), and a `Content-Type` entry in either headers map lowered as declared + the same `reserved-header-name` warning the parameter position gets; webhooks → HTTPBinding.IsWebhook; callbacks → Callbacks; links → Response.Unmodeled (`no_ir_home`, promotable later); path-item `servers`, under `paths`, `webhooks` and a callback expression alike → Operation.Unmodeled (`no_ir_home`: §10 scopes servers by index list at service and channel, and an operation has no such list yet), with an operation's own `servers` — which OpenAPI says override the path item's — kept beside them under `openapi:operationServers`, the one key here not named for the keyword it holds, since two declarations at two pointers cannot share one map key without the survivor depending on lowering order; securitySchemes/security → Auth OR-of-ANDs, 3.2 device flow + `oauth2MetadataUrl` → Flows/OAuth2MetadataURL; servers+variables (3.2 named) → Servers; tags (3.2 parent/kind) → groups + TagDefs; info contact/license → Document; schema `example(s)` → Examples; `xml` object (incl. 3.2 nodeType) → XMLHints at type and property level; `not`/`if-then-else`/`dependentSchemas`/`dependentRequired`/`contains`/`propertyNames`/`unevaluated*` → verbatim Unmodeled per §4.7; `contentEncoding`/`contentMediaType` → `Encoding` on the scalar the position lowers to and `contentSchema` → Unmodeled (`no_ir_home`) per §4.7; `$id`/`$schema`/`$vocabulary` → Unmodeled (`out_of_scope`, `$id` not honoured for resolution); `$dynamicRef` → the anchored type by compiler expansion, else verbatim Unmodeled with the reason it was irreducible; an inline `allOf` branch declaring more than the merge consumes → verbatim Unmodeled (`degraded_lowering`) per §4.8; a boolean `false` `allOf` branch → the composed Model closed, branch verbatim Unmodeled (`degraded_lowering`) per §4.8, a `true` branch a silent no-op; a shape applicator (`properties`/`patternProperties`/`additionalProperties`/`required`/`items`/`prefixItems`, and `format` where no type is declared) the lowered node has no field for → verbatim Unmodeled (`degraded_lowering`) per §4.8; a parameter schema's `xml` and its `readOnly`/`writeOnly` → Parameter.Unmodeled (`no_ir_home`: Parameter has no field for either); `patternProperties` → AdditionalProps.Patterns; `prefixItems` → Tuple, with any trailing `items` → Tuple.Unmodeled (`degraded_lowering` per §4.8: an open tuple has no IR combinator, so the fixed head is lowered and the tail kept beside it); `x-*` → namespaced Unmodeled (legal on every object — hence Unmodeled on every node); `$ref`-adjacent sibling keywords (3.1) and ref-target annotations merge onto the referencing Property/Parameter with **use-site precedence**, applied uniformly (oagen's ad-hoc per-site patching is the counterexample), and at a position carrying no Property/Parameter — an `allOf`/`oneOf`/`anyOf` branch, `items`, a component — bind an alias hoisted at that position instead, per §4.3; a oneOf/anyOf whose variants are all string consts normalizes to a closed `Enum` in a `pass/` normalization — not in the compiler — so per-variant `Docs` survive until the collapse is chosen; mutually-exclusive parameter groups (`x-mutually-exclusive-parameter-groups`) stay as namespaced Unmodeled entries, and their documented *promotion* (no dedicated node needed) is a pass that synthesizes one logical `Parameter` typed by a `Union` of variant models, bound via `HTTPParamBinding.ParamPath` per field; pagination only via injectable policy, marked Inferred | +| **OpenAPI 3.x** | components/schemas → registry (IDs from pointers); inline schemas hoisted with hints; `allOf` → Base/Mixins per §4.3; `oneOf`/`anyOf` → Union (Exclusive bit), null-variant → Nullable ref, co-declared with structural keywords → the composition distributed across the variants per §4.3, or — for the five shapes that cannot be distributed — structural body + verbatim union per §4.8 (branches that declare no shape at all are `validation_only` per §4.7); competing keywords at one position — `const`/`enum`/`allOf`, elected in that order; `oneOf` beside `anyOf`, where oneOf wins; and a parameter's or header's `schema` beside its `content`, where content wins, since a media-type entry names both a schema and the media type serializing it and the IR models both — lower as the elected keyword with every passed-over one verbatim Unmodeled (`degraded_lowering`) at its own pointer per §4.8, and a `{X, null}` oneOf beside an anyOf stays a Union rather than collapsing to a nullable ref; `discriminator` → Discriminator (3.2 `defaultMapping` → Discriminator.Default); `nullable`/type-arrays → Nullable; readOnly/writeOnly → Visibility and schema-level `default` → the referencing Property/Parameter's Default: both bind a *use* of the type rather than the type, so a declaration-site one is pushed down to referencing properties with use-site precedence and the declaration keeps its own copy verbatim (`no_ir_home` Unmodeled + diagnostic) — which is what a component nothing references would otherwise lose silently; `additionalProperties: false` → Additional=closed, `unevaluatedProperties: false` → closed_after_composition, `minProperties`/`maxProperties` → Model.Constraints (the property set's cardinality, as against Additional's openness); parameters → Params + HTTPBinding locations w/ style/explode, `allowEmptyValue` → Parameter.Unmodeled (`no_ir_home`: HTTPParamBinding holds its neighbours but not this one), a header parameter named Accept/Content-Type/Authorization lowered as declared + `reserved-header-name` warning (OpenAPI says such a definition SHALL be ignored; dropping declared content is an emitter's call, not a compiler's), 3.2 `in: querystring` → querystring location; requestBody/responses all content types → Payload.Contents, a non-required requestBody → Payload.Unmodeled (`no_ir_home`, presence is the IR's only body optionality); 3.2 `itemSchema` → Content.Item and `itemEncoding` → Content.ItemEncoding — except beside a positional `prefixEncoding`, where both go to Content.Unmodeled (`no_ir_home`) because a single every-item encoding cannot state ordinals; per-status responses/default → Conditions + ranges, error-response `headers` and its `content` map whatever its arity → ErrorCase.Unmodeled (`no_ir_home`, ErrorCase has neither field: it holds one TypeRef and no media type, so one entry loses the key it was written under just as several lose all but the first); response/encoding header `style` and `explode` → Property.Unmodeled (`no_ir_home`, ir.Property has neither field), and a `Content-Type` entry in either headers map lowered as declared + the same `reserved-header-name` warning the parameter position gets; webhooks → HTTPBinding.IsWebhook; callbacks → Callbacks; links → Response.Unmodeled (`no_ir_home`, promotable later); path-item `servers`, under `paths`, `webhooks` and a callback expression alike → Operation.Unmodeled (`no_ir_home`: §10 scopes servers by index list at service and channel, and an operation has no such list yet), with an operation's own `servers` — which OpenAPI says override the path item's — kept beside them under `openapi:operationServers`, the one key here not named for the keyword it holds, since two declarations at two pointers cannot share one map key without the survivor depending on lowering order; securitySchemes/security → Auth OR-of-ANDs, 3.2 device flow + `oauth2MetadataUrl` → Flows/OAuth2MetadataURL; servers+variables (3.2 named) → Servers; tags (3.2 parent/kind) → groups + TagDefs; info contact/license → Document; schema `example(s)` → Examples; `xml` object (incl. 3.2 nodeType) → XMLHints at type and property level; `not`/`if-then-else`/`dependentSchemas`/`dependentRequired`/`contains`/`propertyNames`/`unevaluated*` → verbatim Unmodeled per §4.7; `contentEncoding`/`contentMediaType` → `Encoding` on the scalar the position lowers to and `contentSchema` → Unmodeled (`no_ir_home`) per §4.7; `$id`/`$schema`/`$vocabulary` → Unmodeled (`out_of_scope`, `$id` not honoured for resolution); `$dynamicRef` → the anchored type by compiler expansion, else verbatim Unmodeled with the reason it was irreducible; an inline `allOf` branch declaring more than the merge consumes → verbatim Unmodeled (`degraded_lowering`) per §4.8; a boolean `false` `allOf` branch → the composed Model closed, branch verbatim Unmodeled (`degraded_lowering`) per §4.8, a `true` branch a silent no-op; a shape applicator (`properties`/`patternProperties`/`additionalProperties`/`required`/`items`/`prefixItems`, and `format` where no type is declared) the lowered node has no field for → verbatim Unmodeled (`degraded_lowering`) per §4.8; a parameter schema's `xml` and its `readOnly`/`writeOnly` → Parameter.Unmodeled (`no_ir_home`: Parameter has no field for either); `patternProperties` → AdditionalProps.Patterns; `prefixItems` → Tuple, with any trailing `items` → Tuple.Unmodeled (`degraded_lowering` per §4.8: an open tuple has no IR combinator, so the fixed head is lowered and the tail kept beside it); `x-*` → namespaced Unmodeled (legal on every object — hence Unmodeled on every node); `$ref`-adjacent sibling keywords (3.1) and ref-target annotations merge onto the referencing Property/Parameter with **use-site precedence**, applied uniformly (oagen's ad-hoc per-site patching is the counterexample), and at a position carrying no Property/Parameter — an `allOf`/`oneOf`/`anyOf` branch, `items`, a component — bind an alias hoisted at that position instead, per §4.3; a oneOf/anyOf whose variants are all string consts normalizes to a closed `Enum` in a `pass/` normalization — not in the compiler — so per-variant `Docs` survive until the collapse is chosen; mutually-exclusive parameter groups (`x-mutually-exclusive-parameter-groups`) stay as namespaced Unmodeled entries, and their documented *promotion* (no dedicated node needed) is a pass that synthesizes one logical `Parameter` typed by a `Union` of variant models, bound via `HTTPParamBinding.ParamPath` per field; pagination only via injectable policy, marked Inferred | | **Swagger 2.0** | lifted to OpenAPI 3.x shape first (body/formData → Payload; host/basePath/schemes → Servers; consumes/produces → content types), then the OpenAPI lowering runs | | **TypeSpec** | consumed post-check (monomorphized, `isFinished`); template instances → TypeCommon.Instantiation incl. value args → TemplateArg; models → Model w/ Base + spread provenance → Mixins; scalars → Scalar chains, constructors in values → Value.Ctor; `@encode`/`@format` → Encoding triple; `@encodedName` → WireNameByFormat at property AND type level; unions w/ named variants → Union, `@discriminated` → Discriminator.PropertyName/Envelope/EnvelopeValueName; `| null` → Nullable; visibility classes (incl. custom, `@invisible` → Visibility.None) → Visibility, op overrides → ParameterVisibility/ReturnTypeVisibility; `@patch` implicitOptionality → HTTPBinding.PatchImplicitOptionality; interfaces → OperationGroups (versionable); `@overload` → OverloadOf; `@sharedRoute` → SharedRoute; `@service` → Service; versioning decorators incl. `@typeChangedFrom`/`@madeOptional`/`@madeRequired` and add/remove cycles → Availability timeline (on members/variants/params too); pagination decorators incl. prev/first/last links and header continuation tokens → Pagination PropPaths (In:"header"); Azure.Core `@pollingOperation`/`@finalOperation` → LongRunning; multipart w/ parts → Content.Encoding/PartEncoding, `Http.File` → FileInfo (content-type set, contents chain, filename location); streams/SSE → StreamDetail + Variant.Event (contentType, terminal); `@error` → UsageFlags.Error; `@example`/`@opExample` → Examples (Input/Output pairs); `@pattern` message → Constraints.PatternMessage; `@mediaTypeHint` → TypeCommon.MediaTypeHint; `never` members deleted + diagnostic per §4.8; TCGC client-shaping decorators (`@clientName`, `@access`, `@usage`, `@scope`, `@override`, …) → namespaced Unmodeled (`out_of_scope`) consumed by emitter policy, never IR semantics; values/consts incl. enum-member refs → Values channel | | **Smithy 2.0** | structures → Model, mixins → Mixins (non-structure mixins flattened — spec-sanctioned); `document` → Any; unions → WireTagged Union, member `@jsonName` → Variant.WireName; enum/intEnum → Enum (open by default); `@sparse` → element Nullable; traits: constraints → Constraints, `@paginated` → Pagination (declared), `@retryable` → ErrorCase.Retryable + Throttling, `@error` fault → ErrorCase.Fault, `@readonly` → Idempotency safe, `@idempotent`/`@idempotencyToken` → Idempotency, `@sensitive` → Sensitive/Secret, `@tags` → Tags, `@clientOptional`/`@input` → Property.ClientOptional (+InputOnly), `@addedDefault` → DefaultAdded, root-shape `@default` pushed down to properties w/ provenance; `@streaming` blob → StreamDetail (+`@requiresLength` → RequiresLength); event streams → StreamDetail.Events union + Property.EventHeader/EventPayload + Initial messages; service-level errors → Service.CommonErrors; protocol traits → Service.Protocols; service `rename`/`version` → Service.Renames/Version; resources → OperationGroup + ResourceInfo (identifiers, properties, lifecycle incl. put/@noReplace, instance vs collection ops); http traits → HTTPBinding incl. `@endpoint`/`@hostLabel` → HostPrefix/host location (additive binding), `@httpPrefixHeaders`/`@httpQueryParams` → Prefix bindings, `@httpResponseCode` → Response.StatusCodeProp, `@requestCompression` → Compression, `@httpChecksumRequired` → ChecksumRequired; `@auth` order → priority-ordered Auth, `@optionalAuth` → empty option; `@jsonName` → WireName; `@mediaType` → Encoding.MediaType; xml traits → XMLHints at type and property level; `@examples` → Examples (Input/Output/Error); waiters + rules-engine traits → verbatim Unmodeled (`out_of_scope`, §15); `smithy.api#Unit` → nil payload / shared empty Model for tag-only variants; other traits → namespaced Unmodeled | diff --git a/testdata/conformance/openapi/codeclared-schema-content.golden.json b/testdata/conformance/openapi/codeclared-schema-content.golden.json new file mode 100644 index 0000000..d390209 --- /dev/null +++ b/testdata/conformance/openapi/codeclared-schema-content.golden.json @@ -0,0 +1,248 @@ +{ + "irVersion": "0.3.0", + "name": "CoDeclaredSchemaContent", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "CoDeclaredSchemaContent", + "canonical": "co_declared_schema_content" + }, + "docs": {}, + "groups": [ + { + "name": { + "hint": "default" + }, + "docs": {}, + "operations": [ + { + "id": "op/openapi/paths/~1x/get", + "name": { + "source": "getX", + "canonical": "get_x" + }, + "docs": {}, + "params": [ + { + "name": { + "source": "p", + "canonical": "p" + }, + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": false, + "docs": {}, + "unmodeled": { + "openapi:schema": { + "reason": "degraded_lowering", + "value": { + "type": "integer" + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1x/get/parameters/0/schema" + } + } + } + }, + { + "name": { + "source": "q", + "canonical": "q" + }, + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": false, + "docs": {}, + "unmodeled": { + "openapi:schema": { + "reason": "degraded_lowering", + "value": { + "type": "integer" + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1x/get/parameters/1/schema" + } + } + } + } + ], + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "headers": [ + { + "id": "p/openapi/paths/~1x/get/responses/200/headers/X-H", + "name": { + "source": "X-H", + "canonical": "x_h" + }, + "wireName": "X-H", + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "encoding": { + "mediaType": "application/json" + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "unmodeled": { + "openapi:schema": { + "reason": "degraded_lowering", + "value": { + "type": "integer" + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1x/get/responses/200/headers/X-H/schema" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1x/get/responses/200/headers/X-H" + } + } + ], + "docs": { + "description": "ok" + } + } + ], + "oneWay": false, + "idempotency": {}, + "auth": null, + "bindings": { + "http": [ + { + "method": "GET", + "uriTemplate": "/x", + "sharedRoute": false, + "paramBindings": [ + { + "param": "p", + "location": "query", + "wireName": "p", + "style": "form", + "explode": true, + "allowReserved": false, + "contentType": "application/json" + }, + { + "param": "q", + "location": "query", + "wireName": "q", + "style": "form", + "explode": true, + "allowReserved": false, + "contentType": "application/json" + } + ], + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1x/get" + } + } + ] + } + ], + "auth": null, + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0 + }, + "prim": "string" + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {}, + "auth": null + } + ], + "diagnostics": [ + { + "severity": "warning", + "code": "openapi/degraded-construct", + "message": "a parameter or header declares either schema or content, not both; this one declares both, so it lowered as its content, with schema kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/paths/~1x/get/parameters/0/schema" + } + }, + { + "severity": "warning", + "code": "openapi/degraded-construct", + "message": "a parameter or header declares either schema or content, not both; this one declares both, so it lowered as its content, with schema kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/paths/~1x/get/parameters/1/schema" + } + }, + { + "severity": "warning", + "code": "openapi/degraded-construct", + "message": "a parameter or header declares either schema or content, not both; this one declares both, so it lowered as its content, with schema kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/paths/~1x/get/responses/200/headers/X-H/schema" + } + } + ], + "sources": [ + { + "format": "openapi@3.1", + "path": "codeclared-schema-content.yaml", + "hash": "2967906975aba5023f63435116ac38f40448f8a82ed8f6fbf43211610e87ed77" + } + ] +} diff --git a/testdata/conformance/openapi/codeclared-schema-content.yaml b/testdata/conformance/openapi/codeclared-schema-content.yaml new file mode 100644 index 0000000..d7bdf9b --- /dev/null +++ b/testdata/conformance/openapi/codeclared-schema-content.yaml @@ -0,0 +1,37 @@ +openapi: 3.1.0 +info: {title: CoDeclaredSchemaContent, version: "1.0.0"} +paths: + /x: + get: + operationId: getX + parameters: + # OpenAPI says a parameter — and a header, which follows the parameter + # rules — declares either `schema` or `content`, never both. A document + # writing both is invalid, and neither position can lower both: each + # holds one type. So one spelling is elected and the other is kept + # verbatim beside it, exactly as codeclared-keywords does for the + # competing keywords at a schema position. + # + # `content` wins, because it carries a schema *and* the media type + # serializing it, and the IR models both here. + - name: p + in: query + schema: {type: integer} + content: + application/json: {schema: {type: string}} + # The same co-declaration written the other way round. Which keyword the + # source happens to write first must not decide the election, so this + # parameter must lower exactly as the one above it does. + - name: q + in: query + content: + application/json: {schema: {type: string}} + schema: {type: integer} + responses: + "200": + description: ok + headers: + X-H: + schema: {type: integer} + content: + application/json: {schema: {type: string}}