@startsimpli/api
v0.5.30
Published
Type-safe Django REST API client for StartSimpli apps
Readme
@startsimpli/api
Type-safe HTTP client + per-domain wrappers for the Django backend (start-simpli-api). One package, used by every Next.js app in the monorepo: raise-simpli, market-simpli, trade-simpli, vault-web.
No Prisma, no DB drivers — this is the only sanctioned way for a Next.js app in this monorepo to talk to Django.
Install
This is a workspace package; consumers depend on it via pnpm workspaces and Next.js needs to transpile its TS source.
// app's package.json
{
"dependencies": {
"@startsimpli/api": "workspace:*"
}
}// app's next.config.ts — REQUIRED so Next compiles the package's src/
const config = {
transpilePackages: [
'@startsimpli/api',
// … other @startsimpli/* packages
],
};The package's main/types point at src/index.ts (no build step), so without transpilePackages Next will reject the TypeScript.
Quick start
// src/lib/api.ts (real shape used by trade-simpli and vault-web)
import { createStartSimpliApi } from '@startsimpli/api';
import { getRegisteredToken } from '@/infrastructure/auth';
export const api = createStartSimpliApi({
baseUrl: process.env.NEXT_PUBLIC_API_URL || '',
getToken: () => getRegisteredToken(),
onUnauthorized: () => {
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
},
});
export type Api = typeof api;createStartSimpliApi returns an object with one wrapper per domain plus the underlying client:
api.contacts // ContactsApi
api.organizations // OrganizationsApi
api.entities // EntitiesApi (low-level tags / metrics / profiles / attributes)
api.workflows // WorkflowsApi
api.messages // MessagesApi
api.messageTemplates// MessageTemplatesApi
api.funnels // FunnelsApi
api.featureFlags // FeatureFlagsApi
api.users // UsersApi
api.enrichment // EnrichmentApi (Apollo)
api.targetLists // TargetListsApi
api.markets // MarketsApi (trade-simpli)
api.vault // VaultApi (vault-web)
api.client // raw ApiClient — escape hatch for custom endpointsEvery config field is optional. baseUrl: '' makes calls relative so Next.js rewrites/proxy routes can intercept them.
Public surface
Grouped by purpose. See src/index.ts for the full export list.
| Area | Exports |
|---|---|
| Factory | createStartSimpliApi |
| Core HTTP | ApiClient, createApiClient, FetchWrapper |
| Domain wrappers | ContactsApi, OrganizationsApi, EntitiesApi, WorkflowsApi, MessagesApi, MessageTemplatesApi, FunnelsApi, FeatureFlagsApi, UsersApi, EnrichmentApi, TargetListsApi, MarketsApi, VaultApi |
| Generic entity adapter | createEntityAdapter |
| Error contract (HTTP layer) | ApiException, parseErrorResponse, handleFetchError, isApiException, isValidationError, isAuthError, isNotFoundError |
| Application error hierarchy (server-side domain) | AppError, AppErrorCode, ValidationError, AuthenticationError, AuthorizationError, NotFoundError, ConflictError, RateLimitError, DatabaseError, ExternalServiceError, isPrismaError, toAppError |
| Sanitization | sanitizeHtml, sanitizeSearchQuery, validateIdentifier, sanitizeUserInput, sanitizeChatMessage, MAX_PROMPT_LENGTH, MAX_CHAT_MESSAGE_LENGTH |
| CORS | getCorsHeaders, applyCorsHeaders, createCorsMiddleware |
| Rate limiting | createRateLimiter, getClientIP |
| Server cache | CacheStore, CacheManager, getCacheManager, resetCacheManager |
| Feature flags React glue | FeatureFlagProvider, useFeatureFlags |
| Server-side data hooks | useServerList, useServerDetail |
| Funnel guards | isFunnelRunConflict, isFunnelValidationError |
| Message stats helper | calculateStatsFromMessages |
| Env helpers | getRequiredEnv, getOptionalEnv, validateEnvVars |
| Constants | ENDPOINTS, COMPANY_SIZE_OPTIONS, LIFECYCLE_STAGE_OPTIONS, REVENUE_RANGE_OPTIONS, ACTIVITY_TYPE_OPTIONS, ACTIVITY_OUTCOME_OPTIONS, LOSS_REASON_OPTIONS, DEAL_STAGE_OPTIONS |
| Utilities | re-exported from ./utils (buildFilterParams, mergeQueryParams, case-transform helpers, etc.) |
| Types | re-exported from ./types (Contact, Organization, Entity, Tag, Metric, Profile, Attribute, PaginatedResponse, ApiError, all per-domain Create/Update/Filter shapes) |
Middleware (withAuth, withValidation, withErrorHandling) is not in the main entry — import it from @startsimpli/api/middleware to keep server-only Next deps out of client/test bundles.
Per-domain wrappers
All wrapper return values are camelCase. Filters/inputs are camelCase too — the fetch layer snake-cases them before they hit Django (see "snake↔camel" below).
ContactsApi — /api/v1/contacts/
Generic CRUD over the Contact entity, with assertion-based writes (writeTags, writeMetrics, writeProfiles).
// market-simpli/src/modules/prospects/api/client.ts
const response = await api.contacts.list(params);
const created = await api.contacts.create({ name, email, writeTags: ['tier_1'] });
await api.contacts.update(id, data);
await api.contacts.delete(id);Also: get, search, getByFirm.
OrganizationsApi — /api/v1/organizations/
Same shape as ContactsApi but for orgs/firms, with extra range helpers (getByCheckSizeRange, getByStage).
EntitiesApi — /api/v1/core/
Low-level access to the generic assertion store: Tags, EntityTags, Metrics, Profiles, Attributes, Relationships. Use when you need to query assertions directly instead of through a typed entity wrapper.
const tags = await api.entities.listTags();
const metrics = await api.entities.listMetrics(undefined, { entityId, type: 'financial' });FunnelsApi — /api/v1/funnels/
Lead/deal funnels: list/create/update/delete, plus run, preview, cancelRun, getRuns, getResults, listTemplates, getFields, and stage operations (addEntity, move, stats, pipeline).
// market-simpli/src/shared/lib/api/funnels.ts
const response = await api.funnels.list({ tags });
const run = await api.funnels.run(funnelId);
const runs = await api.funnels.getRuns(funnelId);Ships two type guards for known failure modes: isFunnelRunConflict(err) (409 on already-running) and isFunnelValidationError(err) (400 with field errors).
MessagesApi + MessageTemplatesApi — /api/v1/messages/, /api/v1/message-templates/
Outbound messaging campaigns: drafts, scheduling, sending, test sends, per-recipient tracking, channel discovery.
// market-simpli/src/app/(dashboard)/email/compose/page.tsx
const message = draftId
? await api.messages.update(draftId, payload)
: await api.messages.create(payload);
await api.messages.sendNow(message.id);
const [lists, channels] = await Promise.all([
api.targetLists.list({ pageSize: 100 }),
api.messages.getChannels(),
]);calculateStatsFromMessages(messages) computes aggregate open/click/reply/bounce rates client-side from a list.
TargetListsApi — /api/v1/targets/lists/
Reusable contact/organization lists. CRUD plus member ops (getMembers, addMembers, removeMembers, refresh). Supports static, funnel-backed, and query-backed lists.
const members = await api.targetLists.getMembers(id, { pageSize: 500 });EnrichmentApi — /api/v1/enrichment/, /api/v1/contacts/<id>/enrich/
Apollo enrichment. The backend caps enrich-apollo at 100 ids/request; enrichApollo(ids, { onProgress }) automatically chunks the input, calls the endpoint sequentially, and returns a single aggregated ApolloEnrichmentSummary. Partial failures are recorded per id without aborting the run.
UsersApi — /api/v1/users/
Three calls: getProfile, updateProfile, changePassword. Used by every app's account settings.
// market-simpli/src/app/(dashboard)/settings/account/page.tsx
const profile = await api.users.getProfile();
await api.users.updateProfile({ firstName, lastName });
await api.users.changePassword({ oldPassword, newPassword });WorkflowsApi — /api/v1/workflows/
Workflow definitions + execution history. CRUD on Workflow, plus WorkflowExecution reads with the standard status / mode filters.
FeatureFlagsApi + <FeatureFlagProvider> + useFeatureFlags()
Server-side flags loaded once at app boot, fed into React via context. Apps mount FeatureFlagProvider near the root and read flags with useFeatureFlags().
MarketsApi — /api/v1/markets/*
Backs trade-simpli. Instruments, OHLCV bars, analytics (returns/volatility/beta/pair_spread), per-instrument news, options chains + IV/Greeks/unusual-activity/ATM IV history, earnings calendar, macro calendar, sector breadth, VIX term structure, social posts, trading snapshots, and an ops health rollup.
// trade-simpli/src/app/(dashboard)/options/[symbol]/page.tsx
api.markets.getOptionsChain({ symbol, expiry })
api.markets.getOptionsIvHistory({ symbol })
api.markets.getOptionsUnusualActivity({ symbols: [symbol], lookback: 20 })
// trade-simpli/src/app/(dashboard)/pairs/[name]/page.tsx
api.markets.getTradingSnapshots({ limit: 90 })
api.markets.getAnalytics(pair.bull, { metric: 'pair_spread', vs: pair.bear, windowDays: 90 })
api.markets.getNews(pair.bull, { limit: 10, minConfidence: 0.3 })VaultApi — /api/v1/vault/*
Backs vault-web. Environments, secrets (value is write-only on create/update; readback requires the separate audited revealSecret), access keys, and an audit log per environment.
// vault-web/src/app/(dashboard)/environments/page.tsx — via hooks that wrap api.vault
const { data } = useEnvironments(api.vault);
const createEnv = useCreateEnvironment(api.vault);
// raw equivalents:
await api.vault.listEnvironments();
await api.vault.createEnvironment({ slug, name });
await api.vault.revealSecret(envSlug, secretId);Error contract
All HTTP failures throw ApiException. The backend (apps.core.exceptions) now emits a standardized error response with a machine code and structured details, and parseErrorResponse lifts those onto the thrown exception:
class ApiException extends Error {
status?: number; // HTTP status
statusText?: string;
detail?: string; // DRF { detail: ... } string when present
errors?: Record<string, string[]>; // DRF field-level validation errors
code?: string; // NEW: machine code, e.g. 'limit_reached', 'no_company'
details?: Record<string, unknown>; // NEW: extra context — feature_key, limit, current, …
}parseErrorResponse recognises three response shapes and normalises them onto the same exception:
- Standardized (preferred) —
{ error, code, statusCode, fieldErrors?, …extras }. The humanerrorbecomes.message,codeis preserved on.code, and every non-reserved key is stapled onto.details. Reserved keys (error,code,statusCode/status_code,fieldErrors/field_errors,timestamp,requestId/request_id,detail) are excluded from.details. - DRF detail —
{ detail: '...' }→.detail+.message. - DRF field errors —
{ field: ['msg', …] }→.errors.
Discriminating on code (the key new contract)
// vault-web/src/app/(dashboard)/environments/page.tsx
import { ApiException } from '@startsimpli/api';
function limitErrorMessage(err: unknown): string | null {
if (err instanceof ApiException && err.code === 'limit_reached') {
// err.details carries { featureKey, limit, current } from the backend
return err.message || "You've reached the Free plan limit. Upgrade for unlimited environments.";
}
return null;
}Known codes (server emits these via apps.core.exceptions):
limit_reached— billing/quota gate;detailscarriesfeatureKey,limit,current. Vault-web renders the upgrade nudge from this.no_company— user has no active company context selected.- Plus any code the backend defines on
apps.core.exceptionssubclasses.
Status-based guards (still supported)
import {
isApiException,
isValidationError, // status === 400 && has .errors
isAuthError, // status === 401 || 403
isNotFoundError, // status === 404
} from '@startsimpli/api';
try {
await api.contacts.get(id);
} catch (err) {
if (isValidationError(err)) console.log(err.errors);
else if (isAuthError(err)) /* redirect via onUnauthorized */;
else if (isNotFoundError(err)) /* show empty state */;
else if (isApiException(err)) console.error(err.code, err.message);
else throw err;
}Server-side domain error hierarchy
Separate from ApiException, the package also exports a Node-side error hierarchy (AppError, ValidationError, AuthenticationError, …, plus toAppError and isPrismaError) for Next.js route handlers / server actions that want to throw typed errors and have middleware (withErrorHandling) format them. Use these in server code; use ApiException everywhere a fetch can fail.
snake↔camel transform
Django speaks snake_case; this package speaks camelCase. The transform is on by default:
- Responses:
snakeToCamel(body)is applied to the parsed JSON before it's returned. - Request bodies:
camelToSnake(body)is applied beforeJSON.stringify. - Query params: built by
buildFilterParams/mergeQueryParams— pass camelCase; they're emitted as snake_case.
Opt out per-client when you need raw keys (e.g. an endpoint that already returns camelCase, or one that requires exact-key passthrough):
import { createStartSimpliApi } from '@startsimpli/api';
const api = createStartSimpliApi({
baseUrl: process.env.NEXT_PUBLIC_API_URL,
getToken,
transformKeys: false, // disable both directions for this client
});Note: a few Django endpoints (notably some billing endpoints under /billing/* proxied through Next route handlers) emit raw snake_case and bypass the DRF camelCase middleware. Apps that proxy those typically pass fromSnake: true in their own proxy helpers — that's an app-level concern, not something transformKeys toggles.
Next.js API route middleware
Imported from a separate entry to avoid pulling server deps into the main bundle:
import { withAuth, withValidation, withErrorHandling } from '@startsimpli/api/middleware';
import { z } from 'zod';
const schema = z.object({ name: z.string() });
export const POST = withErrorHandling(
withAuth(
withValidation(
async (request, { body }, authContext) => {
return NextResponse.json({ ok: true });
},
{ body: schema },
),
),
);withAuth injects { userId, token, isAuthenticated }. withErrorHandling catches thrown AppError / ApiException and formats them into a standard JSON response.
Direct client escape hatch
For endpoints without a wrapper:
import { createApiClient } from '@startsimpli/api';
const client = createApiClient({ baseUrl, getToken });
const data = await client.fetch.get('/custom-endpoint/', { params: { key: 'value' } });
const created = await client.fetch.post('/custom-endpoint/', { name: 'test' });client.fetch is the FetchWrapper; it carries the auth token, runs the key transform, and throws ApiException on failure exactly like the typed wrappers.
Verification
pnpm --filter @startsimpli/api test # vitest, 12 files, 133 tests
pnpm --filter @startsimpli/api type-check # tsc --noEmitTest coverage at the time of writing: 12 test files across src/__tests__/ and src/lib/__tests__/, totalling 133 passing tests. Headline suites: DRF camel/snake transforms, URL building, query-param shaping, JWT refresh, paginated fetchAllPages, no-token-on-unsafe-method guard, response schema validation, entity adapter, entity query builder, funnels API contracts, enrichment chunking, and the Vault API surface.
Shared-first policy
If you find yourself writing an HTTP wrapper, an api-client helper, an error-shape util, or a query-param builder inside an app's src/, stop — extend this package instead. App code should call api.<wrapper>.<method>(), not build its own fetcher. See the monorepo CLAUDE.md rule 9.
Architecture notes
- Base URL: configurable via
baseUrl;''(default) uses relative paths so Next.js rewrites/proxies can intercept. - Auth:
Authorization: Bearer <token>fromgetToken(); the wrapper handles refresh viaonTokenRefreshand one-shot dedup ofonUnauthorizedcallbacks (5s window). - Pagination: DRF format —
page,pageSize(sent aspage_size). - Filtering: Django-filter conventions —
field__gte,field__in, etc. — written camelCase from callers. - Entity assertion model: Django stores generic
Tag/Metric/Profile/Attributeassertions against entities; writes usewriteTags/writeMetrics/writeProfiles, reads include both canonical fields and the raw assertion arrays.
License
MIT
