75 lines
2.7 KiB
TypeScript
75 lines
2.7 KiB
TypeScript
import { readFile, readdir } from "node:fs/promises";
|
|
import { dirname, join, relative, sep } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
type RouteContract = {
|
|
version: 1;
|
|
routes: Array<{
|
|
method: "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
|
|
path: string;
|
|
}>;
|
|
};
|
|
|
|
const repositoryRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
const appRoot = join(repositoryRoot, "app");
|
|
const contractPath = join(repositoryRoot, "contracts", "http", "route-surface.v1.json");
|
|
const exportedMethod = /^\s*export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\b/gm;
|
|
|
|
async function findRouteFiles(directory: string): Promise<string[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const nested = await Promise.all(
|
|
entries.map(async (entry) => {
|
|
const path = join(directory, entry.name);
|
|
if (entry.isDirectory()) return findRouteFiles(path);
|
|
return entry.isFile() && entry.name === "route.ts" ? [path] : [];
|
|
}),
|
|
);
|
|
return nested.flat().sort();
|
|
}
|
|
|
|
function routePath(routeFile: string): string {
|
|
const routeDirectory = relative(appRoot, dirname(routeFile));
|
|
const segments = routeDirectory.split(sep).map((segment) => {
|
|
if (segment.startsWith("[...") && segment.endsWith("]")) {
|
|
return `{${segment.slice(4, -1)}...}`;
|
|
}
|
|
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
return `{${segment.slice(1, -1)}}`;
|
|
}
|
|
return segment;
|
|
});
|
|
return `/${segments.join("/")}`;
|
|
}
|
|
|
|
async function deriveRouteSurface(routeFiles: string[]): Promise<RouteContract["routes"]> {
|
|
const routes = await Promise.all(
|
|
routeFiles.map(async (routeFile) => {
|
|
const source = await readFile(routeFile, "utf8");
|
|
return Array.from(source.matchAll(exportedMethod), (match) => ({
|
|
method: match[1] as RouteContract["routes"][number]["method"],
|
|
path: routePath(routeFile),
|
|
}));
|
|
}),
|
|
);
|
|
return routes.flat().sort((left, right) => {
|
|
const leftKey = `${left.path}\u0000${left.method}`;
|
|
const rightKey = `${right.path}\u0000${right.method}`;
|
|
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
});
|
|
}
|
|
|
|
describe("HTTP route surface contract", () => {
|
|
it("exactly records every exported Next route method without loading handlers", async () => {
|
|
const routeFiles = await findRouteFiles(appRoot);
|
|
const actual = await deriveRouteSurface(routeFiles);
|
|
const contract = JSON.parse(await readFile(contractPath, "utf8")) as RouteContract;
|
|
|
|
expect(routeFiles).toHaveLength(47);
|
|
expect(actual).toHaveLength(66);
|
|
expect(contract.version).toBe(1);
|
|
expect(contract.routes).toEqual(actual);
|
|
});
|
|
});
|