Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
280ba71
Generate a typed Public API v2 client with an advisory drift check
anth-volk Oct 26, 2026
649822a
Load v3's model metadata from the small /v2/model-catalog behind a sw…
anth-volk Oct 26, 2026
a24cae7
Build v3's parameter search from the catalog's search file on the v2 …
anth-volk Oct 26, 2026
8cf5231
Load v3's parameters on demand from /v2/model-catalog/parameters
anth-volk Oct 26, 2026
7e96953
Have v3's views name the parameters they show and wait for them
anth-volk Oct 26, 2026
6412ecd
Hold v3's pages behind a short loading page only while the model cata…
anth-volk Oct 26, 2026
f08c41b
Browse v3's parameter tree one level at a time from /v2/parameters/ch…
anth-volk Oct 26, 2026
7b41814
Save v3's reforms as /v2/policies policies on the v2 routes
anth-volk Oct 26, 2026
96c5620
Ask again for on-demand parameters after the model catalog reloads
anth-volk Oct 26, 2026
fe58c82
Load a report's parameters before parsing its policies
anth-volk Oct 26, 2026
397e06a
Load no metadata on pages that only redirect into v3, after the switch
anth-volk Oct 26, 2026
5a2eb1e
Hold a folder opened from search on its placeholders until its trail …
anth-volk Oct 26, 2026
fb5ea34
Hold the Reforms page's details until their parameters load, and say …
anth-volk Oct 26, 2026
e0656a5
Make the Ask page wait for current law and say when it fails to load
anth-volk Oct 26, 2026
0219e9c
Type v2 requests' country from the generated client, and the model ca…
anth-volk Oct 26, 2026
3375762
Load no metadata on every page that only redirects into v3, after the…
anth-volk Oct 26, 2026
76ddbd1
Report a report's failed parameter load with a retry, and keep the v1…
anth-volk Oct 26, 2026
b5afba1
Show the v2 policy tree's current-law values loading, and say when th…
anth-volk Oct 26, 2026
950fe04
Read the reform store's metadata from the Redux store when the store …
anth-volk Oct 26, 2026
d686011
Keep a reform's v1 and v2 policy IDs apart, and rerun a v2 comparison…
anth-volk Oct 26, 2026
7a5f2a7
Keep Build's Save buttons disabled until the draft's current law loads
anth-volk Oct 26, 2026
581b79e
Hold Build's run when its comparison's current law fails to load, and…
anth-volk Oct 26, 2026
3c7c941
Regenerate the Public API client from the final v2 metadata routes
anth-volk Oct 27, 2026
d9e6ee1
Count the v2 policy tree's folders by what a draft can add, from the …
anth-volk Oct 27, 2026
1adbd8d
Keep the v1 failure displays exactly as today, showing the new ones o…
anth-volk Oct 27, 2026
8037041
Give the chat's draft step to the answer it came from, even after a n…
anth-volk Oct 27, 2026
0641d8c
Use the generated bodies for saved reforms in both stores, and check …
anth-volk Oct 27, 2026
8793eba
Regenerate the lockfile with the pinned Bun 1.2.21
anth-volk Oct 27, 2026
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
20 changes: 20 additions & 0 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,26 @@ jobs:
working-directory: ./website
run: bun run test

public-api-client:
name: Public API client is current
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install --frozen-lockfile

# Regenerates the types from the checked-in OpenAPI document and fails
# when src/api/publicApi/schema.d.ts differs from the result.
- name: Check the generated client against its OpenAPI document
working-directory: ./app
run: bun run public-api:check

check-embeds:
name: Check embedded site URLs
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"evaluate-search": "tsx scripts/evaluate-parameter-search.ts",
"refresh-search-eval-cases": "tsx scripts/refresh-parameter-search-cases.ts",
"build-parameter-dependencies": "python3 scripts/build-parameter-dependency-map.py",
"public-api:generate": "tsx scripts/public-api-client.ts generate",
"public-api:check": "tsx scripts/public-api-client.ts check",
"public-api:refresh": "tsx scripts/public-api-client.ts refresh",
"typecheck": "tsc --noEmit",
"lint": "bun run eslint && bun run stylelint",
"eslint": "eslint . --cache --max-warnings 0",
Expand Down Expand Up @@ -39,6 +42,7 @@
"fuse.js": "^7.0.0",
"html-to-image": "^1.11.13",
"lucide-react": "^0.575.0",
"openapi-fetch": "0.17.0",
"policyengine-household-wizard": "^0.1.0",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
Expand Down Expand Up @@ -78,6 +82,7 @@
"fast-check": "^4.10.2",
"identity-obj-proxy": "^3.0.0",
"jsdom": "^26.1.0",
"openapi-typescript": "7.13.0",
"postcss": "^8.5.5",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.5.3",
Expand Down
144 changes: 144 additions & 0 deletions app/scripts/public-api-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* TypeScript types for the Public API's v2 routes (policyengine-api),
* generated from its OpenAPI document (`GET /v2/openapi.json`).
*
* The document is checked in beside the generated types, so a build never
* calls the API, and CI can check that the types match the document:
*
* bun run public-api:generate writes schema.d.ts from openapi.json
* bun run public-api:check fails when schema.d.ts is out of date
* bun run public-api:refresh <source> copies the document from a URL or a
* file, then writes schema.d.ts
*
* <source> is an API origin (https://api.policyengine.org), a full URL of
* the document, or a path to a saved copy of it.
*/
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import openapiTS, { astToString, type OpenAPI3 } from 'openapi-typescript';
import prettier from 'prettier';

const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
const CLIENT_DIRECTORY = path.resolve(SCRIPT_DIRECTORY, '../src/api/publicApi');

export const OPENAPI_DOCUMENT_PATH = path.join(CLIENT_DIRECTORY, 'openapi.json');
export const GENERATED_SCHEMA_PATH = path.join(CLIENT_DIRECTORY, 'schema.d.ts');

const GENERATED_HEADER = `/**
* Generated from src/api/publicApi/openapi.json by scripts/public-api-client.ts.
* Do not edit: run \`bun run public-api:generate\` (or \`public-api:refresh\`).
*/
`;

/** The path the API serves its v2 OpenAPI document at. */
const OPENAPI_DOCUMENT_ROUTE = '/v2/openapi.json';

/** The generated, formatted types for an OpenAPI document. */
export async function generatePublicApiTypes(openapiDocument: OpenAPI3): Promise<string> {
const syntaxTree = await openapiTS(openapiDocument);
const source = `${GENERATED_HEADER}\n${astToString(syntaxTree)}`;
const prettierConfig = (await prettier.resolveConfig(GENERATED_SCHEMA_PATH)) ?? {};
return prettier.format(source, { ...prettierConfig, filepath: GENERATED_SCHEMA_PATH });
}

export interface SchemaComparison {
upToDate: boolean;
expected: string;
}

/** Whether checked-in types equal what the document generates. */
export async function compareGeneratedTypes(
openapiDocument: OpenAPI3,
checkedInTypes: string
): Promise<SchemaComparison> {
const expected = await generatePublicApiTypes(openapiDocument);
return { upToDate: expected === checkedInTypes, expected };
}

/** Where to read the document from: an origin gets the v2 route appended. */
export function openapiDocumentLocation(source: string): string {
if (!/^https?:\/\//.test(source)) {
return source;
}
const url = new URL(source);
if (url.pathname === '/' || url.pathname === '') {
url.pathname = OPENAPI_DOCUMENT_ROUTE;
}
return url.toString();
}

/** The document as checked in: two-space JSON with a final newline. */
export function serializeOpenapiDocument(openapiDocument: OpenAPI3): string {
return `${JSON.stringify(openapiDocument, null, 2)}\n`;
}

async function readOpenapiDocument(location: string): Promise<OpenAPI3> {
if (/^https?:\/\//.test(location)) {
const response = await fetch(location, { headers: { Accept: 'application/json' } });
if (!response.ok) {
throw new Error(`GET ${location} returned ${response.status}`);
}
return (await response.json()) as OpenAPI3;
}
return JSON.parse(await readFile(location, 'utf8')) as OpenAPI3;
}

async function writeGeneratedTypes(): Promise<void> {
const openapiDocument = await readOpenapiDocument(OPENAPI_DOCUMENT_PATH);
await writeFile(GENERATED_SCHEMA_PATH, await generatePublicApiTypes(openapiDocument));
console.info(`Wrote ${path.relative(process.cwd(), GENERATED_SCHEMA_PATH)}`);
}

async function checkGeneratedTypes(): Promise<boolean> {
const openapiDocument = await readOpenapiDocument(OPENAPI_DOCUMENT_PATH);
const checkedInTypes = await readFile(GENERATED_SCHEMA_PATH, 'utf8');
const { upToDate } = await compareGeneratedTypes(openapiDocument, checkedInTypes);
if (upToDate) {
console.info('The generated Public API types match openapi.json.');
return true;
}
console.error(
'src/api/publicApi/schema.d.ts does not match src/api/publicApi/openapi.json.\n' +
'Run `bun run public-api:generate` in app/ and commit the result.'
);
return false;
}

async function refreshOpenapiDocument(source: string | undefined): Promise<void> {
if (!source) {
throw new Error('Name the document to copy: an API origin, a URL or a file path.');
}
const location = openapiDocumentLocation(source);
const openapiDocument = await readOpenapiDocument(location);
await writeFile(OPENAPI_DOCUMENT_PATH, serializeOpenapiDocument(openapiDocument));
console.info(`Copied ${location} to ${path.relative(process.cwd(), OPENAPI_DOCUMENT_PATH)}`);
await writeGeneratedTypes();
}

async function main(commandLineArguments: string[]): Promise<number> {
const [command, source] = commandLineArguments;
switch (command) {
case 'generate':
await writeGeneratedTypes();
return 0;
case 'check':
return (await checkGeneratedTypes()) ? 0 : 1;
case 'refresh':
await refreshOpenapiDocument(source);
return 0;
default:
console.error('Usage: public-api-client.ts generate | check | refresh <source>');
return 2;
}
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv.slice(2)).then(
(exitCode) => process.exit(exitCode),
(error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
);
}
110 changes: 110 additions & 0 deletions app/src/api/publicApi/catalogParameters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {
createPublicApiClient,
publicApiCountryId,
requireResult,
type PublicApiClient,
} from '@/api/publicApi/client';
import { parameterValueFromApi } from '@/api/publicApi/jsonValues';
import type { components } from '@/api/publicApi/schema';
import { v1FallbackLabel } from '@/libs/flagship/v2ParameterSearch';
import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata';
import type { ValuesList } from '@/types/subIngredients/valueInterval';

/**
* Parameters fetched by name from the Public API's
* `GET /v2/model-catalog/parameters`, with their current-law values and the
* labels of every node above them (for breadcrumbs), in the shape v1's
* metadata gave each parameter, so code that reads the metadata store
* reads them unchanged.
*/

type Schemas = components['schemas'];

/** How many names one request takes (the route's own bound). */
export const CATALOG_PARAMETER_BATCH = 100;

export interface LoadedCatalogParameters {
/** The parameters found, and every node above them. */
parameters: ParameterMetadataCollection;
/** Each found parameter's catalog ID, which `/v2/policies` takes. */
parameterIds: Record<string, string>;
/** Names the catalog version does not have. */
missingNames: string[];
}

/** v1 dated a value in force since the earliest date 0000-01-01; Python's earliest is year 1. */
const V2_EARLIEST_DATE = '0001-01-01';
const V1_EARLIEST_DATE = '0000-01-01';

function v1Values(parameterName: string, values: Schemas['MetadataDatedValue'][]): ValuesList {
return Object.fromEntries(
values.map((dated) => {
const date = dated.start_date.slice(0, 10);
return [
date === V2_EARLIEST_DATE ? V1_EARLIEST_DATE : date,
parameterValueFromApi(dated.value, parameterName),
];
})
);
}

function addResult(
loaded: LoadedCatalogParameters,
result: Schemas['MetadataCatalogParametersResult']
): void {
for (const [path, label] of Object.entries(result.node_labels)) {
loaded.parameters[path] = {
type: 'parameterNode',
parameter: path,
label: label ?? v1FallbackLabel(path),
};
}
for (const parameter of result.parameters) {
loaded.parameters[parameter.name] = {
type: 'parameter',
parameter: parameter.name,
label: parameter.label ?? v1FallbackLabel(parameter.name),
description: parameter.description,
unit: parameter.unit,
values: v1Values(parameter.name, parameter.values),
economy: parameter.economy ?? true,
household: parameter.household ?? true,
};
loaded.parameterIds[parameter.name] = parameter.id;
}
loaded.missingNames.push(...result.missing_names);
}

/** The named parameters of a catalog version, in batches the route takes. */
export async function fetchCatalogParameters(
countryId: string,
policyengineVersion: string,
names: readonly string[],
client: PublicApiClient = createPublicApiClient()
): Promise<LoadedCatalogParameters> {
const unique = [...new Set(names)].sort();
const batches: string[][] = [];
for (let start = 0; start < unique.length; start += CATALOG_PARAMETER_BATCH) {
batches.push(unique.slice(start, start + CATALOG_PARAMETER_BATCH));
}
const results = await Promise.all(
batches.map((batch) =>
requireResult(
client.GET('/v2/model-catalog/parameters', {
params: {
query: {
country_id: publicApiCountryId(countryId),
policyengine_version: policyengineVersion,
name: batch,
},
},
})
)
)
);
const loaded: LoadedCatalogParameters = { parameters: {}, parameterIds: {}, missingNames: [] };
for (const result of results) {
addResult(loaded, result);
}
return loaded;
}
Loading
Loading