mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
feat(server): @ResponseContract checks a handler's responses against its shared schema in tests
The decorator records the @trek/shared response schema a route answers with. Under NODE_ENV=test it adds an interceptor that parses every response and fails the request with a logged 500 naming the handler and the issues, so the e2e and integration suites catch a response that drifted from its contract; in production nothing is added to the request path and the body is never rewritten. lint:response-contracts holds the handlers still without one to a per-file baseline that only shrinks, and runs in server-quality.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"counts": {
|
||||
"src/nest/accommodations/accommodations.controller.ts": 4,
|
||||
"src/nest/addons/addons.controller.ts": 1,
|
||||
"src/nest/admin/admin.controller.ts": 44,
|
||||
"src/nest/airports/airports.controller.ts": 2,
|
||||
"src/nest/assignments/assignments.controller.ts": 13,
|
||||
"src/nest/atlas/atlas.controller.ts": 14,
|
||||
"src/nest/atlas/public-stats.controller.ts": 1,
|
||||
"src/nest/atlas/travel-stats.controller.ts": 1,
|
||||
"src/nest/auth/auth-public.controller.ts": 9,
|
||||
"src/nest/auth/auth.controller.ts": 24,
|
||||
"src/nest/auth/passkey.controller.ts": 7,
|
||||
"src/nest/auth/sessions.controller.ts": 3,
|
||||
"src/nest/backup/backup.controller.ts": 8,
|
||||
"src/nest/budget/budget.controller.ts": 16,
|
||||
"src/nest/categories/categories.controller.ts": 4,
|
||||
"src/nest/collab/collab.controller.ts": 20,
|
||||
"src/nest/collections/collections.controller.ts": 40,
|
||||
"src/nest/config/config.controller.ts": 1,
|
||||
"src/nest/day-notes/day-notes.controller.ts": 4,
|
||||
"src/nest/days/days.controller.ts": 6,
|
||||
"src/nest/doc-sync/doc-sync-webhook.controller.ts": 1,
|
||||
"src/nest/doc-sync/doc-sync.controller.ts": 15,
|
||||
"src/nest/feeds/feeds.controller.ts": 10,
|
||||
"src/nest/files/files-download.controller.ts": 1,
|
||||
"src/nest/files/files.controller.ts": 11,
|
||||
"src/nest/google-quota/google-quota.controller.ts": 2,
|
||||
"src/nest/health/features.controller.ts": 3,
|
||||
"src/nest/help/help.controller.ts": 4,
|
||||
"src/nest/integrations/airtrail.controller.ts": 6,
|
||||
"src/nest/integrations/dawarich.controller.ts": 15,
|
||||
"src/nest/journey/journey-public.controller.ts": 3,
|
||||
"src/nest/journey/journey.controller.ts": 40,
|
||||
"src/nest/llm-parse/llm-capabilities.controller.ts": 1,
|
||||
"src/nest/llm-parse/llm-local.controller.ts": 2,
|
||||
"src/nest/maps/maps.controller.ts": 11,
|
||||
"src/nest/mcp-transport/mcp-transport.controller.ts": 3,
|
||||
"src/nest/memories/immich.controller.ts": 12,
|
||||
"src/nest/memories/synology.controller.ts": 10,
|
||||
"src/nest/memories/unified.controller.ts": 7,
|
||||
"src/nest/notifications/notifications.controller.ts": 18,
|
||||
"src/nest/notifications/push/push.controller.ts": 3,
|
||||
"src/nest/oauth/oauth-api.controller.ts": 8,
|
||||
"src/nest/oauth/oauth-public.controller.ts": 3,
|
||||
"src/nest/oidc/oidc.controller.ts": 5,
|
||||
"src/nest/packing/admin-packing-templates.controller.ts": 11,
|
||||
"src/nest/packing/packing.controller.ts": 20,
|
||||
"src/nest/photos/photos.controller.ts": 3,
|
||||
"src/nest/place-enrichment/place-enrichment.controller.ts": 2,
|
||||
"src/nest/place-shadow/place-shadow.controller.ts": 4,
|
||||
"src/nest/places/places.controller.ts": 17,
|
||||
"src/nest/platform/discovery.controller.ts": 9,
|
||||
"src/nest/plugins/contributions/atlas-layers.controller.ts": 1,
|
||||
"src/nest/plugins/contributions/day-schedule.controller.ts": 1,
|
||||
"src/nest/plugins/contributions/day-tints.controller.ts": 1,
|
||||
"src/nest/plugins/contributions/journal-entry-rows.controller.ts": 1,
|
||||
"src/nest/plugins/contributions/map-layers.controller.ts": 1,
|
||||
"src/nest/plugins/contributions/map-markers.controller.ts": 1,
|
||||
"src/nest/plugins/contributions/pdf-sections.controller.ts": 1,
|
||||
"src/nest/plugins/contributions/place-details.controller.ts": 1,
|
||||
"src/nest/plugins/contributions/plugin-calendar.controller.ts": 1,
|
||||
"src/nest/plugins/contributions/plugin-photos.controller.ts": 3,
|
||||
"src/nest/plugins/contributions/plugin-pois.controller.ts": 1,
|
||||
"src/nest/plugins/contributions/plugin-routes.controller.ts": 1,
|
||||
"src/nest/plugins/contributions/plugin-search.controller.ts": 2,
|
||||
"src/nest/plugins/contributions/trip-card-contributions.controller.ts": 1,
|
||||
"src/nest/plugins/contributions/trip-warnings.controller.ts": 1,
|
||||
"src/nest/plugins/contributions/view-contributions.controller.ts": 1,
|
||||
"src/nest/plugins/oauth/plugin-oauth.controller.ts": 4,
|
||||
"src/nest/plugins/plugin-activity.controller.ts": 1,
|
||||
"src/nest/plugins/plugin-frame.controller.ts": 1,
|
||||
"src/nest/plugins/plugin-user-settings.controller.ts": 3,
|
||||
"src/nest/plugins/plugins-feed.controller.ts": 1,
|
||||
"src/nest/plugins/plugins-proxy.controller.ts": 1,
|
||||
"src/nest/plugins/plugins.controller.ts": 24,
|
||||
"src/nest/public-api/public-api.controller.ts": 3,
|
||||
"src/nest/receipt-scan/receipt-scan.controller.ts": 1,
|
||||
"src/nest/reservation-import/reservation-import.controller.ts": 5,
|
||||
"src/nest/reservations/reservations.controller.ts": 6,
|
||||
"src/nest/reservations/upcoming-reservations.controller.ts": 1,
|
||||
"src/nest/roadtrip/charging-lookup.controller.ts": 1,
|
||||
"src/nest/roadtrip/charging.controller.ts": 1,
|
||||
"src/nest/roadtrip/day-boundaries.controller.ts": 3,
|
||||
"src/nest/roadtrip/google-route.controller.ts": 2,
|
||||
"src/nest/roadtrip/roadtrip-hazards.controller.ts": 1,
|
||||
"src/nest/roadtrip/roadtrip-preferences.controller.ts": 2,
|
||||
"src/nest/roadtrip/roadtrip-search.controller.ts": 1,
|
||||
"src/nest/roadtrip/roadtrip.controller.ts": 7,
|
||||
"src/nest/route-usage/route-usage.controller.ts": 3,
|
||||
"src/nest/school-holidays/school-holidays.controller.ts": 8,
|
||||
"src/nest/settings/settings.controller.ts": 5,
|
||||
"src/nest/share/share.controller.ts": 5,
|
||||
"src/nest/storage/storage-admin.controller.ts": 8,
|
||||
"src/nest/system-notices/system-notices.controller.ts": 2,
|
||||
"src/nest/tags/tags.controller.ts": 4,
|
||||
"src/nest/todo/todo.controller.ts": 7,
|
||||
"src/nest/tours/tours-import.controller.ts": 1,
|
||||
"src/nest/tours/tours.controller.ts": 4,
|
||||
"src/nest/transit/transit.controller.ts": 2,
|
||||
"src/nest/trip-invite/trip-invite.controller.ts": 5,
|
||||
"src/nest/trip-members/trip-members.controller.ts": 7,
|
||||
"src/nest/trips/trips.controller.ts": 12,
|
||||
"src/nest/vacay/vacay.controller.ts": 33,
|
||||
"src/nest/weather/weather.controller.ts": 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* lint:response-contracts: route handlers without a @ResponseContract may only shrink.
|
||||
*
|
||||
* `@ResponseContract(schema)` (src/nest/common/response-contract.ts) names the
|
||||
* @trek/shared schema a handler answers with, and under NODE_ENV=test every
|
||||
* response is parsed against it, so the e2e suites catch a response that
|
||||
* drifted from its contract. This holds the handlers still without one to
|
||||
* what they are today.
|
||||
*
|
||||
* Every method under src/nest/ carrying a route decorator (@Get, @Post, @Put,
|
||||
* @Patch, @Delete, @All, @Head, @Options) counts as a handler. One counts as
|
||||
* covered when it also carries @ResponseContract(...), or when a comment
|
||||
* above it reads `response-contract-exempt: <reason>` (a handler that writes
|
||||
* through @Res(), a redirect or a stream, has no return value to check). The
|
||||
* uncovered handlers are counted per file against
|
||||
* scripts/response-contract-baseline.json: a file may hold at most its entry,
|
||||
* a file without one none. An entry above what its file holds now, or for a
|
||||
* file that is gone, fails too until --update lowers it.
|
||||
*
|
||||
* npm run lint:response-contracts check against the baseline (CI)
|
||||
* npm run lint:response-contracts -- --update lower the counts to what the files hold now;
|
||||
* it never raises an entry and never adds one
|
||||
*
|
||||
* --dir=<path> points the check at another server root (the unit tests use it).
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ts = require('typescript');
|
||||
|
||||
/** The tree the check walks, relative to the server root. */
|
||||
export const ROOT = 'src/nest';
|
||||
|
||||
/** The baseline, relative to the server root. */
|
||||
export const BASELINE = 'scripts/response-contract-baseline.json';
|
||||
|
||||
/** The decorators that make a method a route handler. */
|
||||
export const ROUTE_DECORATORS = new Set(['Get', 'Post', 'Put', 'Patch', 'Delete', 'All', 'Head', 'Options']);
|
||||
|
||||
/** The comment that excuses a handler with no return value to check. */
|
||||
export const EXEMPT_MARKER = 'response-contract-exempt:';
|
||||
|
||||
const SOURCE = /\.ts$/;
|
||||
|
||||
function decoratorName(decorator) {
|
||||
const expr = decorator.expression;
|
||||
const callee = ts.isCallExpression(expr) ? expr.expression : expr;
|
||||
if (ts.isIdentifier(callee)) return callee.text;
|
||||
if (ts.isPropertyAccessExpression(callee)) return callee.name.text;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a comment directly above the method (between its decorators or before them) exempts it. */
|
||||
function isExempt(text, method) {
|
||||
const head = text.slice(method.getFullStart(), method.name.getStart());
|
||||
return head.split('\n').some((line) => /^\s*(\/\/|\/?\*)/.test(line) && line.includes(EXEMPT_MARKER));
|
||||
}
|
||||
|
||||
/** The route handlers of one file's text: `{ name, covered }` in source order. */
|
||||
export function handlersOf(text, fileName = 'file.ts') {
|
||||
const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
const handlers = [];
|
||||
const visit = (node) => {
|
||||
if (ts.isMethodDeclaration(node)) {
|
||||
const names = (ts.getDecorators(node) ?? []).map(decoratorName);
|
||||
if (names.some((n) => n && ROUTE_DECORATORS.has(n))) {
|
||||
const name = node.name && ts.isIdentifier(node.name) ? node.name.text : (node.name?.getText(source) ?? '?');
|
||||
handlers.push({ name, covered: names.includes('ResponseContract') || isExempt(text, node) });
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(source);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) walk(path, files);
|
||||
else if (SOURCE.test(name)) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler totals and the uncovered counts keyed by the server-relative POSIX path
|
||||
* (files with none left out of `uncovered`).
|
||||
*/
|
||||
export function scan(serverDir) {
|
||||
const dir = join(serverDir, ROOT);
|
||||
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
||||
throw new Error(`${ROOT}/ does not exist under ${serverDir}: the check would pass without looking at anything`);
|
||||
}
|
||||
const uncovered = {};
|
||||
let total = 0;
|
||||
for (const path of walk(dir)) {
|
||||
const file = relative(serverDir, path).split('\\').join('/');
|
||||
const handlers = handlersOf(readFileSync(path, 'utf8'), file);
|
||||
total += handlers.length;
|
||||
const n = handlers.filter((h) => !h.covered).length;
|
||||
if (n > 0) uncovered[file] = n;
|
||||
}
|
||||
if (total === 0)
|
||||
throw new Error(`no route handler found under ${ROOT}/: the check would pass without looking at anything`);
|
||||
return { total, uncovered };
|
||||
}
|
||||
|
||||
/** The baseline as committed; missing or malformed stops the run instead of reading as empty. */
|
||||
export function readBaseline(path) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
||||
} catch (err) {
|
||||
throw new Error(`${BASELINE} cannot be read: ${err.message}`);
|
||||
}
|
||||
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
if (!isObject(parsed) || !isObject(parsed.counts)) {
|
||||
throw new Error(`${BASELINE} must be an object with a "counts" object`);
|
||||
}
|
||||
for (const [file, n] of Object.entries(parsed.counts)) {
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new Error(`${BASELINE}: ${file} holds ${JSON.stringify(n)}, expected a whole number above 0`);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** The counts lowered to what the files hold now. Never raises an entry, never adds one. */
|
||||
export function lowerCounts(baseline, counts) {
|
||||
const lowered = {};
|
||||
for (const [file, allowed] of Object.entries(baseline.counts)) {
|
||||
const now = counts[file] ?? 0;
|
||||
if (now > 0) lowered[file] = Math.min(allowed, now);
|
||||
}
|
||||
return Object.fromEntries(Object.entries(lowered).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
||||
}
|
||||
|
||||
/** Files over their allowance, and entries above what their file holds now. */
|
||||
export function compare(baseline, counts) {
|
||||
const grown = Object.entries(counts).filter(([file, n]) => n > (baseline.counts[file] ?? 0));
|
||||
const lowered = lowerCounts(baseline, counts);
|
||||
const stale = Object.entries(baseline.counts).filter(([file, n]) => lowered[file] !== n);
|
||||
return { grown, stale };
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const dirArg = argv.find((a) => a.startsWith('--dir='));
|
||||
const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : fileURLToPath(new URL('..', import.meta.url));
|
||||
const baselinePath = join(serverDir, BASELINE);
|
||||
|
||||
const { total, uncovered } = scan(serverDir);
|
||||
const baseline = readBaseline(baselinePath);
|
||||
if (argv.includes('--update')) {
|
||||
baseline.counts = lowerCounts(baseline, uncovered);
|
||||
writeFileSync(baselinePath, JSON.stringify(baseline, null, 2) + '\n');
|
||||
}
|
||||
|
||||
const { grown, stale } = compare(baseline, uncovered);
|
||||
for (const [file, n] of grown) {
|
||||
const entry = baseline.counts[file];
|
||||
console.error(
|
||||
`FAIL ${file}: ${n} route handler(s) without @ResponseContract, ` +
|
||||
(entry ? `its baseline is ${entry}. ` : 'a file without a baseline entry may hold none. ') +
|
||||
'Declare the @trek/shared response schema the handler answers with (src/nest/common/response-contract.ts), ' +
|
||||
`or mark a handler that writes through @Res() with a "${EXEMPT_MARKER} <reason>" comment.`,
|
||||
);
|
||||
}
|
||||
for (const [file, entry] of stale) {
|
||||
console.error(
|
||||
`FAIL ${file} is held at ${entry} in ${BASELINE}, ` +
|
||||
(file in uncovered ? `but it has ${uncovered[file]} now.` : 'but it has none left (or the file is gone).'),
|
||||
);
|
||||
}
|
||||
if (stale.length) {
|
||||
console.error(
|
||||
'Run npm run lint:response-contracts -- --update to lower the baseline with the change that made it smaller.',
|
||||
);
|
||||
}
|
||||
const open = Object.values(uncovered).reduce((sum, n) => sum + n, 0);
|
||||
const share = ((100 * (total - open)) / total).toFixed(1);
|
||||
console.log(
|
||||
`response-contracts: ${total - open} of ${total} route handler(s) covered (${share} %), the rest held at their baseline`,
|
||||
);
|
||||
return grown.length || stale.length ? 1 : 0;
|
||||
}
|
||||
|
||||
// Compared by real path, so the check still runs when the script is started through a symlink.
|
||||
const isCli =
|
||||
Boolean(process.argv[1]) &&
|
||||
existsSync(process.argv[1]) &&
|
||||
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isCli) {
|
||||
try {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
console.error(`FAIL ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user