Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions auto-detect-newman.html
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ <h1>Swagger Coverage Report</h1>
&#128262; <!-- flashlight icon -->
</button>
<div class="meta-info">
<p><strong>Timestamp:</strong> 9/15/2025, 4:45:45 PM</p>
<p><strong>Timestamp:</strong> 9/16/2025, 5:55:19 PM</p>
<p><strong>API Spec:</strong> Test API</p>

<p><strong>Postman Collection:</strong> Test Newman Collection</p>
Expand Down Expand Up @@ -441,7 +441,7 @@ <h1>Swagger Coverage Report</h1>
hljs.highlightAll();

// coverageData from server
let coverageData = [{"method":"GET","path":"/users","name":"getUsers","statusCode":"200","tags":[],"expectedStatusCodes":["200"],"apiName":"Test API","sourceFile":"test-api.yaml","unmatched":false,"matchedRequests":[{"name":"Get Users","rawUrl":"https://api.example.com/users","method":"GET","testedStatusCodes":["200"],"testScripts":"// Status code is 200"}]},{"method":"POST","path":"/users","name":"createUser","statusCode":"201","tags":[],"expectedStatusCodes":["201","400"],"apiName":"Test API","sourceFile":"test-api.yaml","unmatched":false,"matchedRequests":[{"name":"Create User","rawUrl":"https://api.example.com/users","method":"POST","testedStatusCodes":["201"],"testScripts":"// Status code is 201"}]},{"method":"POST","path":"/users","name":"createUser","statusCode":"400","tags":[],"expectedStatusCodes":["201","400"],"apiName":"Test API","sourceFile":"test-api.yaml","unmatched":true,"matchedRequests":[]},{"method":"GET","path":"/users/{id}","name":"getUserById","statusCode":"200","tags":[],"expectedStatusCodes":["200","404"],"apiName":"Test API","sourceFile":"test-api.yaml","unmatched":true,"matchedRequests":[]},{"method":"GET","path":"/users/{id}","name":"getUserById","statusCode":"404","tags":[],"expectedStatusCodes":["200","404"],"apiName":"Test API","sourceFile":"test-api.yaml","unmatched":true,"matchedRequests":[]}];
let coverageData = [{"method":"GET","path":"/users","name":"getUsers","statusCode":"200","tags":[],"expectedStatusCodes":["200"],"apiName":"Test API","sourceFile":"test-api.yaml","operationId":"getUsers","unmatched":false,"matchedRequests":[{"name":"Get Users","rawUrl":"https://api.example.com/users","method":"GET","testedStatusCodes":["200"],"testScripts":"// Status code is 200"}]},{"method":"POST","path":"/users","name":"createUser","statusCode":"201","tags":[],"expectedStatusCodes":["201","400"],"apiName":"Test API","sourceFile":"test-api.yaml","operationId":"createUser","unmatched":false,"matchedRequests":[{"name":"Create User","rawUrl":"https://api.example.com/users","method":"POST","testedStatusCodes":["201"],"testScripts":"// Status code is 201"}]},{"method":"POST","path":"/users","name":"createUser","statusCode":"400","tags":[],"expectedStatusCodes":["201","400"],"apiName":"Test API","sourceFile":"test-api.yaml","operationId":"createUser","unmatched":true,"matchedRequests":[]},{"method":"GET","path":"/users/{id}","name":"getUserById","statusCode":"200","tags":[],"expectedStatusCodes":["200","404"],"apiName":"Test API","sourceFile":"test-api.yaml","operationId":"getUserById","unmatched":true,"matchedRequests":[]},{"method":"GET","path":"/users/{id}","name":"getUserById","statusCode":"404","tags":[],"expectedStatusCodes":["200","404"],"apiName":"Test API","sourceFile":"test-api.yaml","operationId":"getUserById","unmatched":true,"matchedRequests":[]}];
let apiCount = 1;

// Merge duplicates for display only
Expand Down
40 changes: 34 additions & 6 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@ const { loadNewmanReport, extractRequestsFromNewman } = require("./lib/newman");
const { matchOperationsDetailed } = require("./lib/match");
const { generateHtmlReport } = require("./lib/report");
const { loadExcelSpec } = require("./lib/excel");
const { loadAndParseProto, extractOperationsFromProto } = require("./lib/grpc");
const { loadAndParseGraphQL, extractOperationsFromGraphQL } = require("./lib/graphql");

const program = new Command();

program
.name("swagger-coverage-cli")
.description(
"CLI tool for comparing OpenAPI/Swagger specifications with a Postman collection or Newman run report, producing an enhanced HTML report"
"CLI tool for comparing API specifications (OpenAPI/Swagger, gRPC, GraphQL) with a Postman collection or Newman run report, producing an enhanced HTML report"
)
.version("4.0.0")
.argument("<swaggerFiles>", "Path(s) to the Swagger/OpenAPI file(s) (JSON or YAML). Use comma-separated values for multiple files.")
.argument("<swaggerFiles>", "Path(s) to the API specification file(s) (OpenAPI/Swagger JSON/YAML, gRPC .proto, GraphQL .graphql/.gql, or CSV). Use comma-separated values for multiple files.")
.argument("<postmanCollectionOrNewmanReport>", "Path to the Postman collection (JSON) or Newman run report (JSON).")
.option("-v, --verbose", "Show verbose debug info")
.option("--strict-query", "Enable strict validation of query parameters")
Expand All @@ -39,24 +41,50 @@ program
let allSpecOperations = [];
let allSpecNames = [];
const excelExtensions = [".xlsx", ".xls", ".csv"];
const grpcExtensions = [".proto"];
const graphqlExtensions = [".graphql", ".gql"];

// Process each swagger file
// Process each API specification file
for (const swaggerFile of files) {
const ext = path.extname(swaggerFile).toLowerCase();
let specOperations;
let specName;

if (excelExtensions.includes(ext)) {
// Parse Excel
// Parse Excel/CSV
specOperations = loadExcelSpec(swaggerFile);
specName = path.basename(swaggerFile);
} else if (grpcExtensions.includes(ext)) {
// Parse gRPC proto file
const proto = loadAndParseProto(swaggerFile);
specName = proto.package || path.basename(swaggerFile, ext);
if (verbose) {
console.log(
"gRPC proto file loaded successfully:",
specName,
`(${proto.services.length} services)`
);
}
specOperations = extractOperationsFromProto(proto, verbose);
} else if (graphqlExtensions.includes(ext)) {
// Parse GraphQL schema file
const schema = loadAndParseGraphQL(swaggerFile);
specName = path.basename(swaggerFile, ext);
if (verbose) {
console.log(
"GraphQL schema loaded successfully:",
specName,
`(${schema.queries.length + schema.mutations.length + schema.subscriptions.length} operations)`
);
}
specOperations = extractOperationsFromGraphQL(schema, verbose);
} else {
// Original Swagger flow
// Original Swagger/OpenAPI flow
const spec = await loadAndParseSpec(swaggerFile);
specName = spec.info.title;
if (verbose) {
console.log(
"Specification loaded successfully:",
"OpenAPI/Swagger specification loaded successfully:",
specName,
spec.info.version
);
Expand Down
206 changes: 206 additions & 0 deletions lib/graphql.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// graphql.js

'use strict';

const fs = require('fs');
const path = require('path');

/**
* Load and parse GraphQL schema file (.graphql, .gql)
*/
function loadAndParseGraphQL(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`GraphQL schema file not found: ${filePath}`);
}

const content = fs.readFileSync(filePath, 'utf8');

// Basic validation - check if it looks like a GraphQL schema
const hasValidGraphQLKeywords = /\b(type|schema|Query|Mutation|Subscription|input|enum|interface|union)\b/i.test(content);
if (!hasValidGraphQLKeywords) {
throw new Error('Invalid GraphQL schema format');
}

return parseGraphQLContent(content);
}

/**
* Parse GraphQL schema content and extract types, queries, mutations, subscriptions
*/
function parseGraphQLContent(content) {
// Remove comments
const cleanContent = content
.replace(/#.*$/gm, '')
.replace(/"""[\s\S]*?"""/g, '')
.replace(/"[^"]*"/g, '');

const schema = {
queries: [],
mutations: [],
subscriptions: [],
types: []
};

// Extract root schema definition
const schemaMatch = cleanContent.match(/schema\s*\{([^}]+)\}/);
let rootTypes = {
query: 'Query',
mutation: 'Mutation',
subscription: 'Subscription'
};

if (schemaMatch) {
const schemaBody = schemaMatch[1];
const queryMatch = schemaBody.match(/query:\s*(\w+)/);
const mutationMatch = schemaBody.match(/mutation:\s*(\w+)/);
const subscriptionMatch = schemaBody.match(/subscription:\s*(\w+)/);

if (queryMatch) rootTypes.query = queryMatch[1];
if (mutationMatch) rootTypes.mutation = mutationMatch[1];
if (subscriptionMatch) rootTypes.subscription = subscriptionMatch[1];
}

// Extract type definitions
const typeRegex = /type\s+(\w+)\s*\{([^}]+)\}/g;
let typeMatch;

while ((typeMatch = typeRegex.exec(cleanContent)) !== null) {
const typeName = typeMatch[1];
const typeBody = typeMatch[2];

// Extract fields from type
const fields = extractFieldsFromType(typeBody);

const typeInfo = {
name: typeName,
fields: fields
};

// Categorize based on root types
if (typeName === rootTypes.query) {
schema.queries = fields;
} else if (typeName === rootTypes.mutation) {
schema.mutations = fields;
} else if (typeName === rootTypes.subscription) {
schema.subscriptions = fields;
} else {
schema.types.push(typeInfo);
}
}

return schema;
}

/**
* Extract fields from a GraphQL type definition
*/
function extractFieldsFromType(typeBody) {
const fields = [];

// Match field definitions: fieldName(args): ReturnType
const fieldRegex = /(\w+)(\([^)]*\))?\s*:\s*([^,\n]+)/g;
let fieldMatch;

while ((fieldMatch = fieldRegex.exec(typeBody)) !== null) {
const fieldName = fieldMatch[1];
const args = fieldMatch[2] || '';
const returnType = fieldMatch[3].trim();

// Parse arguments if present
const parsedArgs = parseArguments(args);

fields.push({
name: fieldName,
type: returnType,
arguments: parsedArgs
});
}

return fields;
}

/**
* Parse GraphQL field arguments
*/
function parseArguments(argsString) {
if (!argsString || argsString === '()') {
return [];
}

const args = [];
// Remove parentheses and split by comma
const argContent = argsString.slice(1, -1);
const argParts = argContent.split(',');

for (const argPart of argParts) {
const trimmed = argPart.trim();
if (trimmed) {
const colonIndex = trimmed.indexOf(':');
if (colonIndex > 0) {
const argName = trimmed.substring(0, colonIndex).trim();
const argType = trimmed.substring(colonIndex + 1).trim();
args.push({
name: argName,
type: argType
});
}
}
}

return args;
}

/**
* Extract operations from parsed GraphQL schema
* Each query, mutation, and subscription becomes an "operation"
*/
function extractOperationsFromGraphQL(schema, verbose = false) {
const operations = [];

// Process queries
for (const query of schema.queries) {
operations.push(createGraphQLOperation(query, 'query'));
}

// Process mutations
for (const mutation of schema.mutations) {
operations.push(createGraphQLOperation(mutation, 'mutation'));
}

// Process subscriptions
for (const subscription of schema.subscriptions) {
operations.push(createGraphQLOperation(subscription, 'subscription'));
}

if (verbose) {
console.log(`Extracted ${operations.length} GraphQL operations from schema`);
}

return operations;
}

/**
* Create an operation object for a GraphQL field
*/
function createGraphQLOperation(field, operationType) {
return {
method: 'POST', // GraphQL typically uses POST
path: '/graphql', // Standard GraphQL endpoint
protocol: 'graphql',
operationType: operationType, // query, mutation, subscription
fieldName: field.name,
returnType: field.type,
arguments: field.arguments,
operationId: `${operationType}_${field.name}`,
summary: `GraphQL ${operationType}: ${field.name}`,
tags: [operationType, 'GraphQL'],
expectedStatusCodes: ['200'], // GraphQL typically returns 200 for both success and errors
statusCode: '200' // Default success
};
}

module.exports = {
loadAndParseGraphQL,
extractOperationsFromGraphQL,
parseGraphQLContent
};
Loading
Loading