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
JavaScript
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,
};