@startsimpli/api 0.5.19 → 0.5.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +287 -240
- package/package.json +11 -2
- package/src/constants/endpoints.ts +9 -2
- package/src/index.ts +37 -0
- package/src/lib/error-handler.ts +40 -5
- package/src/lib/errors.ts +7 -2
- package/src/lib/markets-api.ts +237 -7
- package/src/lib/sanitize-core.ts +32 -0
- package/src/lib/sanitize.native.ts +17 -0
- package/src/lib/sanitize.ts +6 -26
- package/src/lib/vault-api.test.ts +62 -0
- package/src/lib/vault-api.ts +155 -0
- package/src/types/api.ts +6 -0
package/README.md
CHANGED
|
@@ -1,219 +1,322 @@
|
|
|
1
1
|
# @startsimpli/api
|
|
2
2
|
|
|
3
|
-
Type-safe
|
|
3
|
+
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`.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
No Prisma, no DB drivers — this is the only sanctioned way for a Next.js app in this monorepo to talk to Django.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Install
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
This is a workspace package; consumers depend on it via pnpm workspaces and Next.js needs to transpile its TS source.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
```jsonc
|
|
12
|
+
// app's package.json
|
|
13
|
+
{
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@startsimpli/api": "workspace:*"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
12
19
|
|
|
13
|
-
```
|
|
14
|
-
|
|
20
|
+
```ts
|
|
21
|
+
// app's next.config.ts — REQUIRED so Next compiles the package's src/
|
|
22
|
+
const config = {
|
|
23
|
+
transpilePackages: [
|
|
24
|
+
'@startsimpli/api',
|
|
25
|
+
// … other @startsimpli/* packages
|
|
26
|
+
],
|
|
27
|
+
};
|
|
15
28
|
```
|
|
16
29
|
|
|
17
|
-
|
|
30
|
+
The package's `main`/`types` point at `src/index.ts` (no build step), so without `transpilePackages` Next will reject the TypeScript.
|
|
18
31
|
|
|
19
|
-
|
|
32
|
+
## Quick start
|
|
20
33
|
|
|
21
|
-
```
|
|
34
|
+
```ts
|
|
35
|
+
// src/lib/api.ts (real shape used by trade-simpli and vault-web)
|
|
22
36
|
import { createStartSimpliApi } from '@startsimpli/api';
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
37
|
+
import { getRegisteredToken } from '@/infrastructure/auth';
|
|
38
|
+
|
|
39
|
+
export const api = createStartSimpliApi({
|
|
40
|
+
baseUrl: process.env.NEXT_PUBLIC_API_URL || '',
|
|
41
|
+
getToken: () => getRegisteredToken(),
|
|
42
|
+
onUnauthorized: () => {
|
|
43
|
+
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
|
|
44
|
+
window.location.href = '/login';
|
|
45
|
+
}
|
|
30
46
|
},
|
|
31
47
|
});
|
|
32
48
|
|
|
33
|
-
|
|
34
|
-
const contacts = await api.contacts.list({ tier: 1 });
|
|
35
|
-
const orgs = await api.organizations.getByStage('seed');
|
|
49
|
+
export type Api = typeof api;
|
|
36
50
|
```
|
|
37
51
|
|
|
38
|
-
|
|
52
|
+
`createStartSimpliApi` returns an object with one wrapper per domain plus the underlying `client`:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
api.contacts // ContactsApi
|
|
56
|
+
api.organizations // OrganizationsApi
|
|
57
|
+
api.entities // EntitiesApi (low-level tags / metrics / profiles / attributes)
|
|
58
|
+
api.workflows // WorkflowsApi
|
|
59
|
+
api.messages // MessagesApi
|
|
60
|
+
api.messageTemplates// MessageTemplatesApi
|
|
61
|
+
api.funnels // FunnelsApi
|
|
62
|
+
api.featureFlags // FeatureFlagsApi
|
|
63
|
+
api.users // UsersApi
|
|
64
|
+
api.enrichment // EnrichmentApi (Apollo)
|
|
65
|
+
api.targetLists // TargetListsApi
|
|
66
|
+
api.markets // MarketsApi (trade-simpli)
|
|
67
|
+
api.vault // VaultApi (vault-web)
|
|
68
|
+
api.client // raw ApiClient — escape hatch for custom endpoints
|
|
69
|
+
```
|
|
39
70
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
71
|
+
Every config field is optional. `baseUrl: ''` makes calls relative so Next.js rewrites/proxy routes can intercept them.
|
|
72
|
+
|
|
73
|
+
## Public surface
|
|
74
|
+
|
|
75
|
+
Grouped by purpose. See `src/index.ts` for the full export list.
|
|
76
|
+
|
|
77
|
+
| Area | Exports |
|
|
78
|
+
|---|---|
|
|
79
|
+
| Factory | `createStartSimpliApi` |
|
|
80
|
+
| Core HTTP | `ApiClient`, `createApiClient`, `FetchWrapper` |
|
|
81
|
+
| Domain wrappers | `ContactsApi`, `OrganizationsApi`, `EntitiesApi`, `WorkflowsApi`, `MessagesApi`, `MessageTemplatesApi`, `FunnelsApi`, `FeatureFlagsApi`, `UsersApi`, `EnrichmentApi`, `TargetListsApi`, `MarketsApi`, `VaultApi` |
|
|
82
|
+
| Generic entity adapter | `createEntityAdapter` |
|
|
83
|
+
| Error contract (HTTP layer) | `ApiException`, `parseErrorResponse`, `handleFetchError`, `isApiException`, `isValidationError`, `isAuthError`, `isNotFoundError` |
|
|
84
|
+
| Application error hierarchy (server-side domain) | `AppError`, `AppErrorCode`, `ValidationError`, `AuthenticationError`, `AuthorizationError`, `NotFoundError`, `ConflictError`, `RateLimitError`, `DatabaseError`, `ExternalServiceError`, `isPrismaError`, `toAppError` |
|
|
85
|
+
| Sanitization | `sanitizeHtml`, `sanitizeSearchQuery`, `validateIdentifier`, `sanitizeUserInput`, `sanitizeChatMessage`, `MAX_PROMPT_LENGTH`, `MAX_CHAT_MESSAGE_LENGTH` |
|
|
86
|
+
| CORS | `getCorsHeaders`, `applyCorsHeaders`, `createCorsMiddleware` |
|
|
87
|
+
| Rate limiting | `createRateLimiter`, `getClientIP` |
|
|
88
|
+
| Server cache | `CacheStore`, `CacheManager`, `getCacheManager`, `resetCacheManager` |
|
|
89
|
+
| Feature flags React glue | `FeatureFlagProvider`, `useFeatureFlags` |
|
|
90
|
+
| Server-side data hooks | `useServerList`, `useServerDetail` |
|
|
91
|
+
| Funnel guards | `isFunnelRunConflict`, `isFunnelValidationError` |
|
|
92
|
+
| Message stats helper | `calculateStatsFromMessages` |
|
|
93
|
+
| Env helpers | `getRequiredEnv`, `getOptionalEnv`, `validateEnvVars` |
|
|
94
|
+
| Constants | `ENDPOINTS`, `COMPANY_SIZE_OPTIONS`, `LIFECYCLE_STAGE_OPTIONS`, `REVENUE_RANGE_OPTIONS`, `ACTIVITY_TYPE_OPTIONS`, `ACTIVITY_OUTCOME_OPTIONS`, `LOSS_REASON_OPTIONS`, `DEAL_STAGE_OPTIONS` |
|
|
95
|
+
| Utilities | re-exported from `./utils` (`buildFilterParams`, `mergeQueryParams`, case-transform helpers, etc.) |
|
|
96
|
+
| Types | re-exported from `./types` (Contact, Organization, Entity, Tag, Metric, Profile, Attribute, PaginatedResponse, ApiError, all per-domain Create/Update/Filter shapes) |
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
## Per-domain wrappers
|
|
101
|
+
|
|
102
|
+
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).
|
|
103
|
+
|
|
104
|
+
### `ContactsApi` — `/api/v1/contacts/`
|
|
105
|
+
|
|
106
|
+
Generic CRUD over the `Contact` entity, with assertion-based writes (`writeTags`, `writeMetrics`, `writeProfiles`).
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// market-simpli/src/modules/prospects/api/client.ts
|
|
110
|
+
const response = await api.contacts.list(params);
|
|
111
|
+
const created = await api.contacts.create({ name, email, writeTags: ['tier_1'] });
|
|
112
|
+
await api.contacts.update(id, data);
|
|
113
|
+
await api.contacts.delete(id);
|
|
114
|
+
```
|
|
47
115
|
|
|
48
|
-
|
|
49
|
-
const contact = await api.contacts.get('contact-uuid');
|
|
50
|
-
|
|
51
|
-
// Create contact with assertions
|
|
52
|
-
const newContact = await api.contacts.create({
|
|
53
|
-
name: 'John Doe',
|
|
54
|
-
email: 'john@example.com',
|
|
55
|
-
title: 'Partner',
|
|
56
|
-
write_tags: ['tier_1', 'focus:fintech'],
|
|
57
|
-
write_metrics: { enrichment_score: 0.95 },
|
|
58
|
-
write_profiles: { linkedin: 'https://linkedin.com/in/johndoe' },
|
|
59
|
-
});
|
|
116
|
+
Also: `get`, `search`, `getByFirm`.
|
|
60
117
|
|
|
61
|
-
|
|
62
|
-
const updated = await api.contacts.update('contact-uuid', {
|
|
63
|
-
title: 'Managing Partner',
|
|
64
|
-
write_tags: ['tier_1', 'vip'],
|
|
65
|
-
});
|
|
118
|
+
### `OrganizationsApi` — `/api/v1/organizations/`
|
|
66
119
|
|
|
67
|
-
|
|
68
|
-
const results = await api.contacts.search('john');
|
|
120
|
+
Same shape as `ContactsApi` but for orgs/firms, with extra range helpers (`getByCheckSizeRange`, `getByStage`).
|
|
69
121
|
|
|
70
|
-
|
|
71
|
-
|
|
122
|
+
### `EntitiesApi` — `/api/v1/core/`
|
|
123
|
+
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const tags = await api.entities.listTags();
|
|
128
|
+
const metrics = await api.entities.listMetrics(undefined, { entityId, type: 'financial' });
|
|
72
129
|
```
|
|
73
130
|
|
|
74
|
-
###
|
|
131
|
+
### `FunnelsApi` — `/api/v1/funnels/`
|
|
75
132
|
|
|
76
|
-
|
|
77
|
-
// List organizations with filters
|
|
78
|
-
const orgs = await api.organizations.list(
|
|
79
|
-
{
|
|
80
|
-
tier: 1,
|
|
81
|
-
stage: 'seed',
|
|
82
|
-
check_size_min_gte: 100000,
|
|
83
|
-
check_size_max_lte: 1000000,
|
|
84
|
-
},
|
|
85
|
-
{ page: 1, page_size: 20 },
|
|
86
|
-
{ ordering: '-aum' }
|
|
87
|
-
);
|
|
133
|
+
Lead/deal funnels: list/create/update/delete, plus `run`, `preview`, `cancelRun`, `getRuns`, `getResults`, `listTemplates`, `getFields`, and stage operations (`addEntity`, `move`, `stats`, `pipeline`).
|
|
88
134
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
check_size_min: 500000,
|
|
100
|
-
check_size_max: 2000000,
|
|
101
|
-
aum: 100000000,
|
|
102
|
-
},
|
|
103
|
-
write_profiles: {
|
|
104
|
-
linkedin: 'https://linkedin.com/company/acme-ventures',
|
|
105
|
-
website: 'https://acmevc.com',
|
|
106
|
-
},
|
|
107
|
-
});
|
|
135
|
+
```ts
|
|
136
|
+
// market-simpli/src/shared/lib/api/funnels.ts
|
|
137
|
+
const response = await api.funnels.list({ tags });
|
|
138
|
+
const run = await api.funnels.run(funnelId);
|
|
139
|
+
const runs = await api.funnels.getRuns(funnelId);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Ships two type guards for known failure modes: `isFunnelRunConflict(err)` (409 on already-running) and `isFunnelValidationError(err)` (400 with field errors).
|
|
143
|
+
|
|
144
|
+
### `MessagesApi` + `MessageTemplatesApi` — `/api/v1/messages/`, `/api/v1/message-templates/`
|
|
108
145
|
|
|
109
|
-
|
|
110
|
-
|
|
146
|
+
Outbound messaging campaigns: drafts, scheduling, sending, test sends, per-recipient tracking, channel discovery.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
// market-simpli/src/app/(dashboard)/email/compose/page.tsx
|
|
150
|
+
const message = draftId
|
|
151
|
+
? await api.messages.update(draftId, payload)
|
|
152
|
+
: await api.messages.create(payload);
|
|
153
|
+
|
|
154
|
+
await api.messages.sendNow(message.id);
|
|
155
|
+
|
|
156
|
+
const [lists, channels] = await Promise.all([
|
|
157
|
+
api.targetLists.list({ pageSize: 100 }),
|
|
158
|
+
api.messages.getChannels(),
|
|
159
|
+
]);
|
|
111
160
|
```
|
|
112
161
|
|
|
113
|
-
|
|
162
|
+
`calculateStatsFromMessages(messages)` computes aggregate open/click/reply/bounce rates client-side from a list.
|
|
114
163
|
|
|
115
|
-
|
|
116
|
-
// Tags
|
|
117
|
-
const tags = await api.entities.listTags();
|
|
164
|
+
### `TargetListsApi` — `/api/v1/targets/lists/`
|
|
118
165
|
|
|
119
|
-
|
|
120
|
-
const entityTags = await api.entities.listEntityTags(
|
|
121
|
-
{ page: 1, page_size: 50 },
|
|
122
|
-
{ entity_id: 'some-uuid', category: 'focus' }
|
|
123
|
-
);
|
|
166
|
+
Reusable contact/organization lists. CRUD plus member ops (`getMembers`, `addMembers`, `removeMembers`, `refresh`). Supports static, funnel-backed, and query-backed lists.
|
|
124
167
|
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
{ entity_id: 'some-uuid', type: 'financial' }
|
|
129
|
-
);
|
|
168
|
+
```ts
|
|
169
|
+
const members = await api.targetLists.getMembers(id, { pageSize: 500 });
|
|
170
|
+
```
|
|
130
171
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
172
|
+
### `EnrichmentApi` — `/api/v1/enrichment/`, `/api/v1/contacts/<id>/enrich/`
|
|
173
|
+
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
### `UsersApi` — `/api/v1/users/`
|
|
177
|
+
|
|
178
|
+
Three calls: `getProfile`, `updateProfile`, `changePassword`. Used by every app's account settings.
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// market-simpli/src/app/(dashboard)/settings/account/page.tsx
|
|
182
|
+
const profile = await api.users.getProfile();
|
|
183
|
+
await api.users.updateProfile({ firstName, lastName });
|
|
184
|
+
await api.users.changePassword({ oldPassword, newPassword });
|
|
136
185
|
```
|
|
137
186
|
|
|
138
|
-
###
|
|
187
|
+
### `WorkflowsApi` — `/api/v1/workflows/`
|
|
139
188
|
|
|
140
|
-
|
|
189
|
+
Workflow definitions + execution history. CRUD on `Workflow`, plus `WorkflowExecution` reads with the standard `status / mode` filters.
|
|
141
190
|
|
|
142
|
-
|
|
143
|
-
import { createApiClient } from '@startsimpli/api';
|
|
191
|
+
### `FeatureFlagsApi` + `<FeatureFlagProvider>` + `useFeatureFlags()`
|
|
144
192
|
|
|
145
|
-
|
|
146
|
-
baseUrl: 'http://localhost:8000/api/v1',
|
|
147
|
-
getToken: async () => getAccessToken(),
|
|
148
|
-
});
|
|
193
|
+
Server-side flags loaded once at app boot, fed into React via context. Apps mount `FeatureFlagProvider` near the root and read flags with `useFeatureFlags()`.
|
|
149
194
|
|
|
150
|
-
|
|
151
|
-
const data = await client.fetch.get('/custom-endpoint/', {
|
|
152
|
-
params: { key: 'value' }
|
|
153
|
-
});
|
|
195
|
+
### `MarketsApi` — `/api/v1/markets/*`
|
|
154
196
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
197
|
+
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.
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
// trade-simpli/src/app/(dashboard)/options/[symbol]/page.tsx
|
|
201
|
+
api.markets.getOptionsChain({ symbol, expiry })
|
|
202
|
+
api.markets.getOptionsIvHistory({ symbol })
|
|
203
|
+
api.markets.getOptionsUnusualActivity({ symbols: [symbol], lookback: 20 })
|
|
204
|
+
|
|
205
|
+
// trade-simpli/src/app/(dashboard)/pairs/[name]/page.tsx
|
|
206
|
+
api.markets.getTradingSnapshots({ limit: 90 })
|
|
207
|
+
api.markets.getAnalytics(pair.bull, { metric: 'pair_spread', vs: pair.bear, windowDays: 90 })
|
|
208
|
+
api.markets.getNews(pair.bull, { limit: 10, minConfidence: 0.3 })
|
|
158
209
|
```
|
|
159
210
|
|
|
160
|
-
|
|
211
|
+
### `VaultApi` — `/api/v1/vault/*`
|
|
161
212
|
|
|
162
|
-
|
|
213
|
+
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.
|
|
163
214
|
|
|
164
|
-
```
|
|
165
|
-
|
|
215
|
+
```ts
|
|
216
|
+
// vault-web/src/app/(dashboard)/environments/page.tsx — via hooks that wrap api.vault
|
|
217
|
+
const { data } = useEnvironments(api.vault);
|
|
218
|
+
const createEnv = useCreateEnvironment(api.vault);
|
|
166
219
|
|
|
167
|
-
|
|
168
|
-
|
|
220
|
+
// raw equivalents:
|
|
221
|
+
await api.vault.listEnvironments();
|
|
222
|
+
await api.vault.createEnvironment({ slug, name });
|
|
223
|
+
await api.vault.revealSecret(envSlug, secretId);
|
|
224
|
+
```
|
|
169
225
|
|
|
170
|
-
|
|
171
|
-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
172
|
-
}
|
|
226
|
+
## Error contract
|
|
173
227
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
228
|
+
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:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
class ApiException extends Error {
|
|
232
|
+
status?: number; // HTTP status
|
|
233
|
+
statusText?: string;
|
|
234
|
+
detail?: string; // DRF { detail: ... } string when present
|
|
235
|
+
errors?: Record<string, string[]>; // DRF field-level validation errors
|
|
236
|
+
code?: string; // NEW: machine code, e.g. 'limit_reached', 'no_company'
|
|
237
|
+
details?: Record<string, unknown>; // NEW: extra context — feature_key, limit, current, …
|
|
238
|
+
}
|
|
177
239
|
```
|
|
178
240
|
|
|
179
|
-
|
|
241
|
+
`parseErrorResponse` recognises three response shapes and normalises them onto the same exception:
|
|
180
242
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
243
|
+
1. **Standardized (preferred)** — `{ error, code, statusCode, fieldErrors?, …extras }`. The human `error` becomes `.message`, `code` is 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`.
|
|
244
|
+
2. **DRF detail** — `{ detail: '...' }` → `.detail` + `.message`.
|
|
245
|
+
3. **DRF field errors** — `{ field: ['msg', …] }` → `.errors`.
|
|
184
246
|
|
|
185
|
-
|
|
186
|
-
name: z.string().min(1),
|
|
187
|
-
email: z.string().email().optional(),
|
|
188
|
-
tier: z.number().int().min(1).max(3).optional(),
|
|
189
|
-
});
|
|
247
|
+
### Discriminating on `code` (the key new contract)
|
|
190
248
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
249
|
+
```ts
|
|
250
|
+
// vault-web/src/app/(dashboard)/environments/page.tsx
|
|
251
|
+
import { ApiException } from '@startsimpli/api';
|
|
252
|
+
|
|
253
|
+
function limitErrorMessage(err: unknown): string | null {
|
|
254
|
+
if (err instanceof ApiException && err.code === 'limit_reached') {
|
|
255
|
+
// err.details carries { featureKey, limit, current } from the backend
|
|
256
|
+
return err.message || "You've reached the Free plan limit. Upgrade for unlimited environments.";
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
199
260
|
```
|
|
200
261
|
|
|
201
|
-
|
|
262
|
+
Known codes (server emits these via `apps.core.exceptions`):
|
|
263
|
+
- `limit_reached` — billing/quota gate; `details` carries `featureKey`, `limit`, `current`. Vault-web renders the upgrade nudge from this.
|
|
264
|
+
- `no_company` — user has no active company context selected.
|
|
265
|
+
- Plus any code the backend defines on `apps.core.exceptions` subclasses.
|
|
266
|
+
|
|
267
|
+
### Status-based guards (still supported)
|
|
202
268
|
|
|
203
|
-
```
|
|
204
|
-
import {
|
|
269
|
+
```ts
|
|
270
|
+
import {
|
|
271
|
+
isApiException,
|
|
272
|
+
isValidationError, // status === 400 && has .errors
|
|
273
|
+
isAuthError, // status === 401 || 403
|
|
274
|
+
isNotFoundError, // status === 404
|
|
275
|
+
} from '@startsimpli/api';
|
|
205
276
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
277
|
+
try {
|
|
278
|
+
await api.contacts.get(id);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (isValidationError(err)) console.log(err.errors);
|
|
281
|
+
else if (isAuthError(err)) /* redirect via onUnauthorized */;
|
|
282
|
+
else if (isNotFoundError(err)) /* show empty state */;
|
|
283
|
+
else if (isApiException(err)) console.error(err.code, err.message);
|
|
284
|
+
else throw err;
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Server-side domain error hierarchy
|
|
289
|
+
|
|
290
|
+
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.
|
|
291
|
+
|
|
292
|
+
## snake↔camel transform
|
|
293
|
+
|
|
294
|
+
Django speaks `snake_case`; this package speaks `camelCase`. The transform is on by default:
|
|
295
|
+
|
|
296
|
+
- **Responses**: `snakeToCamel(body)` is applied to the parsed JSON before it's returned.
|
|
297
|
+
- **Request bodies**: `camelToSnake(body)` is applied before `JSON.stringify`.
|
|
298
|
+
- **Query params**: built by `buildFilterParams` / `mergeQueryParams` — pass camelCase; they're emitted as snake_case.
|
|
299
|
+
|
|
300
|
+
Opt out per-client when you need raw keys (e.g. an endpoint that already returns camelCase, or one that requires exact-key passthrough):
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { createStartSimpliApi } from '@startsimpli/api';
|
|
304
|
+
|
|
305
|
+
const api = createStartSimpliApi({
|
|
306
|
+
baseUrl: process.env.NEXT_PUBLIC_API_URL,
|
|
307
|
+
getToken,
|
|
308
|
+
transformKeys: false, // disable both directions for this client
|
|
210
309
|
});
|
|
211
310
|
```
|
|
212
311
|
|
|
213
|
-
|
|
312
|
+
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.
|
|
313
|
+
|
|
314
|
+
## Next.js API route middleware
|
|
214
315
|
|
|
215
|
-
|
|
216
|
-
|
|
316
|
+
Imported from a separate entry to avoid pulling server deps into the main bundle:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import { withAuth, withValidation, withErrorHandling } from '@startsimpli/api/middleware';
|
|
217
320
|
import { z } from 'zod';
|
|
218
321
|
|
|
219
322
|
const schema = z.object({ name: z.string() });
|
|
@@ -222,107 +325,51 @@ export const POST = withErrorHandling(
|
|
|
222
325
|
withAuth(
|
|
223
326
|
withValidation(
|
|
224
327
|
async (request, { body }, authContext) => {
|
|
225
|
-
|
|
226
|
-
return NextResponse.json({ success: true });
|
|
328
|
+
return NextResponse.json({ ok: true });
|
|
227
329
|
},
|
|
228
|
-
{ body: schema }
|
|
229
|
-
)
|
|
230
|
-
)
|
|
330
|
+
{ body: schema },
|
|
331
|
+
),
|
|
332
|
+
),
|
|
231
333
|
);
|
|
232
334
|
```
|
|
233
335
|
|
|
234
|
-
|
|
336
|
+
`withAuth` injects `{ userId, token, isAuthenticated }`. `withErrorHandling` catches thrown `AppError` / `ApiException` and formats them into a standard JSON response.
|
|
235
337
|
|
|
236
|
-
|
|
237
|
-
import {
|
|
238
|
-
isApiException,
|
|
239
|
-
isValidationError,
|
|
240
|
-
isAuthError,
|
|
241
|
-
isNotFoundError,
|
|
242
|
-
} from '@startsimpli/api';
|
|
338
|
+
## Direct client escape hatch
|
|
243
339
|
|
|
244
|
-
|
|
245
|
-
const contact = await api.contacts.get('invalid-id');
|
|
246
|
-
} catch (error) {
|
|
247
|
-
if (isValidationError(error)) {
|
|
248
|
-
console.log('Validation errors:', error.errors);
|
|
249
|
-
} else if (isAuthError(error)) {
|
|
250
|
-
console.log('Auth error:', error.status);
|
|
251
|
-
} else if (isNotFoundError(error)) {
|
|
252
|
-
console.log('Contact not found');
|
|
253
|
-
} else if (isApiException(error)) {
|
|
254
|
-
console.log('API error:', error.message);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
```
|
|
340
|
+
For endpoints without a wrapper:
|
|
258
341
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
All types are exported for use in your application:
|
|
262
|
-
|
|
263
|
-
```typescript
|
|
264
|
-
import type {
|
|
265
|
-
Contact,
|
|
266
|
-
Organization,
|
|
267
|
-
Entity,
|
|
268
|
-
Tag,
|
|
269
|
-
Metric,
|
|
270
|
-
Profile,
|
|
271
|
-
Attribute,
|
|
272
|
-
PaginatedResponse,
|
|
273
|
-
ApiError,
|
|
274
|
-
CreateContactRequest,
|
|
275
|
-
UpdateContactRequest,
|
|
276
|
-
ContactFilters,
|
|
277
|
-
} from '@startsimpli/api';
|
|
278
|
-
```
|
|
342
|
+
```ts
|
|
343
|
+
import { createApiClient } from '@startsimpli/api';
|
|
279
344
|
|
|
280
|
-
|
|
345
|
+
const client = createApiClient({ baseUrl, getToken });
|
|
281
346
|
|
|
282
|
-
|
|
347
|
+
const data = await client.fetch.get('/custom-endpoint/', { params: { key: 'value' } });
|
|
348
|
+
const created = await client.fetch.post('/custom-endpoint/', { name: 'test' });
|
|
349
|
+
```
|
|
283
350
|
|
|
284
|
-
|
|
351
|
+
`client.fetch` is the `FetchWrapper`; it carries the auth token, runs the key transform, and throws `ApiException` on failure exactly like the typed wrappers.
|
|
285
352
|
|
|
286
|
-
|
|
287
|
-
- **Auth**: Bearer token from `@startsimpli/auth`
|
|
288
|
-
- **Pagination**: Django REST Framework format (`page`, `page_size`)
|
|
289
|
-
- **Filtering**: Django-filter query params (`field__gte=100`, `field__in=1,2,3`)
|
|
353
|
+
## Verification
|
|
290
354
|
|
|
291
|
-
|
|
355
|
+
```bash
|
|
356
|
+
pnpm --filter @startsimpli/api test # vitest, 12 files, 133 tests
|
|
357
|
+
pnpm --filter @startsimpli/api type-check # tsc --noEmit
|
|
358
|
+
```
|
|
292
359
|
|
|
293
|
-
|
|
360
|
+
Test 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.
|
|
294
361
|
|
|
295
|
-
|
|
296
|
-
// Entity with assertions
|
|
297
|
-
{
|
|
298
|
-
id: 'uuid',
|
|
299
|
-
entity_type: 'contact',
|
|
300
|
-
|
|
301
|
-
// Canonical fields
|
|
302
|
-
name: 'John Doe',
|
|
303
|
-
email: 'john@example.com',
|
|
304
|
-
|
|
305
|
-
// Computed from assertions
|
|
306
|
-
tier: 1, // from tags
|
|
307
|
-
linkedin: 'https://...', // from profiles
|
|
308
|
-
enrichment_score: 0.95, // from metrics
|
|
309
|
-
|
|
310
|
-
// Full assertions
|
|
311
|
-
tags: [{ category: 'quality', name: 'tier_1', ... }],
|
|
312
|
-
metrics: [{ type: 'quality', subtype: 'enrichment_score', value: 0.95 }],
|
|
313
|
-
profiles: [{ type: 'professional', subtype: 'linkedin', identifier: '...' }],
|
|
314
|
-
}
|
|
315
|
-
```
|
|
362
|
+
## Shared-first policy
|
|
316
363
|
|
|
317
|
-
|
|
364
|
+
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.
|
|
318
365
|
|
|
319
|
-
|
|
320
|
-
# Run tests
|
|
321
|
-
npm test
|
|
366
|
+
## Architecture notes
|
|
322
367
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
368
|
+
- **Base URL**: configurable via `baseUrl`; `''` (default) uses relative paths so Next.js rewrites/proxies can intercept.
|
|
369
|
+
- **Auth**: `Authorization: Bearer <token>` from `getToken()`; the wrapper handles refresh via `onTokenRefresh` and one-shot dedup of `onUnauthorized` callbacks (5s window).
|
|
370
|
+
- **Pagination**: DRF format — `page`, `pageSize` (sent as `page_size`).
|
|
371
|
+
- **Filtering**: Django-filter conventions — `field__gte`, `field__in`, etc. — written camelCase from callers.
|
|
372
|
+
- **Entity assertion model**: Django stores generic `Tag` / `Metric` / `Profile` / `Attribute` assertions against entities; writes use `writeTags` / `writeMetrics` / `writeProfiles`, reads include both canonical fields and the raw assertion arrays.
|
|
326
373
|
|
|
327
374
|
## License
|
|
328
375
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/api",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.20",
|
|
4
4
|
"description": "Type-safe Django REST API client for StartSimpli apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"exports": {
|
|
8
|
-
".":
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"react-native": "./src/index.ts",
|
|
11
|
+
"default": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"./middleware": {
|
|
14
|
+
"types": "./src/middleware/index.ts",
|
|
15
|
+
"default": "./src/middleware/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
9
18
|
},
|
|
10
19
|
"files": [
|
|
11
20
|
"src",
|
|
@@ -77,11 +77,18 @@ export const ENDPOINTS = {
|
|
|
77
77
|
SECTOR_BREADTH: 'api/v1/markets/sector_breadth',
|
|
78
78
|
EARNINGS_CALENDAR: 'api/v1/markets/calendar',
|
|
79
79
|
TRADING_SNAPSHOTS: 'api/v1/markets/trading/snapshots',
|
|
80
|
+
BARS_BULK: 'api/v1/markets/bars/bulk',
|
|
81
|
+
SOCIAL_POSTS: 'api/v1/markets/social_posts',
|
|
80
82
|
|
|
81
|
-
// Options — schemas
|
|
82
|
-
|
|
83
|
+
// Options — schemas confirmed by claude-mac (agent_bridge req 22ac4889).
|
|
84
|
+
// /options/iv/ summary + /options/skew/ intentionally omitted: iv/history covers
|
|
85
|
+
// rank/RV/spread; skew is client-derived from chain (mac req a2f98ba6).
|
|
86
|
+
OPTIONS_CHAIN: (symbol: string) => `api/v1/markets/instruments/${symbol}/options/chain`,
|
|
83
87
|
OPTIONS_IV_HISTORY: (symbol: string) => `api/v1/markets/instruments/${symbol}/options/iv/history`,
|
|
88
|
+
OPTIONS_ATM_IV_HISTORY: (symbol: string) => `api/v1/markets/instruments/${symbol}/options/atm_iv_history`,
|
|
89
|
+
MACRO_CALENDAR: 'api/v1/markets/macro_calendar',
|
|
84
90
|
OPTIONS_GREEKS: 'api/v1/markets/options/greeks',
|
|
91
|
+
OPTIONS_UNUSUAL_ACTIVITY: 'api/v1/markets/options/unusual_activity',
|
|
85
92
|
VIX_TERM: 'api/v1/markets/vix_term',
|
|
86
93
|
|
|
87
94
|
// Sources ops
|