"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`);