diff --git a/packages/http/src/mocker/index.ts b/packages/http/src/mocker/index.ts index b566fa063..37b176d44 100644 --- a/packages/http/src/mocker/index.ts +++ b/packages/http/src/mocker/index.ts @@ -163,7 +163,7 @@ function parseBodyIfUrlEncoded(request: IHttpRequest, resource: IHttpOperation) mediaType === 'multipart/form-data' ? parseMultipartFormDataParams(requestBody, multipartBoundary) : splitUriParams(requestBody), - E.getOrElse>(() => ({} as Dictionary)) + E.getOrElse>(() => ({} as Dictionary)) ); if (specs.length < 1) { diff --git a/packages/http/src/validator/validators/body.ts b/packages/http/src/validator/validators/body.ts index 527c12da5..a697944f1 100644 --- a/packages/http/src/validator/validators/body.ts +++ b/packages/http/src/validator/validators/body.ts @@ -23,7 +23,7 @@ import { wildcardMediaTypeMatch } from '../utils/wildcardMediaTypeMatch'; export function deserializeFormBody( schema: JSONSchema, encodings: IHttpEncoding[], - decodedUriParams: Dictionary + decodedUriParams: Dictionary ) { if (!schema.properties) { return E.right(decodedUriParams); @@ -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) @@ -93,9 +103,19 @@ export function deserializeFormBody( export function splitUriParams(target: string) { return E.right( - target.split('&').reduce((result: Dictionary, pair: string) => { + target.split('&').reduce((result: Dictionary, 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; }, {}) ); @@ -104,7 +124,7 @@ export function splitUriParams(target: string) { export function parseMultipartFormDataParams( target: string, multipartBoundary?: string -): E.Either, Dictionary> { +): E.Either, Dictionary> { 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.'; @@ -121,15 +141,30 @@ export function parseMultipartFormDataParams( const parts = multipart.parse(bufferBody, multipartBoundary); return E.right( - parts.reduce((result: Dictionary, pair: any) => { - result[pair['name']] = pair['data'].toString(); + parts.reduce((result: Dictionary, 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, mediaType: string) { - return Object.entries(target).reduce((result, [k, v]) => { +export function decodeUriEntities(target: Dictionary, mediaType: string) { + return Object.entries(target).reduce((result: Dictionary, [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 @@ -140,11 +175,11 @@ export function decodeUriEntities(target: Dictionary, 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; @@ -283,23 +318,26 @@ export const validate: validateFn = ( }; function validateAgainstReservedCharacters( - encodedUriParams: Dictionary, + encodedUriParams: Dictionary, encodings: IHttpEncoding[], prefix?: string -): E.Either, Dictionary> { +): E.Either, Dictionary> { return pipe( encodings, A.reduce([], (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; diff --git a/test-harness/specs/validate-body-params/multipart-form-data-repeated-keys-multiple.oas3.txt b/test-harness/specs/validate-body-params/multipart-form-data-repeated-keys-multiple.oas3.txt new file mode 100644 index 000000000..b17b33f89 --- /dev/null +++ b/test-harness/specs/validate-body-params/multipart-form-data-repeated-keys-multiple.oas3.txt @@ -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 diff --git a/test-harness/specs/validate-body-params/multipart-form-data-repeated-keys-single.oas3.txt b/test-harness/specs/validate-body-params/multipart-form-data-repeated-keys-single.oas3.txt new file mode 100644 index 000000000..750724f50 --- /dev/null +++ b/test-harness/specs/validate-body-params/multipart-form-data-repeated-keys-single.oas3.txt @@ -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 diff --git a/test-harness/specs/validate-body-params/x-www-urlencoded-repeated-keys-multiple.oas2.txt b/test-harness/specs/validate-body-params/x-www-urlencoded-repeated-keys-multiple.oas2.txt new file mode 100644 index 000000000..553d262ce --- /dev/null +++ b/test-harness/specs/validate-body-params/x-www-urlencoded-repeated-keys-multiple.oas2.txt @@ -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 diff --git a/test-harness/specs/validate-body-params/x-www-urlencoded-repeated-keys-single.oas2.txt b/test-harness/specs/validate-body-params/x-www-urlencoded-repeated-keys-single.oas2.txt new file mode 100644 index 000000000..e951adf9c --- /dev/null +++ b/test-harness/specs/validate-body-params/x-www-urlencoded-repeated-keys-single.oas2.txt @@ -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