Skip to content
10 changes: 10 additions & 0 deletions lib/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ module.exports = {
'in the request/response body.',
external: true,
usage: ['VALIDATION']
},
{
name: 'Enable strict request matching',
id: 'strictRequestMatching',
type: 'boolean',
default: false,
description: 'Whether requests should be strictly matched with schema operations. Setting to true will not ' +
'include any matches where the URL path segments don\'t match exactly.',
external: true,
usage: ['VALIDATION']
}
];

Expand Down
110 changes: 98 additions & 12 deletions lib/schemaUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2093,7 +2093,7 @@ module.exports = {
// along with the path object, this also returns the values of the
// path variable's values
// also, any endpoint-level params are merged into the returned pathItemObject
findMatchingRequestFromSchema: function (method, url, schema) {
findMatchingRequestFromSchema: function (method, url, schema, options) {
// first step - get array of requests from schema
let parsedUrl = require('url').parse(url),
retVal = [],
Expand Down Expand Up @@ -2138,7 +2138,7 @@ module.exports = {
}

// check if path and pathToMatch match (non-null)
let schemaMatchResult = this.getPostmanUrlSchemaMatchScore(pathToMatch, path);
let schemaMatchResult = this.getPostmanUrlSchemaMatchScore(pathToMatch, path, options);
if (!schemaMatchResult.match) {
// there was no reasonable match b/w the postman path and this schema path
return true;
Expand All @@ -2148,11 +2148,18 @@ module.exports = {
path,
pathItem: pathItemObject,
matchScore: schemaMatchResult.score,
pathVars: schemaMatchResult.pathVars
pathVars: schemaMatchResult.pathVars,
// No. of fixed segment matches between schema and postman url path
// i.e. schema path /user/{userId} and request path /user/{{userId}} has 1 fixed segment match ('user')
fixedMatchedSegments: schemaMatchResult.fixedMatchedSegments,
// No. of variable segment matches between schema and postman url path
// i.e. schema path /user/{userId} and request path /user/{{userId}} has 1 variable segment match ('{userId}')
variableMatchedSegments: schemaMatchResult.variableMatchedSegments
});
});

_.each(filteredPathItemsArray, (fp) => {
// keep endpoints with more fix matched segments first in result
_.each(_.orderBy(filteredPathItemsArray, ['fixedMatchedSegments', 'variableMatchedSegments'], ['desc']), (fp) => {
let path = fp.path,
pathItemObject = fp.pathItem,
score = fp.matchScore,
Expand Down Expand Up @@ -2203,6 +2210,17 @@ module.exports = {
return retVal;
},

/**
* Checks if value is postman variable or not
*
* @param {*} value - Value to check for
* @returns {Boolean} postman variable or not
*/
isPmVariable: function (value) {
// collection/environment variables are in format - {{var}}
return _.isString(value) && _.startsWith(value, '{{') && _.endsWith(value, '}}');
},

/**
*
* @param {*} property - one of QUERYPARAM, PATHVARIABLE, HEADER, BODY, RESPONSE_HEADER, RESPONSE_BODY
Expand Down Expand Up @@ -2820,9 +2838,10 @@ module.exports = {
/**
* @param {string} postmanPath - parsed path (exclude host and params) from the Postman request
* @param {string} schemaPath - schema path from the OAS spec (exclude servers object)
* @param {object} options - a standard list of options that's globally passed around. Check options.js for more.
* @returns {*} score + match + pathVars - higher score - better match. null - no match
*/
getPostmanUrlSchemaMatchScore: function (postmanPath, schemaPath) {
getPostmanUrlSchemaMatchScore: function (postmanPath, schemaPath, options) {
var postmanPathArr = _.reject(postmanPath.split('/'), (segment) => {
return segment === '';
}),
Expand All @@ -2832,6 +2851,8 @@ module.exports = {
matchedPathVars = null,
maxScoreFound = -Infinity,
anyMatchFound = false,
fixedMatchedSegments,
variableMatchedSegments,
postmanPathSuffixes = [];

// get array with all suffixes of postmanPath
Expand All @@ -2846,10 +2867,14 @@ module.exports = {
// for each suffx, calculate score against the schemaPath
// the schema<>postman score is the sum
_.each(postmanPathSuffixes, (pps) => {
let suffixMatchResult = this.getPostmanUrlSuffixSchemaScore(pps, schemaPathArr);
let suffixMatchResult = this.getPostmanUrlSuffixSchemaScore(pps, schemaPathArr, options);
if (suffixMatchResult.match && suffixMatchResult.score > maxScoreFound) {
maxScoreFound = suffixMatchResult.score;
matchedPathVars = suffixMatchResult.pathVars;
// No. of fixed segment matches between schema and postman url path
fixedMatchedSegments = suffixMatchResult.fixedMatchedSegments;
// No. of variable segment matches between schema and postman url path
variableMatchedSegments = suffixMatchResult.variableMatchedSegments;
anyMatchFound = true;
}
});
Expand All @@ -2858,7 +2883,9 @@ module.exports = {
return {
match: true,
score: maxScoreFound,
pathVars: matchedPathVars
pathVars: matchedPathVars,
fixedMatchedSegments,
variableMatchedSegments
};
}
return {
Expand All @@ -2867,19 +2894,32 @@ module.exports = {
},

/**
* @param {*} pmSuffix
* @param {*} schemaPath
* @param {*} pmSuffix - Collection request's path suffix array
* @param {*} schemaPath - schema operation's path suffix array
* @param {object} options - a standard list of options that's globally passed around. Check options.js for more.
* @returns {*} score - null of no match, int for match. higher value indicates better match
* You get points for the number of URL segments that match
* You are penalized for the number of schemaPath segments that you skipped
*/
getPostmanUrlSuffixSchemaScore: function (pmSuffix, schemaPath) {
getPostmanUrlSuffixSchemaScore: function (pmSuffix, schemaPath, options) {
let mismatchFound = false,
variables = [],
minLength = Math.min(pmSuffix.length, schemaPath.length),
sMax = schemaPath.length - 1,
pMax = pmSuffix.length - 1,
matchedSegments = 0;
matchedSegments = 0,
// No. of fixed segment matches between schema and postman url path
fixedMatchedSegments = 0,
// No. of variable segment matches between schema and postman url path
variableMatchedSegments = 0;

if (options.strictRequestMatching && pmSuffix.length !== schemaPath.length) {
return {
match: false,
score: null,
pathVars: []
};
}

// start from the last segment of both
// segments match if the schemaPath segment is {..} or the postmanPathStr is :<anything> or {{anything}}
Expand All @@ -2889,8 +2929,22 @@ module.exports = {
(schemaPath[sMax - i] === pmSuffix[pMax - i]) || // exact match
(schemaPath[sMax - i].startsWith('{') && schemaPath[sMax - i].endsWith('}')) || // schema segment is a pathVar
(pmSuffix[pMax - i].startsWith(':')) || // postman segment is a pathVar
(pmSuffix[pMax - i].startsWith('{{') && pmSuffix[pMax - i].endsWith('}}')) // postman segment is an env var
(this.isPmVariable(pmSuffix[pMax - i])) // postman segment is an env/collection var
) {

// for variable match increase variable matched segments count (used for determining order for multiple matches)
Copy link
Member

@abhijitkane abhijitkane Jun 4, 2020

Choose a reason for hiding this comment

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

for /a/b/{{c}} and /a/b/{{c}}, is the fixMatchedSegments value supposed to be 2 or 3? If 2, the check should be moved after the variables check, right?
also, rename to fixedMatchedSegments everywhere

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah it would be 3 here, Moving fixMatchedSegments check after and in else if statements to variableMatchedSegments segment. so only one of them is valid per segment.

if (
(schemaPath[sMax - i].startsWith('{') && schemaPath[sMax - i].endsWith('}')) && // schema segment is a pathVar
((pmSuffix[pMax - i].startsWith(':')) || // postman segment is a pathVar
(this.isPmVariable(pmSuffix[pMax - i]))) // postman segment is an env/collection var
) {
variableMatchedSegments++;
}
// for exact match increase fix matched segments count (used for determining order for multiple matches)
else if (schemaPath[sMax - i] === pmSuffix[pMax - i]) {
fixedMatchedSegments++;
}

// add a matched path variable only if the schema one was a pathVar
if (schemaPath[sMax - i].startsWith('{') && schemaPath[sMax - i].endsWith('}')) {
variables.push({
Expand All @@ -2916,6 +2970,8 @@ module.exports = {
// penalty for any length difference
// schemaPath will always be > postmanPathSuffix because SchemaPath ands with pps
score: ((2 * matchedSegments) / (schemaPath.length + pmSuffix.length)),
fixedMatchedSegments,
variableMatchedSegments,
pathVars: variables
};
}
Expand All @@ -2924,5 +2980,35 @@ module.exports = {
score: null,
pathVars: []
};
},

/**
* @param {Object} schemaPaths - OpenAPI Paths object
* @param {Array} matchedEndpoints - All matched endpoints
* @returns {Array} - Array of all MISSING_ENDPOINT objects
*/
getMissingSchemaEndpoints: function (schemaPaths, matchedEndpoints) {
let endpoints = [],
schemaJsonPath;

_.forEach(schemaPaths, (schemaPathObj, schemaPath) => {
_.forEach(_.keys(schemaPathObj), (pathKey) => {
schemaJsonPath = `$.paths[${schemaPath}].${_.toLower(pathKey)}`;
if (METHODS.includes(pathKey) && !matchedEndpoints.includes(schemaJsonPath)) {
endpoints.push({
property: 'ENDPOINT',
transactionJsonPath: null,
schemaJsonPath,
reasonCode: 'MISSING_ENDPOINT',
reason: `The endpoint "${_.toUpper(pathKey)} ${schemaPath}" is missing in collection`,
endpoint: {
method: _.toUpper(pathKey),
path: schemaPath
}
});
}
});
});
return endpoints;
}
};
17 changes: 14 additions & 3 deletions lib/schemapack.js
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,8 @@ class SchemaPack {
let schema = this.openapi,
componentsAndPaths,
options = this.computedOptions,
schemaResolutionCache = this.schemaResolutionCache;
schemaResolutionCache = this.schemaResolutionCache,
matchedEndpoints = [];


if (!this.validated) {
Expand Down Expand Up @@ -347,6 +348,13 @@ class SchemaPack {
let requestUrl = transaction.request.url,
matchedPaths;
if (typeof requestUrl === 'object') {

// SDK.Url.toString() resolves pathvar to empty string if value is empty
// so update path variable value to same as key in such cases
_.forEach(requestUrl.variable, (pathVar) => {
_.isEmpty(pathVar.value) && (pathVar.value = ':' + pathVar.key);
});

// SDK URL object. Get raw string representation.
requestUrl = (new sdk.Url(requestUrl)).toString();
}
Expand All @@ -355,7 +363,8 @@ class SchemaPack {
matchedPaths = schemaUtils.findMatchingRequestFromSchema(
transaction.request.method,
requestUrl,
schema
schema,
options
);

if (!matchedPaths.length) {
Expand All @@ -369,6 +378,7 @@ class SchemaPack {
return setTimeout(() => {
// 2. perform validation for each identified matchedPath (schema endpoint)
return async.map(matchedPaths, (matchedPath, pathsCallback) => {
matchedEndpoints.push(matchedPath.jsonPath);
// 3. validation involves checking these individual properties
async.parallel({
path: function(cb) {
Expand Down Expand Up @@ -454,7 +464,8 @@ class SchemaPack {
});

retVal = {
requests: _.keyBy(result, 'requestId')
requests: _.keyBy(result, 'requestId'),
missingEndpoints: schemaUtils.getMissingSchemaEndpoints(schema.paths, matchedEndpoints)
};

callback(null, retVal);
Expand Down
10 changes: 9 additions & 1 deletion test/system/structure.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const optionIds = [
'shortValidationErrors',
'validationPropertiesToIgnore',
'showMissingInSchemaErrors',
'detailedBlobValidation'
'detailedBlobValidation',
'strictRequestMatching'
],
expectedOptions = {
collapseFolders: {
Expand Down Expand Up @@ -92,6 +93,13 @@ const optionIds = [
default: false,
description: 'Determines whether to show detailed mismatch information for application/json content ' +
'in the request/response body.'
},
strictRequestMatching: {
name: 'Enable strict request matching',
type: 'boolean',
default: false,
description: 'Whether requests should be strictly matched with schema operations. Setting to true will not ' +
'include any matches where the URL path segments don\'t match exactly.'
}
};

Expand Down