Skip to content

Commit dc66805

Browse files
committed
feat: support query parameter style/explode
1 parent 96e2e83 commit dc66805

File tree

15 files changed

+207
-90
lines changed

15 files changed

+207
-90
lines changed

packages/openapi-code-generator/src/core/input.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
Responses,
1111
Schema,
1212
Server,
13+
Style,
1314
xInternalPreproccess,
1415
} from "./openapi-types"
1516
import type {
@@ -333,10 +334,15 @@ export class Input {
333334
return parameters
334335
.map((it) => this.loader.parameter(it))
335336
.map((it: Parameter): IRParameter => {
337+
const style = this.styleForParameter(it)
338+
const explode = this.explodeForParameter(it, style)
339+
336340
return {
337341
name: it.name,
338342
in: it.in,
339343
schema: this.normalizeSchemaObject(it.schema),
344+
style,
345+
explode,
340346
description: it.description,
341347
required: it.required ?? false,
342348
deprecated: it.deprecated ?? false,
@@ -345,6 +351,40 @@ export class Input {
345351
})
346352
}
347353

354+
private styleForParameter(parameter: Parameter): Style {
355+
if (parameter.style) {
356+
return parameter.style
357+
}
358+
359+
switch (parameter.in) {
360+
case "query":
361+
return "form"
362+
case "path":
363+
return "simple"
364+
case "header":
365+
return "simple"
366+
case "cookie":
367+
return "form"
368+
default: {
369+
throw new Error(
370+
`unsupported parameter in '${parameter.in satisfies never}'`,
371+
)
372+
}
373+
}
374+
}
375+
376+
private explodeForParameter(parameter: Parameter, style: Style): boolean {
377+
if (typeof parameter.explode === "boolean") {
378+
return parameter.explode
379+
}
380+
381+
if (style === "form") {
382+
return true
383+
}
384+
385+
return false
386+
}
387+
348388
private normalizeOperationId(
349389
operationId: string | undefined,
350390
method: string,

packages/openapi-code-generator/src/core/openapi-types-normalized.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ export interface IRParameter {
127127
name: string
128128
in: "path" | "query" | "header" | "cookie" | "body"
129129
schema: MaybeIRModel
130+
style: Style | undefined
131+
explode: boolean | undefined
130132
description: string | undefined
131133
required: boolean
132134
deprecated: boolean

packages/openapi-code-generator/src/core/openapi-types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,12 @@ export interface Parameter {
203203
name: string
204204
in: "path" | "query" | "header" | "cookie"
205205
schema: Schema | Reference
206+
// todo: support content
207+
// content?: {
208+
// [contentType: string]: MediaType
209+
// }
210+
style?: Style
211+
explode?: boolean
206212
description?: string
207213
required?: boolean
208214
deprecated?: boolean

packages/openapi-code-generator/src/typescript/client/client-operation-builder.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {extractPlaceholders} from "../../core/openapi-utils"
99
import {camelCase, isDefined} from "../../core/utils"
1010
import type {SchemaBuilder} from "../common/schema-builders/schema-builder"
1111
import type {TypeBuilder} from "../common/type-builder"
12+
import {object, quotedStringLiteral} from "../common/type-utils"
1213
import {
1314
combineParams,
1415
type MethodParameterDefinition,
@@ -131,14 +132,47 @@ export class ClientOperationBuilder {
131132
return result
132133
}
133134

134-
queryString(): string {
135-
const {parameters} = this.operation
135+
query(): {paramsObject: string; encodings: string} | null {
136+
const parameters = this.operation.parameters.filter(
137+
(it) => it.in === "query",
138+
)
136139

137-
// todo: consider style / explode / allowReserved etc here
138-
return parameters
139-
.filter((it) => it.in === "query")
140-
.map((it) => `'${it.name}': ${this.paramName(it.name)}`)
141-
.join(",\n")
140+
if (parameters.length === 0) {
141+
return null
142+
}
143+
144+
const paramsObject = object(
145+
parameters.map(
146+
(it) => `${quotedStringLiteral(it.name)}: ${this.paramName(it.name)}`,
147+
),
148+
)
149+
150+
const encodings = object(
151+
parameters
152+
.filter((it) => it.style !== undefined || it.explode !== undefined)
153+
.filter((it) => {
154+
const schema = it.schema && this.input.schema(it.schema)
155+
// primitive values don't get influenced by encoding, so we can avoid putting it in the generated code.
156+
return (
157+
schema.type !== "string" &&
158+
schema.type !== "boolean" &&
159+
schema.type !== "number"
160+
)
161+
})
162+
.map(
163+
(it) =>
164+
`${quotedStringLiteral(it.name)}: ${object(
165+
[
166+
it.style !== undefined
167+
? `style: ${quotedStringLiteral(it.style)}`
168+
: undefined,
169+
it.explode !== undefined ? `explode: ${it.explode}` : undefined,
170+
].filter(isDefined),
171+
)}`,
172+
),
173+
)
174+
175+
return {paramsObject, encodings}
142176
}
143177

144178
headers({

packages/openapi-code-generator/src/typescript/client/typescript-angular/angular-service-builder.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export class AngularServiceBuilder extends AbstractClientBuilder {
3535

3636
const operationParameter = builder.methodParameter()
3737

38-
const queryString = builder.queryString()
38+
const query = builder.query()
3939
const headers = builder.headers({nullContentTypeValue: "undefined"})
4040

4141
const returnType = builder
@@ -51,7 +51,9 @@ export class AngularServiceBuilder extends AbstractClientBuilder {
5151
const body = `
5252
${[
5353
headers ? `const headers = this._headers(${headers})` : "",
54-
queryString ? `const params = this._queryParams({${queryString}})` : "",
54+
query
55+
? `const params = this._query(${[query.paramsObject, query.encodings].filter(Boolean).join(",")})`
56+
: "",
5557
requestBody?.parameter && requestBody.isSupported
5658
? `const body = ${builder.paramName(requestBody.parameter.name)}`
5759
: "",
@@ -63,7 +65,7 @@ return this.httpClient.request<any>(
6365
"${method}",
6466
${hasServers ? "basePath" : "this.config.basePath"} + \`${url}\`, {
6567
${[
66-
queryString ? "params" : "",
68+
query ? "params" : "",
6769
headers ? "headers" : "",
6870
requestBody?.parameter
6971
? requestBody.isSupported
@@ -146,6 +148,15 @@ export type QueryParams = {
146148
| QueryParams[]
147149
}
148150
151+
export type Style = "deepObject" | "form" | "pipeDelimited" | "spaceDelimited"
152+
153+
export type Encoding = {
154+
// allowReserved?: boolean;
155+
// contentType?: string;
156+
explode?: boolean
157+
style?: Style
158+
}
159+
149160
export type Server<T> = string & {__server__: T}
150161
151162
@Injectable({
@@ -164,8 +175,10 @@ export class ${clientName} {
164175
)
165176
}
166177
167-
private _queryParams(
168-
queryParams: QueryParams
178+
private _query(
179+
queryParams: QueryParams,
180+
// todo: use encodings
181+
_encodings?: Record<string, Encoding>,
169182
): HttpParams {
170183
return Object.entries(queryParams).reduce((result, [name, value]) => {
171184
if (typeof value === "string" || typeof value === "boolean" || typeof value === "number") {

packages/openapi-code-generator/src/typescript/client/typescript-axios/typescript-axios-client-builder.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export class TypescriptAxiosClientBuilder extends AbstractClientBuilder {
4141

4242
const operationParameter = builder.methodParameter()
4343

44-
const queryString = builder.queryString()
44+
const query = builder.query()
4545
const headers = builder.headers({nullContentTypeValue: "false"})
4646

4747
const returnType =
@@ -67,7 +67,7 @@ export class TypescriptAxiosClientBuilder extends AbstractClientBuilder {
6767
: null
6868

6969
const axiosFragment = `this._request({${[
70-
`url: url ${queryString ? "+ query" : ""}`,
70+
`url: url ${query ? "+ query" : ""}`,
7171
`method: "${method}"`,
7272
requestBody?.parameter
7373
? requestBody.isSupported
@@ -90,7 +90,9 @@ export class TypescriptAxiosClientBuilder extends AbstractClientBuilder {
9090
headers
9191
? `const headers = this._headers(${headers}, opts.headers)`
9292
: "const headers = this._headers({}, opts.headers)",
93-
queryString ? `const query = this._query({ ${queryString} })` : "",
93+
query
94+
? `const query = this._query(${[query.paramsObject, query.encodings].filter(Boolean).join(",")})`
95+
: "",
9496
requestBody?.parameter && requestBody.isSupported
9597
? `const body = ${this.serializeRequestBody(requestBody)}`
9698
: "",

packages/openapi-code-generator/src/typescript/client/typescript-fetch/typescript-fetch-client-builder.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export class TypescriptFetchClientBuilder extends AbstractClientBuilder {
7575

7676
const operationParameter = builder.methodParameter()
7777

78-
const queryString = builder.queryString()
78+
const query = builder.query()
7979
const headers = builder.headers({nullContentTypeValue: "undefined"})
8080

8181
const returnType = builder
@@ -89,7 +89,7 @@ export class TypescriptFetchClientBuilder extends AbstractClientBuilder {
8989
? builder.responseSchemas()
9090
: null
9191

92-
const fetchFragment = `this._fetch(url ${queryString ? "+ query" : ""},
92+
const fetchFragment = `this._fetch(url ${query ? "+ query" : ""},
9393
{${[
9494
`method: "${method}"`,
9595
requestBody?.parameter
@@ -109,7 +109,9 @@ export class TypescriptFetchClientBuilder extends AbstractClientBuilder {
109109
headers
110110
? `const headers = this._headers(${headers}, opts.headers)`
111111
: "const headers = this._headers({}, opts.headers)",
112-
queryString ? `const query = this._query({ ${queryString} })` : "",
112+
query
113+
? `const query = this._query(${[query.paramsObject, query.encodings].filter(Boolean).join(",")})`
114+
: "",
113115
requestBody?.parameter && requestBody.isSupported
114116
? `const body = ${this.serializeRequestBody(requestBody)}`
115117
: "",

packages/openapi-code-generator/src/typescript/common/typescript-common.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ export function requestBodyAsParameter(
248248
name: "requestBody",
249249
description: requestBody.description,
250250
in: "body",
251+
style: undefined,
252+
explode: undefined,
251253
required: requestBody.required,
252254
schema: result.mediaType.schema,
253255
allowEmptyValue: false,
@@ -275,6 +277,8 @@ export function requestBodyAsParameter(
275277
name: "requestBody",
276278
description: requestBody.description,
277279
in: "body",
280+
style: undefined,
281+
explode: undefined,
278282
required: requestBody.required,
279283
schema: {type: "never", nullable: false, readOnly: false},
280284
allowEmptyValue: false,

packages/typescript-axios-runtime/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,13 @@
2929
"test": "jest"
3030
},
3131
"dependencies": {
32-
"qs": "^6.14.0",
3332
"tslib": "^2.8.1"
3433
},
3534
"peerDependencies": {
3635
"axios": "^1.6.0"
3736
},
3837
"devDependencies": {
3938
"@jest/globals": "^30.2.0",
40-
"@types/qs": "^6.14.0",
4139
"axios": "^1.13.2",
4240
"jest": "^30.2.0",
4341
"typescript": "^5.9.3"

packages/typescript-axios-runtime/src/main.spec.ts

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,19 @@ import {
99
type HeaderParams,
1010
type QueryParams,
1111
} from "./main"
12+
import type {Encoding} from "./request-bodies/url-search-params"
1213

1314
class ConcreteAxiosClient extends AbstractAxiosClient {
1415
// biome-ignore lint/complexity/noUselessConstructor: make public
1516
constructor(config: AbstractAxiosConfig) {
1617
super(config)
1718
}
1819

19-
query(params: QueryParams) {
20-
return this._query(params)
20+
query(
21+
params: QueryParams,
22+
encodings: Record<string, Encoding> | undefined = undefined,
23+
): string {
24+
return this._query(params, encodings)
2125
}
2226

2327
headers(
@@ -46,28 +50,40 @@ describe("typescript-axios-runtime/main", () => {
4650
expect(client.query({foo: ["bar", "baz"]})).toBe("?foo=bar&foo=baz")
4751
})
4852

49-
it("handles objects", () => {
50-
expect(client.query({foo: {bar: "baz"}})).toBe(
51-
`?${encodeURIComponent("foo[bar]")}=baz`,
52-
)
53+
it("objects are unnested by default", () => {
54+
expect(client.query({foo: {bar: "baz"}})).toBe(`?bar=baz`)
55+
})
56+
57+
it("handles exploded style deepObject", () => {
58+
expect(
59+
client.query(
60+
{foo: {bar: "baz"}},
61+
{foo: {explode: true, style: "deepObject"}},
62+
),
63+
).toBe(`?${encodeURIComponent("foo[bar]")}=baz`)
5364
})
5465

5566
it("handles arrays of objects with multiple properties", () => {
5667
expect(
57-
client.query({
58-
foo: [
59-
{prop1: "one", prop2: "two"},
60-
{prop1: "foo", prop2: "bar"},
61-
],
62-
limit: 10,
63-
undefined: undefined,
64-
includeSomething: false,
65-
}),
68+
client.query(
69+
{
70+
foo: [
71+
{prop1: "one", prop2: "two"},
72+
{prop1: "foo", prop2: "bar"},
73+
],
74+
limit: 10,
75+
undefined: undefined,
76+
includeSomething: false,
77+
},
78+
{
79+
foo: {explode: true, style: "deepObject"},
80+
},
81+
),
6682
).toBe(
67-
`?${encodeURIComponent("foo[prop1]")}=one&${encodeURIComponent(
68-
"foo[prop2]",
69-
)}=two&${encodeURIComponent("foo[prop1]")}=foo&${encodeURIComponent(
70-
"foo[prop2]",
83+
`?${encodeURIComponent("foo[0][prop1]")}=one&${encodeURIComponent(
84+
"foo[0][prop2]",
85+
)}=two&${encodeURIComponent("foo[1][prop1]")}=foo&${encodeURIComponent(
86+
"foo[1][prop2]",
7187
)}=bar&limit=10&includeSomething=false`,
7288
)
7389
})

0 commit comments

Comments
 (0)