CD/CD consolidation
This commit is contained in:
129
devops/tools/api-compat/api-changelog.mjs
Normal file
129
devops/tools/api-compat/api-changelog.mjs
Normal file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import yaml from 'yaml';
|
||||
|
||||
const ROOT = path.resolve('src/Api/StellaOps.Api.OpenApi');
|
||||
const BASELINE = path.join(ROOT, 'baselines', 'stella-baseline.yaml');
|
||||
const CURRENT = path.join(ROOT, 'stella.yaml');
|
||||
const OUTPUT = path.join(ROOT, 'CHANGELOG.md');
|
||||
const RELEASE_OUT = path.resolve('src/Sdk/StellaOps.Sdk.Release/out/api-changelog');
|
||||
|
||||
function panic(message) {
|
||||
console.error(`[api:changelog] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function loadSpec(file) {
|
||||
if (!fs.existsSync(file)) {
|
||||
panic(`Spec not found: ${file}`);
|
||||
}
|
||||
return yaml.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
function enumerateOps(spec) {
|
||||
const ops = new Map();
|
||||
for (const [route, methods] of Object.entries(spec.paths || {})) {
|
||||
for (const [method, operation] of Object.entries(methods || {})) {
|
||||
const lower = method.toLowerCase();
|
||||
if (!['get','post','put','delete','patch','head','options','trace'].includes(lower)) continue;
|
||||
const id = `${lower.toUpperCase()} ${route}`;
|
||||
ops.set(id, operation || {});
|
||||
}
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
function diffSpecs(oldSpec, newSpec) {
|
||||
const oldOps = enumerateOps(oldSpec);
|
||||
const newOps = enumerateOps(newSpec);
|
||||
const additive = [];
|
||||
const breaking = [];
|
||||
|
||||
for (const id of newOps.keys()) {
|
||||
if (!oldOps.has(id)) {
|
||||
additive.push(id);
|
||||
}
|
||||
}
|
||||
for (const id of oldOps.keys()) {
|
||||
if (!newOps.has(id)) {
|
||||
breaking.push(id);
|
||||
}
|
||||
}
|
||||
return { additive: additive.sort(), breaking: breaking.sort() };
|
||||
}
|
||||
|
||||
function renderMarkdown(diff) {
|
||||
const lines = [];
|
||||
lines.push('# API Changelog');
|
||||
lines.push('');
|
||||
const date = new Date().toISOString();
|
||||
lines.push(`Generated: ${date}`);
|
||||
lines.push('');
|
||||
lines.push('## Additive Operations');
|
||||
if (diff.additive.length === 0) {
|
||||
lines.push('- None');
|
||||
} else {
|
||||
diff.additive.forEach((op) => lines.push(`- ${op}`));
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## Breaking Operations');
|
||||
if (diff.breaking.length === 0) {
|
||||
lines.push('- None');
|
||||
} else {
|
||||
diff.breaking.forEach((op) => lines.push(`- ${op}`));
|
||||
}
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function ensureReleaseDir() {
|
||||
fs.mkdirSync(RELEASE_OUT, { recursive: true });
|
||||
}
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function signDigest(digest) {
|
||||
const key = process.env.API_CHANGELOG_SIGNING_KEY;
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hmac = crypto.createHmac('sha256', Buffer.from(key, 'utf8'));
|
||||
hmac.update(digest);
|
||||
return hmac.digest('hex');
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(BASELINE)) {
|
||||
console.log('[api:changelog] baseline missing; skipping');
|
||||
return;
|
||||
}
|
||||
const diff = diffSpecs(loadSpec(BASELINE), loadSpec(CURRENT));
|
||||
const markdown = renderMarkdown(diff);
|
||||
fs.writeFileSync(OUTPUT, markdown, 'utf8');
|
||||
console.log(`[api:changelog] wrote changelog to ${OUTPUT}`);
|
||||
|
||||
ensureReleaseDir();
|
||||
const releaseChangelog = path.join(RELEASE_OUT, 'CHANGELOG.md');
|
||||
fs.writeFileSync(releaseChangelog, markdown, 'utf8');
|
||||
|
||||
const digest = sha256(markdown);
|
||||
const digestFile = path.join(RELEASE_OUT, 'CHANGELOG.sha256');
|
||||
fs.writeFileSync(digestFile, `${digest} CHANGELOG.md\n`, 'utf8');
|
||||
|
||||
const signature = signDigest(digest);
|
||||
if (signature) {
|
||||
fs.writeFileSync(path.join(RELEASE_OUT, 'CHANGELOG.sig'), signature, 'utf8');
|
||||
console.log('[api:changelog] wrote signature for release artifact');
|
||||
} else {
|
||||
console.log('[api:changelog] signature skipped (API_CHANGELOG_SIGNING_KEY not set)');
|
||||
}
|
||||
|
||||
console.log(`[api:changelog] copied changelog + digest to ${RELEASE_OUT}`);
|
||||
}
|
||||
|
||||
main();
|
||||
104
devops/tools/api-compat/api-compat-changelog.mjs
Normal file
104
devops/tools/api-compat/api-compat-changelog.mjs
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate a Markdown changelog from two OpenAPI specs using the api-compat-diff tool.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/api-compat-changelog.mjs <oldSpec> <newSpec> [--title "Release X"] [--fail-on-breaking]
|
||||
*
|
||||
* Output is written to stdout.
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import process from 'process';
|
||||
import path from 'path';
|
||||
|
||||
function panic(message) {
|
||||
console.error(`[api-compat-changelog] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
panic('Usage: node scripts/api-compat-changelog.mjs <oldSpec> <newSpec> [--title "Release X"] [--fail-on-breaking]');
|
||||
}
|
||||
|
||||
const opts = {
|
||||
oldSpec: args[0],
|
||||
newSpec: args[1],
|
||||
title: 'API Compatibility Report',
|
||||
failOnBreaking: false,
|
||||
};
|
||||
|
||||
for (let i = 2; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === '--title' && args[i + 1]) {
|
||||
opts.title = args[i + 1];
|
||||
i += 1;
|
||||
} else if (arg === '--fail-on-breaking') {
|
||||
opts.failOnBreaking = true;
|
||||
}
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
function runCompatDiff(oldSpec, newSpec) {
|
||||
const output = execFileSync(
|
||||
'node',
|
||||
['scripts/api-compat-diff.mjs', oldSpec, newSpec, '--output', 'json'],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
function formatList(items, symbol) {
|
||||
if (!items || items.length === 0) {
|
||||
return `${symbol} None`;
|
||||
}
|
||||
return items.map((item) => `${symbol} ${item}`).join('\n');
|
||||
}
|
||||
|
||||
function renderMarkdown(title, diff, oldSpec, newSpec) {
|
||||
return [
|
||||
`# ${title}`,
|
||||
'',
|
||||
`- Old spec: \`${path.relative(process.cwd(), oldSpec)}\``,
|
||||
`- New spec: \`${path.relative(process.cwd(), newSpec)}\``,
|
||||
'',
|
||||
'## Summary',
|
||||
`- Additive operations: ${diff.additive.operations.length}`,
|
||||
`- Breaking operations: ${diff.breaking.operations.length}`,
|
||||
`- Additive responses: ${diff.additive.responses.length}`,
|
||||
`- Breaking responses: ${diff.breaking.responses.length}`,
|
||||
'',
|
||||
'## Additive',
|
||||
'### Operations',
|
||||
formatList(diff.additive.operations, '-'),
|
||||
'',
|
||||
'### Responses',
|
||||
formatList(diff.additive.responses, '-'),
|
||||
'',
|
||||
'## Breaking',
|
||||
'### Operations',
|
||||
formatList(diff.breaking.operations, '-'),
|
||||
'',
|
||||
'### Responses',
|
||||
formatList(diff.breaking.responses, '-'),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv);
|
||||
const diff = runCompatDiff(opts.oldSpec, opts.newSpec);
|
||||
const markdown = renderMarkdown(opts.title, diff, opts.oldSpec, opts.newSpec);
|
||||
console.log(markdown);
|
||||
|
||||
if (opts.failOnBreaking && (diff.breaking.operations.length > 0 || diff.breaking.responses.length > 0)) {
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
26
devops/tools/api-compat/api-compat-changelog.test.mjs
Normal file
26
devops/tools/api-compat/api-compat-changelog.test.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import assert from 'assert';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const root = path.join(__dirname, '..');
|
||||
|
||||
const fixturesDir = path.join(root, 'scripts', '__fixtures__', 'api-compat');
|
||||
const oldSpec = path.join(fixturesDir, 'old.yaml');
|
||||
const newSpec = path.join(fixturesDir, 'new.yaml');
|
||||
|
||||
const output = execFileSync('node', ['scripts/api-compat-changelog.mjs', oldSpec, newSpec, '--title', 'Test Report'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert(output.includes('# Test Report'));
|
||||
assert(output.includes('Additive operations: 1'));
|
||||
assert(output.includes('Breaking operations: 0'));
|
||||
assert(output.includes('- get /bar'));
|
||||
assert(output.includes('- get /foo -> 201'));
|
||||
assert(output.includes('- get /foo -> 200'));
|
||||
|
||||
console.log('api-compat-changelog test passed');
|
||||
359
devops/tools/api-compat/api-compat-diff.mjs
Normal file
359
devops/tools/api-compat/api-compat-diff.mjs
Normal file
@@ -0,0 +1,359 @@
|
||||
#!/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();
|
||||
}
|
||||
34
devops/tools/api-compat/api-compat-diff.test.mjs
Normal file
34
devops/tools/api-compat/api-compat-diff.test.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from 'assert';
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const fixturesDir = path.join(__dirname, '__fixtures__', 'api-compat');
|
||||
const oldSpec = path.join(fixturesDir, 'old.yaml');
|
||||
const newSpec = path.join(fixturesDir, 'new.yaml');
|
||||
|
||||
const output = execFileSync('node', ['scripts/api-compat-diff.mjs', oldSpec, newSpec, '--output', 'json'], {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
const diff = JSON.parse(output);
|
||||
|
||||
assert.deepStrictEqual(diff.additive.operations, ['get /bar']);
|
||||
assert.deepStrictEqual(diff.breaking.operations, []);
|
||||
assert.deepStrictEqual(diff.additive.responses, ['get /foo -> 201']);
|
||||
assert.deepStrictEqual(diff.breaking.responses, ['get /foo -> 200']);
|
||||
assert.deepStrictEqual(diff.additive.parameters, []);
|
||||
assert.deepStrictEqual(diff.breaking.parameters, [
|
||||
'get /foo -> + parameter tenant in query (required)',
|
||||
'get /foo -> - parameter filter in query',
|
||||
]);
|
||||
assert.deepStrictEqual(diff.additive.requestBodies, []);
|
||||
assert.deepStrictEqual(diff.breaking.requestBodies, ['post /baz -> requestBody made required']);
|
||||
assert.deepStrictEqual(diff.additive.responseContentTypes, []);
|
||||
assert.deepStrictEqual(diff.breaking.responseContentTypes, []);
|
||||
|
||||
console.log('api-compat-diff test passed');
|
||||
139
devops/tools/api-compat/api-example-coverage.mjs
Normal file
139
devops/tools/api-compat/api-example-coverage.mjs
Normal file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
// Verifies every OpenAPI operation has at least one request example and one response example.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const OAS_ROOT = path.join(ROOT, 'src', 'Api', 'StellaOps.Api.OpenApi');
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(OAS_ROOT)) {
|
||||
console.log('[api:examples] no OpenAPI directory found; skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await findYamlFiles(OAS_ROOT);
|
||||
if (files.length === 0) {
|
||||
console.log('[api:examples] no OpenAPI files found; skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (const relative of files) {
|
||||
const fullPath = path.join(OAS_ROOT, relative);
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
let doc;
|
||||
try {
|
||||
doc = parse(content, { prettyErrors: true });
|
||||
} catch (err) {
|
||||
failures.push({ file: relative, path: '', method: '', reason: `YAML parse error: ${err.message}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const paths = doc?.paths || {};
|
||||
for (const [route, methods] of Object.entries(paths)) {
|
||||
for (const [method, operation] of Object.entries(methods || {})) {
|
||||
if (!isHttpMethod(method)) continue;
|
||||
|
||||
const hasRequestExample = operation?.requestBody ? hasExample(operation.requestBody) : true;
|
||||
const hasResponseExample = Object.values(operation?.responses || {}).some(resp => hasExample(resp));
|
||||
|
||||
if (!hasRequestExample || !hasResponseExample) {
|
||||
const missing = [];
|
||||
if (!hasRequestExample) missing.push('request');
|
||||
if (!hasResponseExample) missing.push('response');
|
||||
failures.push({ file: relative, path: route, method, reason: `missing ${missing.join(' & ')} example` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[api:examples] found operations without examples:');
|
||||
for (const f of failures) {
|
||||
const locus = [f.file, f.path, f.method.toUpperCase()].filter(Boolean).join(' ');
|
||||
console.error(` - ${locus}: ${f.reason}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[api:examples] all operations contain request and response examples');
|
||||
}
|
||||
|
||||
async function findYamlFiles(root) {
|
||||
const results = [];
|
||||
async function walk(dir) {
|
||||
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full);
|
||||
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.yaml')) {
|
||||
results.push(path.relative(root, full));
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return results;
|
||||
}
|
||||
|
||||
function isHttpMethod(method) {
|
||||
return ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'].includes(method.toLowerCase());
|
||||
}
|
||||
|
||||
function hasExample(node) {
|
||||
if (!node) return false;
|
||||
|
||||
// request/response objects may include content -> mediaType -> schema/example/examples
|
||||
const content = node.content || {};
|
||||
for (const media of Object.values(content)) {
|
||||
if (!media) continue;
|
||||
if (media.example !== undefined) return true;
|
||||
if (media.examples && Object.keys(media.examples).length > 0) return true;
|
||||
if (media.schema && hasSchemaExample(media.schema)) return true;
|
||||
}
|
||||
|
||||
// response objects may have "examples" directly (non-standard but allowed by spectral rules)
|
||||
if (node.examples && Object.keys(node.examples).length > 0) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasSchemaExample(schema) {
|
||||
if (!schema) return false;
|
||||
if (schema.example !== undefined) return true;
|
||||
if (schema.examples && Array.isArray(schema.examples) && schema.examples.length > 0) return true;
|
||||
|
||||
// Recurse into allOf/oneOf/anyOf
|
||||
const composites = ['allOf', 'oneOf', 'anyOf'];
|
||||
for (const key of composites) {
|
||||
if (Array.isArray(schema[key])) {
|
||||
if (schema[key].some(hasSchemaExample)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For objects, check properties
|
||||
if (schema.type === 'object' && schema.properties) {
|
||||
for (const value of Object.values(schema.properties)) {
|
||||
if (hasSchemaExample(value)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For arrays, check items
|
||||
if (schema.type === 'array' && schema.items) {
|
||||
return hasSchemaExample(schema.items);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[api:examples] fatal error', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user