Fix: optional query arg silently hoisted past required body arg, swapping caller arguments#1816
Open
ngDuyAnh wants to merge 1 commit into
Open
Fix: optional query arg silently hoisted past required body arg, swapping caller arguments#1816ngDuyAnh wants to merge 1 commit into
ngDuyAnh wants to merge 1 commit into
Conversation
After acacode#1756 surfaced `requestParamsOptional` so the extracted query argument gets marked optional whenever all its fields are optional, the existing "sort by optionality" step in procedure-call.ejs began hoisting the optional query argument *past* a required body argument. The signature flipped from `(query, body, params)` to `(body, query, params)` for endpoints with a required requestBody and a non-required query parameter, silently swapping callers' positional arguments. Fix: before sorting, promote any optional arg that has a defaultValue and is followed by a required arg back to "positionally required". argToTmpl already emits `name: T = default` (no `?:`) for defaulted args, which is TS-legal before a required arg, so the user-facing type stays the same while declaration order is preserved. Adds a regression test (tests/spec/optional-query-required-body) that fails on baseline and passes with the fix. Updates two existing snapshots that were capturing the bug — both `signRequest` cases now correctly read `(query: SignRequestParams = {}, body: Claims, params: RequestParams = {})`.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
extractRequestParamsgenerates a wrapper with arguments in the wrong orderwhen an endpoint has a non-required query parameter together with a required
request body. The generated signature comes out as
(body, query, params)instead of
(query, body, params), which silently swaps the first twopositional arguments at every existing call site.
The bug is a runtime regression introduced by #1756. The output is still
type-legal under
// @ts-nocheck(the default generator header), so TypeScriptdoes not catch the swap; the failure surfaces only when the wrapper is called
and the body content is serialized into the URL query string.
Reproduction
Spec (minimal):
{ "openapi": "3.0.0", "paths": { "/check-impact": { "post": { "operationId": "checkImpact", "parameters": [ { "in": "query", "name": "cadence", "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["staff_id"], "properties": { "staff_id": { "type": "integer" } } } } } }, "responses": { "200": { "description": "OK" } } } } } }Generate with
--extract-request-params --extract-request-body:Before (current
main)A caller writing
api.checkImpact({ cadence: "weekly" }, { staff_id: 1 })—which reads naturally as
(query, body)and matches the OpenAPI declarationorder — passes
{ cadence: "weekly" }asbodyand{ staff_id: 1 }asquery. The body fields are URL-encoded into the query string and the serversees an empty JSON body. There is no runtime error; the request is just wrong.
Expected
Regression timeline
(query, body, params)fed24c6/ #1756 ("Fix: query params object incorrectly required when all fields are optional", May 21 2026, currently onmain/ 13.12.x)(body, query, params)PR #1756 surfaced
requestParamsSchema.typeData.allFieldsAreOptionalfromschema-routes.tsasroute.request.requestParamsOptionaland threaded it into the template so theextracted query argument gets marked
optionalwhen all of its fields areoptional. That part is a real UX improvement — callers can omit the empty
{}placeholder for endpoints whose only query parameters are optional.The unintended interaction is with the existing "Sort by optionality" step
in
templates/{default,modular}/procedure-call.ejs:_.sortByis stable and putsfalse(required) beforetrue(optional). Forendpoints with a required body and a now-optional query, the required body is
hoisted ahead of the optional query — even though the query already carries a
defaultValue: '{}'that would let it sit in front of a required argumentwithout producing invalid TypeScript.
Real-world impact
Every existing caller that was written against the pre-#1756 wrapper shape now
swaps body and query at runtime, with no compiler or runtime error to flag it.
This affects any project that pins
^13.2.3(the documented range) andre-resolves a fresh
node_modules— for example, a CI build that runsnpm installfrom a non-committed lockfile after the publish that contains #1756.Concretely: I hit this on a deployed app where
~/localresolved^13.2.3to13.2.16and generated the correct shape; the CI deploy resolved the samerange to a post-#1756 version and generated the swapped shape. Same call site,
same spec, two different argument orders, and a 400 response with the body
fields URL-encoded into the query string.
Proposed solution
The intent of the
sortBystep is to keep generated signaturesTypeScript-legal: a required parameter cannot follow a parameter typed as
name?: T. But an argument that has adefaultValueis only positionallyoptional —
argToTmplalready emits it asname: T = default, notname?: T,and TS happily accepts a required parameter after that.
So the fix is to leave defaulted args in declaration order when a required
arg follows them, while still pushing truly-optional (
?:) args to the end:Applied identically to
templates/default/procedure-call.ejsandtemplates/modular/procedure-call.ejs.Why this preserves #1756's UX win
For endpoints where the body is also optional (or there is no body), no
required arg follows the query, so the predicate doesn't fire and the query
stays
optional: true. Callers can still omit it entirely:It only kicks in when there is a required arg after the defaulted arg —
exactly the case where the sort would otherwise swap them.
Why not simpler alternatives
query?: T, body: T2) forendpoints whose query arg has no
defaultValue.o => o.optional && !o.defaultValue(treat any defaulted arg aspositionally required). Promotes too much:
requestConfigParamalwayscarries
defaultValue: '{}', and this would hoist it ahead of a trailingquery?: Tfor GET endpoints, churning unrelated snapshots. Theposition-aware variant in this PR only fires when the swap actually matters.
callers to pass
{}even when there is nothing to pass.Tests
Adds
tests/spec/optional-query-required-body/:cadencequery, requiredbody).
querybeforedataand that thequery arg keeps its
= {}default. Locks the entire generated file viasnapshot.
mainand pass with the fix.Updates two existing snapshots that were locking in the regression. Both
endpoints (
authentiqandextractRequestParamstest fixture) use thesignRequestoperation, which hasbody: Claims+ optional query and wasbeing generated as
(body, query, params). The new (correct) snapshot is:All other tests pass. (The 4 failing
paths-2*strict-TSC tests in my localrun fail on
maintoo — they shell out tobunx tsc, which wasn't on PATH inmy environment; not related to this change.)
End-to-end verification
Regenerated a real client (~120 endpoints) against the patched generator. Only
the two endpoints whose swagger matches the bug shape (required body +
non-required query) changed; every other endpoint's signature is byte-identical
to the 13.2.x output. Both regressed endpoints now produce
(query: ... = {}, data, params = {}), matching what their callers werewritten against.
Closes the silent-swap regression introduced by #1756 without reverting that
PR's UX improvement.
Summary by cubic
Fixes a regression that flipped generated wrapper arguments to (body, query, params) when the query is optional and the body is required, which silently swapped caller arguments at runtime. Preserves the original (query, body, params) order while keeping the UX from #1756 where empty query objects can be omitted.
templates/{default,modular}/procedure-call.ejs, promote optional args with a default value to “positionally required” when a required arg follows, then sort. This keeps query before body and still emitsquery: T = {}(notquery?: T).tests/spec/optional-query-required-bodyto lock the expected signature and full snapshot.signRequest) to the corrected(query = {}, body, params)order.Written for commit 4252aed. Summary will update on new commits.