mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 05:53:43 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			195 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			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);
 | 
						|
}
 |