140 lines
4.4 KiB
JavaScript
140 lines
4.4 KiB
JavaScript
#!/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);
|
|
});
|