swagger-coverage-cli
Version:
A Node.js CLI tool to measure test coverage of Swagger/OpenAPI specs using Postman collections or Newman run reports. Features smart endpoint mapping with intelligent status code prioritization and enhanced path matching.
734 lines (644 loc) • 22.3 kB
JavaScript
// match.js
"use strict";
const Ajv = require("ajv");
const ajv = new Ajv();
/**
* matchOperationsDetailed:
* - Iterates over each spec operation ("specOp") and creates a "coverage item"
* with properties like method, path, name, tags, expectedStatusCodes, etc.
* - For each specOp, searches Postman requests (pmReq) that match the method,
* path, and optionally checks code, strict query, strict body.
* - Builds an array of "coverage items", each with `unmatched` boolean and
* `matchedRequests` array for all matched Postman requests, including JS test scripts.
*
* @param {Array} specOps - array of operations from extractOperationsFromSpec, e.g.:
* [
* {
* method: "get",
* path: "/v2/artist/elements",
* statusCode: "200",
* tags: ["Artists","Collections"],
* expectedStatusCodes: ["200","400"],
* parameters: [...],
* requestBodyContent: [...],
* ...
* },
* ...
* ]
* @param {Array} postmanReqs - array of requests from extractRequestsFromPostman
* [
* {
* name: "Get Elements (Postman)",
* folder: "Artists",
* method: "get",
* rawUrl: "https://api.example.com/v2/artist/elements?foo=bar",
* testedStatusCodes: ["200","404"],
* queryParams: [...],
* bodyInfo: {...},
* testScripts: "pm.test('Status code is 200', function () { pm.response.to.have.status(200); });"
* },
* ...
* ]
* @param {Object} opts
* @param {boolean} opts.verbose
* @param {boolean} opts.strictQuery
* @param {boolean} opts.strictBody
* @returns {Array} coverageItems
* [
* {
* method: "GET",
* path: "/v2/artist/elements",
* name: "listElements",
* tags: ["Artists", "Collections"],
* expectedStatusCodes: ["200","400"],
* statusCode: "200",
* unmatched: false,
* matchedRequests: [
* {
* name: "Get Elements (Postman)",
* rawUrl: "https://api.example.com/v2/artist/elements?foo=bar",
* method: "GET",
* testedStatusCodes: ["200","404"],
* testScripts: "pm.test('Status code is 200', function () { pm.response.to.have.status(200); });"
* },
* ...
* ]
* },
* ...
* ]
*/
function matchOperationsDetailed(specOps, postmanReqs, { verbose, strictQuery, strictBody, smartMapping = true }) {
let coverageItems = [];
if (smartMapping) {
// Group operations by method and path to handle smart status code prioritization
const operationGroups = groupOperationsByMethodAndPath(specOps);
for (const groupKey in operationGroups) {
const operations = operationGroups[groupKey];
const smartMatches = findSmartMatches(operations, postmanReqs, { strictQuery, strictBody });
coverageItems = coverageItems.concat(smartMatches);
}
} else {
// Original matching logic
for (const specOp of specOps) {
// Initialize matchedRequests array
const coverageItem = {
method: specOp.method ? specOp.method.toUpperCase() : "GET",
path: specOp.path || "",
name: specOp.operationId || specOp.summary || "(No operationId in spec)",
statusCode: specOp.statusCode || "",
tags: specOp.tags || [],
expectedStatusCodes: specOp.expectedStatusCodes || [],
apiName: specOp.apiName || "",
sourceFile: specOp.sourceFile || "",
protocol: specOp.protocol || "rest",
unmatched: true,
matchedRequests: []
};
for (const pmReq of postmanReqs) {
if (doesMatchProtocolAware(specOp, pmReq, { strictQuery, strictBody })) {
coverageItem.unmatched = false;
coverageItem.matchedRequests.push({
name: pmReq.name,
rawUrl: pmReq.rawUrl,
method: pmReq.method.toUpperCase(),
testedStatusCodes: pmReq.testedStatusCodes,
testScripts: pmReq.testScripts || ""
});
}
}
coverageItems.push(coverageItem);
}
}
if (verbose) {
const totalCount = coverageItems.length;
const matchedCount = coverageItems.filter(i => !i.unmatched).length;
console.log(`Operations mapped: ${matchedCount}, not covered: ${totalCount - matchedCount}`);
if (smartMapping) {
const primaryMatches = coverageItems.filter(i => !i.unmatched && i.isPrimaryMatch);
const secondaryMatches = coverageItems.filter(i => !i.unmatched && !i.isPrimaryMatch);
console.log(`Smart mapping: ${primaryMatches.length} primary matches, ${secondaryMatches.length} secondary matches`);
}
}
return coverageItems;
}
/**
* doesMatch:
* - Compares a single specOp to a single pmReq to see if they match.
* - Checks method, path, and optional status-code presence in pmReq.testedStatusCodes.
* - If strictQuery is enabled, ensures required query params are present and conform.
* - If strictBody is enabled, ensures requestBody is JSON (if spec says application/json).
*
* @param {Object} specOp
* @param {Object} pmReq
* @param {boolean} strictQuery
* @param {boolean} strictBody
* @returns {boolean} whether pmReq matches specOp
*/
function doesMatch(specOp, pmReq, { strictQuery, strictBody }) {
// 1. Method
if (pmReq.method.toLowerCase() !== specOp.method.toLowerCase()) {
return false;
}
// 2. Path
if (!urlMatchesSwaggerPath(pmReq.rawUrl, specOp.path)) {
return false;
}
// 3. Status code
if (specOp.statusCode) {
const specStatusCode = specOp.statusCode.toString();
if (!pmReq.testedStatusCodes.includes(specStatusCode)) {
return false;
}
}
// 4. Strict Query
if (strictQuery) {
if (!checkQueryParamsStrict(specOp, pmReq)) {
return false;
}
}
// 5. Strict Body
if (strictBody) {
if (!checkRequestBodyStrict(specOp, pmReq)) {
return false;
}
}
return true;
}
/**
* checkQueryParamsStrict:
* - Example approach verifying required query params from specOp.parameters
*/
function checkQueryParamsStrict(specOp, pmReq) {
const queryParamsSpec = (specOp.parameters || []).filter(p => p.in === "query");
for (const p of queryParamsSpec) {
if (p.required) {
const found = pmReq.queryParams.some(q => q.key === p.name);
if (!found) return false;
}
// If there's a schema (enum/pattern/type) do advanced validation
if (p.schema && Object.keys(p.schema).length > 0) {
const paramValue = getParamValue(pmReq.queryParams, p.name);
if (paramValue !== undefined) {
if (!validateParamWithSchema(paramValue, p.schema)) {
return false;
}
} else if (p.required) {
return false;
}
}
}
return true;
}
/** Utility to find a query param value by key */
function getParamValue(queryParams, paramName) {
const qp = queryParams.find(q => q.key === paramName);
return qp ? qp.value : undefined;
}
/**
* checkRequestBodyStrict:
* - If specOp.requestBodyContent includes 'application/json',
* require pmReq.bodyInfo to be 'raw' and valid JSON
*/
function checkRequestBodyStrict(specOp, pmReq) {
if (!specOp.requestBodyContent) return true;
const hasJson = specOp.requestBodyContent.some(ct => ct.includes("application/json"));
if (!hasJson) return true;
if (!pmReq.bodyInfo || pmReq.bodyInfo.mode !== "raw") {
return false;
}
try {
JSON.parse(pmReq.bodyInfo.content);
return true;
} catch {
return false;
}
}
/**
* validateParamWithSchema:
* - Basic AJV-based validation for a single param value against a spec schema
* (type, pattern, enum, etc.)
*/
function validateParamWithSchema(value, paramSchema) {
// e.g.: { type: 'string', enum: ['foo','bar'], pattern: '^[a-z]+$' }
// Convert to a standard JSON schema snippet:
const schema = {
type: paramSchema.type || "string",
enum: paramSchema.enum,
pattern: paramSchema.pattern
// etc. (format, minLength, maxLength, etc.)
};
let data = value;
// Convert if type=number or boolean
if (schema.type === "number" || schema.type === "integer") {
const num = Number(value);
if (isNaN(num)) return false;
data = num;
} else if (schema.type === "boolean") {
if (value.toLowerCase() === "true") data = true;
else if (value.toLowerCase() === "false") data = false;
else return false;
}
const validate = ajv.compile(schema);
return validate(data);
}
/**
* urlMatchesSwaggerPath:
* - Replaces {param} segments with [^/]+ in a regex, ignoring query part
* - Enhanced with better parameter pattern matching
*/
function urlMatchesSwaggerPath(postmanUrl, swaggerPath) {
// Handle null/undefined URLs
if (!postmanUrl || !swaggerPath) {
return false;
}
let cleaned = postmanUrl.replace(/^(https?:\/\/)?\{\{.*?\}\}/, "");
cleaned = cleaned.replace(/^https?:\/\/[^/]+/, "");
cleaned = cleaned.split("?")[0];
cleaned = cleaned.replace(/\/+$/, "");
if (!cleaned) cleaned = "/";
// Enhanced regex generation with more flexible parameter matching
const regexStr =
"^" +
swaggerPath
.replace(/\/+$/, "")
.replace(/\{[^}]+\}/g, "[^/]+") +
"$";
const re = new RegExp(regexStr);
return re.test(cleaned);
}
/**
* Calculate path similarity for fuzzy matching
*/
function calculatePathSimilarity(postmanUrl, swaggerPath) {
// Handle null/undefined inputs
if (!postmanUrl || !swaggerPath) {
return 0;
}
const cleanedUrl = postmanUrl.replace(/^(https?:\/\/)?\{\{.*?\}\}/, "")
.replace(/^https?:\/\/[^/]+/, "")
.split("?")[0]
.replace(/\/+$/, "");
const normalizedUrl = cleanedUrl || "/";
const normalizedSwagger = swaggerPath.replace(/\/+$/, "") || "/";
// Direct match gets highest score
if (urlMatchesSwaggerPath(postmanUrl, swaggerPath)) {
return 1.0;
}
// Split paths into segments for comparison
const urlSegments = normalizedUrl.split('/').filter(s => s);
const swaggerSegments = normalizedSwagger.split('/').filter(s => s);
if (urlSegments.length !== swaggerSegments.length) {
return 0; // Different segment count = no match
}
// Handle root path special case
if (urlSegments.length === 0 && swaggerSegments.length === 0) {
return 1.0;
}
let matches = 0;
for (let i = 0; i < urlSegments.length; i++) {
const urlSeg = urlSegments[i];
const swaggerSeg = swaggerSegments[i];
if (urlSeg === swaggerSeg) {
matches += 1; // Exact segment match
} else if (swaggerSeg.startsWith('{') && swaggerSeg.endsWith('}')) {
matches += 0.8; // Parameter match (slightly lower score)
} else if (urlSeg.match(/^\d+$/) && swaggerSeg.startsWith('{') && swaggerSeg.endsWith('}')) {
matches += 0.9; // Numeric parameter match (higher confidence)
} else {
// No match for this segment
return 0;
}
}
return urlSegments.length > 0 ? matches / urlSegments.length : 0;
}
/**
* Group operations by method and path for smart status code handling
*/
function groupOperationsByMethodAndPath(specOps) {
const groups = {};
for (const op of specOps) {
const key = `${op.method}:${op.path}`;
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(op);
}
return groups;
}
/**
* Find smart matches for a group of operations (same method/path, different status codes)
*/
function findSmartMatches(operations, postmanReqs, { strictQuery, strictBody }) {
const coverageItems = [];
// Sort operations by status code priority (2xx first, then others)
const prioritizedOps = operations.sort((a, b) => {
const aCode = parseInt(a.statusCode) || 999;
const bCode = parseInt(b.statusCode) || 999;
const aIsSuccess = aCode >= 200 && aCode < 300;
const bIsSuccess = bCode >= 200 && bCode < 300;
if (aIsSuccess && !bIsSuccess) return -1;
if (!aIsSuccess && bIsSuccess) return 1;
return aCode - bCode;
});
// Find matching requests for this operation group
const matchingRequests = [];
for (const pmReq of postmanReqs) {
// Check if this request could match any operation in the group
if (operations.some(op => doesMatchBasicProtocolAware(op, pmReq, { strictQuery, strictBody }))) {
matchingRequests.push(pmReq);
}
}
let primaryMatchAssigned = false;
for (const specOp of prioritizedOps) {
const coverageItem = {
method: specOp.method ? specOp.method.toUpperCase() : "GET",
path: specOp.path || "",
name: specOp.operationId || specOp.summary || "(No operationId in spec)",
statusCode: specOp.statusCode || "",
tags: specOp.tags || [],
expectedStatusCodes: specOp.expectedStatusCodes || [],
apiName: specOp.apiName || "",
sourceFile: specOp.sourceFile || "",
protocol: specOp.protocol || "rest",
unmatched: true,
matchedRequests: [],
isPrimaryMatch: false,
matchConfidence: 0
};
// Find requests that match this specific operation
for (const pmReq of matchingRequests) {
const matchResult = doesMatchWithConfidence(specOp, pmReq, { strictQuery, strictBody });
if (matchResult.matches) {
// Only mark as matched if:
// 1. This is the primary match (first successful status code), OR
// 2. No primary match has been assigned yet and this request actually tests this status code, OR
// 3. Operation has no specific status code (e.g., statusCode is null)
const requestTestsThisStatus = specOp.statusCode && pmReq.testedStatusCodes.includes(specOp.statusCode.toString());
const isPrimaryCandidate = !primaryMatchAssigned && isSuccessStatusCode(specOp.statusCode);
const hasNoStatusCode = !specOp.statusCode;
if (isPrimaryCandidate || requestTestsThisStatus || hasNoStatusCode) {
coverageItem.unmatched = false;
coverageItem.matchConfidence = Math.max(coverageItem.matchConfidence, matchResult.confidence);
coverageItem.matchedRequests.push({
name: pmReq.name,
rawUrl: pmReq.rawUrl,
method: pmReq.method.toUpperCase(),
testedStatusCodes: pmReq.testedStatusCodes,
testScripts: pmReq.testScripts || "",
confidence: matchResult.confidence
});
if (isPrimaryCandidate || hasNoStatusCode) {
coverageItem.isPrimaryMatch = true;
primaryMatchAssigned = true;
}
}
}
}
coverageItems.push(coverageItem);
}
return coverageItems;
}
/**
* Basic matching without status code requirement (for grouping)
*/
function doesMatchBasic(specOp, pmReq, { strictQuery, strictBody }) {
// Handle missing methods
if (!pmReq.method || !specOp.method) {
return false;
}
// 1. Method
if (pmReq.method.toLowerCase() !== specOp.method.toLowerCase()) {
return false;
}
// 2. Path
if (!urlMatchesSwaggerPath(pmReq.rawUrl, specOp.path)) {
return false;
}
// 3. Strict Query (if enabled)
if (strictQuery) {
if (!checkQueryParamsStrict(specOp, pmReq)) {
return false;
}
}
// 4. Strict Body (if enabled)
if (strictBody) {
if (!checkRequestBodyStrict(specOp, pmReq)) {
return false;
}
}
return true;
}
/**
* Enhanced matching with confidence scoring
*/
function doesMatchWithConfidence(specOp, pmReq, { strictQuery, strictBody }) {
let confidence = 0;
// Basic match first
if (!doesMatchBasicProtocolAware(specOp, pmReq, { strictQuery, strictBody })) {
return { matches: false, confidence: 0 };
}
// Base confidence for method and path match
confidence += 0.6;
// Status code matching
if (specOp.statusCode) {
const specStatusCode = specOp.statusCode.toString();
if (pmReq.testedStatusCodes.includes(specStatusCode)) {
confidence += 0.3; // High bonus for exact status code match
} else if (pmReq.testedStatusCodes.some(code => isSuccessStatusCode(code)) &&
isSuccessStatusCode(specStatusCode)) {
confidence += 0.2; // Medium bonus for both being success codes
} else {
// No status code penalty, but don't add bonus
}
} else {
confidence += 0.1; // Small bonus for operations without specific status codes
}
// Additional confidence for parameter matching
if (strictQuery || strictBody) {
confidence += 0.1; // Bonus for strict validation passing
}
return {
matches: true,
confidence: Math.min(confidence, 1.0) // Cap at 1.0
};
}
/**
* Check if a status code represents success (2xx)
*/
function isSuccessStatusCode(statusCode) {
if (!statusCode) return false;
const code = parseInt(statusCode);
return code >= 200 && code < 300;
}
/**
* Protocol-aware URL matching for gRPC and GraphQL
*/
function urlMatchesPath(postmanUrl, specPath, protocol) {
if (protocol === 'grpc') {
return urlMatchesGrpcPath(postmanUrl, specPath);
} else if (protocol === 'graphql') {
return urlMatchesGraphQLPath(postmanUrl, specPath);
} else {
// Default to OpenAPI/REST matching
return urlMatchesSwaggerPath(postmanUrl, specPath);
}
}
/**
* gRPC path matching - gRPC services are accessed via HTTP/2 POST to service/method
*/
function urlMatchesGrpcPath(postmanUrl, grpcPath) {
if (!postmanUrl || !grpcPath) {
return false;
}
// Clean the Postman URL
let cleaned = postmanUrl.replace(/^(https?:\/\/)?{{.*?}}/, "");
cleaned = cleaned.replace(/^https?:\/\/[^/]+/, "");
cleaned = cleaned.split("?")[0];
cleaned = cleaned.replace(/\/+$/, "");
if (!cleaned) cleaned = "/";
// gRPC paths are in format /package.service/method
// Match exact paths or allow flexible service matching
return cleaned === grpcPath || cleaned.endsWith(grpcPath);
}
/**
* GraphQL path matching - GraphQL typically uses a single endpoint /graphql
*/
function urlMatchesGraphQLPath(postmanUrl, graphqlPath) {
if (!postmanUrl || !graphqlPath) {
return false;
}
// Clean the Postman URL
let cleaned = postmanUrl.replace(/^(https?:\/\/)?{{.*?}}/, "");
cleaned = cleaned.replace(/^https?:\/\/[^/]+/, "");
cleaned = cleaned.split("?")[0];
cleaned = cleaned.replace(/\/+$/, "");
if (!cleaned) cleaned = "/";
// GraphQL typically uses /graphql endpoint
return cleaned === graphqlPath ||
cleaned === '/graphql' ||
cleaned.endsWith('/graphql');
}
/**
* Protocol-aware request body matching
*/
function matchesRequestBody(postmanReq, specOp) {
const protocol = specOp.protocol || 'rest';
if (protocol === 'grpc') {
// gRPC uses protobuf or gRPC-specific content types
const contentType = postmanReq.bodyInfo?.contentType || '';
return contentType.includes('grpc') ||
contentType.includes('protobuf') ||
contentType.includes('application/grpc');
} else if (protocol === 'graphql') {
// GraphQL uses JSON with query/mutation/subscription
const contentType = postmanReq.bodyInfo?.contentType || '';
const body = postmanReq.bodyInfo?.raw || '';
return contentType.includes('json') &&
(body.includes('query') || body.includes('mutation') || body.includes('subscription'));
} else {
// Default REST/OpenAPI matching
return true; // Use existing logic
}
}
/**
* Enhanced doesMatch function with protocol awareness
*/
function doesMatchProtocolAware(specOp, pmReq, { strictQuery, strictBody }) {
const protocol = specOp.protocol || 'rest';
// 1. Method - gRPC and GraphQL typically use POST
if (protocol === 'grpc' || protocol === 'graphql') {
if (pmReq.method.toLowerCase() !== 'post') {
return false;
}
} else {
if (pmReq.method.toLowerCase() !== specOp.method.toLowerCase()) {
return false;
}
}
// 2. Protocol-aware path matching
if (!urlMatchesPath(pmReq.rawUrl, specOp.path, protocol)) {
return false;
}
// 3. Status code check (same for all protocols)
if (specOp.statusCode) {
const specStatusCode = specOp.statusCode.toString();
if (!pmReq.testedStatusCodes.includes(specStatusCode)) {
return false;
}
}
// 4. Protocol-aware body matching
if (strictBody && (protocol === 'grpc' || protocol === 'graphql')) {
if (!matchesRequestBody(pmReq, specOp)) {
return false;
}
} else if (strictBody) {
// Use existing REST body validation
if (!checkRequestBodyStrict(specOp, pmReq)) {
return false;
}
}
// 5. Query parameters (mainly for REST APIs)
if (strictQuery && protocol === 'rest') {
if (!checkQueryParamsStrict(specOp, pmReq)) {
return false;
}
}
return true;
}
/**
* Protocol-aware basic matching without status code requirement (for grouping)
*/
function doesMatchBasicProtocolAware(specOp, pmReq, { strictQuery, strictBody }) {
const protocol = specOp.protocol || 'rest';
// Handle missing methods
if (!pmReq.method || !specOp.method) {
return false;
}
// 1. Method - protocol aware
if (protocol === 'grpc' || protocol === 'graphql') {
if (pmReq.method.toLowerCase() !== 'post') {
return false;
}
} else {
if (pmReq.method.toLowerCase() !== specOp.method.toLowerCase()) {
return false;
}
}
// 2. Protocol-aware path matching
if (!urlMatchesPath(pmReq.rawUrl, specOp.path, protocol)) {
return false;
}
// 3. Strict Query (mainly for REST)
if (strictQuery && protocol === 'rest') {
if (!checkQueryParamsStrict(specOp, pmReq)) {
return false;
}
}
// 4. Strict Body (protocol aware)
if (strictBody && (protocol === 'grpc' || protocol === 'graphql')) {
if (!matchesRequestBody(pmReq, specOp)) {
return false;
}
} else if (strictBody) {
if (!checkRequestBodyStrict(specOp, pmReq)) {
return false;
}
}
return true;
}
module.exports = {
matchOperationsDetailed,
urlMatchesSwaggerPath,
validateParamWithSchema,
matchOperations: matchOperationsDetailed,
groupOperationsByMethodAndPath,
findSmartMatches,
isSuccessStatusCode,
calculatePathSimilarity,
urlMatchesPath,
urlMatchesGrpcPath,
urlMatchesGraphQLPath,
doesMatchProtocolAware,
doesMatchBasicProtocolAware,
matchesRequestBody
};