From 025822a76a976956309e3ebe2065b408d6416d9e Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Sat, 24 Oct 2026 21:48:04 +0200 Subject: [PATCH] fix: serve the proxy's own code pages that its problem documents link to --- docs/development.md | 2 ++ src/app/codes/[code]/route.ts | 9 +++++ src/lib/proxy/problem.ts | 4 +-- src/lib/proxy/proxy-codes.json | 15 ++++++++ src/lib/proxy/proxyCodes.ts | 51 +++++++++++++++++++++++++++ src/lib/proxy/test/proxyCodes.test.ts | 48 +++++++++++++++++++++++++ 6 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 src/lib/proxy/proxy-codes.json create mode 100644 src/lib/proxy/proxyCodes.ts create mode 100644 src/lib/proxy/test/proxyCodes.test.ts diff --git a/docs/development.md b/docs/development.md index ccc1c54..e1ac5ce 100644 --- a/docs/development.md +++ b/docs/development.md @@ -223,6 +223,8 @@ pnpm install A new package version packs under a new file name. Point the `@developmentfinance/client` entry in `package.json` at it before re-locking. A rebuilt tarball keeps its file name, so a local re-vendor needs the client's directory under `node_modules/.pnpm` deleted before `pnpm install --frozen-lockfile` picks it up. Commit the tarball and the lockfile together. +The tarball carries the statuses of the codes the proxy raises itself and not their descriptions. `src/lib/proxy/proxy-codes.json` holds the full file, copied from the spec-kit repository's `python/client-contract/src/dfo_client_contract/proxy-codes.json` and formatted with Prettier. Copy it again when the client contract changes it. + ## Conventions - Styles: global partials live in `src/styles` (`_variables`, `_mixins`, `_breakpoints`, …). They are on the Sass load path, so import them from anywhere as `@use "variables" as *;` with no relative paths. Fluid rem sizing, `html { font-size: vw(16px) }`, scales the whole layout with the viewport (separate bases for desktop, tablet and phone, see `$widths`). Size things with `rem()` and `@include font-size()`, and write every design px value as `rem(x)`. Breakpoints are `@include breakpoint(phone)` (< 660px) and `@include breakpoint(tablet)` (660–1023px). diff --git a/src/app/codes/[code]/route.ts b/src/app/codes/[code]/route.ts index 830a578..39e2211 100644 --- a/src/app/codes/[code]/route.ts +++ b/src/app/codes/[code]/route.ts @@ -3,13 +3,22 @@ import { NextRequest } from 'next/server'; import { codeTarget } from '@/lib/proxy/codes'; import { forward } from '@/lib/proxy/forward'; import { notFoundProblem } from '@/lib/proxy/problem'; +import { proxyCodeEntry } from '@/lib/proxy/proxyCodes'; import { searchApiBase } from '@/lib/searchApi'; // The API's refusal-code registry (GET /codes/{code}), served at the same // root path on the site's domain. A problem document's `type` is that // root-relative path, so the link in it resolves here (see src/lib/proxy/codes). +// The codes the proxy raises itself are not in the API's registry, so they are +// answered here and every other code is forwarded. export const GET = async (req: NextRequest, { params }: { params: Promise<{ code: string }> }) => { const { code } = await params; + const proxyEntry = proxyCodeEntry(code); + + if (proxyEntry) { + return Response.json(proxyEntry, { headers: { 'Cache-Control': 'no-cache' } }); + } + const target = codeTarget(searchApiBase(), code, req.nextUrl.search); if (!target) { diff --git a/src/lib/proxy/problem.ts b/src/lib/proxy/problem.ts index 435ad24..ae9dd67 100644 --- a/src/lib/proxy/problem.ts +++ b/src/lib/proxy/problem.ts @@ -10,7 +10,7 @@ * `node --test` can load it. */ -const TITLES = { +export const TITLES = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', @@ -29,7 +29,7 @@ export type ProblemStatus = keyof typeof TITLES; * `proxy-codes.json` holds, so `/codes/` resolves to a page. Any other * code the site refuses with is its own, and its `type` is `about:blank`. */ -const REGISTRY_CODES = [ +export const REGISTRY_CODES = [ 'request_invalid', 'not_found', 'rate_limited', diff --git a/src/lib/proxy/proxy-codes.json b/src/lib/proxy/proxy-codes.json new file mode 100644 index 0000000..3110900 --- /dev/null +++ b/src/lib/proxy/proxy-codes.json @@ -0,0 +1,15 @@ +{ + "format_version": 1, + "codes": [ + { + "code": "rate_limited", + "status": 429, + "condition": "The website proxy's rate limit for the caller's address is spent; the problem document is retryable and the response carries Retry-After." + }, + { + "code": "transient_unavailable", + "status": 502, + "condition": "The website proxy could not reach the API behind it; the problem document is retryable." + } + ] +} diff --git a/src/lib/proxy/proxyCodes.ts b/src/lib/proxy/proxyCodes.ts new file mode 100644 index 0000000..1a3c7c7 --- /dev/null +++ b/src/lib/proxy/proxyCodes.ts @@ -0,0 +1,51 @@ +/** + * The codes the website proxy raises itself, as `GET /codes/{code}` answers + * them. A problem document's `type` for such a code is `/codes/`, and + * the API's registry does not hold it, so src/app/codes/[code]/route.ts + * answers here before it forwards. The body has the shape of the API's + * `CodeRegistryEntry` (the served OpenAPI document's `/codes/{code}`). + * + * `proxy-codes.json` is the client contract's file of the same name, + * vendored from the spec-kit repository's + * `python/client-contract/src/dfo_client_contract/proxy-codes.json` at + * 0d19172, formatted by Prettier. The vendored `@developmentfinance/client` + * carries its statuses and not its descriptions, so the file is copied here. + * Kept apart from the route so `node --test` can load it. + */ +import { TITLES } from './problem.ts'; +import contract from './proxy-codes.json' with { type: 'json' }; + +type CodeRegistryEntry = { + code: string; + classification: 'refusal'; + definition: string; + lifecycle: 'active'; + mappings: []; + title: string; + detail: null; +}; + +const isProblemStatus = (status: number): status is keyof typeof TITLES => status in TITLES; + +const entry = ({ code, status, condition }: (typeof contract.codes)[number]): CodeRegistryEntry => { + if (!isProblemStatus(status)) { + throw new Error(`proxy-codes.json gives ${code} the status ${status}, which has no title`); + } + + return { + code, + classification: 'refusal', + definition: condition, + lifecycle: 'active', + mappings: [], + title: TITLES[status], + detail: null, + }; +}; + +const ENTRIES: ReadonlyMap = new Map( + contract.codes.map((proxyCode) => [proxyCode.code, entry(proxyCode)]), +); + +/** The registry entry for a code the proxy raises, or undefined for any other code. */ +export const proxyCodeEntry = (code: string): CodeRegistryEntry | undefined => ENTRIES.get(code); diff --git a/src/lib/proxy/test/proxyCodes.test.ts b/src/lib/proxy/test/proxyCodes.test.ts new file mode 100644 index 0000000..600b9a9 --- /dev/null +++ b/src/lib/proxy/test/proxyCodes.test.ts @@ -0,0 +1,48 @@ +/** + * `proxyCodes.ts`: the `/codes/{code}` answers for the codes the proxy raises itself. + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { REGISTRY_CODES, rateLimitedProblem, unreachableProblem } from '../problem.ts'; +import { proxyCodeEntry } from '../proxyCodes.ts'; + +// Read from the file, so the expected split does not come from the module under test. +const { codes } = JSON.parse( + readFileSync(new URL('../proxy-codes.json', import.meta.url), 'utf8'), +) as { codes: { code: string; status: number; condition: string }[] }; +const PROXY_CODES = new Set(codes.map(({ code }) => code)); + +for (const code of REGISTRY_CODES) { + const isProxyCode = PROXY_CODES.has(code); + + test(`proxyCodeEntry: ${code} is ${isProxyCode ? 'answered here' : 'left to the API'}`, () => { + assert.equal(proxyCodeEntry(code) !== undefined, isProxyCode); + }); +} + +test('proxyCodeEntry: every proxy code in the contract is a registry code, so its type resolves', () => { + for (const code of PROXY_CODES) { + assert.ok((REGISTRY_CODES as readonly string[]).includes(code), code); + } +}); + +test('proxyCodeEntry: the body has the API registry entry shape, titled as the problem document is', async () => { + const [{ code, condition }] = codes.filter(({ code }) => code === 'rate_limited'); + const problem = await rateLimitedProblem({ limit: 1, reset: 1 }, {}).json(); + + assert.deepEqual(proxyCodeEntry(code), { + code, + classification: 'refusal', + definition: condition, + lifecycle: 'active', + mappings: [], + title: problem.title, + detail: null, + }); + assert.equal( + proxyCodeEntry('transient_unavailable')?.title, + (await unreachableProblem('x', {}).json()).title, + ); +});