#!/usr/bin/env node /** * API compatibility diff tool * Compares two OpenAPI 3.x specs (YAML or JSON) and reports additive vs breaking changes. * * Usage: * node scripts/api-compat-diff.mjs [--output json|text] [--fail-on-breaking] * * Output (text): * - Added/removed operations * - Added/removed responses * - Parameter additions/removals/requiredness changes * - Response content-type additions/removals * - Request body additions/removals/requiredness and content-type changes * * Output (json): * { * additive: { operations, responses, parameters, responseContentTypes, requestBodies }, * breaking: { operations, responses, parameters, responseContentTypes, requestBodies } * } * * Exit codes: * 0 => success * 1 => invalid/missing args or IO/parsing error * 2 => breaking changes detected with --fail-on-breaking */ import fs from 'fs'; import path from 'path'; import process from 'process'; import yaml from 'yaml'; function panic(message) { console.error(`[api-compat-diff] ${message}`); process.exit(1); } function parseArgs(argv) { const args = argv.slice(2); const opts = { output: 'text', failOnBreaking: false }; if (args.length < 2) { panic('Usage: node scripts/api-compat-diff.mjs [--output json|text] [--fail-on-breaking]'); } [opts.oldSpec, opts.newSpec] = args.slice(0, 2); for (let i = 2; i < args.length; i += 1) { const arg = args[i]; if (arg === '--output' && args[i + 1]) { opts.output = args[i + 1].toLowerCase(); i += 1; } else if (arg === '--fail-on-breaking') { opts.failOnBreaking = true; } } if (!['text', 'json'].includes(opts.output)) { panic(`Unsupported output mode: ${opts.output}`); } return opts; } function loadSpec(specPath) { if (!fs.existsSync(specPath)) { panic(`Spec not found: ${specPath}`); } const raw = fs.readFileSync(specPath, 'utf8'); const ext = path.extname(specPath).toLowerCase(); try { if (ext === '.json') { return JSON.parse(raw); } return yaml.parse(raw); } catch (err) { panic(`Failed to parse ${specPath}: ${err.message}`); } } function normalizeParams(params) { const map = new Map(); if (!Array.isArray(params)) return map; for (const param of params) { if (!param || typeof param !== 'object') continue; if (param.$ref) { map.set(`ref:${param.$ref}`, { required: param.required === true, isRef: true }); continue; } const name = param.name; const loc = param.in; if (!name || !loc) continue; const key = `${name}:${loc}`; map.set(key, { required: param.required === true, isRef: false }); } return map; } function describeParam(key, requiredFlag) { if (key.startsWith('ref:')) { return key.replace(/^ref:/, ''); } const [name, loc] = key.split(':'); const requiredLabel = requiredFlag ? ' (required)' : ''; return `${name} in ${loc}${requiredLabel}`; } function enumerateOperations(spec) { const ops = new Map(); if (!spec?.paths || typeof spec.paths !== 'object') { return ops; } for (const [pathKey, pathItem] of Object.entries(spec.paths)) { if (!pathItem || typeof pathItem !== 'object') { continue; } const pathParams = normalizeParams(pathItem.parameters ?? []); for (const method of Object.keys(pathItem)) { const lowerMethod = method.toLowerCase(); if (!['get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace'].includes(lowerMethod)) { continue; } const op = pathItem[method]; if (!op || typeof op !== 'object') { continue; } const opId = `${lowerMethod} ${pathKey}`; const opParams = normalizeParams(op.parameters ?? []); const parameters = new Map(pathParams); for (const [key, val] of opParams.entries()) { parameters.set(key, val); } const responseContentTypes = new Map(); const responses = new Set(); const responseEntries = Object.entries(op.responses ?? {}); for (const [code, resp] of responseEntries) { responses.add(code); const contentTypes = new Set(Object.keys(resp?.content ?? {})); responseContentTypes.set(code, contentTypes); } const requestBody = op.requestBody ? { present: true, required: op.requestBody.required === true, contentTypes: new Set(Object.keys(op.requestBody.content ?? {})), } : { present: false, required: false, contentTypes: new Set() }; ops.set(opId, { method: lowerMethod, path: pathKey, responses, responseContentTypes, parameters, requestBody, }); } } return ops; } function diffOperations(oldOps, newOps) { const additiveOps = []; const breakingOps = []; const additiveResponses = []; const breakingResponses = []; const additiveParams = []; const breakingParams = []; const additiveResponseContentTypes = []; const breakingResponseContentTypes = []; const additiveRequestBodies = []; const breakingRequestBodies = []; // Operations added or removed for (const [id] of newOps.entries()) { if (!oldOps.has(id)) { additiveOps.push(id); } } for (const [id] of oldOps.entries()) { if (!newOps.has(id)) { breakingOps.push(id); } } // Response- and parameter-level diffs for shared operations for (const [id, newOp] of newOps.entries()) { if (!oldOps.has(id)) continue; const oldOp = oldOps.get(id); for (const code of newOp.responses) { if (!oldOp.responses.has(code)) { additiveResponses.push(`${id} -> ${code}`); } } for (const code of oldOp.responses) { if (!newOp.responses.has(code)) { breakingResponses.push(`${id} -> ${code}`); } } for (const code of newOp.responses) { if (!oldOp.responses.has(code)) continue; const oldTypes = oldOp.responseContentTypes.get(code) ?? new Set(); const newTypes = newOp.responseContentTypes.get(code) ?? new Set(); for (const ct of newTypes) { if (!oldTypes.has(ct)) { additiveResponseContentTypes.push(`${id} -> ${code} (${ct})`); } } for (const ct of oldTypes) { if (!newTypes.has(ct)) { breakingResponseContentTypes.push(`${id} -> ${code} (${ct})`); } } } for (const [key, oldParam] of oldOp.parameters.entries()) { if (!newOp.parameters.has(key)) { breakingParams.push(`${id} -> - parameter ${describeParam(key, oldParam.required)}`); } } for (const [key, newParam] of newOp.parameters.entries()) { if (!oldOp.parameters.has(key)) { const target = newParam.required ? breakingParams : additiveParams; target.push(`${id} -> + parameter ${describeParam(key, newParam.required)}`); continue; } const oldParam = oldOp.parameters.get(key); if (oldParam.required !== newParam.required) { if (newParam.required) { breakingParams.push(`${id} -> parameter ${describeParam(key)} made required`); } else { additiveParams.push(`${id} -> parameter ${describeParam(key)} made optional`); } } } const { requestBody: oldBody } = oldOp; const { requestBody: newBody } = newOp; if (oldBody.present && !newBody.present) { breakingRequestBodies.push(`${id} -> - requestBody`); } else if (!oldBody.present && newBody.present) { const target = newBody.required ? breakingRequestBodies : additiveRequestBodies; const label = newBody.required ? 'required' : 'optional'; target.push(`${id} -> + requestBody (${label})`); } else if (oldBody.present && newBody.present) { if (oldBody.required !== newBody.required) { if (newBody.required) { breakingRequestBodies.push(`${id} -> requestBody made required`); } else { additiveRequestBodies.push(`${id} -> requestBody made optional`); } } for (const ct of newBody.contentTypes) { if (!oldBody.contentTypes.has(ct)) { additiveRequestBodies.push(`${id} -> requestBody content-type added: ${ct}`); } } for (const ct of oldBody.contentTypes) { if (!newBody.contentTypes.has(ct)) { breakingRequestBodies.push(`${id} -> requestBody content-type removed: ${ct}`); } } } } return { additive: { operations: additiveOps.sort(), responses: additiveResponses.sort(), parameters: additiveParams.sort(), responseContentTypes: additiveResponseContentTypes.sort(), requestBodies: additiveRequestBodies.sort(), }, breaking: { operations: breakingOps.sort(), responses: breakingResponses.sort(), parameters: breakingParams.sort(), responseContentTypes: breakingResponseContentTypes.sort(), requestBodies: breakingRequestBodies.sort(), }, }; } function renderText(diff) { const lines = []; lines.push('Additive:'); lines.push(` Operations: ${diff.additive.operations.length}`); diff.additive.operations.forEach((op) => lines.push(` + ${op}`)); lines.push(` Responses: ${diff.additive.responses.length}`); diff.additive.responses.forEach((resp) => lines.push(` + ${resp}`)); lines.push(` Parameters: ${diff.additive.parameters.length}`); diff.additive.parameters.forEach((param) => lines.push(` + ${param}`)); lines.push(` Response content-types: ${diff.additive.responseContentTypes.length}`); diff.additive.responseContentTypes.forEach((ct) => lines.push(` + ${ct}`)); lines.push(` Request bodies: ${diff.additive.requestBodies.length}`); diff.additive.requestBodies.forEach((rb) => lines.push(` + ${rb}`)); lines.push('Breaking:'); lines.push(` Operations: ${diff.breaking.operations.length}`); diff.breaking.operations.forEach((op) => lines.push(` - ${op}`)); lines.push(` Responses: ${diff.breaking.responses.length}`); diff.breaking.responses.forEach((resp) => lines.push(` - ${resp}`)); lines.push(` Parameters: ${diff.breaking.parameters.length}`); diff.breaking.parameters.forEach((param) => lines.push(` - ${param}`)); lines.push(` Response content-types: ${diff.breaking.responseContentTypes.length}`); diff.breaking.responseContentTypes.forEach((ct) => lines.push(` - ${ct}`)); lines.push(` Request bodies: ${diff.breaking.requestBodies.length}`); diff.breaking.requestBodies.forEach((rb) => lines.push(` - ${rb}`)); return lines.join('\n'); } function main() { const opts = parseArgs(process.argv); const oldSpec = loadSpec(opts.oldSpec); const newSpec = loadSpec(opts.newSpec); const diff = diffOperations(enumerateOperations(oldSpec), enumerateOperations(newSpec)); if (opts.output === 'json') { console.log(JSON.stringify(diff, null, 2)); } else { console.log(renderText(diff)); } if (opts.failOnBreaking && ( diff.breaking.operations.length > 0 || diff.breaking.responses.length > 0 || diff.breaking.parameters.length > 0 || diff.breaking.responseContentTypes.length > 0 || diff.breaking.requestBodies.length > 0 )) { process.exit(2); } } if (import.meta.url === `file://${process.argv[1]}`) { main(); }