UNPKG

reqover

Version:

Reqover is language agnostic tool that gives a picture about coverage of APIs based on Open API (Swagger).

495 lines (432 loc) 13.3 kB
var swaggerParser = require("swagger-parser"); const UrlPattern = require("url-pattern"); const merge = require("deepmerge"); const _ = require("lodash"); const uuid = require("uuid"); const $RefParser = require("@apidevtools/json-schema-ref-parser"); const HTTP_METHODS = [ "GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS", "HEAD", ]; async function getSwaggerPaths(baseApiPath, swaggerSpec) { if (!swaggerSpec) { return {}; } const swaggerInfo = await swaggerParser.parse(swaggerSpec); const resolvedSchema = await $RefParser.dereference(swaggerInfo); const { paths } = resolvedSchema; const apiPaths = Object.entries(paths); const tags = swaggerInfo.tags?.map((t) => t.name) || ["default"]; const apiList = apiPaths.map(([apiPath, value]) => { let path = `${apiPath}`; if (baseApiPath) { path = `${baseApiPath}${apiPath}`; } const parametersFromSpecification = value.parameters || []; const methods = Object.entries(value) .filter(([methodName]) => HTTP_METHODS.includes(methodName.toUpperCase())) .map(([methodName, data]) => { const { responses, parameters, requestBody } = data; const tags = data.tags || ["default"]; let declaredParameters = parameters || []; const declaredResponses = Object.entries(responses).map(([k, v]) => { return { code: k, description: v.description, }; }); declaredParameters = declaredParameters.concat( parametersFromSpecification ); const requestBodyParameter = convertRequestBodyToParameter(requestBody); if (Object.keys(requestBodyParameter).length > 0) { declaredParameters.push(requestBodyParameter); } const deprecated = data.deprecated || false; return { path: path, tags: tags, name: methodName.toUpperCase(), responses: declaredResponses, parameters: declaredParameters, deprecated, }; }); return { path: path, methods }; }); return { basePath: baseApiPath, tags: tags, apiList: apiList }; } function convertRequestBodyToParameter(requestBody) { if (requestBody) { return { in: "body", name: "body", schema: {}, required: requestBody.required || false, description: "", }; } return {}; } async function getSwaggerCoverageReport(build) { const apiData = build.swaggerApiList; const results = build.swagger_results; const ignoreConfig = build.ignoreConfig; const enableDebug = build.enableDebug; const includeOptionalParams = build.includeOptionalParams; const swaggerUrls = apiData.apiList.map((u) => u.path); const apiCovList = await Promise.all( apiData.apiList.map(async (apiItem) => { const coveredApis = await findCoveredApis(apiItem, swaggerUrls, results, { enableDebug, }); const coveredMethods = apiItem.methods.map((method) => { const skipStatusCodes = getStatusesToSkip(ignoreConfig, method); const { name, responses, parameters } = method; const coveredMethodNames = coveredApis.filter((c) => c.method == name); const { coveredStatusCodes, missingStatusCodes } = getStatusCodesCoverage( coveredMethodNames, responses, skipStatusCodes ); const { requestsCount, bodies } = getMetadata( method, coveredMethodNames, coveredApis ); const { coveredParameters, missingParameters } = getParametersCoverage( name, coveredMethodNames, parameters, bodies ); const { coverage, status } = calculateCoverage( coveredStatusCodes, missingStatusCodes, coveredParameters, missingParameters, { includeOptionalParams, } ); return { id: uuid.v4(), path: apiItem["path"], tags: method.tags, requests: requestsCount, method: name, deprecated: method.deprecated, coverage, status, responses: { all: responses, missed: missingStatusCodes, covered: coveredStatusCodes, skipped: skipStatusCodes.filter((c) => responses.includes(c)), }, parameters: { covered: coveredParameters, missed: missingParameters, }, }; }); return coveredMethods; }) ); const result = apiCovList.flat(); const tags = apiData.tags; const missing = result.filter((res) => res.coverage == 0); const partial = result.filter( (res) => res.coverage > 0 && res.coverage < 100 ); const full = result.filter( (res) => res.coverage == 100 && res.responses.skipped.length == 0 ); const hasSkippedCodes = result.filter( (res) => res.responses.skipped.length > 0 && res.requests > 0 && res.responses.missed.length == 0 ); const all = mapItems( _.mapValues(_.groupBy(result, "tags"), (clist) => clist.map((res) => _.omit(res, "tags")) ) ); const missed = mapItems( _.mapValues(_.groupBy(missing, "tags"), (clist) => clist.map((res) => _.omit(res, "tags")) ) ); const partially = mapItems( _.mapValues(_.groupBy(partial, "tags"), (clist) => clist.map((res) => _.omit(res, "tags")) ) ); const fully = mapItems( _.mapValues(_.groupBy(full, "tags"), (clist) => clist.map((res) => _.omit(res, "tags")) ) ); const hasSkipCodes = mapItems( _.mapValues(_.groupBy(hasSkippedCodes, "tags"), (clist) => clist.map((res) => _.omit(res, "tags")) ) ); const partialPercent = Math.round((partial.length / result.length) * 100); const fullPercent = Math.round((full.length / result.length) * 100); const skippedPercent = Math.round( (hasSkippedCodes.length / result.length) * 100 ); const missingPercent = 100 - (partialPercent + fullPercent + skippedPercent); return { baseApiPath: apiData.basePath, tags: tags, summary: { operations: { missing: missingPercent, partial: partialPercent, full: fullPercent, skipped: skippedPercent, }, }, all: { size: result.length, items: all }, missing: { size: missing.length, items: missed }, partial: { size: partial.length, items: partially }, full: { size: full.length, items: fully }, hasSkipCodes: { size: hasSkippedCodes.length, items: hasSkipCodes }, }; } const findCoveredApis = async (apiItem, swaggerUrls, results, config) => { if (!results) { return []; } const apiPath = apiItem["path"]; if (config.enableDebug) { console.log(`\n=== Debug info for API path ${apiPath}`); } const filteredResults = results .filter((path) => { const currentPath = path["path"]; if (apiPath != currentPath && isSwaggerUrl(swaggerUrls, currentPath)) { return false; } const isMatch = regExMatchOfPath(apiPath, currentPath); return isMatch; }) .map((api) => { const currentPath = api["path"]; const match = regExMatchOfPath(apiPath, currentPath); if (match) { api.parameters.push( ...Object.keys(match).map((k) => { return { name: k, value: match[k] }; }) ); } return { ...api, }; }); if (config.enableDebug) { console.log(`Found ${filteredResults.length} results for path ${apiPath}`); filteredResults.forEach((r) => { console.log(`Matching result ${JSON.stringify(r)}`); }); } return filteredResults; }; function isSwaggerUrl(swaggerUrls, path) { return swaggerUrls.includes(path); } const regExMatchOfPath = (apiPath, rPath) => { return new UrlPattern(apiPath.replace(/\/{/g, "{/:"), { optionalSegmentStartChar: "{", optionalSegmentEndChar: "}", segmentNameCharset: "a-zA-Z0-9_-", }).match(rPath); }; const getStatusCodesCoverage = ( coveredMethodNames, responses, skipStatusCodes ) => { let coveredStatusCodes = [ ...new Set(coveredMethodNames.map((m) => m.statusCode)), ]; let missingStatusCodes = responses.filter( (r) => !coveredStatusCodes.includes(r.code) ); coveredStatusCodes = coveredStatusCodes.map((code) => { const coveredStatusCode = responses.filter((r) => r.code === code); if (coveredStatusCode.length > 0) { const covered = coveredStatusCode[0]; return { code, declared: true, description: covered.description }; } else { return { code, declared: false, description: "" }; } }); if (skipStatusCodes) { missingStatusCodes = missingStatusCodes.map((m) => { if (skipStatusCodes.includes(parseInt(m.code))) { return { ...m, ignored: true }; } else { return { ...m, ignored: false }; } }); } return { coveredStatusCodes, missingStatusCodes }; }; const getParametersCoverage = ( methodName, coveredMethodNames, parameters, bodies ) => { const coveredMethodParameters = [ ...new Set( coveredMethodNames .map((m) => { const parameters = m.parameters.map((it) => { return { ...it, statusCode: m.statusCode }; }); return parameters; }) .flat() ), ]; if (methodName != "GET" && bodies.length > 0) { const bodyParams = parameters.filter((p) => p.in == "body"); if (bodyParams.length > 0) { const bodyParameterName = bodyParams[0].name; coveredMethodNames.forEach((method) => { coveredMethodParameters.push({ name: bodyParameterName, value: method.body, statusCode: method.statusCode, }); }); } } const methodParameters = parameters ?.filter((p) => p.in !== "header") .map(({ name, required, type, ...p }) => { const items = p.items || {}; const result = { name, required: required || false, in: p.in, type, items: items, description: p.description, }; const coveredParameters = coveredMethodParameters.filter((cp) => cp.name == name) || []; return { ...result, covered: coveredParameters }; }); const coveredParameters = methodParameters.filter( (cp) => cp.covered.length > 0 ); const missingParameters = methodParameters.filter( (cp) => cp.covered.length == 0 ); return { coveredParameters, missingParameters }; }; const getMetadata = (method, coveredMethodNames, coveredApis) => { let requestsCount = 0; let bodies = []; if (coveredMethodNames.length > 0) { const covered = coveredApis.filter((a) => a.method === method.name); requestsCount = covered.length; bodies = covered .map((ca) => ca.body) .filter((b) => b && Object.keys(b).length > 0); } return { requestsCount, bodies }; }; const calculateCoverage = ( coveredStatusCodes, missingStatusCodes, coveredParameters, missingParameters, options ) => { const missingNotIgnoredStatusCodes = missingStatusCodes.filter( (code) => !code.ignored ); const onlyRequiredParams = missingParameters.filter((p) => p.required); let totalParameters = coveredParameters.length + onlyRequiredParams.length; if (options.includeOptionalParams) { totalParameters = coveredParameters.length + missingParameters.length; } const totalStatusCodes = coveredStatusCodes.length + missingNotIgnoredStatusCodes.length; let coverage = +( ((coveredStatusCodes.length + coveredParameters.length) / (totalStatusCodes + totalParameters)) * 100 ).toFixed(); if (isNaN(coverage)) { coverage = 0; } let status = "danger"; status = +coverage >= 0 && +coverage < 100 ? "warning" : "success"; return { coverage, status }; }; function mergeBody(bodies) { const combineMerge = (target, source, options) => { const destination = target.slice(); source.forEach((item, index) => { if (typeof destination[index] === "undefined") { destination[index] = options.cloneUnlessOtherwiseSpecified( item, options ); } else if (options.isMergeableObject(item)) { destination[index] = merge(target[index], item, options); } else if (target.indexOf(item) === -1) { destination.push(item); } }); return destination; }; return merge.all(bodies, { arrayMerge: combineMerge }); } function getStatusesToSkip(pathConfig, method) { const globalIgnore = pathConfig?.status || []; if (!pathConfig) { return globalIgnore; } const item = pathConfig[method.path]; if (!item) { return globalIgnore; } const result = item[method.name]; if (result) { return globalIgnore.concat(result.status); } return globalIgnore; } function mapItems(items) { if (Object.keys(items).length === 0) { return {}; } return Object.assign( ...Object.entries(items).map(([k, v]) => ({ [k]: { id: uuid.v4(), data: v, }, })) ); } module.exports = { getSwaggerPaths, getSwaggerCoverageReport, };