Skip to content
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

feat(oas): support application/x-www-form-urlencoded OAS style serialization #227

Open
wants to merge 3 commits into
base: master
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
17 changes: 7 additions & 10 deletions packages/oas/src/converter/parts/postdata/BodyConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { Sampler } from '../../Sampler';
import type { SubConverter } from '../../SubConverter';
import { XmlSerializer } from '../../serializers';
import type { OpenAPI, OpenAPIV2, OpenAPIV3, PostData } from '@har-sdk/core';
import { stringify } from 'qs';

export interface EncodingData {
value: unknown;
Expand Down Expand Up @@ -38,6 +37,12 @@ export abstract class BodyConverter<T extends OpenAPI.Document>
method: string
): string | undefined;

protected abstract encodeFormUrlencoded(
value: unknown,
fields?: Record<string, OpenAPIV3.EncodingObject>,
schema?: OpenAPIV3.SchemaObject | OpenAPIV2.SchemaObject
Comment on lines +41 to +43
Copy link
Member

Choose a reason for hiding this comment

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

Convert args to an object and introduce interface

): string;

protected encodePayload({ contentType, ...options }: EncodingData): PostData {
return {
mimeType: contentType.includes('multipart')
Expand Down Expand Up @@ -70,7 +75,7 @@ export abstract class BodyConverter<T extends OpenAPI.Document>
case 'application/json':
return this.encodeJson(value);
case 'application/x-www-form-urlencoded':
return this.encodeFormUrlencoded(value);
return this.encodeFormUrlencoded(value, fields, schema);
case 'application/xml':
case 'text/xml':
case 'application/atom+xml':
Expand Down Expand Up @@ -188,14 +193,6 @@ export abstract class BodyConverter<T extends OpenAPI.Document>
return typeof value === 'string' ? value : JSON.stringify(value);
}

// TODO: we should take into account the the Encoding Object's style property (OAS3)
private encodeFormUrlencoded(value: unknown): string {
return stringify(value, {
format: 'RFC3986',
encode: false
});
}

private encodeXml(
data: unknown,
schema: OpenAPIV3.SchemaObject | OpenAPIV2.SchemaObject
Expand Down
13 changes: 13 additions & 0 deletions packages/oas/src/converter/parts/postdata/Oas2BodyConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { Sampler } from '../../Sampler';
import { filterLocationParams, getParameters } from '../../../utils';
import { Oas2MediaTypesResolver } from '../Oas2MediaTypesResolver';
import type { OpenAPIV2, PostData } from '@har-sdk/core';
import { OpenAPIV3 } from '@har-sdk/core';
import { stringify } from 'qs';

export class Oas2BodyConverter extends BodyConverter<OpenAPIV2.Document> {
private readonly oas2MediaTypeResolver: Oas2MediaTypesResolver;
Expand Down Expand Up @@ -45,6 +47,17 @@ export class Oas2BodyConverter extends BodyConverter<OpenAPIV2.Document> {
});
}

protected encodeFormUrlencoded(
value: unknown,
_fields?: Record<string, OpenAPIV3.EncodingObject>,
_schema?: OpenAPIV3.SchemaObject | OpenAPIV2.SchemaObject
): string {
return stringify(value, {
format: 'RFC3986',
encode: false
});
}

private convertFormData(
params: OpenAPIV2.ParameterObject[],
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { BodyConverter } from './BodyConverter';
import type { Sampler } from '../../Sampler';
import { FormUrlEncodedMediaTypeEncoder } from './encoders/FormUrlEncodedMediaTypeEncoder';
import type { OpenAPIV3, PostData } from '@har-sdk/core';
import { OpenAPIV2 } from '@har-sdk/core';
import pointer from 'json-pointer';

export class Oas3RequestBodyConverter extends BodyConverter<OpenAPIV3.Document> {
private readonly formUrlEncodedEncoder = new FormUrlEncodedMediaTypeEncoder();
constructor(spec: OpenAPIV3.Document, sampler: Sampler) {
super(spec, sampler);
}
Expand Down Expand Up @@ -43,6 +46,18 @@ export class Oas3RequestBodyConverter extends BodyConverter<OpenAPIV3.Document>
});
}

protected encodeFormUrlencoded(
value: unknown,
fields?: Record<string, OpenAPIV3.EncodingObject>,
schema?: OpenAPIV3.SchemaObject | OpenAPIV2.SchemaObject
): string {
return this.formUrlEncodedEncoder.encode({
fields,
value,
schema
});
}

private sampleAndEncodeRequestBody({
media,
tokens,
Expand Down Expand Up @@ -84,13 +99,15 @@ export class Oas3RequestBodyConverter extends BodyConverter<OpenAPIV3.Document>
Object.entries(mediaType.encoding ?? {}).map(
([property, encoding]: [string, OpenAPIV3.EncodingObject]) => [
property,
this.encodeValue({
value: data[property],
contentType: encoding.contentType,
schema: (mediaType.schema as OpenAPIV3.SchemaObject).properties[
property
]
})
encoding.contentType
? this.encodeValue({
Copy link
Member

Choose a reason for hiding this comment

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

The encodeValue should manage this scenario. Ensure it is able to accept undefined as a valid contentType.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There is a possibility to encode some primitive or array to string. See (#227 (comment)) which will prevent correct serialization due to loss of type info

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense. However, you should figure out how to handle the contentMediaType in this case.

value: data[property],
contentType: encoding.contentType,
schema: (mediaType.schema as OpenAPIV3.SchemaObject).properties[
property
]
})
: data[property]
]
)
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
OasStyleSerializer,
OasStyleSerializerType
} from '../style-serializers/OasStyleSerializer';
import { isObject } from '../../../../utils';
import { isPrimitive } from '../../../../utils/isPrimitive';
import { isArrayOfPrimitives } from '../../../../utils/isArrayOfPrimitives';
import { OasDeepObjectStyleSerializer } from '../style-serializers/OasDeepObjectStyleSerializer';
import { OasDefaultStylesSerializer } from '../style-serializers/OasDefaultStylesSerializer';
import { OpenAPIV2, type OpenAPIV3 } from '@har-sdk/core';

export class FormUrlEncodedMediaTypeEncoder {
constructor(
private readonly oasStyleSerializers: OasStyleSerializer[] = [
new OasDeepObjectStyleSerializer(),
new OasDefaultStylesSerializer()
]
) {}

public encode(encodingData: {
value: unknown;
fields?: Record<string, OpenAPIV3.EncodingObject>;
schema?: OpenAPIV3.SchemaObject | OpenAPIV2.SchemaObject;
}): string {
const { value, fields } = encodingData;

if (!isObject(value) || Array.isArray(value)) {
return '';
}

const entries = Object.entries(value)
.map(([key, val]: [string, unknown]) => {
const serializationParams = this.getSerializationParams(
(fields ? fields[key] : undefined) ?? {}
);

const { style, explode, allowReserved } = serializationParams;

const stringifyObject =
style === OasStyleSerializerType.FORM &&
!(isPrimitive(val) || isArrayOfPrimitives(val));

return this.findSerializer(style)?.serialize(
{
key,
value: stringifyObject ? JSON.stringify(val) : val
Copy link
Member

Choose a reason for hiding this comment

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

When dealing with complex values that are not arrays of primitive types or simple objects with primitive fields, you have to rely on the contentType. It is not necessary a JSON

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, when contentType is specified it has already handled in Oas3RequestBodyConverter.encodeProperties and thus here we receive string primitive.

Copy link
Member

Choose a reason for hiding this comment

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

The contentMediaType can be present in the schema itself, which is intended to be handled if there are no encodings or media type object keys. Even beyond this, the behavior for nested objects and arrays is undefined in the specification; it is better to simply ignore values in such cases to avoid API misbehaviour.

},
{ allowReserved, explode, style }
);
})
.filter(Boolean);

return entries.join('&');
}

private findSerializer(style: OasStyleSerializerType) {
return this.oasStyleSerializers
.filter((x) => x.supportsStyle(style))
.shift();
}

private getSerializationParams(encodingObject: OpenAPIV3.EncodingObject) {
const [encodingObjectStyle]: OasStyleSerializerType[] = Object.values(
OasStyleSerializerType
).filter((x) => x === encodingObject.style);

const style = encodingObjectStyle ?? OasStyleSerializerType.FORM;

return {
style,
explode: encodingObject.explode ?? false,
allowReserved: encodingObject.allowReserved ?? false,
contentType: encodingObject.contentType
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
OasStyleSerializer,
type OasStyleSerializerData,
type OasStyleSerializerOptions,
OasStyleSerializerType
} from './OasStyleSerializer';
import { isPrimitive } from '../../../../utils/isPrimitive';
import { UriTemplator } from '../../UriTemplator';
import { decodeReserved } from '../../../../utils/decodeReserved';

export class OasDeepObjectStyleSerializer implements OasStyleSerializer {
public supportsStyle(style: OasStyleSerializerType): boolean {
return OasStyleSerializerType.DEEP_OBJECT === style;
}

public serialize(
{ key, value }: OasStyleSerializerData,
{ style, explode, allowReserved }: OasStyleSerializerOptions
): string {
if (!this.supportsStyle(style) || !this.shouldSerialize(value, explode)) {
return '';
}

const result = Object.entries(value).reduce(
(acc, [prop, val]: [string, unknown]) => {
const keyValue = new UriTemplator()
.substitute(`?${key}[${prop}]={val}`, {
val: val ?? ''
})
.substring(1);

if (keyValue.length) {
const prefix = acc ? `${acc}&` : '';

return `${prefix}${keyValue}`;
}

return acc;
},
''
);

return allowReserved ? decodeReserved(result) : result;
}

private shouldSerialize(value: unknown, explode: boolean): value is object {
return !!value && explode && !Array.isArray(value) && !isPrimitive(value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
OasStyleSerializer,
type OasStyleSerializerData,
type OasStyleSerializerOptions,
OasStyleSerializerType
} from './OasStyleSerializer';
import { isArrayOfPrimitives } from '../../../../utils/isArrayOfPrimitives';
import { UriTemplator } from '../../UriTemplator';
import { decodeReserved } from '../../../../utils/decodeReserved';

export class OasDefaultStylesSerializer implements OasStyleSerializer {
private readonly CUSTOM_DELIMITERS: ReadonlyMap<
OasStyleSerializerType,
string
> = new Map<OasStyleSerializerType, string>([
[OasStyleSerializerType.PIPE_DELIMITED, '|'],
[OasStyleSerializerType.SPACE_DELIMITED, '%20']
]);

public supportsStyle(style: OasStyleSerializerType): boolean {
return [
OasStyleSerializerType.PIPE_DELIMITED,
OasStyleSerializerType.SPACE_DELIMITED,
OasStyleSerializerType.FORM
].includes(style);
}

public serialize(
{ key, value }: OasStyleSerializerData,
{ explode, allowReserved, style }: OasStyleSerializerOptions
): string {
if (
!this.supportsStyle(style) ||
this.isNonArrayValueForArrayStyle(style, value)
) {
return '';
}

const template = this.getTemplateString(key, explode, style);

let result = new UriTemplator()
.substitute(template, {
[key]: value
})
.substring(1);

result = this.replaceDelimiter(style, result);

return allowReserved ? decodeReserved(result) : result;
}

private isNonArrayValueForArrayStyle(
style: OasStyleSerializerType,
value: unknown
) {
return (
[
OasStyleSerializerType.PIPE_DELIMITED,
OasStyleSerializerType.SPACE_DELIMITED
].includes(style) && !isArrayOfPrimitives(value)
);
}

private replaceDelimiter(style: OasStyleSerializerType, result: string) {
const customDelimiter = this.CUSTOM_DELIMITERS.get(style);

return customDelimiter ? result.replace(/,/g, customDelimiter) : result;
}

private getTemplateString(
key: string,
explode: boolean,
style?: OasStyleSerializerType
) {
const suffix = explode && style === OasStyleSerializerType.FORM ? '*' : '';

return `{?${key}${suffix}}`;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export enum OasStyleSerializerType {
FORM = 'form',
SPACE_DELIMITED = 'spaceDelimited',
PIPE_DELIMITED = 'pipeDelimited',
DEEP_OBJECT = 'deepObject'
}

export interface OasStyleSerializerData {
key: string;
value: unknown;
}

export interface OasStyleSerializerOptions {
explode: boolean;
allowReserved: boolean;
style: OasStyleSerializerType;
}

export interface OasStyleSerializer {
supportsStyle(style: OasStyleSerializerType): boolean;

serialize(
serializerData: OasStyleSerializerData,
serializerOptions: OasStyleSerializerOptions
): string;
}
10 changes: 10 additions & 0 deletions packages/oas/src/utils/decodeReserved.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const specialChars = new Map(
Array.from(":/?#[]@!$&'()*+,;=").map((c) => [c.charCodeAt(0), c])
);

export const decodeReserved = (val: string) =>
val.replace(/%[0-9A-Fa-f]{2}/g, (match) => {
const decodedChar = specialChars.get(parseInt(match.substring(1), 16));

return decodedChar ? decodedChar : match;
});
4 changes: 4 additions & 0 deletions packages/oas/src/utils/isArrayOfPrimitives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { isPrimitive } from './isPrimitive';

export const isArrayOfPrimitives = (value: unknown): boolean =>
Array.isArray(value) && value.every((item: unknown) => isPrimitive(item));
7 changes: 7 additions & 0 deletions packages/oas/src/utils/isPrimitive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const isPrimitive = (
value: unknown
): value is null | undefined | string | boolean | number | bigint | symbol =>
// ADHOC: typeof null === 'object'
value === null ||
value === undefined ||
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
value === undefined ||
typeof value === 'undefined' ||

(typeof value !== 'object' && typeof value !== 'function');
5 changes: 5 additions & 0 deletions packages/oas/tests/DefaultConverter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ describe('DefaultConverter', () => {
input: 'cookies.oas.yaml',
expected: 'cookies.oas.result.json',
message: 'should properly serialize cookies (oas)'
},
{
input: 'params-body-form-url-encoded.oas.yaml',
expected: 'params-body-form-url-encoded.oas.result.json',
message: 'should properly serialize form-url-encoded body (oas)'
}
].forEach(({ input: inputFile, expected: expectedFile, message }) => {
it(message, async () => {
Expand Down
Loading
Loading