Files
HealthLog/eslint-plugins/healthlog/api-fetch-required.js
T
Marc-André Bombeck 320c33b918 lint: enforce api-fetch wrapper across src/lib/queries
Add src/lib/queries/ to the api-fetch-required ENFORCED_ROOTS so the
rule matches its own header comment, then route the two flagged query
hooks through apiGet instead of hand-rolling the envelope unwrap.
2026-06-20 17:10:24 +02:00

142 lines
5.0 KiB
JavaScript

/**
* @fileoverview ESLint rule — client-side `/api/...` calls must route
* through the typed `apiFetch` wrapper.
*
* `src/lib/api/api-fetch.ts` owns the `.ok` check, the
* `{ data, error, meta? }` envelope unwrap, and the `ApiError` shape
* (message via `readError`, `status`, error-side `meta`). A raw
* `fetch("/api/…")` in a component re-implements that contract by hand
* — the exact drift this wrapper retired — so this rule flags it at
* authoring time and in CI.
*
* Complements `healthlog/safe-fetch-required`: that rule covers
* OUTBOUND egress (absolute / variable-sourced URLs) and exempts
* same-origin relative paths; this rule covers exactly those same-origin
* `/api/...` paths inside the client surface.
*
* Allowed:
* - `apiFetch` / verb helpers / `apiFetchEnvelope` / `apiFetchRaw` —
* distinct identifiers, never match.
* - calls inside `src/lib/api/api-fetch.ts` — the wrapper's own `fetch`.
* - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they
* mock or assert against `fetch` directly.
*
* The check is a syntactic `CallExpression` match against a bare
* `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` /
* `window.fetch(` / `self.fetch(` member forms, where the first
* argument is a string literal or template literal whose head starts
* with `/api/`. Variable-sourced or absolute URLs are the
* safe-fetch-required rule's territory and stay out of scope here.
*
* @see src/lib/api/api-fetch.ts
* @see eslint-plugins/healthlog/safe-fetch-required.js
*/
"use strict";
// Files exempt from the rule — the wrapper internals and the test
// surface. Posix-style suffix matches against the absolute filename via
// `String#includes`, mirroring the safe-fetch-required convention.
const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"];
// Enforce across the client-facing source roots. `src/lib/` stays out:
// server-side lib code never self-calls `/api/` routes, and the
// safe-fetch rule already covers its outbound calls. `src/hooks/` is
// client-only by construction (v1.16.4) — its `/api/` reads route
// through the wrapper like every component. `src/lib/queries/` is the
// one `src/lib/` exception: it holds client-only TanStack-Query hooks
// (every file is `"use client"`), so its `/api/` reads route through the
// wrapper too.
const ENFORCED_ROOTS = [
"src/components/",
"src/app/",
"src/hooks/",
"src/lib/queries/",
];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
function isTestFile(posix) {
return (
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
posix.includes("/__tests__/") ||
posix.includes("/__mocks__/")
);
}
function isEnforced(filename) {
const posix = toPosix(filename);
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
if (isTestFile(posix)) return false;
return true;
}
/** @type {import("eslint").Rule.RuleModule} */
const apiFetchRequiredRule = {
meta: {
type: "problem",
docs: {
description:
"Same-origin /api/ calls must route through the apiFetch wrapper (src/lib/api/api-fetch.ts).",
},
schema: [],
messages: {
rawApiFetch:
'Raw fetch("/api/…") re-implements the envelope unwrap + error contract by hand. Import { apiFetch } (or a verb helper / apiFetchRaw) from "@/lib/api/api-fetch" and call it instead.',
},
},
create(context) {
const filename = context.filename ?? context.getFilename?.();
if (!filename || !isEnforced(filename)) {
return {};
}
function isFetchCallee(callee) {
// Bare `fetch(...)`.
if (callee.type === "Identifier" && callee.name === "fetch") return true;
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`.
if (
callee.type === "MemberExpression" &&
!callee.computed &&
callee.property.type === "Identifier" &&
callee.property.name === "fetch" &&
callee.object.type === "Identifier" &&
(callee.object.name === "globalThis" ||
callee.object.name === "window" ||
callee.object.name === "self")
) {
return true;
}
return false;
}
function isApiPath(arg) {
if (!arg) return false;
const isApiHead = (head) => head.startsWith("/api/");
// `fetch("/api/…")`
if (arg.type === "Literal" && typeof arg.value === "string") {
return isApiHead(arg.value);
}
// `fetch(`/api/${id}`)` — inspect the template's first cooked chunk.
if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) {
return isApiHead(arg.quasis[0].value.cooked ?? "");
}
return false;
}
return {
CallExpression(node) {
if (!isFetchCallee(node.callee)) return;
if (!isApiPath(node.arguments[0])) return;
context.report({ node, messageId: "rawApiFetch" });
},
};
},
};
module.exports = apiFetchRequiredRule;