Skip to content

openapi: referenced non-schema components hoist once per use site under fabricated pointers #107

Description

@OmarAlJarrah

Summary

Component schemas (/components/schemas/<name>) are interned under a name-derived ID and resolve to one node however many times they are referenced. Every other component type — parameters, requestBodies, responses, headers, examples, callbacks, pathItems — is lowered at its use-site pointer instead. resolveRef swaps the referenced object's content in, but the pointer handed to the lowering call still describes the $ref site, which has no children of its own. The result is the same three failures #36 fixed for path-item-level parameters, but reached through references rather than through the path-item merge:

  • Provenance points at nothing. A $ref site is a one-key object; <ref-site>/schema, <ref-site>/content/..., and <ref-site>/headers/... do not exist in the source document.
  • IDs are not derived from the declaration's pointer (invariant 3). They are derived from where the reference happens to be written, so the same declaration yields a different ID per use site.
  • One declaration hoists once per use site instead of interning once.

Reproduction

Two operations referencing the same four components:

paths:
  /a:
    get:
      operationId: getA
      parameters: [{$ref: '#/components/parameters/Page'}]
      requestBody: {$ref: '#/components/requestBodies/Body'}
      responses: {"200": {$ref: '#/components/responses/OK'}}
  /b:
    get:
      operationId: getB
      parameters: [{$ref: '#/components/parameters/Page'}]
      requestBody: {$ref: '#/components/requestBodies/Body'}
      responses: {"200": {$ref: '#/components/responses/OK'}}
components:
  parameters:
    Page: {name: page, in: query, schema: {type: string, enum: [p, q]}}
  requestBodies:
    Body:
      content: {application/json: {schema: {type: object, properties: {n: {type: string}}}}}
  responses:
    OK:
      description: ok
      content: {application/json: {schema: {type: object, properties: {m: {type: string}}}}}
      headers: {X-Rate: {$ref: '#/components/headers/Rate'}}
  headers:
    Rate: {schema: {type: string, enum: [x, y]}}

Four declarations, each written exactly once, produce eight hoisted types:

t/anon/paths/~1a/get/parameters/0/schema
t/anon/paths/~1a/get/requestBody/content/application~1json/schema
t/anon/paths/~1a/get/responses/200/content/application~1json/schema
t/anon/paths/~1a/get/responses/200/headers/X-Rate/schema
t/anon/paths/~1b/get/parameters/0/schema
t/anon/paths/~1b/get/requestBody/content/application~1json/schema
t/anon/paths/~1b/get/responses/200/content/application~1json/schema
t/anon/paths/~1b/get/responses/200/headers/X-Rate/schema

None of those eight pointers exists in the document. /paths/~1a/get/parameters/0 holds {$ref: ...} and has no schema child; the real location is /components/parameters/Page/schema. The header case compounds twice over — /paths/~1a/get/responses/200/headers/X-Rate/schema is a fabricated path through a referenced response into a referenced header, whose actual declaration is /components/headers/Rate/schema.

Referenced path items behave the same way: /a: {$ref: '#/components/pathItems/Shared'} and /b: {$ref: '#/components/pathItems/Shared'} hoist the shared path item's parameter schemas twice, under /paths/~1a/... and /paths/~1b/....

Root cause

Every lowering site composes its child pointers from the pointer it was called with, which is always the use site, while resolveRef independently supplies the referenced object's content:

  • params.go fillParamTypeschemaPtr := pptr + ptr("schema")
  • content.go lowerRequestBody:210bodyPtr := opPointer + ptr("requestBody")
  • content.go lowerHeaders:144hptr := basePtr + ptr("headers", name), which also feeds propID(hptr), so header PropIDs are affected as well as TypeIDs
  • operations.go lowerResponses:285rptr := opPointer + ptr("responses", code)
  • operations.go lowerCallbacks:395opPointer + ptr("callbacks", cbName, exprStr)

Nothing in this chain consults whether the object it is lowering arrived inline or through a $ref.

Expected

A referenced component is lowered at the pointer it is declared at, so its ID is stable across use sites and it interns once — the same guarantee /components/schemas/<name> already has. Provenance must reference locations that exist in the source document.

Notes

  • This is deliberately out of scope for openapi: merged path-level parameters get fabricated source pointers — wrong provenance, unstable IDs, duplicated hoists #36, which fixed the path-item-parameter merge only. The use-site convention is consistent across the compiler today, so changing it is a single coherent change rather than a per-site patch.
  • Worth deciding as part of this: whether a referenced non-schema component should get a named ID (as namedTypeID gives component schemas) or stay anonymous with a declaration-site pointer. The Naming on the resulting node differs between the two.
  • Cross-document $refs are already dropped with a diagnostic and are not part of this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions