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: Support mapping repeated keys to array in form-data #2654

Open
wants to merge 4 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
2 changes: 1 addition & 1 deletion packages/http/src/mocker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ function parseBodyIfUrlEncoded(request: IHttpRequest, resource: IHttpOperation)
mediaType === 'multipart/form-data'
? parseMultipartFormDataParams(requestBody, multipartBoundary)
: splitUriParams(requestBody),
E.getOrElse<IPrismDiagnostic[], Dictionary<string>>(() => ({} as Dictionary<string>))
E.getOrElse<IPrismDiagnostic[], Dictionary<string | string[]>>(() => ({} as Dictionary<string | string[]>))
);

if (specs.length < 1) {
Expand Down
80 changes: 59 additions & 21 deletions packages/http/src/validator/validators/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { wildcardMediaTypeMatch } from '../utils/wildcardMediaTypeMatch';
export function deserializeFormBody(
schema: JSONSchema,
encodings: IHttpEncoding[],
decodedUriParams: Dictionary<string>
decodedUriParams: Dictionary<string | string[]>
) {
if (!schema.properties) {
return E.right(decodedUriParams);
Expand Down Expand Up @@ -54,12 +54,22 @@ export function deserializeFormBody(
(properties: string[]) => {
const deserialized = {}
for (let property of properties) {
deserialized[property] = decodedUriParams[property];
const propertySchema = schema.properties?.[property];
if (
propertySchema &&
typeof propertySchema !== 'boolean' &&
propertySchema.type === 'array' &&
typeof decodedUriParams[property] === 'string'
) {
deserialized[property] = [decodedUriParams[property]];
} else {
deserialized[property] = decodedUriParams[property];
}

const encoding = encodings.find(enc => enc.property === property);

if (encoding && encoding.style) {
const deserializer = body[encoding.style];
const propertySchema = schema.properties?.[property];

if (propertySchema && typeof propertySchema !== 'boolean') {
let deserializedValues = deserializer(property, decodedUriParams, propertySchema, encoding.explode)
Expand Down Expand Up @@ -93,9 +103,19 @@ export function deserializeFormBody(

export function splitUriParams(target: string) {
return E.right(
target.split('&').reduce((result: Dictionary<string>, pair: string) => {
target.split('&').reduce((result: Dictionary<string | string[]>, pair: string) => {
const [key, ...rest] = pair.split('=');
result[key] = rest.join('=');
const value = rest.join('=');
if (result[key]) {
const existingValue: string | string[] = result[key];
if (Array.isArray(existingValue)) {
existingValue.push(value);
} else {
result[key] = [existingValue, value];
}
} else {
result[key] = value;
}
return result;
}, {})
);
Expand All @@ -104,7 +124,7 @@ export function splitUriParams(target: string) {
export function parseMultipartFormDataParams(
target: string,
multipartBoundary?: string
): E.Either<NEA.NonEmptyArray<IPrismDiagnostic>, Dictionary<string>> {
): E.Either<NEA.NonEmptyArray<IPrismDiagnostic>, Dictionary<string | string[]>> {
if (!multipartBoundary) {
const error =
'Boundary parameter for multipart/form-data is not defined or generated in the request header. Try removing manually defined content-type from your request header if it exists.';
Expand All @@ -121,15 +141,30 @@ export function parseMultipartFormDataParams(
const parts = multipart.parse(bufferBody, multipartBoundary);

return E.right(
parts.reduce((result: Dictionary<string>, pair: any) => {
result[pair['name']] = pair['data'].toString();
parts.reduce((result: Dictionary<string | string[]>, pair: any) => {
const key = pair['name'];
const value = pair['data'].toString();

// This code handles the case where the same key is used multiple times in the multipart/form-data request
// for representing an array of values.
if (result[key]) {
const existingValue: string | string[] = result[key];
if (Array.isArray(existingValue)) {
existingValue.push(value);
} else {
result[key] = [existingValue, value];
}
} else {
result[key] = value;
}

return result;
}, {})
);
}

export function decodeUriEntities(target: Dictionary<string>, mediaType: string) {
return Object.entries(target).reduce((result, [k, v]) => {
export function decodeUriEntities(target: Dictionary<string | string[]>, mediaType: string) {
return Object.entries(target).reduce((result: Dictionary<string | string[]>, [k, v]) => {
try {
// In application/x-www-form-urlencoded format, the standard encoding of spaces is the plus sign "+",
// and plus signs in the input string are encoded as "%2B". The encoding of spaces as plus signs is
Expand All @@ -140,11 +175,11 @@ export function decodeUriEntities(target: Dictionary<string>, mediaType: string)
// we must replace all + in the encoded string (which must all represent spaces by the standard), with %20,
// the non-application/x-www-form-urlencoded encoding of spaces, so that decodeURIComponent decodes them correctly
if (typeIs(mediaType, 'application/x-www-form-urlencoded')) {
v = v.replaceAll('+', '%20')
v = Array.isArray(v) ? v.map(val => val.replaceAll('+', '%20')) : v.replaceAll('+', '%20');
}
// NOTE: this will decode the value even if it shouldn't (i.e when text/plain mime type).
// the decision to decode or not should be made before calling this function
result[decodeURIComponent(k)] = decodeURIComponent(v);
result[decodeURIComponent(k)] = Array.isArray(v) ? v.map(decodeURIComponent) : decodeURIComponent(v);
} catch (e) {
// when the data is binary, for example, uri decoding will fail so leave value as-is
result[decodeURIComponent(k)] = v;
Expand Down Expand Up @@ -283,23 +318,26 @@ export const validate: validateFn<unknown, IMediaTypeContent> = (
};

function validateAgainstReservedCharacters(
encodedUriParams: Dictionary<string>,
encodedUriParams: Dictionary<string | string[]>,
encodings: IHttpEncoding[],
prefix?: string
): E.Either<NEA.NonEmptyArray<IPrismDiagnostic>, Dictionary<string>> {
): E.Either<NEA.NonEmptyArray<IPrismDiagnostic>, Dictionary<string | string[]>> {
return pipe(
encodings,
A.reduce<IHttpEncoding, IPrismDiagnostic[]>([], (diagnostics, encoding) => {
const allowReserved = get(encoding, 'allowReserved', false);
const property = encoding.property;
const value = encodedUriParams[property];
const rawValue = encodedUriParams[property];
const values: string[] = Array.isArray(rawValue) ? rawValue : [rawValue];

if (!allowReserved && /[/?#[\]@!$&'()*+,;=]/.test(value)) {
diagnostics.push({
path: prefix ? [prefix, property] : [property],
message: 'Reserved characters used in request body',
severity: DiagnosticSeverity.Error,
});
for (const value of values) {
if (!allowReserved && /[/?#[\]@!$&'()*+,;=]/.test(value)) {
diagnostics.push({
path: prefix ? [prefix, property] : [property],
message: 'Reserved characters used in request body',
severity: DiagnosticSeverity.Error,
});
}
}

return diagnostics;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
====test====
Send repeated keys to fulfill array validation in multipart/form-data.
====spec====
openapi: '3.1.0'
paths:
/path:
post:
responses:
200:
content:
text/plain:
example: ok
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
array:
type: array
items:
type: string
====server====
mock -p 4010 ${document}
====command====
curl -i -X POST http://localhost:4010/path -H "Content-Type: multipart/form-data" -F "array=value1" -F "array=value2"
====expect====
HTTP/1.1 200 OK
content-type: text/plain

ok
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
====test====
Send repeated keys to fulfill array validation in multipart/form-data.
====spec====
openapi: '3.1.0'
paths:
/path:
post:
responses:
200:
content:
text/plain:
example: ok
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
array:
type: array
items:
type: string
====server====
mock -p 4010 ${document}
====command====
curl -i -X POST http://localhost:4010/path -H "Content-Type: multipart/form-data" -F "array=value1"
====expect====
HTTP/1.1 200 OK
content-type: text/plain

ok
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
====test====
When I have a document with a Request with form-data body that should
be an array of strings (comma separated values)
And I send the correct values
I should receive a 200 response
====spec====
swagger: '2.0'
paths:
/path:
post:
produces:
- text/plain
consumes:
- application/x-www-form-urlencoded
responses:
200:
schema:
type: string
parameters:
- in: formData
type: array
name: arr
items:
type: string
collectionFormat: csv
====server====
mock -p 4010 ${document}
====command====
curl -i -X POST http://localhost:4010/path -H "Content-Type: application/x-www-form-urlencoded" --data-urlencode "arr=a&arr=b&arr=c"
====expect====
HTTP/1.1 200 OK
content-type: text/plain

string
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
====test====
When I have a document with a Request with form-data body that should
be an array of strings (comma separated values)
And I send the correct values
I should receive a 200 response
====spec====
swagger: '2.0'
paths:
/path:
post:
produces:
- text/plain
consumes:
- application/x-www-form-urlencoded
responses:
200:
schema:
type: string
parameters:
- in: formData
type: array
name: arr
items:
type: string
collectionFormat: csv
====server====
mock -p 4010 ${document}
====command====
curl -i -X POST http://localhost:4010/path -H "Content-Type: application/x-www-form-urlencoded" --data-urlencode "arr=a"
====expect====
HTTP/1.1 200 OK
content-type: text/plain

string
Loading