Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
360 lines
11 KiB
JavaScript
360 lines
11 KiB
JavaScript
#!/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 <oldSpec> <newSpec> [--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 <oldSpec> <newSpec> [--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();
|
|
}
|