@com-fin/api-sdk
v0.1.8
Published
Typed API client, auth/error/retry layer, and TanStack Query hook factories for the Community Finance Platform, shared by com-fin-platform, com-fin-admin, and com-fin-member-app.
Readme
@com-fin/api-sdk
Shared, framework-agnostic TypeScript API client for the Community Finance
Platform — one package consumed by com-fin-platform, com-fin-admin, and
com-fin-member-app so all three apps get identical request/response types,
auth handling, retry behaviour, error shapes, and query caching instead of
each reimplementing it.
This package ships zero UI code and zero framework-specific code — no React components, nothing web-only or React-Native-only. It works from a Next.js app and an Expo app alike.
Layout
src/
├── generated/ orval output — typed request functions + response types.
│ NEVER hand-edit; see "Regenerating" below.
├── client/ hand-written layer on top of generated code:
│ http-client.ts auth, retry/backoff, error normalization
│ token-storage.interface.ts storage contract consumer apps implement
│ error-types.ts ApiError hierarchy all three apps catch
├── hooks/ TanStack Query hook factories, one folder per domain
│ (organisations, memberships, savings, rotation, meetings, payments)
└── schemas/ zod schemas mirroring the API's request DTOs, for client-side
form validation that matches server-side validation errorsInstall (local development)
The three consumer apps are siblings of this repo on disk. Until this package is published, add it as a local path/workspace dependency:
// com-fin-platform/package.json (or com-fin-admin, com-fin-member-app)
{
"dependencies": {
"@com-fin/api-sdk": "file:../com-fin-api-sdk"
}
}npm workspaces / pnpm workspaces both resolve file: deps the same way — no
extra config needed on the consumer side. After npm install, changes made
here require the consumer app to reinstall (npm install) or, for a tighter
inner loop, run this package's npm run dev (tsup --watch) alongside the
consumer app's dev server, since file: deps are copied, not symlinked, by
plain npm. If the team wants live symlinking during development, switch the
three consumer repos + this one into an npm/pnpm workspace at a shared root
— out of scope for this package alone to impose.
Publishing later (GitHub Packages)
No reason to version this independently yet — all four repos move together. Once that changes (e.g. mobile ships on a slower cadence than web), publish to GitHub Packages:
publishConfig.registryis already set tohttps://npm.pkg.github.cominpackage.json.- Flip
"private": truetofalse. - Each consumer repo needs an
.npmrcscoping the@com-finnamespace to GitHub Packages:@com-fin:registry=https://npm.pkg.github.com //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} npm version <major|minor|patch> && npm publishfrom CI on tag push (add apublish.ymlworkflow alongside.github/workflows/ci.ymlwhen this day comes — CI here intentionally does not publish yet).- Consumer
package.jsons switch fromfile:../com-fin-api-sdkto a real semver range, e.g."@com-fin/api-sdk": "^1.0.0".
Configuring the client
Call this once, at app startup, before any hook runs:
import { configureApiClient } from '@com-fin/api-sdk';
import { myAppTokenStorage } from './token-storage'; // your TokenStorage implementation
configureApiClient({
baseUrl: process.env.EXPO_PUBLIC_API_URL ?? 'https://api.communityfinance.rw/api/v1',
tokenStorage: myAppTokenStorage,
onSessionExpired: () => router.replace('/login'),
});Implementing TokenStorage
This package never assumes a storage mechanism — see src/client/token-storage.interface.ts. Each app supplies its own:
- Web (
com-fin-platform,com-fin-admin): an httpOnly-cookie-backed implementation if the API sets cookies, or alocalStorage-backed one otherwise. com-fin-member-app(Expo): anexpo-secure-store-backed implementation (already an installed dependency there).
// example: expo-secure-store adapter
import * as SecureStore from 'expo-secure-store';
import type { TokenStorage, TokenPair } from '@com-fin/api-sdk';
export const secureStoreTokenStorage: TokenStorage = {
async getTokens() {
const raw = await SecureStore.getItemAsync('com-fin-tokens');
return raw ? (JSON.parse(raw) as TokenPair) : null;
},
async setTokens(tokens) {
await SecureStore.setItemAsync('com-fin-tokens', JSON.stringify(tokens));
},
async clearTokens() {
await SecureStore.deleteItemAsync('com-fin-tokens');
},
};Errors
Every non-2xx response is normalized to an ApiError subclass (see
src/client/error-types.ts) — ValidationError,
AuthenticationError, AuthorizationError (with moduleKey set when a
@RequiresModule gate rejected the request), NotFoundError,
ConflictError, NetworkError. All three apps can render off the same
error.code / error.message regardless of which one triggered it.
Using the hooks
import { useOrganisations, useRecordContribution } from '@com-fin/api-sdk';
const { data, isLoading } = useOrganisations();
const recordContribution = useRecordContribution(orgId);
recordContribution.mutate({ membershipId, amount: 5000, method: 'cash' });Query keys are centralised per domain (e.g. organisationKeys in
src/hooks/organisations) so mutations invalidate the right queries
automatically — consumer apps don't hand-roll cache invalidation.
Regenerating the client
npm run generate # regenerate src/generated from openapi.json
INPUT=http://localhost:3000/api/v1-json npm run generate # ...or from a running com-fin-api instanceopenapi.json is a committed snapshot. Until com-fin-api exposes a live
spec endpoint, it's the source of truth and must be updated by hand to stay
in sync with the real API as endpoints land; once com-fin-api ships
@nestjs/swagger, replace this manual step with a spec:pull script that
curls its /api/v1-json endpoint and overwrites openapi.json, then run
npm run generate.
Never hand-edit anything under src/generated. npm run generate:check
(also run in CI, see .github/workflows/ci.yml) regenerates into the
working tree and fails the build if that produces a git diff — i.e. if
openapi.json and the committed src/generated have drifted apart.
Why refresh bypasses codegen
src/client/http-client.ts calls POST /auth/refresh directly with a raw
fetch, not through a generated function. Generated request functions all
call through apiFetch (the orval mutator) for auth + retry — routing the
refresh call through the same mutator would be self-referential (refreshing
a token in order to refresh a token). It's the one intentional exception to
"all requests go through src/generated."
