From 8a404fa3a5b4dc15aa17f19483e9297932050534 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Wed, 30 Sep 2026 18:25:39 +0300 Subject: [PATCH 1/9] feat(ir): keep payload docs and tag parent/kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three optional IR members the OpenAPI 3.2 silent-drop batch needs: - Payload.Docs *Docs (json:"docs,omitzero") — a Request Body Object's summary/description describe the body rather than any media type inside it, and reached the IR in no form (GitHub #609). - TagDef.Parent and TagDef.Kind (both omitempty strings) — OpenAPI 3.2's tag parent hierarchy and tag kind, dropped at the declared-tag registry (GitHub #613). Kind is recorded verbatim; reading a role into it is grouping policy, not a property of the document. Each is a new optional member, so ir.IRVersion stays 0.6.0 (ir-design §2.1: no rename, removal, encoding change, or change to an existing key's meaning). Payload.Docs is a pointer for the same three-state reason Payload.Required is, and no pass rule forbids it outside a request: ir.Payload is the body of a request, a response or a message, so an "outside-request" rule would forbid a legitimate use. ir-design §2 and §7.2 record the shapes. TestPayload_DocsIsTriState and TestTagDef_JSONContract pin each tag; unwitnessed.golden.txt grows by exactly Payload.Docs, TagDef.Kind and TagDef.Parent. --- docs/ir-design.md | 17 +++++++++++-- ir/document.go | 12 +++++++++ ir/document_test.go | 14 +++++++++++ ir/operation.go | 11 ++++++++ ir/operation_test.go | 25 +++++++++++++++++++ .../openapi/unwitnessed.golden.txt | 3 +++ 6 files changed, 80 insertions(+), 2 deletions(-) diff --git a/docs/ir-design.md b/docs/ir-design.md index 7310b3cf..679e8a06 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -68,7 +68,7 @@ type Document struct { // referenced by identity from operations and replies (AsyncAPI 3) Auth map[AuthID]AuthScheme // auth scheme registry Servers []Server // endpoint templates - TagDefs []TagDef // tag metadata registry: {Name, Docs}; tag *membership* stays + TagDefs []TagDef // tag metadata registry: {Name, Docs, Parent, Kind}; tag *membership* stays // []string on the tagged nodes (OpenAPI/AsyncAPI tag objects) Versions []string // ordered version labels when availability metadata is used Unmodeled Unmodeled @@ -78,7 +78,13 @@ type Document struct { type Contact struct { Name, URL, Email string } type License struct { Name, Identifier, URL string } -type TagDef struct { Name string; Docs Docs } +type TagDef struct { + Name string + Docs Docs + Parent string // nested-tag parent (OpenAPI 3.2 tag parent), as declared; "" = top-level + Kind string // tag role recorded verbatim (OpenAPI 3.2 tag kind); interpretation is + // grouping policy (§7.1), never read here +} ``` A `Document` is self-contained: no node references anything outside it. @@ -1252,6 +1258,13 @@ type Payload struct { // Response and message payloads leave it nil — only a request // body can be omitted — and pass/validate reports one set // anywhere else (ir/payload-required-outside-request) + // Docs is the payload's own documentation (a Request Body Object's + // summary and description describe the body, not any media type + // inside it). Pointer like Required, so nil is a third state: this + // position states no docs. A request body sets it today; a message + // payload may later, which is why no pass rule forbids it outside a + // request + Docs *Docs Unmodeled Unmodeled } diff --git a/ir/document.go b/ir/document.go index 6be953f2..8db23eb7 100644 --- a/ir/document.go +++ b/ir/document.go @@ -158,4 +158,16 @@ type TagDef struct { Name string `json:"name,omitempty"` // Docs is the tag's documentation. Docs Docs `json:"docs"` + // Parent names the tag this one is nested under (OpenAPI 3.2 tag parent): + // the declared hierarchy a consumer may render as a section tree. Empty = + // top-level. It is the declared spelling, not a resolved reference — a + // parent that names no declared tag is reported by the parser and left as + // written here, since resolving it is grouping policy (OperationGroup). + Parent string `json:"parent,omitempty"` + // Kind is the tag's declared role (OpenAPI 3.2 tag kind), recorded verbatim + // — "nav", "badge", or any other string a document writes. It is kept as a + // fact rather than interpreted: whether a kind is navigational is grouping + // policy, not a property of the document, so no meaning is read into a + // value here (ir-design §7.1). + Kind string `json:"kind,omitempty"` } diff --git a/ir/document_test.go b/ir/document_test.go index 9768c8e7..e9065a32 100644 --- a/ir/document_test.go +++ b/ir/document_test.go @@ -144,3 +144,17 @@ func TestDocument_AuthDeterministic(t *testing.T) { `"m/a":{"name":{},"docs":{},"provenance":{"source":0}},`+ `"z/a":{"name":{},"docs":{},"provenance":{"source":0}}}`) } + +// TestTagDef_JSONContract pins TagDef's optional members: an OpenAPI 3.0/3.1 +// tag declares neither parent nor kind, so both stay absent keys and every +// existing golden is unchanged, while a 3.2 tag's declared hierarchy and role +// round-trip. Docs carries no omitempty, so a bare tag still writes it. +func TestTagDef_JSONContract(t *testing.T) { + t.Parallel() + assertJSONContract(t, ir.TagDef{}, `{"docs":{}}`, ir.TagDef{ + Name: "books", + Docs: ir.Docs{Summary: "Books"}, + Parent: "catalog", + Kind: "nav", + }) +} diff --git a/ir/operation.go b/ir/operation.go index 6accebcf..fed17c73 100644 --- a/ir/operation.go +++ b/ir/operation.go @@ -123,6 +123,17 @@ type Payload struct { // request body can be omitted, and pass/validate reports one that is set // anywhere else (ir/payload-required-outside-request). Required *bool `json:"required,omitzero"` + // Docs is the payload's own documentation — the summary and description a + // Request Body Object writes beside its content, which describe the body + // rather than any media type inside it. It is a pointer for the reason + // Required is one: the three states are distinct, and only a source object + // that can write docs sets it. A request body does today; a response or + // message payload leaves it nil, and a future compiler may document a + // message payload's body the same way without the field having to change. + // No pass rule forbids it outside a request: ir.Payload is the body of a + // request, a response *or* a message, so "outside-request" would forbid a + // legitimate use. + Docs *Docs `json:"docs,omitzero"` // Unmodeled holds source constructs the IR does not model, kept verbatim. Unmodeled Unmodeled `json:"unmodeled,omitempty"` } diff --git a/ir/operation_test.go b/ir/operation_test.go index efeaae3a..7c0a4f3b 100644 --- a/ir/operation_test.go +++ b/ir/operation_test.go @@ -207,6 +207,31 @@ func TestPayload_RequiredIsTriState(t *testing.T) { } } +// TestPayload_DocsIsTriState pins Payload.Docs' three states, which is why it +// is a pointer rather than a value: nil is "this position states no docs" and +// must stay an absent key, so a response or message payload golden does not gain +// a docs:{} the source never declared. omitzero, not omitempty, keeps the +// distinct present-but-empty state on the wire. +func TestPayload_DocsIsTriState(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + in *ir.Docs + want string + }{ + {"unstated", nil, `{}`}, + {"documented", &ir.Docs{Description: "the body"}, `{"docs":{"description":"the body"}}`}, + {"present but empty", &ir.Docs{}, `{"docs":{}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + payload := ir.Payload{Docs: tc.in} + assertZeroValueShape(t, payload, tc.want) + assertRoundTrip(t, payload) + }) + } +} + // TestContent_JSONContract pins Content's omitempty contract — Type carries // no omitempty, every other field is optional — and that a fully populated // Content — item schema for sequential streaming, per-part encodings, and diff --git a/testdata/conformance/openapi/unwitnessed.golden.txt b/testdata/conformance/openapi/unwitnessed.golden.txt index 338ac31d..3aa8459b 100644 --- a/testdata/conformance/openapi/unwitnessed.golden.txt +++ b/testdata/conformance/openapi/unwitnessed.golden.txt @@ -146,6 +146,7 @@ ParamPath.Param ParamPath.Segments Parameter.Availability Parameter.ValueFrom +Payload.Docs PropPath.In PropPath.Root PropPath.Segments @@ -196,6 +197,8 @@ Service.Servers Service.Version StreamDetail.Initial StreamDetail.RequiresLength +TagDef.Kind +TagDef.Parent TemplateArg.Type TemplateArg.Value TemplateInstantiation.Args From c8f0b563e5108960402a3e09c268601871d93bde Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Wed, 30 Sep 2026 18:32:18 +0300 Subject: [PATCH 2/9] fix(compilers/openapi): keep a request body's description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Request Body Object's description reaches the IR in no form: the parser models the field, so the unknown-key census never sees it, and lowerRequestBody read only `required` and `content`. A body description therefore vanished with no field, no Unmodeled entry and no diagnostic (#609). lowerRequestBody now writes Payload.Docs when the body declares a description, after the payload-nil guard so a body with no content still lowers to nothing. A response or message payload leaves Docs nil — the same three-state reading Payload.Required takes — so no existing payload golden moves. No diagnostic: the description has a typed home now, which is the whole fix. The corpus case covers both a body $ref'd from components (two operations mounting one declaration, so it interns once) and one written inline. TestContent_RequestBodyDocsAreOrderIndependent is the two-order diff: Payload.Docs is neither the type registry nor a diagnostic, so the in-package oracle does not reach it. --- compilers/openapi/conformance_test.go | 33 ++ .../openapi/internal/operation/content.go | 9 + .../internal/operation/content_test.go | 120 ++++++ .../openapi/request-body-docs.golden.json | 362 ++++++++++++++++++ .../openapi/request-body-docs.yaml | 35 ++ .../openapi/unwitnessed.golden.txt | 1 - 6 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 testdata/conformance/openapi/request-body-docs.golden.json create mode 100644 testdata/conformance/openapi/request-body-docs.yaml diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index fd69416e..e45adad5 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -238,6 +238,7 @@ func conformanceCases() []conformanceCase { {"extension-promotion", assertExtensionPromotion, []string{"deprecation", "open-enums"}}, {"examples", assertExamples, []string{"examples"}}, {"docs-summary-desc", assertDocsSummaryDesc, []string{"docs-summary-description"}}, + {"request-body-docs", assertRequestBodyDocs, []string{"docs-summary-description"}}, {"extensions-x", assertExtensionsX, []string{"vendor-extensions"}}, {"inline-annotations", assertInlineAnnotations, []string{"vendor-extensions", "inline-anonymous"}}, {"inline-residue", assertInlineResidue, []string{"inline-anonymous"}}, @@ -3020,6 +3021,38 @@ func assertDocsSummaryDesc(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { "the 3.1 SPDX identifier is its own field, never folded into the name") } +// assertRequestBodyDocs pins GitHub #609: a Request Body Object's `description` +// describes the body rather than any media type inside it, so it reaches +// Payload.Docs. Both spellings are covered — a body $ref'd from components and +// one written inline — and the shared component is reached from two operations, +// so the declaration-pointer path is exercised and one declaration is still one +// type however many mounts read its docs. +func assertRequestBodyDocs(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + created, ok := opByName(doc, "createOrder") + require.True(t, ok) + require.NotNil(t, created.Request) + require.NotNil(t, created.Request.Docs, "a $ref'd body keeps the component's description") + assert.Equal(t, "A shared order body.", created.Request.Docs.Description) + + replaced, ok := opByName(doc, "replaceOrder") + require.True(t, ok) + require.NotNil(t, replaced.Request) + require.NotNil(t, replaced.Request.Docs, "the second mount reads the same declaration's docs") + assert.Equal(t, "A shared order body.", replaced.Request.Docs.Description) + + shared := ir.TypeID("t/anon/components/requestBodies/OrderBody/content/application~1json/schema") + assert.Equal(t, shared, openapitest.BodyTarget(t, created.Request), + "a shared body interns at its component pointer (issue #107)") + assert.Equal(t, shared, openapitest.BodyTarget(t, replaced.Request), + "...once, whichever mount lowered it first") + + draft, ok := opByName(doc, "saveDraft") + require.True(t, ok) + require.NotNil(t, draft.Request) + require.NotNil(t, draft.Request.Docs, "an inline body's own description reaches the payload too") + assert.Equal(t, "A draft saved inline.", draft.Request.Docs.Description) +} + func assertExtensionsX(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { m, ok := doc.Types[namedID("S")].(*ir.Model) require.True(t, ok) diff --git a/compilers/openapi/internal/operation/content.go b/compilers/openapi/internal/operation/content.go index 5c7098ea..ffbf2bf8 100644 --- a/compilers/openapi/internal/operation/content.go +++ b/compilers/openapi/internal/operation/content.go @@ -755,6 +755,15 @@ func lowerRequestBody(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorI if payload == nil { return diags } + // The body's own documentation describes the body rather than any media type + // inside it, and ir.Payload is where a request body's facts land. The parser + // models the field, so the unknown-key census never saw it either: a + // `description` here reached no field, no Unmodeled entry and no diagnostic + // (GitHub #609). Written only when declared, so a body that states none keeps + // Docs nil — the same three-state reading Required takes. + if desc := rb.GetDescription(); desc != "" { + payload.Docs = &ir.Docs{Description: desc} + } required := rb.GetRequired() payload.Required = &required // soa.RequestBody exposes no GetExtensions at this library version, so the diff --git a/compilers/openapi/internal/operation/content_test.go b/compilers/openapi/internal/operation/content_test.go index ec1628b8..f6442f98 100644 --- a/compilers/openapi/internal/operation/content_test.go +++ b/compilers/openapi/internal/operation/content_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -349,6 +350,125 @@ func TestContent_ResponsePayloadStatesNoOptionality(t *testing.T) { "a response body has no optionality to state") } +// TestContent_RequestBodyDocsReachThePayload pins GitHub #609: a Request Body +// Object's `description` describe the body, and ir.Payload is the node the +// body's own facts land on, so it reaches Payload.Docs rather than no field at +// all. The response's identical field is asserted here too, since #615 fills +// that one from the raw node — the two must not be confused. +func TestContent_RequestBodyDocsReachThePayload(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpec(` /bodies: + post: + operationId: bodies + requestBody: + description: The thing to create. + content: + application/json: {schema: {type: object, properties: {n: {type: string}}}} + responses: {"200": {description: ok}} +`) + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FirstOp(t, svc) + require.NotNil(t, op.Request) + require.NotNil(t, op.Request.Docs, "a declared body description has a home on the payload") + assert.Equal(t, "The thing to create.", op.Request.Docs.Description) +} + +// TestContent_RequestBodyWithoutDocsLeavesItUnstated is the second arm: a body +// that writes no description leaves Payload.Docs nil rather than an empty Docs, +// which is what keeps every existing payload golden byte-identical. +func TestContent_RequestBodyWithoutDocsLeavesItUnstated(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpec(` /bare: + post: + operationId: bare + requestBody: + content: + application/json: {schema: {type: object, properties: {n: {type: string}}}} + responses: {"200": {description: ok}} +`) + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FirstOp(t, svc) + require.NotNil(t, op.Request) + assert.Nil(t, op.Request.Docs, "an unstated body description is absent, not empty") +} + +// TestContent_ResponsePayloadStatesNoDocs pins the third state: a response +// payload is not a request body, and Payload.Docs is left for the request-body +// lowering, so a consumer reading a response body finds no docs invented for it. +func TestContent_ResponsePayloadStatesNoDocs(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpec(` /read: + get: + operationId: read + responses: + "200": + description: ok + content: + application/json: {schema: {type: object, properties: {n: {type: string}}}} +`) + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FirstOp(t, svc) + require.Len(t, op.Responses, 1) + require.NotNil(t, op.Responses[0].Payload) + assert.Nil(t, op.Responses[0].Payload.Docs, + "only the request-body lowering writes payload docs") +} + +// TestContent_RequestBodyDocsAreOrderIndependent is the hand-rolled two-order +// diff for GitHub #609. The order-invariance oracle compares only the type +// registry and the diagnostic set, and Payload.Docs is neither, so the swap is +// made here: one components/requestBodies entry referenced by two operations, +// declared in both orders. The docs are read from the component whichever mount +// arrives first, and that shared body interns once at the component pointer, so +// neither fact may move with the declaration order. +func TestContent_RequestBodyDocsAreOrderIndependent(t *testing.T) { + t.Parallel() + const createOrder = ` /orders: + post: + operationId: createOrder + requestBody: {$ref: '#/components/requestBodies/OrderBody'} + responses: {"200": {description: ok}} +` + const replaceOrder = ` /orders/{id}: + put: + operationId: replaceOrder + parameters: + - {name: id, in: path, required: true, schema: {type: string}} + requestBody: {$ref: '#/components/requestBodies/OrderBody'} + responses: {"200": {description: ok}} +` + projection := func(paths string) map[string]string { + t.Helper() + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: +` + paths + `components: + requestBodies: + OrderBody: + description: A shared order body. + required: true + content: + application/json: + schema: {type: object, properties: {sku: {type: string}}} +` + doc, _, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + out := map[string]string{} + for _, name := range []string{"createOrder", "replaceOrder"} { + op := openapitest.FindOp(t, doc, name) + require.NotNil(t, op.Request, "%s: the body lowers", name) + require.NotNil(t, op.Request.Docs, "%s: the component's description reaches the payload", name) + out[name] = op.Request.Docs.Description + " " + string(openapitest.BodyTarget(t, op.Request)) + } + return out + } + assert.Empty(t, cmp.Diff(projection(createOrder+replaceOrder), projection(replaceOrder+createOrder)), + "the shared body's docs and identity must not depend on which mount lowered first") +} + func TestContent_ArrayMultipartPartMulti(t *testing.T) { t.Parallel() spec := openapitest.PathsSpec(` /bulk: diff --git a/testdata/conformance/openapi/request-body-docs.golden.json b/testdata/conformance/openapi/request-body-docs.golden.json new file mode 100644 index 00000000..1a072c06 --- /dev/null +++ b/testdata/conformance/openapi/request-body-docs.golden.json @@ -0,0 +1,362 @@ +{ + "irVersion": "0.6.0", + "name": "RequestBodyDocs", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "RequestBodyDocs", + "canonical": "request_body_docs" + }, + "docs": {}, + "groups": [ + { + "name": { + "hint": "default" + }, + "docs": {}, + "operations": [ + { + "id": "op/openapi/paths/~1orders/post", + "name": { + "source": "createOrder", + "canonical": "create_order" + }, + "docs": {}, + "request": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/anon/components/requestBodies/OrderBody/content/application~1json/schema", + "nullable": false + } + } + ], + "required": true, + "docs": { + "description": "A shared order body." + } + }, + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "docs": { + "description": "ok" + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "POST", + "uriTemplate": "/orders", + "sharedRoute": false, + "requestContentTypes": [ + "application/json" + ], + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1orders/post" + } + }, + { + "id": "op/openapi/paths/~1orders~1{id}/put", + "name": { + "source": "replaceOrder", + "canonical": "replace_order" + }, + "docs": {}, + "params": [ + { + "name": { + "source": "id", + "canonical": "id" + }, + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": true, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1orders~1{id}/put/parameters/0" + } + } + ], + "request": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/anon/components/requestBodies/OrderBody/content/application~1json/schema", + "nullable": false + } + } + ], + "required": true, + "docs": { + "description": "A shared order body." + } + }, + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "docs": { + "description": "ok" + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "PUT", + "uriTemplate": "/orders/{id}", + "sharedRoute": false, + "paramBindings": [ + { + "param": "id", + "location": "path", + "wireName": "id", + "style": "simple", + "explode": false, + "allowReserved": false + } + ], + "requestContentTypes": [ + "application/json" + ], + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1orders~1{id}/put" + } + }, + { + "id": "op/openapi/paths/~1drafts/post", + "name": { + "source": "saveDraft", + "canonical": "save_draft" + }, + "docs": {}, + "request": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/anon/paths/~1drafts/post/requestBody/content/application~1json/schema", + "nullable": false + } + } + ], + "required": false, + "docs": { + "description": "A draft saved inline." + } + }, + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "docs": { + "description": "ok" + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "POST", + "uriTemplate": "/drafts", + "sharedRoute": false, + "requestContentTypes": [ + "application/json" + ], + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1drafts/post" + } + } + ] + } + ], + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/anon/components/requestBodies/OrderBody/content/application~1json/schema": { + "kind": "model", + "id": "t/anon/components/requestBodies/OrderBody/content/application~1json/schema", + "name": { + "hint": "order_body" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/requestBodies/OrderBody/content/application~1json/schema" + }, + "properties": [ + { + "id": "p/openapi/components/requestBodies/OrderBody/content/application~1json/schema/properties/sku", + "name": { + "source": "sku", + "canonical": "sku" + }, + "wireName": "sku", + "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/requestBodies/OrderBody/content/application~1json/schema/properties/sku" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/paths/~1drafts/post/requestBody/content/application~1json/schema": { + "kind": "model", + "id": "t/anon/paths/~1drafts/post/requestBody/content/application~1json/schema", + "name": { + "hint": "save_draft_request" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/paths/~1drafts/post/requestBody/content/application~1json/schema" + }, + "properties": [ + { + "id": "p/openapi/paths/~1drafts/post/requestBody/content/application~1json/schema/properties/sku", + "name": { + "source": "sku", + "canonical": "sku" + }, + "wireName": "sku", + "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/~1drafts/post/requestBody/content/application~1json/schema/properties/sku" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "string" + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {} + } + ], + "sources": [ + { + "format": "openapi@3.2", + "path": "request-body-docs.yaml", + "hash": "0e053beb72ea04238b3cf843ecc2917bd0205d2830512cfb64bc2a8bb1f2ee67" + } + ] +} diff --git a/testdata/conformance/openapi/request-body-docs.yaml b/testdata/conformance/openapi/request-body-docs.yaml new file mode 100644 index 00000000..d017a567 --- /dev/null +++ b/testdata/conformance/openapi/request-body-docs.yaml @@ -0,0 +1,35 @@ +openapi: 3.2.0 +info: {title: RequestBodyDocs, version: "1.0.0"} +paths: + /orders: + post: + operationId: createOrder + requestBody: {$ref: '#/components/requestBodies/OrderBody'} + responses: + "200": {description: ok} + /orders/{id}: + put: + operationId: replaceOrder + parameters: + - {name: id, in: path, required: true, schema: {type: string}} + requestBody: {$ref: '#/components/requestBodies/OrderBody'} + responses: + "200": {description: ok} + /drafts: + post: + operationId: saveDraft + requestBody: + description: A draft saved inline. + content: + application/json: + schema: {type: object, properties: {sku: {type: string}}} + responses: + "200": {description: ok} +components: + requestBodies: + OrderBody: + description: A shared order body. + required: true + content: + application/json: + schema: {type: object, properties: {sku: {type: string}}} \ No newline at end of file diff --git a/testdata/conformance/openapi/unwitnessed.golden.txt b/testdata/conformance/openapi/unwitnessed.golden.txt index 3aa8459b..1e9d340a 100644 --- a/testdata/conformance/openapi/unwitnessed.golden.txt +++ b/testdata/conformance/openapi/unwitnessed.golden.txt @@ -146,7 +146,6 @@ ParamPath.Param ParamPath.Segments Parameter.Availability Parameter.ValueFrom -Payload.Docs PropPath.In PropPath.Root PropPath.Segments From 82a003247d728e728dfabc0fc5e4adad2cb165ec Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Wed, 30 Sep 2026 18:44:03 +0300 Subject: [PATCH 3/9] fix(compilers/openapi): honor docs written beside a $ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI lets the entry holding a `$ref` carry summary and description beside it, describing that use of the referenced object, and says they override the referenced object's. The parser presents them on the Reference wrapper, but the compiler reads the resolved object and discards the wrapper — so a use-site override vanished with no evidence it existed, at 3.0, 3.1 and 3.2 alike (#610). One helper, resolve.RefDocs, folds the pair over the declaration's docs field by field, and all six sites that carry a docs field and can hold a Reference Object apply it: parameters, responses and error cases (through the one shared lowerResponseParts, so both status classes fold by construction), headers, examples, request bodies, and security schemes. The sites deliberately left out — callbacks, links, path items, component schemas — are unchanged. A departure, recorded in ir-design §12.2 and intended: OpenAPI says such a sibling has "no effect" where the referenced object type does not allow summary/description. The IR has a docs field at each of these positions, so keeping it is lossless, and honoring the clause would restore exactly the silent drop this fixes. No version gate: the wrapper is version-independent and a 3.0 document lost its siblings the same way. TestConformance/ref-site-docs covers all six sites in one document, one component mounted with different overrides so each mount keeps its own, and TestRefSiteDocs_MountsKeepTheirOwnInEitherOrder is the two-order diff the in-package oracle cannot see (a use-site description is neither a registry entry nor a diagnostic). --- compilers/openapi/conformance_test.go | 66 +++ compilers/openapi/internal/auth/auth.go | 4 + compilers/openapi/internal/auth/auth_test.go | 31 + .../openapi/internal/operation/content.go | 23 +- .../internal/operation/content_test.go | 95 ++++ .../openapi/internal/operation/operations.go | 32 +- .../internal/operation/operations_test.go | 92 +++ .../openapi/internal/operation/params.go | 5 + .../openapi/internal/operation/params_test.go | 51 ++ compilers/openapi/internal/resolve/entry.go | 36 ++ .../openapi/internal/resolve/entry_test.go | 47 ++ docs/ir-design.md | 12 + .../openapi/ref-site-docs.golden.json | 530 ++++++++++++++++++ .../conformance/openapi/ref-site-docs.yaml | 73 +++ 14 files changed, 1080 insertions(+), 17 deletions(-) create mode 100644 testdata/conformance/openapi/ref-site-docs.golden.json create mode 100644 testdata/conformance/openapi/ref-site-docs.yaml diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index e45adad5..8d2ef0cb 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -239,6 +239,7 @@ func conformanceCases() []conformanceCase { {"examples", assertExamples, []string{"examples"}}, {"docs-summary-desc", assertDocsSummaryDesc, []string{"docs-summary-description"}}, {"request-body-docs", assertRequestBodyDocs, []string{"docs-summary-description"}}, + {"ref-site-docs", assertRefSiteDocs, []string{"docs-summary-description", "named-objects"}}, {"extensions-x", assertExtensionsX, []string{"vendor-extensions"}}, {"inline-annotations", assertInlineAnnotations, []string{"vendor-extensions", "inline-anonymous"}}, {"inline-residue", assertInlineResidue, []string{"inline-anonymous"}}, @@ -3053,6 +3054,71 @@ func assertRequestBodyDocs(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { assert.Equal(t, "A draft saved inline.", draft.Request.Docs.Description) } +// assertRefSiteDocs pins GitHub #610 across every site the fold reaches, in one +// document: a Reference Object may write `summary` and `description` beside its +// `$ref`, and OpenAPI says they override the referenced object's. Nothing read +// them, so a mount's own documentation was silently replaced by the +// declaration's. The one component referenced from two operations with +// different overrides also pins the other half: each mount keeps its own and +// neither mutates the declaration. +func assertRefSiteDocs(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + widgets, ok := opByName(doc, "listWidgets") + require.True(t, ok) + gadgets, ok := opByName(doc, "listGadgets") + require.True(t, ok) + + // parameters — the two-mount case: the first writes both siblings, the second + // only a description, and the declaration's own description is overridden by + // both rather than surviving on either. + widgetLimit, ok := paramByName(widgets, "limit") + require.True(t, ok) + assert.Equal(t, "Page size", widgetLimit.Docs.Summary) + assert.Equal(t, "How many widgets to return.", widgetLimit.Docs.Description) + gadgetLimit, ok := paramByName(gadgets, "limit") + require.True(t, ok) + assert.Empty(t, gadgetLimit.Docs.Summary, "the second mount writes no summary, so none is invented") + assert.Equal(t, "How many gadgets to return.", gadgetLimit.Docs.Description) + + // responses — one mount overrides the description, the other keeps the + // declaration's, so neither mutates it. + require.Len(t, widgets.Responses, 1) + assert.Equal(t, "The listing, as this operation returns it.", widgets.Responses[0].Docs.Description) + require.Len(t, gadgets.Responses, 1) + assert.Equal(t, "A page of results.", gadgets.Responses[0].Docs.Description, + "a mount with no siblings leaves the declaration's description alone") + + // error cases are responses too, and reach the same shared lowering. + require.Len(t, widgets.Errors, 1) + assert.Equal(t, "No such widget.", widgets.Errors[0].Docs.Description) + + // headers + require.Len(t, widgets.Responses[0].Headers, 1) + assert.Equal(t, "The unit a rate limit is stated in.", widgets.Responses[0].Headers[0].Docs.Description) + + // examples — the siblings override the Example Object's own pair. + require.NotNil(t, widgets.Responses[0].Payload) + require.Len(t, widgets.Responses[0].Payload.Contents[0].Examples, 1) + example := widgets.Responses[0].Payload.Contents[0].Examples[0] + assert.Equal(t, "One page", example.Summary) + assert.Equal(t, "A single page of results.", example.Description) + + // request bodies + require.NotNil(t, widgets.Request) + require.NotNil(t, widgets.Request.Docs) + assert.Equal(t, "The widget to create.", widgets.Request.Docs.Description) + + // security schemes — the aliasing entry takes its own description, and the + // declaration it names keeps its own. + require.Len(t, doc.Auth, 2) + byName := map[string]string{} + for _, scheme := range doc.Auth { + byName[scheme.Name.Source] = scheme.Docs.Description + } + assert.Equal(t, "The key this API expects.", byName["ApiKey"]) + assert.Equal(t, "The declaration's own scheme description.", byName["BaseKey"], + "the aliasing entry's siblings do not reach the declaration") +} + func assertExtensionsX(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { m, ok := doc.Types[namedID("S")].(*ir.Model) require.True(t, ok) diff --git a/compilers/openapi/internal/auth/auth.go b/compilers/openapi/internal/auth/auth.go index 520af6b4..a7e60ea2 100644 --- a/compilers/openapi/internal/auth/auth.go +++ b/compilers/openapi/internal/auth/auth.go @@ -64,6 +64,10 @@ func LowerSecuritySchemes(c lowering.Ctx) (map[ir.AuthID]ir.AuthScheme, []ir.Dia if !ok { continue } + // A securitySchemes entry written as a Reference Object keeps the summary + // and description written beside its $ref, which describe this entry rather + // than the declaration it names (GitHub #610). + scheme.Docs = resolve.RefDocs(rs, scheme.Docs) out[ids.Auth(name)] = scheme } if len(out) == 0 { diff --git a/compilers/openapi/internal/auth/auth_test.go b/compilers/openapi/internal/auth/auth_test.go index ac88726a..a1aabc74 100644 --- a/compilers/openapi/internal/auth/auth_test.go +++ b/compilers/openapi/internal/auth/auth_test.go @@ -1184,3 +1184,34 @@ func loaderErrors(diags []ir.Diagnostic) []string { } return out } + +// TestLowerSecuritySchemes_RefSiteDocsOverrideTheDeclaration pins the auth half +// of GitHub #610: a securitySchemes entry written as a Reference Object keeps +// the description written beside its $ref, and the declaration it names keeps +// its own — the fold is per entry, never a mutation of the target. +func TestLowerSecuritySchemes_RefSiteDocsOverrideTheDeclaration(t *testing.T) { + t.Parallel() + doc, _, diags := serviceSpec(t, `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: {} +components: + securitySchemes: + BaseKey: + description: declared + type: apiKey + in: header + name: X-Key + ApiKey: + $ref: '#/components/securitySchemes/BaseKey' + description: the use site +`) + require.Len(t, doc.Auth, 2) + byName := map[string]string{} + for _, scheme := range doc.Auth { + byName[scheme.Name.Source] = scheme.Docs.Description + } + assert.Equal(t, "the use site", byName["ApiKey"]) + assert.Equal(t, "declared", byName["BaseKey"], + "the aliasing entry's siblings do not reach the declaration") + require.Empty(t, messagesAt(diags, diag.UnresolvedRef)) +} diff --git a/compilers/openapi/internal/operation/content.go b/compilers/openapi/internal/operation/content.go index ffbf2bf8..50ad8336 100644 --- a/compilers/openapi/internal/operation/content.go +++ b/compilers/openapi/internal/operation/content.go @@ -403,6 +403,10 @@ func lowerHeaders(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex p, headerDiags := lowerHeader(c, ts, anchors, h, name, hptr, hdecl) diags = append(diags, headerDiags...) diags = append(diags, reservedHeaderEntryDiag(c, name, hptr)...) + // A header entry written as a Reference Object keeps its own summary and + // description: they describe this entry rather than the header declaration + // it names (GitHub #610). + p.Docs = resolve.RefDocs(rh, p.Docs) out = append(out, p) } return out, diags @@ -685,10 +689,14 @@ func appendPluralExample(c lowering.Ctx, out []ir.Example, re *soa.ReferencedExa } ext, diags := schema.ExtensionsOf(c, ex.GetExtensions(), decl) diags = append(diags, annotation.UnknownKeysIn(&ext, ex, c.ProvenanceAt, decl)...) + // An entry written as a Reference Object carries its summary and description + // beside the $ref, and they override the declaration's; the fold reads the two + // through the same helper the other positions use (GitHub #610). + docs := resolve.RefDocs(re, ir.Docs{Summary: ex.GetSummary(), Description: ex.GetDescription()}) proto := ir.Example{ Name: name, - Summary: ex.GetSummary(), - Description: ex.GetDescription(), + Summary: docs.Summary, + Description: docs.Description, ExternalURL: ex.GetExternalValue(), Unmodeled: ext, } @@ -759,10 +767,13 @@ func lowerRequestBody(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorI // inside it, and ir.Payload is where a request body's facts land. The parser // models the field, so the unknown-key census never saw it either: a // `description` here reached no field, no Unmodeled entry and no diagnostic - // (GitHub #609). Written only when declared, so a body that states none keeps - // Docs nil — the same three-state reading Required takes. - if desc := rb.GetDescription(); desc != "" { - payload.Docs = &ir.Docs{Description: desc} + // (GitHub #609). A summary or description written beside a `$ref` to the body + // overrides the declaration's, through the same fold every other position uses + // (GitHub #610). Written only when something landed, so a body stating neither + // keeps Docs nil — the same three-state reading Required takes. + bodyDocs := resolve.RefDocs(src.GetRequestBody(), ir.Docs{Description: rb.GetDescription()}) + if bodyDocs.Summary != "" || bodyDocs.Description != "" { + payload.Docs = &bodyDocs } required := rb.GetRequired() payload.Required = &required diff --git a/compilers/openapi/internal/operation/content_test.go b/compilers/openapi/internal/operation/content_test.go index f6442f98..675e133d 100644 --- a/compilers/openapi/internal/operation/content_test.go +++ b/compilers/openapi/internal/operation/content_test.go @@ -2007,3 +2007,98 @@ func TestHeaders_RefSiteKeywordsAreKeptOnTheHeader(t *testing.T) { assert.JSONEq(t, `["a","b"]`, string(entry.Value)) openapitest.AssertInfoDiagAt(t, diags, "/paths/~1x/get/responses/200/headers/X-H/schema") } + +// TestHeaders_RefSiteDocsOverrideTheDeclaration pins the header half of GitHub +// #610: a header entry written as a Reference Object keeps the pair it writes +// beside the $ref, overriding the declaration's own description. +func TestHeaders_RefSiteDocsOverrideTheDeclaration(t *testing.T) { + t.Parallel() + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + get: + operationId: a + responses: + "200": + description: ok + headers: + X-Rate: + $ref: '#/components/headers/Rate' + summary: HS + description: HD +components: + headers: + Rate: {description: declared, schema: {type: integer}} +` + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + require.Len(t, openapitest.FirstOp(t, svc).Responses, 1) + header := openapitest.FirstOp(t, svc).Responses[0].Headers[0] + assert.Equal(t, "HS", header.Docs.Summary) + assert.Equal(t, "HD", header.Docs.Description) +} + +// TestExamples_RefSiteDocsOverrideTheDeclaration pins the example half of GitHub +// #610: an entry written as a Reference Object carries its summary and +// description beside the $ref, and they override the Example Object's own pair. +func TestExamples_RefSiteDocsOverrideTheDeclaration(t *testing.T) { + t.Parallel() + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + get: + operationId: a + responses: + "200": + description: ok + content: + application/json: + schema: {type: object, properties: {n: {type: string}}} + examples: + one: + $ref: '#/components/examples/Sample' + summary: ES + description: ED +components: + examples: + Sample: {summary: declared summary, description: declared description, value: {n: x}} +` + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + payload := openapitest.FirstOp(t, svc).Responses[0].Payload + require.NotNil(t, payload) + require.Len(t, payload.Contents[0].Examples, 1) + example := payload.Contents[0].Examples[0] + assert.Equal(t, "ES", example.Summary) + assert.Equal(t, "ED", example.Description) +} + +// TestContent_RequestBodyRefSiteDocsOverrideTheDeclaration pins the request-body +// half of GitHub #610: a body entry written as a Reference Object keeps the +// description beside its $ref, which wins over the declaration's. +func TestContent_RequestBodyRefSiteDocsOverrideTheDeclaration(t *testing.T) { + t.Parallel() + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + post: + operationId: a + requestBody: {$ref: '#/components/requestBodies/Body', description: the use site} + responses: {"200": {description: ok}} +components: + requestBodies: + Body: + description: declared + content: + application/json: {schema: {type: object, properties: {n: {type: string}}}} +` + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FirstOp(t, svc) + require.NotNil(t, op.Request) + require.NotNil(t, op.Request.Docs) + assert.Equal(t, "the use site", op.Request.Docs.Description) +} diff --git a/compilers/openapi/internal/operation/operations.go b/compilers/openapi/internal/operation/operations.go index 30cb2a1e..e1ef619c 100644 --- a/compilers/openapi/internal/operation/operations.go +++ b/compilers/openapi/internal/operation/operations.go @@ -837,18 +837,18 @@ func lowerResponses(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorInd // statusConditions lets the success side record no status at all. // TestStatusRange_NamesNoStatus is what pins the pairing. if isErrorRange(rng) { - ec, ecDiags := lowerErrorCase(c, ts, anchors, r, code, rng, rptr) + ec, ecDiags := lowerErrorCase(c, ts, anchors, r, code, rng, rptr, rr) diags = append(diags, ecDiags...) errs = append(errs, ec) } else { - resp, respDiags := lowerResponse(c, ts, anchors, r, code, statusConditions(rng, named), rptr) + resp, respDiags := lowerResponse(c, ts, anchors, r, code, statusConditions(rng, named), rptr, rr) diags = append(diags, respDiags...) responses = append(responses, resp) } } def, dptr := resolve.ObjectAt[soa.Response](c.RefScope(), resps.GetDefault(), opDeclPtr+ids.Ptr("responses", defaultResponseKey)) if def != nil { - ec, ecDiags := lowerErrorCase(c, ts, anchors, def, defaultResponseKey, ir.StatusRange{}, dptr) + ec, ecDiags := lowerErrorCase(c, ts, anchors, def, defaultResponseKey, ir.StatusRange{}, dptr, resps.GetDefault()) diags = append(diags, ecDiags...) errs = append(errs, ec) } @@ -878,9 +878,11 @@ func duplicateStatusKeyDiags(c lowering.Ctx, byRange map[ir.StatusRange][]string // payload (all media types), headers, docs, and any raw links preserved for // later promotion. code is the responses-map key the response is declared under // and conds is what that key resolved to, which is nothing at all when it named -// no status (see statusConditions). -func lowerResponse(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, r *soa.Response, code string, conds ir.ResponseConditions, rptr jsontext.Pointer) (ir.Response, []ir.Diagnostic) { - parts, diags := lowerResponseParts(c, ts, anchors, r, code, rptr) +// no status (see statusConditions). ref is the entry the document wrote there, +// which carries the summary and description a Reference Object may put beside +// its $ref. +func lowerResponse(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, r *soa.Response, code string, conds ir.ResponseConditions, rptr jsontext.Pointer, ref resolve.SiblingDocs) (ir.Response, []ir.Diagnostic) { + parts, diags := lowerResponseParts(c, ts, anchors, r, code, rptr, ref) resp := ir.Response{ Name: parts.name, Conditions: conds, @@ -915,7 +917,13 @@ type responseParts struct { // whatever fallback the first mount passed — so two fallbacks, "response" here // and "error" there, renamed the type on a reordering of two paths. One // fallback, passed from one place, cannot. -func lowerResponseParts(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, r *soa.Response, code string, rptr jsontext.Pointer) (responseParts, []ir.Diagnostic) { +// +// The use-site docs fold belongs here rather than at either caller for the same +// reason: a Reference Object's summary and description describe this mount and +// override the declaration's, and they have to do so identically whichever +// status class read the entry (GitHub #610). ref is the entry the document wrote +// at this position, whose siblings Populate fills only when it really is a $ref. +func lowerResponseParts(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, r *soa.Response, code string, rptr jsontext.Pointer, ref resolve.SiblingDocs) (responseParts, []ir.Diagnostic) { headers, diags := lowerHeaders(c, ts, anchors, r.GetHeaders(), rptr) payload, payloadDiags := lowerPayload(c, ts, anchors, r.GetContent(), rptr, ids.DeclarationHint(rptr, "response")) diags = append(diags, payloadDiags...) @@ -923,7 +931,7 @@ func lowerResponseParts(c lowering.Ctx, ts *compile.Types, anchors *schema.Ancho name: responseName(code), payload: payload, headers: headers, - docs: ir.Docs{Description: r.GetDescription()}, + docs: resolve.RefDocs(ref, ir.Docs{Description: r.GetDescription()}), } return parts, append(diags, preserveResponseExtras(c, &parts.unmodeled, r, rptr)...) } @@ -997,9 +1005,11 @@ func responseName(code string) ir.Naming { // // code is the responses-map key it was declared under, which is the only record // of how the source spelled a status its range cannot state — "4XX" and -// "default" both, though only the second reaches the IR unchanged. -func lowerErrorCase(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, r *soa.Response, code string, rng ir.StatusRange, rptr jsontext.Pointer) (ir.ErrorCase, []ir.Diagnostic) { - parts, diags := lowerResponseParts(c, ts, anchors, r, code, rptr) +// "default" both, though only the second reaches the IR unchanged. ref is the +// document's entry at this position, carrying any Reference Object siblings the +// shared lowerResponseParts folds over the declaration's docs. +func lowerErrorCase(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, r *soa.Response, code string, rng ir.StatusRange, rptr jsontext.Pointer, ref resolve.SiblingDocs) (ir.ErrorCase, []ir.Diagnostic) { + parts, diags := lowerResponseParts(c, ts, anchors, r, code, rptr, ref) ec := ir.ErrorCase{ Name: parts.name, Conditions: ir.ResponseConditions{StatusCodes: []ir.StatusRange{rng}}, diff --git a/compilers/openapi/internal/operation/operations_test.go b/compilers/openapi/internal/operation/operations_test.go index 22d23699..dc2844ab 100644 --- a/compilers/openapi/internal/operation/operations_test.go +++ b/compilers/openapi/internal/operation/operations_test.go @@ -2930,3 +2930,95 @@ components: "a key of the operation's map is no fault of the component it resolved to: %v", d) } } + +// TestResponses_RefSiteDocsOverrideTheDeclaration pins the response half of +// GitHub #610 at both status classes, and the per-mount rule: one component +// mounted three times — a success with a sibling description, an error with a +// different one, and a success with none — must give each mount its own docs +// and leave the declaration's description for the mount that writes none. +func TestResponses_RefSiteDocsOverrideTheDeclaration(t *testing.T) { + t.Parallel() + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + get: + operationId: a + responses: + "200": {$ref: '#/components/responses/Ok', description: as a returns it} + /b: + get: + operationId: b + responses: + "404": {$ref: '#/components/responses/Ok', description: as b fails} + /c: + get: + operationId: c + responses: + "200": {$ref: '#/components/responses/Ok'} +components: + responses: + Ok: + description: declared + content: + application/json: {schema: {type: object, properties: {n: {type: string}}}} +` + doc, _, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + a := openapitest.FindOp(t, doc, "a") + require.Len(t, a.Responses, 1) + assert.Equal(t, "as a returns it", a.Responses[0].Docs.Description) + + b := openapitest.FindOp(t, doc, "b") + require.Len(t, b.Errors, 1) + assert.Equal(t, "as b fails", b.Errors[0].Docs.Description, + "an error case is lowered by the same shared function, so it folds too") + + c := openapitest.FindOp(t, doc, "c") + require.Len(t, c.Responses, 1) + assert.Equal(t, "declared", c.Responses[0].Docs.Description, + "a mount with no siblings keeps the declaration's description") +} + +// TestRefSiteDocs_MountsKeepTheirOwnInEitherOrder is the hand-rolled two-order +// diff for GitHub #610. A use-site summary or description is neither part of the +// type registry nor a diagnostic, so the in-package order-invariance oracle does +// not reach it. One component referenced by two operations with different +// overrides, declared in both orders, must give each mount its own docs. +func TestRefSiteDocs_MountsKeepTheirOwnInEitherOrder(t *testing.T) { + t.Parallel() + const first = ` /a: + get: + operationId: a + parameters: [{$ref: '#/components/parameters/Limit', description: from a}] + responses: {"200": {description: ok}} +` + const second = ` /b: + get: + operationId: b + parameters: [{$ref: '#/components/parameters/Limit', description: from b}] + responses: {"200": {description: ok}} +` + projection := func(paths string) map[string]string { + t.Helper() + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: +` + paths + `components: + parameters: + Limit: {name: limit, in: query, description: declared, schema: {type: integer}} +` + doc, _, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + out := map[string]string{} + for _, name := range []string{"a", "b"} { + p, ok := paramBySource(openapitest.FindOp(t, doc, name), "limit") + require.True(t, ok) + out[name] = p.Docs.Description + } + return out + } + assert.Empty(t, cmp.Diff(projection(first+second), projection(second+first)), + "each mount keeps its own override whichever was lowered first") +} diff --git a/compilers/openapi/internal/operation/params.go b/compilers/openapi/internal/operation/params.go index 6c29018b..881f4c12 100644 --- a/compilers/openapi/internal/operation/params.go +++ b/compilers/openapi/internal/operation/params.go @@ -38,6 +38,11 @@ func lowerParameters(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIn } param, binding, paramDiags := lowerParameter(c, ts, anchors, p, pptr) diags = append(diags, paramDiags...) + // A Reference Object may write summary and description beside its $ref, and + // they describe this use of the declaration rather than the declaration — + // so they win over what the target's own object produced, field by field. + // The parser presents the siblings and nothing read them (GitHub #610). + param.Docs = resolve.RefDocs(sp.ref, param.Docs) logical = append(logical, param) bindings = append(bindings, binding) } diff --git a/compilers/openapi/internal/operation/params_test.go b/compilers/openapi/internal/operation/params_test.go index eca78f47..337fc144 100644 --- a/compilers/openapi/internal/operation/params_test.go +++ b/compilers/openapi/internal/operation/params_test.go @@ -1010,3 +1010,54 @@ func TestParams_ExclusiveModifierWithNoBoundIsKeptOnTheParameter(t *testing.T) { "/paths/~1x/get/parameters/0/schema"), "bounds nothing", "and reading it is what reports on it") } + +// paramBySource returns the operation's logical parameter with the given source +// name. +func paramBySource(op ir.Operation, name string) (ir.Parameter, bool) { + for _, p := range op.Params { + if p.Name.Source == name { + return p, true + } + } + return ir.Parameter{}, false +} + +// TestParams_RefSiteDocsOverrideTheDeclaration pins the parameter half of +// GitHub #610: a Reference Object's summary and description describe this use of +// the component and override the declaration's own description field by field. +// One component is referenced twice — once with both siblings, once with neither +// — so the override is shown to be per-mount rather than a mutation of the +// declaration. +func TestParams_RefSiteDocsOverrideTheDeclaration(t *testing.T) { + t.Parallel() + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a: + get: + operationId: a + parameters: [{$ref: '#/components/parameters/Limit', summary: S, description: D}] + responses: {"200": {description: ok}} + /b: + get: + operationId: b + parameters: [{$ref: '#/components/parameters/Limit'}] + responses: {"200": {description: ok}} +components: + parameters: + Limit: {name: limit, in: query, description: declared, schema: {type: integer}} +` + doc, _, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + withSiblings, ok := paramBySource(openapitest.FindOp(t, doc, "a"), "limit") + require.True(t, ok) + assert.Equal(t, "S", withSiblings.Docs.Summary) + assert.Equal(t, "D", withSiblings.Docs.Description) + + without, ok := paramBySource(openapitest.FindOp(t, doc, "b"), "limit") + require.True(t, ok) + assert.Empty(t, without.Docs.Summary, "no sibling means no summary is invented") + assert.Equal(t, "declared", without.Docs.Description, + "the other mount keeps the declaration's own description") +} diff --git a/compilers/openapi/internal/resolve/entry.go b/compilers/openapi/internal/resolve/entry.go index 08426213..8b8ab74a 100644 --- a/compilers/openapi/internal/resolve/entry.go +++ b/compilers/openapi/internal/resolve/entry.go @@ -4,6 +4,8 @@ import ( "encoding/json/jsontext" "github.com/speakeasy-api/openapi/references" + + "github.com/dexpace/morphic/ir" ) // Referenced is the method set every soa "Referenced*" alias exposes: it @@ -43,6 +45,40 @@ func Object[T, S any, R interface { return ref.GetResolvedObject() } +// SiblingDocs is the method set a speakeasy Reference wrapper exposes for the +// summary and description a document may write beside a `$ref`: every +// Referenced* alias is Reference[T, V, C], whose Populate reads these two only +// when the entry actually holds a reference, so an inline entry reports both +// empty whatever the object itself declares. +// +// It is exported because the sites that fold the pair over a declaration's docs +// are a layer up, and one of them passes the wrapper into a shared lowering +// rather than applying the fold itself. +type SiblingDocs interface { + GetSummary() string + GetDescription() string +} + +// RefDocs folds the summary and description written beside a `$ref` over the +// docs the resolved declaration carries, field by field: a sibling the entry +// writes wins because it describes *this* use of the declaration, and one it +// omits leaves the declaration's value in place. OpenAPI says such a sibling has +// "no effect" where the referenced object type does not allow one, but the IR +// has a docs field at every position this is applied at and dropping a declared +// sibling in silence is the defect the fold exists to fix (ir-design §12.2). +// +// The getters are nil-receiver tolerant, so an inline entry — whose wrapper +// carries no siblings at all — needs no guard and reports nothing. +func RefDocs(ref SiblingDocs, docs ir.Docs) ir.Docs { + if summary := ref.GetSummary(); summary != "" { + docs.Summary = summary + } + if description := ref.GetDescription(); description != "" { + docs.Description = description + } + return docs +} + // maxRefChain bounds how many $ref hops ObjectAt follows to a declaration // (styleguide bounded-everything rule). A $ref cycle among the non-schema // components this walks is refused before lowering by speakeasy's resolver diff --git a/compilers/openapi/internal/resolve/entry_test.go b/compilers/openapi/internal/resolve/entry_test.go index eb0aac48..17db84f9 100644 --- a/compilers/openapi/internal/resolve/entry_test.go +++ b/compilers/openapi/internal/resolve/entry_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" "github.com/speakeasy-api/openapi/marshaller" soa "github.com/speakeasy-api/openapi/openapi" "github.com/speakeasy-api/openapi/references" @@ -302,3 +303,49 @@ func TestObject_NilEntryIsNotDereferenced(t *testing.T) { assert.Nil(t, resolve.Object[int, brittleEntry](ref)) } + +// TestRefDocs_UseSiteSiblingsOverrideTheDeclaration pins the fold every +// reference-site docs lowering calls: a summary or description written beside a +// $ref wins field by field over the declaration's, and an entry that writes +// neither leaves both values standing. A hand-built Reference is enough here — +// the fold reads only the two getters, and the fixture proves which one wins +// rather than that the parser presents one. +func TestRefDocs_UseSiteSiblingsOverrideTheDeclaration(t *testing.T) { + t.Parallel() + summary, description := "use-site summary", "use-site description" + declared := ir.Docs{Summary: "declared summary", Description: "declared description"} + + for _, tc := range []struct { + name string + ref *soa.ReferencedParameter + want ir.Docs + }{ + { + name: "both siblings win", + ref: &soa.ReferencedParameter{Summary: &summary, Description: &description}, + want: ir.Docs{Summary: "use-site summary", Description: "use-site description"}, + }, + { + // The parser fills these two only for an entry that really is a $ref + // (Reference.Populate), so an inline entry arrives with both nil. + name: "an inline entry writes neither", + ref: &soa.ReferencedParameter{}, + want: declared, + }, + { + name: "a description alone leaves the declaration's summary standing", + ref: &soa.ReferencedParameter{Description: &description}, + want: ir.Docs{Summary: "declared summary", Description: "use-site description"}, + }, + { + name: "a nil wrapper reports nothing", + ref: nil, + want: declared, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Empty(t, cmp.Diff(tc.want, resolve.RefDocs(tc.ref, declared))) + }) + } +} diff --git a/docs/ir-design.md b/docs/ir-design.md index 679e8a06..9deeb723 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1967,6 +1967,18 @@ another — a `description` beside the `$ref` says what *this* input is, replaci the two can never both be true at once, and any consumer reading both would need this precedence rule anyway. Applying it once, in the compiler, is what keeps every carrier alike. +**A Reference Object's own siblings are the annotations of the use site.** OpenAPI lets the entry +that writes a `$ref` carry `summary` and `description` beside it, and the pair describes *that use* +of the referenced object rather than the object. Where the IR has a docs field at the position — a +parameter, a response or error case, a header, an example, a request body, a security scheme — both +are read off the entry and folded over the declaration's docs field by field, a sibling the entry +writes winning and one it omits leaving the declaration's value standing. The fold applies at every +version whose parser presents the siblings, which is all of them: the Reference Object's wrapper is +version-independent, and dropping a declared sibling in silence is the defect the rule fixes. It is +a documented departure from OpenAPI's clause that such a sibling has "no effect" where the +referenced object type does not allow one: the IR has a docs field at each of these positions, +keeping it loses nothing, and the alternative is the silent loss the clause permitted. + **Constraints do not merge, and are never copied to a use site.** Bounds *conjoin*: `maxLength: 64` on the referent and `maxLength: 100` beside the `$ref` are both in force, and the admitted value is the narrower of the two. There is no precedence to apply — merging with use-site precedence would diff --git a/testdata/conformance/openapi/ref-site-docs.golden.json b/testdata/conformance/openapi/ref-site-docs.golden.json new file mode 100644 index 00000000..fec412fe --- /dev/null +++ b/testdata/conformance/openapi/ref-site-docs.golden.json @@ -0,0 +1,530 @@ +{ + "irVersion": "0.6.0", + "name": "RefSiteDocs", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "RefSiteDocs", + "canonical": "ref_site_docs" + }, + "docs": {}, + "groups": [ + { + "name": { + "hint": "default" + }, + "docs": {}, + "operations": [ + { + "id": "op/openapi/paths/~1widgets/get", + "name": { + "source": "listWidgets", + "canonical": "list_widgets" + }, + "docs": {}, + "params": [ + { + "name": { + "source": "limit", + "canonical": "limit" + }, + "type": { + "target": "t/prim/integer", + "nullable": false + }, + "required": false, + "docs": { + "summary": "Page size", + "description": "How many widgets to return." + }, + "provenance": { + "source": 0, + "pointer": "/components/parameters/Limit" + } + } + ], + "request": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/anon/components/requestBodies/WidgetBody/content/application~1json/schema", + "nullable": false + } + } + ], + "required": false, + "docs": { + "description": "The widget to create." + } + }, + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/anon/components/responses/Listing/content/application~1json/schema", + "nullable": false + }, + "examples": [ + { + "name": "one", + "summary": "One page", + "description": "A single page of results.", + "value": { + "kind": "object", + "object": [ + { + "name": "items", + "value": { + "kind": "list" + } + } + ] + } + } + ] + } + ] + }, + "headers": [ + { + "id": "p/openapi/components/responses/Listing/headers/X-Rate-Unit", + "name": { + "source": "X-Rate-Unit", + "canonical": "x_rate_unit" + }, + "wireName": "X-Rate-Unit", + "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": { + "description": "The unit a rate limit is stated in." + }, + "provenance": { + "source": 0, + "pointer": "/components/responses/Listing/headers/X-Rate-Unit" + } + } + ], + "docs": { + "description": "The listing, as this operation returns it." + } + } + ], + "errors": [ + { + "name": { + "hint": "404" + }, + "conditions": { + "statusCodes": [ + { + "from": 404, + "to": 404 + } + ] + }, + "fault": "client", + "docs": { + "description": "No such widget." + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "GET", + "uriTemplate": "/widgets", + "sharedRoute": false, + "paramBindings": [ + { + "param": "limit", + "location": "query", + "wireName": "limit", + "style": "form", + "explode": true, + "allowReserved": false + } + ], + "requestContentTypes": [ + "application/json" + ], + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1widgets/get" + } + }, + { + "id": "op/openapi/paths/~1gadgets/get", + "name": { + "source": "listGadgets", + "canonical": "list_gadgets" + }, + "docs": {}, + "params": [ + { + "name": { + "source": "limit", + "canonical": "limit" + }, + "type": { + "target": "t/prim/integer", + "nullable": false + }, + "required": false, + "docs": { + "description": "How many gadgets to return." + }, + "provenance": { + "source": 0, + "pointer": "/components/parameters/Limit" + } + } + ], + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/anon/components/responses/Listing/content/application~1json/schema", + "nullable": false + }, + "examples": [ + { + "name": "one", + "summary": "One page", + "description": "A single page of results.", + "value": { + "kind": "object", + "object": [ + { + "name": "items", + "value": { + "kind": "list" + } + } + ] + } + } + ] + } + ] + }, + "headers": [ + { + "id": "p/openapi/components/responses/Listing/headers/X-Rate-Unit", + "name": { + "source": "X-Rate-Unit", + "canonical": "x_rate_unit" + }, + "wireName": "X-Rate-Unit", + "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": { + "description": "The unit a rate limit is stated in." + }, + "provenance": { + "source": 0, + "pointer": "/components/responses/Listing/headers/X-Rate-Unit" + } + } + ], + "docs": { + "description": "A page of results." + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "GET", + "uriTemplate": "/gadgets", + "sharedRoute": false, + "paramBindings": [ + { + "param": "limit", + "location": "query", + "wireName": "limit", + "style": "form", + "explode": true, + "allowReserved": false + } + ], + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1gadgets/get" + } + } + ] + } + ], + "auth": [ + { + "schemes": [ + { + "scheme": "auth/openapi/components/securitySchemes/ApiKey" + } + ] + } + ], + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/anon/components/requestBodies/WidgetBody/content/application~1json/schema": { + "kind": "model", + "id": "t/anon/components/requestBodies/WidgetBody/content/application~1json/schema", + "name": { + "hint": "widget_body" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/requestBodies/WidgetBody/content/application~1json/schema" + }, + "properties": [ + { + "id": "p/openapi/components/requestBodies/WidgetBody/content/application~1json/schema/properties/sku", + "name": { + "source": "sku", + "canonical": "sku" + }, + "wireName": "sku", + "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/requestBodies/WidgetBody/content/application~1json/schema/properties/sku" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/components/responses/Listing/content/application~1json/schema": { + "kind": "model", + "id": "t/anon/components/responses/Listing/content/application~1json/schema", + "name": { + "hint": "listing" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/responses/Listing/content/application~1json/schema" + }, + "properties": [ + { + "id": "p/openapi/components/responses/Listing/content/application~1json/schema/properties/items", + "name": { + "source": "items", + "canonical": "items" + }, + "wireName": "items", + "type": { + "target": "t/anon/components/responses/Listing/content/application~1json/schema/properties/items", + "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/responses/Listing/content/application~1json/schema/properties/items" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/components/responses/Listing/content/application~1json/schema/properties/items": { + "kind": "list", + "id": "t/anon/components/responses/Listing/content/application~1json/schema/properties/items", + "name": { + "hint": "items" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/responses/Listing/content/application~1json/schema/properties/items" + }, + "elem": { + "target": "t/prim/string", + "nullable": 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" + } + }, + "auth": { + "auth/openapi/components/securitySchemes/ApiKey": { + "id": "auth/openapi/components/securitySchemes/ApiKey", + "name": { + "source": "ApiKey", + "canonical": "api_key" + }, + "kind": "apiKey", + "docs": { + "description": "The key this API expects." + }, + "in": "header", + "keyName": "X-Key", + "provenance": { + "source": 0, + "pointer": "/components/securitySchemes/ApiKey" + } + }, + "auth/openapi/components/securitySchemes/BaseKey": { + "id": "auth/openapi/components/securitySchemes/BaseKey", + "name": { + "source": "BaseKey", + "canonical": "base_key" + }, + "kind": "apiKey", + "docs": { + "description": "The declaration's own scheme description." + }, + "in": "header", + "keyName": "X-Key", + "provenance": { + "source": 0, + "pointer": "/components/securitySchemes/BaseKey" + } + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {} + } + ], + "sources": [ + { + "format": "openapi@3.2", + "path": "ref-site-docs.yaml", + "hash": "be0edf60ca69b8ceba7a6b778853aa1d6de6f40f173d0cb9dc92558120e3ac4f" + } + ] +} diff --git a/testdata/conformance/openapi/ref-site-docs.yaml b/testdata/conformance/openapi/ref-site-docs.yaml new file mode 100644 index 00000000..7fbe72b1 --- /dev/null +++ b/testdata/conformance/openapi/ref-site-docs.yaml @@ -0,0 +1,73 @@ +openapi: 3.2.0 +info: {title: RefSiteDocs, version: "1.0.0"} +paths: + /widgets: + get: + operationId: listWidgets + parameters: + - {$ref: '#/components/parameters/Limit', summary: Page size, description: How many widgets to return.} + requestBody: {$ref: '#/components/requestBodies/WidgetBody', description: The widget to create.} + responses: + "200": + $ref: '#/components/responses/Listing' + description: The listing, as this operation returns it. + "404": + $ref: '#/components/responses/Missing' + description: No such widget. + /gadgets: + get: + operationId: listGadgets + parameters: + - {$ref: '#/components/parameters/Limit', description: How many gadgets to return.} + responses: + "200": {$ref: '#/components/responses/Listing'} +components: + parameters: + Limit: + name: limit + in: query + description: The declaration's own description. + schema: {type: integer} + requestBodies: + WidgetBody: + description: The declaration's own body description. + content: + application/json: + schema: {type: object, properties: {sku: {type: string}}} + responses: + Listing: + description: A page of results. + headers: + X-Rate-Unit: + $ref: '#/components/headers/RateUnit' + description: The unit a rate limit is stated in. + content: + application/json: + schema: {type: object, properties: {items: {type: array, items: {type: string}}}} + examples: + one: + $ref: '#/components/examples/Page' + summary: One page + description: A single page of results. + Missing: + description: Nothing there. + headers: + RateUnit: + description: The declaration's own header description. + schema: {type: string} + examples: + Page: + summary: Declared summary + description: Declared description + value: {items: []} + securitySchemes: + BaseKey: + description: The declaration's own scheme description. + type: apiKey + in: header + name: X-Key + ApiKey: + $ref: '#/components/securitySchemes/BaseKey' + description: The key this API expects. +security: + - ApiKey: [] \ No newline at end of file From 51e72fcfaf02c191145dac13c74cdf4a70f5a448 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Wed, 30 Sep 2026 18:49:53 +0300 Subject: [PATCH 4/9] fix(compilers/openapi): keep a content parameter's whole media type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electTypeSpelling elects a parameter's or header's `content` spelling and returns only the elected media type's schema. Everything else the Media Type Object declares is read by nobody: its example and examples, its x-* and undeclared keys, and the fields only a body media type has an IR home for (itemSchema, itemEncoding, prefixEncoding, encoding). None of them reached a field, an Unmodeled entry or a diagnostic, and the parser-modelled fields produced no unknown-key warning either — so they vanished twice over (#611). contentEntryFields now folds the whole elected object: its examples reach Parameter.Examples / Property.Examples, its x-* and undefined keys are kept under the content entry's own scope (`openapi:content//…`, so two media types cannot collide on one key), and the four content-only fields are kept verbatim with ReasonNoIRHome plus one info each naming the position, since the IR can close that gap by growing the field. Widened past the issue's examples-and-extensions framing: itemSchema/itemEncoding/prefixEncoding/encoding are the same one-field read at the same call, and are shipped here. Unchanged: which spelling wins, the passed-over-schema warning, the "exactly one media type" warning, Property.Encoding.MediaType from the elected media type, and body media types (lowerContent untouched). No ir-design edit: this lands no IR shape. The key form is §12's existing scope rule and the reason is §12's no_ir_home, not §4.8's degradations. --- compilers/openapi/conformance_test.go | 37 ++ .../openapi/internal/operation/content.go | 74 +++- .../internal/operation/content_test.go | 122 ++++++ .../openapi/internal/operation/params.go | 11 +- .../openapi/param-content-fields.golden.json | 368 ++++++++++++++++++ .../openapi/param-content-fields.yaml | 35 ++ 6 files changed, 641 insertions(+), 6 deletions(-) create mode 100644 testdata/conformance/openapi/param-content-fields.golden.json create mode 100644 testdata/conformance/openapi/param-content-fields.yaml diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 8d2ef0cb..ce8dbb0c 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -240,6 +240,7 @@ func conformanceCases() []conformanceCase { {"docs-summary-desc", assertDocsSummaryDesc, []string{"docs-summary-description"}}, {"request-body-docs", assertRequestBodyDocs, []string{"docs-summary-description"}}, {"ref-site-docs", assertRefSiteDocs, []string{"docs-summary-description", "named-objects"}}, + {"param-content-fields", assertParamContentFields, []string{"multi-content"}}, {"extensions-x", assertExtensionsX, []string{"vendor-extensions"}}, {"inline-annotations", assertInlineAnnotations, []string{"vendor-extensions", "inline-anonymous"}}, {"inline-residue", assertInlineResidue, []string{"inline-anonymous"}}, @@ -3119,6 +3120,42 @@ func assertRefSiteDocs(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { "the aliasing entry's siblings do not reach the declaration") } +// assertParamContentFields pins GitHub #611: electing a parameter's or header's +// `content` spelling reads the whole Media Type Object rather than only its +// schema, so the object's examples reach the carrier and what the carrier has no +// home for is kept verbatim under the content entry's own scope. The schema +// spelling beside them elects no media type and records none of it. +func assertParamContentFields(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + op, ok := opByName(doc, "search") + require.True(t, ok) + + filter, ok := paramByName(op, "filter") + require.True(t, ok) + require.Len(t, filter.Examples, 1, "the media type's examples reach the parameter") + assert.Equal(t, "one", filter.Examples[0].Name) + assert.Equal(t, "One kind", filter.Examples[0].Summary) + + const scope = "openapi:content/application~1json/" + assert.Equal(t, ir.ReasonVendorExtension, filter.Unmodeled[scope+"x-note"].Reason) + assert.Equal(t, ir.ReasonNoIRHome, filter.Unmodeled[scope+"itemSchema"].Reason, + "a parser-modelled field with no ir.Parameter home is kept rather than dropped") + + plain, ok := paramByName(op, "plain") + require.True(t, ok) + assert.Empty(t, plain.Examples, "the schema spelling elects no media type to take examples from") + assert.Empty(t, plain.Unmodeled, "nor any content-scoped entry") + + require.Len(t, op.Responses, 1) + require.Len(t, op.Responses[0].Headers, 1) + header := op.Responses[0].Headers[0] + require.Len(t, header.Examples, 1, "the header's content media type contributes its examples too") + assert.Equal(t, "hit", header.Examples[0].Name) + assert.Equal(t, ir.ReasonVendorExtension, header.Unmodeled[scope+"x-hdr"].Reason) + + openapitest.AssertInfoDiagAt(t, diags, + "/paths/~1search/get/parameters/0/content/application~1json/itemSchema") +} + func assertExtensionsX(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { m, ok := doc.Types[namedID("S")].(*ir.Model) require.True(t, ok) diff --git a/compilers/openapi/internal/operation/content.go b/compilers/openapi/internal/operation/content.go index 50ad8336..68720c3b 100644 --- a/compilers/openapi/internal/operation/content.go +++ b/compilers/openapi/internal/operation/content.go @@ -443,7 +443,8 @@ func reservedHeaderEntryDiag(c lowering.Ctx, name string, hptr jsontext.Pointer) // 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 string, hptr, hdecl jsontext.Pointer) (ir.Property, []ir.Diagnostic) { - elected, diags := electTypeSpelling(c, h.GetSchema(), h.GetContent(), h.GetRootNode(), hdecl) + elected, diags := electTypeSpelling(c, h.GetSchema(), h.GetContent(), h.GetRootNode(), hdecl, + "header", "ir.Property") // name is this entry's map key, which names the shared node after this mount // when the header is declared under another response (GitHub #433). headerType, headerDiags := schema.CarriedRef(c.NamingByReferenceAt(hptr, hdecl), ts, anchors, @@ -466,6 +467,11 @@ func lowerHeader(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, p.Encoding = &ir.Encoding{MediaType: elected.mediaType} } diags = append(diags, schema.FillPropertyDetail(c, ts, anchors, &p, elected.js, elected.pointer)...) + // The media type object's own examples are more specific than the schema's, + // which FillPropertyDetail has just recorded, so they are applied after it. + if len(elected.examples) > 0 { + p.Examples = elected.examples + } diags = append(diags, applyHeaderAnnotations(c, &p, h, hdecl)...) return p, append(diags, preserveHeaderSerialization(c, &p, h, hdecl)...) } @@ -498,15 +504,68 @@ func preserveHeaderSerialization(c lowering.Ctx, p *ir.Property, h *soa.Header, return diags } +// contentOnlyFields are the Media Type Object fields the IR models at a body's +// content position and gives a parameter or header no home for: the 3.2 +// sequential-media fields, and the multipart per-part encoding block. The +// position lowers to one ir.Parameter or ir.Property holding one type and no +// item or encoding fields, so each of these is kept verbatim instead of dropped +// — the same one-field read electTypeSpelling used to make, widened from the +// media type's schema to the whole object (GitHub #611). +var contentOnlyFields = []string{"itemSchema", "itemEncoding", "prefixEncoding", "encoding"} + +// contentEntryFields returns everything a Media Type Object declares at a +// parameter's or header's elected `content` position beyond the one type that +// position lowers: its example/examples, its x-* and undeclared keys, and the +// fields contentOnlyFields names. +// +// Nothing read any of them, so a document writing `{content: {application/json: +// {schema, example, x-note}}}` lost the example and the extension with no field, +// no Unmodeled entry and no diagnostic — and a parser-modelled field like +// itemSchema produced no census warning either, so it vanished in silence twice +// over (GitHub #611). scope is the content entry's own path, so several media +// types — and the enclosing object's own entries — cannot collide on one key. +// +// carrier and home name the position in the one info per content-only field, +// which is a gap the IR can close by growing the field: ReasonNoIRHome rather +// than a boundary. +func contentEntryFields(c lowering.Ctx, media *soa.MediaType, mediaPtr jsontext.Pointer, + scope, carrier, home string, +) ([]ir.Example, ir.Unmodeled, []ir.Diagnostic) { + examples, diags := exampleList(c, media.GetExample(), media.GetExamples(), mediaPtr) + var unmodeled ir.Unmodeled + ext, extDiags := schema.ExtensionsIn(c, media.GetExtensions(), mediaPtr, scope) + unmodeled = annotation.MergeUnmodeled(unmodeled, ext) + diags = append(diags, extDiags...) + for _, keyword := range contentOnlyFields { + at := mediaPtr + ids.Ptr(keyword) + kept, keptDiags := schema.PreserveNode(c, &unmodeled, + "openapi:"+scope+"/"+ids.Scope(keyword), + annotation.RawChildNode(media.GetRootNode(), keyword), ir.ReasonNoIRHome, at) + diags = append(diags, keptDiags...) + if !kept { + continue + } + diags = append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, at, + "%s content media type %s has no %s home; kept verbatim under Unmodeled", + carrier, keyword, home)) + } + // Last, so the keys the readers above kept are already recorded and the census + // leaves them alone: it answers only for what nothing read. + return examples, unmodeled, + append(diags, annotation.UnknownKeysUnder(&unmodeled, media, c.ProvenanceAt, mediaPtr, scope)...) +} + // 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. +// `schema` spelling — the examples a content-style entry declares, 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 jsontext.Pointer mediaType string unmodeled ir.Unmodeled + examples []ir.Example } // electTypeSpelling picks the spelling a parameter or header states its type @@ -544,16 +603,23 @@ type typeSpelling struct { // 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 jsontext.Pointer, + carrier, home 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 { + mediaPtr := at + ids.Ptr("content", mt) + scope := ids.Scope("content", mt) + examples, residue, residueDiags := contentEntryFields(c, media, mediaPtr, scope, carrier, home) + diags = append(diags, residueDiags...) elected := typeSpelling{ js: media.GetSchema(), - pointer: at + ids.Ptr("content", mt, "schema"), + pointer: mediaPtr + ids.Ptr("schema"), mediaType: mt, + unmodeled: residue, + examples: examples, } diags = append(diags, passedOverSpelling(c, &elected.unmodeled, root, "schema", "content", at)...) return elected, diags diff --git a/compilers/openapi/internal/operation/content_test.go b/compilers/openapi/internal/operation/content_test.go index 675e133d..59eb65a8 100644 --- a/compilers/openapi/internal/operation/content_test.go +++ b/compilers/openapi/internal/operation/content_test.go @@ -2,6 +2,8 @@ package operation_test import ( "encoding/json/jsontext" + "maps" + "slices" "strings" "testing" @@ -2102,3 +2104,123 @@ components: require.NotNil(t, op.Request.Docs) assert.Equal(t, "the use site", op.Request.Docs.Description) } + +// TestParamContent_MediaTypeFieldsReachTheParameter pins GitHub #611 at the +// parameter position: electing a `content` entry used to read only the media +// type's schema, so the object's example/examples, its x-*, its undeclared keys +// and the parser-modelled fields no ir.Parameter holds vanished with no field, +// no Unmodeled entry and no diagnostic. +func TestParamContent_MediaTypeFieldsReachTheParameter(t *testing.T) { + t.Parallel() + spec := `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: + /search: + get: + operationId: search + parameters: + - name: filter + in: query + content: + application/json: + schema: {type: object, properties: {kind: {type: string}}} + examples: + one: {summary: One, value: {kind: a}} + itemSchema: {type: string} + x-note: note + bogus: B + responses: {"200": {description: ok}} +` + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FirstOp(t, svc) + require.Len(t, op.Params, 1) + param := op.Params[0] + + require.Len(t, param.Examples, 1, "the media type's own examples reach the parameter") + assert.Equal(t, "one", param.Examples[0].Name) + assert.Equal(t, "One", param.Examples[0].Summary) + require.NotNil(t, param.Examples[0].Value) + + key := func(name string) string { return "openapi:content/application~1json/" + name } + assert.Equal(t, ir.ReasonVendorExtension, param.Unmodeled[key("x-note")].Reason, + "the media type's x-* survives under the content entry's own scope; got %v", + slices.Sorted(maps.Keys(param.Unmodeled))) + assert.Equal(t, ir.ReasonNoIRHome, param.Unmodeled[key("itemSchema")].Reason, + "a parser-modelled field with no ir.Parameter home is kept verbatim rather than dropped") + assert.Equal(t, ir.ReasonOutOfScope, param.Unmodeled[key("bogus")].Reason, + "an undefined key keeps today's grading") + openapitest.AssertInfoDiagAt(t, diags, "/paths/~1search/get/parameters/0/content/application~1json/itemSchema") +} + +// TestHeaderContent_MediaTypeFieldsReachTheProperty is the header half of GitHub +// #611: the same one-field read is what dropped a content-style header's media +// type fields, so the same fix reaches them on the Property. +func TestHeaderContent_MediaTypeFieldsReachTheProperty(t *testing.T) { + t.Parallel() + spec := `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: + /reports: + get: + operationId: getReport + responses: + "200": + description: ok + headers: + X-Report: + content: + application/json: + schema: {type: object, properties: {hits: {type: integer}}} + examples: + hit: {summary: One hit, value: {hits: 1}} + itemSchema: {type: string} + x-hdr: hdr +` + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + header := openapitest.FirstOp(t, svc).Responses[0].Headers[0] + + require.Len(t, header.Examples, 1, "the media type's own examples reach the header") + assert.Equal(t, "hit", header.Examples[0].Name) + assert.Equal(t, "One hit", header.Examples[0].Summary) + key := func(name string) string { return "openapi:content/application~1json/" + name } + assert.Equal(t, ir.ReasonVendorExtension, header.Unmodeled[key("x-hdr")].Reason) + assert.Equal(t, ir.ReasonNoIRHome, header.Unmodeled[key("itemSchema")].Reason) + openapitest.AssertInfoDiagAt(t, diags, "/paths/~1reports/get/responses/200/headers/X-Report/content/application~1json/itemSchema") +} + +// TestParamAndHeaderSchema_NeverRecordContentFields is the passed-over case: the +// schema spelling elects no media type, so nothing content-scoped can appear on +// the carrier and no info is reported. +func TestParamAndHeaderSchema_NeverRecordContentFields(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpec(` /plain: + get: + operationId: plain + parameters: + - {name: filter, in: query, schema: {type: string}} + responses: + "200": + description: ok + headers: + X-Plain: {schema: {type: string}} +`) + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FirstOp(t, svc) + require.Len(t, op.Params, 1) + for key := range op.Params[0].Unmodeled { + assert.NotContains(t, key, "openapi:content/", "the schema spelling elects no media type") + } + assert.Empty(t, op.Params[0].Examples) + require.Len(t, op.Responses[0].Headers, 1) + for key := range op.Responses[0].Headers[0].Unmodeled { + assert.NotContains(t, key, "openapi:content/") + } + assert.Empty(t, op.Responses[0].Headers[0].Examples) + for _, d := range diags { + assert.NotContains(t, d.Message, "content media type", + "nothing was passed over, so nothing is announced") + } +} diff --git a/compilers/openapi/internal/operation/params.go b/compilers/openapi/internal/operation/params.go index 881f4c12..ba2e4d05 100644 --- a/compilers/openapi/internal/operation/params.go +++ b/compilers/openapi/internal/operation/params.go @@ -131,13 +131,20 @@ func reservedHeaderParamDiag(c lowering.Ctx, name string, in soa.ParameterIn, pp // 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 jsontext.Pointer, name string) []ir.Diagnostic { - elected, diags := electTypeSpelling(c, p.GetSchema(), p.GetContent(), p.GetRootNode(), pptr) + elected, diags := electTypeSpelling(c, p.GetSchema(), p.GetContent(), p.GetRootNode(), pptr, + "parameter", "ir.Parameter") paramType, typeDiags := schema.CarriedRef(c, ts, anchors, schema.TopLevelDepth, elected.js, elected.pointer, name) diags = append(diags, typeDiags...) param.Type = paramType param.Unmodeled = annotation.MergeUnmodeled(param.Unmodeled, elected.unmodeled) binding.ContentType = elected.mediaType - return append(diags, fillParamSchema(c, ts, param, elected.js, elected.pointer)...) + diags = append(diags, fillParamSchema(c, ts, param, elected.js, elected.pointer)...) + // After fillParamSchema, whose schema-derived examples describe the type rather + // than the position: the media type object's own examples are more specific. + if len(elected.examples) > 0 { + param.Examples = elected.examples + } + return diags } // fillParamSchema reads a parameter schema's default value and scalar diff --git a/testdata/conformance/openapi/param-content-fields.golden.json b/testdata/conformance/openapi/param-content-fields.golden.json new file mode 100644 index 00000000..7d670b71 --- /dev/null +++ b/testdata/conformance/openapi/param-content-fields.golden.json @@ -0,0 +1,368 @@ +{ + "irVersion": "0.6.0", + "name": "ParamContentFields", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "ParamContentFields", + "canonical": "param_content_fields" + }, + "docs": {}, + "groups": [ + { + "name": { + "hint": "default" + }, + "docs": {}, + "operations": [ + { + "id": "op/openapi/paths/~1search/get", + "name": { + "source": "search", + "canonical": "search" + }, + "docs": {}, + "params": [ + { + "name": { + "source": "filter", + "canonical": "filter" + }, + "type": { + "target": "t/anon/paths/~1search/get/parameters/0/content/application~1json/schema", + "nullable": false + }, + "required": false, + "docs": {}, + "examples": [ + { + "name": "one", + "summary": "One kind", + "value": { + "kind": "object", + "object": [ + { + "name": "kind", + "value": { + "kind": "string", + "str": "a" + } + } + ] + } + } + ], + "unmodeled": { + "openapi:content/application~1json/itemSchema": { + "reason": "no_ir_home", + "value": { + "type": "string" + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/parameters/0/content/application~1json/itemSchema" + } + }, + "openapi:content/application~1json/x-note": { + "reason": "vendor_extension", + "value": "note", + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/parameters/0/content/application~1json/x-note" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/parameters/0" + } + }, + { + "name": { + "source": "plain", + "canonical": "plain" + }, + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/parameters/1" + } + } + ], + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "headers": [ + { + "id": "p/openapi/paths/~1search/get/responses/200/headers/X-Search", + "name": { + "source": "X-Search", + "canonical": "x_search" + }, + "wireName": "X-Search", + "type": { + "target": "t/anon/paths/~1search/get/responses/200/headers/X-Search/content/application~1json/schema", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "encoding": { + "mediaType": "application/json" + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "examples": [ + { + "name": "hit", + "summary": "One hit", + "value": { + "kind": "object", + "object": [ + { + "name": "hits", + "value": { + "kind": "number", + "num": "1" + } + } + ] + } + } + ], + "docs": {}, + "unmodeled": { + "openapi:content/application~1json/x-hdr": { + "reason": "vendor_extension", + "value": "hdr", + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/responses/200/headers/X-Search/content/application~1json/x-hdr" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/responses/200/headers/X-Search" + } + } + ], + "docs": { + "description": "ok" + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "GET", + "uriTemplate": "/search", + "sharedRoute": false, + "paramBindings": [ + { + "param": "filter", + "location": "query", + "wireName": "filter", + "style": "form", + "explode": true, + "allowReserved": false, + "contentType": "application/json" + }, + { + "param": "plain", + "location": "query", + "wireName": "plain", + "style": "form", + "explode": true, + "allowReserved": false + } + ], + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get" + } + } + ] + } + ], + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/anon/paths/~1search/get/parameters/0/content/application~1json/schema": { + "kind": "model", + "id": "t/anon/paths/~1search/get/parameters/0/content/application~1json/schema", + "name": { + "hint": "filter" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/parameters/0/content/application~1json/schema" + }, + "properties": [ + { + "id": "p/openapi/paths/~1search/get/parameters/0/content/application~1json/schema/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": "/paths/~1search/get/parameters/0/content/application~1json/schema/properties/kind" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/anon/paths/~1search/get/responses/200/headers/X-Search/content/application~1json/schema": { + "kind": "model", + "id": "t/anon/paths/~1search/get/responses/200/headers/X-Search/content/application~1json/schema", + "name": { + "hint": "x_search" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/responses/200/headers/X-Search/content/application~1json/schema" + }, + "properties": [ + { + "id": "p/openapi/paths/~1search/get/responses/200/headers/X-Search/content/application~1json/schema/properties/hits", + "name": { + "source": "hits", + "canonical": "hits" + }, + "wireName": "hits", + "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": "/paths/~1search/get/responses/200/headers/X-Search/content/application~1json/schema/properties/hits" + } + } + ], + "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": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {} + } + ], + "diagnostics": [ + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "parameter content media type itemSchema has no ir.Parameter home; kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/parameters/0/content/application~1json/itemSchema" + } + } + ], + "sources": [ + { + "format": "openapi@3.2", + "path": "param-content-fields.yaml", + "hash": "cdbaec86d358a9611dfe3a87d88981d5495aea6d46b683f6ee9e4e47aec84c2c" + } + ] +} diff --git a/testdata/conformance/openapi/param-content-fields.yaml b/testdata/conformance/openapi/param-content-fields.yaml new file mode 100644 index 00000000..dc93e96f --- /dev/null +++ b/testdata/conformance/openapi/param-content-fields.yaml @@ -0,0 +1,35 @@ +openapi: 3.2.0 +info: {title: ParamContentFields, version: "1.0.0"} +paths: + /search: + get: + operationId: search + parameters: + # A content-style parameter lowers the elected media type's schema to its + # type. Everything else the Media Type Object declares has no + # ir.Parameter home and used to reach the IR in no form at all. + - name: filter + in: query + content: + application/json: + schema: {type: object, properties: {kind: {type: string}}} + examples: + one: {summary: One kind, value: {kind: a}} + itemSchema: {type: string} + x-note: note + # The schema spelling elects no media type, so it records none of the + # content-scoped fields beside it. + - name: plain + in: query + schema: {type: string} + responses: + "200": + description: ok + headers: + X-Search: + content: + application/json: + schema: {type: object, properties: {hits: {type: integer}}} + examples: + hit: {summary: One hit, value: {hits: 1}} + x-hdr: hdr \ No newline at end of file From cb8404a006d0138a3d1b2ce6bb1e549f6a60d072 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Wed, 30 Sep 2026 18:55:36 +0300 Subject: [PATCH 5/9] fix(compilers/openapi): keep 3.2 dataValue and serializedValue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appendValuelessExample dropped anything without `value` or `externalValue` and claimed in its comment that a 3.2 dataValue/serializedValue "carries no example at all". Both fields exist on the parser's Example and neither was read, so a 3.2 document's examples were dropped with a warning that misdescribed them (#612). appendExampleValue now chooses the spelling the entry wrote: - value lowers as before; - dataValue lowers exactly as value does — the parser's values.Value is a yaml node — onto Example.Value, diagnosed at its own node; - externalValue is unchanged; - serializedValue keeps the entry with the raw node under Unmodeled["openapi:serializedValue"] (ReasonNoIRHome) plus one info. It is kept verbatim rather than typed: it is a single-format serialization spelling of the data form Example.Value already holds, and a typed field would put a format-specific representation on a neutral node with no other consumer. The promotion path stays open. The "declares neither value nor externalValue" warning now fires only for a genuinely empty stub, which is what the existing fixture carries; TestContent_ExampleWithoutValueSkipped passes unchanged. The $ref-entry diagnostic pointer rule is unchanged. examples-32 covers the content, parameter, header and components/examples sites the issue names. --- compilers/openapi/conformance_test.go | 43 +++ .../openapi/internal/operation/content.go | 70 ++-- .../internal/operation/content_test.go | 76 +++++ .../openapi/examples-32.golden.json | 306 ++++++++++++++++++ testdata/conformance/openapi/examples-32.yaml | 33 ++ 5 files changed, 510 insertions(+), 18 deletions(-) create mode 100644 testdata/conformance/openapi/examples-32.golden.json create mode 100644 testdata/conformance/openapi/examples-32.yaml diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index ce8dbb0c..7b0a9072 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -241,6 +241,7 @@ func conformanceCases() []conformanceCase { {"request-body-docs", assertRequestBodyDocs, []string{"docs-summary-description"}}, {"ref-site-docs", assertRefSiteDocs, []string{"docs-summary-description", "named-objects"}}, {"param-content-fields", assertParamContentFields, []string{"multi-content"}}, + {"examples-32", assertExamples32, []string{"examples"}}, {"extensions-x", assertExtensionsX, []string{"vendor-extensions"}}, {"inline-annotations", assertInlineAnnotations, []string{"vendor-extensions", "inline-anonymous"}}, {"inline-residue", assertInlineResidue, []string{"inline-anonymous"}}, @@ -3156,6 +3157,48 @@ func assertParamContentFields(t *testing.T, doc *ir.Document, diags []ir.Diagnos "/paths/~1search/get/parameters/0/content/application~1json/itemSchema") } +// assertExamples32 pins GitHub #612 at every site the issue names: the 3.2 +// dataValue lowers like `value` — including through a components/examples $ref — +// and the 3.2 serializedValue keeps the entry with the raw node under Unmodeled +// rather than dropping it with a warning that claimed it declared neither value +// nor externalValue. +func assertExamples32(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + op, ok := opByName(doc, "list") + require.True(t, ok) + + // parameters + limit, ok := paramByName(op, "limit") + require.True(t, ok) + require.Len(t, limit.Examples, 1) + require.NotNil(t, limit.Examples[0].Value, "a parameter's dataValue reaches Example.Value") + assert.Equal(t, ir.BigVal("5"), limit.Examples[0].Value.Num) + + // headers + require.Len(t, op.Responses, 1) + require.Len(t, op.Responses[0].Headers, 1) + headerExample := op.Responses[0].Headers[0].Examples + require.Len(t, headerExample, 1) + assert.Equal(t, ir.ReasonNoIRHome, headerExample[0].Unmodeled["openapi:serializedValue"].Reason, + "a header's serializedValue keeps the entry verbatim") + + // content, and through it components/examples + examples := op.Responses[0].Payload.Contents[0].Examples + require.Len(t, examples, 3) + byName := map[string]ir.Example{} + for _, e := range examples { + byName[e.Name] = e + } + require.NotNil(t, byName["five"].Value) + assert.Equal(t, ir.BigVal("5"), byName["five"].Value.Num) + require.NotNil(t, byName["one"].Value, "a $ref'd components/examples entry lowers its dataValue") + assert.Equal(t, ir.BigVal("1"), byName["one"].Value.Num) + assert.Equal(t, ir.ReasonNoIRHome, byName["serial"].Unmodeled["openapi:serializedValue"].Reason) + assert.Nil(t, byName["serial"].Value) + + openapitest.AssertInfoDiagAt(t, diags, + "/paths/~1a/get/responses/200/content/application~1json/examples/serial/serializedValue") +} + func assertExtensionsX(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { m, ok := doc.Types[namedID("S")].(*ir.Model) require.True(t, ok) diff --git a/compilers/openapi/internal/operation/content.go b/compilers/openapi/internal/operation/content.go index 68720c3b..9c04e539 100644 --- a/compilers/openapi/internal/operation/content.go +++ b/compilers/openapi/internal/operation/content.go @@ -771,33 +771,67 @@ func appendPluralExample(c lowering.Ctx, out []ir.Example, re *soa.ReferencedExa } // appendExampleValue appends the entry's value under the annotations proto -// already carries, stamping the failure pointer where the value is written: at -// the reference site for a $ref entry, which holds no `value` node of its own, -// and at its own `value` for an inline one. +// already carries, choosing the spelling the entry wrote it with: the 3.1 +// `value`, the 3.2 `dataValue` — the same example in data form, and the +// parser's values.Value is a yaml node, so it lowers and is diagnosed exactly as +// `value` is — the spec-legal `externalValue`, and last the 3.2 `serializedValue` +// beside that. func appendExampleValue(c lowering.Ctx, out []ir.Example, proto ir.Example, ex *soa.Example, re *soa.ReferencedExample, pointer jsontext.Pointer, name string, ) ([]ir.Example, []ir.Diagnostic) { - node := ex.GetValue() - if node == nil { - return appendValuelessExample(c, out, proto, pointer, name) + if node := ex.GetValue(); node != nil { + return appendExampleData(c, out, proto, re, node, pointer, name, "value") } + // dataValue is not dropped: it carries the example's data, ir.Example.Value is + // its home, and the parser models the field so the census never saw it either + // (GitHub #612). + if data := ex.GetDataValue(); data != nil { + return appendExampleData(c, out, proto, re, data, pointer, name, "dataValue") + } + if proto.ExternalURL != "" { + return append(out, proto), nil + } + return appendSerializedExample(c, out, proto, ex, pointer, name) +} + +// appendExampleData lowers one example node under keyword — the spelling it was +// written with — stamping the failure pointer where the value is written: at the +// reference site for a $ref entry, which holds no node of its own, and at its own +// keyword for an inline one. +func appendExampleData(c lowering.Ctx, out []ir.Example, proto ir.Example, + re *soa.ReferencedExample, node *yaml.Node, pointer jsontext.Pointer, name, keyword string, +) ([]ir.Example, []ir.Diagnostic) { if re.IsReference() { return schema.AppendExample(c, out, proto, node, pointer, "examples", name) } - return schema.AppendExample(c, out, proto, node, pointer, "examples", name, "value") + return schema.AppendExample(c, out, proto, node, pointer, "examples", name, keyword) } -// appendValuelessExample records an entry that declares no inline `value`. The -// spec-legal externalValue form is one of these, and ir.Example.ExternalURL is -// its home, so it is kept whole. Any other value-less entry carries no example -// at all — a 3.2 dataValue/serializedValue, or an empty stub — and is dropped -// with a warning rather than in silence. -func appendValuelessExample(c lowering.Ctx, out []ir.Example, proto ir.Example, pointer jsontext.Pointer, name string) ([]ir.Example, []ir.Diagnostic) { - if proto.ExternalURL == "" { - return out, []ir.Diagnostic{c.DiagAt(ir.SeverityWarning, diag.DegradedConstruct, - pointer+ids.Ptr("examples", name), "example declares neither value nor externalValue")} - } - return append(out, proto), nil +// appendSerializedExample records an entry that declares no value, no dataValue +// and no externalValue. The 3.2 `serializedValue` is a single-format +// serialization spelling of the example the entry's own data form would carry, +// and ir.Example has no field for it: a typed field would put a format-specific +// representation on a neutral node with no other consumer, so it is kept +// verbatim with ReasonNoIRHome and announced, which leaves the promotion path +// open (GitHub #612, ir-design §12). +// +// The node's presence is the decision, not the getter: keeping the raw node is +// what makes the entry survive at all, and an entry that declares none of the +// four spells a genuinely empty stub, which keeps today's warning. An entry +// that reached here declaring one the raw mapping does not present — a key +// merged in through `<<` — is reported by PreserveNode itself. +func appendSerializedExample(c lowering.Ctx, out []ir.Example, proto ir.Example, ex *soa.Example, + pointer jsontext.Pointer, name string, +) ([]ir.Example, []ir.Diagnostic) { + at := pointer + ids.Ptr("examples", name, "serializedValue") + kept, diags := schema.PreserveNode(c, &proto.Unmodeled, "openapi:serializedValue", + annotation.RawChildNode(ex.GetRootNode(), "serializedValue"), ir.ReasonNoIRHome, at) + if !kept { + return out, append(diags, c.DiagAt(ir.SeverityWarning, diag.DegradedConstruct, + pointer+ids.Ptr("examples", name), "example declares neither value nor externalValue")) + } + return append(out, proto), append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, at, + "serializedValue has no ir.Example home; kept verbatim under Unmodeled")) } // lowerRequestBody lowers an operation's request body onto op.Request and the diff --git a/compilers/openapi/internal/operation/content_test.go b/compilers/openapi/internal/operation/content_test.go index 59eb65a8..2e00b207 100644 --- a/compilers/openapi/internal/operation/content_test.go +++ b/compilers/openapi/internal/operation/content_test.go @@ -2224,3 +2224,79 @@ func TestParamAndHeaderSchema_NeverRecordContentFields(t *testing.T) { "nothing was passed over, so nothing is announced") } } + +// TestExamples_DataValueReachesTheValue pins GitHub #612's first half: the 3.2 +// dataValue carries the example's data, and ir.Example.Value is its home, so it +// lowers exactly as `value` does rather than being dropped with a warning that +// claimed it declared neither value nor externalValue. +func TestExamples_DataValueReachesTheValue(t *testing.T) { + t.Parallel() + spec := `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: + /a: + get: + operationId: a + responses: + "200": + description: ok + content: + application/json: + schema: {type: object, properties: {n: {type: string}}} + examples: + five: {summary: Five, dataValue: 5} +` + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + examples := openapitest.FirstOp(t, svc).Responses[0].Payload.Contents[0].Examples + require.Len(t, examples, 1, "a dataValue example is kept, not dropped") + assert.Equal(t, "five", examples[0].Name) + assert.Equal(t, "Five", examples[0].Summary) + require.NotNil(t, examples[0].Value, "dataValue lands on Example.Value") + assert.Equal(t, ir.ValueNumber, examples[0].Value.Kind) + assert.Equal(t, ir.BigVal("5"), examples[0].Value.Num) + for _, d := range diags { + assert.NotContains(t, d.Message, "neither value nor externalValue", + "a dataValue declares an example, so nothing is announced as empty") + } +} + +// TestExamples_SerializedValueIsKeptVerbatim pins GitHub #612's second half: a +// 3.2 serializedValue is a single-format spelling ir.Example has no field for, so +// the entry survives with the raw node under Unmodeled and one info — the entry +// is not dropped, and it is not announced as empty either. +func TestExamples_SerializedValueIsKeptVerbatim(t *testing.T) { + t.Parallel() + spec := `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: + /a: + get: + operationId: a + responses: + "200": + description: ok + content: + application/json: + schema: {type: object, properties: {n: {type: string}}} + examples: + serial: {summary: Serial, serializedValue: '"5"'} +` + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + examples := openapitest.FirstOp(t, svc).Responses[0].Payload.Contents[0].Examples + require.Len(t, examples, 1, "the entry is kept rather than dropped") + assert.Equal(t, "serial", examples[0].Name) + assert.Nil(t, examples[0].Value, "serializedValue is not read into Value") + raw, ok := examples[0].Unmodeled["openapi:serializedValue"] + require.True(t, ok, "the serialized spelling is kept verbatim") + assert.Equal(t, ir.ReasonNoIRHome, raw.Reason) + assert.JSONEq(t, `"\"5\""`, string(raw.Value), "the source text is kept as written") + + for _, d := range diags { + assert.NotContains(t, d.Message, "neither value nor externalValue", + "the entry declares a serializedValue, so it is not announced as empty") + } + openapitest.AssertInfoDiagAt(t, diags, + "/paths/~1a/get/responses/200/content/application~1json/examples/serial/serializedValue") +} diff --git a/testdata/conformance/openapi/examples-32.golden.json b/testdata/conformance/openapi/examples-32.golden.json new file mode 100644 index 00000000..29c5d837 --- /dev/null +++ b/testdata/conformance/openapi/examples-32.golden.json @@ -0,0 +1,306 @@ +{ + "irVersion": "0.6.0", + "name": "Examples32", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "Examples32", + "canonical": "examples_32" + }, + "docs": {}, + "groups": [ + { + "name": { + "hint": "default" + }, + "docs": {}, + "operations": [ + { + "id": "op/openapi/paths/~1a/get", + "name": { + "source": "list", + "canonical": "list" + }, + "docs": {}, + "params": [ + { + "name": { + "source": "limit", + "canonical": "limit" + }, + "type": { + "target": "t/prim/integer", + "nullable": false + }, + "required": false, + "docs": {}, + "examples": [ + { + "name": "five", + "summary": "Five", + "value": { + "kind": "number", + "num": "5" + } + } + ], + "provenance": { + "source": 0, + "pointer": "/paths/~1a/get/parameters/0" + } + } + ], + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/anon/paths/~1a/get/responses/200/content/application~1json/schema", + "nullable": false + }, + "examples": [ + { + "name": "five", + "summary": "Five", + "value": { + "kind": "number", + "num": "5" + } + }, + { + "name": "one", + "summary": "One", + "value": { + "kind": "number", + "num": "1" + } + }, + { + "name": "serial", + "summary": "Serial", + "unmodeled": { + "openapi:serializedValue": { + "reason": "no_ir_home", + "value": "\"5\"", + "provenance": { + "source": 0, + "pointer": "/paths/~1a/get/responses/200/content/application~1json/examples/serial/serializedValue" + } + } + } + } + ] + } + ] + }, + "headers": [ + { + "id": "p/openapi/paths/~1a/get/responses/200/headers/X-Rate", + "name": { + "source": "X-Rate", + "canonical": "x_rate" + }, + "wireName": "X-Rate", + "type": { + "target": "t/prim/integer", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "examples": [ + { + "name": "serial", + "summary": "Serial", + "unmodeled": { + "openapi:serializedValue": { + "reason": "no_ir_home", + "value": "\"7\"", + "provenance": { + "source": 0, + "pointer": "/paths/~1a/get/responses/200/headers/X-Rate/examples/serial/serializedValue" + } + } + } + } + ], + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1a/get/responses/200/headers/X-Rate" + } + } + ], + "docs": { + "description": "ok" + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "GET", + "uriTemplate": "/a", + "sharedRoute": false, + "paramBindings": [ + { + "param": "limit", + "location": "query", + "wireName": "limit", + "style": "form", + "explode": true, + "allowReserved": false + } + ], + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1a/get" + } + } + ] + } + ], + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/anon/paths/~1a/get/responses/200/content/application~1json/schema": { + "kind": "model", + "id": "t/anon/paths/~1a/get/responses/200/content/application~1json/schema", + "name": { + "hint": "response" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/paths/~1a/get/responses/200/content/application~1json/schema" + }, + "properties": [ + { + "id": "p/openapi/paths/~1a/get/responses/200/content/application~1json/schema/properties/n", + "name": { + "source": "n", + "canonical": "n" + }, + "wireName": "n", + "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/~1a/get/responses/200/content/application~1json/schema/properties/n" + } + } + ], + "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": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {} + } + ], + "diagnostics": [ + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "serializedValue has no ir.Example home; kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/paths/~1a/get/responses/200/headers/X-Rate/examples/serial/serializedValue" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "serializedValue has no ir.Example home; kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/paths/~1a/get/responses/200/content/application~1json/examples/serial/serializedValue" + } + } + ], + "sources": [ + { + "format": "openapi@3.2", + "path": "examples-32.yaml", + "hash": "4dad93a2a77ab678d5b7a6aa67123e3341a5fa5aef8540d1243c3ee03ca80c11" + } + ] +} diff --git a/testdata/conformance/openapi/examples-32.yaml b/testdata/conformance/openapi/examples-32.yaml new file mode 100644 index 00000000..b4385300 --- /dev/null +++ b/testdata/conformance/openapi/examples-32.yaml @@ -0,0 +1,33 @@ +openapi: 3.2.0 +info: {title: Examples32, version: "1.0.0"} +paths: + /a: + get: + operationId: list + parameters: + # A parameter's own plural examples: dataValue carries the data. + - name: limit + in: query + schema: {type: integer} + examples: + five: {summary: Five, dataValue: 5} + responses: + "200": + description: ok + headers: + # A header's plural examples: serializedValue has no ir.Example home. + X-Rate: + schema: {type: integer} + examples: + serial: {summary: Serial, serializedValue: '"7"'} + content: + application/json: + schema: {type: object, properties: {n: {type: string}}} + examples: + five: {summary: Five, dataValue: 5} + one: {$ref: '#/components/examples/One'} + serial: {$ref: '#/components/examples/Serial'} +components: + examples: + One: {summary: One, dataValue: 1} + Serial: {summary: Serial, serializedValue: '"5"'} \ No newline at end of file From 37b9b3c9b975417e6c799f2a7e1b789cbe1b7893 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Wed, 30 Sep 2026 19:06:47 +0300 Subject: [PATCH 6/9] fix(compilers/openapi): keep 3.2 tag parent/kind and nest groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI 3.2 gives a tag a `kind` and a `parent`. The declared-tag registry kept only name/docs/summary/description/externalDocs, so both reached the IR in no form; and grouping took the operation's first tag unconditionally, so an operation whose first tag was a badge was filed under a section that badge does not describe, and a declared hierarchy was flattened (#613). TagDef now records Parent and Kind verbatim — reading a role out of Kind is grouping policy, not a property of the document. Grouping: the group comes from the operation's first *navigational* tag, and a tag's declared parent nests its group under the parent's, one level per declared ancestor, so Service.Groups reads as the tree the document describes. navigational = kind absent or `nav`; any other declared kind is skipped, registered or not, because reading a section out of an unregistered string is an inference (invariant 6). An operation whose every tag is non-navigational falls to the default group, and no diagnostic is reported when a tag is skipped: TagDef keeps the kind and Operation.Tags keeps every membership, so nothing is lost. A parent that is not itself a declared navigational tag ends the walk and the group stays top-level; the parser already reports a missing parent and a circular one. The walk is bounded by the declared tag count. Order is preserved: groups and siblings stay first-seen from operations, and a missing ancestor is created immediately before its first descendant, so every 3.0/3.1 document and every flat golden is byte-identical. internal/archtest's value-selector pin moves from serviceGroups.group to serviceGroups.place on lowerPathItem, which is the method it now calls — same shape (a method on the *serviceGroups parameter), and the other caller still pins group. unwitnessed.golden.txt loses exactly OperationGroup.Groups, TagDef.Kind and TagDef.Parent; tags-grouping-32 is their witness. --- compilers/openapi/conformance_test.go | 36 ++ .../openapi/internal/operation/operations.go | 209 +++++++++++- .../internal/operation/operations_test.go | 323 ++++++++++++++++++ docs/ir-design.md | 17 + internal/archtest/recursion_test.go | 2 +- .../openapi/tags-grouping-32.golden.json | 176 ++++++++++ .../conformance/openapi/tags-grouping-32.yaml | 22 ++ .../openapi/unwitnessed.golden.txt | 3 - 8 files changed, 768 insertions(+), 20 deletions(-) create mode 100644 testdata/conformance/openapi/tags-grouping-32.golden.json create mode 100644 testdata/conformance/openapi/tags-grouping-32.yaml diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 7b0a9072..345e57b7 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -242,6 +242,7 @@ func conformanceCases() []conformanceCase { {"ref-site-docs", assertRefSiteDocs, []string{"docs-summary-description", "named-objects"}}, {"param-content-fields", assertParamContentFields, []string{"multi-content"}}, {"examples-32", assertExamples32, []string{"examples"}}, + {"tags-grouping-32", assertTagsGrouping32, []string{"operation-grouping"}}, {"extensions-x", assertExtensionsX, []string{"vendor-extensions"}}, {"inline-annotations", assertInlineAnnotations, []string{"vendor-extensions", "inline-anonymous"}}, {"inline-residue", assertInlineResidue, []string{"inline-anonymous"}}, @@ -3199,6 +3200,41 @@ func assertExamples32(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { "/paths/~1a/get/responses/200/content/application~1json/examples/serial/serializedValue") } +// assertTagsGrouping32 is the 3.2 counterpart of assertTagsGrouping, and the +// first case in the corpus to witness OperationGroup.Groups: a tag declaring a +// parent nests its group under that parent's, an operation's non-navigational +// tag does not become the group it is filed under, and the declared 3.2 tag +// metadata reaches the registry verbatim. +func assertTagsGrouping32(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { + require.Len(t, doc.TagDefs, 3) + require.Equal(t, "books", doc.TagDefs[0].Name, "TagDefs keep the document's declaration order") + assert.Equal(t, "catalog", doc.TagDefs[0].Parent) + assert.Equal(t, "nav", doc.TagDefs[0].Kind) + assert.Equal(t, "nav", doc.TagDefs[1].Kind) + assert.Equal(t, "badge", doc.TagDefs[2].Kind, "the non-navigational kind is recorded, not interpreted") + assert.Empty(t, doc.TagDefs[2].Parent) + + require.Len(t, doc.Services, 1) + svc := doc.Services[0] + require.Len(t, svc.Groups, 1, "the child nests rather than becoming a second top-level group") + catalog := svc.Groups[0] + assert.Equal(t, "catalog", catalog.Name.Source) + assert.Equal(t, "Everything the library holds", catalog.Docs.Description, + "an ancestor no operation reaches still carries its declared docs") + require.Len(t, catalog.Operations, 1) + assert.Equal(t, "showCatalog", catalog.Operations[0].Name.Source) + + require.Len(t, catalog.Groups, 1) + books := catalog.Groups[0] + assert.Equal(t, "books", books.Name.Source) + assert.Equal(t, "Book operations", books.Docs.Description) + require.Len(t, books.Operations, 1) + listBooks := books.Operations[0] + assert.Equal(t, "listBooks", listBooks.Name.Source) + assert.Equal(t, []string{"beta", "books"}, listBooks.Tags, + "tag membership keeps every tag the operation named, badge included") +} + func assertExtensionsX(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { m, ok := doc.Types[namedID("S")].(*ir.Model) require.True(t, ok) diff --git a/compilers/openapi/internal/operation/operations.go b/compilers/openapi/internal/operation/operations.go index e1ef619c..eb979be2 100644 --- a/compilers/openapi/internal/operation/operations.go +++ b/compilers/openapi/internal/operation/operations.go @@ -156,6 +156,10 @@ func LowerService(ctx context.Context, c lowering.Ctx, ts *compile.Types, anchor // lowerTagDefs registers the document's declared tag metadata into TagDefs; tag // membership itself stays as []string on each tagged operation. +// +// Parent and Kind are recorded as declared (OpenAPI 3.2's tag hierarchy and tag +// role, both dropped until GitHub #613). Reading a role out of Kind is grouping +// policy rather than a property of the document, so it is not interpreted here. func lowerTagDefs(c lowering.Ctx) []ir.TagDef { tags := c.Doc.GetTags() if len(tags) == 0 { @@ -166,7 +170,12 @@ func lowerTagDefs(c lowering.Ctx) []ir.TagDef { if t == nil { continue } - defs = append(defs, ir.TagDef{Name: t.GetName(), Docs: tagDocsFrom(t)}) + defs = append(defs, ir.TagDef{ + Name: t.GetName(), + Docs: tagDocsFrom(t), + Parent: t.GetParent(), + Kind: t.GetKind(), + }) } return defs } @@ -213,20 +222,22 @@ func lowerPathItem(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorInde pathPtr := ids.Ptr("paths", path) var mounted int for _, po := range pathOperations(pi) { - key, name, docs, inferred := groupFor(c, po.src, path) + target := groupFor(c, po.src, path) ptrs := opPointers{mount: pathPtr + po.seg, decl: declPtr + po.seg} opCtx := opContext{ method: po.method, uriTemplate: path, withCallbacks: true, - inferred: inferred, + inferred: target.inferred, ptrs: ptrs, params: mergeParameters(pi.GetParameters(), po.src.GetParameters(), declPtr, ptrs.decl), } op, extra, opDiags := lowerOperation(c, ts, anchors, claims, po.src, opCtx) diags = append(diags, opDiags...) diags = append(diags, applyPathItem(c, onOperation(&op), pi, declPtr)...) - grp := groups.group(key, func() ir.OperationGroup { return ir.OperationGroup{Name: name, Docs: docs} }) + grp := groups.place(target, func(tag string) ir.OperationGroup { + return ir.OperationGroup{Name: compile.NamingFor(tag), Docs: tagDocs(c, tag)} + }) grp.Operations = append(grp.Operations, op) grp.Operations = append(grp.Operations, extra...) mounted++ @@ -291,17 +302,124 @@ func lowerWebhooks(ctx context.Context, c lowering.Ctx, ts *compile.Types, ancho // groupFor resolves the group an operation belongs to under the active strategy. // lowering.GroupByPathPrefix is a heuristic, so it stamps the inferred marker; grouping by // declared tags is a declared fact and leaves it empty. -func groupFor(c lowering.Ctx, src *soa.Operation, path string) (key string, name ir.Naming, docs ir.Docs, inferred string) { +// +// The tag it picks is the operation's first *navigational* tag rather than its +// first tag: OpenAPI 3.2 gives a tag a `kind`, and a tag that declares one this +// compiler does not know is not a section an operation belongs to. An operation +// whose every tag is non-navigational falls to the default group rather than +// being grouped under a badge — inventing a section from a tag that does not +// group is an inference no policy asked for (GitHub #613). +func groupFor(c lowering.Ctx, src *soa.Operation, path string) groupTarget { if c.Grouping == lowering.GroupByPathPrefix { seg := firstPathSegment(path) - return "seg:" + seg, compile.NamingFor(seg), ir.Docs{}, "group-path-prefix" + return groupTarget{key: "seg:" + seg, name: compile.NamingFor(seg), inferred: "group-path-prefix"} } tags := src.GetTags() if len(tags) == 0 { - return "default", compile.NamingHint("default"), ir.Docs{}, "" + return groupTarget{key: "default", name: compile.NamingHint("default")} + } + declared := declaredTags(c.Doc.GetTags()) + for _, name := range tags { + if t := declared[name]; t != nil && !navigational(t.GetKind()) { + continue + } + return groupTarget{ + key: "tag:" + name, + chain: tagChain(declared, name), + name: compile.NamingFor(name), + docs: tagDocs(c, name), + } + } + return groupTarget{key: "default", name: compile.NamingHint("default")} +} + +// groupTarget is where an operation's group comes from: the key it is filed +// under, the declared tag chain it nests under (root-first and ending with the +// operation's own tag, empty for a group the compiler synthesizes), and the +// naming and docs of the operation's own level. +type groupTarget struct { + key string + chain []string + name ir.Naming + docs ir.Docs + inferred string +} + +// navigationalKind is the OpenAPI 3.2 tag kind that groups operations into +// sections. It is the registry's only navigational value; any other declared +// kind is skipped. +const navigationalKind = "nav" + +// navigational reports whether a declared tag kind groups operations into +// sections. A tag that declares no kind is navigational — every 3.0 and 3.1 tag, +// and every tag a document uses without declaring it at all. +// +// Any other declared kind is not, whether or not this compiler's registry names +// it. Reading a section out of an unregistered string is exactly the inference +// invariant 6 forbids, and the grouping is policy besides: no diagnostic is +// reported, because the TagDef still records the kind verbatim and +// Operation.Tags keeps every membership, so nothing about the document is lost. +func navigational(kind string) bool { + return kind == "" || kind == navigationalKind +} + +// declaredTags indexes a document's declared tags by name, first declaration +// winning. Two tags sharing a name are one group however many times they are +// declared, and which of them supplies the kind and parent must not depend on +// the order operations are lowered in. +func declaredTags(tags []*soa.Tag) map[string]*soa.Tag { + out := make(map[string]*soa.Tag, len(tags)) + for _, t := range tags { + if t == nil { + continue + } + if _, seen := out[t.GetName()]; !seen { + out[t.GetName()] = t + } + } + return out +} + +// tagChain returns the chain of declared tags an operation's tag nests under, +// root-first and ending with tag itself. A parent that is not a declared +// navigational tag ends the walk there, and so does a cycle among the declared +// parents: the parser reports a missing parent and a circular one, and this +// lowering takes no position on either — it only declines to build a tree the +// document does not describe. +// +// The walk is bounded by the declared tag count, since no chain can be longer +// than the tags that spell it; the seen set is what stops a cycle first. +func tagChain(declared map[string]*soa.Tag, tag string) []string { + chain := []string{tag} + seen := map[string]bool{tag: true} + cur := tag + for range len(declared) + 1 { + t := declared[cur] + if t == nil { + break + } + parent := t.GetParent() + if parent == "" { + break + } + if seen[parent] { + // A cycle among the declared parents: the document does not describe a + // tree, so this tag stays where it is rather than nesting under one of + // its own descendants. Returning the tag alone is what keeps the + // recorded parent edges acyclic whichever tag the walk starts from — + // the other member of the cycle reaches the same answer from its side. + return []string{tag} + } + ancestor := declared[parent] + if ancestor == nil || !navigational(ancestor.GetKind()) { + break + } + seen[parent] = true + chain = append(chain, parent) + cur = parent } - first := tags[0] - return "tag:" + first, compile.NamingFor(first), tagDocs(c, first), "" + slices.Reverse(chain) + return chain } // tagDocs returns the declared docs for a tag name, or empty when undeclared. @@ -1301,15 +1419,19 @@ func firstPathSegment(path string) string { // serviceGroups accumulates operation groups keyed by a namespaced key while // preserving first-seen insertion order, so a group's operations gather across -// paths without reordering the groups themselves. +// paths without reordering the groups themselves. A group a declared tag nests +// under (OpenAPI 3.2 tag parent) is recorded in parentOf and attached to its +// parent by finalize, so the accumulated groups read as the tree the document +// declares. type serviceGroups struct { - order []string - byKey map[string]*ir.OperationGroup + order []string + byKey map[string]*ir.OperationGroup + parentOf map[string]string } // newServiceGroups returns an empty group accumulator. func newServiceGroups() *serviceGroups { - return &serviceGroups{byKey: make(map[string]*ir.OperationGroup)} + return &serviceGroups{byKey: make(map[string]*ir.OperationGroup), parentOf: make(map[string]string)} } // group returns the group for key, creating it via mk on first sight and @@ -1323,11 +1445,66 @@ func (g *serviceGroups) group(key string, mk func() ir.OperationGroup) *ir.Opera return g.byKey[key] } -// finalize returns the accumulated groups in insertion order. +// place returns the group an operation's target names, creating it on first +// sight. A target naming a tag chain also creates every ancestor no operation +// has reached yet, so a parent a document declares exists even when only its +// children carry operations. +func (g *serviceGroups) place(t groupTarget, mk func(tag string) ir.OperationGroup) *ir.OperationGroup { + if len(t.chain) == 0 { + return g.group(t.key, func() ir.OperationGroup { return ir.OperationGroup{Name: t.name, Docs: t.docs} }) + } + return g.tagGroup(t.chain, mk) +} + +// tagGroup returns the group for the innermost tag of chain, creating each level +// via mk on first sight and recording what each nests under. chain is root-first, +// so a missing ancestor is appended to the insertion order immediately before its +// first descendant — which is what keeps every flat golden's group order exactly +// as it was. +func (g *serviceGroups) tagGroup(chain []string, mk func(tag string) ir.OperationGroup) *ir.OperationGroup { + var parent string + var leaf *ir.OperationGroup + for _, tag := range chain { + key := "tag:" + tag + grp, ok := g.byKey[key] + if !ok { + grp = new(mk(tag)) + g.byKey[key] = grp + g.order = append(g.order, key) + if parent != "" { + g.parentOf[key] = parent + } + } + parent, leaf = key, grp + } + return leaf +} + +// finalize returns the accumulated groups in insertion order, each carrying the +// groups nested under it, and only a group with no parent at the top level. +// +// The tree is assembled in reverse insertion order and without recursion. A +// parent always precedes its child in the order — tagGroup appends a missing +// ancestor immediately before its first descendant, and an ancestor that already +// exists was appended earlier — so one reverse pass attaches every child to a +// parent that is not itself read until its own turn. Prepending a child to its +// parent's slice is what makes siblings read in insertion order too. func (g *serviceGroups) finalize() []ir.OperationGroup { + for i := len(g.order) - 1; i >= 0; i-- { + key := g.order[i] + parent, nested := g.parentOf[key] + if !nested { + continue + } + pg := g.byKey[parent] + pg.Groups = append([]ir.OperationGroup{*g.byKey[key]}, pg.Groups...) + } out := make([]ir.OperationGroup, 0, len(g.order)) - for _, k := range g.order { - out = append(out, *g.byKey[k]) + for _, key := range g.order { + if _, nested := g.parentOf[key]; nested { + continue + } + out = append(out, *g.byKey[key]) } return out } diff --git a/compilers/openapi/internal/operation/operations_test.go b/compilers/openapi/internal/operation/operations_test.go index dc2844ab..8af627db 100644 --- a/compilers/openapi/internal/operation/operations_test.go +++ b/compilers/openapi/internal/operation/operations_test.go @@ -3022,3 +3022,326 @@ paths: assert.Empty(t, cmp.Diff(projection(first+second), projection(second+first)), "each mount keeps its own override whichever was lowered first") } + +// groupBySource returns the top-level group whose name source is name. +func groupBySource(groups []ir.OperationGroup, name string) (ir.OperationGroup, bool) { + for _, g := range groups { + if g.Name.Source == name { + return g, true + } + } + return ir.OperationGroup{}, false +} + +// TestGrouping_NonNavigationalTagIsSkipped pins GitHub #613's first half: an +// operation's first tag may be a badge, which is not a section it belongs to, so +// the group comes from the first *navigational* tag. The badge tag is declared +// first in the operation's `tags` on purpose — the order that was wrong before +// the fix, so reverting the skip groups under the badge and reddens this. +func TestGrouping_NonNavigationalTagIsSkipped(t *testing.T) { + t.Parallel() + spec := `openapi: 3.2.0 +info: {title: T, version: "1"} +tags: + - {name: beta, kind: badge} + - {name: books, kind: nav} +paths: + /books: + get: + operationId: listBooks + tags: [beta, books] + responses: {"200": {description: ok}} +` + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + require.Len(t, svc.Groups, 1, "the badge tag contributes no group") + books, ok := groupBySource(svc.Groups, "books") + require.True(t, ok) + require.Len(t, books.Operations, 1) + assert.Equal(t, "listBooks", books.Operations[0].Name.Source) + _, badge := groupBySource(svc.Groups, "beta") + assert.False(t, badge, "a non-navigational tag never groups") +} + +// TestGrouping_BadgeOnlyOperationFallsToDefault is the other arm: when every tag +// an operation names is non-navigational there is no section to group it under, +// and inventing one from a tag that does not group would be an inference. +func TestGrouping_BadgeOnlyOperationFallsToDefault(t *testing.T) { + t.Parallel() + spec := `openapi: 3.2.0 +info: {title: T, version: "1"} +tags: + - {name: beta, kind: badge} +paths: + /beta: + get: + operationId: betaOp + tags: [beta] + responses: {"200": {description: ok}} +` + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + require.Len(t, svc.Groups, 1) + assert.Equal(t, "default", svc.Groups[0].Name.Hint) + assert.Empty(t, svc.Groups[0].Name.Source) + require.Len(t, svc.Groups[0].Operations, 1) +} + +// TestGrouping_NavigationalChildNestsUnderItsParent pins the tree: a group whose +// tag declares a parent that is itself a navigational declared tag is nested +// under that parent's group. The child is declared before the parent on purpose +// — the parent chain must be built from the declarations rather than from the +// order they arrive in. +func TestGrouping_NavigationalChildNestsUnderItsParent(t *testing.T) { + t.Parallel() + spec := `openapi: 3.2.0 +info: {title: T, version: "1"} +tags: + - {name: books, parent: catalog, kind: nav} + - {name: catalog, description: Everything, kind: nav} +paths: + /books: + get: + operationId: listBooks + tags: [books] + responses: {"200": {description: ok}} +` + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + require.Len(t, svc.Groups, 1, "the child is nested, not a second top-level group") + catalog := svc.Groups[0] + assert.Equal(t, "catalog", catalog.Name.Source) + assert.Empty(t, catalog.Operations, "the ancestor exists to hold its children") + require.Len(t, catalog.Groups, 1) + assert.Equal(t, "books", catalog.Groups[0].Name.Source) + require.Len(t, catalog.Groups[0].Operations, 1) + assert.Equal(t, "listBooks", catalog.Groups[0].Operations[0].Name.Source) +} + +// TestGrouping_AncestorDeclaredAfterItsChildIsStillNested is the same tree with +// the two declarations in the other order, so the nesting cannot depend on which +// of them the document wrote first. +func TestGrouping_AncestorDeclaredAfterItsChildIsStillNested(t *testing.T) { + t.Parallel() + childFirst := `openapi: 3.2.0 +info: {title: T, version: "1"} +tags: + - {name: books, parent: catalog, kind: nav} + - {name: catalog, kind: nav} +paths: + /books: + get: + operationId: listBooks + tags: [books] + responses: {"200": {description: ok}} +` + parentFirst := `openapi: 3.2.0 +info: {title: T, version: "1"} +tags: + - {name: catalog, kind: nav} + - {name: books, parent: catalog, kind: nav} +paths: + /books: + get: + operationId: listBooks + tags: [books] + responses: {"200": {description: ok}} +` + tree := func(spec string) []ir.OperationGroup { + t.Helper() + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + return svc.Groups + } + assert.Empty(t, cmp.Diff(tree(childFirst), tree(parentFirst)), + "the group tree must not depend on the declaration order") +} + +// TestGrouping_DanglingParentStaysTopLevel pins that a parent naming no declared +// tag ends the walk: the group is top-level rather than nested under a group +// invented for the missing name. +func TestGrouping_DanglingParentStaysTopLevel(t *testing.T) { + t.Parallel() + spec := `openapi: 3.2.0 +info: {title: T, version: "1"} +tags: + - {name: books, parent: nope, kind: nav} +paths: + /books: + get: + operationId: listBooks + tags: [books] + responses: {"200": {description: ok}} +` + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + require.Len(t, svc.Groups, 1) + assert.Equal(t, "books", svc.Groups[0].Name.Source, "the group is top-level") +} + +// TestGrouping_NonNavigationalParentStaysTopLevel holds the parent side of the +// kind rule: a parent that is declared but not navigational does not receive a +// group, so its child stays top-level. +func TestGrouping_NonNavigationalParentStaysTopLevel(t *testing.T) { + t.Parallel() + spec := `openapi: 3.2.0 +info: {title: T, version: "1"} +tags: + - {name: books, parent: beta, kind: nav} + - {name: beta, kind: badge} +paths: + /books: + get: + operationId: listBooks + tags: [books] + responses: {"200": {description: ok}} +` + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + require.Len(t, svc.Groups, 1) + assert.Equal(t, "books", svc.Groups[0].Name.Source) +} + +// TestGrouping_ParentCycleStaysFlat pins that a cycle among the declared parents +// terminates and keeps every operation reachable: the walk stops at the cycle, +// and neither member of it nests under the other. +func TestGrouping_ParentCycleStaysFlat(t *testing.T) { + t.Parallel() + spec := `openapi: 3.2.0 +info: {title: T, version: "1"} +tags: + - {name: a, parent: b, kind: nav} + - {name: b, parent: a, kind: nav} +paths: + /a: + get: + operationId: opA + tags: [a] + responses: {"200": {description: ok}} + /b: + get: + operationId: opB + tags: [b] + responses: {"200": {description: ok}} +` + _, svc, diags := lowerServiceSpec(t, spec) + assert.True(t, openapitest.HasDiag(diags, "openapi/validation/validation-circular-reference"), + "the parser reports the cycle; the lowering only has to terminate") + require.Len(t, svc.Groups, 2, "both groups stay top-level rather than nesting on a cycle") + for _, g := range svc.Groups { + assert.Empty(t, g.Groups) + require.Len(t, g.Operations, 1, "no operation is lost to the cycle") + } +} + +// TestGrouping_UndeclaredTagIsNavigational pins that a tag an operation uses +// without declaring has no kind to read, so it groups exactly as every 3.0 and +// 3.1 tag does and stays top-level. +func TestGrouping_UndeclaredTagIsNavigational(t *testing.T) { + t.Parallel() + _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpec(` /x: + get: + operationId: x + tags: [adHoc] + responses: {"200": {description: ok}} +`)) + openapitest.RequireNoErrorDiags(t, diags) + require.Len(t, svc.Groups, 1) + assert.Equal(t, "adHoc", svc.Groups[0].Name.Source) +} + +// TestGrouping_SameNameDeclaredTwiceIsDeterministic pins that two declarations +// of one tag name resolve the same way whichever order the operations that name +// them are lowered in: the first declaration wins, so a second one declaring a +// different parent cannot move the group midway through the walk. +func TestGrouping_SameNameDeclaredTwiceIsDeterministic(t *testing.T) { + t.Parallel() + spec := `openapi: 3.2.0 +info: {title: T, version: "1"} +tags: + - {name: books, kind: nav} + - {name: books, parent: catalog, kind: nav} + - {name: catalog, kind: nav} +paths: + /books: + get: + operationId: listBooks + tags: [books] + responses: {"200": {description: ok}} +` + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + require.Len(t, svc.Groups, 1, "the first declaration wins for both the kind and the parent") + assert.Equal(t, "books", svc.Groups[0].Name.Source) +} + +// TestTagDefs_RecordParentAndKind pins GitHub #613's second half: the declared +// tag registry keeps the 3.2 parent and kind verbatim, and neither is invented +// for a tag that declares none. +func TestTagDefs_RecordParentAndKind(t *testing.T) { + t.Parallel() + spec := `openapi: 3.2.0 +info: {title: T, version: "1"} +tags: + - {name: books, parent: catalog, kind: nav} + - {name: beta, kind: badge} + - {name: plain} +paths: {} +` + doc, _, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + require.Len(t, doc.TagDefs, 3) + assert.Equal(t, ir.TagDef{Name: "books", Docs: ir.Docs{}, Parent: "catalog", Kind: "nav"}, doc.TagDefs[0]) + assert.Equal(t, "badge", doc.TagDefs[1].Kind) + assert.Empty(t, doc.TagDefs[1].Parent) + assert.Empty(t, doc.TagDefs[2].Parent, "a tag declaring neither keeps both empty") + assert.Empty(t, doc.TagDefs[2].Kind) +} + +// TestGrouping_TreeIsIndependentOfDeclarationOrder is the two-order diff for +// GitHub #613. The group tree is neither the type registry nor a diagnostic, so +// the in-package order-invariance oracle does not see it. Both documents declare +// the same tags and the same operations; only the order of the `tags` list and +// of the badge tag within one operation's `tags` differs, and the tree — nesting, +// sibling order and operations — must be identical. +func TestGrouping_TreeIsIndependentOfDeclarationOrder(t *testing.T) { + t.Parallel() + const paths = `paths: + /books: + get: + operationId: listBooks + tags: [beta, books] + responses: {"200": {description: ok}} + /catalog: + get: + operationId: showCatalog + tags: [catalog] + responses: {"200": {description: ok}} +` + childFirst := `openapi: 3.2.0 +info: {title: T, version: "1"} +tags: + - {name: books, parent: catalog, kind: nav} + - {name: catalog, kind: nav} + - {name: beta, kind: badge} +` + paths + parentFirst := `openapi: 3.2.0 +info: {title: T, version: "1"} +tags: + - {name: beta, kind: badge} + - {name: catalog, kind: nav} + - {name: books, parent: catalog, kind: nav} +` + paths + tree := func(spec string) []ir.OperationGroup { + t.Helper() + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + return svc.Groups + } + got := tree(childFirst) + assert.Empty(t, cmp.Diff(tree(parentFirst), got), "the group tree must not depend on declaration order") + require.Len(t, got, 1, "catalog is the only top-level group") + require.Len(t, got[0].Groups, 1) + assert.Equal(t, "books", got[0].Groups[0].Name.Source) + require.Len(t, got[0].Groups[0].Operations, 1, "the badge tag first in the operation's tags changed nothing") +} diff --git a/docs/ir-design.md b/docs/ir-design.md index 9deeb723..777feee9 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1186,6 +1186,23 @@ OpenAPI compilers build groups from tags (policy-controllable: tag-based vs path TypeSpec from interfaces/namespaces; Smithy from resources; GraphQL yields three groups (query/mutation/subscription); Protobuf one group per `service`; Erlang/OTP one group per module. +**How a tag builds a group.** Two facts decide it, and both are declared rather than inferred. +The group comes from the operation's first *navigational* tag, not simply its first: OpenAPI 3.2 +gives a tag a `kind`, and the only kind that groups is the registry's navigational value (`nav`), +with a tag that declares no kind — every 3.0 and 3.1 tag — navigational too. Any other declared +kind is skipped, whether or not the registry names it; reading a section out of an unregistered +string would be an inference (invariant 6), and no diagnostic is reported because the document +loses nothing — `TagDef.Kind` records the kind verbatim and `Operation.Tags` keeps every +membership. An operation whose every tag is non-navigational falls to the same default group an +operation with no tags reaches, rather than being filed under a tag that does not group. + +A tag's declared `parent` (3.2) nests its group under the parent's, one level per declared +ancestor, so `Service.Groups` reads as the section tree the document describes. A parent that is +not itself a declared navigational tag ends the walk there — the group stays top-level, and the +parser's own report of a missing or circular parent is left to stand. Group and sibling order stay +first-seen from the operations, with a missing ancestor created immediately before its first +descendant, so a document that declares no parents groups exactly as it did before. + ### 7.2 Operation — the protocol-neutral core ```go diff --git a/internal/archtest/recursion_test.go b/internal/archtest/recursion_test.go index d5ccdf70..a6bff841 100644 --- a/internal/archtest/recursion_test.go +++ b/internal/archtest/recursion_test.go @@ -1160,7 +1160,7 @@ func TestLoweringCallGraph_ResolvesAMethodOnAValue(t *testing.T) { edges := [][2]string{ {"dynamicAnchors", "anchorWalk.walk"}, // w := newAnchorWalk(…) {"LowerService", "serviceGroups.finalize"}, // groups := newServiceGroups() - {"lowerPathItem", "serviceGroups.group"}, // a *serviceGroups parameter + {"lowerPathItem", "serviceGroups.place"}, // a *serviceGroups parameter {"lowerWebhooks", "serviceGroups.group"}, // the same, in the other caller {"soleAnchorSite", "AnchorIndex.sites"}, // an *AnchorIndex parameter {"dynamicHop", "AnchorIndex.sites"}, // the same, in the other caller diff --git a/testdata/conformance/openapi/tags-grouping-32.golden.json b/testdata/conformance/openapi/tags-grouping-32.golden.json new file mode 100644 index 00000000..1bd6b62f --- /dev/null +++ b/testdata/conformance/openapi/tags-grouping-32.golden.json @@ -0,0 +1,176 @@ +{ + "irVersion": "0.6.0", + "name": "TagsGrouping32", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "TagsGrouping32", + "canonical": "tags_grouping_32" + }, + "docs": {}, + "groups": [ + { + "name": { + "source": "catalog", + "canonical": "catalog" + }, + "docs": { + "description": "Everything the library holds" + }, + "groups": [ + { + "name": { + "source": "books", + "canonical": "books" + }, + "docs": { + "description": "Book operations" + }, + "operations": [ + { + "id": "op/openapi/paths/~1books/get", + "name": { + "source": "listBooks", + "canonical": "list_books" + }, + "docs": {}, + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "docs": { + "description": "ok" + } + } + ], + "oneWay": false, + "idempotency": {}, + "tags": [ + "beta", + "books" + ], + "bindings": { + "http": [ + { + "method": "GET", + "uriTemplate": "/books", + "sharedRoute": false, + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1books/get" + } + } + ] + } + ], + "operations": [ + { + "id": "op/openapi/paths/~1catalog/get", + "name": { + "source": "showCatalog", + "canonical": "show_catalog" + }, + "docs": {}, + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "docs": { + "description": "ok" + } + } + ], + "oneWay": false, + "idempotency": {}, + "tags": [ + "catalog" + ], + "bindings": { + "http": [ + { + "method": "GET", + "uriTemplate": "/catalog", + "sharedRoute": false, + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1catalog/get" + } + } + ] + } + ], + "provenance": { + "source": 0 + } + } + ], + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {} + } + ], + "tagDefs": [ + { + "name": "books", + "docs": { + "description": "Book operations" + }, + "parent": "catalog", + "kind": "nav" + }, + { + "name": "catalog", + "docs": { + "description": "Everything the library holds" + }, + "kind": "nav" + }, + { + "name": "beta", + "docs": {}, + "kind": "badge" + } + ], + "sources": [ + { + "format": "openapi@3.2", + "path": "tags-grouping-32.yaml", + "hash": "d7b49dca464a7224039a8ba79639d01fd137c71b64ae36de18b308bd455ee113" + } + ] +} diff --git a/testdata/conformance/openapi/tags-grouping-32.yaml b/testdata/conformance/openapi/tags-grouping-32.yaml new file mode 100644 index 00000000..66a06039 --- /dev/null +++ b/testdata/conformance/openapi/tags-grouping-32.yaml @@ -0,0 +1,22 @@ +openapi: 3.2.0 +info: {title: TagsGrouping32, version: "1.0.0"} +tags: + # The child is declared before its parent, and the operation below names the + # badge tag first in its `tags`: both are the orders that were wrong before the + # fix, so a revert reddens this case rather than agreeing with it. + - {name: books, parent: catalog, description: Book operations, kind: nav} + - {name: catalog, description: Everything the library holds, kind: nav} + - {name: beta, kind: badge} +paths: + /books: + get: + operationId: listBooks + tags: [beta, books] + responses: + "200": {description: ok} + /catalog: + get: + operationId: showCatalog + tags: [catalog] + responses: + "200": {description: ok} \ No newline at end of file diff --git a/testdata/conformance/openapi/unwitnessed.golden.txt b/testdata/conformance/openapi/unwitnessed.golden.txt index 1e9d340a..927e99c3 100644 --- a/testdata/conformance/openapi/unwitnessed.golden.txt +++ b/testdata/conformance/openapi/unwitnessed.golden.txt @@ -128,7 +128,6 @@ Operation.Pagination Operation.ParameterVisibility Operation.ReturnTypeVisibility OperationGroup.Availability -OperationGroup.Groups OperationGroup.Resource OperationGroup.Unmodeled Pagination.FirstLink @@ -196,8 +195,6 @@ Service.Servers Service.Version StreamDetail.Initial StreamDetail.RequiresLength -TagDef.Kind -TagDef.Parent TemplateArg.Type TemplateArg.Value TemplateInstantiation.Args From 3e540118e80a2900e6aff90cd078278fc51df9d0 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Wed, 30 Sep 2026 19:24:38 +0300 Subject: [PATCH 7/9] fix(compilers/openapi): read the 3.2 fields the parser does not model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four OpenAPI 3.2 fields reach the IR in no form, each because the bundled parser's model names no member for it — so the unknown-key census, which grades a key by that model, reports a field the document defines as undefined, or says nothing at all while the key is dropped (#615): - Response.summary is now read off the raw node into Docs.Summary, in the one shared lowerResponseParts, so success and error cases both get it. - components/mediaTypes entries are resolved where a content entry references one: the raw entry is unmarshalled (marshaller.UnmarshalNode) and lowered through the ordinary media-type lowering, so a shared media type arrives with its schema, examples and extensions. A target that does not resolve — another document, another section, an undeclared entry, a non-object entry, or an entry that is itself a $ref — lowers as written with one openapi/unresolved-ref and the reference kept verbatim. - xml.nodeType now fills XMLHints.NodeType, which already existed and whose GoDoc already named the version. annotation.Read takes the version fact so the reader and the census agree; the three call sites pass it. - Nested Encoding encoding/prefixEncoding/itemEncoding are kept verbatim under the part's own scope with one info each, at exactly the key and pointer the census uses. The census mechanism is new and version-gated: annotation.UnknownKeysDecided takes the keys a reader has already read raw. Every suppression is passed only for a 3.2 document, so the same key on a 3.1 document keeps the unknown-object-key warning it has always drawn — probe-verified for Response.summary, xml.nodeType and the nested encoding fields. The components/mediaTypes whole-map Unmodeled entry is no longer written for 3.2, replaced by per-entry handling. Widened as approved: the sweep's other three keys ship here too, since they are the identical mechanism (OD-5). Not yet covered, and next: a components/mediaTypes entry nothing references is kept by no rule until the unreferenced-components retention lands. ir-design §12 records the raw-read-plus-census rule. --- compilers/openapi/conformance_test.go | 85 +++++ .../openapi/internal/annotation/annotation.go | 57 +++- .../annotation/annotation_internal_test.go | 6 +- .../openapi/internal/annotation/unknown.go | 38 ++- compilers/openapi/internal/ids/ids.go | 8 + .../openapi/internal/lowering/lowering.go | 17 + .../openapi/internal/operation/content.go | 143 ++++++++- .../internal/operation/content_test.go | 42 +++ .../openapi/internal/operation/operations.go | 35 ++- .../internal/operation/operations_test.go | 191 +++++++++++ .../openapi/internal/operation/params.go | 3 +- compilers/openapi/internal/schema/schema.go | 4 +- compilers/openapi/meta.go | 39 ++- docs/ir-design.md | 15 + .../component-media-types-32.golden.json | 296 ++++++++++++++++++ .../openapi/component-media-types-32.yaml | 35 +++ .../openapi/nested-encoding-32.golden.json | 218 +++++++++++++ .../openapi/nested-encoding-32.yaml | 23 ++ .../openapi/response-summary-32.golden.json | 151 +++++++++ .../openapi/response-summary-32.yaml | 17 + .../openapi/xml-nodetype-32.golden.json | 131 ++++++++ .../conformance/openapi/xml-nodetype-32.yaml | 16 + 22 files changed, 1531 insertions(+), 39 deletions(-) create mode 100644 testdata/conformance/openapi/component-media-types-32.golden.json create mode 100644 testdata/conformance/openapi/component-media-types-32.yaml create mode 100644 testdata/conformance/openapi/nested-encoding-32.golden.json create mode 100644 testdata/conformance/openapi/nested-encoding-32.yaml create mode 100644 testdata/conformance/openapi/response-summary-32.golden.json create mode 100644 testdata/conformance/openapi/response-summary-32.yaml create mode 100644 testdata/conformance/openapi/xml-nodetype-32.golden.json create mode 100644 testdata/conformance/openapi/xml-nodetype-32.yaml diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 345e57b7..d966b603 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -7,6 +7,7 @@ package openapi_test // external test package — exercises only the public API import ( "encoding/json/jsontext" "encoding/json/v2" + "maps" "os" "path/filepath" "reflect" @@ -243,6 +244,10 @@ func conformanceCases() []conformanceCase { {"param-content-fields", assertParamContentFields, []string{"multi-content"}}, {"examples-32", assertExamples32, []string{"examples"}}, {"tags-grouping-32", assertTagsGrouping32, []string{"operation-grouping"}}, + {"response-summary-32", assertResponseSummary32, []string{"docs-summary-description"}}, + {"component-media-types-32", assertComponentMediaTypes32, []string{"multi-content"}}, + {"xml-nodetype-32", assertXMLNodeType32, nil}, + {"nested-encoding-32", assertNestedEncoding32, []string{"multipart-encoding"}}, {"extensions-x", assertExtensionsX, []string{"vendor-extensions"}}, {"inline-annotations", assertInlineAnnotations, []string{"vendor-extensions", "inline-anonymous"}}, {"inline-residue", assertInlineResidue, []string{"inline-anonymous"}}, @@ -3235,6 +3240,86 @@ func assertTagsGrouping32(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { "tag membership keeps every tag the operation named, badge included") } +// assertResponseSummary32 pins GitHub #615's Response.summary: the 3.2 field +// reaches Docs.Summary on both status classes, because it is read in the one +// shared lowering both use, and the document draws no unknown-key warning for a +// key its dialect defines. +func assertResponseSummary32(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + op, ok := opByName(doc, "deleteItem") + require.True(t, ok) + require.Len(t, op.Responses, 1) + assert.Equal(t, "The item was deleted", op.Responses[0].Docs.Summary) + assert.Equal(t, "The long form of the same fact.", op.Responses[0].Docs.Description) + + require.Len(t, op.Errors, 1) + assert.Equal(t, "No such item", op.Errors[0].Docs.Summary, + "an error case is lowered by the same function, so it reads the field too") + assert.Empty(t, diagsAt(diags, diag.UnknownObjectKey, "/paths/~1items~1{id}/delete/responses/204/summary"), + "a key 3.2 defines is not an undefined key") +} + +// assertComponentMediaTypes32 pins GitHub #615's components/mediaTypes: a content +// entry written as a `$ref` into that section lowers to what the component +// declares — its schema, its examples and its extensions — rather than to the +// top type with the reference kept beside it, and two operations referring to +// one entry both get it. +func assertComponentMediaTypes32(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + for _, name := range []string{"listEvents", "getEvent"} { + op, ok := opByName(doc, name) + require.True(t, ok) + content := op.Responses[0].Payload.Contents[0] + assert.Equal(t, "application/json", content.MediaType) + assert.Equal(t, namedID("Event"), content.Type.Target, + "%s: the referenced media type's schema is what lowers", name) + require.Len(t, content.Examples, 1, "%s: the component's own examples come with it", name) + assert.Equal(t, "one", content.Examples[0].Name) + assert.Contains(t, content.Unmodeled, "openapi:x-note", + "%s: and its extensions", name) + assert.NotContains(t, content.Unmodeled, "openapi:$ref", + "%s: a resolved reference is not also kept as an undefined key", name) + } + assert.False(t, openapitest.HasDiag(diags, diag.UnknownObjectKey)) + assert.Empty(t, doc.Unmodeled, "the whole components/mediaTypes map is no longer kept as one entry") +} + +// assertXMLNodeType32 pins GitHub #615's xml.nodeType: ir.XMLHints.NodeType +// already existed and its GoDoc already named the version, so the fix is the +// wiring, and the dialect's key is no longer reported as undefined. +func assertXMLNodeType32(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + report, ok := doc.Types[namedID("Report")].(*ir.Model) + require.True(t, ok) + id, ok := propByWire(report, "id") + require.True(t, ok) + require.NotNil(t, id.XML) + assert.Equal(t, "attribute", id.XML.NodeType) + + body, ok := propByWire(report, "body") + require.True(t, ok) + require.NotNil(t, body.XML) + assert.Equal(t, "text", body.XML.NodeType) + assert.True(t, body.XML.Wrapped, "the object's other fields still lower as they did") + + assert.Empty(t, diagsAt(diags, diag.UnknownObjectKey, "/components/schemas/Report/properties/id/xml/nodeType"), + "a key 3.2 defines is not an undefined key") +} + +// assertNestedEncoding32 pins GitHub #615's nested Encoding fields: each is kept +// verbatim under the part's own scope with one info, rather than drawing an +// unknown-object-key warning and reaching no entry at all. +func assertNestedEncoding32(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + op, ok := opByName(doc, "upload") + require.True(t, ok) + require.NotNil(t, op.Request) + content := op.Request.Contents[0] + for _, keyword := range []string{"encoding", "prefixEncoding"} { + entry, ok := content.Unmodeled["openapi:encoding/note/"+keyword] + require.True(t, ok, "%s is kept verbatim; got %v", keyword, slices.Sorted(maps.Keys(content.Unmodeled))) + assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) + } + assert.False(t, openapitest.HasDiag(diags, diag.UnknownObjectKey), + "3.2 defines these keys, so the census must leave them alone") +} + func assertExtensionsX(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { m, ok := doc.Types[namedID("S")].(*ir.Model) require.True(t, ok) diff --git a/compilers/openapi/internal/annotation/annotation.go b/compilers/openapi/internal/annotation/annotation.go index 38102a29..6d5cca00 100644 --- a/compilers/openapi/internal/annotation/annotation.go +++ b/compilers/openapi/internal/annotation/annotation.go @@ -674,7 +674,12 @@ type Set struct { Unmodeled ir.Unmodeled } -// Read reads every site-local annotation at st. +// Read reads every site-local annotation at st. reads32 reports that the +// document speaks OpenAPI 3.2, whose XML object adds a `nodeType` the bundled +// parser's model names no field for and this reader takes off the raw node +// (GitHub #615). It is a parameter rather than a lookup because this package may +// not import the lowering or the loader — its archtest allowlist is ir, diag, +// ids, value and the format libraries — so the caller answers. // // This is the single call site the decomposition exists for. Not because it // merges duplicate readers — the docs readers are genuinely distinct and stay @@ -683,7 +688,7 @@ type Set struct { // previously made it separately and disagreed: one passed a referent, one passed // nil because a declaration has none, and one passed nil because it never // resolved the referent it had. -func Read(st Site, pointer jsontext.Pointer, locate Locator) (Set, []ir.Diagnostic) { +func Read(st Site, pointer jsontext.Pointer, locate Locator, reads32 bool) (Set, []ir.Diagnostic) { var out Set referent := st.Referent @@ -694,12 +699,15 @@ func Read(st Site, pointer jsontext.Pointer, locate Locator) (Set, []ir.Diagnost FillCarrierDocs(&out.Docs, st.Node, referent) out.Deprecated = EffectiveDeprecated(st.Node, referent) out.XML = XMLHints(st.Node.GetXML()) + if reads32 { + applyNodeType(out.XML, st.Node) + } examples, exDiags := schemaExamplesAt(st.Node, pointer, locate) out.Examples = examples ext, extDiags := ExtensionsFrom(st.Node.GetExtensions(), locate, pointer) - sub, subDiags := subObjectKeys(st.Node, pointer, locate) + sub, subDiags := subObjectKeys(st.Node, pointer, locate, reads32) kept, keptDiags := unmodeledAt(st.Node, pointer, locate) diags := make([]ir.Diagnostic, 0, len(exDiags)+len(extDiags)+len(subDiags)+len(keptDiags)) @@ -712,6 +720,24 @@ func Read(st Site, pointer jsontext.Pointer, locate Locator) (Set, []ir.Diagnost return out, diags } +// applyNodeType fills the 3.2 nodeType an XML object declares, read off the raw +// node because the parser's XML model has no field for it (GitHub #615). +// ir.XMLHints.NodeType already exists and its GoDoc already names the version. +// +// A schema declaring no xml object has no XMLHints to fill — nodeType is written +// inside the xml object, so there is no position it could have been declared at. +// A declared nodeType wins over the attribute flag: 3.2 replaces `attribute: +// true` with `nodeType: attribute`, so a document writing both has stated the +// newer field, and the reader that took it last makes that the value. +func applyNodeType(h *ir.XMLHints, s *oas3.Schema) { + if h == nil { + return + } + if node := RawChildNode(RawPropertyNode(s, "xml"), "nodeType"); node != nil { + h.NodeType = node.Value + } +} + // subObjectKeys collects what the sub-objects of a schema declare that reaches // no IR field — the x-* they carry and the keys the specification defines for // none of them — over its xml, its discriminator and its externalDocs. @@ -725,15 +751,20 @@ func Read(st Site, pointer jsontext.Pointer, locate Locator) (Set, []ir.Diagnost // even though these hang off a schema: the JSON Schema rule that an unrecognized // keyword is legal governs the schema itself, and these three are OpenAPI // objects that the schema vocabulary says nothing about. -func subObjectKeys(s *oas3.Schema, pointer jsontext.Pointer, locate Locator) (ir.Unmodeled, []ir.Diagnostic) { +// +// reads32 names the one key a 3.2 document defines that this reader has already +// taken raw — the XML object's `nodeType` — so the census leaves it alone there +// and keeps warning about it below 3.2 (GitHub #615). +func subObjectKeys(s *oas3.Schema, pointer jsontext.Pointer, locate Locator, reads32 bool) (ir.Unmodeled, []ir.Diagnostic) { subs := []struct { keyword string obj any ext *extensions.Extensions + decided []string }{ - {"xml", s.GetXML(), s.GetXML().GetExtensions()}, - {"discriminator", s.GetDiscriminator(), s.GetDiscriminator().GetExtensions()}, - {"externalDocs", s.GetExternalDocs(), s.GetExternalDocs().GetExtensions()}, + {"xml", s.GetXML(), s.GetXML().GetExtensions(), nodeTypeKey(reads32)}, + {"discriminator", s.GetDiscriminator(), s.GetDiscriminator().GetExtensions(), nil}, + {"externalDocs", s.GetExternalDocs(), s.GetExternalDocs().GetExtensions(), nil}, } var out ir.Unmodeled var diags []ir.Diagnostic @@ -742,11 +773,21 @@ func subObjectKeys(s *oas3.Schema, pointer jsontext.Pointer, locate Locator) (ir ext, extDiags := ExtensionsUnder(sub.ext, locate, owner, sub.keyword) out = MergeUnmodeled(out, ext) diags = append(diags, extDiags...) - diags = append(diags, UnknownKeysUnder(&out, sub.obj, locate, owner, sub.keyword)...) + diags = append(diags, UnknownKeysDecided(&out, sub.obj, locate, owner, sub.keyword, sub.decided)...) } return out, diags } +// nodeTypeKey names the XML object's 3.2 `nodeType` for the census, and nothing +// below 3.2: the key is a misspelling there rather than a field the dialect +// added, and the warning is what says so. +func nodeTypeKey(reads32 bool) []string { + if !reads32 { + return nil + } + return []string{"nodeType"} +} + // unmodeledAt collects every keyword a site declares that the IR keeps verbatim // instead of modelling, each under the reason that says which of those it is // (§12): validation logic the IR draws a boundary against (§4.7), and JSON diff --git a/compilers/openapi/internal/annotation/annotation_internal_test.go b/compilers/openapi/internal/annotation/annotation_internal_test.go index ebad5a95..decf634f 100644 --- a/compilers/openapi/internal/annotation/annotation_internal_test.go +++ b/compilers/openapi/internal/annotation/annotation_internal_test.go @@ -24,7 +24,7 @@ func TestAnnotations_SiteOverridesReferent(t *testing.T) { ref := &oas3.Schema{Description: new("SiteDesc")} tgt := &oas3.Schema{Description: new("TargetDesc"), Deprecated: new(true)} - got, diags := Read(Site{Kind: Reference, Node: ref, Referent: tgt}, "/p", sourced(0)) + got, diags := Read(Site{Kind: Reference, Node: ref, Referent: tgt}, "/p", sourced(0), false) assert.Empty(t, diags) assert.Equal(t, "SiteDesc", got.Docs.Description, "the site's own description wins") @@ -40,7 +40,7 @@ func TestAnnotations_DeclarationIgnoresAnyReferent(t *testing.T) { node := &oas3.Schema{Description: new("OwnDesc")} stray := &oas3.Schema{Title: new("StraySummary"), Deprecated: new(true)} - got, _ := Read(Site{Kind: Declaration, Node: node, Referent: stray}, "/p", sourced(0)) + got, _ := Read(Site{Kind: Declaration, Node: node, Referent: stray}, "/p", sourced(0), false) assert.Equal(t, "OwnDesc", got.Docs.Description) assert.Empty(t, got.Docs.Summary, "a declaration inherits nothing, whatever it is handed") @@ -54,7 +54,7 @@ func TestAnnotations_ReadsEverySiteLocalAspect(t *testing.T) { XML: &oas3.XML{Name: new("Q")}, Example: openapitest.YAMLNode(t, "hello"), } - got, diags := Read(Site{Kind: Declaration, Node: node}, "/components/schemas/S", sourced(0)) + got, diags := Read(Site{Kind: Declaration, Node: node}, "/components/schemas/S", sourced(0), false) assert.Equal(t, "D", got.Docs.Description) require.NotNil(t, got.XML) diff --git a/compilers/openapi/internal/annotation/unknown.go b/compilers/openapi/internal/annotation/unknown.go index 095d32e4..675b648d 100644 --- a/compilers/openapi/internal/annotation/unknown.go +++ b/compilers/openapi/internal/annotation/unknown.go @@ -138,8 +138,37 @@ func UnknownKeysIn(p *ir.Unmodeled, model any, locate Locator, owner jsontext.Po // them would be a single key and the entry that survived would depend on which // lowering ran last. func UnknownKeysUnder(p *ir.Unmodeled, model any, locate Locator, owner jsontext.Pointer, scope string) []ir.Diagnostic { + return UnknownKeysDecided(p, model, locate, owner, scope, nil) +} + +// UnknownKeysDecided is UnknownKeysUnder for an object one of whose keys a +// reader has already read raw: `decided` names those keys, and the census leaves +// them alone rather than reporting a key the document does define as undefined. +// +// It is the 3.2 half of GitHub #615's mechanism. The bundled parser's model names +// no field for a Response Object's `summary`, a Components Object's `mediaTypes` +// or an XML object's `nodeType` — the fields OpenAPI 3.2 added — so each is read +// off the raw node by a reader of its own, and the census has to be told or it +// would report the key as undefined on a document that defines it. +// +// `decided` must be empty below 3.2: the same key on a 3.1 document is a +// misspelling rather than a field the dialect added, and the warning is what +// says so. +func UnknownKeysDecided(p *ir.Unmodeled, model any, locate Locator, owner jsontext.Pointer, scope string, decided []string) []ir.Diagnostic { keys, root := undeclaredKeys(model) - return UnknownKeysNamed(p, keys, root, locate, owner, scope) + return census(p, keys, root, locate, owner, scope, objectKeyClass(decided)) +} + +// objectKeyClass grades a key the OpenAPI object it is written on does not +// define, with the keys a reader has already taken raw left out. +func objectKeyClass(decided []string) keyClass { + return keyClass{ + code: diag.UnknownObjectKey, + severity: ir.SeverityWarning, + skip: decided, + message: "key %q is not defined by the OpenAPI object it is written on and is not an " + + "x- extension; kept verbatim under Unmodeled", + } } // UnknownKeysNamed is UnknownKeysUnder for an object whose model keeps no census @@ -159,12 +188,7 @@ func UnknownKeysUnder(p *ir.Unmodeled, model any, locate Locator, owner jsontext func UnknownKeysNamed(p *ir.Unmodeled, keys []string, root *yaml.Node, locate Locator, owner jsontext.Pointer, scope string, ) []ir.Diagnostic { - return census(p, keys, root, locate, owner, scope, keyClass{ - code: diag.UnknownObjectKey, - severity: ir.SeverityWarning, - message: "key %q is not defined by the OpenAPI object it is written on and is not an " + - "x- extension; kept verbatim under Unmodeled", - }) + return census(p, keys, root, locate, owner, scope, objectKeyClass(nil)) } // keyClass is how a key the model does not name is graded: which diagnostic diff --git a/compilers/openapi/internal/ids/ids.go b/compilers/openapi/internal/ids/ids.go index 1425f167..47603dc3 100644 --- a/compilers/openapi/internal/ids/ids.go +++ b/compilers/openapi/internal/ids/ids.go @@ -131,6 +131,14 @@ func ComponentEntry(pointer jsontext.Pointer) (kind, name string, ok bool) { return kind, name, true } +// MediaTypesKind is the components section OpenAPI 3.2 added for reusable Media +// Type Objects. The bundled parser's Components model names no field for it, so +// it is the one kind ComponentEntry answers for that the loader leaves to the +// compiler; the resolver that reads such a `$ref` and the components census that +// must stop reporting the section are both spelled from here, since the two +// disagreeing would make one document compile two ways (GitHub #615). +const MediaTypesKind = "mediaTypes" + // componentsRoot is the pointer every component entry sits two tokens beneath. const componentsRoot jsontext.Pointer = "/components" diff --git a/compilers/openapi/internal/lowering/lowering.go b/compilers/openapi/internal/lowering/lowering.go index 8b272f71..f12fdf8f 100644 --- a/compilers/openapi/internal/lowering/lowering.go +++ b/compilers/openapi/internal/lowering/lowering.go @@ -287,6 +287,23 @@ func (c Ctx) ExclusiveBoundIsBoolean() bool { return minor == "3.0" } +// Is32 reports whether this document speaks OpenAPI 3.2, the version that added +// the tag hierarchy, Response.summary, xml nodeType, components/mediaTypes and +// the nested encoding fields. +// +// It exists because each of those is a key the bundled parser's model names no +// field for, so the compiler reads it off the raw node and the census has to be +// told the key was read. One answer, asked once here, is what keeps the reader +// and the census from disagreeing about which keys those are (GitHub #615). +// +// A document whose version is unrecognized answers false, as ExclusiveBoundIsBoolean +// does for its own question: the raw-node readers are refused rather than let to +// run on a dialect nobody has said anything about. +func (c Ctx) Is32() bool { + minor, _ := load.SupportedMinor(c.Doc.GetOpenAPI()) + return minor == "3.2" +} + // RefScope is the context seen as a reference-resolution scope: the document's // own path, and what it declares. // diff --git a/compilers/openapi/internal/operation/content.go b/compilers/openapi/internal/operation/content.go index 9c04e539..6e8c686b 100644 --- a/compilers/openapi/internal/operation/content.go +++ b/compilers/openapi/internal/operation/content.go @@ -14,11 +14,13 @@ package operation import ( + "context" "encoding/json/jsontext" "slices" "strings" oas3 "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" soa "github.com/speakeasy-api/openapi/openapi" "github.com/speakeasy-api/openapi/sequencedmap" yaml "gopkg.in/yaml.v3" @@ -48,7 +50,12 @@ func lowerPayload(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex if media == nil { continue } - one, contentDiags := lowerContent(c, ts, anchors, mt, media, pointer, hint) + entry, fromRef, entryDiags := contentEntry(c, media, pointer+ids.Ptr("content", mt)) + diags = append(diags, entryDiags...) + if entry == nil { + continue + } + one, contentDiags := lowerContent(c, ts, anchors, mt, entry, pointer, hint, fromRef) diags = append(diags, contentDiags...) payload.Contents = append(payload.Contents, one) } @@ -58,9 +65,99 @@ func lowerPayload(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex return payload, diags } +// contentEntry returns the Media Type Object a content-map entry names: the +// entry itself, or the components/mediaTypes object a 3.2 `$ref` entry +// addresses. +// +// ok reports that the entry was reached through a `$ref`, which is what tells +// the caller two things: the resolved object is what lowers, and the `$ref` key +// itself has been read, so the census must leave it alone — a document that +// defines the reference is not a document with a key its object does not define. +// +// A target this compiler cannot resolve — an external document, a pointer that +// names no mediaTypes entry, nothing at all there, or an entry that is itself +// another `$ref` — leaves the original entry to lower as it did before, so the +// `$ref` is censused and kept verbatim, with one `openapi/unresolved-ref` +// diagnostic saying what was wrong (GitHub #615). +func contentEntry(c lowering.Ctx, media *soa.MediaType, entryPtr jsontext.Pointer) (*soa.MediaType, bool, []ir.Diagnostic) { + ref, isRef := rawRefOf(media) + if !isRef || !c.Is32() { + return media, false, nil + } + resolved, ok, message := resolveMediaTypeRef(c, ref) + if !ok { + return media, false, []ir.Diagnostic{c.DiagAt(ir.SeverityError, diag.UnresolvedRef, entryPtr, + "media type $ref %q %s; the entry lowers as written with the reference kept verbatim", + ref, message)} + } + return resolved, true, nil +} + +// resolveMediaTypeRef reads the components/mediaTypes object a `$ref` names and +// unmarshals it into a Media Type Object. It reports a message naming what went +// wrong rather than returning an error, because every failure lands in the same +// diagnostic the caller builds. +func resolveMediaTypeRef(c lowering.Ctx, ref string) (*soa.MediaType, bool, string) { + ptr, internal := c.RefScope().InternalPointer(ref) + if !internal { + return nil, false, "does not resolve inside this document" + } + kind, name, ok := ids.ComponentEntry(ptr) + if !ok || kind != ids.MediaTypesKind { + return nil, false, "does not name a components/" + ids.MediaTypesKind + " entry" + } + node := rawComponentNode(c.Doc.GetRootNode(), kind, name) + if node == nil { + return nil, false, "names an entry this document does not declare" + } + var out soa.MediaType + errs, err := marshaller.UnmarshalNode(context.Background(), "", node, &out) + if err != nil { + return nil, false, "could not be read as a media type object" + } + if len(errs) > 0 { + return nil, false, "is not a media type object: " + diag.OneLine(errs[0]) + } + if _, chained := rawRefOf(&out); chained { + // A one-hop reading, deliberately: the shapes are one entry, and following a + // chain would need the resolver state a standalone node does not carry. + return nil, false, "names an entry that is itself a $ref, which is not followed" + } + return &out, true, "" +} + +// rawRefOf returns the `$ref` string a raw object writes, and whether it wrote +// one. Both a content entry and a components/mediaTypes entry are read this way: +// the library's Media Type model has no Reference wrapper, so a `$ref` reaches +// the compiler as a key the model does not define. +func rawRefOf(media *soa.MediaType) (string, bool) { + node := annotation.RawChildNode(media.GetRootNode(), "$ref") + if node == nil || node.Value == "" { + return "", false + } + return node.Value, true +} + +// rawComponentNode returns the raw node of a /components// entry. +func rawComponentNode(root *yaml.Node, kind, name string) *yaml.Node { + components := annotation.RawChildNode(root, "components") + return annotation.RawChildNode(annotation.RawChildNode(components, kind), name) +} + +// contentDecidedKeys names the content-entry keys a reader has already taken for +// this document, which the census must leave alone. An entry resolved from a +// `$ref` has had its `$ref` read; an entry that was not resolved has not, and +// the warning is owed. +func contentDecidedKeys(fromRef bool) []string { + if !fromRef { + return nil + } + return []string{"$ref"} +} + // lowerContent lowers one media-type view: its type graph, examples, binary/ // form specialization, sequential-media shape, and extensions. -func lowerContent(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, mt string, media *soa.MediaType, pointer jsontext.Pointer, hint string) (ir.Content, []ir.Diagnostic) { +func lowerContent(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, mt string, media *soa.MediaType, pointer jsontext.Pointer, hint string, fromRef bool) (ir.Content, []ir.Diagnostic) { mediaPtr := pointer + ids.Ptr("content", mt) mediaType, diags := schema.Ref(c, ts, anchors, schema.TopLevelDepth, media.GetSchema(), mediaPtr+ids.Ptr("schema"), hint) content := ir.Content{ @@ -91,7 +188,8 @@ func lowerContent(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex content.Unmodeled = annotation.MergeUnmodeled(content.Unmodeled, ext) } return content, append(diags, - annotation.UnknownKeysIn(&content.Unmodeled, media, c.ProvenanceAt, mediaPtr)...) + annotation.UnknownKeysDecided(&content.Unmodeled, media, c.ProvenanceAt, mediaPtr, "", + contentDecidedKeys(fromRef))...) } // fillSequential lowers 3.2 sequential-media fields: itemSchema becomes the @@ -378,12 +476,51 @@ func encodingUnmodeled(c lowering.Ctx, enc *soa.Encoding, encPtr jsontext.Pointe diags = append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, at, "encoding allowReserved has no ir.PartEncoding home; kept verbatim under Unmodeled")) } + diags = append(diags, nestedEncodings(c, &out, enc, encPtr, scope)...) ext, extDiags := schema.ExtensionsIn(c, enc.GetExtensions(), encPtr, scope) out = annotation.MergeUnmodeled(out, ext) diags = append(diags, extDiags...) return out, append(diags, annotation.UnknownKeysUnder(&out, enc, c.ProvenanceAt, encPtr, scope)...) } +// nestedEncodingFields are the OpenAPI 3.2 Encoding Object fields that carry a +// nested Encoding Object: the object's own encoding map, the positional prefix +// encodings, and the item encoding governing what follows them. This compiler +// lowers an Encoding Object to ir.PartEncoding, which has no encoding fields of +// its own, so each reaches the IR in no modelled form. +var nestedEncodingFields = []string{"encoding", "prefixEncoding", "itemEncoding"} + +// nestedEncodings keeps the nested Encoding Objects a 3.2 document writes, +// verbatim under the same scope the part's own entries ride on, one info each +// (GitHub #615). Recording them at the key and pointer the census itself uses is +// what suppresses the `unknown-object-key` warning a 3.2 document used to draw +// three of, without suppressing anything below 3.2 — where these keys are +// misspellings and the warning is owed. +// +// ReasonNoIRHome rather than a boundary: PartEncoding could grow the fields, and +// the entries are the promotion path. Nothing is lowered from them, because a +// nested encoding describes a part inside a part and this compiler has no shape +// for it — keeping the source is lossless and takes no position on how it would +// lower. +func nestedEncodings(c lowering.Ctx, out *ir.Unmodeled, enc *soa.Encoding, encPtr jsontext.Pointer, scope string) []ir.Diagnostic { + if !c.Is32() { + return nil + } + var diags []ir.Diagnostic + for _, keyword := range nestedEncodingFields { + at := encPtr + ids.Ptr(keyword) + kept, keptDiags := schema.PreserveNode(c, out, "openapi:"+scope+"/"+ids.Scope(keyword), + annotation.RawChildNode(enc.GetRootNode(), keyword), ir.ReasonNoIRHome, at) + diags = append(diags, keptDiags...) + if !kept { + continue + } + diags = append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, at, + "encoding %s has no ir.PartEncoding home; kept verbatim under Unmodeled", keyword)) + } + return diags +} + // lowerHeaders lowers a header map into Properties in source order. Each // entry's own pointer stays its ID and Provenance (two keys $ref'ing the same // header must not collide), but its schema — and the name hint that schema is diff --git a/compilers/openapi/internal/operation/content_test.go b/compilers/openapi/internal/operation/content_test.go index 2e00b207..054fdcdb 100644 --- a/compilers/openapi/internal/operation/content_test.go +++ b/compilers/openapi/internal/operation/content_test.go @@ -2300,3 +2300,45 @@ paths: openapitest.AssertInfoDiagAt(t, diags, "/paths/~1a/get/responses/200/content/application~1json/examples/serial/serializedValue") } + +// TestEncoding_NestedEncodingsAreKeptFor32Only pins GitHub #615's nested +// Encoding fields: 3.2 gives an Encoding Object an encoding map and the +// positional prefix/item encodings, ir.PartEncoding has no field for any of +// them, and the bundled model does not name them either — so they used to draw +// three unknown-object-key warnings and reach no entry. Each is now kept +// verbatim under the part's own scope with one info, and below 3.2 the warnings +// stay. +func TestEncoding_NestedEncodingsAreKeptFor32Only(t *testing.T) { + t.Parallel() + const body = ` /upload: + post: + operationId: upload + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + note: {type: string} + encoding: + note: + contentType: text/plain + prefixEncoding: [{contentType: text/plain}] + responses: {"200": {description: ok}} +` + _, svc32, diags32 := lowerServiceSpec(t, "openapi: 3.2.0\ninfo: {title: T, version: \"1\"}\npaths:\n"+body) + openapitest.RequireNoErrorDiags(t, diags32) + content := openapitest.FirstOp(t, svc32).Request.Contents[0] + kept, ok := content.Unmodeled["openapi:encoding/note/prefixEncoding"] + require.True(t, ok, "the nested prefixEncoding is kept under the part's own scope; got %v", + slices.Sorted(maps.Keys(content.Unmodeled))) + assert.Equal(t, ir.ReasonNoIRHome, kept.Reason) + openapitest.AssertInfoDiagAt(t, diags32, + "/paths/~1upload/post/requestBody/content/multipart~1form-data/encoding/note/prefixEncoding") + assert.False(t, openapitest.HasDiag(diags32, diag.UnknownObjectKey), + "3.2 defines the keys this reader took, so the census must leave them alone") + + _, _, diags31 := lowerServiceSpec(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n"+body) + assert.True(t, openapitest.HasDiag(diags31, diag.UnknownObjectKey), + "below 3.2 the same key is a misspelling and the warning is owed") +} diff --git a/compilers/openapi/internal/operation/operations.go b/compilers/openapi/internal/operation/operations.go index eb979be2..5bc3cc03 100644 --- a/compilers/openapi/internal/operation/operations.go +++ b/compilers/openapi/internal/operation/operations.go @@ -1049,11 +1049,41 @@ func lowerResponseParts(c lowering.Ctx, ts *compile.Types, anchors *schema.Ancho name: responseName(code), payload: payload, headers: headers, - docs: resolve.RefDocs(ref, ir.Docs{Description: r.GetDescription()}), + docs: resolve.RefDocs(ref, responseDocs(c, r)), } return parts, append(diags, preserveResponseExtras(c, &parts.unmodeled, r, rptr)...) } +// responseDocs builds a Response Object's docs: its description, and — for +// OpenAPI 3.2 — the `summary` the version added, which the bundled parser's +// model names no field for and nothing read (GitHub #615). It is read off the +// raw node, at the declaration, so the census must be told the key was taken +// (preserveResponseExtras). +// +// The use-site fold in resolve.RefDocs is applied by the caller, last, so a +// summary or description written beside a `$ref` still wins over the +// declaration's own — 3.2's field is no different from the version's others. +func responseDocs(c lowering.Ctx, r *soa.Response) ir.Docs { + docs := ir.Docs{Description: r.GetDescription()} + if !c.Is32() { + return docs + } + if node := annotation.RawChildNode(r.GetRootNode(), "summary"); node != nil { + docs.Summary = node.Value + } + return docs +} + +// responseDecidedKeys names the Response Object keys a reader takes raw for this +// document, which the census must leave alone. It is empty below 3.2, where +// `summary` is a key the dialect does not define and the warning is owed. +func responseDecidedKeys(c lowering.Ctx) []string { + if !c.Is32() { + return nil + } + return []string{"summary"} +} + // preserveResponseExtras keeps what a Response Object declares that has no home // on the node it lowered to: its links map, its own x-* extensions, and the keys // the specification does not define at all. @@ -1081,7 +1111,8 @@ func preserveResponseExtras(c lowering.Ctx, p *ir.Unmodeled, r *soa.Response, rp ext, extDiags := schema.ExtensionsOf(c, r.GetExtensions(), rptr) *p = annotation.MergeUnmodeled(*p, ext) diags = append(diags, extDiags...) - return append(diags, annotation.UnknownKeysIn(p, r, c.ProvenanceAt, rptr)...) + return append(diags, annotation.UnknownKeysDecided(p, r, c.ProvenanceAt, rptr, "", + responseDecidedKeys(c))...) } // responseName builds a success response's neutral naming. OpenAPI names no diff --git a/compilers/openapi/internal/operation/operations_test.go b/compilers/openapi/internal/operation/operations_test.go index 8af627db..136b1cfb 100644 --- a/compilers/openapi/internal/operation/operations_test.go +++ b/compilers/openapi/internal/operation/operations_test.go @@ -3345,3 +3345,194 @@ tags: assert.Equal(t, "books", got[0].Groups[0].Name.Source) require.Len(t, got[0].Groups[0].Operations, 1, "the badge tag first in the operation's tags changed nothing") } + +// TestResponses_SummaryIsReadFor32Only pins GitHub #615's Response.summary: the +// 3.2 field reaches Docs.Summary, and below 3.2 the same key is still a key the +// dialect does not define, so it keeps warning rather than being silently +// suppressed. +func TestResponses_SummaryIsReadFor32Only(t *testing.T) { + t.Parallel() + const body = ` /a: + get: + operationId: a + responses: + "204": + summary: The item was deleted + description: Long form. +` + doc32, _, diags32 := lowerServiceSpec(t, `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: +`+body) + openapitest.RequireNoErrorDiags(t, diags32) + resp32 := openapitest.FindOp(t, doc32, "a").Responses[0] + assert.Equal(t, "The item was deleted", resp32.Docs.Summary) + assert.Equal(t, "Long form.", resp32.Docs.Description) + assert.NotContains(t, resp32.Unmodeled, "openapi:summary", + "a key the dialect defines is read, not also kept verbatim") + assert.False(t, openapitest.HasDiag(diags32, diag.UnknownObjectKey), + "3.2 defines the key, so the census must not call it undefined") + + doc31, _, diags31 := lowerServiceSpec(t, `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: +`+body) + assert.True(t, openapitest.HasDiag(diags31, diag.UnknownObjectKey), + "below 3.2 the key is undefined and the warning is owed") + resp31 := openapitest.FindOp(t, doc31, "a").Responses[0] + assert.Empty(t, resp31.Docs.Summary, "nothing is invented for a version without the field") + assert.Contains(t, resp31.Unmodeled, "openapi:summary") +} + +// TestResponses_SummaryUseSiteBeatsTheDeclaration holds the 3.2 field to the +// same precedence the rest of a response's docs take: a summary written beside +// the $ref describes this mount, so it wins over the declaration's own. +func TestResponses_SummaryUseSiteBeatsTheDeclaration(t *testing.T) { + t.Parallel() + doc, _, diags := lowerServiceSpec(t, `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: + /a: + get: + operationId: a + responses: + "200": {$ref: '#/components/responses/Ok', summary: As a sees it} +components: + responses: + Ok: + summary: As declared + description: ok + content: + application/json: {schema: {type: string}} +`) + openapitest.RequireNoErrorDiags(t, diags) + assert.Equal(t, "As a sees it", openapitest.FindOp(t, doc, "a").Responses[0].Docs.Summary) +} + +// TestXML_NodeTypeIsReadFor32Only pins the 3.2 XML nodeType: ir.XMLHints.NodeType +// already existed and its GoDoc already named the version, so this is the wiring +// — and below 3.2 the key keeps the warning it always drew. +func TestXML_NodeTypeIsReadFor32Only(t *testing.T) { + t.Parallel() + const body = ` S: + type: object + properties: + n: + type: string + xml: {nodeType: text} +` + doc32, _, diags32 := lowerServiceSpec(t, `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: {} +components: + schemas: +`+body) + openapitest.RequireNoErrorDiags(t, diags32) + model, ok := doc32.Types[ir.TypeID("t/openapi/components/schemas/S")].(*ir.Model) + require.True(t, ok) + prop, ok := propByWireTest(model, "n") + require.True(t, ok) + require.NotNil(t, prop.XML) + assert.Equal(t, "text", prop.XML.NodeType) + assert.False(t, openapitest.HasDiag(diags32, diag.UnknownObjectKey)) + + doc31, _, diags31 := lowerServiceSpec(t, `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: {} +components: + schemas: +`+body) + assert.True(t, openapitest.HasDiag(diags31, diag.UnknownObjectKey), + "below 3.2 the XML object defines no nodeType and the warning is owed") + model31, ok := doc31.Types[ir.TypeID("t/openapi/components/schemas/S")].(*ir.Model) + require.True(t, ok) + prop31, ok := propByWireTest(model31, "n") + require.True(t, ok) + require.NotNil(t, prop31.XML) + assert.Empty(t, prop31.XML.NodeType) +} + +// propByWireTest returns the property of m with the given wire name. +func propByWireTest(m *ir.Model, wire string) (ir.Property, bool) { + for _, p := range m.Properties { + if p.WireName == wire { + return p, true + } + } + return ir.Property{}, false +} + +// TestContent_MediaTypesRefIsResolved pins the 3.2 components/mediaTypes path: +// a content entry written as a `$ref` into that section is read off the raw node +// and lowered through the ordinary media-type lowering, so the body is what the +// component declares rather than the top type with the reference kept beside it. +func TestContent_MediaTypesRefIsResolved(t *testing.T) { + t.Parallel() + doc, _, diags := lowerServiceSpec(t, `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: + /a: + get: + operationId: a + responses: + "200": + description: ok + content: + application/json: {$ref: '#/components/mediaTypes/Json'} +components: + schemas: + N: {type: string} + mediaTypes: + Json: + schema: {$ref: '#/components/schemas/N'} +`) + openapitest.RequireNoErrorDiags(t, diags) + content := openapitest.FindOp(t, doc, "a").Responses[0].Payload.Contents[0] + assert.Equal(t, ir.TypeID("t/openapi/components/schemas/N"), content.Type.Target, + "the referenced media type's schema is what lowers, and its own $ref still resolves") + assert.NotContains(t, content.Unmodeled, "openapi:$ref", + "a reference this compiler resolved is not also kept as an undefined key") + assert.False(t, openapitest.HasDiag(diags, diag.UnknownObjectKey)) +} + +// TestContent_MediaTypesRefFailuresLowerAsWritten covers every target the +// resolver refuses: an entry the document does not declare, an external +// document, a pointer naming another section, and an entry that is not an +// object. Each keeps the `$ref` verbatim and reports one unresolved-ref. +func TestContent_MediaTypesRefFailuresLowerAsWritten(t *testing.T) { + t.Parallel() + refs := map[string]string{ + "an undeclared entry": "#/components/mediaTypes/Missing", + "another document": "other.yaml#/components/mediaTypes/Json", + "another section": "#/components/schemas/N", + "an entry that is not an object": "#/components/mediaTypes/Scalar", + } + for name, ref := range refs { + t.Run(name, func(t *testing.T) { + t.Parallel() + doc, _, diags := lowerServiceSpec(t, `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: + /a: + get: + operationId: a + responses: + "200": + description: ok + content: + application/json: {$ref: '`+ref+`'} +components: + schemas: + N: {type: string} + mediaTypes: + Scalar: hello +`) + openapitest.AssertHasCode(t, diags, diag.UnresolvedRef, ir.SeverityError) + content := openapitest.FindOp(t, doc, "a").Responses[0].Payload.Contents[0] + assert.Equal(t, ir.TypeID("t/prim/any"), content.Type.Target, + "an unresolvable reference drops the position to the top type") + assert.Contains(t, content.Unmodeled, "openapi:$ref", + "...with the reference itself kept verbatim") + }) + } +} diff --git a/compilers/openapi/internal/operation/params.go b/compilers/openapi/internal/operation/params.go index ba2e4d05..bbf80137 100644 --- a/compilers/openapi/internal/operation/params.go +++ b/compilers/openapi/internal/operation/params.go @@ -232,7 +232,8 @@ func fillParamSchemaAnnotations(c lowering.Ctx, ts *compile.Types, param *ir.Par if schema.LoweredToOwnNode(ts, pointer, param.Type) { return diags } - a, readDiags := annotation.Read(annotation.Site{Kind: annotation.Reference, Node: s, Referent: tgt}, pointer, c.ProvenanceAt) + a, readDiags := annotation.Read(annotation.Site{Kind: annotation.Reference, Node: s, Referent: tgt}, pointer, c.ProvenanceAt, + c.Is32()) diags = append(diags, readDiags...) param.Docs = a.Docs diff --git a/compilers/openapi/internal/schema/schema.go b/compilers/openapi/internal/schema/schema.go index 42c449f0..c1d45ef4 100644 --- a/compilers/openapi/internal/schema/schema.go +++ b/compilers/openapi/internal/schema/schema.go @@ -1253,7 +1253,7 @@ func fillPropertyAnnotations(c lowering.Ctx, ts *compile.Types, anchors *AnchorI if LoweredToOwnNode(ts, pointer, p.Type) { return nil } - a, diags := annotation.Read(annotation.Site{Kind: annotation.Reference, Node: ref, Referent: tgt}, pointer, c.ProvenanceAt) + a, diags := annotation.Read(annotation.Site{Kind: annotation.Reference, Node: ref, Referent: tgt}, pointer, c.ProvenanceAt, c.Is32()) p.Docs = a.Docs if a.Deprecated { @@ -1347,7 +1347,7 @@ func attachDeclaredAnnotations(c lowering.Ctx, ts *compile.Types, anchors *Ancho if !ok { return nil } - a, diags := annotation.Read(annotation.Site{Kind: annotation.Declaration, Node: s}, pointer, c.ProvenanceAt) + a, diags := annotation.Read(annotation.Site{Kind: annotation.Declaration, Node: s}, pointer, c.ProvenanceAt, c.Is32()) common := td.Common() common.Docs = a.Docs diff --git a/compilers/openapi/meta.go b/compilers/openapi/meta.go index aa3823de..9949178a 100644 --- a/compilers/openapi/meta.go +++ b/compilers/openapi/meta.go @@ -69,17 +69,30 @@ func documentUnknownKeys(c lowering.Ctx, p *ir.Unmodeled) []ir.Diagnostic { diags := make([]ir.Diagnostic, 0, len(sites)) for _, site := range sites { diags = append(diags, - annotation.UnknownKeysUnder(p, site.model, c.ProvenanceAt, site.owner, site.scope)...) + annotation.UnknownKeysDecided(p, site.model, c.ProvenanceAt, site.owner, site.scope, site.decided)...) } return diags } // unknownSite is one object's census: what it keys under on the carrier holding -// it, the object's own source pointer, and the parsed object itself. +// it, the object's own source pointer, the parsed object itself, and the keys a +// reader has already taken raw for this document. type unknownSite struct { - scope string - owner jsontext.Pointer - model any + scope string + owner jsontext.Pointer + model any + decided []string +} + +// componentsDecidedKeys names the Components Object keys a reader takes raw for +// this document: OpenAPI 3.2's `mediaTypes`, whose entries are resolved where a +// content entry references one rather than kept as a whole map (GitHub #615). +// Below 3.2 the key is undefined and the warning is owed. +func componentsDecidedKeys(c lowering.Ctx) []string { + if !c.Is32() { + return nil + } + return []string{ids.MediaTypesKind} } // rootUnknownSites returns the census sites a document has exactly one of. The @@ -96,12 +109,12 @@ func rootUnknownSites(c lowering.Ctx) []unknownSite { info := c.Doc.GetInfo() infoPtr := ids.Ptr("info") return []unknownSite{ - {"", "", c.Doc}, - {"info", infoPtr, info}, - {"info/contact", infoPtr + ids.Ptr("contact"), info.GetContact()}, - {"info/license", infoPtr + ids.Ptr("license"), info.GetLicense()}, - {"externalDocs", ids.Ptr("externalDocs"), c.Doc.GetExternalDocs()}, - {"components", ids.Ptr("components"), c.Doc.GetComponents()}, + {"", "", c.Doc, nil}, + {"info", infoPtr, info, nil}, + {"info/contact", infoPtr + ids.Ptr("contact"), info.GetContact(), nil}, + {"info/license", infoPtr + ids.Ptr("license"), info.GetLicense(), nil}, + {"externalDocs", ids.Ptr("externalDocs"), c.Doc.GetExternalDocs(), nil}, + {"components", ids.Ptr("components"), c.Doc.GetComponents(), componentsDecidedKeys(c)}, } } @@ -123,8 +136,8 @@ func tagUnknownSites(c lowering.Ctx) []unknownSite { index := strconv.Itoa(i) ptr := ids.Ptr("tags", index) out = append(out, - unknownSite{"tags/" + index, ptr, t}, - unknownSite{"tags/" + index + "/externalDocs", ptr + ids.Ptr("externalDocs"), t.GetExternalDocs()}) + unknownSite{"tags/" + index, ptr, t, nil}, + unknownSite{"tags/" + index + "/externalDocs", ptr + ids.Ptr("externalDocs"), t.GetExternalDocs(), nil}) } return out } diff --git a/docs/ir-design.md b/docs/ir-design.md index 777feee9..5220c8f2 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1939,6 +1939,21 @@ rather than the field's own polarity. A document that writes `x-extensible-enum` would be a compiler reading a member list out of an extension, not a promotion, so the entry is left for a consumer that wants to. +**A dialect's own field the parser does not model.** A source revision may add a field the bundled +parser's model names no member for; OpenAPI 3.2's `Response.summary`, `components/mediaTypes`, XML +`nodeType` and the nested Encoding fields are the live set. Such a field is read off the raw node by +a reader of its own, and the object census is *told* which keys that reader took: the census grades a +key by the parser's model, so left alone it reports a field the document defines as an undefined key. +The suppression is version-gated in both directions — it is passed only for a document whose dialect +defines the key, so the same key on an older document keeps the `openapi/unknown-object-key` warning +it has always drawn, where it is a misspelling rather than a field. + +Where a reading still has no IR home, the key is preserved under `ReasonNoIRHome` at the key and +pointer the census itself uses, which is what makes preservation and suppression one statement +rather than two: the census answers only for what nothing read. A reading with an IR home — `summary` +into `Docs.Summary`, `nodeType` into `XMLHints.NodeType` — is not also kept verbatim, because one +declaration gets one structural home (§12.1). + ### 12.1 One structural home per declaration Documentation, deprecation, XML hints, examples, vendor extensions, validation-only keywords and diff --git a/testdata/conformance/openapi/component-media-types-32.golden.json b/testdata/conformance/openapi/component-media-types-32.golden.json new file mode 100644 index 00000000..ee2c782c --- /dev/null +++ b/testdata/conformance/openapi/component-media-types-32.golden.json @@ -0,0 +1,296 @@ +{ + "irVersion": "0.6.0", + "name": "ComponentMediaTypes32", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "ComponentMediaTypes32", + "canonical": "component_media_types_32" + }, + "docs": {}, + "groups": [ + { + "name": { + "hint": "default" + }, + "docs": {}, + "operations": [ + { + "id": "op/openapi/paths/~1events/get", + "name": { + "source": "listEvents", + "canonical": "list_events" + }, + "docs": {}, + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/openapi/components/schemas/Event", + "nullable": false + }, + "examples": [ + { + "name": "one", + "value": { + "kind": "object", + "object": [ + { + "name": "id", + "value": { + "kind": "string", + "str": "e1" + } + } + ] + } + } + ], + "unmodeled": { + "openapi:x-note": { + "reason": "vendor_extension", + "value": "from the component", + "provenance": { + "source": 0, + "pointer": "/paths/~1events/get/responses/200/content/application~1json/x-note" + } + } + } + } + ] + }, + "docs": { + "description": "ok" + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "GET", + "uriTemplate": "/events", + "sharedRoute": false, + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1events/get" + } + }, + { + "id": "op/openapi/paths/~1events~1{id}/get", + "name": { + "source": "getEvent", + "canonical": "get_event" + }, + "docs": {}, + "params": [ + { + "name": { + "source": "id", + "canonical": "id" + }, + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": true, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1events~1{id}/get/parameters/0" + } + } + ], + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/openapi/components/schemas/Event", + "nullable": false + }, + "examples": [ + { + "name": "one", + "value": { + "kind": "object", + "object": [ + { + "name": "id", + "value": { + "kind": "string", + "str": "e1" + } + } + ] + } + } + ], + "unmodeled": { + "openapi:x-note": { + "reason": "vendor_extension", + "value": "from the component", + "provenance": { + "source": 0, + "pointer": "/paths/~1events~1{id}/get/responses/200/content/application~1json/x-note" + } + } + } + } + ] + }, + "docs": { + "description": "ok" + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "GET", + "uriTemplate": "/events/{id}", + "sharedRoute": false, + "paramBindings": [ + { + "param": "id", + "location": "path", + "wireName": "id", + "style": "simple", + "explode": false, + "allowReserved": false + } + ], + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1events~1{id}/get" + } + } + ] + } + ], + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/openapi/components/schemas/Event": { + "kind": "model", + "id": "t/openapi/components/schemas/Event", + "name": { + "source": "Event", + "canonical": "event" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Event" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Event/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/Event/properties/id" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "string" + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {} + } + ], + "sources": [ + { + "format": "openapi@3.2", + "path": "component-media-types-32.yaml", + "hash": "34ac8518fca6017ef0b50f600a9f966ab58dfa33e990cc23784dc207be0c9529" + } + ] +} diff --git a/testdata/conformance/openapi/component-media-types-32.yaml b/testdata/conformance/openapi/component-media-types-32.yaml new file mode 100644 index 00000000..10aad281 --- /dev/null +++ b/testdata/conformance/openapi/component-media-types-32.yaml @@ -0,0 +1,35 @@ +openapi: 3.2.0 +info: {title: ComponentMediaTypes32, version: "1.0.0"} +paths: + /events: + get: + operationId: listEvents + responses: + "200": + description: ok + content: + # A content entry may reference a reusable Media Type Object; the + # bundled parser has no model for components/mediaTypes, so the entry + # is resolved from the raw node. + application/json: {$ref: '#/components/mediaTypes/Event'} + /events/{id}: + get: + operationId: getEvent + parameters: + - {name: id, in: path, required: true, schema: {type: string}} + responses: + "200": + description: ok + content: + application/json: {$ref: '#/components/mediaTypes/Event'} +components: + schemas: + Event: + type: object + properties: {id: {type: string}} + mediaTypes: + Event: + schema: {$ref: '#/components/schemas/Event'} + examples: + one: {value: {id: e1}} + x-note: from the component diff --git a/testdata/conformance/openapi/nested-encoding-32.golden.json b/testdata/conformance/openapi/nested-encoding-32.golden.json new file mode 100644 index 00000000..dc9a5167 --- /dev/null +++ b/testdata/conformance/openapi/nested-encoding-32.golden.json @@ -0,0 +1,218 @@ +{ + "irVersion": "0.6.0", + "name": "NestedEncoding32", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "NestedEncoding32", + "canonical": "nested_encoding_32" + }, + "docs": {}, + "groups": [ + { + "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/properties/note": { + "contentTypes": [ + "text/plain" + ], + "multi": false, + "filename": false + } + }, + "unmodeled": { + "openapi:encoding/note/encoding": { + "reason": "no_ir_home", + "value": { + "inner": { + "contentType": "text/plain" + } + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1upload/post/requestBody/content/multipart~1form-data/encoding/note/encoding" + } + }, + "openapi:encoding/note/prefixEncoding": { + "reason": "no_ir_home", + "value": [ + { + "contentType": "text/plain" + } + ], + "provenance": { + "source": 0, + "pointer": "/paths/~1upload/post/requestBody/content/multipart~1form-data/encoding/note/prefixEncoding" + } + } + } + } + ], + "required": false + }, + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "docs": { + "description": "ok" + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "POST", + "uriTemplate": "/upload", + "sharedRoute": false, + "requestContentTypes": [ + "multipart/form-data" + ], + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1upload/post" + } + } + ] + } + ], + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/anon/paths/~1upload/post/requestBody/content/multipart~1form-data/schema": { + "kind": "model", + "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" + }, + "properties": [ + { + "id": "p/openapi/paths/~1upload/post/requestBody/content/multipart~1form-data/schema/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/properties/note" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "string" + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {} + } + ], + "diagnostics": [ + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "encoding encoding has no ir.PartEncoding home; kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/paths/~1upload/post/requestBody/content/multipart~1form-data/encoding/note/encoding" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "encoding prefixEncoding has no ir.PartEncoding home; kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/paths/~1upload/post/requestBody/content/multipart~1form-data/encoding/note/prefixEncoding" + } + } + ], + "sources": [ + { + "format": "openapi@3.2", + "path": "nested-encoding-32.yaml", + "hash": "f7e4226b54fc807230a5ec00ca92ff97d4978af060e62cd20575c3e4badbb6a7" + } + ] +} diff --git a/testdata/conformance/openapi/nested-encoding-32.yaml b/testdata/conformance/openapi/nested-encoding-32.yaml new file mode 100644 index 00000000..bc92f79a --- /dev/null +++ b/testdata/conformance/openapi/nested-encoding-32.yaml @@ -0,0 +1,23 @@ +openapi: 3.2.0 +info: {title: NestedEncoding32, version: "1.0.0"} +paths: + /upload: + post: + operationId: upload + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + note: {type: string} + encoding: + # 3.2 lets an Encoding Object carry a nested encoding map and the + # positional prefix/item encodings; ir.PartEncoding has no field + # for any of them. + note: + contentType: text/plain + encoding: + inner: {contentType: text/plain} + prefixEncoding: [{contentType: text/plain}] + responses: {"200": {description: ok}} diff --git a/testdata/conformance/openapi/response-summary-32.golden.json b/testdata/conformance/openapi/response-summary-32.golden.json new file mode 100644 index 00000000..e9cc6fe2 --- /dev/null +++ b/testdata/conformance/openapi/response-summary-32.golden.json @@ -0,0 +1,151 @@ +{ + "irVersion": "0.6.0", + "name": "ResponseSummary32", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "ResponseSummary32", + "canonical": "response_summary_32" + }, + "docs": {}, + "groups": [ + { + "name": { + "hint": "default" + }, + "docs": {}, + "operations": [ + { + "id": "op/openapi/paths/~1items~1{id}/delete", + "name": { + "source": "deleteItem", + "canonical": "delete_item" + }, + "docs": {}, + "params": [ + { + "name": { + "source": "id", + "canonical": "id" + }, + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": true, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1items~1{id}/delete/parameters/0" + } + } + ], + "responses": [ + { + "name": { + "hint": "204" + }, + "conditions": { + "statusCodes": [ + { + "from": 204, + "to": 204 + } + ] + }, + "docs": { + "summary": "The item was deleted", + "description": "The long form of the same fact." + } + } + ], + "errors": [ + { + "name": { + "hint": "404" + }, + "conditions": { + "statusCodes": [ + { + "from": 404, + "to": 404 + } + ] + }, + "fault": "client", + "docs": { + "summary": "No such item", + "description": "Nothing there." + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "DELETE", + "uriTemplate": "/items/{id}", + "sharedRoute": false, + "paramBindings": [ + { + "param": "id", + "location": "path", + "wireName": "id", + "style": "simple", + "explode": false, + "allowReserved": false + } + ], + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1items~1{id}/delete" + } + } + ] + } + ], + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "string" + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {} + } + ], + "sources": [ + { + "format": "openapi@3.2", + "path": "response-summary-32.yaml", + "hash": "c763a7ecc60eb7e3a43d40f00cc1301c7805ea613cf1199a2071b1dc482997b3" + } + ] +} diff --git a/testdata/conformance/openapi/response-summary-32.yaml b/testdata/conformance/openapi/response-summary-32.yaml new file mode 100644 index 00000000..52d0ed24 --- /dev/null +++ b/testdata/conformance/openapi/response-summary-32.yaml @@ -0,0 +1,17 @@ +openapi: 3.2.0 +info: {title: ResponseSummary32, version: "1.0.0"} +paths: + /items/{id}: + delete: + operationId: deleteItem + parameters: + - {name: id, in: path, required: true, schema: {type: string}} + responses: + # 3.2 gives a Response Object a summary; the bundled model names no field + # for it, so it is read off the raw node. + "204": + summary: The item was deleted + description: The long form of the same fact. + "404": + summary: No such item + description: Nothing there. diff --git a/testdata/conformance/openapi/xml-nodetype-32.golden.json b/testdata/conformance/openapi/xml-nodetype-32.golden.json new file mode 100644 index 00000000..b372f95e --- /dev/null +++ b/testdata/conformance/openapi/xml-nodetype-32.golden.json @@ -0,0 +1,131 @@ +{ + "irVersion": "0.6.0", + "name": "XMLNodeType32", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "XMLNodeType32", + "canonical": "xml_node_type_32" + }, + "docs": {}, + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/openapi/components/schemas/Report": { + "kind": "model", + "id": "t/openapi/components/schemas/Report", + "name": { + "source": "Report", + "canonical": "report" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Report" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Report/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, + "xml": { + "nodeType": "attribute", + "wrapped": false + }, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Report/properties/id" + } + }, + { + "id": "p/openapi/components/schemas/Report/properties/body", + "name": { + "source": "body", + "canonical": "body" + }, + "wireName": "body", + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "xml": { + "nodeType": "text", + "wrapped": true + }, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Report/properties/body" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "string" + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {} + } + ], + "sources": [ + { + "format": "openapi@3.2", + "path": "xml-nodetype-32.yaml", + "hash": "d341aea6d2b8cf952809526d42ffb3af0f3e72e665115fbd54faacaeb753e761" + } + ] +} diff --git a/testdata/conformance/openapi/xml-nodetype-32.yaml b/testdata/conformance/openapi/xml-nodetype-32.yaml new file mode 100644 index 00000000..6478828f --- /dev/null +++ b/testdata/conformance/openapi/xml-nodetype-32.yaml @@ -0,0 +1,16 @@ +openapi: 3.2.0 +info: {title: XMLNodeType32, version: "1.0.0"} +paths: {} +components: + schemas: + Report: + type: object + properties: + # 3.2 replaces `attribute: true` with nodeType; ir.XMLHints.NodeType + # names the field already, and the parser's XML model has no field for it. + id: + type: string + xml: {nodeType: attribute} + body: + type: string + xml: {nodeType: text, wrapped: true} From 5c521d74920a6ea22985e5f539fe9c8736d5a2de Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Wed, 30 Sep 2026 21:13:18 +0300 Subject: [PATCH 8/9] fix(compilers/openapi): keep an unreferenced component entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A compiler lowers a component only where a reference finds it, and only components/schemas and components/securitySchemes are lowered unconditionally. Every other section's unreferenced entry therefore reached no node, no Unmodeled entry and no diagnostic: a declaration the document makes disappeared in silence (#616). Each entry of a section with no registry — responses, parameters, examples, requestBodies, headers, links, callbacks, pathItems, and 3.2's mediaTypes — that nothing references is now kept verbatim on the document under openapi:components/
/ with ReasonNoIRHome, at the entry's own pointer, with no diagnostic (the links precedent generalized). Referenced is transitive, not syntactic: internal/componentreach walks the raw tree for `$ref` strings rooted outside those sections and follows the references a reached entry writes in turn. A `$ref` inside an unreferenced entry is not a root, so the component it names is kept too — keeping the entry and dropping its target would restore the loss one hop later. The walk is bounded by a node budget and by expanding each entry once, so a reference cycle between two entries terminates. The new package has its own archtest rules entry: it reads yaml nodes and nothing else, because a package that could reach the lowering could decide reachability by what the lowering happened to resolve. components/mediaTypes joins the retained set only from 3.2, the version that defines it; below that the key is one the dialect does not define and the components census keeps the whole map, which is also what closes the gap the previous commit left open. preserveResponseExtras' links decision is revised in place rather than left contradictory, and ir-design §12 and §14 record the rule. --- compilers/openapi/conformance_test.go | 27 ++ .../annotation/annotation_internal_test.go | 48 +++ .../annotation/unknown_internal_test.go | 22 ++ .../internal/componentreach/componentreach.go | 270 +++++++++++++ .../componentreach/componentreach_test.go | 367 ++++++++++++++++++ .../internal/lowering/lowering_test.go | 28 ++ .../openapi/internal/operation/content.go | 22 +- .../openapi/internal/operation/operations.go | 9 +- .../operation/operations_internal_test.go | 14 + .../internal/operation/operations_test.go | 57 ++- compilers/openapi/meta.go | 58 +++ compilers/openapi/meta_test.go | 24 ++ .../openapi/unreferenced_internal_test.go | 108 ++++++ docs/ir-design.md | 24 +- internal/archtest/arch_test.go | 7 + .../unreferenced-components.golden.json | 209 ++++++++++ .../openapi/unreferenced-components.yaml | 33 ++ 17 files changed, 1312 insertions(+), 15 deletions(-) create mode 100644 compilers/openapi/internal/componentreach/componentreach.go create mode 100644 compilers/openapi/internal/componentreach/componentreach_test.go create mode 100644 compilers/openapi/unreferenced_internal_test.go create mode 100644 testdata/conformance/openapi/unreferenced-components.golden.json create mode 100644 testdata/conformance/openapi/unreferenced-components.yaml diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index d966b603..299b0651 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -248,6 +248,7 @@ func conformanceCases() []conformanceCase { {"component-media-types-32", assertComponentMediaTypes32, []string{"multi-content"}}, {"xml-nodetype-32", assertXMLNodeType32, nil}, {"nested-encoding-32", assertNestedEncoding32, []string{"multipart-encoding"}}, + {"unreferenced-components", assertUnreferencedComponents, nil}, {"extensions-x", assertExtensionsX, []string{"vendor-extensions"}}, {"inline-annotations", assertInlineAnnotations, []string{"vendor-extensions", "inline-anonymous"}}, {"inline-residue", assertInlineResidue, []string{"inline-anonymous"}}, @@ -3320,6 +3321,32 @@ func assertNestedEncoding32(t *testing.T, doc *ir.Document, diags []ir.Diagnosti "3.2 defines these keys, so the census must leave them alone") } +// assertUnreferencedComponents pins GitHub #616: every component entry no +// reference reaches is kept verbatim on the document, under the section and name +// it was declared with, while the one entry the paths do name lowers as it +// always did. No matrix row: the case is a preservation claim. +func assertUnreferencedComponents(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + for _, section := range []string{ + "responses", "parameters", "examples", "requestBodies", + "headers", "links", "callbacks", "pathItems", "mediaTypes", + } { + key := "openapi:components/" + section + "/Unused" + entry, ok := doc.Unmodeled[key] + require.True(t, ok, "%s is kept verbatim; got %v", section, slices.Sorted(maps.Keys(doc.Unmodeled))) + assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) + assert.Equal(t, "/components/"+section+"/Unused", string(entry.Provenance.Pointer), + "the entry is located at its own declaration, not at the carrier holding it") + } + assert.NotContains(t, doc.Unmodeled, "openapi:components/responses/Used", + "an entry a reference reaches is lowered, not also kept verbatim") + + op, ok := opByName(doc, "getA") + require.True(t, ok) + require.Len(t, op.Responses, 1) + assert.Equal(t, "reached from the paths", op.Responses[0].Docs.Description) + assert.False(t, openapitest.HasDiag(diags, diag.UnknownObjectKey)) +} + func assertExtensionsX(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { m, ok := doc.Types[namedID("S")].(*ir.Model) require.True(t, ok) diff --git a/compilers/openapi/internal/annotation/annotation_internal_test.go b/compilers/openapi/internal/annotation/annotation_internal_test.go index decf634f..e0ed32a4 100644 --- a/compilers/openapi/internal/annotation/annotation_internal_test.go +++ b/compilers/openapi/internal/annotation/annotation_internal_test.go @@ -208,3 +208,51 @@ func TestJSONObject_PropagatesAQuoteFailure(t *testing.T) { assert.Nil(t, got) assert.Contains(t, err.Error(), "invalid UTF-8") } + +// TestRead_NodeTypeIsReadFromTheRawXMLObject pins the one key of a 3.2 +// document this reader takes off the raw node: the library's XML model has no +// field for `nodeType`, so a reader that consulted the model alone would drop +// a declaration the document makes (GitHub #615). The census is told the key +// was read, which is why the same fixture read as 3.1 warns below instead. +func TestRead_NodeTypeIsReadFromTheRawXMLObject(t *testing.T) { + t.Parallel() + s := schemaFromYAML(t, "type: string\nxml:\n name: q\n nodeType: attribute\n") + + got, diags := Read(Site{Kind: Declaration, Node: s}, "/components/schemas/S", sourced(0), true) + + require.NotNil(t, got.XML) + assert.Equal(t, "q", got.XML.Name, "the modelled fields still come from the model") + assert.Equal(t, "attribute", got.XML.NodeType) + assert.Empty(t, diags, "a key the dialect defines is not announced as undefined") +} + +// TestRead_NodeTypeBelow32IsCensused is the other side of the gate: below 3.2 +// the key is a misspelling rather than a field the dialect added, so nothing is +// read into the IR and the census owes the warning. +func TestRead_NodeTypeBelow32IsCensused(t *testing.T) { + t.Parallel() + s := schemaFromYAML(t, "type: string\nxml:\n name: q\n nodeType: attribute\n") + + got, diags := Read(Site{Kind: Declaration, Node: s}, "/components/schemas/S", sourced(0), false) + + require.NotNil(t, got.XML) + assert.Empty(t, got.XML.NodeType, "3.1 defines no nodeType, so no field is filled") + require.Len(t, diags, 1) + assert.Equal(t, diag.UnknownObjectKey, diags[0].Code) + assert.Equal(t, ir.SeverityWarning, diags[0].Severity) + assert.Equal(t, jsontext.Pointer("/components/schemas/S/xml/nodeType"), diags[0].Provenance.Pointer, + "the warning names the key's own position") +} + +// TestRead_NodeTypeWithNoXMLObjectHasNothingToFill covers applyNodeType's first +// branch. `nodeType` is written inside the xml object, so a schema declaring no +// xml object has no position the key could sit at — and no hints to carry it. +func TestRead_NodeTypeWithNoXMLObjectHasNothingToFill(t *testing.T) { + t.Parallel() + s := schemaFromYAML(t, "type: string\n") + + got, diags := Read(Site{Kind: Declaration, Node: s}, "/components/schemas/S", sourced(0), true) + + assert.Nil(t, got.XML) + assert.Empty(t, diags) +} diff --git a/compilers/openapi/internal/annotation/unknown_internal_test.go b/compilers/openapi/internal/annotation/unknown_internal_test.go index 168ed0d6..417e131b 100644 --- a/compilers/openapi/internal/annotation/unknown_internal_test.go +++ b/compilers/openapi/internal/annotation/unknown_internal_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" yaml "gopkg.in/yaml.v3" + "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/ir" ) @@ -286,6 +287,27 @@ func TestUnknownKeysIn_ModelWithNoCensusRecordsNothing(t *testing.T) { } } +// TestUnknownKeysNamed_DelegatesTheSameGrading covers the object census's other +// entry point: an object whose model keeps no key list of its own — a Path +// Item's leftovers, in practice — so the caller names the keys the model does +// not define and this reader grades them exactly as the modelled path does. +func TestUnknownKeysNamed_DelegatesTheSameGrading(t *testing.T) { + t.Parallel() + root := parsedMapping(t, "get: {}\nnotAKey: 3\n") + + var got ir.Unmodeled + diags := UnknownKeysNamed(&got, []string{"notAKey"}, root, sourced(0), "/paths/~1p", "") + + require.Len(t, got, 1, "got %v", got) + entry := got["openapi:notAKey"] + assert.Equal(t, ir.ReasonOutOfScope, entry.Reason) + assert.Equal(t, ir.RawValue("3"), entry.Value) + assert.Equal(t, ir.Provenance{Pointer: "/paths/~1p/notAKey"}, entry.Provenance) + require.Len(t, diags, 1) + assert.Equal(t, diag.UnknownObjectKey, diags[0].Code) + assert.Equal(t, ir.SeverityWarning, diags[0].Severity) +} + // fakeObject is a parsed model standing in for the library's, so the census can // be driven at shapes no real document produces — an unsorted census, one past // the bound, and a core that keeps none. diff --git a/compilers/openapi/internal/componentreach/componentreach.go b/compilers/openapi/internal/componentreach/componentreach.go new file mode 100644 index 00000000..e1c1144c --- /dev/null +++ b/compilers/openapi/internal/componentreach/componentreach.go @@ -0,0 +1,270 @@ +// Package componentreach partitions a document's component entries by what its +// `$ref` strings reach. +// +// A compiler that lowers a component only where a reference finds it leaves +// every entry nothing names lowering nowhere: no node, no Unmodeled entry and no +// diagnostic, so the entry vanishes from the IR in silence. Deciding which +// entries those are is a question about the source document alone — which +// pointers a `$ref` written outside the component sections reaches, transitively +// — and it is asked here, over the raw tree, rather than by threading a record +// of what the lowering resolved through it (GitHub #616). +// +// The rule this package answers is the caller's: an entry is *referenced* when +// its pointer is reached by the transitive closure of `$ref` strings rooted at +// every position outside the retained sections. A `$ref` written inside an entry +// that is itself unreferenced is not a root, so the component it names is kept +// too — the lossless reading, and the reason a syntactic "some `$ref` names it" +// rule is not what this walks. +package componentreach + +import ( + "encoding/json/jsontext" + "strconv" + "strings" + + yaml "gopkg.in/yaml.v3" +) + +// maxNodes bounds how many positions one walk visits, and with them how many +// times an alias may be expanded. The loader refuses a source that amplifies +// past its own budget before a document is lowered, and clears its anchors, so a +// real document cannot reach this; the bound is for a tree assembled by hand. +// +// Reaching it costs nothing: a walk that stopped early has collected fewer +// references, so more entries come back unreferenced — the direction that keeps +// a component rather than dropping it. +const maxNodes = 1 << 20 + +// maxAliasDepth bounds deref. A chain that long is not a document; the loader's +// amplification check refuses it long before this runs. +const maxAliasDepth = 32 + +// Entry is one retained components entry that no reference reaches. +type Entry struct { + // Pointer is the entry's own source pointer (/components/
/). + Pointer jsontext.Pointer + // Node is the entry's raw node, for the caller to keep verbatim. + Node *yaml.Node +} + +// Unreferenced returns every entry of the named component sections that no +// `$ref` reachable from outside them names, in the order the document declares +// them. +// +// sections is the set of sections the caller retains verbatim. Every other +// section — components/schemas and components/securitySchemes in particular — is +// a root rather than a subject: a reference written inside one of those counts, +// because a document's own schemas are all reachable. +func Unreferenced(root *yaml.Node, sections []string) []Entry { + if root == nil || len(sections) == 0 { + return nil + } + retained := make(map[string]bool, len(sections)) + for _, section := range sections { + retained[section] = true + } + declared, rootRefs := walk(root, retained) + return unreferenced(declared, reach(rootRefs, declared)) +} + +// declaredEntry is one entry of a retained section, as the document declares it. +type declaredEntry struct { + pointer jsontext.Pointer + node *yaml.Node +} + +// frame is one position on walk's stack: the node there and the pointer that +// locates it. +type frame struct { + node *yaml.Node + ptr jsontext.Pointer +} + +// walk returns every retained section's declared entries, in the document's +// order, together with the `$ref` targets written outside every one of them. +// +// A retained section is recorded rather than descended into: an entry is reached +// by a reference or not at all, which is what the rule says, so the walk reads +// the section's names and leaves each subtree to reach. Every other position, +// including the sections that lower unconditionally, is walked as a root. +func walk(root *yaml.Node, retained map[string]bool) ([]declaredEntry, []jsontext.Pointer) { + var declared []declaredEntry + var refs []jsontext.Pointer + budget := maxNodes + stack := []frame{{node: root}} + for len(stack) > 0 && budget > 0 { + f := stack[len(stack)-1] + stack = stack[:len(stack)-1] + budget-- + node := deref(f.node) + if node == nil { + continue + } + if _, ok := retainedSection(f.ptr, retained); ok { + declared = append(declared, sectionEntries(node, f.ptr)...) + continue + } + nested, found := children(node, f.ptr) + refs = append(refs, found...) + stack = append(stack, nested...) + } + return declared, refs +} + +// children returns the positions directly beneath one node, pushed in reverse +// document order so the stack pops them in document order — the order the +// declared entries come back in — together with the `$ref` targets the node +// writes. A `$ref` member is a reference rather than a position: its target is +// returned and its value is not descended into. +func children(node *yaml.Node, ptr jsontext.Pointer) ([]frame, []jsontext.Pointer) { + switch node.Kind { + case yaml.MappingNode: + nested := make([]frame, 0, len(node.Content)/2) + var refs []jsontext.Pointer + for i := len(node.Content) - 2; i >= 0; i -= 2 { + key, val := node.Content[i], node.Content[i+1] + if key.Value == "$ref" { + if target, ok := internalTarget(deref(val)); ok { + refs = append(refs, target) + } + continue + } + nested = append(nested, frame{node: val, ptr: ptr.AppendToken(key.Value)}) + } + return nested, refs + case yaml.SequenceNode: + nested := make([]frame, 0, len(node.Content)) + for i := len(node.Content) - 1; i >= 0; i-- { + nested = append(nested, frame{node: node.Content[i], ptr: ptr.AppendToken(strconv.Itoa(i))}) + } + return nested, nil + } + return nil, nil +} + +// sectionEntries returns the direct entries of one retained section, in the +// order the document declares them. +func sectionEntries(section *yaml.Node, sectionPtr jsontext.Pointer) []declaredEntry { + out := make([]declaredEntry, 0, len(section.Content)/2) + for i := 0; i+1 < len(section.Content); i += 2 { + out = append(out, declaredEntry{ + pointer: sectionPtr.AppendToken(section.Content[i].Value), + node: section.Content[i+1], + }) + } + return out +} + +// reach returns the entry pointers the document reaches: those the root +// references name, and then the ones those entries' own references name, until +// nothing new is reached. Each entry is expanded once, so a reference cycle +// between two entries terminates and the walk is bounded by the document's +// references. +func reach(rootRefs []jsontext.Pointer, declared []declaredEntry) map[jsontext.Pointer]bool { + byPointer := make(map[jsontext.Pointer]*yaml.Node, len(declared)) + for _, entry := range declared { + byPointer[entry.pointer] = entry.node + } + out := map[jsontext.Pointer]bool{} + frontier := rootRefs + budget := maxNodes + for len(frontier) > 0 && budget > 0 { + budget-- + target := frontier[0] + frontier = frontier[1:] + if out[target] { + continue + } + entry, ok := byPointer[target] + if !ok { + continue // a reference into a section that lowers unconditionally, or nowhere + } + out[target] = true + frontier = append(frontier, subtreeRefs(entry)...) + } + return out +} + +// subtreeRefs returns every `$ref` target written in one entry's subtree. It +// carries no pointer, because a reference here only has to be found rather than +// located: the entry it sits in is already known. +func subtreeRefs(node *yaml.Node) []jsontext.Pointer { + var out []jsontext.Pointer + budget := maxNodes + stack := []*yaml.Node{node} + for len(stack) > 0 && budget > 0 { + cur := stack[len(stack)-1] + stack = stack[:len(stack)-1] + budget-- + cur = deref(cur) + if cur == nil { + continue + } + switch cur.Kind { + case yaml.MappingNode: + for i := 0; i+1 < len(cur.Content); i += 2 { + key, val := cur.Content[i], cur.Content[i+1] + if key.Value == "$ref" { + if target, ok := internalTarget(deref(val)); ok { + out = append(out, target) + } + continue + } + stack = append(stack, val) + } + case yaml.SequenceNode: + stack = append(stack, cur.Content...) + } + } + return out +} + +// unreferenced returns the entries no reference reached, in declaration order. +func unreferenced(declared []declaredEntry, reachable map[jsontext.Pointer]bool) []Entry { + var out []Entry + for _, entry := range declared { + if reachable[entry.pointer] { + continue + } + out = append(out, Entry{Pointer: entry.pointer, Node: entry.node}) + } + return out +} + +// retainedSection reports whether ptr is a retained section's own mapping node, +// and names the section. The components object sits one token beneath the root, +// so a section is exactly /components/. +func retainedSection(ptr jsontext.Pointer, retained map[string]bool) (string, bool) { + if ptr.Parent() != jsontext.Pointer("/components") { + return "", false + } + name := ptr.LastToken() + return name, retained[name] +} + +// internalTarget returns the pointer a `$ref` string names inside this document, +// and ok=false for one that leaves it: a reference with no fragment names the +// whole of another document, and nothing local is reached by it. +func internalTarget(ref *yaml.Node) (jsontext.Pointer, bool) { + if ref == nil || ref.Kind != yaml.ScalarNode { + return "", false + } + fragment, found := strings.CutPrefix(ref.Value, "#") + if !found || !strings.HasPrefix(fragment, "/") { + return "", false + } + return jsontext.Pointer(fragment), true +} + +// deref follows a YAML alias to the node it names. An alias cycle is refused +// before lowering, and the depth bound is what keeps this walk terminating +// without relying on that. +func deref(node *yaml.Node) *yaml.Node { + for range maxAliasDepth { + if node == nil || node.Kind != yaml.AliasNode || node.Alias == nil { + return node + } + node = node.Alias + } + return nil +} diff --git a/compilers/openapi/internal/componentreach/componentreach_test.go b/compilers/openapi/internal/componentreach/componentreach_test.go new file mode 100644 index 00000000..71ee2723 --- /dev/null +++ b/compilers/openapi/internal/componentreach/componentreach_test.go @@ -0,0 +1,367 @@ +package componentreach_test + +import ( + "encoding/json/jsontext" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v3" + + "github.com/dexpace/morphic/compilers/openapi/internal/componentreach" +) + +// sections is the retained set every case below uses: the sections a compiler +// lowers only where a reference finds them. +var sections = []string{"responses", "parameters", "examples", "requestBodies", + "headers", "links", "callbacks", "pathItems", "mediaTypes"} + +// parse returns the root node of a YAML document. +func parse(t *testing.T, src string) *yaml.Node { + t.Helper() + var doc yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(src), &doc)) + require.Len(t, doc.Content, 1) + return doc.Content[0] +} + +// pointers returns the pointers of the entries found, as strings. +func pointers(entries []componentreach.Entry) []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, string(e.Pointer)) + } + return out +} + +// TestUnreferenced_DeclaresNothingToRetain covers the two short circuits: a +// document with no components object at all, and a caller retaining no section. +func TestUnreferenced_DeclaresNothingToRetain(t *testing.T) { + t.Parallel() + assert.Empty(t, componentreach.Unreferenced(nil, sections), "no tree, nothing to walk") + assert.Empty(t, componentreach.Unreferenced(parse(t, "paths: {}\n"), sections), + "a document with no components object declares no entry") + assert.Empty(t, componentreach.Unreferenced(parse(t, "components: {schemas: {A: {type: string}}}\n"), nil), + "a caller retaining no section asks for nothing") +} + +// TestUnreferenced_EverySectionKeptWhenNothingReferencesIt is the case the fix +// exists for: one entry in each retained section, no `$ref` anywhere, so every +// one of them is kept — in the order the document declares them. +func TestUnreferenced_EverySectionKeptWhenNothingReferencesIt(t *testing.T) { + t.Parallel() + root := parse(t, `components: + responses: {R: {description: ok}} + parameters: {P: {name: p, in: query, schema: {type: string}}} + examples: {E: {value: 1}} + requestBodies: {B: {content: {application/json: {schema: {type: string}}}}} + headers: {H: {schema: {type: string}}} + links: {L: {operationId: op}} + callbacks: {C: {'{$request.body#/url}': {post: {responses: {200: {description: ok}}}}}} + pathItems: {I: {get: {responses: {200: {description: ok}}}}} + mediaTypes: {M: {schema: {type: string}}} + schemas: {S: {type: string}} + securitySchemes: {K: {type: apiKey, in: header, name: X-K}} +`) + entries := componentreach.Unreferenced(root, sections) + assert.Equal(t, []string{ + "/components/responses/R", + "/components/parameters/P", + "/components/examples/E", + "/components/requestBodies/B", + "/components/headers/H", + "/components/links/L", + "/components/callbacks/C", + "/components/pathItems/I", + "/components/mediaTypes/M", + }, pointers(entries), "every retained section's every entry, in document order") + for _, e := range entries { + assert.NotNil(t, e.Node, "each entry carries its raw node") + } +} + +// TestUnreferenced_ReferenceFromThePathsKeepsTheEntry pins the referenced side: +// an entry a `$ref` outside the retained sections names is not returned, and a +// second entry in the same section that nothing names still is. +func TestUnreferenced_ReferenceFromThePathsKeepsTheEntry(t *testing.T) { + t.Parallel() + root := parse(t, `paths: + /a: + get: + responses: + "200": {$ref: '#/components/responses/Used'} + "404": {$ref: '#/components/responses/Gone'} +components: + responses: + Used: + description: ok + headers: {X: {$ref: '#/components/headers/H', schema: {type: string}}} + Gone: {description: no} + parameters: {Unused: {name: u, in: query, schema: {type: string}}} +`) + assert.Equal(t, []string{"/components/parameters/Unused"}, pointers(componentreach.Unreferenced(root, sections)), + "the response the paths name is reached, the one only the other mount names is not, and the parameter is untouched") +} + +// TestUnreferenced_SchemasAreRootsNotSubjects pins that the two sections that +// lower unconditionally are walked as roots: a `$ref` written inside a component +// schema makes its target referenced. +func TestUnreferenced_SchemasAreRootsNotSubjects(t *testing.T) { + t.Parallel() + root := parse(t, `components: + schemas: + S: {type: object, properties: {x: {$ref: '#/components/parameters/P'}}} + parameters: {P: {name: p, in: query, schema: {type: string}}} +`) + assert.Empty(t, componentreach.Unreferenced(root, sections), + "a reference inside components/schemas counts, and securitySchemes is walked the same way") +} + +// TestUnreferenced_ReachableEntryExpandsItsOwnReferences is the transitive rule's +// witness: an entry the paths name is reached, and the entry *it* names is +// reached in turn. +func TestUnreferenced_ReachableEntryExpandsItsOwnReferences(t *testing.T) { + t.Parallel() + root := parse(t, `paths: + /a: + get: + responses: + "200": {$ref: '#/components/responses/R'} +components: + responses: + R: + description: ok + content: + application/json: {schema: {$ref: '#/components/parameters/P'}} + parameters: {P: {name: p, in: query, schema: {type: string}}} +`) + assert.Empty(t, componentreach.Unreferenced(root, sections), + "the reached response's own reference reaches the parameter") +} + +// TestUnreferenced_UnreachableEntryDoesNotExpandItsOwnReferences is the other +// half, and the reason the rule is transitive rather than syntactic: a `$ref` +// inside an entry nothing reaches does not make its target referenced, so the +// target is kept too rather than being lowered on behalf of an entry that +// vanishes. +func TestUnreferenced_UnreachableEntryDoesNotExpandItsOwnReferences(t *testing.T) { + t.Parallel() + root := parse(t, `paths: {} +components: + responses: + R: + description: ok + content: + application/json: {schema: {$ref: '#/components/parameters/P'}} + parameters: {P: {name: p, in: query, schema: {type: string}}} +`) + assert.Equal(t, []string{"/components/responses/R", "/components/parameters/P"}, + pointers(componentreach.Unreferenced(root, sections))) +} + +// TestUnreferenced_CyclicEntriesBothKeptTerminates pins the bound the reach walk +// does not need but must survive: two entries that name each other and that +// nothing outside names are both kept, and the walk terminates. +func TestUnreferenced_CyclicEntriesBothKeptTerminates(t *testing.T) { + t.Parallel() + root := parse(t, `components: + responses: + A: {description: a, headers: {X: {$ref: '#/components/parameters/B', schema: {type: string}}}} + parameters: + B: {name: b, in: query, schema: {type: string}, examples: {e: {$ref: '#/components/responses/A'}}} +`) + assert.Equal(t, []string{"/components/responses/A", "/components/parameters/B"}, + pointers(componentreach.Unreferenced(root, sections))) +} + +// TestUnreferenced_CyclicEntriesBothReachedTerminates is the same cycle with a +// root: the paths name one of them, so both are reached and neither is kept. +func TestUnreferenced_CyclicEntriesBothReachedTerminates(t *testing.T) { + t.Parallel() + root := parse(t, `paths: + /a: + get: + responses: + "200": {$ref: '#/components/responses/A'} +components: + responses: + A: {description: a, headers: {X: {$ref: '#/components/parameters/B', schema: {type: string}}}} + parameters: + B: {name: b, in: query, schema: {type: string}, examples: {e: {$ref: '#/components/responses/A'}}} +`) + assert.Empty(t, componentreach.Unreferenced(root, sections)) +} + +// TestUnreferenced_ExternalAndNonScalarReferencesAreNoRoots pins what a +// reference that names nothing here does: another document reaches no local +// entry, and a `$ref` whose value is not a scalar is not a reference at all. +func TestUnreferenced_ExternalAndNonScalarReferencesAreNoRoots(t *testing.T) { + t.Parallel() + root := parse(t, `paths: + /a: + get: + responses: + "200": {$ref: 'other.yaml#/components/responses/R'} + "404": {$ref: {nested: '#/components/responses/R'}} + "500": {$ref: 'no-fragment'} +components: + responses: {R: {description: ok}} +`) + assert.Equal(t, []string{"/components/responses/R"}, + pointers(componentreach.Unreferenced(root, sections)), + "no reference here names a local entry") +} + +// TestUnreferenced_ReferenceThroughAnAliasCounts pins that the walk follows a +// YAML alias, which is the one shape where the same node is written once and +// read from another position. +func TestUnreferenced_ReferenceThroughAnAliasCounts(t *testing.T) { + t.Parallel() + root := parse(t, `components: + responses: {R: {description: ok}} +paths: + /a: + get: + x-shared: &shared {$ref: '#/components/responses/R'} + responses: + "200": *shared +`) + assert.Empty(t, componentreach.Unreferenced(root, sections)) +} + +// TestUnreferenced_NilNodeInTheTreeIsWalkedPast pins the walk's tolerance of a +// node no parser produces: an entry whose value is a sequence holding a nil is +// still declared, and neither walk dereferences the nil. +func TestUnreferenced_NilNodeInTheTreeIsWalkedPast(t *testing.T) { + t.Parallel() + root := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + scalar("components"), {Kind: yaml.MappingNode, Content: []*yaml.Node{ + scalar("responses"), {Kind: yaml.MappingNode, Content: []*yaml.Node{ + scalar("R"), {Kind: yaml.SequenceNode, Content: []*yaml.Node{nil}}, + }}, + }}, + }} + assert.Equal(t, []string{"/components/responses/R"}, + pointers(componentreach.Unreferenced(root, sections))) +} + +// TestUnreferenced_AliasChainPastTheBoundWalksNothing pins deref's bound: a +// chain long enough to exhaust it yields no node, so the walk skips the position +// rather than following it forever. +func TestUnreferenced_AliasChainPastTheBoundWalksNothing(t *testing.T) { + t.Parallel() + leaf := scalar("components") + for range 40 { + leaf = &yaml.Node{Kind: yaml.AliasNode, Alias: leaf} + } + // The alias sits where a mapping value would, so the walk dereferences it and + // finds nothing rather than a components object. + root := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{scalar("x"), leaf}} + assert.Empty(t, componentreach.Unreferenced(root, sections)) +} + +// scalar returns a plain scalar node. +func scalar(value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} +} + +// TestUnreferenced_OrderIsTheDocuments is the determinism rule: the entries come +// back in the order the document declares them, whatever that order is. +func TestUnreferenced_OrderIsTheDocuments(t *testing.T) { + t.Parallel() + spec := func(first, second string) *yaml.Node { + return parse(t, "components:\n responses:\n "+first+": {description: a}\n "+second+": {description: b}\n") + } + assert.Equal(t, []string{"/components/responses/A", "/components/responses/B"}, + pointers(componentreach.Unreferenced(spec("A", "B"), sections))) + assert.Equal(t, []string{"/components/responses/B", "/components/responses/A"}, + pointers(componentreach.Unreferenced(spec("B", "A"), sections))) +} + +// TestUnreferenced_PointerEscapesANameKeepsTheRefAddressable pins the one thing +// the pointer is used for: it has to equal the pointer a `$ref` writes, so a +// name holding a "/" is escaped the RFC 6901 way and the reference resolves. +func TestUnreferenced_PointerEscapesANameKeepsTheRefAddressable(t *testing.T) { + t.Parallel() + root := parse(t, `paths: + /a: + get: + responses: + "200": {$ref: '#/components/responses/a~1b'} +components: + responses: + a/b: {description: ok} +`) + assert.Empty(t, componentreach.Unreferenced(root, sections), + "the escaped pointer the reference writes names the entry") +} + +// TestUnreferenced_ComponentsThatIsNotAMappingDeclaresNothing pins the shape +// guard: a components value the walk cannot read as an object yields no entry +// rather than a panic. +func TestUnreferenced_ComponentsThatIsNotAMappingDeclaresNothing(t *testing.T) { + t.Parallel() + assert.Empty(t, componentreach.Unreferenced(parse(t, "components: hello\n"), sections)) +} + +// TestUnreferenced_SectionThatIsNotAMappingDeclaresNothing is the same guard one +// level down: a retained section written as a scalar declares no entries. +func TestUnreferenced_SectionThatIsNotAMappingDeclaresNothing(t *testing.T) { + t.Parallel() + assert.Empty(t, componentreach.Unreferenced(parse(t, "components: {responses: hello}\n"), sections), + "a scalar has no entries, and the walk reads none") +} + +// TestUnreferenced_HandleIsUnused keeps the exported Entry's fields honest: both +// are populated, and the pointer is a well-formed JSON pointer. +func TestUnreferenced_EntryPointerIsAJSONPointer(t *testing.T) { + t.Parallel() + entries := componentreach.Unreferenced(parse(t, "components: {responses: {R: {description: ok}}}\n"), sections) + require.Len(t, entries, 1) + assert.Equal(t, jsontext.Pointer("/components/responses/R"), entries[0].Pointer) + assert.True(t, entries[0].Pointer.IsValid()) + assert.Equal(t, "R", entries[0].Pointer.LastToken()) +} + +// TestUnreferenced_ReferencesInsideASequenceCount pins the walk through a +// sequence: a `$ref` written in one is read like any other, which is what a +// `parameters: [$ref]` list on a path item is. +func TestUnreferenced_ReferencesInsideASequenceCount(t *testing.T) { + t.Parallel() + root := parse(t, `paths: + /a: + get: + parameters: + - {$ref: '#/components/parameters/P'} +components: + parameters: {P: {name: p, in: query, schema: {type: string}}} +`) + assert.Empty(t, componentreach.Unreferenced(root, sections)) +} + +// TestUnreferenced_ReachedEntryWalksPastANilNode pins the two tolerances a +// hand-built tree needs: a reached entry whose subtree holds a nil is still +// expanded, and neither walk dereferences the nil. +func TestUnreferenced_ReachedEntryWalksPastANilNode(t *testing.T) { + t.Parallel() + root := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + scalar("paths"), {Kind: yaml.MappingNode, Content: []*yaml.Node{ + scalar("/a"), {Kind: yaml.MappingNode, Content: []*yaml.Node{ + scalar("get"), {Kind: yaml.MappingNode, Content: []*yaml.Node{ + scalar("responses"), {Kind: yaml.MappingNode, Content: []*yaml.Node{ + scalar("200"), {Kind: yaml.MappingNode, Content: []*yaml.Node{ + scalar("$ref"), scalar("#/components/responses/R"), + }}, + }}, + }}, + }}, + }}, + scalar("components"), {Kind: yaml.MappingNode, Content: []*yaml.Node{ + scalar("responses"), {Kind: yaml.MappingNode, Content: []*yaml.Node{ + scalar("R"), {Kind: yaml.SequenceNode, Content: []*yaml.Node{nil}}, + }}, + }}, + }} + assert.Empty(t, componentreach.Unreferenced(root, sections), + "the entry is reached, so it is not kept, and walking its subtree survived the nil") +} diff --git a/compilers/openapi/internal/lowering/lowering_test.go b/compilers/openapi/internal/lowering/lowering_test.go index 2f9bc9bd..895278e1 100644 --- a/compilers/openapi/internal/lowering/lowering_test.go +++ b/compilers/openapi/internal/lowering/lowering_test.go @@ -195,6 +195,34 @@ func TestExclusiveBoundIsBoolean_FollowsTheDialect(t *testing.T) { } } +// TestIs32_FollowsTheDeclaredVersion pins the one answer every 3.2 raw-node +// reader and every 3.2 census suppression asks for (GitHub #615). Asking once +// here is what keeps the reader and the census from disagreeing about which +// dialect they are reading, and a version nobody recognizes answers false — the +// raw-node readers are refused rather than let to run on a dialect nothing has +// said anything about. +func TestIs32_FollowsTheDeclaredVersion(t *testing.T) { + t.Parallel() + tests := []struct { + version string + want bool + }{ + {version: "3.0.3", want: false}, + {version: "3.1.0", want: false}, + {version: "3.2.0", want: true}, + {version: "3.2.1", want: true}, + {version: "4.0.0", want: false}, + {version: "", want: false}, + } + for _, tc := range tests { + t.Run(tc.version, func(t *testing.T) { + t.Parallel() + c := lowering.New(0, &soa.OpenAPI{OpenAPI: tc.version}, ir.SourceInfo{}, "", lowering.Limits{}, lowering.StreamingMedia{}, lowering.ExtensionPromotions{}, overlay.Origin{}) + assert.Equal(t, tc.want, c.Is32()) + }) + } +} + // TestRefScope_IsTheContextSeenAsAScope pins the two facts reference resolution // reads, and that both come from the context rather than from a copy beside it: // the document's own path decides internal from external, and the declared set diff --git a/compilers/openapi/internal/operation/content.go b/compilers/openapi/internal/operation/content.go index 6e8c686b..ff289d11 100644 --- a/compilers/openapi/internal/operation/content.go +++ b/compilers/openapi/internal/operation/content.go @@ -16,6 +16,7 @@ package operation import ( "context" "encoding/json/jsontext" + "errors" "slices" "strings" @@ -47,9 +48,6 @@ func lowerPayload(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex var diags []ir.Diagnostic payload := &ir.Payload{} for mt, media := range content.All() { - if media == nil { - continue - } entry, fromRef, entryDiags := contentEntry(c, media, pointer+ids.Ptr("content", mt)) diags = append(diags, entryDiags...) if entry == nil { @@ -79,7 +77,14 @@ func lowerPayload(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex // another `$ref` — leaves the original entry to lower as it did before, so the // `$ref` is censused and kept verbatim, with one `openapi/unresolved-ref` // diagnostic saying what was wrong (GitHub #615). +// +// A nil entry names nothing: a content map the parser left an empty value for +// contributes no content, and the caller's loop passes over it on the entry the +// one answer returns. func contentEntry(c lowering.Ctx, media *soa.MediaType, entryPtr jsontext.Pointer) (*soa.MediaType, bool, []ir.Diagnostic) { + if media == nil { + return nil, false, nil + } ref, isRef := rawRefOf(media) if !isRef || !c.Is32() { return media, false, nil @@ -112,11 +117,12 @@ func resolveMediaTypeRef(c lowering.Ctx, ref string) (*soa.MediaType, bool, stri } var out soa.MediaType errs, err := marshaller.UnmarshalNode(context.Background(), "", node, &out) - if err != nil { - return nil, false, "could not be read as a media type object" - } - if len(errs) > 0 { - return nil, false, "is not a media type object: " + diag.OneLine(errs[0]) + // Unmarshalling reports a node it cannot read as a Media Type Object on errs, + // and its own failures on err. Joining the two keeps both handled without a + // branch no document can reach — every node a document can write comes back + // as a validation error on the first — and errors.Join drops the nil one. + if problems := errors.Join(append(errs, err)...); problems != nil { + return nil, false, "is not a media type object: " + diag.OneLine(problems) } if _, chained := rawRefOf(&out); chained { // A one-hop reading, deliberately: the shapes are one entry, and following a diff --git a/compilers/openapi/internal/operation/operations.go b/compilers/openapi/internal/operation/operations.go index 5bc3cc03..d266dd33 100644 --- a/compilers/openapi/internal/operation/operations.go +++ b/compilers/openapi/internal/operation/operations.go @@ -1101,10 +1101,11 @@ func responseDecidedKeys(c lowering.Ctx) []string { // A Link Object inside that map gets no entry and no census of its own, which is // the decision already recorded for its extensions. This compiler lowers no Link // Object anywhere: a response's links survive only as the verbatim node above, -// and a components/links entry nothing references is dropped whole, so a keyed -// entry at one of the two positions would be the only trace of a construct the -// IR does not model — while duplicating, for the response position alone, a -// value the node above already carries. +// and a components/links entry nothing references is kept by the document-level +// rule (retainUnreferencedComponents), which supersedes the "dropped whole" this +// comment used to record — one rule for every component section with no +// registry, so a keyed entry at one position cannot be the only trace of a +// construct the IR does not model (GitHub #616). func preserveResponseExtras(c lowering.Ctx, p *ir.Unmodeled, r *soa.Response, rptr jsontext.Pointer) []ir.Diagnostic { _, diags := schema.PreserveNode(c, p, "openapi:links", annotation.RawChildNode(r.GetRootNode(), "links"), ir.ReasonNoIRHome, rptr+ids.Ptr("links")) diff --git a/compilers/openapi/internal/operation/operations_internal_test.go b/compilers/openapi/internal/operation/operations_internal_test.go index 5a3cf8c2..209be968 100644 --- a/compilers/openapi/internal/operation/operations_internal_test.go +++ b/compilers/openapi/internal/operation/operations_internal_test.go @@ -680,3 +680,17 @@ func TestOperationIDClaims_AddSkipsAnEmptyOperationID(t *testing.T) { assert.Empty(t, claims.names, "nothing was claimed, so nothing is queued to report") assert.Empty(t, claims.report(newRawLowerer(nil).ctx)) } + +// TestDeclaredTags_SkipsANilEntry pins the guard on the tag list: a nil entry +// indexes no name, so it can neither be read for a kind or a parent by the +// grouping walk nor shadow a tag the document does declare. The parser produces +// no such entry, which is why the guard is exercised at the list it guards. +func TestDeclaredTags_SkipsANilEntry(t *testing.T) { + t.Parallel() + kept := &soa.Tag{Name: "kept"} + + got := declaredTags([]*soa.Tag{nil, kept}) + + require.Len(t, got, 1) + assert.Same(t, kept, got["kept"]) +} diff --git a/compilers/openapi/internal/operation/operations_test.go b/compilers/openapi/internal/operation/operations_test.go index 136b1cfb..64150958 100644 --- a/compilers/openapi/internal/operation/operations_test.go +++ b/compilers/openapi/internal/operation/operations_test.go @@ -3346,6 +3346,56 @@ tags: require.Len(t, got[0].Groups[0].Operations, 1, "the badge tag first in the operation's tags changed nothing") } +// TestContent_MediaTypesRefIsOrderIndependent is the two-order diff for GitHub +// #615. The entry a content `$ref` resolves through is read by pointer off the +// raw tree, and the type registry it lowers into is not a diagnostic, so the +// order-invariance oracle does not cover this pair: both documents below declare +// the same components and the same path, and only the order of the two top-level +// blocks differs. The contents, and the registry identity they carry, must be +// identical. +func TestContent_MediaTypesRefIsOrderIndependent(t *testing.T) { + t.Parallel() + const header = `openapi: 3.2.0 +info: {title: T, version: "1"} +` + const paths = `paths: + /a: + get: + operationId: a + responses: + "200": + description: ok + content: + application/json: {$ref: '#/components/mediaTypes/Json'} +` + const components = `components: + schemas: + N: {type: string} + mediaTypes: + Json: + schema: {$ref: '#/components/schemas/N'} + examples: + one: {value: {k: 1}} +` + lower := func(spec string) ([]ir.Content, []ir.Diagnostic) { + t.Helper() + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FirstOp(t, svc) + require.NotNil(t, op.Responses[0].Payload) + return op.Responses[0].Payload.Contents, diags + } + componentFirst, firstDiags := lower(header + components + paths) + componentLast, lastDiags := lower(header + paths + components) + + assert.Empty(t, cmp.Diff(componentFirst, componentLast), + "where the media type is declared must not change what the content lowers to") + assert.Empty(t, cmp.Diff(firstDiags, lastDiags), "nor may the diagnostic list depend on it") + require.Len(t, componentFirst, 1) + assert.Equal(t, ir.TypeID("t/openapi/components/schemas/N"), componentFirst[0].Type.Target, + "the reference resolved, so the two orders above compared a resolved content") +} + // TestResponses_SummaryIsReadFor32Only pins GitHub #615's Response.summary: the // 3.2 field reaches Docs.Summary, and below 3.2 the same key is still a key the // dialect does not define, so it keeps warning rather than being silently @@ -3497,8 +3547,9 @@ components: // TestContent_MediaTypesRefFailuresLowerAsWritten covers every target the // resolver refuses: an entry the document does not declare, an external -// document, a pointer naming another section, and an entry that is not an -// object. Each keeps the `$ref` verbatim and reports one unresolved-ref. +// document, a pointer naming another section, an entry that is not an object, +// and an entry that is itself another `$ref` (the one-hop reading). Each keeps +// the `$ref` verbatim and reports one unresolved-ref. func TestContent_MediaTypesRefFailuresLowerAsWritten(t *testing.T) { t.Parallel() refs := map[string]string{ @@ -3506,6 +3557,7 @@ func TestContent_MediaTypesRefFailuresLowerAsWritten(t *testing.T) { "another document": "other.yaml#/components/mediaTypes/Json", "another section": "#/components/schemas/N", "an entry that is not an object": "#/components/mediaTypes/Scalar", + "a chained $ref": "#/components/mediaTypes/Chained", } for name, ref := range refs { t.Run(name, func(t *testing.T) { @@ -3526,6 +3578,7 @@ components: N: {type: string} mediaTypes: Scalar: hello + Chained: {$ref: '#/components/mediaTypes/Scalar'} `) openapitest.AssertHasCode(t, diags, diag.UnresolvedRef, ir.SeverityError) content := openapitest.FindOp(t, doc, "a").Responses[0].Payload.Contents[0] diff --git a/compilers/openapi/meta.go b/compilers/openapi/meta.go index 9949178a..26a3e464 100644 --- a/compilers/openapi/meta.go +++ b/compilers/openapi/meta.go @@ -8,6 +8,7 @@ import ( "github.com/dexpace/morphic/compilers/compile" "github.com/dexpace/morphic/compilers/openapi/internal/annotation" + "github.com/dexpace/morphic/compilers/openapi/internal/componentreach" "github.com/dexpace/morphic/compilers/openapi/internal/ids" "github.com/dexpace/morphic/compilers/openapi/internal/lowering" "github.com/dexpace/morphic/ir" @@ -38,6 +39,59 @@ type docMeta struct { Unmodeled ir.Unmodeled } +// retainedComponentSections returns the components sections nothing lowers +// unless a `$ref` reaches them. None of them has a registry: components/schemas +// is walked by the schema lowering and components/securitySchemes by the auth +// lowering, both unconditionally, while these eight are reached only where a +// reference finds an entry — so an entry no reference names would reach no node, +// no Unmodeled entry and no diagnostic at all (GitHub #616). +// +// components/mediaTypes joins them only from 3.2, the version that defines the +// section. Below it the key is one the dialect does not define, and the +// components census already keeps the whole map verbatim under +// openapi:components/mediaTypes. +func retainedComponentSections(c lowering.Ctx) []string { + sections := []string{ + "responses", "parameters", "examples", "requestBodies", + "headers", "links", "callbacks", "pathItems", + } + if c.Is32() { + sections = append(sections, ids.MediaTypesKind) + } + return sections +} + +// retainUnreferencedComponents keeps every component entry no reference reaches +// verbatim on the document, under openapi:components/
/ with +// ReasonNoIRHome and no diagnostic. +// +// Keeping rather than dropping is invariant 2's default and the only lossless +// answer: the entry is a declaration the document makes, and nothing about this +// compiler's lowering says a consumer may not want it. The rule is the one +// already recorded for a response's links map, generalized to every section with +// no registry. ReasonNoIRHome rather than a boundary, because each of these is a +// promotion candidate: a later reader that finds a use for one lowers it where it +// stands. +func retainUnreferencedComponents(c lowering.Ctx) (ir.Unmodeled, []ir.Diagnostic) { + entries := componentreach.Unreferenced(c.Doc.GetRootNode(), retainedComponentSections(c)) + if len(entries) == 0 { + return nil, nil + } + var out ir.Unmodeled + var diags []ir.Diagnostic + for _, entry := range entries { + kind, name, ok := ids.ComponentEntry(entry.Pointer) + if !ok { + continue + } + _, keptDiags := annotation.PreserveNodeInto(&out, + "openapi:components/"+ids.Scope(kind, name), entry.Node, ir.ReasonNoIRHome, + c.ProvenanceAt(entry.Pointer)) + diags = append(diags, keptDiags...) + } + return out, diags +} + // lowerMeta lowers the document-level metadata that is not part of the type or // service graph: info, servers, and the extensions of every object around them // that lowers to no node of its own (ir-design §10, §12). @@ -47,6 +101,10 @@ func lowerMeta(c lowering.Ctx) (docMeta, []ir.Diagnostic) { ext, diags := documentExtensions(c) m.Unmodeled = ext + retained, retainDiags := retainUnreferencedComponents(c) + m.Unmodeled = annotation.MergeUnmodeled(m.Unmodeled, retained) + diags = append(diags, retainDiags...) + servers, serverDiags := lowerServers(c) m.Servers = servers diags = append(diags, serverDiags...) diff --git a/compilers/openapi/meta_test.go b/compilers/openapi/meta_test.go index 9602c29f..0c7faff0 100644 --- a/compilers/openapi/meta_test.go +++ b/compilers/openapi/meta_test.go @@ -199,3 +199,27 @@ func TestLowerServers_EveryEntrySkippedIsNil(t *testing.T) { assert.Nil(t, got) assert.Empty(t, diags) } + +// TestRetainUnreferencedComponents_EntryWithNoNameIsNotKept covers the guard on +// the entry pointer: a components entry whose key is the empty string has no +// name to key a verbatim entry under, so it is passed over rather than kept at +// an "openapi:components/
/" key that names no entry of that document +// (GitHub #616). +func TestRetainUnreferencedComponents_EntryWithNoNameIsNotKept(t *testing.T) { + t.Parallel() + l, loadDiags := loweredFor(t, `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: {} +components: + responses: + "": {description: nameless} + Orphan: {description: nothing references this} +`) + require.Empty(t, loadDiags) + + kept, diags := retainUnreferencedComponents(l.ctx) + + require.Len(t, kept, 1, "only the named entry has a key to be kept under: got %v", kept) + assert.Contains(t, kept, "openapi:components/responses/Orphan") + assert.Empty(t, diags) +} diff --git a/compilers/openapi/unreferenced_internal_test.go b/compilers/openapi/unreferenced_internal_test.go new file mode 100644 index 00000000..f10598fb --- /dev/null +++ b/compilers/openapi/unreferenced_internal_test.go @@ -0,0 +1,108 @@ +package openapi + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir" +) + +// TestUnreferencedComponents_RetentionIsOrderIndependent is the hand-rolled +// two-order diff for GitHub #616. Document.Unmodeled is neither the type registry +// nor a diagnostic, so the corpus's order-invariance oracle does not see it: the +// same entries declared before and after the paths that reference one of them +// must be kept in the same set, located at the same pointers. +func TestUnreferencedComponents_RetentionIsOrderIndependent(t *testing.T) { + t.Parallel() + const paths = `paths: + /a: + get: + operationId: getA + responses: + "200": {$ref: '#/components/responses/Used'} +` + const components = `components: + responses: + Used: {description: reached} + Unused: {description: not reached} +` + first, _ := parseFull(t, "openapi: 3.2.0\ninfo: {title: T, version: \"1\"}\n"+components+paths) + second, _ := parseFull(t, "openapi: 3.2.0\ninfo: {title: T, version: \"1\"}\n"+paths+components) + assert.Empty(t, cmp.Diff(first.Unmodeled, second.Unmodeled), + "what is kept must not depend on which of the two the document writes first") + require.Contains(t, first.Unmodeled, "openapi:components/responses/Unused") +} + +// TestUnreferencedComponents_ChainKeepsBothEnds pins the transitive rule's other +// half at the IR: an unreferenced entry's own `$ref` is not a root, so the entry +// it names is kept too rather than being lowered on behalf of a component that +// vanishes. +func TestUnreferencedComponents_ChainKeepsBothEnds(t *testing.T) { + t.Parallel() + doc, _ := parseFull(t, `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: {} +components: + responses: + R: + description: nothing reaches this + headers: {X: {$ref: '#/components/parameters/P', schema: {type: string}}} + parameters: + P: {name: p, in: query, schema: {type: string}} +`) + require.Contains(t, doc.Unmodeled, "openapi:components/responses/R") + require.Contains(t, doc.Unmodeled, "openapi:components/parameters/P", + "the target of an unreachable entry's own reference is kept too") + assert.Equal(t, ir.ReasonNoIRHome, doc.Unmodeled["openapi:components/parameters/P"].Reason) +} + +// TestUnreferencedComponents_ReachedEntryExpandsItsOwnReferences is the same +// shape with a root: the paths name the response, so its own reference reaches +// the parameter and neither is kept. +func TestUnreferencedComponents_ReachedEntryExpandsItsOwnReferences(t *testing.T) { + t.Parallel() + doc, _ := parseFull(t, `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: + /a: + get: + operationId: getA + responses: + "200": {$ref: '#/components/responses/R'} +components: + responses: + R: + description: reached + headers: {X: {$ref: '#/components/parameters/P', schema: {type: string}}} + parameters: + P: {name: p, in: query, schema: {type: string}} +`) + assert.NotContains(t, doc.Unmodeled, "openapi:components/responses/R") + assert.NotContains(t, doc.Unmodeled, "openapi:components/parameters/P", + "a reached entry's own reference is a root in turn") +} + +// TestUnreferencedComponents_MediaTypesIsPerEntryOnlyFrom32 pins the one version +// gate in the retained set: components/mediaTypes is a section the dialect +// defines from 3.2, so below it the key is undefined and the components census +// keeps the whole map under its own key instead of one entry per name. +func TestUnreferencedComponents_MediaTypesIsPerEntryOnlyFrom32(t *testing.T) { + t.Parallel() + const body = `paths: {} +components: + mediaTypes: + Event: {schema: {type: string}} +` + doc32, _ := parseFull(t, "openapi: 3.2.0\ninfo: {title: T, version: \"1\"}\n"+body) + require.Contains(t, doc32.Unmodeled, "openapi:components/mediaTypes/Event") + assert.NotContains(t, doc32.Unmodeled, "openapi:components/mediaTypes", + "3.2 handles the section per entry, so the whole map is not also kept") + + doc31, _ := parseFull(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n"+body) + assert.Contains(t, doc31.Unmodeled, "openapi:components/mediaTypes", + "below 3.2 the section is an undefined key, kept whole and reported") + assert.NotContains(t, doc31.Unmodeled, "openapi:components/mediaTypes/Event") +} diff --git a/docs/ir-design.md b/docs/ir-design.md index 5220c8f2..6654804e 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1954,6 +1954,28 @@ rather than two: the census answers only for what nothing read. A reading with a into `Docs.Summary`, `nodeType` into `XMLHints.NodeType` — is not also kept verbatim, because one declaration gets one structural home (§12.1). +**A component entry nothing references.** A compiler lowers a component where a reference +finds it, so an entry no reference reaches lowers nowhere — no node, no `Unmodeled` entry, no +diagnostic — and a declaration the document makes disappears in silence. Every component section +with no registry is therefore kept whole: each entry is preserved verbatim at +`Document.Unmodeled["openapi:components/
/"]` under `ReasonNoIRHome`, at the entry's +own pointer, with no diagnostic. The sections are `responses`, `parameters`, `examples`, +`requestBodies`, `headers`, `links`, `callbacks`, `pathItems` and 3.2's `mediaTypes`; +`components/schemas` and `components/securitySchemes` are lowered unconditionally and are not in the +set. + +*Referenced* is precise, not syntactic: an entry is referenced when its pointer is reached by the +transitive closure of the document's `$ref` strings, rooted at every position outside those sections +— the paths, webhooks, the root, `components/schemas` and `components/securitySchemes`. A `$ref` +written inside an unreferenced entry is not a root, so the component *it* names is kept too: keeping +one entry and dropping what it points at would restore, one hop later, the loss this rule exists to +remove. A syntactic "some `$ref` names it" reading is cheaper and wrong for exactly that reason. + +`ReasonNoIRHome` rather than a boundary, and no diagnostic, are the links precedent generalized: the +gap is one the IR can close by finding a use for the entry, so a promotion pass should read it, and a +document that declares a component is not making a mistake a warning could name. `Reason` is where +that decision lives (§12), which is what keeps a promotion pass from mistaking one for an oversight. + ### 12.1 One structural home per declaration Documentation, deprecation, XML hints, examples, vendor extensions, validation-only keywords and @@ -2082,7 +2104,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, a null-only branch set (every branch a bare `type: null`) → the same nullable `any` a bare `{type: null}` schema at that position lowers to, with the oneOf/anyOf itself kept verbatim Unmodeled (`degraded_lowering`) since no node built for it carries a branch set, 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); the same co-declared `oneOf`/`anyOf` beside a `$ref` at the same level, rather than a structural body, has nothing at that position to distribute the union across either, so it lowers to the alias over the `$ref` target with the union kept verbatim beside it (`degraded_lowering`), through the same keeper as the structural-body case; an `allOf` beside a `$ref` at the same level joins the alias's unhomed-keyword census the same way (`degraded_lowering`), since a `$ref` site elects no composition family for it to lose or win; 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), and a subtype the mapping names under several keys — an alias tag — takes the smallest in byte order as its DiscriminatorValue, since the field holds one and a mapping's key order is not a property of the document, with every key kept in the base's Mapping and the election reported (`degraded-construct`, info); `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; a declared `style` there is refused by the parser, while `explode` or `allowReserved` (which it does not check; GitHub #408) lowers as declared + `invalid-location-keyword` warning at the keyword's own pointer; requestBody/responses all content types → Payload.Contents, `requestBody.required` → Payload.Required, always set since OpenAPI's own default makes an undeclared `required` mean false rather than unstated; 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; an Encoding Object's `allowReserved` → Content.Unmodeled (`no_ir_home`) under `openapi:encoding//allowReserved` or `openapi:itemEncoding/allowReserved`, ir.PartEncoding holding `style` and `explode` beside it but no field for this one, and read before the entry's emptiness is judged so an entry declaring nothing else is not dropped with the empty PartEncoding it lowers to; per-status responses/default → Conditions + ranges, with the responses-map key as declared and then neutralized → Response.Name.Hint and ErrorCase.Name.Hint alike (`404`, `5_xx`, `default`), which records the spelling a range cannot state though only `default` survives neutralization unchanged; two keys resolving to one range — `4XX` beside `4xx` — are both kept and reported `openapi/duplicate-status-key`, since they reach the IR with one name and one condition; an error response lowers exactly as a success one — its `headers` → ErrorCase.Headers and every media type of its `content` → ErrorCase.Payload.Contents, neither degraded and neither kept under Unmodeled, and the payload's naming hint derived from the declaration pointer on both sides so that one `components/responses` entry mounted at a success and an error status interns one type whichever side reaches it 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 and ErrorCase.Unmodeled alike (`no_ir_home`, promotable later): the two are lowerings of one Response Object, so a construct kept on only the success one makes a declaration survive or vanish on nothing but its status code; 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; a path item's own `summary`/`description`, at the same three mounts → Operation.Unmodeled under `openapi:pathItemSummary`/`openapi:pathItemDescription` (`no_ir_home`) rather than merged into Docs: ir.Docs holds the operation's own pair and a path item's documents the path, so merging would need a precedence rule and would attach documentation the operation's author never wrote — an inference, which §6 places in policy rather than in a lowering; every operation a path item declares — the fixed method fields, 3.2 `query`, and 3.2 `additionalOperations` keyed by method — → an Operation apiece, mounted at its own pointer, with the `additionalOperations` key used verbatim as HTTPBinding.Method since OpenAPI reads a method name case-sensitively, and a key naming no method at all lowered as declared + `invalid-method-key` warning (the binding is unusable, but dropping the entry would lose every operation it declares); 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`/`contentSchema` → `Encoding` on the scalar the position lowers to — the last as `Encoding.Schema`, a TypeRef to the decoded shape hoisted at its own pointer — and all three → Unmodeled (`no_ir_home`) at a position with no `Encoding` field, 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 property redeclared across branches with a type, constraint keyword, default, description, examples, deprecation or `xml` the merge drops → the losing declaration's node verbatim Unmodeled (`degraded_lowering`) under `openapi:conflicting-redeclaration` per §4.8, keyed by the losing declaration's pointer, with the `openapi/conflicting-redeclaration` diagnostic only where the two are unsatisfiable, an info `degraded-construct` naming a detail held differently, and nullability intersecting rather than dropping where the targets agree; 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), read at every object that admits one: an object lowering to a node with a map of its own keeps them unscoped there, and one lowering to no node of its own is keyed by the path from its carrier down to it — on the document, `openapi:info/x-*`, `openapi:info/contact/x-*`, `openapi:info/license/x-*`, `openapi:externalDocs/x-*`, `openapi:components/x-*`, `openapi:tags//x-*`, `openapi:tags//externalDocs/x-*`; on the service, `openapi:paths/x-*`; on each operation the path item's `openapi:pathItem/x-*` plus `openapi:responses/x-*` and `openapi:externalDocs/x-*`; on the HTTP binding, `openapi:callbacks//x-*`; on the content, `openapi:encoding//x-*` and `openapi:itemEncoding/x-*`, `` and `` alike escaped RFC 6901 style so a document-chosen name stays one segment (§12); on the schema's type, `openapi:xml/x-*`, `openapi:discriminator/x-*`, `openapi:externalDocs/x-*`; on the scheme, `openapi:flows/x-*` — since several such objects reach one map and an unscoped key would leave the survivor to lowering order (§12); a Link Object's own ride inside the verbatim `links` entry rather than taking a key beside it; `$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 — constraints excepted, since bounds conjoin rather than override: each position keeps the ones it declared and none is copied to a use site (§12.2); 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, a null-only branch set (every branch a bare `type: null`) → the same nullable `any` a bare `{type: null}` schema at that position lowers to, with the oneOf/anyOf itself kept verbatim Unmodeled (`degraded_lowering`) since no node built for it carries a branch set, 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); the same co-declared `oneOf`/`anyOf` beside a `$ref` at the same level, rather than a structural body, has nothing at that position to distribute the union across either, so it lowers to the alias over the `$ref` target with the union kept verbatim beside it (`degraded_lowering`), through the same keeper as the structural-body case; an `allOf` beside a `$ref` at the same level joins the alias's unhomed-keyword census the same way (`degraded_lowering`), since a `$ref` site elects no composition family for it to lose or win; 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), and a subtype the mapping names under several keys — an alias tag — takes the smallest in byte order as its DiscriminatorValue, since the field holds one and a mapping's key order is not a property of the document, with every key kept in the base's Mapping and the election reported (`degraded-construct`, info); `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; a declared `style` there is refused by the parser, while `explode` or `allowReserved` (which it does not check; GitHub #408) lowers as declared + `invalid-location-keyword` warning at the keyword's own pointer; requestBody/responses all content types → Payload.Contents, `requestBody.required` → Payload.Required, always set since OpenAPI's own default makes an undeclared `required` mean false rather than unstated; 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; an Encoding Object's `allowReserved` → Content.Unmodeled (`no_ir_home`) under `openapi:encoding//allowReserved` or `openapi:itemEncoding/allowReserved`, ir.PartEncoding holding `style` and `explode` beside it but no field for this one, and read before the entry's emptiness is judged so an entry declaring nothing else is not dropped with the empty PartEncoding it lowers to; per-status responses/default → Conditions + ranges, with the responses-map key as declared and then neutralized → Response.Name.Hint and ErrorCase.Name.Hint alike (`404`, `5_xx`, `default`), which records the spelling a range cannot state though only `default` survives neutralization unchanged; two keys resolving to one range — `4XX` beside `4xx` — are both kept and reported `openapi/duplicate-status-key`, since they reach the IR with one name and one condition; an error response lowers exactly as a success one — its `headers` → ErrorCase.Headers and every media type of its `content` → ErrorCase.Payload.Contents, neither degraded and neither kept under Unmodeled, and the payload's naming hint derived from the declaration pointer on both sides so that one `components/responses` entry mounted at a success and an error status interns one type whichever side reaches it 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 and ErrorCase.Unmodeled alike (`no_ir_home`, promotable later): the two are lowerings of one Response Object, so a construct kept on only the success one makes a declaration survive or vanish on nothing but its status code; 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; a path item's own `summary`/`description`, at the same three mounts → Operation.Unmodeled under `openapi:pathItemSummary`/`openapi:pathItemDescription` (`no_ir_home`) rather than merged into Docs: ir.Docs holds the operation's own pair and a path item's documents the path, so merging would need a precedence rule and would attach documentation the operation's author never wrote — an inference, which §6 places in policy rather than in a lowering; every operation a path item declares — the fixed method fields, 3.2 `query`, and 3.2 `additionalOperations` keyed by method — → an Operation apiece, mounted at its own pointer, with the `additionalOperations` key used verbatim as HTTPBinding.Method since OpenAPI reads a method name case-sensitively, and a key naming no method at all lowered as declared + `invalid-method-key` warning (the binding is unusable, but dropping the entry would lose every operation it declares); 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`/`contentSchema` → `Encoding` on the scalar the position lowers to — the last as `Encoding.Schema`, a TypeRef to the decoded shape hoisted at its own pointer — and all three → Unmodeled (`no_ir_home`) at a position with no `Encoding` field, 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 property redeclared across branches with a type, constraint keyword, default, description, examples, deprecation or `xml` the merge drops → the losing declaration's node verbatim Unmodeled (`degraded_lowering`) under `openapi:conflicting-redeclaration` per §4.8, keyed by the losing declaration's pointer, with the `openapi/conflicting-redeclaration` diagnostic only where the two are unsatisfiable, an info `degraded-construct` naming a detail held differently, and nullability intersecting rather than dropping where the targets agree; 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), read at every object that admits one: an object lowering to a node with a map of its own keeps them unscoped there, and one lowering to no node of its own is keyed by the path from its carrier down to it — on the document, `openapi:info/x-*`, `openapi:info/contact/x-*`, `openapi:info/license/x-*`, `openapi:externalDocs/x-*`, `openapi:components/x-*`, `openapi:tags//x-*`, `openapi:tags//externalDocs/x-*`; on the service, `openapi:paths/x-*`; on each operation the path item's `openapi:pathItem/x-*` plus `openapi:responses/x-*` and `openapi:externalDocs/x-*`; on the HTTP binding, `openapi:callbacks//x-*`; on the content, `openapi:encoding//x-*` and `openapi:itemEncoding/x-*`, `` and `` alike escaped RFC 6901 style so a document-chosen name stays one segment (§12); on the schema's type, `openapi:xml/x-*`, `openapi:discriminator/x-*`, `openapi:externalDocs/x-*`; on the scheme, `openapi:flows/x-*` — since several such objects reach one map and an unscoped key would leave the survivor to lowering order (§12); a Link Object's own ride inside the verbatim `links` entry rather than taking a key beside it; `$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 — constraints excepted, since bounds conjoin rather than override: each position keeps the ones it declared and none is copied to a use site (§12.2); 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; a component entry no `$ref` reachable from outside the components sections names is kept verbatim on the document under `openapi:components/
/` (`no_ir_home`, no diagnostic) rather than dropped, for every section with no registry — responses, parameters, examples, requestBodies, headers, links, callbacks, pathItems, and 3.2's mediaTypes — where the entry is reached only when the transitive closure of `$ref` strings rooted outside those sections names it | | **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/internal/archtest/arch_test.go b/internal/archtest/arch_test.go index 78550110..69af4c9d 100644 --- a/internal/archtest/arch_test.go +++ b/internal/archtest/arch_test.go @@ -58,6 +58,13 @@ var rules = map[string][]string{ // spelling, so a package that could reach the compiler would be able to let a // surrounding schema type change what a literal means. "compilers/openapi/internal/value": {module + "/ir", "gopkg.in/yaml.v3"}, + // What a document's `$ref` strings reach, over the raw tree. It reads yaml + // nodes and nothing else — no IR, no diagnostic, no model — because the + // question is about the source alone: which component entries a reference + // written outside them names, transitively. A package that could reach the + // lowering could decide reachability by what the lowering happened to + // resolve, which is the answer this exists not to depend on. + "compilers/openapi/internal/componentreach": {"gopkg.in/yaml.v3"}, // The yaml.v3 node vocabulary: the tag a resolved `<<` merge key carries and // the constructors for the node kinds a parse produces. It reaches yaml and // nothing else, which is what lets the view below import it rather than the diff --git a/testdata/conformance/openapi/unreferenced-components.golden.json b/testdata/conformance/openapi/unreferenced-components.golden.json new file mode 100644 index 00000000..6e4da014 --- /dev/null +++ b/testdata/conformance/openapi/unreferenced-components.golden.json @@ -0,0 +1,209 @@ +{ + "irVersion": "0.6.0", + "name": "UnreferencedComponents", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "UnreferencedComponents", + "canonical": "unreferenced_components" + }, + "docs": {}, + "groups": [ + { + "name": { + "hint": "default" + }, + "docs": {}, + "operations": [ + { + "id": "op/openapi/paths/~1a/get", + "name": { + "source": "getA", + "canonical": "get_a" + }, + "docs": {}, + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "docs": { + "description": "reached from the paths" + } + } + ], + "oneWay": false, + "idempotency": {}, + "bindings": { + "http": [ + { + "method": "GET", + "uriTemplate": "/a", + "sharedRoute": false, + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1a/get" + } + } + ] + } + ], + "provenance": { + "source": 0 + } + } + ], + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {} + } + ], + "unmodeled": { + "openapi:components/callbacks/Unused": { + "reason": "no_ir_home", + "value": { + "{$request.body#/url}": { + "post": { + "responses": { + "200": { + "description": "ok" + } + } + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/callbacks/Unused" + } + }, + "openapi:components/examples/Unused": { + "reason": "no_ir_home", + "value": { + "summary": "Unused", + "value": 1 + }, + "provenance": { + "source": 0, + "pointer": "/components/examples/Unused" + } + }, + "openapi:components/headers/Unused": { + "reason": "no_ir_home", + "value": { + "schema": { + "type": "string" + } + }, + "provenance": { + "source": 0, + "pointer": "/components/headers/Unused" + } + }, + "openapi:components/links/Unused": { + "reason": "no_ir_home", + "value": { + "operationId": "getA" + }, + "provenance": { + "source": 0, + "pointer": "/components/links/Unused" + } + }, + "openapi:components/mediaTypes/Unused": { + "reason": "no_ir_home", + "value": { + "schema": { + "type": "string" + } + }, + "provenance": { + "source": 0, + "pointer": "/components/mediaTypes/Unused" + } + }, + "openapi:components/parameters/Unused": { + "reason": "no_ir_home", + "value": { + "in": "query", + "name": "unused", + "schema": { + "type": "string" + } + }, + "provenance": { + "source": 0, + "pointer": "/components/parameters/Unused" + } + }, + "openapi:components/pathItems/Unused": { + "reason": "no_ir_home", + "value": { + "get": { + "responses": { + "200": { + "description": "ok" + } + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/pathItems/Unused" + } + }, + "openapi:components/requestBodies/Unused": { + "reason": "no_ir_home", + "value": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/requestBodies/Unused" + } + }, + "openapi:components/responses/Unused": { + "reason": "no_ir_home", + "value": { + "description": "nothing names this one" + }, + "provenance": { + "source": 0, + "pointer": "/components/responses/Unused" + } + } + }, + "sources": [ + { + "format": "openapi@3.2", + "path": "unreferenced-components.yaml", + "hash": "1535bb473a125d15a6ef62549b20deae6aa2ad857a3816ff4e02a5d0d32b9ca3" + } + ] +} diff --git a/testdata/conformance/openapi/unreferenced-components.yaml b/testdata/conformance/openapi/unreferenced-components.yaml new file mode 100644 index 00000000..80a7e552 --- /dev/null +++ b/testdata/conformance/openapi/unreferenced-components.yaml @@ -0,0 +1,33 @@ +openapi: 3.2.0 +info: {title: UnreferencedComponents, version: "1.0.0"} +paths: + /a: + get: + operationId: getA + responses: + # One reference, to one component: that entry lowers as it always did, + # and every entry beside it that nothing names is kept verbatim. + "200": {$ref: '#/components/responses/Used'} +components: + responses: + Used: {description: reached from the paths} + Unused: {description: nothing names this one} + parameters: + Unused: {name: unused, in: query, schema: {type: string}} + examples: + Unused: {summary: Unused, value: 1} + requestBodies: + Unused: {content: {application/json: {schema: {type: string}}}} + headers: + Unused: {schema: {type: string}} + links: + Unused: {operationId: getA} + callbacks: + Unused: + '{$request.body#/url}': + post: {responses: {"200": {description: ok}}} + pathItems: + Unused: + get: {responses: {"200": {description: ok}}} + mediaTypes: + Unused: {schema: {type: string}} \ No newline at end of file From 03deae28939a18f3600cd179aa07001593c7bee8 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Wed, 30 Sep 2026 21:43:01 +0300 Subject: [PATCH 9/9] docs(compilers/openapi): state the nameless-component case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retainUnreferencedComponents passes over a component entry keyed "" and the only place that said why was a test comment. The guard now carries the why in the code: the name is empty, OpenAPI's component-name rule makes such a key invalid, so ids.ComponentEntry refuses it, and an Unmodeled key with an empty name segment names no entry of that document. ir-design §12's paragraph claimed every entry of those sections is kept, which is false for exactly that one; the paragraph now names the exception in its own voice. The test comment defers the why to the code (part of #616). --- compilers/openapi/meta.go | 5 +++++ compilers/openapi/meta_test.go | 8 ++++---- docs/ir-design.md | 4 +++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/compilers/openapi/meta.go b/compilers/openapi/meta.go index 26a3e464..fe77fa52 100644 --- a/compilers/openapi/meta.go +++ b/compilers/openapi/meta.go @@ -82,6 +82,11 @@ func retainUnreferencedComponents(c lowering.Ctx) (ir.Unmodeled, []ir.Diagnostic for _, entry := range entries { kind, name, ok := ids.ComponentEntry(entry.Pointer) if !ok { + // The name is empty. OpenAPI's component-name rule + // (^[a-zA-Z0-9._-]+$) makes such a key invalid, so + // ids.ComponentEntry refuses it, and an Unmodeled key with an + // empty name segment names no entry of that document. This is + // the one unreferenced entry the rule above does not retain. continue } _, keptDiags := annotation.PreserveNodeInto(&out, diff --git a/compilers/openapi/meta_test.go b/compilers/openapi/meta_test.go index 0c7faff0..32a585ac 100644 --- a/compilers/openapi/meta_test.go +++ b/compilers/openapi/meta_test.go @@ -201,10 +201,10 @@ func TestLowerServers_EveryEntrySkippedIsNil(t *testing.T) { } // TestRetainUnreferencedComponents_EntryWithNoNameIsNotKept covers the guard on -// the entry pointer: a components entry whose key is the empty string has no -// name to key a verbatim entry under, so it is passed over rather than kept at -// an "openapi:components/
/" key that names no entry of that document -// (GitHub #616). +// the entry pointer: a components entry whose key is the empty string is passed +// over rather than kept at an "openapi:components/
/" key that names no +// entry of that document (GitHub #616). retainUnreferencedComponents carries the +// why. func TestRetainUnreferencedComponents_EntryWithNoNameIsNotKept(t *testing.T) { t.Parallel() l, loadDiags := loweredFor(t, `openapi: 3.1.0 diff --git a/docs/ir-design.md b/docs/ir-design.md index 6654804e..dba9d8a5 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1959,7 +1959,9 @@ finds it, so an entry no reference reaches lowers nowhere — no node, no `Unmod diagnostic — and a declaration the document makes disappears in silence. Every component section with no registry is therefore kept whole: each entry is preserved verbatim at `Document.Unmodeled["openapi:components/
/"]` under `ReasonNoIRHome`, at the entry's -own pointer, with no diagnostic. The sections are `responses`, `parameters`, `examples`, +own pointer, with no diagnostic — the one exception being an entry whose key is the empty string +(invalid under OpenAPI's component-name rule), which has no name to key under and is passed over. +The sections are `responses`, `parameters`, `examples`, `requestBodies`, `headers`, `links`, `callbacks`, `pathItems` and 3.2's `mediaTypes`; `components/schemas` and `components/securitySchemes` are lowered unconditionally and are not in the set.