up
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

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -7,15 +7,16 @@
* node scripts/api-compat-diff.mjs <oldSpec> <newSpec> [--output json|text] [--fail-on-breaking]
*
* Output (text):
* - Added operations (additive)
* - Removed operations (breaking)
* - Added responses (additive)
* - Removed responses (breaking)
* - 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: [...] },
* breaking: { operations: [...], responses: [...] }
* additive: { operations, responses, parameters, responseContentTypes, requestBodies },
* breaking: { operations, responses, parameters, responseContentTypes, requestBodies }
* }
*
* Exit codes:
@@ -79,6 +80,35 @@ function loadSpec(specPath) {
}
}
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') {
@@ -89,17 +119,52 @@ function enumerateOperations(spec) {
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 responses = pathItem[method]?.responses ?? {};
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: new Set(Object.keys(responses)),
responses,
responseContentTypes,
parameters,
requestBody,
});
}
}
@@ -112,9 +177,15 @@ function diffOperations(oldOps, newOps) {
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, op] of newOps.entries()) {
for (const [id] of newOps.entries()) {
if (!oldOps.has(id)) {
additiveOps.push(id);
}
@@ -126,7 +197,7 @@ function diffOperations(oldOps, newOps) {
}
}
// Response-level diffs for shared operations
// 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);
@@ -142,16 +213,92 @@ function diffOperations(oldOps, newOps) {
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(),
},
};
}
@@ -163,11 +310,23 @@ function renderText(diff) {
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');
}
@@ -184,7 +343,13 @@ function main() {
console.log(renderText(diff));
}
if (opts.failOnBreaking && (diff.breaking.operations.length > 0 || diff.breaking.responses.length > 0)) {
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);
}
}