Skip to content

[typescript-fetch] fix: explode object query parameters - #24803

Open
wiebren wants to merge 1 commit into
OpenAPITools:masterfrom
wiebren:fix/exploded-object-query-parameters-typescript-fetch
Open

[typescript-fetch] fix: explode object query parameters#24803
wiebren wants to merge 1 commit into
OpenAPITools:masterfrom
wiebren:fix/exploded-object-query-parameters-typescript-fetch

Conversation

@wiebren

@wiebren wiebren commented Aug 28, 2026

Copy link
Copy Markdown

A query parameter whose schema is an object and whose style/explode are left at their
defaults — style: form, explode: true — must go on the wire as one parameter per entry,
keyed by the property name alone. The typescript-fetch client bracketed it instead, while
csharp, java and php got it right.

Given:

parameters:
  - in: query
    name: filter
    schema:
      type: object

called with {"category": "books", "createdDate:gte": "2023-01-01"}:

generator on the wire
expected category=books&createdDate%3Agte=2023-01-01
typescript-fetch filter[category]=books&filter[createdDate%3Agte]=2023-01-01
csharp, java, php correct

Split out of #24797 at the maintainer's request, one PR per language. The go half
stays in #24797, the python half is #24802. The three are independent — no shared
main/ code — and can be reviewed and merged in any order. They each add the same test
fixture at the same path with identical content, so whichever lands first, the others
rebase cleanly.

Two causes

1. typescript-fetch/apis.mustache — the explode branch was gated on isContainer,
which DefaultCodegen.fromParameter only sets via updateParameterForMap, which in turn
needs ModelUtils.isMapSchema (so, additionalProperties). A bare type: object instead
goes through setTypeProperties, which sets isMap and isFreeFormObject and leaves
isContainer false. The parameter was therefore assigned whole and the runtime's
querystringSingleKey bracketed it. Now keyed on isMap; inside {{^isArray}},
isContainer implies isMap, so this is the same condition plus free-form objects.

2. TypeScriptFetchClientCodegenExtendedCodegenParameter's copy constructor copies
isExplode and style but not isDeepObject, isFormStyle, isMatrix,
isAllowEmptyValue, isSpaceDelimited or isPipeDelimited, so all six were false in every
typescript-fetch template regardless of the document. --global-property debugOperations=true on a parameter declaring style: deepObject shows "isDeepObject": false two lines above "style": "deepObject". This is independent of (1), and it is why
fixing (1) alone would have broken deepObject parameters — they are explode: true too.

Verified on the wire, not just asserted

The client was generated from the new fixture and pointed at a server that echoes its own raw
query string back:

parameter typescript-fetch
filter (object, defaults) category=books&createdDate%3Agte=2023-01-01
typedFilter (map, defaults) same
deepFilter (style: deepObject) deepFilter%5Bcategory%5D=books&…
flatFilter (explode: false) flatFilter%5Bcategory%5D=books&…

The first two rows are the fixed behaviour; the last two are byte-for-byte what the generator
did before.

Known gap, called out deliberately

Declared object models. A $refed object model as a query parameter is isModel, not
isMap, so it still goes on the wire whole. This is pre-existing. It cannot be fixed by
widening the isMap branch: typescript-fetch interface properties are not the wire names,
which is what the ToJSON functions exist to translate — FormatTest.ts maps 'float' to
value['_float'] and 'pattern_with_digits' to value['patternWithDigits'], and dateTime
needs serializeDateTime. Iterating Object.keys over a model would put _float=… and a raw
Date on the wire. A correct fix has to route through {{dataType}}ToJSON first, which is a
feature rather than a template branch.

Note on the diff

The loop body gains an as any cast on the indexing. It is not needed for a declared map,
but a free-form object is typed object and object[key] is error TS7053 under strict,
which is how most consumers compile. Checked both ways against the generated fixture client:
without the cast, one TS7053; with it, zero errors. That cast is the entire diff in the three
existing typescript-fetch petstore samples.

Tests

modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml covers
the four combinations that decide the wire format:

  • TypeScriptFetchClientCodegenTest#testExplodedObjectQueryParameter

It fails without the fixes (verified by stashing only the main/ changes). 67 tests pass
across TypeScriptFetchClientCodegenTest, TypeScriptFetchModelTest and
TypeScriptFetchClientOptionsTest.

PR checklist

  • Read the contribution guidelines.
  • Built the project and updated samples (./bin/generate-samples.sh for
    bin/configs/typescript-fetch*.yaml; ./bin/utils/export_docs_generators.sh produced
    no diff). 3 sample files changed, all the as any cast above, from the petstore
    fixture's language parameter — a declared map with the default style.
  • Technical committee: @TiFu @macjohnny @topce @akehir @amakhrov @davidgamero @joscha

Summary by cubic

Fixes the typescript-fetch client so object query parameters with default form/explode go on the wire as one parameter per entry, keyed by the property name alone (category=books), instead of bracketed under the parameter name (filter[category]=books). deepObject and explode: false objects keep their previous wire format.

Bug Fixes

  • The template's explode branch was gated on isContainer, which free-form objects never set; it now keys on isMap, covering declared maps and free-form objects, with an as any cast so strict TypeScript still compiles.
  • ExtendedCodegenParameter's copy constructor dropped six style-related flags, so isDeepObject and friends were always false in templates; they're now copied, which keeps deepObject parameters intact.
  • Adds a test fixture covering the four style/explode combinations.

Known gap

  • A $refed object model as a query parameter still goes on the wire as a whole; fixing it requires routing through the model's ToJSON for wire-name translation, which is out of scope.

Written for commit 0dec118. Summary will update on new commits.

Review in cubic


Generated with Claude Code

A query parameter whose schema is an object and whose style/explode are left at
their defaults — style: form, explode: true — must go on the wire as one
parameter per entry, keyed by the property name alone. The typescript-fetch
client bracketed it as filter[category]=books instead.

typescript-fetch/apis.mustache gated the explode branch on isContainer, which
DefaultCodegen.fromParameter only sets via updateParameterForMap, which in turn
needs ModelUtils.isMapSchema (so, additionalProperties). A bare type: object
instead goes through setTypeProperties, which sets isMap and isFreeFormObject
and leaves isContainer false. The parameter was therefore assigned whole and the
runtime's querystringSingleKey bracketed it. The branch is now keyed on isMap;
inside {{^isArray}}, isContainer implies isMap, so this is the same condition
plus free-form objects.

TypeScriptFetchClientCodegen's ExtendedCodegenParameter copy constructor copied
isExplode and style but not isDeepObject, isFormStyle, isMatrix,
isAllowEmptyValue, isSpaceDelimited or isPipeDelimited, so all six were false in
every typescript-fetch template regardless of the document. This is independent
of the template fix, and it is why fixing the template alone would have broken
deepObject parameters — they are explode: true too.

The loop body gains an `as any` cast on the indexing. It is not needed for a
declared map, but a free-form object is typed `object` and object[key] is
error TS7053 under strict, which is how most consumers compile.
@wiebren

wiebren commented Aug 28, 2026

Copy link
Copy Markdown
Author

Split per language as requested, out of #24797. This is the typescript-fetch half; the
siblings are:

The three touch disjoint sets of files under main/ and have no ordering dependency, so they
can be reviewed and merged independently. Each adds
modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml at the
same path with identical content, so whichever lands first, the others rebase cleanly.

Each branch was tested on its own after the split, not just as part of the original combined
branch.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No issues found across 7 files

Re-trigger cubic

@wiebren

wiebren commented Aug 28, 2026

Copy link
Copy Markdown
Author

cubic came back clean on this PR, but its earlier run against the pre-split commit on #24797
raised two typescript-fetch points. Recording the dispositions here so they are not lost with
the stale review:

Declared object models still serialized whole — valid, pre-existing, deliberately out of
scope.
This is the known gap already in the description. cubic's suggested patch was to add
an isModel branch running the same Object.keys loop, and that would be actively wrong:
typescript-fetch interface properties are not the wire names, which is the whole reason the
ToJSON functions exist. FormatTest.ts maps 'float' to value['_float'] and
'pattern_with_digits' to value['patternWithDigits'], and dateTime needs
serializeDateTime. Iterating Object.keys over a model would put _float=… and a raw
Date on the wire — worse than the whole-object assignment it replaces. A correct fix routes
through {{dataType}}ToJSON first, which is a feature, not a template branch.

__proto__ as an own key is dropped — valid, exotic, not fixed here. Confirmed:

const src = JSON.parse('{"__proto__":"x","category":"books"}');
const queryParameters = {};
for (const key of Object.keys(src)) queryParameters[key] = src[key];
Object.keys(queryParameters);  // ['category'] — the __proto__ entry is gone

The assignment hits the prototype setter instead of creating an own property, so the entry
silently disappears. Worth noting this is not introduced here — the pre-existing
isContainer branch ran the same loop for declared maps; this PR only widens which
parameters reach it. Fixing it properly means giving queryParameters a null prototype or
using Object.defineProperty, which touches the shared runtime querystring path and every
generator that shares it. I would rather do that as its own PR than smuggle it into a
serialization fix. Say the word if you would prefer it here.

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.

1 participant