Files
zulip/tools/check-openapi.ts
Anders Kaseorg 0fc5f6994f check-openapi: Convert to TypeScript.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-09-04 17:02:06 -07:00

195 lines
6.9 KiB
JavaScript
Executable File

#!/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] <file>...";
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<void> {
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<typeof CST.setScalarValue>[2]}
>();
const promises: Promise<void>[] = [];
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);
}