#!/usr/bin/env node import * as fs from "node:fs"; import {parseArgs} from "node:util"; import * as Diff from "diff"; import ExampleValidator from "openapi-examples-validator"; import {format as prettierFormat} from "prettier"; import SwaggerParser from "swagger-parser"; import {CST, Composer, LineCounter, Parser, Scalar, YAMLMap, YAMLSeq, visit} from "yaml"; const usage = "Usage: check-openapi [--fix] ..."; const { values: {fix, help}, positionals: files, } = parseArgs({options: {fix: {type: "boolean"}, help: {type: "boolean"}}, allowPositionals: true}); if (help) { console.log(usage); process.exit(0); } async function checkFile(file) { const yaml = await fs.promises.readFile(file, "utf8"); const lineCounter = new LineCounter(); const tokens = [...new Parser(lineCounter.addNewLine).parse(yaml)]; const docs = [...new Composer().compose(tokens)]; if (docs.length !== 1) { return; } const [doc] = docs; if (doc.errors.length > 0) { for (const error of doc.errors) { console.error("%s: %s", file, error.message); } process.exitCode = 1; return; } const root = doc.contents; if (!(root instanceof YAMLMap && root.has("openapi"))) { return; } let ok = true; const reformats = new Map(); const promises = []; visit(doc, { Map(_key, node) { if (node.has("$ref") && node.items.length !== 1) { const {line, col} = lineCounter.linePos(node.range[0]); console.error("%s:%d:%d: Siblings of $ref have no effect", file, line, col); ok = false; } }, Pair(_key, node) { if ( node.key instanceof Scalar && node.key.value === "allOf" && node.value instanceof YAMLSeq && node.value.items.filter( (subschema) => !(subschema instanceof YAMLMap && subschema.has("$ref")), ).length > 1 ) { const {line, col} = lineCounter.linePos(node.value.range[0]); console.error("%s:%d:%d: Too many inline allOf subschemas", file, line, col); ok = false; } if ( node.key instanceof Scalar && node.key.value === "description" && node.value instanceof Scalar && typeof node.value.value === "string" ) { promises.push( (async () => { let formatted = await prettierFormat(node.value.value, { parser: "markdown", }); if ( ![Scalar.BLOCK_FOLDED, Scalar.BLOCK_LITERAL].includes(node.value.type) ) { formatted = formatted.replace(/\n$/, ""); } if (formatted !== node.value.value) { if (fix) { reformats.set(node.value.range[0], { value: formatted, context: {afterKey: true}, }); } else { ok = false; const {line, col} = lineCounter.linePos(node.value.range[0]); console.error( "%s:%d:%d: Format description with Prettier:", file, line, col, ); let diff = ""; for (const part of Diff.diffLines(node.value.value, formatted)) { const prefix = part.added ? "\u001B[32m+" : part.removed ? "\u001B[31m-" : "\u001B[34m "; diff += prefix; diff += part.value .replace(/\n$/, "") .replaceAll("\n", "\n" + prefix); diff += "\n"; } diff += "\u001B[0m"; console.error(diff); } } })(), ); } }, }); await Promise.all(promises); if (!ok) { process.exitCode = 1; } if (reformats.size > 0) { console.log("%s: Fixing problems", file); for (const token of tokens) { CST.visit(token, ({value}) => { if (CST.isScalar(value) && reformats.has(value.offset)) { const reformat = reformats.get(value.offset); CST.setScalarValue(value, reformat.value, reformat.context); } }); } await fs.promises.writeFile(file, tokens.map((token) => CST.stringify(token)).join("")); } try { await SwaggerParser.validate(file); } catch (error) { if (!(error instanceof SyntaxError)) { throw error; } console.error("%s: %s", file, error.message); process.exitCode = 1; } const res = await ExampleValidator.validateFile(file); if (!res.valid) { for (const error of res.errors) { console.error(error); } process.exitCode = 1; } } (async () => { for (const file of files) { await checkFile(file); } })().catch((error) => { console.error(error); process.exit(1); });