diff --git a/lib/schemaUtils.js b/lib/schemaUtils.js index b962f6e..2322332 100644 --- a/lib/schemaUtils.js +++ b/lib/schemaUtils.js @@ -1105,6 +1105,35 @@ module.exports = { return helper; }, + /** + * Generates appropriate collection element based on parameter location + * + * @param {Object} param - Parameter object habing key, value and description (optional) + * @param {String} location - Parameter location ("in" property of OAS defined parameter object) + * @returns {Object} - SDK element + */ + generateSdkParam: function (param, location) { + const sdkElementMap = { + 'query': sdk.QueryParam, + 'header': sdk.Header, + 'path': sdk.Variable + }; + + let generatedParam = { + key: param.key, + value: param.value + }; + + _.has(param, 'disabled') && (generatedParam.disabled = param.disabled); + + // use appropriate sdk element based on location parmaeter is in for param generation + if (sdkElementMap[location]) { + generatedParam = new sdkElementMap[location](generatedParam); + } + param.description && (generatedParam.description = param.description); + return generatedParam; + }, + /** * Generates Auth helper for response, params (query, headers) in helper object is added in * request (originalRequest) part of example. @@ -1510,12 +1539,12 @@ module.exports = { case 'form': if (explode && _.isObject(paramValue)) { _.forEach(paramValue, (value, key) => { - pmParams.push({ + pmParams.push(this.generateSdkParam({ key: _.isArray(paramValue) ? paramName : key, value: (value === undefined ? '' : value), description, disabled - }); + }, _.get(param, 'in'))); }); return pmParams; } @@ -1528,12 +1557,12 @@ module.exports = { case 'deepObject': if (_.isObject(paramValue)) { _.forOwn(paramValue, (value, key) => { - pmParams.push({ + pmParams.push(this.generateSdkParam({ key: param.name + '[' + key + ']', value: (value === undefined ? '' : value), description, disabled - }); + }, _.get(param, 'in'))); }); } return pmParams; @@ -1559,12 +1588,12 @@ module.exports = { // prepend starting value to serialised value (valid for empty value also) serialisedValue = startValue + serialisedValue; - pmParams.push({ + pmParams.push(this.generateSdkParam({ key: paramName, value: serialisedValue, description, disabled - }); + }, _.get(param, 'in'))); return pmParams; }, @@ -1672,55 +1701,36 @@ module.exports = { } description = (required ? '(Required) ' : '') + description + (enumValue ? ' (This can only be one of ' + enumValue + ')' : ''); - if (encoding.hasOwnProperty(key)) { - encoding[key].name = key; - encoding[key].schema = { - type: typeof value - }; - encoding[key].description = description; - params = this.convertParamsWithStyle(encoding[key], value, PARAMETER_SOURCE.REQUEST, components, - schemaCache, options); - // TODO: Show warning for incorrect schema if !params - params && params.forEach((element) => { - // Collection v2.1 schema allows urlencoded param value to be only string - if (typeof element.value !== 'string') { - try { - // convert other datatype to string (i.e. number, boolean etc) - element.value = JSON.stringify(element.value); - } - catch (e) { - // JSON.stringify can fail in few cases, suggest invalid type for such case - // eslint-disable-next-line max-len - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#Exceptions - element.value = 'INVALID_URLENCODED_PARAM_TYPE'; - } - } - delete element.description; - }); - paramArray.push(...params); - } - else { + + !encoding[key] && (encoding[key] = {}); + encoding[key].name = key; + encoding[key].schema = { + type: typeof value + }; + // for urlencoded body serialisation is treated similar to query param + // reference https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#fixed-fields-13 + encoding[key].in = 'query'; + encoding[key].description = description; + + params = this.convertParamsWithStyle(encoding[key], value, PARAMETER_SOURCE.REQUEST, components, + schemaCache, options); + // TODO: Show warning for incorrect schema if !params + params && params.forEach((element) => { // Collection v2.1 schema allows urlencoded param value to be only string - if (typeof value !== 'string') { + if (typeof element.value !== 'string') { try { // convert other datatype to string (i.e. number, boolean etc) - value = JSON.stringify(value); + element.value = JSON.stringify(element.value); } catch (e) { // JSON.stringify can fail in few cases, suggest invalid type for such case // eslint-disable-next-line max-len // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#Exceptions - value = 'INVALID_URLENCODED_PARAM_TYPE'; + element.value = 'INVALID_URLENCODED_PARAM_TYPE'; } } - - param = new sdk.QueryParam({ - key: key, - value: value - }); - param.description = description; - paramArray.push(param); - } + }); + paramArray.push(...params); }); updateOptions = { mode: rDataMode, @@ -2256,7 +2266,6 @@ module.exports = { }); }); item.request.url.query.members.forEach((query) => { - query.description = _.get(query, 'description.content', ''); // Collection v2.1 schema allows query param value to be string/null if (typeof query.value !== 'string') { try { @@ -3799,6 +3808,160 @@ module.exports = { ); }, 0); } + else if (requestBody && requestBody.mode === 'urlencoded') { + let urlencodedBodySchema = _.get(schemaPath, ['requestBody', 'content', URLENCODED, 'schema']), + resolvedSchemaParams = [], + pathPrefix = `${schemaPathPrefix}.requestBody.content[${URLENCODED}].schema`; + + urlencodedBodySchema = deref.resolveRefs(urlencodedBodySchema, PARAMETER_SOURCE.REQUEST, components, + schemaCache.schemaResolutionCache, PROCESSING_TYPE.VALIDATION, 'example', 0, {}, options.stackLimit); + + // resolve each property as separate param similar to query parmas + _.forEach(_.get(urlencodedBodySchema, 'properties'), (propSchema, propName) => { + let resolvedProp = { + name: propName, + schema: propSchema, + in: 'query' // serialization follows same behaviour as query params + }, + encodingValue = _.get(schemaPath, ['requestBody', 'content', URLENCODED, 'encoding', propName]), + pSerialisationInfo, + isPropSeparable; + + if (_.isObject(encodingValue)) { + _.has(encodingValue, 'style') && (resolvedProp.style = encodingValue.style); + _.has(encodingValue, 'explode') && (resolvedProp.explode = encodingValue.explode); + } + + if (_.includes(_.get(urlencodedBodySchema, 'required'), propName)) { + resolvedProp.required = true; + } + + pSerialisationInfo = this.getParamSerialisationInfo(resolvedProp, PARAMETER_SOURCE.REQUEST, + components, schemaCache); + isPropSeparable = _.includes(['form', 'deepObject'], pSerialisationInfo.style); + + if (isPropSeparable && propSchema.type === 'array' && pSerialisationInfo.explode) { + // add schema of items and instead array + resolvedSchemaParams.push(_.assign({}, resolvedProp, { + schema: _.get(propSchema, 'items'), + isResolvedParam: true + })); + } + else if (isPropSeparable && propSchema.type === 'object' && pSerialisationInfo.explode) { + // add schema of all properties instead entire object + _.forEach(_.get(propSchema, 'properties', {}), (value, key) => { + resolvedSchemaParams.push({ + name: key, + schema: value, + isResolvedParam: true + }); + }); + } + else { + resolvedSchemaParams.push(resolvedProp); + } + }); + + return async.map(requestBody.urlencoded, (uParam, cb) => { + let mismatches = [], + index = _.findIndex(requestBody.urlencoded, uParam), + resolvedParamValue = uParam.value; + + const schemaParam = _.find(resolvedSchemaParams, (param) => { return param.name === uParam.key; }); + + if (!schemaParam) { + // no schema param found + if (options.showMissingInSchemaErrors) { + mismatches.push({ + property: mismatchProperty, + transactionJsonPath: transactionPathPrefix + `.urlencoded[${index}]`, + schemaJsonPath: null, + reasonCode: 'MISSING_IN_SCHEMA', + reason: `The Url Encoded body param "${uParam.key}" was not found in the schema` + }); + } + return cb(null, mismatches); + } + + if (!schemaParam.isResolvedParam) { + resolvedParamValue = this.deserialiseParamValue(schemaParam, uParam.value, PARAMETER_SOURCE.REQUEST, + components, schemaCache); + } + // store value of transaction to use in mismatch object + schemaParam.actualValue = uParam.value; + + // param found in spec. check param's schema + setTimeout(() => { + if (!schemaParam.schema) { + // no errors to show if there's no schema present in the spec + return cb(null, []); + } + this.checkValueAgainstSchema(mismatchProperty, + transactionPathPrefix + `.urlencoded[${index}].value`, + uParam.key, + resolvedParamValue, + pathPrefix + '.properties[' + schemaParam.name + ']', + schemaParam.schema, + PARAMETER_SOURCE.REQUEST, + components, options, schemaCache, cb + ); + }, 0); + }, (err, res) => { + let mismatches = [], + mismatchObj, + // fetches property name from schem path + getPropNameFromSchemPath = (schemaPath) => { + let regex = /\.properties\[(.+)\]/gm; + return _.last(regex.exec(schemaPath)); + }; + + // update actual value and suggested value from JSON to serialized strings + _.forEach(_.flatten(res), (mismatchObj) => { + if (!_.isEmpty(mismatchObj)) { + let propertyName = getPropNameFromSchemPath(mismatchObj.schemaJsonPath), + schemaParam = _.find(resolvedSchemaParams, (param) => { return param.name === propertyName; }), + serializedParamValue; + + if (schemaParam) { + // serialize param value (to be used in suggested value) + serializedParamValue = _.get(this.convertParamsWithStyle(schemaParam, _.get(mismatchObj, + 'suggestedFix.suggestedValue'), PARAMETER_SOURCE.REQUEST, components, schemaCache, options), + '[0].value'); + _.set(mismatchObj, 'suggestedFix.actualValue', schemaParam.actualValue); + _.set(mismatchObj, 'suggestedFix.suggestedValue', serializedParamValue); + } + } + }); + + _.each(resolvedSchemaParams, (uParam) => { + // report mismatches only for reuired properties + if (!_.find(requestBody.urlencoded, (param) => { return param.key === uParam.name; }) && uParam.required) { + mismatchObj = { + property: mismatchProperty, + transactionJsonPath: transactionPathPrefix + '.urlencoded', + schemaJsonPath: pathPrefix + '.properties[' + uParam.name + ']', + reasonCode: 'MISSING_IN_REQUEST', + reason: `The Url Encoded body param "${uParam.name}" was not found in the transaction` + }; + + if (options.suggestAvailableFixes) { + mismatchObj.suggestedFix = { + key: uParam.name, + actualValue: null, + suggestedValue: { + key: uParam.name, + value: safeSchemaFaker(uParam.schema || {}, 'example', PROCESSING_TYPE.VALIDATION, + PARAMETER_SOURCE.REQUEST, components, SCHEMA_FORMATS.DEFAULT, options.indentCharacter, schemaCache, + options.stackLimit) + } + }; + } + mismatches.push(mismatchObj); + } + }); + return callback(null, _.concat(_.flatten(res), mismatches)); + }); + } else { return callback(null, []); } diff --git a/test/data/valid_openapi/required_in_parameters.json b/test/data/valid_openapi/required_in_parameters.json index 5f85b7d..74f3214 100644 --- a/test/data/valid_openapi/required_in_parameters.json +++ b/test/data/valid_openapi/required_in_parameters.json @@ -91,15 +91,11 @@ "formParam1": { "description": "Description of formParam1", "required": true, - "schema": { - "type": "string" - } + "type": "string" }, "formParam2": { "description": "Description of formParam2", - "schema": { - "type": "string" - } + "type": "string" } } } @@ -120,15 +116,11 @@ "urlencodedParam1": { "description": "Description of urlencodedParam1", "required": true, - "schema": { - "type": "string" - } + "type": "string" }, "urlencodedParam2": { "description": "Description of urlencodedParam2", - "schema": { - "type": "string" - } + "type": "string" } } } diff --git a/test/data/validationData/urlencodedBodyCollection.json b/test/data/validationData/urlencodedBodyCollection.json new file mode 100644 index 0000000..9c77f5a --- /dev/null +++ b/test/data/validationData/urlencodedBodyCollection.json @@ -0,0 +1,161 @@ +{ + "item": [ + { + "id": "f5983708-1a61-43a4-919b-ca40a34fe2a3", + "name": "pet", + "description": { + "content": "", + "type": "text/plain" + }, + "item": [ + { + "id": "9c4d4bf3-c8f6-47f5-83d6-6e25f89c1314", + "name": "Updates a pet in the store with form data", + "request": { + "name": "Updates a pet in the store with form data", + "description": {}, + "url": { + "path": [ + "pets", + ":petId" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [], + "variable": [ + { + "description": "(Required) ID of pet that needs to be updated", + "type": "any", + "value": "elit nulla", + "key": "petId" + } + ] + }, + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "method": "POST", + "auth": null, + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "prop1", + "value": "hello" + }, + { + "key": "prop2", + "value": "false" + }, + { + "key": "propObjectNonExplodable", + "value": "prop3,hello,prop4,true" + }, + { + "key": "propArray", + "value": "str1" + }, + { + "key": "propArray", + "value": "999" + }, + { + "key": "propSimple", + "value": "123" + } + ] + } + }, + "response": [ + { + "id": "613fc0d2-9836-48a6-9518-8f239ba9427f", + "name": "Pet updated.", + "originalRequest": { + "url": { + "path": [ + "pets", + ":petId" + ], + "host": [ + "{{baseUrl}}" + ], + "query": [], + "variable": [ + { + "type": "any", + "key": "petId" + } + ] + }, + "method": "POST", + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "prop1", + "value": "hello" + }, + { + "key": "prop2", + "value": "world" + }, + { + "key": "propObjectNonExplodable", + "value": "prop1,hello,prop2,world" + }, + { + "key": "propArray", + "value": "str1" + }, + { + "key": "propArray", + "value": "str2" + }, + { + "key": "propSimple", + "value": "123" + } + ] + } + }, + "status": "OK", + "code": 200, + "header": [ + { + "key": "Content-Type", + "value": "text/plain" + } + ], + "body": "", + "cookie": [], + "_postman_previewlanguage": "text" + } + ], + "event": [] + } + ], + "event": [] + } + ], + "event": [], + "variable": [ + { + "id": "baseUrl", + "type": "string", + "value": "http://petstore.swagger.io/v1" + } + ], + "info": { + "_postman_id": "acd16ff0-eda5-48b4-b0de-4d35138da21d", + "name": "Swagger Petstore", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": { + "content": "", + "type": "text/plain" + } + } +} \ No newline at end of file diff --git a/test/data/validationData/urlencodedBodySpec.yaml b/test/data/validationData/urlencodedBodySpec.yaml new file mode 100644 index 0000000..da50771 --- /dev/null +++ b/test/data/validationData/urlencodedBodySpec.yaml @@ -0,0 +1,68 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +servers: + - url: http://petstore.swagger.io/v1 +paths: + /pets/{petId}: + post: + tags: + - pet + summary: Updates a pet in the store with form data + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: string + requestBody: + content: + 'application/x-www-form-urlencoded': + schema: + properties: + propObjectExplodable: + type: object + properties: + prop1: + type: string + example: hello + prop2: + type: string + example: world + propObjectNonExplodable: + type: object + properties: + prop3: + type: string + example: hello + prop4: + type: string + example: world + propArray: + type: array + items: + type: string + example: exampleString + example: + - str1 + - str2 + propSimple: + type: integer + example: 123 + required: + - status + encoding: + propObjectExplodable: + style: form + explode: true + propObjectNonExplodable: + style: form + explode: false + responses: + '200': + description: Pet updated. diff --git a/test/unit/validator.test.js b/test/unit/validator.test.js index 9a88e89..535b1e8 100644 --- a/test/unit/validator.test.js +++ b/test/unit/validator.test.js @@ -631,4 +631,42 @@ describe('VALIDATE FUNCTION TESTS ', function () { done(); }); }); + + it('Should be able to validate schema with request body of content type "application/x-www-form-urlencoded" ' + + 'against transaction with valid UrlEncoded body correctly', function (done) { + let urlencodedBodySpec = fs.readFileSync(path.join(__dirname, VALIDATION_DATA_FOLDER_PATH + + '/urlencodedBodySpec.yaml'), 'utf-8'), + urlencodedBodyCollection = fs.readFileSync(path.join(__dirname, VALIDATION_DATA_FOLDER_PATH + + '/urlencodedBodyCollection.json'), 'utf-8'), + resultObj, + historyRequest = [], + schemaPack = new Converter.SchemaPack({ type: 'string', data: urlencodedBodySpec }, + { suggestAvailableFixes: true }); + + getAllTransactions(JSON.parse(urlencodedBodyCollection), historyRequest); + + schemaPack.validateTransaction(historyRequest, (err, result) => { + expect(err).to.be.null; + expect(result).to.be.an('object'); + resultObj = result.requests[historyRequest[0].id].endpoints[0]; + expect(resultObj.mismatches).to.have.lengthOf(3); + + // for explodable property of type object named "propObjectExplodable", + // second property named "prop2" is incorrect, while property "prop1" is correct + expect(resultObj.mismatches[0].transactionJsonPath).to.eql('$.request.body.urlencoded[1].value'); + expect(resultObj.mismatches[0].suggestedFix.actualValue).to.eql('false'); + expect(resultObj.mismatches[0].suggestedFix.suggestedValue).to.eql('world'); + + // for non explodable property of type object, entire property with updated value should be suggested + expect(resultObj.mismatches[1].transactionJsonPath).to.eql('$.request.body.urlencoded[2].value'); + expect(resultObj.mismatches[1].suggestedFix.actualValue).to.eql('prop3,hello,prop4,true'); + expect(resultObj.mismatches[1].suggestedFix.suggestedValue).to.eql('prop3,hello,prop4,world'); + + // for type array property named "propArray" second element is incorrect + expect(resultObj.mismatches[2].transactionJsonPath).to.eql('$.request.body.urlencoded[4].value'); + expect(resultObj.mismatches[2].suggestedFix.actualValue).to.eql('999'); + expect(resultObj.mismatches[2].suggestedFix.suggestedValue).to.eql('exampleString'); + done(); + }); + }); });