Skip to content

Commit 2804cf9

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 db531eb commit 2804cf9

File tree

105 files changed

+5885
-1318
lines changed

Some content is hidden

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

105 files changed

+5885
-1318
lines changed

docs/generators/python.md

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

6149
export class BaseService {
7-
protected basePath = '{{{basePath}}}';
150+
protected basePath = 'http://localhost';
8151
public defaultHeaders = new HttpHeaders();
9-
public configuration: {{configurationClassName}};
152+
public configuration: Configuration;
10153
public encoder: HttpParameterCodec;
11154
12-
constructor(basePath?: string|string[], configuration?: {{configurationClassName}}) {
13-
this.configuration = configuration || new {{configurationClassName}}();
155+
constructor(basePath?: string | string[], configuration?: Configuration) {
156+
this.configuration = configuration || new Configuration();
14157
if (typeof this.configuration.basePath !== 'string') {
15158
const firstBasePath = Array.isArray(basePath) ? basePath[0] : undefined;
16159
if (firstBasePath != undefined) {
@@ -29,47 +172,58 @@ export class BaseService {
29172
return consumes.indexOf('multipart/form-data') !== -1;
30173
}
31174

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 {
175+
protected addToHttpParams(httpParams: OpenApiHttpParams, key: string, value: any | null | undefined, paramStyle: QueryParamStyle, explode: boolean): OpenApiHttpParams {
41176
if (value === null || value === undefined) {
42177
return httpParams;
43178
}
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));
179+
180+
if (paramStyle === QueryParamStyle.DeepObject) {
181+
if (typeof value !== 'object') {
182+
throw Error(`An object must be provided for key ${key} as it is a deep object`);
53183
}
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));
184+
185+
return Object.keys(value as Record<string, any>).reduce(
186+
(hp, k) => hp.append(`${key}[${k}]`, value[k]),
187+
httpParams,
188+
);
189+
} else if (paramStyle === QueryParamStyle.Json) {
190+
return httpParams.append(key, JSON.stringify(value));
191+
} else {
192+
// Form-style, SpaceDelimited or PipeDelimited
193+
194+
if (Object(value) !== value) {
195+
// If it is a primitive type, add its string representation
196+
return httpParams.append(key, value.toString());
57197
} else if (value instanceof Date) {
58-
if (key != null) {
59-
httpParams = httpParams.append(key, value.toISOString());
198+
return httpParams.append(key, value.toISOString());
199+
} else if (Array.isArray(value)) {
200+
// Otherwise, if it's an array, add each element.
201+
if (paramStyle === QueryParamStyle.Form) {
202+
return httpParams.set(key, value, {explode: explode, delimiter: ','});
203+
} else if (paramStyle === QueryParamStyle.SpaceDelimited) {
204+
return httpParams.set(key, value, {explode: explode, delimiter: ' '});
60205
} else {
61-
throw Error("key may not be null if value is Date");
206+
// PipeDelimited
207+
return httpParams.set(key, value, {explode: explode, delimiter: '|'});
62208
}
63209
} else {
64-
Object.keys(value).forEach(k => {
65-
const paramKey = key ? `${key}.${k}` : k;
66-
httpParams = this.addToHttpParamsRecursive(httpParams, value[k], paramKey);
67-
});
210+
// Otherwise, if it's an object, add each field.
211+
if (paramStyle === QueryParamStyle.Form) {
212+
if (explode) {
213+
Object.keys(value).forEach(k => {
214+
httpParams = httpParams.append(k, value[k]);
215+
});
216+
return httpParams;
217+
} else {
218+
return concatHttpParamsObject(httpParams, key, value, ',');
219+
}
220+
} else if (paramStyle === QueryParamStyle.SpaceDelimited) {
221+
return concatHttpParamsObject(httpParams, key, value, ' ');
222+
} else {
223+
// PipeDelimited
224+
return concatHttpParamsObject(httpParams, key, value, '|');
225+
}
68226
}
69-
return httpParams;
70-
} else if (key != null) {
71-
return httpParams.append(key, value);
72227
}
73-
throw Error("key may not be null if value is not object or array");
74228
}
75229
}

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

Lines changed: 38 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
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';
109

1110
{{#imports}}
@@ -16,7 +15,7 @@ import { {{ classname }} } from '{{ filename }}';
1615
// @ts-ignore
1716
import { BASE_PATH, COLLECTION_FORMATS } from '../variables';
1817
import { {{configurationClassName}} } from '../configuration';
19-
import { BaseService } from '../api.base.service';
18+
import { BaseService, OpenApiHttpParams, QueryParamStyle } from '../api.base.service';
2019
{{#withInterfaces}}
2120
import {
2221
{{classname}}Interface{{#useSingleRequestParameter}}{{#operations}}{{#operation}}{{#allParams.0}},
@@ -86,6 +85,7 @@ export class {{classname}} extends BaseService {
8685
{{/useSingleRequestParameter}}
8786
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
8887
* @param reportProgress flag to report request and response progress.
88+
* @param options additional options
8989
{{#isDeprecated}}
9090
* @deprecated
9191
{{/isDeprecated}}
@@ -106,32 +106,41 @@ export class {{classname}} extends BaseService {
106106
{{/allParams}}
107107

108108
{{#hasQueryParamsOrAuth}}
109-
let localVarQueryParameters = new HttpParams({encoder: this.encoder});
109+
let localVarQueryParameters = new OpenApiHttpParams(this.encoder);
110110
{{#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}}
111+
112+
localVarQueryParameters = this.addToHttpParams(
113+
localVarQueryParameters,
114+
'{{baseName}}',
115+
<any>{{paramName}},
116+
{{#isQueryParamObjectFormatJson}}
117+
QueryParamStyle.Json,
118+
{{/isQueryParamObjectFormatJson}}
119+
{{^isQueryParamObjectFormatJson}}
120+
{{^style}}
121+
QueryParamStyle.Form,
122+
{{/style}}
123+
{{#style}}
124+
{{#isDeepObject}}
125+
QueryParamStyle.DeepObject,
126+
{{/isDeepObject}}
127+
{{#isFormStyle}}
128+
QueryParamStyle.Form,
129+
{{/isFormStyle}}
130+
{{#isSpaceDelimited}}
131+
QueryParamStyle.SpaceDelimited,
132+
{{/isSpaceDelimited}}
133+
{{#isPipeDelimited}}
134+
QueryParamStyle.PipeDelimited,
135+
{{/isPipeDelimited}}
136+
{{#queryIsJsonMimeType}}
137+
QueryParamStyle.Json,
138+
{{/queryIsJsonMimeType}}
139+
{{/style}}
140+
{{/isQueryParamObjectFormatJson}}
141+
{{isExplode}},
142+
);
143+
135144
{{/queryParams}}
136145

137146
{{/hasQueryParamsOrAuth}}
@@ -290,7 +299,7 @@ export class {{classname}} extends BaseService {
290299
{{/hasFormParams}}
291300
{{/bodyParam}}
292301
{{#hasQueryParamsOrAuth}}
293-
params: localVarQueryParameters,
302+
params: localVarQueryParameters.toHttpParams(),
294303
{{/hasQueryParamsOrAuth}}
295304
{{#isResponseFile}}
296305
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+
}

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,

0 commit comments

Comments
 (0)