Skip to content

fix(compilers/openapi): elect content over a co-declared schema - #374

Open
OmarAlJarrah wants to merge 1 commit into
mainfrom
fix/openapi-param-schema-content-election
Open

fix(compilers/openapi): elect content over a co-declared schema#374
OmarAlJarrah wants to merge 1 commit into
mainfrom
fix/openapi-param-schema-content-election

Conversation

@OmarAlJarrah

Copy link
Copy Markdown
Member

Summary

OpenAPI says a parameter — and a header, which follows the parameter rules — contains either a
schema property or a content property, never both. A document writing both was accepted in
silence: one keyword lowered, the other was dropped, with no diagnostic in either channel. The two
positions also disagreed about which one survived. fillParamType tried content first and
returned, so the co-declared schema was lost; headerSchema tried schema first, so the content
map was. The same source shape lowered differently depending only on where the object sat.

Both positions now share one election, and the keyword it passes over is kept verbatim under
Unmodeled (degraded_lowering, at its own pointer) with a warning naming both spellings — the
shape schema.dispatchOf/recordSkippedFamilies and singleContentEntry already use for the
other cases of "the source declared more than this position can lower".

Why content wins. A media-type entry carries a schema and the media type serializing it,
and the IR models both at these positions — HTTPParamBinding.ContentType and
Property.Encoding.MediaType. Electing content therefore leaves nothing modelled behind, where
electing schema would demote a declared wire fact the IR does hold into an opaque Unmodeled
payload; either way the loser survives verbatim, so the only question is which one keeps more of
the document in modelled form.

The specification does not settle it, in either direction: 3.1 writes "A parameter MUST contain
either a schema property, or a content property, but not both" and 3.2 writes "Parameter
Objects MUST include either a content field or a schema field, but not both". The order flips
between versions, and a prohibition states no precedence in either spelling.

Of the two behaviours already here, only one was a decision. fillParamType read content first
from the day it was written; the header path read schema first because it read nothing else
until a content arm was appended below it in #139 — its order records how the two arms were added,
not a choice between them.

An empty or unusable content map runs the election the other way: nothing is elected from it, the
schema lowers, and the content node is kept and named on the same terms. That direction is only
reachable for documents the library's own validator already rejects, but it keeps the rule
symmetric — and a content the model layer failed to parse still has a raw node worth keeping.

Test plan

gofmt, go vet ./..., golangci-lint run (0 issues), go build ./..., and
./scripts/check-coverage.sh (100% of 4945 statements) all pass.

The issue's reproducer, before and after

Compiled with morphic compile repro.yaml -skip-validate.

Before (dbf0054) — .diagnostics is null, and the two positions disagree:

{
  "diagnostics": null,
  "params[0].type.target": "t/prim/string",
  "params[0].unmodeled": null,
  "responses[0].headers[0].type.target": "t/prim/integer",
  "responses[0].headers[0].unmodeled": null
}

After — one order at both positions, the loser kept, both reported:

{
  "diagnostics": [
    {"severity": "warning", "code": "openapi/degraded-construct",
     "message": "a parameter or header declares either schema or content, not both; this one declares both, so it lowered as its content, with schema kept verbatim under Unmodeled",
     "provenance": {"source": 0, "pointer": "/paths/~1x/get/parameters/0/schema"}},
    {"severity": "warning", "code": "openapi/degraded-construct",
     "message": "a parameter or header declares either schema or content, not both; this one declares both, so it lowered as its content, with schema kept verbatim under Unmodeled",
     "provenance": {"source": 0, "pointer": "/paths/~1x/get/responses/200/headers/X-H/schema"}}
  ],
  "params[0]": {
    "type": {"target": "t/prim/string"},
    "unmodeled": {"openapi:schema": {"reason": "degraded_lowering", "value": {"type": "integer"},
      "provenance": {"source": 0, "pointer": "/paths/~1x/get/parameters/0/schema"}}}
  },
  "bindings.http[0].paramBindings[0].contentType": "application/json",
  "responses[0].headers[0]": {
    "type": {"target": "t/prim/string"},
    "encoding": {"mediaType": "application/json"},
    "unmodeled": {"openapi:schema": {"reason": "degraded_lowering", "value": {"type": "integer"},
      "provenance": {"source": 0, "pointer": "/paths/~1x/get/responses/200/headers/X-H/schema"}}}
  }
}

Coverage added

  • testdata/conformance/openapi/codeclared-schema-content.yaml puts the shape in the corpus, so
    the two-order oracle, the JSON round-trip, determinism and irverify all run over it. It
    declares the co-declaration twice in opposite keyword orders (p writes schema first, q
    writes content first) and the golden shows the two lowering identically. Confirmed the sweep
    actually reaches the new file rather than merely running: a deliberate dangling $ref added to
    it reddens TestHarness_InRepoCorpus, and removing it goes green again.
  • TestElectTypeSpelling_CoDeclaredSpellingsElectContent asserts the elected type, the modelled
    media type, the preserved payload and its reason and pointer, and the message text, at both
    positions.
  • TestElectTypeSpelling_SoleSpellingReportsNothing is the control: a position writing one
    spelling keeps nothing and says nothing, so the change cannot fire on well-formed documents.
  • TestElectTypeSpelling_UnusableContentElectsSchemaAndKeepsIt covers the other direction.

Both plants redden

The fix is two call sites, so the old behaviour was restored at each in turn and the tests run.

Restoring fillParamType's content-first early return:

--- FAIL: TestElectTypeSpelling_CoDeclaredSpellingsElectContent/operation_parameter
        Error:      	Should be true
        Messages:   	the passed-over schema is kept verbatim; got map[]
--- FAIL: TestConformance/codeclared-schema-content
        Error:      	Should be true
        Messages:   	openapi:schema kept verbatim; got map[]

Restoring the header path's schema-first early return:

--- FAIL: TestElectTypeSpelling_CoDeclaredSpellingsElectContent/response_header
        Error:      	Expected value not to be nil.
        Messages:   	a content-style header records its media type
--- FAIL: TestConformance/codeclared-schema-content
        Error:      	Not equal:
        	            	expected: "t/prim/string"
        	            	actual  : "t/prim/integer"
        Messages:   	the header elects content too: one order, not one per position

Both were restored from a copy afterwards and the tree is green.

Closes #320

OpenAPI says a parameter — and a header, which follows the parameter rules —
contains either a `schema` property or a `content` property, never both. A
document writing both was accepted in silence: one keyword lowered, the other
was dropped with no diagnostic in either channel. The two positions also
disagreed about which survived. fillParamType tried `content` first and
returned, so the schema was lost; the header path tried `schema` first, so the
content map was. The same source shape lowered differently depending only on
where the object sat.

Both positions now share one election. `content` wins: a media-type entry
carries a schema *and* the media type serializing it, and the IR models both
here — HTTPParamBinding.ContentType and Property.Encoding.MediaType — so
electing it leaves nothing modelled behind, where electing `schema` would push a
declared wire fact into an opaque payload. The specification does not settle it:
3.1 names `schema` first in the sentence forbidding both and 3.2 names `content`
first, and a prohibition states no precedence either way. Of the two behaviours
that were here, only the parameter's was a decision — the header read `schema`
first because it read nothing else until a content arm was appended below it.

The passed-over keyword is kept verbatim under Unmodeled with
ReasonDegradedLowering at its own pointer, and a warning names both spellings —
the shape recordSkippedFamilies and singleContentEntry already use for the other
cases of "the source declared more than this position can lower".
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

openapi: a parameter or header declaring both schema and content silently drops one

1 participant