@classic-homes/api
v1.0.1
Published
Typed client for the CHAPI API, generated from the committed OpenAPI contract.
Readme
@classic-homes/api
Typed client for the CHAPI API. Every path, query param, request body and
response is typed from CHAPI's OpenAPI contract (openapi.json at the repo
root), so consuming repos get autocomplete and compile-time checks against the
real API — including the v2 (D1-backed) endpoints. v2 response bodies are typed
per resource (e.g. LotV2, CommunityV2) from the field registry.
Install
npm install @classic-homes/apiThis is a scoped, restricted package published under the @classic-homes org, so a
consuming repo must authenticate npm to that scope first. Add an .npmrc at the
repo root:
# .npmrc (consuming repo)
@classic-homes:registry=https://registry.npmjs.org/
//registry.npmjs.org/:_authToken=${NPM_TOKEN}Then export a token with read access to the org before installing / in CI:
export NPM_TOKEN=xxxxxxxx # org read token; do NOT commit it
npm install @classic-homes/apiUsage
import { createChapiClient } from '@classic-homes/api';
const chapi = createChapiClient({
baseUrl: 'https://api.example.com',
token: async () => getAccessToken(), // string | () => string | Promise<string>
});
// List v2 lots — params, query and response are fully typed.
const { data, error } = await chapi.GET('/v2/lots', {
params: { query: { page: 1, limit: 25, sort: '-lotNumber' } },
});
if (error) throw new Error('request failed');
for (const lot of data.data) {
// lot is typed from the OpenAPI schema
}
// PATCH v2 enrichment
await chapi.PATCH('/v2/filings/{id}', {
params: { path: { id: 390 } },
body: { webStatus: 'Production' },
});The client is a thin wrapper over
openapi-fetch; GET/POST/PATCH/…
methods and the params/body shapes come from it.
Authentication
Every request is sent as Authorization: Bearer <token>. The API accepts either:
- A JWT — obtained from CHAPI's auth flow. Use a
tokenfunction to refresh short-lived JWTs per request. - An API key — pass the key string as
token. The API checks the bearer value as an API key first, then falls back to JWT verification.
// static API key
const chapi = createChapiClient({ baseUrl, token: process.env.CHAPI_API_KEY });
// or a refreshing JWT
const chapi = createChapiClient({ baseUrl, token: () => auth.getAccessToken() });Auth helpers
The package also ships typed auth calls, error predicates, and an auto-refreshing session that handles CHAPI's short-lived access tokens and refresh-token rotation (every refresh returns a NEW refresh token that replaces the old one).
import {
createChapiClient,
createAuthSession,
login,
isUnauthorized,
isForbidden,
isRateLimited,
} from '@cos/chapi-client';
// 1. Log in (typed wrapper over POST /v1/auth/login)
const bootstrap = createChapiClient({ baseUrl });
const { data, error } = await login(bootstrap, { email, password });
if (error) throw new Error('login failed');
// 2. Create a session that keeps the access token fresh and follows rotation.
// Persist BOTH tokens on change — the previous refresh token is now invalid.
const session = createAuthSession({
baseUrl,
tokens: data, // { accessToken, refreshToken, sessionToken }
onTokensChanged: (t) => saveToStorage(t),
});
// 3. Use the session's token provider — it refreshes transparently before expiry.
const chapi = createChapiClient({ baseUrl, token: session.token });
// 4. Branch on typed errors instead of string-matching codes
const res = await chapi.GET('/v2/homes', { params: { query: { page: 1 } } });
if (isUnauthorized(res.error)) redirectToLogin();
else if (isForbidden(res.error)) showNoAccess();
else if (isRateLimited(res.error)) backOff();logout(client) and refresh(client, refreshToken) are also exported. See
docs/TOKEN_LIFECYCLE.md in the API repo for the full lifecycle.
Errors, pagination & permissions
- Errors —
openapi-fetchreturns{ data, error }(it does not throw on non-2xx).erroris the typed error body ({ error: { code, message, ... } }). Always branch onerrorbefore usingdata. - Pagination — list responses carry
meta.pagination(page,limit,total,totalPages).limitmax is 500. Page withparams.query.page/limit. - Sources & staleness —
meta.sourcesnames the backing edge DB;meta.sync(lastCompleted,isStale) and theX-Data-Staleheader report data freshness. - Permissions are exact-match — v2 hides fields your token isn't explicitly
granted. A
*wildcard does NOT unlock permission-scoped fields (e.g. financial fields need<resource>:read:financial). Missing fields usually mean a missing permission, not a bug. - PATCH write-through —
meta.writeThroughis'ok'when the edge DB was updated in-request, or'deferred'when the write is pending the next sync (the underlying record was still updated; the read model just lags briefly).
Incremental sync (updated-since)
Each resource exposes GET /v2/<resource>/updated-since?since=<ISO> returning rows
with lastUpdated >= since, ordered by lastUpdated then primary key. To pull a
stable incremental feed, page until empty, then advance since to the max
lastUpdated seen and dedupe by id (rows sharing a boundary timestamp can repeat
across polls):
async function pullSince(chapi, since: string) {
const seen = new Set<string>();
let page = 1;
let maxUpdated = since;
for (;;) {
const { data, error } = await chapi.GET('/v2/lots/updated-since', {
params: { query: { since, page, limit: 500 } },
});
if (error) throw new Error('updated-since failed');
for (const row of data.data) {
if (seen.has(String(row.id))) continue;
seen.add(String(row.id));
if (row.lastUpdated && row.lastUpdated > maxUpdated) maxUpdated = row.lastUpdated;
// ...upsert row into your store...
}
if (data.data.length < 500) break;
page += 1;
}
return maxUpdated; // pass as `since` on the next poll
}How the types stay accurate
openapi.json(repo root) is generated from the endpoint registry vianpm run openapi:generateand committed.- CI runs
npm run openapi:checkto fail the build if the committed spec drifts from the code. - This package's
npm run generateregeneratessrc/types.tsfrom that spec (src/types.tsis git-ignored — always built, never hand-edited).
Scripts
| Script | Purpose |
| --- | --- |
| npm run generate | Regenerate src/types.ts from ../../openapi.json |
| npm run build | Generate types, then compile to dist/ |
| npm run typecheck | Type-check without emitting |
Publishing (maintainers)
The package is published by the Publish SDK workflow on an sdk-v* tag. To cut a
release:
- From the repo root, regenerate and verify the contract:
npm run openapi:generate && npm run openapi:check. - Bump
versioninpackages/chapi-client/package.jsonand add aCHANGELOG.mdentry. - Commit, then tag:
git tag sdk-v<version> && git push origin sdk-v<version>.
The workflow rebuilds src/types.ts from the committed openapi.json and runs
npm publish --provenance, so the published types always match the tagged
contract. Deploy tags (v*) are separate from SDK tags (sdk-v*).
Example consumer
A minimal runnable example lives at examples/sdk-consumer/ in the CHAPI repo
(list lots + PATCH a filing against a local wrangler dev).
