Skip to content

fix(compilers/openapi): warn on allowReserved outside query - #725

Open
fuad-daoud wants to merge 6 commits into
mainfrom
fix/allowreserved-locations
Open

fuad-daoud wants to merge 6 commits into
mainfrom
fix/allowreserved-locations

Conversation

@fuad-daoud

@fuad-daoud fuad-daoud commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

allowReserved is a query-only keyword in OpenAPI 3.0 and 3.1: both dialects apply it to in: query alone. 3.2 replaced that rule with a different one. It applies the keyword only to in/style values that percent-encode, which leaves out in: header and in: cookie with style: cookie. This PR covers 3.0 and 3.1 only (#628). The compiler already kept every declared value on ir.HTTPParamBinding.AllowReserved and said nothing there, so a 3.0/3.1 document declaring allowReserved at path, header or cookie produced IR indistinguishable from a legal one.

Rule. A new diagnostic code, reported at the keyword's own coordinate:

  • code: openapi/inapplicable-location-keyword
  • severity: warning
  • pointer: <parameters>/<i>/allowReserved
  • message: parameter field allowReserved only applies to in=query before OpenAPI 3.2; lowered as declared
  • fires when: the document's minor version is 3.0 or 3.1, the keyword is present (presence, not truth: a declared allowReserved: false is reported too, matching the querystring check and the allowEmptyValue discipline), and in is path, header or cookie

A parameter reached through an external $ref (allow-external-refs=true) is the exception to "the keyword's own coordinate". It is judged by the root document's version and reported at its use-site pointer (<operation>/parameters/<i>/allowReserved), where the entry is a bare $ref. The in: querystring rule beside it and the exclusive-bound dialect check already behave the same way. The doc comment on allowReservedLocationDiag and the ir-design §14 clause state this.

Where it lives.

  • lowering.Ctx.DialectMinor() returns the document's minor version ("3.0"/"3.1"/"3.2", or "" when unrecognized), and callers compare it. It replaces ExclusiveBoundIsBoolean, whose two callers now compare == "3.0", so a new dialect question no longer adds a method to Ctx (at 18 of its 20-method cap). operation's archtest allowlist excludes load, which is why the version is read through Ctx. A context with no document answers "" without panicking, because GetOpenAPI is nil-safe.
  • allowReservedLocationDiag sits beside querystringKeywordDiags and is called from lowerParameter beside the two existing diagnostic calls.
  • in: querystring is deliberately outside the location set. The parser admits that location in any version, so including it would put two location-keyword warnings (openapi: explode and allowReserved at in: querystring are kept in silence #408's and this one) at one pointer for a 3.1 document and change openapi: explode and allowReserved at in: querystring are kept in silence #408's behaviour.
  • diag.InapplicableLocationKeyword is new and joins the code list in diag_test.go. diag.InvalidLocationKeyword's GoDoc now describes the 3.2 querystring rule alone, and the docs/ir-design.md §14 OpenAPI census clause names both. The before-3.2 rule reports its own code because, unlike the querystring case, it breaks no MUST, so a consumer can weigh it separately.

Not changed. The value still lands in ir.HTTPParamBinding.AllowReserved at every location and version: dropping declared content is an emitter's call, not a compiler's (invariant 2). 3.2 stays silent at every location, including the in: header and style: cookie declarations its percent-encoding rule leaves out (see Deliberately not done). A 3.1/3.0 in: query declaration stays silent, and the 3.2 in: querystring reports from #408 are untouched. Duplicates are already impossible, since compile.Diags.Append dedups by full diagnostic identity, so one $ref'd component parameter mounted at several operations reports once.

Test plan

  • go test ./compilers/openapi/internal/lowering/: TestDialectMinor_FollowsTheDocument (3.0.x→3.0, 3.1.x→3.1, 3.2.0→3.2, a two-part "3.1"→3.1, 4.0.0/""→"") and TestDialectMinor_TheZeroContextNamesNoDialect.
  • go test ./compilers/openapi/internal/operation/: TestParams_AllowReservedLocationRule is one flat table over version × location × declared value × expected code and report. It covers 3.0.3/3.1.0 path, header and cookie (one declaring false), the query, undeclared and 3.2 controls, and a 3.1 in: querystring row that pins exactly one report across both codes, the openapi: explode and allowReserved at in: querystring are kept in silence #408 one. TestParams_AllowReservedOutsideQueryReportsOncePerDeclaration pins one report per declaration at its own pointer. Its spec has a $ref'd component mounted at two operations, a path-item-level parameter shared by two operations, a callback operation's parameter and a webhook's.
  • go test ./compilers/openapi -run TestConformance: new param-allowreserved-locations.yaml fixture (3.1.0: path true, header true, cookie false, query true as the version-independent control) with its golden. It expects exactly three inapplicable-location-keyword warnings, in source order, at the three keyword pointers, and "format": "openapi@3.1". No existing golden moved and unwitnessed.golden.txt is untouched. The fixture's golden was regenerated after main's IR 0.7.0 merge. Only irVersion, idSpaces and the default group's id changed (ce3e81a).
  • assertParamStyles now uses its diags argument to pin the 3.1 query declaration as silent. That is the version-independent half of the rule, on a spec that already declared allowReserved at a query parameter.
  • Mutations, each restored:
    • Adding in: querystring to the case list reddens the querystring row.
    • Narrowing the dialect check to 3.0 reddens the 3.1 rows.
    • Widening it to 3.2 reddens the 3.2 rows.
    • Deleting the call reddens every reporting row and the per-declaration test.
    • Putting the rule back on invalid-location-keyword reddens the table, the per-declaration test and the conformance case.
    • Dropping the header declaration from the fixture reddens the golden run (no -update).
  • go run ./cmd/morphic-harness testdata/conformance/openapi/param-allowreserved-locations.yaml → ok (irverify, round-trip, determinism, order-invariance).
  • make gate green on eed9bd9, including the 100 % statement gate.

Deliberately not done

Closes #628.

OpenAPI 3.0/3.1 apply allowReserved to in: query alone; 3.2 widened it to
every location. A path, header or cookie declaration in a 3.0/3.1 document
lowered as declared and said nothing, so the IR of a spec using the keyword
where the dialect does not was indistinguishable from a legal one. This
adds the second rule under the existing openapi/invalid-location-keyword
code, warning at the keyword's own pointer, presence rather than truth, and
still lowering the value as declared (invariant 2).

- lowering.Ctx.AllowReservedIsQueryOnly reads load.SupportedMinor the way
  ExclusiveBoundIsBoolean beside it does; operation's archtest allowlist
  excludes load, so the dialect question cannot be answered from there.
- allowReservedLocationDiag reports path/header/cookie in 3.0/3.1 and stays
  out of querystring, whose single #408 report is unchanged.
- conformance fixture param-allowreserved-locations.yaml + golden; the
  param-styles assertion now pins the 3.1 query declaration as silent.
@fuad-daoud fuad-daoud self-assigned this Sep 30, 2026
@fuad-daoud fuad-daoud changed the title fix(compilers/openapi): warn on allowReserved outside query (#628) fix(compilers/openapi): warn on allowReserved outside query Sep 30, 2026
Resolve the conflicts with main and bring the doc comments this branch
adds under the 100-word cap main now enforces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCzyGPq9DQ7RLfagZm2JB5
// AllowReservedIsQueryOnly reports whether this document's dialect confines
// allowReserved to in: query. OpenAPI 3.0 and 3.1 apply the keyword at the
// query location alone, so a path, header or cookie declaration is one the
// dialect does not use; 3.2 widens it to every location.

@OmarAlJarrah OmarAlJarrah Oct 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This premise looks wrong for 3.2. The 3.2 text for allowReserved says "This field only applies to in and style values that automatically percent-encode", so 3.2 did not widen the keyword to every location. in: header never percent-encodes ("URI percent-encoding MUST NOT be applied"), and neither does in: cookie with style: cookie; path, query and default-style (form) cookies do.

Leaving 3.2 silent is fine as scope, since #628 covers 3.0 and 3.1. But the claim that 3.2 "applies the keyword at every location" should be reworded wherever it appears, and the 3.2 in: header gap recorded where the next reader will reach it. The 3.2 block of TestParams_AllowReservedOutsideQueryIsSilentElsewhere currently asserts that silence as intended behaviour.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same claim is in these places, so rewording needs to cover all of them: lowering.go:358; lowering_test.go:204, 231 and 237; params.go:103; params_test.go:411, 450 and 467; the OpenAPI 3.x row of the §14 table in docs/ir-design.md ("while 3.2 applies the keyword at every location and is silent"); the first commit's message; and the PR summary.

The commit message matters most: with the repo's COMMIT_MESSAGES squash setting it is pasted into the commit on main. (params_test.go:400 only says the declared value is kept at each tested location, and is fine as written.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. 3.2 limits allowReserved to in/style values that percent-encode, so header and style: cookie are excluded. dcc489f removes the "every location" and "location-agnostic" wording from every site you listed (lowering.go, lowering_test.go, params.go, params_test.go and the ir-design §14 row). The doc comment on allowReservedLocationDiag and the §14 clause now record that 3.2's rule isn't reported, and an issue will track it. The 3.2 rows are still silent, but the test comment now calls that a scoping choice, not a rule. dbf33cf can't be rewritten, so its message will be replaced by an explicit squash body at merge.

return nil
}
switch in {
case soa.ParameterInPath, soa.ParameterInHeader, soa.ParameterInCookie:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exclusion of in: querystring here is not pinned by any test. Adding soa.ParameterInQueryString to this case list leaves go test ./compilers/openapi/... ./cmd/... green, and a 3.1 document with in: querystring plus allowReserved: true then reports two warnings at .../parameters/0/allowReserved (the #408 one and this one).

The existing querystring tests all use 3.2, where AllowReservedIsQueryOnly is false and this function returns before the switch, so none of them reach the case list. A small 3.1 case asserting exactly one invalid-location-keyword at that pointer would pin the claim the PR description and the comment above make.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced: the mutation left everything green. TestParams_AllowReservedLocationRule now has a 3.1.0 in: querystring row. It requires exactly one location-keyword report at /paths/~1p/get/parameters/0/allowReserved across both codes: the #408 invalid-location-keyword with its message. With querystring added to the case list, that row fails.

default:
return nil
}
return []ir.Diagnostic{c.DiagAt(ir.SeverityWarning, diag.InvalidLocationKeyword,

@OmarAlJarrah OmarAlJarrah Oct 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a parameter that arrives through an external $ref (allow-external-refs=true) two things differ from the description, both reproduced with a two-file spec:

  • The dialect is read from the root document (c.Doc), so a 3.1 root pulling a parameter from a 3.2 document warns "before OpenAPI 3.2", and a 3.2 root pulling one from a 3.0.3 document stays silent.
  • ObjectAt keeps the use-site pointer for a reference that leaves the document, so the warning lands at <operation>/parameters/<i>/allowReserved. That entry is a bare $ref with no such key; the keyword is in the other file.

The querystring rule beside this has the same pointer behaviour, so this may be acceptable, but "reported at the keyword's own coordinate" is not true for this case and is worth stating.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reproduced both directions with a two-file spec and allow-external-refs=true: a 3.1 root referencing a parameter in a 3.2 file warns "before OpenAPI 3.2" at the use-site .../parameters/0/allowReserved, and a 3.2 root referencing one in a 3.0.3 file stays silent.

This is the same shape as the existing in: querystring rule, which also reports at the use-site pointer when the parameter arrives through an external $ref, and as ExclusiveBoundIsBoolean, which also reads the root document's version. So I would not change the behaviour in this PR. What the PR should do is state the limitation: "reported at the keyword's own coordinate" in the PR body, and the doc comment on allowReservedLocationDiag, are not true for a parameter that comes from another file.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced both directions with the CLI. Behaviour is unchanged, as you suggested, since it matches the querystring rule and the exclusive-bound check. The limitation is now stated in the allowReservedLocationDiag doc comment, in ir-design §14 and in the PR body.

// which matches ExclusiveBoundIsBoolean's choice of the unrecognized-version
// answer and keeps the reader from inventing a rule for a document it cannot
// place. In production load refuses an unsupported version before any lowering
// runs; this is what lets a zero context answer rather than panic.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last sentence does not follow. load refusing an unsupported version is not what lets a zero context answer without panicking; that is GetOpenAPI being nil-safe on a nil *soa.OpenAPI, which is what the ExclusiveBoundIsBoolean comment above says.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two more reasons the sentence is off, both from compiling specs with the built CLI. openapi: 3.3.0 and 4.0.0 never reach load: the engine stops them with engine/no-compiler-for-format. And a malformed two-part version such as openapi: "3.1" draws load's validation-supported-version errors, yet lowering still runs and this rule still fires. So "load refuses an unsupported version before any lowering runs" does not hold as written.

ExclusiveBoundIsBoolean makes no such claim. The PR body's "unreachable in production because load refuses unsupported versions" needs the same trim.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and the follow-up holds. 3.3.0 stops at engine/no-compiler-for-format, and a two-part "3.1" draws load's validation errors but still lowers as 3.1, so the rule fires on it (TestDialectMinor_FollowsTheDocument pins "3.1"→"3.1"). The comment now credits the nil-safe GetOpenAPI only.

Comment thread compilers/openapi/internal/diag/diag.go Outdated
// The bundled parser enforces this for style at that location but not for
// these two (GitHub #408), so the compiler reports the gap itself. The value
// still lowers as declared: dropping stated content is an emitter's call
// The bundled parser enforces this for style at querystring but not for these

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"but not for these (GitHub #408)" now reads as covering the before-3.2 rule too, but #408 is only about in: querystring; the new rule is separate from it. The parenthetical should attach to the querystring sentence only.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. #408 now attaches only to the querystring sentence. Since eed9bd9 that's the only rule InvalidLocationKeyword's comment describes.

Comment thread compilers/openapi/internal/diag/diag.go Outdated
// OpenAPI 3.2 binds the whole query string from the parameter's content there
// and states its serialization through the media type alone, so neither
// keyword has anything left to qualify.
// InvalidLocationKeyword reports a serialization keyword at a parameter

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both rules now report under one code, so a consumer that wants to treat the 3.2 in: querystring violations (a MUST NOT) differently from the before-3.2 dialect mismatch (a keyword the dialect simply has no use for) has to match on the message text. Worth confirming one code is the intent, or giving the second rule its own code.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split (eed9bd9). The pre-3.2 rule now reports openapi/inapplicable-location-keyword, and invalid-location-keyword stays with the 3.2 querystring MUST NOT. A consumer can tell them apart by code, not message text. The table test asserts each row's code, and putting the rule back on the old code reddens it, the per-declaration test and the conformance case.

// answer and keeps the reader from inventing a rule for a document it cannot
// place. In production load refuses an unsupported version before any lowering
// runs; this is what lets a zero context answer rather than panic.
func (c Ctx) AllowReservedIsQueryOnly() bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This body is the same as ExclusiveBoundIsBoolean above apart from the comparison. lowering.Ctx is at 18 of the 20 methods TestMethodsPerType_StayUnderTheCap allows, and every per-keyword dialect question adds one. A single accessor for the minor version on Ctx (callers compare it) would answer both questions and the next one without growing the type.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Ctx now has a single DialectMinor() string, and callers compare it. It replaces both ExclusiveBoundIsBoolean and AllowReservedIsQueryOnly, so Ctx loses two methods and gains one. I swapped the comparison in each schema.go/params.go caller and checked that every caller's existing tests go red.

// TestParams_AllowReservedOutsideQueryIsSilentElsewhere is the control for the
// rule above: the 3.0/3.1 warning tracks the keyword *and* its location, so a
// query declaration, an absent keyword and every 3.2 location all stay silent.
func TestParams_AllowReservedOutsideQueryIsSilentElsewhere(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This packs three independent scenarios into one body, with doc, diags = reassigned between them, so a failing require in the first hides the other two. A table over version, location, keyword present and expected warning would be flat, match the testing convention, and let the 3.2 header/cookie rows be changed in one place if the 3.2 question on AllowReservedIsQueryOnly is resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whichever shape the test takes, the PR body's "reports once" claim for a $ref'd component parameter has no test. Compiling by hand: a 3.1 cookie parameter declaring allowReserved: true in components/parameters, referenced from several operations, gives one warning at #/components/parameters/Shared/allowReserved; a path-item-level parameter reports once at /paths/~1a/parameters/0/allowReserved; parameters under callbacks and webhooks each report at their own pointer. All of that behaves sensibly, but only the plain operation-level case is pinned. Rows for a $ref'd parameter, a path-level parameter, a callback and a webhook would pin the rest.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. TestParams_AllowReservedLocationRule is one flat table over version × location × declared value × expected code and report, covering both the reported and the silent cases.

On the follow-up: added TestParams_AllowReservedOutsideQueryReportsOncePerDeclaration. Its spec covers a component $ref'd from two operations, a path-item parameter shared by two operations, a callback parameter and a webhook parameter. It requires exactly one inapplicable-location-keyword report at each declaration's own pointer, and four in total.

@OmarAlJarrah

Copy link
Copy Markdown
Member

Notes on the PR description (the inline comments cover the code):

  • The summary says 3.2 "widened [allowReserved] to every location". The 3.2 text limits it to in/style values that percent-encode, which excludes in: header and in: cookie with style: cookie (details on the thread at lowering.go:358). "Deliberately not done" already notes that 3.2 style combinations that do not percent-encode go unreported, but it does not mention in: header, which is a location question rather than a style one.
  • "unreachable in production because load refuses unsupported versions" is imprecise; see the reply on lowering.go:364.
  • "reported at the keyword's own coordinate" does not hold for a parameter that arrives through an external $ref; see the reply on params.go:119.

OpenAPI 3.2 did not widen allowReserved to every location: it applies
the keyword only to in/style values that percent-encode, which excludes
in: header and in: cookie with style: cookie. The rule here stays
scoped to 3.0 and 3.1, so 3.2 is silent, but the code, tests and
ir-design §14 now say why rather than claiming 3.2 allows it
everywhere, and they record that 3.2's own rule goes unreported.

They also record that a parameter reached through an external $ref is
judged by the root document's version and reported at its use-site
pointer, as the querystring rule beside it already is.

lowering.Ctx answers the dialect with DialectMinor, which callers
compare, in place of one predicate per keyword. It replaces
ExclusiveBoundIsBoolean and AllowReservedIsQueryOnly, so the next
dialect question does not grow a type near its method cap.

The operation tests become one flat table over version, location,
declared value and expected report. A 3.1 in: querystring row pins its
single #408 report, so adding querystring to the rule's location set
now reddens. A second test pins one report per declaration for a
$ref'd component, a path-item parameter, a callback and a webhook.
The fixture added on this branch predates the idSpaces and group IDs main
introduced with IR 0.7.0; the merge brought those in without touching the
golden. Only those fields change.
allowReserved at path, header or cookie in a 3.0 or 3.1 document now
reports openapi/inapplicable-location-keyword instead of sharing
openapi/invalid-location-keyword with the 3.2 querystring rule. The
querystring case breaks a MUST NOT; this one is a keyword its dialect
simply ignores. A consumer that weighs the two differently can now tell
them apart by code rather than by message text.
@fuad-daoud

Copy link
Copy Markdown
Collaborator Author

Thanks. All three notes are addressed in the updated PR description:

  • The summary no longer says 3.2 widened allowReserved to every location. It now says 3.2 applies the keyword only to in/style values that percent-encode, which leaves out in: header and in: cookie with style: cookie, and that this PR covers 3.0 and 3.1 only. "Deliberately not done" now names the in: header gap explicitly. openapi: allowReserved where OpenAPI 3.2 does not apply it is kept without a warning #819 tracks 3.2's own rule (header, style: cookie, and the style combinations 3.2 calls unusable).
  • The "unreachable in production" sentence is gone. The body now says what actually happens: 3.3.0 stops at the engine, and a two-part "3.1" still lowers as 3.1.
  • "Reported at the keyword's own coordinate" now states the exception: a parameter reached through an external $ref is judged by the root document's version and reported at its use-site pointer.

The first commit's message still carries the old 3.2 claim. The branch won't be rewritten, so I'll set the squash body by hand at merge rather than paste the commit messages.

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: allowReserved on a path or header parameter is kept without a warning

2 participants