Skip to content

feat: parameter deserialization #3116

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions packages/@ama-sdk/core/src/fwk/param-deserialization.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
deserializePathParams,
deserializeQueryParams,
} from './param-deserialization';

describe('Deserialize parameters', () => {
it('should correctly deserialize query parameters of type primitive', () => {
expect(deserializeQueryParams({ idPrimitive: 'idPrimitive=5' }, { idPrimitive: { explode: true, style: 'form', paramType: 'primitive' } })).toEqual({ idPrimitive: '5' });
expect(deserializeQueryParams({ idPrimitive: 'idPrimitive=5' }, { idPrimitive: { explode: false, style: 'form', paramType: 'primitive' } })).toEqual({ idPrimitive: '5' });
});

it('should correctly deserialize query parameters of type array', () => {
expect(deserializeQueryParams({ idArray: 'idArray=3&idArray=4&idArray=5' }, { idArray: { explode: true, style: 'form', paramType: 'array' } })).toEqual({ idArray: ['3', '4', '5'] });
expect(deserializeQueryParams({ idArray: 'idArray=3,4,5' }, { idArray: { explode: false, style: 'form', paramType: 'array' } })).toEqual({ idArray: ['3', '4', '5'] });
expect(deserializeQueryParams({ idArray: 'should-not-work' }, { idArray: { explode: true, style: 'spaceDelimited', paramType: 'array' } })).toEqual({ idArray: undefined });
Copy link
Contributor

Choose a reason for hiding this comment

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

should this return undefined or throw an error?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this should return undefined, we don't throw errors for non-supported use cases

Copy link
Contributor

Choose a reason for hiding this comment

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

I would expect a function like this to throw when the format is not recognized, but I'm ok to return undefined

expect(deserializeQueryParams({ idArray: 'idArray=3%204%205' }, { idArray: { explode: false, style: 'spaceDelimited', paramType: 'array' } })).toEqual({ idArray: ['3', '4', '5'] });
expect(deserializeQueryParams({ idArray: 'should-not-work' }, { idArray: { explode: true, style: 'pipeDelimited', paramType: 'array' } })).toEqual({ idArray: undefined });
expect(deserializeQueryParams({ idArray: 'idArray=3%7C4%7C5' }, { idArray: { explode: false, style: 'pipeDelimited', paramType: 'array' } })).toEqual({ idArray: ['3', '4', '5'] });
});

it('should correctly deserialize query parameters of type object', () => {
expect(deserializeQueryParams({ idObject: 'role=admin&firstName=Alex' }, { idObject: { explode: true, style: 'form', paramType: 'object' } }))
.toEqual({ idObject: { role: 'admin', firstName: 'Alex' } });
expect(deserializeQueryParams({ idObject: 'idObject=role,admin,firstName,Alex' }, { idObject: { explode: false, style: 'form', paramType: 'object' } }))
.toEqual({ idObject: { role: 'admin', firstName: 'Alex' } });
expect(deserializeQueryParams({ idObject: 'should-not-work' }, { idObject: { explode: true, style: 'spaceDelimited', paramType: 'object' } })).toEqual({ idObject: undefined });
expect(deserializeQueryParams({ idObject: 'idObject=role%20admin%20firstName%20Alex' }, { idObject: { explode: false, style: 'spaceDelimited', paramType: 'object' } }))
.toEqual({ idObject: { role: 'admin', firstName: 'Alex' } });
expect(deserializeQueryParams({ idObject: 'should-not-work' }, { idObject: { explode: true, style: 'pipeDelimited', paramType: 'object' } })).toEqual({ idObject: undefined });
expect(deserializeQueryParams({ idObject: 'idObject=role%7Cadmin%7CfirstName%7CAlex' }, { idObject: { explode: false, style: 'pipeDelimited', paramType: 'object' } }))
.toEqual({ idObject: { role: 'admin', firstName: 'Alex' } });
expect(deserializeQueryParams({ idObject: 'idObject%5Brole%5D=admin&idObject%5BfirstName%5D=Alex' }, { idObject: { explode: true, style: 'deepObject', paramType: 'object' } }))
.toEqual({ idObject: { role: 'admin', firstName: 'Alex' } });
expect(deserializeQueryParams({ idObject: 'should-not-work' }, { idObject: { explode: false, style: 'deepObject', paramType: 'object' } })).toEqual({ idObject: undefined });
});

it('should correctly deserialize path parameters of type primitive', () => {
expect(deserializePathParams({ idPrimitive: '5' }, { idPrimitive: { explode: true, style: 'simple', paramType: 'primitive' } })).toEqual({ idPrimitive: '5' });
expect(deserializePathParams({ idPrimitive: '5' }, { idPrimitive: { explode: false, style: 'simple', paramType: 'primitive' } })).toEqual({ idPrimitive: '5' });
expect(deserializePathParams({ idPrimitive: '.5' }, { idPrimitive: { explode: true, style: 'label', paramType: 'primitive' } })).toEqual({ idPrimitive: '5' });
expect(deserializePathParams({ idPrimitive: '.5' }, { idPrimitive: { explode: false, style: 'label', paramType: 'primitive' } })).toEqual({ idPrimitive: '5' });
expect(deserializePathParams({ idPrimitive: ';idPrimitive=5' }, { idPrimitive: { explode: true, style: 'matrix', paramType: 'primitive' } })).toEqual({ idPrimitive: '5' });
expect(deserializePathParams({ idPrimitive: ';idPrimitive=5' }, { idPrimitive: { explode: false, style: 'matrix', paramType: 'primitive' } })).toEqual({ idPrimitive: '5' });
});

it('should correctly deserialize path parameters of type array', () => {
expect(deserializePathParams({ idArray: '3,4,5' }, { idArray: { explode: true, style: 'simple', paramType: 'array' } })).toEqual({ idArray: ['3', '4', '5'] });
expect(deserializePathParams({ idArray: '3,4,5' }, { idArray: { explode: false, style: 'simple', paramType: 'array' } })).toEqual({ idArray: ['3', '4', '5'] });
expect(deserializePathParams({ idArray: '.3.4.5' }, { idArray: { explode: true, style: 'label', paramType: 'array' } })).toEqual({ idArray: ['3', '4', '5'] });
expect(deserializePathParams({ idArray: '.3,4,5' }, { idArray: { explode: false, style: 'label', paramType: 'array' } })).toEqual({ idArray: ['3', '4', '5'] });
expect(deserializePathParams({ idArray: ';idArray=3;idArray=4;idArray=5' }, { idArray: { explode: true, style: 'matrix', paramType: 'array' } })).toEqual({ idArray: ['3', '4', '5'] });
expect(deserializePathParams({ idArray: ';idArray=3,4,5' }, { idArray: { explode: false, style: 'matrix', paramType: 'array' } })).toEqual({ idArray: ['3', '4', '5'] });
});

it('should correctly deserialize path parameters of type object', () => {
expect(deserializePathParams({ idObject: 'role=admin,firstName=Alex' }, { idObject: { explode: true, style: 'simple', paramType: 'object' } }))
.toEqual({ idObject: { role: 'admin', firstName: 'Alex' } });
expect(deserializePathParams({ idObject: 'role,admin,firstName,Alex' }, { idObject: { explode: false, style: 'simple', paramType: 'object' } }))
.toEqual({ idObject: { role: 'admin', firstName: 'Alex' } });
expect(deserializePathParams({ idObject: '.role=admin.firstName=Alex' }, { idObject: { explode: true, style: 'label', paramType: 'object' } }))
.toEqual({ idObject: { role: 'admin', firstName: 'Alex' } });
expect(deserializePathParams({ idObject: '.role,admin,firstName,Alex' }, { idObject: { explode: false, style: 'label', paramType: 'object' } }))
.toEqual({ idObject: { role: 'admin', firstName: 'Alex' } });
expect(deserializePathParams({ idObject: ';role=admin;firstName=Alex' }, { idObject: { explode: true, style: 'matrix', paramType: 'object' } }))
.toEqual({ idObject: { role: 'admin', firstName: 'Alex' } });
expect(deserializePathParams({ idObject: ';idObject=role,admin,firstName,Alex' }, { idObject: { explode: false, style: 'matrix', paramType: 'object' } }))
.toEqual({ idObject: { role: 'admin', firstName: 'Alex' } });
});
});
212 changes: 212 additions & 0 deletions packages/@ama-sdk/core/src/fwk/param-deserialization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import {
CLOSING_SQUARE_BRACKET_URL_CODE,
OPENING_SQUARE_BRACKET_URL_CODE,
type ParamSerialization,
PIPE_URL_CODE,
type PrimitiveType,
SPACE_URL_CODE,
type SupportedParamType,
} from './param-serialization';

/** Specification of the parameter type for parameter deserialization */
export type ParamTypeForDeserialization = 'primitive' | 'array' | 'object';

/**
* Split parameter elements by delimiter based on the serialization style (with removal of the prefix)
* @param serializedParamValue serialized parameter value
* @param paramSerialization parameter serialization
*/
function splitParamElements(serializedParamValue: string, paramSerialization: ParamSerialization) {
switch (paramSerialization.style) {
case 'simple': {
return serializedParamValue.split(',').map((value) => decodeURIComponent(value));
}
case 'label': {
// NOTE: Path parameters of style label are prefixed with a '.'
return serializedParamValue.substring(1).split(paramSerialization.explode ? '.' : ',').map((value) => decodeURIComponent(value));
}
case 'matrix': {
// NOTE: Path parameters of style matrix and exploded true are prefixed with a ';paramName='
// NOTE: Path parameters of style matrix and exploded false are written like this: ';paramName=value1;paramName=value2'
return paramSerialization.explode
? serializedParamValue.substring(1).split(';').map((value) => decodeURIComponent(value.split('=')[1]))
: serializedParamValue.split('=')[1].split(',').map((value) => decodeURIComponent(value));
}
case 'form': {
// NOTE: Query parameters of style form and explode true are written like this: 'paramName=value1&paramName=value2&paramName=value3'
// NOTE: Query parameters of style form and explode false are prefixed with the parameter name and delimited by a ','
return paramSerialization.explode
? serializedParamValue.split('&').map((value) => decodeURIComponent(value.split('=')[1]))
: serializedParamValue.split('=')[1].split(',').map((value) => decodeURIComponent(value));
}
case 'spaceDelimited': {
// NOTE: Query parameters of style spaceDelimited and explode false are prefixed with the parameter name and delimited by the encoded space character
// Here is an example of the format: 'paramName=value1%20value2%20value3'
return paramSerialization.explode ? undefined : serializedParamValue.split('=')[1].split(SPACE_URL_CODE).map((value) => decodeURIComponent(value));
}
case 'pipeDelimited': {
// NOTE: Query parameters of style spaceDelimited and explode false are prefixed with the parameter name and delimited by the encoded pipe character
// Here is an example of the format: 'paramName=value1%7Cvalue2%7Cvalue3'
return paramSerialization.explode ? undefined : serializedParamValue.split('=')[1].split(PIPE_URL_CODE).map((value) => decodeURIComponent(value));
}
}
}

function objectFromPairwiseArray(splitObject: string[]) {
return splitObject.reduce((obj: { [key: string]: PrimitiveType }, currentValue, index, array) => {
// NOTE: Every other item of the array is a key and the following item is the corresponding value
if (index % 2 === 0) {
obj[decodeURIComponent(currentValue)] = decodeURIComponent(array[index + 1]);
}
return obj;
}, {} as { [key: string]: PrimitiveType });
}

/**
* Deserialize query parameters of type array
* OpenAPI Parameter Serialization {@link https://swagger.io/specification | documentation}
* @param serializedParamValue serialized query parameter value
* @param paramSerialization parameter serialization
*/
function deserializeArrayQueryParams(serializedParamValue: string, paramSerialization: ParamSerialization) {
return splitParamElements(serializedParamValue, paramSerialization);
}

/**
* Deserialize query parameters of type object
* OpenAPI Parameter Serialization {@link https://swagger.io/specification | documentation}
* @param serializedParamValue serialized query parameter value
* @param paramSerialization parameter serialization
*/
function deserializeObjectQueryParams(serializedParamValue: string, paramSerialization: ParamSerialization) {
// NOTE: Applies to the exploded styles 'form' and 'deepObject'
if (paramSerialization.explode && (paramSerialization.style === 'form' || paramSerialization.style === 'deepObject')) {
return serializedParamValue.split('&').reduce((obj: { [key: string]: PrimitiveType }, serializedProperty) => {
const [key, value] = serializedProperty.split('=');
// NOTE: The key of an object in deepObject style is surrounded by opening and closing square brackets
const objKey = paramSerialization.style === 'deepObject' ? key.split(OPENING_SQUARE_BRACKET_URL_CODE)[1].split(CLOSING_SQUARE_BRACKET_URL_CODE)[0] : key;
obj[decodeURIComponent(objKey)] = decodeURIComponent(value);
return obj;
}, {} as { [key: string]: PrimitiveType });
}

// NOTE: Applies to the non-exploded styles 'form', 'spaceDelimited', and 'pipeDelimited'
if (paramSerialization.style !== 'deepObject' && !paramSerialization.explode) {
// NOTE: The splitParamElements function is called since object query parameters can be split by delimiters based on the serialization style
// NOTE: The deserialized value will exist since these exploded styles are supported
const splitObject = splitParamElements(serializedParamValue, paramSerialization);
return objectFromPairwiseArray(splitObject!);
}
}

/**
* Deserialize query parameters based on the values of exploded and style and the parameter type
* OpenAPI Parameter Serialization {@link https://swagger.io/specification | documentation}
* @param serializedQueryParams serialized query parameters
* @param queryParamSerialization query parameter serialization
*/
export function deserializeQueryParams<T extends { [key: string]: string }>(
serializedQueryParams: T,
queryParamSerialization: { [p in keyof T]: ParamSerialization & { paramType: ParamTypeForDeserialization } }
): { [p in keyof T]: SupportedParamType } {
return Object.entries(serializedQueryParams).reduce((acc, [queryParamName, serializedParamValue]) => {
const paramSerialization = queryParamSerialization[queryParamName];
let deserializedValue: SupportedParamType;
if (paramSerialization.paramType === 'array') {
deserializedValue = deserializeArrayQueryParams(serializedParamValue, paramSerialization);
} else if (paramSerialization.paramType === 'object') {
deserializedValue = deserializeObjectQueryParams(serializedParamValue, paramSerialization);
} else {
// NOTE: Query parameters of type primitive are prefixed with the parameter name like this: 'paramName=value'
deserializedValue = decodeURIComponent(serializedParamValue.split('=')[1]);
}
if (deserializedValue) {
acc[queryParamName as keyof T] = deserializedValue;
}
return acc;
}, {} as { [p in keyof T]: SupportedParamType });
Copy link
Contributor

Choose a reason for hiding this comment

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

the type is a bit incorrect as this stage (accept if T is {}).
Maybe it would be more something like:

Suggested change
}, {} as { [p in keyof T]: SupportedParamType });
}, {} as Record<string, SupportedParamType>) as { [p in keyof T]: SupportedParamType };

Copy link
Contributor Author

@sdo-1A sdo-1A Apr 30, 2025

Choose a reason for hiding this comment

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

Isn't it fine to accept {} ? Because we have conditions in the functions to avoid unsupported use cases so it possible that we return an empty object
If I change the type to Record<string, SupportedParamType>, we could not return an empty object {}

Copy link
Contributor

Choose a reason for hiding this comment

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

{ [p in keyof T]: SupportedParamType } means that all fields of the object T should be present in your object. So if T has more than 1 field, the code {} as { [p in keyof T]: SupportedParamType } is wrong.

The purpose is not to change the return type (which remains { [p in keyof T]: SupportedParamType }) but the acc type to avoid wrong typing.

}

/**
* Deserialize path parameters of type primitive
* OpenAPI Parameter Serialization {@link https://swagger.io/specification | documentation}
* @param serializedParamValue serialized path parameter value
* @param paramSerialization parameter serialization
*/
function deserializePrimitivePathParams(serializedParamValue: string, paramSerialization: ParamSerialization) {
switch (paramSerialization.style) {
case 'simple': {
return decodeURIComponent(serializedParamValue);
}
case 'label': {
// NOTE: Path parameters of style label are prefixed with a '.'
return decodeURIComponent(serializedParamValue.substring(1));
}
case 'matrix': {
return decodeURIComponent(serializedParamValue.substring(1).split('=')[1]);
}
}
}

/**
* Deserialize path parameters of type array
* OpenAPI Parameter Serialization {@link https://swagger.io/specification | documentation}
* @param serializedParamValue serialized path parameter value
* @param paramSerialization parameter serialization
*/
function deserializeArrayPathParams(serializedParamValue: string, paramSerialization: ParamSerialization) {
return splitParamElements(serializedParamValue, paramSerialization);
}

/**
* Deserialize path parameters of type object
* OpenAPI Parameter Serialization {@link https://swagger.io/specification | documentation}
* @param serializedParamValue serialized path parameter value
* @param paramSerialization parameter serialization
*/
function deserializeObjectPathParams(serializedParamValue: string, paramSerialization: ParamSerialization) {
// NOTE: The splitParamElements function is called since object path parameters can be split by delimiters based on the serialization style
// NOTE: There is an exception for path parameters of exploded style 'matrix' which is not serialized like its corresponding array
// This exception is serialized like this (prefixed by a ';'): ';prop1=value1;prop2=value2'
const splitObject: string[] = paramSerialization.style === 'matrix' && paramSerialization.explode
? serializedParamValue.substring(1).split(';').map((value) => decodeURIComponent(value))
: splitParamElements(serializedParamValue, paramSerialization)!;

// NOTE: Object path parameters that are exploded are serialized as 'prop=value'
if (paramSerialization.explode) {
return splitObject.reduce((obj: { [key: string]: PrimitiveType }, serializedProperty) => {
const [key, value] = serializedProperty.split('=').map((v) => decodeURIComponent(v));
obj[key] = value;
return obj;
}, {} as { [key: string]: PrimitiveType });
}

return objectFromPairwiseArray(splitObject);
}

/**
* Deserialize path parameters based on the values of exploded and style and the parameter type
* OpenAPI Parameter Serialization {@link https://swagger.io/specification | documentation}
* @param serializedPathParams serialized path parameters
* @param pathParamSerialization path parameter serialization
*/
export function deserializePathParams<T extends { [key: string]: string }>(
serializedPathParams: T,
pathParamSerialization: { [p in keyof T]: ParamSerialization & { paramType: ParamTypeForDeserialization } }
): { [p in keyof T]: SupportedParamType } {
return Object.entries(serializedPathParams).reduce((acc, [pathParamName, serializedParamValue]) => {
const paramSerialization = pathParamSerialization[pathParamName];
let deserializedValue: SupportedParamType;
if (paramSerialization.paramType === 'array') {
deserializedValue = deserializeArrayPathParams(serializedParamValue, paramSerialization);
} else if (paramSerialization.paramType === 'object') {
deserializedValue = deserializeObjectPathParams(serializedParamValue, paramSerialization);
} else {
deserializedValue = deserializePrimitivePathParams(serializedParamValue, paramSerialization);
}
if (deserializedValue) {
acc[pathParamName as keyof T] = deserializedValue;
}
return acc;
}, {} as { [p in keyof T]: SupportedParamType });
Copy link
Contributor

@kpanot kpanot Apr 11, 2025

Choose a reason for hiding this comment

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

Same comment as above:

Suggested change
}, {} as { [p in keyof T]: SupportedParamType });
}, {} as Record<string, SupportedParamType>);

}
8 changes: 4 additions & 4 deletions packages/@ama-sdk/core/src/fwk/param-serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ export type PrimitiveType = string | number | boolean | Date | utils.Date | util
export type SupportedParamType = PrimitiveType | PrimitiveType[] | { [key: string]: PrimitiveType };

/** URL encoding of space character, delimiter for spaceDelimited style */
const SPACE_URL_CODE = encodeURIComponent(' ');
export const SPACE_URL_CODE = encodeURIComponent(' ');
/** URL encoding of pipe character, delimiter for pipeDelimited style */
const PIPE_URL_CODE = encodeURIComponent('|');
export const PIPE_URL_CODE = encodeURIComponent('|');
/** URL encoding of opening square bracket, used in deepObject style */
const OPENING_SQUARE_BRACKET_URL_CODE = encodeURIComponent('[');
export const OPENING_SQUARE_BRACKET_URL_CODE = encodeURIComponent('[');
/** URL encoding of closing square bracket, used in deepObject style */
const CLOSING_SQUARE_BRACKET_URL_CODE = encodeURIComponent(']');
export const CLOSING_SQUARE_BRACKET_URL_CODE = encodeURIComponent(']');

/**
* Verify if property is of type utils.Date or utils.DateTime
Expand Down
Loading
Loading