Skip to content

Commit 47e7625

Browse files
authored
fix: sdk exploded syntax - take into account request plugin options (#3076)
## Proposed change When we previously implemented the exploded syntax for SDK parameter serialization, we removed the possibility of taking into account the query parameters that can be updated in the Request Plugins - which needs to be fixed. Here is a possible fix, which prepares the URL using the request plugins options (which could update the query parameters). Also, the parameter `queryParamSerialization` has been added to `RequestOptionsParameters`, which can be used in order to serialize the query parameter modifications that are potentially made. In another PR, I will add a method allowing to deserialize the parameters, which facilitates the modifications in the Request Plugins. ## Related issues <!-- Please make sure to follow the [contribution guidelines](https://github.com/amadeus-digital/Otter/blob/main/CONTRIBUTING.md) --> *- No issue associated -* <!-- * 🐛 Fix #issue --> <!-- * 🐛 Fix resolves #issue --> <!-- * 🚀 Feature #issue --> <!-- * 🚀 Feature resolves #issue --> <!-- * :octocat: Pull Request #issue -->
2 parents c894748 + f00d17a commit 47e7625

File tree

23 files changed

+608
-153
lines changed

23 files changed

+608
-153
lines changed

packages/@ama-sdk/client-angular/src/api-angular-client.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ const DEFAULT_OPTIONS = {
5555
angularPlugins: [],
5656
requestPlugins: [],
5757
enableTokenization: false,
58-
disableFallback: false
58+
disableFallback: false,
59+
enableParameterSerialization: false
5960
} as const satisfies Omit<BaseApiAngularClientOptions, 'basePath' | 'httpClient'>;
6061

6162
/** Client to process the call to the API using Angular API */
@@ -130,7 +131,7 @@ export class ApiAngularClient implements ApiClient {
130131
}
131132

132133
/** @inheritdoc */
133-
public prepareUrlWithQueryParams(url: string, serializedQueryParams: { [key: string]: string }): string {
134+
public prepareUrlWithQueryParams(url: string, serializedQueryParams?: { [key: string]: string }): string {
134135
return prepareUrlWithQueryParams(url, serializedQueryParams);
135136
}
136137

packages/@ama-sdk/client-beacon/src/api-beacon-client.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export interface BaseApiBeaconClientConstructor extends PartialExcept<Omit<BaseA
3535
const DEFAULT_OPTIONS = {
3636
replyPlugins: [] as never[],
3737
requestPlugins: [],
38-
enableTokenization: false
38+
enableTokenization: false,
39+
enableParameterSerialization: false
3940
} as const satisfies Omit<BaseApiBeaconClientOptions, 'basePath'>;
4041

4142
/**
@@ -130,7 +131,7 @@ export class ApiBeaconClient implements ApiClient {
130131
}
131132

132133
/** @inheritdoc */
133-
public prepareUrlWithQueryParams(url: string, serializedQueryParams: { [key: string]: string }): string {
134+
public prepareUrlWithQueryParams(url: string, serializedQueryParams?: { [key: string]: string }): string {
134135
return prepareUrlWithQueryParams(url, serializedQueryParams);
135136
}
136137

packages/@ama-sdk/client-fetch/src/api-fetch-client.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ const DEFAULT_OPTIONS = {
5151
fetchPlugins: [],
5252
requestPlugins: [],
5353
enableTokenization: false,
54-
disableFallback: false
54+
disableFallback: false,
55+
enableParameterSerialization: false
5556
} as const satisfies Omit<BaseApiFetchClientOptions, 'basePath'>;
5657

5758
/** Client to process the call to the API using Fetch API */
@@ -131,7 +132,7 @@ export class ApiFetchClient implements ApiClient {
131132
}
132133

133134
/** @inheritdoc */
134-
public prepareUrlWithQueryParams(url: string, serializedQueryParams: { [key: string]: string }): string {
135+
public prepareUrlWithQueryParams(url: string, serializedQueryParams?: { [key: string]: string }): string {
135136
return prepareUrlWithQueryParams(url, serializedQueryParams);
136137
}
137138

packages/@ama-sdk/core/src/clients/api-angular-client.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ const DEFAULT_OPTIONS: Omit<BaseApiAngularClientOptions, 'basePath' | 'httpClien
8282
angularPlugins: [],
8383
requestPlugins: [],
8484
enableTokenization: false,
85-
disableFallback: false
85+
disableFallback: false,
86+
enableParameterSerialization: false
8687
};
8788

8889
/**
@@ -160,7 +161,7 @@ export class ApiAngularClient implements ApiClient {
160161
}
161162

162163
/** @inheritdoc */
163-
public prepareUrlWithQueryParams(url: string, serializedQueryParams: { [key: string]: string }): string {
164+
public prepareUrlWithQueryParams(url: string, serializedQueryParams?: { [key: string]: string }): string {
164165
return prepareUrlWithQueryParams(url, serializedQueryParams);
165166
}
166167

packages/@ama-sdk/core/src/clients/api-beacon-client.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ export interface BaseApiBeaconClientConstructor extends PartialExcept<Omit<BaseA
5252
const DEFAULT_OPTIONS: Omit<BaseApiBeaconClientOptions, 'basePath'> = {
5353
replyPlugins: [] as never[],
5454
requestPlugins: [],
55-
enableTokenization: false
55+
enableTokenization: false,
56+
enableParameterSerialization: false
5657
};
5758

5859
/**
@@ -148,7 +149,7 @@ export class ApiBeaconClient implements ApiClient {
148149
}
149150

150151
/** @inheritdoc */
151-
public prepareUrlWithQueryParams(url: string, serializedQueryParams: { [key: string]: string }): string {
152+
public prepareUrlWithQueryParams(url: string, serializedQueryParams?: { [key: string]: string }): string {
152153
return prepareUrlWithQueryParams(url, serializedQueryParams);
153154
}
154155

packages/@ama-sdk/core/src/clients/api-fetch-client.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ const DEFAULT_OPTIONS: Omit<BaseApiFetchClientOptions, 'basePath'> = {
7373
fetchPlugins: [],
7474
requestPlugins: [],
7575
enableTokenization: false,
76-
disableFallback: false
76+
disableFallback: false,
77+
enableParameterSerialization: false
7778
};
7879

7980
/**
@@ -156,7 +157,7 @@ export class ApiFetchClient implements ApiClient {
156157
}
157158

158159
/** @inheritdoc */
159-
public prepareUrlWithQueryParams(url: string, serializedQueryParams: { [key: string]: string }): string {
160+
public prepareUrlWithQueryParams(url: string, serializedQueryParams?: { [key: string]: string }): string {
160161
return prepareUrlWithQueryParams(url, serializedQueryParams);
161162
}
162163

packages/@ama-sdk/core/src/fwk/api.helpers.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import type {
1313
* Prepares the url to be called
1414
* @param url Base url to be used
1515
* @param queryParameters Key value pair with the parameters. If the value is undefined, the key is dropped
16-
* @deprecated use {@link prepareUrlWithQueryParams} with query parameter serialization, will be removed in v14.
1716
*/
1817
export function prepareUrl(url: string, queryParameters: { [key: string]: string | undefined } = {}) {
1918
const queryPart = Object.keys(queryParameters)
@@ -31,7 +30,7 @@ export function prepareUrl(url: string, queryParameters: { [key: string]: string
3130
* @param url Base url to be used
3231
* @param serializedQueryParams Key value pairs of query parameter names and their serialized values
3332
*/
34-
export function prepareUrlWithQueryParams(url: string, serializedQueryParams: { [key: string]: string }): string {
33+
export function prepareUrlWithQueryParams(url: string, serializedQueryParams: { [key: string]: string } = {}): string {
3534
const paramsPrefix = url.includes('?') ? '&' : '?';
3635
const queryPart = Object.values(serializedQueryParams).join('&');
3736
return url + (queryPart ? paramsPrefix + queryPart : '');

packages/@ama-sdk/core/src/fwk/core/api-client.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
ParamSerializationOptions,
23
RequestBody,
34
RequestMetadata,
45
RequestOptions,
@@ -27,6 +28,8 @@ export interface RequestOptionsParameters {
2728
basePath: string;
2829
/** Query Parameters */
2930
queryParams?: { [key: string]: string | undefined };
31+
/** Parameter serialization options */
32+
paramSerializationOptions?: ParamSerializationOptions;
3033
/** Force body to string */
3134
body?: RequestBody;
3235
/** Force headers to Headers type */
@@ -91,7 +94,7 @@ export interface ApiClient {
9194
* @param url Base url to be used
9295
* @param serializedQueryParams Key value pairs of query parameter names and their serialized values
9396
*/
94-
prepareUrlWithQueryParams(url: string, serializedQueryParams: { [key: string]: string }): string;
97+
prepareUrlWithQueryParams(url: string, serializedQueryParams?: { [key: string]: string }): string;
9598

9699
/**
97100
* Serialize query parameters based on the values of exploded and style

packages/@ama-sdk/core/src/fwk/core/base-api-constructor.ts

+2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export interface BaseApiClientOptions {
2727
disableFallback?: boolean;
2828
/** Logger (optional, fallback to console logger if undefined) */
2929
logger?: Logger;
30+
/** Enable parameter serialization with exploded syntax */
31+
enableParameterSerialization?: boolean;
3032
/** Custom query parameter serialization method */
3133
serializeQueryParams?<T extends { [key: string]: SupportedParamType }>(queryParams: T, queryParamSerialization: { [p in keyof T]: ParamSerialization }): { [p in keyof T]: string };
3234
/** Custom query parameter serialization method */

packages/@ama-sdk/core/src/fwk/param-serialization.ts

+44-10
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ export type PrimitiveType = string | number | boolean | Date | utils.Date | util
1515
/** Supported types for the operation parameters - primitives, primitive arrays, and simple non-nested objects */
1616
export type SupportedParamType = PrimitiveType | PrimitiveType[] | { [key: string]: PrimitiveType };
1717

18+
/** URL encoding of space character, delimiter for spaceDelimited style */
19+
const SPACE_URL_CODE = encodeURIComponent(' ');
20+
/** URL encoding of pipe character, delimiter for pipeDelimited style */
21+
const PIPE_URL_CODE = encodeURIComponent('|');
22+
/** URL encoding of opening square bracket, used in deepObject style */
23+
const OPENING_SQUARE_BRACKET_URL_CODE = encodeURIComponent('[');
24+
/** URL encoding of closing square bracket, used in deepObject style */
25+
const CLOSING_SQUARE_BRACKET_URL_CODE = encodeURIComponent(']');
26+
1827
/**
1928
* Verify if property is of type utils.Date or utils.DateTime
2029
* @param prop
@@ -23,6 +32,31 @@ export function isDateType(prop: any): prop is Date | utils.Date | utils.DateTim
2332
return prop instanceof Date || prop instanceof utils.Date || prop instanceof utils.DateTime;
2433
}
2534

35+
/**
36+
* Check if the parameter is a record of type <string, string>.
37+
* @param param
38+
*/
39+
export function isParamValueRecord(param: any): param is { [key: string]: string } {
40+
return typeof param === 'object' && Object.values(param).every((item) => typeof item === 'string');
41+
}
42+
43+
/** Query parameter value and serialization */
44+
export type QueryParamValueSerialization = { value: SupportedParamType } & ParamSerialization;
45+
46+
/**
47+
* Serialize query parameters of request plugins
48+
* @param queryParams
49+
*/
50+
export function serializeRequestPluginQueryParams(queryParams: { [key: string]: QueryParamValueSerialization }) {
51+
const queryParamsValues: { [key: string]: SupportedParamType } = {};
52+
const queryParamSerialization: { [key: string]: ParamSerialization } = {};
53+
Object.entries(queryParams).forEach(([paramKey, paramValue]) => {
54+
queryParamsValues[paramKey] = paramValue.value;
55+
queryParamSerialization[paramKey] = { explode: paramValue.explode, style: paramValue.style };
56+
});
57+
return serializeQueryParams(queryParamsValues, queryParamSerialization);
58+
}
59+
2660
/**
2761
* Serialize query parameters of type array
2862
* OpenAPI Parameter Serialization {@link https://swagger.io/specification | documentation}
@@ -46,13 +80,13 @@ function serializeArrayQueryParams(queryParamName: string, queryParamValue: Prim
4680
if (emptyArray) {
4781
break;
4882
}
49-
return encodeURIComponent(queryParamName) + '=' + filteredArray.map((v) => isDateType(v) ? v.toJSON() : encodeURIComponent(v.toString())).join('%20');
83+
return encodeURIComponent(queryParamName) + '=' + filteredArray.map((v) => isDateType(v) ? v.toJSON() : encodeURIComponent(v.toString())).join(SPACE_URL_CODE);
5084
}
5185
case 'pipeDelimited': {
5286
if (emptyArray) {
5387
break;
5488
}
55-
return encodeURIComponent(queryParamName) + '=' + filteredArray.map((v) => isDateType(v) ? v.toJSON() : encodeURIComponent(v.toString())).join('%7C');
89+
return encodeURIComponent(queryParamName) + '=' + filteredArray.map((v) => isDateType(v) ? v.toJSON() : encodeURIComponent(v.toString())).join(PIPE_URL_CODE);
5690
}
5791
}
5892
}
@@ -81,15 +115,16 @@ function serializeObjectQueryParams(queryParamName: string, queryParamValue: { [
81115
encodeURIComponent(propName) + ',' + (isDateType(propValue) ? propValue.toJSON() : encodeURIComponent(propValue.toString()))).join(',');
82116
} else if (paramSerialization.style === 'spaceDelimited' && !paramSerialization.explode && !emptyObject) {
83117
return encodeURIComponent(queryParamName) + '=' + Object.entries(filteredObject).map(([propName, propValue]) =>
84-
encodeURIComponent(propName) + '%20' + (isDateType(propValue) ? propValue.toJSON() : encodeURIComponent(propValue.toString()))
85-
).join('%20');
118+
encodeURIComponent(propName) + SPACE_URL_CODE + (isDateType(propValue) ? propValue.toJSON() : encodeURIComponent(propValue.toString()))
119+
).join(SPACE_URL_CODE);
86120
} else if (paramSerialization.style === 'pipeDelimited' && !paramSerialization.explode && !emptyObject) {
87121
return encodeURIComponent(queryParamName) + '=' + Object.entries(filteredObject).map(([propName, propValue]) =>
88-
encodeURIComponent(propName) + '%7C' + (isDateType(propValue) ? propValue.toJSON() : encodeURIComponent(propValue.toString()))
89-
).join('%7C');
122+
encodeURIComponent(propName) + PIPE_URL_CODE + (isDateType(propValue) ? propValue.toJSON() : encodeURIComponent(propValue.toString()))
123+
).join(PIPE_URL_CODE);
90124
} else if (paramSerialization.style === 'deepObject' && paramSerialization.explode && !emptyObject) {
91125
return Object.entries(filteredObject).map(([propName, propValue]) =>
92-
encodeURIComponent(queryParamName) + '%5B' + encodeURIComponent(propName) + '%5D=' + (isDateType(propValue) ? propValue.toJSON() : encodeURIComponent(propValue.toString()))
126+
encodeURIComponent(queryParamName) + OPENING_SQUARE_BRACKET_URL_CODE + encodeURIComponent(propName) + CLOSING_SQUARE_BRACKET_URL_CODE + '='
127+
+ (isDateType(propValue) ? propValue.toJSON() : encodeURIComponent(propValue.toString()))
93128
).join('&');
94129
}
95130
}
@@ -110,9 +145,8 @@ export function serializeQueryParams<T extends { [key: string]: SupportedParamTy
110145
} else if (typeof queryParamValue === 'object' && !isDateType(queryParamValue)) {
111146
serializedValue = serializeObjectQueryParams(queryParamName, queryParamValue, paramSerialization);
112147
} else {
113-
if (paramSerialization.style === 'form') {
114-
serializedValue = encodeURIComponent(queryParamName) + '=' + (isDateType(queryParamValue) ? queryParamValue.toJSON() : encodeURIComponent(queryParamValue.toString()));
115-
}
148+
// NOTE: 'form' style is the default value for primitive types
149+
serializedValue = encodeURIComponent(queryParamName) + '=' + (isDateType(queryParamValue) ? queryParamValue.toJSON() : encodeURIComponent(queryParamValue.toString()));
116150
}
117151
if (serializedValue) {
118152
acc[queryParamName as keyof T] = serializedValue;

packages/@ama-sdk/core/src/plugins/additional-params/additional-params-sync.request.ts

+27-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1+
import {
2+
stringifyQueryParams,
3+
} from '../../fwk/api.helpers';
4+
import {
5+
isParamValueRecord,
6+
type QueryParamValueSerialization,
7+
serializeRequestPluginQueryParams,
8+
} from '../../fwk/param-serialization';
19
import {
210
PluginSyncRunner,
311
RequestOptions,
412
RequestPlugin,
13+
RequestPluginContext,
514
} from '../core';
615
import {
716
isStringOrUndefined,
@@ -11,7 +20,8 @@ export interface AdditionalParametersSync {
1120
/** Additional headers */
1221
headers?: { [key: string]: string } | ((headers: Headers) => { [key: string]: string });
1322
/** Additional query params */
14-
queryParams?: { [key: string]: string } | ((defaultValues?: { [key: string]: string }) => { [key: string]: string });
23+
queryParams?: { [key: string]: string } | { [key: string]: QueryParamValueSerialization }
24+
| ((defaultValues?: { [key: string]: string } | { [key: string]: QueryParamValueSerialization }) => { [key: string]: string } | { [key: string]: QueryParamValueSerialization });
1525
/** Additional body params */
1626
body?: (defaultValues?: string) => string | null;
1727
}
@@ -30,15 +40,29 @@ export class AdditionalParamsSyncRequest implements RequestPlugin {
3040
this.additionalParams = additionalParams;
3141
}
3242

33-
public load(): PluginSyncRunner<RequestOptions, RequestOptions> {
43+
public load(context?: RequestPluginContext): PluginSyncRunner<RequestOptions, RequestOptions> {
3444
return {
3545
transform: (data: RequestOptions) => {
3646
const queryParams = typeof this.additionalParams.queryParams === 'function' ? this.additionalParams.queryParams(data.queryParams) : this.additionalParams.queryParams;
3747
const headers = typeof this.additionalParams.headers === 'function' ? this.additionalParams.headers(data.headers) : this.additionalParams.headers;
3848
const body = this.additionalParams.body && isStringOrUndefined(data.body) ? this.additionalParams.body(data.body) : undefined;
3949

4050
if (queryParams) {
41-
data.queryParams = { ...data.queryParams, ...queryParams };
51+
if (data.paramSerializationOptions?.enableParameterSerialization) {
52+
if (isParamValueRecord(queryParams)) {
53+
throw new Error('It is not possible to serialize additional query parameters without their serialization properties `value`, `explode`, and `style`.');
54+
} else {
55+
data.queryParams = { ...data.queryParams, ...serializeRequestPluginQueryParams(queryParams) };
56+
}
57+
} else {
58+
if (isParamValueRecord(queryParams)) {
59+
data.queryParams = { ...data.queryParams, ...queryParams };
60+
} else {
61+
const queryParamsValues = Object.fromEntries(Object.entries(queryParams).map(([key, value]) => [key, value.value]));
62+
data.queryParams = { ...data.queryParams, ...stringifyQueryParams(queryParamsValues) };
63+
(context?.logger || console).log('The serialization of additional query parameters has been ignored since parameter serialization is not enabled.');
64+
}
65+
}
4266
}
4367

4468
if (body !== undefined) {

packages/@ama-sdk/core/src/plugins/additional-params/additional-params.request.ts

+29-3
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
1+
import {
2+
stringifyQueryParams,
3+
} from '../../fwk/api.helpers';
4+
import {
5+
isParamValueRecord,
6+
type QueryParamValueSerialization,
7+
serializeRequestPluginQueryParams,
8+
} from '../../fwk/param-serialization';
19
import {
210
PluginRunner,
311
RequestOptions,
412
RequestPlugin,
13+
RequestPluginContext,
514
} from '../core';
615

716
export interface AdditionalParameters {
817
/** Additional headers */
918
headers?: { [key: string]: string } | ((headers: Headers) => { [key: string]: string } | Promise<{ [key: string]: string }>);
1019
/** Additional query params */
11-
queryParams?: { [key: string]: string } | ((defaultValues?: { [key: string]: string }) => { [key: string]: string } | Promise<{ [key: string]: string }>);
20+
queryParams?: { [key: string]: string } | { [key: string]: QueryParamValueSerialization }
21+
| ((defaultValues?: { [key: string]: string } | { [key: string]: QueryParamValueSerialization }) =>
22+
{ [key: string]: string } | { [key: string]: QueryParamValueSerialization } | Promise<{ [key: string]: string }> | Promise<{ [key: string]: QueryParamValueSerialization }>
23+
);
1224
/** Additional body params */
1325
body?: (defaultValues?: string) => string | null | Promise<string>;
1426
}
@@ -37,15 +49,29 @@ export class AdditionalParamsRequest implements RequestPlugin {
3749
this.additionalParams = additionalParams;
3850
}
3951

40-
public load(): PluginRunner<RequestOptions, RequestOptions> {
52+
public load(context?: RequestPluginContext): PluginRunner<RequestOptions, RequestOptions> {
4153
return {
4254
transform: async (data: RequestOptions) => {
4355
const queryParams = typeof this.additionalParams.queryParams === 'function' ? await this.additionalParams.queryParams(data.queryParams) : this.additionalParams.queryParams;
4456
const headers = typeof this.additionalParams.headers === 'function' ? await this.additionalParams.headers(data.headers) : this.additionalParams.headers;
4557
const body = this.additionalParams.body && isStringOrUndefined(data.body) ? this.additionalParams.body(data.body) : undefined;
4658

4759
if (queryParams) {
48-
data.queryParams = { ...data.queryParams, ...queryParams };
60+
if (data.paramSerializationOptions?.enableParameterSerialization) {
61+
if (isParamValueRecord(queryParams)) {
62+
throw new Error('It is not possible to serialize additional query parameters without their serialization properties `value`, `explode`, and `style`.');
63+
} else {
64+
data.queryParams = { ...data.queryParams, ...serializeRequestPluginQueryParams(queryParams) };
65+
}
66+
} else {
67+
if (isParamValueRecord(queryParams)) {
68+
data.queryParams = { ...data.queryParams, ...queryParams };
69+
} else {
70+
const queryParamsValues = Object.fromEntries(Object.entries(queryParams).map(([key, value]) => [key, value.value]));
71+
data.queryParams = { ...data.queryParams, ...stringifyQueryParams(queryParamsValues) };
72+
(context?.logger || console).log('The serialization of additional query parameters has been ignored since parameter serialization is not enabled.');
73+
}
74+
}
4975
}
5076

5177
if (body !== undefined) {

0 commit comments

Comments
 (0)