#!/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); });