Skip to content

Commit ad80f9f

Browse files
typescript-angular: Reimplement query param serialisation
This notably fixes: - JSON query param serialisation - array serialisation with style=form and explode=true As the class HttpParams from Angular is specially designed for the mimetype: `application/x-www-form-urlencoded` it does not support the range of query parameters defined by the OpenAPI specification. To workaround this issue, this patch introduces a custom `OpenAPIHttpParams` class which supports a wider range of query param styles. Note that as `HttpClient` is used afterwards, the class `OpenApiHttpParams` has a method to convert it into a `HttpParams` from Angular with a no-op HttpParameterCodec to avoid double serialisation of the query parameters.
1 parent ca750c0 commit ad80f9f

File tree

150 files changed

+5892
-1174
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

150 files changed

+5892
-1174
lines changed

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptAngularClientCodegen.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ public void processOpts() {
204204
supportingFiles.add(new SupportingFile("param.mustache", getIndexDirectory(), "param.ts"));
205205
supportingFiles.add(new SupportingFile("gitignore", "", ".gitignore"));
206206
supportingFiles.add(new SupportingFile("git_push.sh.mustache", "", "git_push.sh"));
207+
supportingFiles.add(new SupportingFile("queryParams.mustache", getIndexDirectory(), "query.params.ts"));
207208

208209
if(ngVersionAtLeast_17) {
209210
supportingFiles.add(new SupportingFile("README.mustache", getIndexDirectory(), "README.md"));

modules/openapi-generator/src/main/resources/typescript-angular/api.base.service.mustache

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { HttpHeaders, HttpParams, HttpParameterCodec } from '@angular/common/http';
33
import { CustomHttpParameterCodec } from './encoder';
44
import { {{configurationClassName}} } from './configuration';
5+
import { OpenApiHttpParams, QueryParamStyle, concatHttpParamsObject} from './query.params';
56

67
export class BaseService {
78
protected basePath = '{{{basePath}}}';
@@ -29,47 +30,58 @@ export class BaseService {
2930
return consumes.indexOf('multipart/form-data') !== -1;
3031
}
3132

32-
protected addToHttpParams(httpParams: HttpParams, value: any, key?: string, isDeep: boolean = false): HttpParams {
33-
// If the value is an object (but not a Date), recursively add its keys.
34-
if (typeof value === 'object' && !(value instanceof Date)) {
35-
return this.addToHttpParamsRecursive(httpParams, value, isDeep ? key : undefined, isDeep);
36-
}
37-
return this.addToHttpParamsRecursive(httpParams, value, key);
38-
}
39-
40-
protected addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string, isDeep: boolean = false): HttpParams {
33+
protected addToHttpParams(httpParams: OpenApiHttpParams, key: string, value: any | null | undefined, paramStyle: QueryParamStyle, explode: boolean): OpenApiHttpParams {
4134
if (value === null || value === undefined) {
4235
return httpParams;
4336
}
44-
if (typeof value === 'object') {
45-
// If JSON format is preferred, key must be provided.
46-
if (key != null) {
47-
return isDeep
48-
? Object.keys(value as Record<string, any>).reduce(
49-
(hp, k) => hp.append(`${key}[${k}]`, value[k]),
50-
httpParams,
51-
)
52-
: httpParams.append(key, JSON.stringify(value));
37+
38+
if (paramStyle === QueryParamStyle.DeepObject) {
39+
if (typeof value !== 'object') {
40+
throw Error(`An object must be provided for key ${key} as it is a deep object`);
5341
}
54-
// Otherwise, if it's an array, add each element.
55-
if (Array.isArray(value)) {
56-
value.forEach(elem => httpParams = this.addToHttpParamsRecursive(httpParams, elem, key));
42+
43+
return Object.keys(value as Record<string, any>).reduce(
44+
(hp, k) => hp.append(`${key}[${k}]`, value[k]),
45+
httpParams,
46+
);
47+
} else if (paramStyle === QueryParamStyle.Json) {
48+
return httpParams.append(key, JSON.stringify(value));
49+
} else {
50+
// Form-style, SpaceDelimited or PipeDelimited
51+
52+
if (Object(value) !== value) {
53+
// If it is a primitive type, add its string representation
54+
return httpParams.append(key, value.toString());
5755
} else if (value instanceof Date) {
58-
if (key != null) {
59-
httpParams = httpParams.append(key, value.toISOString());
56+
return httpParams.append(key, value.toISOString());
57+
} else if (Array.isArray(value)) {
58+
// Otherwise, if it's an array, add each element.
59+
if (paramStyle === QueryParamStyle.Form) {
60+
return httpParams.set(key, value, {explode: explode, delimiter: ','});
61+
} else if (paramStyle === QueryParamStyle.SpaceDelimited) {
62+
return httpParams.set(key, value, {explode: explode, delimiter: ' '});
6063
} else {
61-
throw Error("key may not be null if value is Date");
64+
// PipeDelimited
65+
return httpParams.set(key, value, {explode: explode, delimiter: '|'});
6266
}
6367
} else {
64-
Object.keys(value).forEach(k => {
65-
const paramKey = key ? `${key}.${k}` : k;
66-
httpParams = this.addToHttpParamsRecursive(httpParams, value[k], paramKey);
67-
});
68+
// Otherwise, if it's an object, add each field.
69+
if (paramStyle === QueryParamStyle.Form) {
70+
if (explode) {
71+
Object.keys(value).forEach(k => {
72+
httpParams = httpParams.append(k, value[k]);
73+
});
74+
return httpParams;
75+
} else {
76+
return concatHttpParamsObject(httpParams, key, value, ',');
77+
}
78+
} else if (paramStyle === QueryParamStyle.SpaceDelimited) {
79+
return concatHttpParamsObject(httpParams, key, value, ' ');
80+
} else {
81+
// PipeDelimited
82+
return concatHttpParamsObject(httpParams, key, value, '|');
83+
}
6884
}
69-
return httpParams;
70-
} else if (key != null) {
71-
return httpParams.append(key, value);
7285
}
73-
throw Error("key may not be null if value is not object or array");
7486
}
7587
}

modules/openapi-generator/src/main/resources/typescript-angular/api.service.mustache

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33

44
import { Inject, Injectable, Optional } from '@angular/core';
55
import { HttpClient, HttpHeaders, HttpParams,
6-
HttpResponse, HttpEvent, HttpParameterCodec{{#httpContextInOptions}}, HttpContext {{/httpContextInOptions}}
6+
HttpResponse, HttpEvent{{#httpContextInOptions}}, HttpContext {{/httpContextInOptions}}
77
} from '@angular/common/http';
8-
import { CustomHttpParameterCodec } from '../encoder';
98
import { Observable } from 'rxjs';
9+
import { OpenApiHttpParams, QueryParamStyle } from '../query.params';
1010

1111
{{#imports}}
1212
// @ts-ignore
@@ -86,6 +86,7 @@ export class {{classname}} extends BaseService {
8686
{{/useSingleRequestParameter}}
8787
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
8888
* @param reportProgress flag to report request and response progress.
89+
* @param options additional options
8990
{{#isDeprecated}}
9091
* @deprecated
9192
{{/isDeprecated}}
@@ -106,32 +107,46 @@ export class {{classname}} extends BaseService {
106107
{{/allParams}}
107108

108109
{{#hasQueryParamsOrAuth}}
109-
let localVarQueryParameters = new HttpParams({encoder: this.encoder});
110+
let localVarQueryParameters = new OpenApiHttpParams(this.encoder);
110111
{{#queryParams}}
111-
{{#isArray}}
112-
if ({{paramName}}) {
113-
{{#isQueryParamObjectFormatJson}}
114-
localVarQueryParameters = this.addToHttpParams(localVarQueryParameters,
115-
<any>{{paramName}}, '{{baseName}}');
116-
{{/isQueryParamObjectFormatJson}}
117-
{{^isQueryParamObjectFormatJson}}
118-
{{#isCollectionFormatMulti}}
119-
{{paramName}}.forEach((element) => {
120-
localVarQueryParameters = this.addToHttpParams(localVarQueryParameters,
121-
<any>element, '{{baseName}}');
122-
})
123-
{{/isCollectionFormatMulti}}
124-
{{^isCollectionFormatMulti}}
125-
localVarQueryParameters = this.addToHttpParams(localVarQueryParameters,
126-
[...{{paramName}}].join(COLLECTION_FORMATS['{{collectionFormat}}']), '{{baseName}}');
127-
{{/isCollectionFormatMulti}}
128-
{{/isQueryParamObjectFormatJson}}
129-
}
130-
{{/isArray}}
131-
{{^isArray}}
132-
localVarQueryParameters = this.addToHttpParams(localVarQueryParameters,
133-
<any>{{paramName}}, '{{baseName}}'{{#isDeepObject}}, true{{/isDeepObject}});
134-
{{/isArray}}
112+
113+
localVarQueryParameters = this.addToHttpParams(
114+
localVarQueryParameters,
115+
'{{baseName}}',
116+
<any>{{paramName}},
117+
{{#isQueryParamObjectFormatJson}}
118+
QueryParamStyle.Json,
119+
{{/isQueryParamObjectFormatJson}}
120+
{{^isQueryParamObjectFormatJson}}
121+
{{^style}}
122+
{{#queryIsJsonMimeType}}
123+
QueryParamStyle.Json,
124+
{{/queryIsJsonMimeType}}
125+
{{^queryIsJsonMimeType}}
126+
QueryParamStyle.Form,
127+
{{/queryIsJsonMimeType}}
128+
{{/style}}
129+
{{#style}}
130+
{{#isDeepObject}}
131+
QueryParamStyle.DeepObject,
132+
{{/isDeepObject}}
133+
{{#isFormStyle}}
134+
QueryParamStyle.Form,
135+
{{/isFormStyle}}
136+
{{#isSpaceDelimited}}
137+
QueryParamStyle.SpaceDelimited,
138+
{{/isSpaceDelimited}}
139+
{{#isPipeDelimited}}
140+
QueryParamStyle.PipeDelimited,
141+
{{/isPipeDelimited}}
142+
{{#queryIsJsonMimeType}}
143+
QueryParamStyle.Json,
144+
{{/queryIsJsonMimeType}}
145+
{{/style}}
146+
{{/isQueryParamObjectFormatJson}}
147+
{{isExplode}},
148+
);
149+
135150
{{/queryParams}}
136151

137152
{{/hasQueryParamsOrAuth}}
@@ -290,7 +305,7 @@ export class {{classname}} extends BaseService {
290305
{{/hasFormParams}}
291306
{{/bodyParam}}
292307
{{#hasQueryParamsOrAuth}}
293-
params: localVarQueryParameters,
308+
params: localVarQueryParameters.toHttpParams(),
294309
{{/hasQueryParamsOrAuth}}
295310
{{#isResponseFile}}
296311
responseType: "blob",

modules/openapi-generator/src/main/resources/typescript-angular/encoder.mustache

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,18 @@ export class CustomHttpParameterCodec implements HttpParameterCodec {
1818
return decodeURIComponent(v);
1919
}
2020
}
21+
22+
export class NoOpHttpParameterCodec implements HttpParameterCodec {
23+
encodeKey(k: string): string {
24+
return k;
25+
}
26+
encodeValue(v: string): string {
27+
return v;
28+
}
29+
decodeKey(k: string): string {
30+
return k;
31+
}
32+
decodeValue(v: string): string {
33+
return v;
34+
}
35+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { HttpParams, HttpParameterCodec } from '@angular/common/http';
2+
import { CustomHttpParameterCodec, NoOpHttpParameterCodec } from './encoder';
3+
4+
export enum QueryParamStyle {
5+
Json,
6+
Form,
7+
DeepObject,
8+
SpaceDelimited,
9+
PipeDelimited,
10+
}
11+
12+
export type Delimiter = "," | " " | "|" | "\t";
13+
14+
export interface ParamOptions {
15+
/** When true, serialized as multiple repeated key=value pairs. When false, serialized as a single key with joined values using `delimiter`. */
16+
explode?: boolean;
17+
/** Delimiter used when explode=false. The delimiter itself is inserted unencoded between encoded values. */
18+
delimiter?: Delimiter;
19+
}
20+
21+
interface ParamEntry {
22+
values: string[];
23+
options: Required<ParamOptions>;
24+
}
25+
26+
export class OpenApiHttpParams {
27+
private params: Map<string, ParamEntry> = new Map();
28+
private defaults: Required<ParamOptions>;
29+
private encoder: HttpParameterCodec;
30+
31+
/**
32+
* @param encoder Parameter serializer
33+
* @param defaults Global defaults used when a specific parameter has no explicit options.
34+
* By OpenAPI default, explode is true for query params with style=form.
35+
*/
36+
constructor(encoder?: HttpParameterCodec, defaults?: { explode?: boolean; delimiter?: Delimiter }) {
37+
this.encoder = encoder || new CustomHttpParameterCodec();
38+
this.defaults = {
39+
explode: defaults?.explode ?? true,
40+
delimiter: defaults?.delimiter ?? ",",
41+
};
42+
}
43+
44+
private resolveOptions(local?: ParamOptions): Required<ParamOptions> {
45+
return {
46+
explode: local?.explode ?? this.defaults.explode,
47+
delimiter: local?.delimiter ?? this.defaults.delimiter,
48+
};
49+
}
50+
51+
/**
52+
* Replace the parameter's values and (optionally) its options.
53+
* Options are stored per-parameter (not global).
54+
*/
55+
set(key: string, values: string[] | string, options?: ParamOptions): this {
56+
const arr = Array.isArray(values) ? values.slice() : [values];
57+
const opts = this.resolveOptions(options);
58+
this.params.set(key, {values: arr, options: opts});
59+
return this;
60+
}
61+
62+
/**
63+
* Append a single value to the parameter. If the parameter didn't exist it will be created
64+
* and use resolved options (global defaults merged with any provided options).
65+
*/
66+
append(key: string, value: string, options?: ParamOptions): this {
67+
const entry = this.params.get(key);
68+
if (entry) {
69+
// If new options provided, override the stored options for subsequent serialization
70+
if (options) {
71+
entry.options = this.resolveOptions({...entry.options, ...options});
72+
}
73+
entry.values.push(value);
74+
} else {
75+
this.set(key, [value], options);
76+
}
77+
return this;
78+
}
79+
80+
/**
81+
* Serialize to a query string according to per-parameter OpenAPI options.
82+
* - If explode=true for that parameter → repeated key=value pairs (each value encoded).
83+
* - If explode=false for that parameter → single key=value where values are individually encoded
84+
* and joined using the configured delimiter. The delimiter character is inserted AS-IS
85+
* (not percent-encoded).
86+
*/
87+
toString(): string {
88+
const records = this.toRecord();
89+
const parts: string[] = [];
90+
91+
for (const key in records) {
92+
parts.push(`${key}=${records[key]}`);
93+
}
94+
95+
return parts.join("&");
96+
}
97+
98+
/**
99+
* Return parameters as a plain record.
100+
* - If a parameter has exactly one value, returns that value directly.
101+
* - If a parameter has multiple values, returns a readonly array of values.
102+
*/
103+
toRecord(): Record<string, string | number | boolean | ReadonlyArray<string | number | boolean>> {
104+
const parts: Record<string, string | number | boolean | ReadonlyArray<string | number | boolean>> = {};
105+
106+
for (const [key, entry] of this.params.entries()) {
107+
const encodedKey = this.encoder.encodeKey(key);
108+
109+
if (entry.options.explode) {
110+
parts[encodedKey] = entry.values.map((v) => this.encoder.encodeValue(v));
111+
} else {
112+
const encodedValues = entry.values.map((v) => this.encoder.encodeValue(v));
113+
114+
// join with the delimiter *unencoded*
115+
parts[encodedKey] = encodedValues.join(entry.options.delimiter);
116+
}
117+
}
118+
119+
return parts;
120+
}
121+
122+
/**
123+
* Return an Angular's HttpParams with a NoOp parameter codec as the parameters are already encoded.
124+
*/
125+
toHttpParams(): HttpParams {
126+
const records = this.toRecord();
127+
128+
let httpParams = new HttpParams({encoder: new NoOpHttpParameterCodec()});
129+
130+
return httpParams.appendAll(records);
131+
}
132+
}
133+
134+
export function concatHttpParamsObject(httpParams: OpenApiHttpParams, key: string, item: {
135+
[index: string]: any
136+
}, delimiter: Delimiter): OpenApiHttpParams {
137+
let keyAndValues: string[] = [];
138+
139+
for (const k in item) {
140+
keyAndValues.push(k);
141+
keyAndValues.push(item[k].toString());
142+
}
143+
144+
return httpParams.set(key, keyAndValues, {explode: false, delimiter: delimiter});
145+
}

modules/openapi-generator/src/main/resources/typescript-angular/tsconfig.mustache

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
"noImplicitAny": false,
66
"target": "{{#supportsES6}}es6{{/supportsES6}}{{^supportsES6}}es5{{/supportsES6}}",
77
"module": "{{#supportsES6}}es6{{/supportsES6}}{{^supportsES6}}commonjs{{/supportsES6}}",
8+
{{^supportsES6}}
9+
"downlevelIteration": true,
10+
{{/supportsES6}}
811
"moduleResolution": "node",
912
"removeComments": true,
1013
"strictNullChecks": true,

modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/typescriptangular/TypeScriptAngularClientCodegenTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ public void testDeepObject() throws IOException {
486486

487487
// THEN
488488
final String fileContents = Files.readString(Paths.get(output + "/api/default.service.ts"));
489-
assertThat(fileContents).containsOnlyOnce("<any>options, 'options', true);");
490-
assertThat(fileContents).containsOnlyOnce("<any>inputOptions, 'inputOptions', true);");
489+
assertThat(fileContents).containsSubsequence("'options',\n", "<any>options,\n", "QueryParamStyle.DeepObject,\n", "true,\n");
490+
assertThat(fileContents).containsSubsequence("'inputOptions',\n", "<any>inputOptions,\n", "QueryParamStyle.DeepObject,\n", "true,\n");
491491
}
492492
}

0 commit comments

Comments
 (0)