This commit is contained in:
Tutur33
2023-11-24 22:35:41 +01:00
parent 3c0b507a93
commit 7644b2a0f7
45165 changed files with 4803356 additions and 3 deletions
+53
View File
@@ -0,0 +1,53 @@
const get = require('lodash.get');
const set = require('lodash.set');
module.exports.parseErrors = (errors, response) => ({
actual: mapValuesByDataPath(errors, response),
expected: mapErrorsByDataPath(errors),
});
function mapValuesByDataPath(errors, response) {
return errors && errors.reduce((prev, error) => {
if (!error.dataPath) {
return prev;
}
const { root, fullPath } = getDataPath(error.dataPath);
const value = get(response, fullPath);
if (root !== fullPath || root.match(/[[.]/)) {
return set({ ...prev }, fullPath, value);
}
return { ...prev };
}, {});
}
function mapErrorsByDataPath(errors) {
return errors && errors.reduce((prev, error) => {
if (!error.params) {
return prev;
}
const { missingProperty, additionalProperty } = error.params;
const { fullPath } = getDataPath(error.dataPath);
if (missingProperty) {
const message = error.message.replace(/ '.*$/, '');
return set({ ...prev }, `${fullPath}.${missingProperty}`, message);
}
if (additionalProperty) {
const { message } = error;
return set({ ...prev }, `${fullPath}.${additionalProperty}`, message);
}
return set({ ...prev }, fullPath, error.message);
}, {});
}
function getDataPath(dataPath) {
const fullPath = dataPath
.replace(/^\./, '')
.replace(/\\/, '');
const [root] = fullPath.split('.');
return { root, fullPath };
}
+11
View File
@@ -0,0 +1,11 @@
module.exports.encode = (string) => {
const buffer = Buffer.from(string, 'utf8');
return buffer.toString('base64');
};
module.exports.decode = (string) => {
const buffer = Buffer.from(string, 'base64');
return buffer.toString();
};
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
messages: {
FAILED_TO_EXTRACT_RESPONSE_DETAILS: 'failed to extract response details',
EXPECTED_RESPONSE_TO_MATCH_SCHEMA: 'expected response to match API schema',
EXPECTED_RESPONSE_TO_NOT_MATCH_SCHEMA: 'expected response to not match API schema',
REQUIRED_API_DEFINITIONS_PATH: "'apiDefinitionsPath' is required",
DUPLICATE_API_DEFINITION: 'same api definition exist in two seperated files',
},
};
@@ -0,0 +1,17 @@
const chalk = require('chalk');
const columnify = require('columnify');
module.exports = (data) => columnify(data, {
columnSplitter: ' | ',
minWidth: 10,
headingTransform(title) {
switch (title) {
case 'statuses':
return chalk.yellow.underline.bold(`*${title.toUpperCase()}*`);
case 'method':
return chalk.green.underline.bold(`*${title.toUpperCase()}*`);
default:
return chalk.cyan.underline.bold(`*${title.toUpperCase()}*`);
}
},
});
+93
View File
@@ -0,0 +1,93 @@
/* eslint-disable no-console */
const chalk = require('chalk');
const get = require('lodash.get');
const set = require('lodash.set');
const flatten = require('lodash.flatten');
const fs = require('fs');
const schemaUtils = require('./schema-utils');
const buildTable = require('./coverage-table');
let coverage;
let schema;
module.exports.init = ({
reportCoverage, apiDefinitionsPath, buildSchemaOptions, exportCoverage,
}) => {
coverage = {};
const buildSchemaOptionsOverride = buildSchemaOptions || {};
schema = schemaUtils.getSchemaByFilesPath(apiDefinitionsPath, buildSchemaOptionsOverride);
let parsedCoverage;
if (reportCoverage === true || exportCoverage === true) {
process.on('beforeExit', () => {
parsedCoverage = getReport();
});
}
if (reportCoverage === true) {
process.on('beforeExit', () => {
printReport(parsedCoverage);
});
}
if (exportCoverage === true) {
process.on('beforeExit', () => {
printReportToFile(parsedCoverage);
});
}
};
module.exports.setCoverage = ({ path, method, status }) => {
if (!schema) {
console.warn('Coverage was not initiated. If you want to calculate coverage, make sure to call init() on coverage helper. Skipping coverage calculation');
return;
}
const route = schemaUtils.pathMatcher(schema, path, method);
set(coverage, `[${route}|${method}|${status}]`, true);
};
function getReport() {
const uncoveredDefinitions = Object.keys(schema)
.map((route) => getUncoveredDefinitions(route));
return flatten(uncoveredDefinitions)
.filter((api) => !!api);
}
module.exports.getReport = getReport;
function getUncoveredDefinitions(route) {
return Object
.keys(schema[route])
.map((method) => {
const statuses = Object
.keys(schema[route][method].responses)
.filter((status) => !(get(coverage, `[${route}|${method}|${status}]`)))
.toString();
return statuses ? { route, method: method.toUpperCase(), statuses } : undefined;
});
}
function printReport(report) {
console.info(chalk.bold('* API definitions coverage report *'));
if (report.length === 0) {
console.info(chalk.green('\nAll API definitions are covered\n'));
} else {
console.info(chalk.red('\nUncovered API definitions found'));
const table = buildTable(report);
console.info(table);
}
}
function printReportToFile(report) {
try {
fs.writeFileSync('./coverage.json', JSON.stringify(report));
} catch (e) {
console.info(chalk.red('Error writing report to file'));
console.info(chalk.red(e.message));
console.info(chalk.red(e.stack));
}
}
@@ -0,0 +1,2 @@
module.exports.filterMissingProps = (obj, requiredProps) => requiredProps
.filter((prop) => obj[prop] === undefined);
@@ -0,0 +1,129 @@
const get = require('lodash.get');
const urijs = require('uri-js');
function parseResponse(response) {
if (isFastifyResponse(response)) {
return parseFastifyResponse(response);
}
return parseGenericResponse(response);
}
function parseFastifyResponse(response) {
try {
const { res, req } = response.raw;
return {
request: {
method: getRequestMethod(req || res),
path: resolveUrlDataFastify(req.headers, req).path,
},
response: {
status: response.statusCode,
headers: response.headers,
body: response.json(),
},
};
} catch (error) {
return undefined;
}
}
function parseGenericResponse(response) {
try {
const { request, config } = response;
return {
request: {
method: getRequestMethod(config || request || response),
path: getRequestPath(request || response),
},
response: {
status: getResponseCode(response),
headers: getResponseHeaders(response),
body: getResponseBody(response),
},
};
} catch (error) {
return undefined;
}
}
function getRequestMethod(request) {
const { method } = request;
return method && method.toLowerCase();
}
function getRequestPath(request) {
// request promise
if (request.uri) {
return cleanPath(request.uri.pathname);
}
// axios
if (request.path) {
return cleanPath(request.path);
}
// supertest
if (get(request, 'req.path')) {
return cleanPath(request.req.path);
}
return undefined;
}
function getResponseHeaders(response) {
return response.headers;
}
function getResponseCode(response) {
// request-promise
if (response.statusCode) {
return response.statusCode;
}
// other
return response.status;
}
function getResponseBody(response) {
// request-promise/other
if (response.body) {
return response.body;
}
// axios
if (response.data) {
return response.data;
}
return undefined;
}
function cleanPath(path) {
return path
.split('?')[0] // clean query params
.replace(/\/*$/, ''); // clean trailing slashes
}
function isFastifyResponse(response) {
const userAgent = response && response.raw && response.raw.req && response.raw.req.headers['user-agent'];
if (userAgent) {
return userAgent.toLowerCase() === 'lightmyrequest';
}
return false;
}
function resolveUrlDataFastify(headers, req) {
const scheme = `${headers[':scheme'] ? `${headers[':scheme']}:` : ''}//`;
const host = headers[':authority'] || headers.host;
const path = headers[':path'] || req.url;
return urijs.parse(scheme + host + path);
}
module.exports = {
parseResponse,
};
+77
View File
@@ -0,0 +1,77 @@
const get = require('lodash.get');
const apiSchemaBuilder = require('api-schema-builder');
const { messages } = require('./common');
const base64 = require('./base64');
const getSchema = (() => {
const schemas = {};
return (filePath, schemaBuilderOpts) => {
const encodedFilePath = base64.encode(filePath);
if (!schemas[encodedFilePath]) {
schemas[encodedFilePath] = apiSchemaBuilder.buildSchemaSync(filePath, schemaBuilderOpts);
}
return schemas[encodedFilePath];
};
})();
module.exports.getSchemaByFilesPath = (apiDefinitionsPath, buildSchemaOptions) => {
const filesPaths = Array.isArray(apiDefinitionsPath) ? apiDefinitionsPath : [apiDefinitionsPath];
const schemas = filesPaths.map((path) => getSchema(path, buildSchemaOptions));
// eslint-disable-next-line array-callback-return,consistent-return
const schema = schemas.reduce((acc, cur) => {
// eslint-disable-next-line no-restricted-syntax
for (const key of Object.keys(cur)) {
if (acc[key]) {
throw new Error(`${messages.DUPLICATE_API_DEFINITION}: ${key}`);
}
acc[key] = cur[key];
}
return acc;
}, {});
return schema;
};
module.exports.getValidatorByPathMethodAndCode = (schema, request, response) => {
const route = pathMatcher(schema, request.path, request.method);
return get(schema, `${route}.${request.method}.responses.${response.status}`);
};
module.exports.pathMatcher = pathMatcher;
function pathMatcher(routes, path, method) {
return Object
.keys(routes)
.sort((currentRoute, nextRoute) => {
const firstResult = calculateRouteScore(currentRoute);
const secondResult = calculateRouteScore(nextRoute);
return firstResult - secondResult;
})
.filter((route) => {
const routeArr = route.split('/');
const pathArr = path.split('/');
if (routeArr.length !== pathArr.length) return false;
return routeArr.every((seg, idx) => {
if (seg === pathArr[idx]) return true;
// if current path segment is param
if (seg.startsWith(':') && pathArr[idx]) return true;
return false;
});
}).filter(((route) => routes[route][String(method).toLowerCase()]))[0];
}
function calculateRouteScore(route) {
return route
// split to path segments
.split('/')
// mark path params locations
.map((pathSegment) => pathSegment.includes(':'))
// give weight to each path segment according to its location
.map((isPathParam, i, pathSegments) => isPathParam * (10 ** pathSegments.length - i))
.reduce((sum, seg) => sum + seg, 0); // summarize the path score
}
+11
View File
@@ -0,0 +1,11 @@
const chaiPlugin = require('./plugins/chai');
const shouldPlugin = require('./plugins/should');
const jestPlugin = require('./plugins/jest');
const validators = require('./validators');
module.exports = {
chaiPlugin,
jestPlugin,
shouldPlugin,
validators,
};
+19
View File
@@ -0,0 +1,19 @@
const schemaMatcher = require('./schema-matcher');
const statusCodeMatcher = require('./status-matcher');
const coverage = require('../../helpers/coverage');
const { messages } = require('../../helpers/common');
module.exports = function getChaiPlugin(options) {
if (!(options instanceof Object) || !options.apiDefinitionsPath) {
throw new Error(messages.REQUIRED_API_DEFINITIONS_PATH);
}
coverage.init(options);
return function apiSchemaPlugin(chai) {
const { Assertion } = chai;
schemaMatcher(Assertion, options);
statusCodeMatcher(Assertion);
};
};
@@ -0,0 +1,20 @@
const validators = require('../../validators');
module.exports = (Assertion, options) => {
Assertion.addMethod('matchApiSchema', function addApiSchemaMethod(apiDefinitionsPath) {
const {
predicate, actual, expected, matchMsg, noMatchMsg,
} = validators.schemaValidator(
this._obj,
{ ...options, apiDefinitionsPath: apiDefinitionsPath || options.apiDefinitionsPath },
);
this.assert(
predicate,
matchMsg,
noMatchMsg,
expected,
actual,
);
});
};
@@ -0,0 +1,31 @@
const validators = require('../../validators');
module.exports = (Assertion) => {
buildMatcher('successful', 200);
buildMatcher('created', 201);
buildMatcher('badRequest', 400);
buildMatcher('unauthorized', 401);
buildMatcher('forbidden', 403);
buildMatcher('notFound', 404);
buildMatcher('serverError', 500);
buildMatcher('serviceUnavailable', 503);
buildMatcher('gatewayTimeout', 504);
buildMatcher('status');
function buildMatcher(str, statusToTest) {
Assertion.addMethod(str, function statusCodeMatcher(customStatus) {
const expectedStatus = statusToTest || customStatus;
const {
predicate, actual, expected, matchMsg, noMatchMsg,
} = validators.statusValidator(expectedStatus, this._obj);
this.assert(
predicate,
matchMsg,
noMatchMsg,
expected,
actual,
);
});
}
};
+14
View File
@@ -0,0 +1,14 @@
const schemaMatcher = require('./schema-matcher');
const statusCodeMatcher = require('./status-matcher');
const coverage = require('../../helpers/coverage');
const { messages } = require('../../helpers/common');
module.exports = function apiSchemaPlugin(options) {
if (!(options instanceof Object) || !options.apiDefinitionsPath) {
throw new Error(messages.REQUIRED_API_DEFINITIONS_PATH);
}
coverage.init(options);
schemaMatcher(options);
statusCodeMatcher();
};
@@ -0,0 +1,41 @@
const diff = require('jest-diff').default;
const matcherUtils = require('jest-matcher-utils');
const validators = require('../../validators');
module.exports = (options) => {
expect.extend(
{
toMatchApiSchema: (validatedRequest, apiDefinitionsPath) => {
const {
predicate, expected, actual,
} = validators.schemaValidator(
validatedRequest,
{ ...options, apiDefinitionsPath: apiDefinitionsPath || options.apiDefinitionsPath },
);
const pass = predicate;
const message = pass
? () => `${matcherUtils.matcherHint('toMatchApiSchema')
}\n\n`
+ `Expected: ${matcherUtils.printExpected(expected)}\n`
+ `Received: ${matcherUtils.printReceived(actual)}`
: () => {
const difference = diff(expected, actual, {
expand: this.expand,
});
return (
`${matcherUtils.matcherHint('toMatchApiSchema')
}\n\n${
difference && difference.includes('- Expect')
? `Difference:\n\n${difference}`
: `Expected: ${matcherUtils.printExpected(expected)}\n`
+ `Received: ${matcherUtils.printReceived(actual)}`}`
);
};
return { actual, message, pass };
},
},
);
};
@@ -0,0 +1,51 @@
const diff = require('jest-diff');
const matcherUtils = require('jest-matcher-utils');
const validators = require('../../validators');
module.exports = () => {
buildMatcher('toBeSuccessful', 200);
buildMatcher('toBeCreated', 201);
buildMatcher('toBeBadRequest', 400);
buildMatcher('toBeUnauthorized', 401);
buildMatcher('toBeForbidden', 403);
buildMatcher('toBeNotFound', 404);
buildMatcher('toBeServerError', 500);
buildMatcher('toBeServiceUnavailable', 503);
buildMatcher('toBeGatewayTimeout', 504);
buildMatcher('toHaveStatus');
function buildMatcher(str, statusToTest) {
expect.extend({
[str]: (validatedResponse, customStatus) => {
const expectedStatus = statusToTest || customStatus;
const {
predicate, expected, actual,
} = validators.statusValidator(expectedStatus, validatedResponse);
const pass = predicate;
const message = pass
? () => `${matcherUtils.matcherHint(str)
}\n\n`
+ `Expected: ${matcherUtils.printExpected(expected)}\n`
+ `Received: ${matcherUtils.printReceived(actual)}`
: () => {
const difference = diff(expected, actual, {
expand: this.expand,
});
return (
`${matcherUtils.matcherHint(str)
}\n\n${
difference && difference.includes('- Expect')
? `Difference:\n\n${difference}`
: `Expected: ${matcherUtils.printExpected(expected)}\n`
+ `Received: ${matcherUtils.printReceived(actual)}`}`
);
};
return { actual, message, pass };
},
});
}
};
+16
View File
@@ -0,0 +1,16 @@
const schemaMatcher = require('./schema-matcher');
const statusCodeMatcher = require('./status-matcher');
const coverage = require('../../helpers/coverage');
const { messages } = require('../../helpers/common');
module.exports = function apiSchemaPlugin(should, options) {
const Assertion = should.Assertion || should;
if (!(options instanceof Object) || !options.apiDefinitionsPath) {
throw new Error(messages.REQUIRED_API_DEFINITIONS_PATH);
}
coverage.init(options);
schemaMatcher(Assertion, options);
statusCodeMatcher(Assertion);
};
@@ -0,0 +1,20 @@
const validators = require('../../validators');
module.exports = (Assertion, options) => {
Assertion.add('matchApiSchema', function addApiSchemaMethod(apiDefinitionsPath) {
const {
predicate, actual, expected, matchMsg,
} = validators.schemaValidator(
this.obj,
{ ...options, apiDefinitionsPath: apiDefinitionsPath || options.apiDefinitionsPath },
);
this.params = {
message: matchMsg,
expected,
actual,
};
predicate.should.be.true();
});
};
@@ -0,0 +1,31 @@
const validators = require('../../validators');
module.exports = (Assertion) => {
buildMatcher('successful', 200);
buildMatcher('created', 201);
buildMatcher('badRequest', 400);
buildMatcher('unauthorized', 401);
buildMatcher('forbidden', 403);
buildMatcher('notFound', 404);
buildMatcher('serverError', 500);
buildMatcher('serviceUnavailable', 503);
buildMatcher('gatewayTimeout', 504);
buildMatcher('status');
function buildMatcher(str, statusToTest) {
Assertion.add(str, function statusCodeMatcher(customStatus) {
const expectedStatus = statusToTest || customStatus;
const {
predicate, actual, expected, matchMsg,
} = validators.statusValidator(expectedStatus, this.obj);
this.params = {
message: matchMsg,
expected,
actual,
};
predicate.should.be.true();
});
}
};
+92
View File
@@ -0,0 +1,92 @@
const ajvUtils = require('./helpers/ajv-utils');
const schemaUtils = require('./helpers/schema-utils');
const responseAdapter = require('./helpers/response-adapter');
const { messages } = require('./helpers/common');
const { filterMissingProps } = require('./helpers/object-utils');
const { setCoverage } = require('./helpers/coverage');
module.exports.schemaValidator = function schemaValidator(obj, options = {}) {
const { apiDefinitionsPath } = options;
const buildSchemaOptions = options.buildSchemaOptions || {};
// load the schema
if (!apiDefinitionsPath) {
throw new Error(messages.REQUIRED_API_DEFINITIONS_PATH);
}
const schema = schemaUtils.getSchemaByFilesPath(apiDefinitionsPath, buildSchemaOptions);
// parse the response object
const parsedResponse = responseAdapter.parseResponse(obj);
// validate the response object contains the required props
validateRequiredProps(parsedResponse);
// extract the request path schema
const { request, response } = parsedResponse;
const { path, method } = parsedResponse.request;
const { status } = parsedResponse.response;
const validator = schemaUtils.getValidatorByPathMethodAndCode(schema, request, response);
if (!(validator instanceof Object) || !(validator.validate instanceof Function)) {
throw new Error(`schema not found for ${JSON.stringify({ path, method, status })}`);
}
// validate
const predicate = validator.validate(response);
const { actual, expected } = ajvUtils.parseErrors(validator.errors, response);
// mark API as covered
setCoverage({ path, method, status });
return {
predicate,
actual,
expected,
errors: validator.errors,
matchMsg: messages.EXPECTED_RESPONSE_TO_MATCH_SCHEMA,
noMatchMsg: messages.EXPECTED_RESPONSE_TO_NOT_MATCH_SCHEMA,
};
};
module.exports.statusValidator = function statusValidator(expectedStatus, obj) {
// parse the response object
const parsedResponse = responseAdapter.parseResponse(obj);
// validate the response object is valid
if (!(parsedResponse instanceof Object)) {
throw new Error(messages.FAILED_TO_EXTRACT_RESPONSE_DETAILS);
}
const { response } = parsedResponse;
const { status, body } = response;
if (!status) {
throw new Error("required properties for validating schema are missing: 'status'");
}
// validate
return {
predicate: status === expectedStatus,
actual: { status, body },
expected: { status: expectedStatus },
matchMsg: `expected http status code ${status} to be ${expectedStatus}`,
noMatchMsg: `expected http status code ${status} to not be ${expectedStatus}`,
};
};
function validateRequiredProps(parsedResponse) {
if (!(parsedResponse instanceof Object)) {
throw new Error(messages.FAILED_TO_EXTRACT_RESPONSE_DETAILS);
}
const { request, response } = parsedResponse;
const missingRequestProps = filterMissingProps(request, ['path', 'method']);
const missingResponseProps = filterMissingProps(response, ['status']);
if (missingRequestProps.length > 0 || missingResponseProps.length > 0) {
const missingProps = missingRequestProps
.concat(missingResponseProps)
.map((prop) => `'${prop}'`)
.toString();
throw new Error(`required properties for validating schema are missing: ${missingProps}`);
}
}