#!/usr/bin/env node import assert from "node:assert/strict"; import * as fs from "node:fs"; import {parseArgs} from "node:util"; import SwaggerParser from "@apidevtools/swagger-parser"; import * as Diff from "diff"; import ExampleValidator from "openapi-examples-validator"; import {format as prettierFormat} from "prettier"; import {CST, Composer, LineCounter, Parser, Scalar, YAMLMap, YAMLSeq, visit} from "yaml"; const usage = "Usage: check-openapi.ts [--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: string): Promise { 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; assert.ok(doc !== undefined); 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< number, {value: string; context: Parameters[2]} >(); const promises: Promise[] = []; visit(doc, { Map(_key, node) { if (node.has("$ref") && node.items.length !== 1) { assert.ok(node.range); const {line, col} = lineCounter.linePos(node.range[0]); console.error("%s:%d:%d: Siblings of $ref have no effect", file, line, col); ok = false; } const combinator = ["allOf", "anyOf", "oneOf"].find((combinator) => node.has(combinator), ); if (node.has("nullable") && combinator !== undefined) { assert.ok(node.range); const {line, col} = lineCounter.linePos(node.range[0]); console.error( `%s:%d:%d: nullable has no effect as a sibling of ${combinator}`, 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 ) { assert.ok(node.value.range); 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" ) { const value = node.value; const description = node.value.value; promises.push( (async () => { let formatted = await prettierFormat(description, { parser: "markdown", }); if ( value.type !== Scalar.BLOCK_FOLDED && value.type !== Scalar.BLOCK_LITERAL ) { formatted = formatted.replace(/\n$/, ""); } if (formatted !== description) { assert.ok(value.range); if (fix) { reformats.set(value.range[0], { value: formatted, context: {afterKey: true}, }); } else { ok = false; const {line, col} = lineCounter.linePos(value.range[0]); console.error( "%s:%d:%d: Format description with Prettier:", file, line, col, ); let diff = ""; for (const part of Diff.diffLines(description, 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) { if (token.type === "document") { CST.visit(token, ({value}) => { let reformat; if ( CST.isScalar(value) && (reformat = reformats.get(value.offset)) !== undefined ) { 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; } } for (const file of files) { await checkFile(file); }