105 lines
4.4 KiB
JavaScript
105 lines
4.4 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const YAML = require("yaml");
|
|
|
|
const walk = (directory) => fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) =>
|
|
entry.isDirectory() ? walk(path.join(directory, entry.name)) : [path.join(directory, entry.name)],
|
|
);
|
|
|
|
const document = YAML.parse(fs.readFileSync("genealogy-app-openapi.yaml", "utf8"));
|
|
assert.strictEqual(document.openapi, "3.0.3", "backend contract must be OpenAPI 3.0.3");
|
|
const operations = new Set(
|
|
Object.entries(document.paths).flatMap(([endpoint, pathItem]) =>
|
|
Object.keys(pathItem)
|
|
.filter((method) => ["get", "post", "put", "delete", "patch"].includes(method))
|
|
.map((method) => `${method.toUpperCase()} ${endpoint}`),
|
|
),
|
|
);
|
|
|
|
const apiSource = fs.readFileSync("utils/api.js", "utf8");
|
|
const appApiStart = apiSource.indexOf("export const appApi = {");
|
|
assert.ok(appApiStart >= 0, "appApi owner is missing");
|
|
const appApiSource = apiSource.slice(appApiStart);
|
|
const methodMatches = [...appApiSource.matchAll(/^ async ([A-Za-z0-9_]+)\(/gm)];
|
|
const methodSources = new Map(
|
|
methodMatches.map((match, index) => [
|
|
match[1],
|
|
appApiSource.slice(match.index, methodMatches[index + 1]?.index),
|
|
]),
|
|
);
|
|
|
|
const normalizeEndpoint = (endpoint) => endpoint
|
|
.replace(/\$\{[^}]+\}/g, "{parameter}")
|
|
.replace(/\{[^/]+\}/g, "{parameter}");
|
|
const resolveOpenApiEndpoint = (endpoint) => Object.keys(document.paths).find(
|
|
(candidate) => normalizeEndpoint(candidate) === normalizeEndpoint(endpoint),
|
|
);
|
|
|
|
const collectLiteralOperations = (source) => {
|
|
const entries = [];
|
|
const add = (method, endpoint) => {
|
|
if (endpoint.includes("${relationPath}")) {
|
|
for (const relationPath of ["parents", "spouses", "siblings", "children"]) {
|
|
entries.push({ method, endpoint: endpoint.replace("${relationPath}", relationPath) });
|
|
}
|
|
return;
|
|
}
|
|
entries.push({ method, endpoint });
|
|
};
|
|
const literal = "([`'])";
|
|
const endpoint = "(/genealogy[^`']*)";
|
|
const readPattern = new RegExp(`readRemote(?:List|Object)\\(\\s*${literal}${endpoint}\\1`, "g");
|
|
for (const match of source.matchAll(readPattern)) add("GET", match[2]);
|
|
const writePattern = new RegExp(`writeRemote(?:Void|Object)\\(\\s*${literal}${endpoint}\\1\\s*,\\s*['\"](POST|PUT|DELETE|PATCH)['\"]`, "g");
|
|
for (const match of source.matchAll(writePattern)) add(match[3], match[2]);
|
|
const directPattern = new RegExp(`url:\\s*${literal}${endpoint}\\1\\s*,\\s*method:\\s*['\"](GET|POST|PUT|DELETE|PATCH)['\"]`, "g");
|
|
for (const match of source.matchAll(directPattern)) add(match[3], match[2]);
|
|
const conditionalMethodPattern = new RegExp(`url:\\s*${literal}${endpoint}\\1\\s*,\\s*method:\\s*[^?]+\\?\\s*['\"](POST|PUT|DELETE|PATCH)['\"]\\s*:\\s*['\"](POST|PUT|DELETE|PATCH)['\"]`, "g");
|
|
for (const match of source.matchAll(conditionalMethodPattern)) {
|
|
add(match[3], match[2]);
|
|
add(match[4], match[2]);
|
|
}
|
|
return entries;
|
|
};
|
|
|
|
const pageCalls = walk("pages")
|
|
.filter((file) => file.endsWith(".vue"))
|
|
.flatMap((file) => {
|
|
const source = fs.readFileSync(file, "utf8");
|
|
return [...source.matchAll(/appApi\.([A-Za-z0-9_]+)/g)].map((match) => ({
|
|
file: file.replace(/\\/g, "/"),
|
|
method: match[1],
|
|
}));
|
|
});
|
|
const routedPages = new Set(
|
|
JSON.parse(fs.readFileSync("pages.json", "utf8")).pages.map((page) => `${page.path}.vue`),
|
|
);
|
|
|
|
const unresolvedMethods = [];
|
|
const unmappedOperations = [];
|
|
for (const method of [...new Set(pageCalls.map((call) => call.method))]) {
|
|
const source = methodSources.get(method);
|
|
if (!source) {
|
|
unresolvedMethods.push(method);
|
|
continue;
|
|
}
|
|
const references = collectLiteralOperations(source);
|
|
if (!references.length) {
|
|
unresolvedMethods.push(method);
|
|
continue;
|
|
}
|
|
for (const reference of references) {
|
|
const endpoint = resolveOpenApiEndpoint(reference.endpoint);
|
|
if (!endpoint || !operations.has(`${reference.method} ${endpoint}`)) {
|
|
unmappedOperations.push(`${method}: ${reference.method} ${reference.endpoint}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
assert.deepStrictEqual(unresolvedMethods, [], "page call has no inspectable adapter endpoint owner");
|
|
assert.deepStrictEqual(unmappedOperations, [], "page adapter endpoint diverges from current backend OpenAPI");
|
|
process.stdout.write(`PAGE-OPENAPI-CONTRACT PASS routed=${routedPages.size} apiPages=${new Set(pageCalls.map((call) => call.file)).size} calls=${pageCalls.length}\n`);
|