refactor(data): establish API contract guardrails
CI / Quality and Bundle Budgets (push) Has been cancelled
CI / Quality and Bundle Budgets (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
findMissingOperations,
|
||||
normalizePath,
|
||||
} from "../openapi-contract.mjs";
|
||||
|
||||
describe("OpenAPI contract comparison", () => {
|
||||
it("accepts matching paths and methods", () => {
|
||||
const missing = findMissingOperations(
|
||||
{
|
||||
chatHistory: { method: "get", path: "/api/chat/history" },
|
||||
unlockAlbum: {
|
||||
method: "post",
|
||||
path: "/api/private-room/albums/{albumId}/unlock",
|
||||
},
|
||||
},
|
||||
{
|
||||
paths: {
|
||||
"/api/chat/history": { get: {} },
|
||||
"/api/private-room/albums/{album_id}/unlock/": { post: {} },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(missing, []);
|
||||
});
|
||||
|
||||
it("reports missing paths and methods with operation ids", () => {
|
||||
const missing = findMissingOperations(
|
||||
{
|
||||
chatHistory: { method: "get", path: "/api/chat/history" },
|
||||
chatSend: { method: "post", path: "/api/chat/send" },
|
||||
},
|
||||
{ paths: { "/api/chat/history": { post: {} } } },
|
||||
);
|
||||
|
||||
assert.deepEqual(missing, [
|
||||
{
|
||||
operationId: "chatHistory",
|
||||
method: "get",
|
||||
path: "/api/chat/history",
|
||||
reason: "method is missing",
|
||||
},
|
||||
{
|
||||
operationId: "chatSend",
|
||||
method: "post",
|
||||
path: "/api/chat/send",
|
||||
reason: "path is missing",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes trailing slashes and path parameter names", () => {
|
||||
assert.equal(
|
||||
normalizePath("/api/albums/{album_id}/unlock/"),
|
||||
"/api/albums/{}/unlock",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects documents without OpenAPI paths", () => {
|
||||
assert.throws(
|
||||
() => findMissingOperations({}, {}),
|
||||
/does not contain a paths object/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
findMissingOperations,
|
||||
loadJsonSource,
|
||||
} from "./openapi-contract.mjs";
|
||||
|
||||
const source = process.argv[2] ?? process.env.BACKEND_OPENAPI_SOURCE;
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
"Provide an OpenAPI JSON path/URL or set BACKEND_OPENAPI_SOURCE",
|
||||
);
|
||||
}
|
||||
|
||||
const contractUrl = new URL(
|
||||
"../../src/data/services/api/api_contract.json",
|
||||
import.meta.url,
|
||||
);
|
||||
const [contract, openApiDocument] = await Promise.all([
|
||||
readFile(contractUrl, "utf8").then(JSON.parse),
|
||||
loadJsonSource(source),
|
||||
]);
|
||||
const missing = findMissingOperations(contract, openApiDocument);
|
||||
|
||||
if (missing.length > 0) {
|
||||
const details = missing
|
||||
.map(
|
||||
({ operationId, method, path, reason }) =>
|
||||
`- ${operationId}: ${method.toUpperCase()} ${path} (${reason})`,
|
||||
)
|
||||
.join("\n");
|
||||
throw new Error(`Backend OpenAPI is missing frontend operations:\n${details}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Backend OpenAPI covers all ${Object.keys(contract).length} frontend operations.`,
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export async function loadJsonSource(source) {
|
||||
if (/^https?:\/\//u.test(source)) {
|
||||
const response = await fetch(source, {
|
||||
headers: { accept: "application/json" },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Unable to load OpenAPI document: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const content = await readFile(resolve(source), "utf8");
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
export function findMissingOperations(contract, openApiDocument) {
|
||||
const openApiPaths = openApiDocument?.paths;
|
||||
if (!openApiPaths || typeof openApiPaths !== "object") {
|
||||
throw new Error("OpenAPI document does not contain a paths object");
|
||||
}
|
||||
|
||||
const normalizedPaths = new Map(
|
||||
Object.entries(openApiPaths).map(([path, pathItem]) => [
|
||||
normalizePath(path),
|
||||
pathItem,
|
||||
]),
|
||||
);
|
||||
|
||||
return Object.entries(contract).flatMap(([operationId, operation]) => {
|
||||
const method = operation.method.toLowerCase();
|
||||
const pathItem = normalizedPaths.get(normalizePath(operation.path));
|
||||
if (!pathItem) {
|
||||
return [{ operationId, ...operation, reason: "path is missing" }];
|
||||
}
|
||||
if (!pathItem[method]) {
|
||||
return [{ operationId, ...operation, reason: "method is missing" }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizePath(path) {
|
||||
const withoutTrailingSlash = path.length > 1 ? path.replace(/\/+$/u, "") : path;
|
||||
return withoutTrailingSlash.replace(/\{[^/{}]+\}/gu, "{}");
|
||||
}
|
||||
Reference in New Issue
Block a user