Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
9 changes: 9 additions & 0 deletions src/app/codes/[code]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/proxy/problem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* `node --test` can load it.
*/

const TITLES = {
export const TITLES = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
Expand All @@ -29,7 +29,7 @@ export type ProblemStatus = keyof typeof TITLES;
* `proxy-codes.json` holds, so `/codes/<code>` 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',
Expand Down
15 changes: 15 additions & 0 deletions src/lib/proxy/proxy-codes.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
51 changes: 51 additions & 0 deletions src/lib/proxy/proxyCodes.ts
Original file line number Diff line number Diff line change
@@ -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/<code>`, 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<string, CodeRegistryEntry> = 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);
48 changes: 48 additions & 0 deletions src/lib/proxy/test/proxyCodes.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
Loading