@vahidkaargar/customized-api-client 0.2.0
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 +639 -0
- package/dist/index.cjs +1171 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +317 -0
- package/dist/index.d.ts +317 -0
- package/dist/index.js +1087 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
package/README.md
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
# `@vahidkaargar/customized-api-client`
|
|
2
|
+
|
|
3
|
+
TypeScript **Axios** client for **JSON:API v1.1** APIs. It targets backends that use `application/vnd.api+json`, **Bearer** auth, mandatory **`Idempotency-Key`** on mutations, optimistic concurrency via **`If-Match`**, explicit **retry** rules, and **normalized** success/error results so application code does not re-parse raw Axios responses.
|
|
4
|
+
|
|
5
|
+
**Requirements:** Node.js **≥ 20**. **ESM** and **CJS** builds are published (`import` / `require`).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of contents
|
|
10
|
+
|
|
11
|
+
1. [Install](#install)
|
|
12
|
+
2. [Quick start](#quick-start)
|
|
13
|
+
3. [Configuration](#configuration)
|
|
14
|
+
4. [Base URL modes](#base-url-modes)
|
|
15
|
+
5. [Making requests](#making-requests)
|
|
16
|
+
6. [Success results (`ClientSuccess`)](#success-results-clientsuccess)
|
|
17
|
+
7. [Errors](#errors)
|
|
18
|
+
8. [Idempotency](#idempotency)
|
|
19
|
+
9. [Optimistic concurrency](#optimistic-concurrency)
|
|
20
|
+
10. [Retries](#retries)
|
|
21
|
+
11. [Pagination and query parameters](#pagination-and-query-parameters)
|
|
22
|
+
12. [Included resources](#included-resources)
|
|
23
|
+
13. [Async jobs (202) and polling](#async-jobs-202-and-polling)
|
|
24
|
+
14. [Bulk operations (207)](#bulk-operations-207)
|
|
25
|
+
15. [Response key transformation](#response-key-transformation)
|
|
26
|
+
16. [Guards and validation helpers](#guards-and-validation-helpers)
|
|
27
|
+
17. [Security and logging](#security-and-logging)
|
|
28
|
+
18. [Health checks](#health-checks)
|
|
29
|
+
19. [Typing your API](#typing-your-api)
|
|
30
|
+
20. [Advanced / low-level exports](#advanced--low-level-exports)
|
|
31
|
+
21. [API reference (exports)](#api-reference-exports)
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install @vahidkaargar/customized-api-client
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import { createApiClient } from '@vahidkaargar/customized-api-client';
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import {
|
|
51
|
+
createApiClient,
|
|
52
|
+
ApiClientError,
|
|
53
|
+
getNextPageUrl,
|
|
54
|
+
readResourceVersion,
|
|
55
|
+
} from '@vahidkaargar/customized-api-client';
|
|
56
|
+
|
|
57
|
+
const client = createApiClient({
|
|
58
|
+
baseURL: 'https://api.example.com/api/v1',
|
|
59
|
+
auth: {
|
|
60
|
+
type: 'bearer',
|
|
61
|
+
getToken: async () => sessionStorage.getItem('access_token'),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const res = await client.get('/widgets');
|
|
67
|
+
if (res.kind === 'jsonapi-success') {
|
|
68
|
+
const doc = res.document;
|
|
69
|
+
const next = getNextPageUrl(doc.links);
|
|
70
|
+
// ...
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (e instanceof ApiClientError) {
|
|
74
|
+
console.error(e.status, e.primaryCode, e.errors);
|
|
75
|
+
}
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
Pass a single config object to `createApiClient`:
|
|
85
|
+
|
|
86
|
+
| Option | Type | Default | Description |
|
|
87
|
+
|--------|------|---------|-------------|
|
|
88
|
+
| `baseURL` | `string` | **required** | API origin; see [Base URL modes](#base-url-modes). |
|
|
89
|
+
| `baseUrlMode` | `'modeB' \| 'modeA'` | `'modeB'` | How relative paths are joined with `baseURL`. |
|
|
90
|
+
| `auth` | `AuthConfig` | — | Bearer token or partner secret provider. |
|
|
91
|
+
| `getAcceptLanguage` | `() => string \| null \| …` | — | Sets `Accept-Language` when returned value is non-empty. |
|
|
92
|
+
| `defaultHeaders` | `Record<string, string>` | — | Merged into every request (after JSON:API defaults). |
|
|
93
|
+
| `timeout` | `number` | `30000` | Axios timeout in ms. |
|
|
94
|
+
| `retry` | `RetryOptions` | see [Retries](#retries) | Bounded retry with backoff. |
|
|
95
|
+
| `generateIdempotencyKey` | `() => string` | ULID | Custom idempotency key factory for mutations. |
|
|
96
|
+
| `onIdempotencyReplay` | `(ctx) => void` | — | Called when response has `Idempotent-Replayed: true`. |
|
|
97
|
+
| `onUnauthorized` | `(error) => void \| Promise<void>` | — | Called when a response normalizes to **401**. |
|
|
98
|
+
| `onDeprecated` | `(info) => void` | — | Called when deprecation/sunset headers are present. |
|
|
99
|
+
| `transformResponseKeys` | `'none' \| 'camelCase-attributes-meta'` | `'none'` | Optional camelCase on `attributes` / `meta` only. |
|
|
100
|
+
| `maxBodyLogLength` | `number` | — | Documented cap for logging; use `truncateForLog` in app code. |
|
|
101
|
+
|
|
102
|
+
### Authentication
|
|
103
|
+
|
|
104
|
+
User and partner credentials both use **`Authorization: Bearer …`** (no extra partner headers).
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
// User session token
|
|
108
|
+
auth: { type: 'bearer', getToken: () => getAccessToken() }
|
|
109
|
+
|
|
110
|
+
// Partner integration (same header shape)
|
|
111
|
+
auth: { type: 'partner-bearer', getSecret: () => process.env.PARTNER_SECRET }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
If the provider returns `null` / `undefined`, no `Authorization` header is sent.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Base URL modes
|
|
119
|
+
|
|
120
|
+
### Mode B (default)
|
|
121
|
+
|
|
122
|
+
`baseURL` **includes** `/api/v1`. Resource paths are short:
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
createApiClient({ baseURL: 'https://api.example.com/api/v1' });
|
|
126
|
+
await client.get('/admin/teams'); // → https://api.example.com/api/v1/admin/teams
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
If a path accidentally repeats `/api/v1` while `baseURL` already ends with `/api/v1`, the client strips the duplicate segment.
|
|
130
|
+
|
|
131
|
+
### Mode A
|
|
132
|
+
|
|
133
|
+
`baseURL` is **origin only**; the client prefixes `/api/v1` for relative paths that do not already start with it:
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
createApiClient({
|
|
137
|
+
baseURL: 'https://api.example.com',
|
|
138
|
+
baseUrlMode: 'modeA',
|
|
139
|
+
});
|
|
140
|
+
await client.get('/teams'); // → https://api.example.com/api/v1/teams
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Absolute URLs
|
|
144
|
+
|
|
145
|
+
`getByUrl(fullUrl)` and `request({ url: 'https://…' })` use the URL as-is (still through interceptors: auth, JSON:API headers, retries).
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Making requests
|
|
150
|
+
|
|
151
|
+
### Verb methods (throw on error)
|
|
152
|
+
|
|
153
|
+
| Method | HTTP | Notes |
|
|
154
|
+
|--------|------|--------|
|
|
155
|
+
| `get(path, opts?)` | GET | |
|
|
156
|
+
| `head(path, opts?)` | HEAD | Often `no-content` (204). |
|
|
157
|
+
| `post(path, data?, opts?)` | POST | Sends idempotency key. |
|
|
158
|
+
| `patch(path, data?, opts?)` | PATCH | Sends idempotency key. |
|
|
159
|
+
| `put(path, data?, opts?)` | PUT | Sends idempotency key. |
|
|
160
|
+
| `delete(path, opts?)` | DELETE | Sends idempotency key. |
|
|
161
|
+
| `patchWithVersion(path, data, version, opts?)` | PATCH | Sets `If-Match: "v=<version>"`. |
|
|
162
|
+
| `getByUrl(fullUrl, opts?)` | GET | Follow `links.next` or external URLs. |
|
|
163
|
+
| `request(axConfig, opts?)` | any | Escape hatch; merges `opts` with Axios config. |
|
|
164
|
+
|
|
165
|
+
Per-call options (`RequestCallOptions`):
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
await client.post('/widgets', body, {
|
|
169
|
+
idempotencyKey: 'my-stable-key', // max 64 chars
|
|
170
|
+
ifMatchVersion: 3,
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Safe variants (no throw on `ApiClientError`)
|
|
175
|
+
|
|
176
|
+
Return `Result<ClientSuccess, ApiClientError>`: `{ ok: true, value }` or `{ ok: false, error }`.
|
|
177
|
+
|
|
178
|
+
- `safeGet`, `safeHead`, `safePost`, `safePatch`, `safePut`, `safeDelete`, `safeRequest`
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
const r = await client.safeGet('/widgets');
|
|
182
|
+
if (!r.ok) {
|
|
183
|
+
if (r.error.status === 404) return null;
|
|
184
|
+
throw r.error;
|
|
185
|
+
}
|
|
186
|
+
const doc = r.value.kind === 'jsonapi-success' ? r.value.document : undefined;
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Non-`ApiClientError` failures (e.g. programmer errors) still throw from `safe*`.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Success results (`ClientSuccess`)
|
|
194
|
+
|
|
195
|
+
Every successful verb returns a **discriminated union** — narrow on `kind`:
|
|
196
|
+
|
|
197
|
+
### `jsonapi-success` (200 / 201)
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
if (res.kind === 'jsonapi-success') {
|
|
201
|
+
res.status; // 200 | 201
|
|
202
|
+
res.document; // JsonApiDocument — data, included, links, meta
|
|
203
|
+
res.headers.etag;
|
|
204
|
+
res.headers.contentLanguage;
|
|
205
|
+
res.headers.idempotentReplayed; // true if Idempotent-Replayed: true
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### `no-content` (204)
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
if (res.kind === 'no-content') {
|
|
213
|
+
res.status; // 204
|
|
214
|
+
res.headers;
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### `accepted` (202)
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
if (res.kind === 'accepted') {
|
|
222
|
+
res.location; // absolute URL to poll (resolved from Location header)
|
|
223
|
+
res.rawBody; // optional server body
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### `multi-status` (207)
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
if (res.kind === 'multi-status') {
|
|
231
|
+
for (const item of res.items) {
|
|
232
|
+
item.httpStatus;
|
|
233
|
+
item.body;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Normalized headers (all success kinds)
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
res.headers.etag?: string;
|
|
242
|
+
res.headers.contentLanguage?: string;
|
|
243
|
+
res.headers.idempotentReplayed: boolean;
|
|
244
|
+
res.headers.retryAfterSeconds?: number;
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Errors
|
|
250
|
+
|
|
251
|
+
### Default: `ApiClientError`
|
|
252
|
+
|
|
253
|
+
HTTP **≥ 400** and malformed JSON:API error documents throw **`ApiClientError`** (extends `Error`).
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
import { ApiClientError, isApiClientError } from '@vahidkaargar/customized-api-client';
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await client.patch('/widgets/1', payload);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if (e instanceof ApiClientError) {
|
|
262
|
+
e.status; // e.g. 422
|
|
263
|
+
e.primaryCode; // first errors[].code
|
|
264
|
+
e.errors; // JsonApiErrorObject[]
|
|
265
|
+
e.retryAfterSeconds; // from Retry-After when parseable
|
|
266
|
+
e.requestMethod; // e.g. 'PATCH'
|
|
267
|
+
e.responseHeaders; // redacted in toJSON()
|
|
268
|
+
console.log(e.toJSON()); // safe for logs (secrets redacted)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Empty or non-JSON error bodies get synthetic codes such as `EMPTY_ERROR_BODY`, `INVALID_JSON`, `MISSING_ERRORS_ARRAY`.
|
|
274
|
+
|
|
275
|
+
### Validation UX
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
import {
|
|
279
|
+
groupValidationErrorsByPointer,
|
|
280
|
+
isValidationError,
|
|
281
|
+
} from '@vahidkaargar/customized-api-client';
|
|
282
|
+
|
|
283
|
+
if (isValidationError(err)) {
|
|
284
|
+
const byPointer = groupValidationErrorsByPointer(err.errors);
|
|
285
|
+
// { '/data/attributes/name': ['too short', ...], '/': ['...'] }
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Idempotency
|
|
292
|
+
|
|
293
|
+
- **POST, PATCH, PUT, DELETE** always send **`Idempotency-Key`** (default: **ULID** per request).
|
|
294
|
+
- Override per call: `opts.idempotencyKey` (non-empty, max **64** characters).
|
|
295
|
+
- Replace generator: `generateIdempotencyKey: () => myKey()`.
|
|
296
|
+
- On retry, the **same key and body** are reused.
|
|
297
|
+
- When the server replays a prior result, response header **`Idempotent-Replayed: true`** sets `headers.idempotentReplayed` and invokes **`onIdempotencyReplay`**:
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
onIdempotencyReplay: ({ url, method }) => {
|
|
301
|
+
metrics.increment('idempotency_replay');
|
|
302
|
+
},
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
**GET / HEAD** never send idempotency keys.
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Optimistic concurrency
|
|
310
|
+
|
|
311
|
+
Versioned resources should send **`If-Match: "v=<n>"`** when updating.
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
// Convenience helper
|
|
315
|
+
await client.patchWithVersion('/widgets/42', body, 7);
|
|
316
|
+
|
|
317
|
+
// Or per call
|
|
318
|
+
await client.patch('/widgets/42', body, { ifMatchVersion: 7 });
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Read version from a resource for the next update:
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
import { readResourceVersion, etagFromResponseHeaders } from '@vahidkaargar/customized-api-client';
|
|
325
|
+
|
|
326
|
+
const res = await client.get('/widgets/42');
|
|
327
|
+
if (res.kind !== 'jsonapi-success' || !res.document.data || Array.isArray(res.document.data)) {
|
|
328
|
+
throw new Error('expected single resource');
|
|
329
|
+
}
|
|
330
|
+
const resource = res.document.data;
|
|
331
|
+
const version = readResourceVersion(resource, res.headers.etag);
|
|
332
|
+
// Prefer meta.version (number or numeric string), else ETag v=n
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Typical server responses: **428** (precondition required), **412** (conflict) — use [guards](#guards-and-validation-helpers); these are **not** auto-retried.
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## Retries
|
|
340
|
+
|
|
341
|
+
Default `retry`:
|
|
342
|
+
|
|
343
|
+
| Field | Default |
|
|
344
|
+
|-------|---------|
|
|
345
|
+
| `maxAttempts` | `4` |
|
|
346
|
+
| `baseDelayMs` | `200` |
|
|
347
|
+
| `maxDelayMs` | `10000` |
|
|
348
|
+
| `jitterRatio` | `0.2` |
|
|
349
|
+
|
|
350
|
+
Honors **`Retry-After`** when present (seconds or HTTP-date).
|
|
351
|
+
|
|
352
|
+
### Policy summary
|
|
353
|
+
|
|
354
|
+
| Situation | Retried? |
|
|
355
|
+
|-----------|----------|
|
|
356
|
+
| Network error (no response) | Yes (all methods) |
|
|
357
|
+
| GET/HEAD **408, 429, 5xx** | Yes |
|
|
358
|
+
| GET/HEAD **401, 403, 412, 428, 4xx** validation | No |
|
|
359
|
+
| POST/PATCH/PUT/DELETE **5xx / 429** | **No** (same idempotency key would not help blind 5xx retry) |
|
|
360
|
+
| POST/PATCH/PUT/DELETE **409** `IDEMPOTENCY_REQUEST_IN_PROGRESS` | Yes |
|
|
361
|
+
| **409** `IDEMPOTENCY_KEY_REUSED` | No |
|
|
362
|
+
| **401 / 403 / 412 / 428 / 422** etc. | No |
|
|
363
|
+
|
|
364
|
+
Disable retries: `retry: { maxAttempts: 1 }`.
|
|
365
|
+
|
|
366
|
+
Inspect policy in tests or custom tooling: `retryAllowed({ method, status, primaryErrorCode, isNetworkError })`.
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## Pagination and query parameters
|
|
371
|
+
|
|
372
|
+
### Following `links.next`
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
import { getNextPageUrl, parsePaginationKind } from '@vahidkaargar/customized-api-client';
|
|
376
|
+
|
|
377
|
+
let res = await client.get('/widgets');
|
|
378
|
+
while (res.kind === 'jsonapi-success') {
|
|
379
|
+
const kind = parsePaginationKind(res.document.meta, res.document.links);
|
|
380
|
+
// kind: 'offset' | 'cursor' | 'unknown'
|
|
381
|
+
|
|
382
|
+
const next = getNextPageUrl(res.document.links);
|
|
383
|
+
if (!next) break;
|
|
384
|
+
res = await client.getByUrl(next);
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
`parsePaginationKind` understands:
|
|
389
|
+
|
|
390
|
+
- JSON:API `page[number]`, `page[cursor]`, `page[size]` in `links.next`
|
|
391
|
+
- Legacy `page` / `per_page` in URLs
|
|
392
|
+
- `meta` fields: `current_page`, `last_page`, `has_more`, `next_cursor`, etc.
|
|
393
|
+
|
|
394
|
+
### Building query strings
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
import {
|
|
398
|
+
buildJsonApiQuery,
|
|
399
|
+
buildOffsetPageParams,
|
|
400
|
+
buildCursorPageParams,
|
|
401
|
+
DEFAULT_PAGE_SIZE_CAP,
|
|
402
|
+
} from '@vahidkaargar/customized-api-client';
|
|
403
|
+
|
|
404
|
+
const params = {
|
|
405
|
+
...buildJsonApiQuery({
|
|
406
|
+
filter: { status: 'active', owner_id: 1 },
|
|
407
|
+
sort: ['-created_at', 'name'],
|
|
408
|
+
fields: { widgets: ['name', 'status'] },
|
|
409
|
+
include: ['owner', 'tags'],
|
|
410
|
+
}),
|
|
411
|
+
...buildOffsetPageParams({ number: 2, size: 50 }),
|
|
412
|
+
};
|
|
413
|
+
// page[size] is capped at DEFAULT_PAGE_SIZE_CAP (100)
|
|
414
|
+
|
|
415
|
+
await client.request({
|
|
416
|
+
method: 'GET',
|
|
417
|
+
url: '/widgets',
|
|
418
|
+
params,
|
|
419
|
+
});
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
const cursorParams = buildCursorPageParams({ cursor: 'abc123', size: 25 });
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## Included resources
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
import { indexIncluded, resolveIncluded } from '@vahidkaargar/customized-api-client';
|
|
432
|
+
|
|
433
|
+
const res = await client.get('/widgets/1?include=owner');
|
|
434
|
+
if (res.kind !== 'jsonapi-success') return;
|
|
435
|
+
|
|
436
|
+
const idx = indexIncluded(res.document.included);
|
|
437
|
+
const ownerRef = { type: 'users', id: '9' };
|
|
438
|
+
const owner = resolveIncluded(ownerRef, idx);
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Async jobs (202) and polling
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
import { pollAsyncResult } from '@vahidkaargar/customized-api-client';
|
|
447
|
+
|
|
448
|
+
const accepted = await client.post('/jobs', { data: { type: 'jobs', attributes: { … } } });
|
|
449
|
+
if (accepted.kind !== 'accepted') throw new Error('expected 202');
|
|
450
|
+
|
|
451
|
+
const done = await pollAsyncResult(client, accepted, {
|
|
452
|
+
maxAttempts: 10,
|
|
453
|
+
delayMs: 500,
|
|
454
|
+
});
|
|
455
|
+
// Polls GET on location until non-202 or max attempts (then throws)
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
`accepted.location` is absolute (resolved from `Location` relative to the request URL when needed).
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## Bulk operations (207)
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
const res = await client.post('/bulk/widgets', bulkPayload);
|
|
466
|
+
if (res.kind === 'multi-status') {
|
|
467
|
+
for (const item of res.items) {
|
|
468
|
+
if (item.httpStatus >= 400) {
|
|
469
|
+
// item.body may be a JSON:API error document
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
---
|
|
476
|
+
|
|
477
|
+
## Response key transformation
|
|
478
|
+
|
|
479
|
+
Wire format uses **snake_case** in JSON:API `attributes` / `meta`. Opt in to shallow **camelCase** on responses only:
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
const client = createApiClient({
|
|
483
|
+
baseURL: 'https://api.example.com/api/v1',
|
|
484
|
+
transformResponseKeys: 'camelCase-attributes-meta',
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const res = await client.get('/widgets/1');
|
|
488
|
+
if (res.kind === 'jsonapi-success' && !Array.isArray(res.document.data) && res.document.data) {
|
|
489
|
+
const attrs = res.document.data.attributes as { displayName?: string };
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
**Request bodies are not transformed** — send snake_case (or whatever your API expects).
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
## Guards and validation helpers
|
|
498
|
+
|
|
499
|
+
| Function | True when |
|
|
500
|
+
|----------|-----------|
|
|
501
|
+
| `isAuthenticationError` | `ApiClientError` status **401** |
|
|
502
|
+
| `isForbiddenError` | **403** |
|
|
503
|
+
| `isValidationError` | **422** |
|
|
504
|
+
| `isPreconditionRequiredError` | **428** |
|
|
505
|
+
| `isPreconditionFailedError` | **412** |
|
|
506
|
+
| `isConflictError` | **409** |
|
|
507
|
+
| `isPayloadTooLargeError` | **413** |
|
|
508
|
+
| `isRetryablePerPolicy` | Would retry per client policy (usually for UI hints, not for manual retry of failed calls) |
|
|
509
|
+
|
|
510
|
+
---
|
|
511
|
+
|
|
512
|
+
## Security and logging
|
|
513
|
+
|
|
514
|
+
- Tokens come only from your **`getToken` / `getSecret`** callbacks; the client does not store credentials.
|
|
515
|
+
- **`ApiClientError.toJSON()`** and **`redactHeaderRecord()`** redact `Authorization` and `Idempotency-Key`.
|
|
516
|
+
- **`truncateForLog(value, maxLen)`** safely stringifies values for logs (truncates, handles circular refs).
|
|
517
|
+
- Non-HTTPS `baseURL` outside **localhost** logs a **one-time** console warning.
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
import { truncateForLog, redactHeaderRecord } from '@vahidkaargar/customized-api-client';
|
|
521
|
+
|
|
522
|
+
logger.info(truncateForLog(responseBody, 2_000));
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## Health checks
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
import { createHealthCheck } from '@vahidkaargar/customized-api-client';
|
|
531
|
+
|
|
532
|
+
const ping = createHealthCheck(client); // or pass ApiClientConfig to build a client internally
|
|
533
|
+
const ok = await ping(); // GET /health/live — true if no throw
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
## Typing your API
|
|
539
|
+
|
|
540
|
+
This package exports **JSON:API primitives** (`JsonApiDocument`, `ClientSuccess`, helpers)—not endpoint-specific types tied to one backend.
|
|
541
|
+
|
|
542
|
+
If you want OpenAPI-driven types:
|
|
543
|
+
|
|
544
|
+
1. Keep the spec in your **backend** repo or a dedicated **`@myorg/api-types`** package.
|
|
545
|
+
2. Run codegen there (for example [`openapi-typescript`](https://github.com/drwpow/openapi-typescript))—not in this client package.
|
|
546
|
+
3. Pass generated types at call sites via generics:
|
|
547
|
+
|
|
548
|
+
```typescript
|
|
549
|
+
import { createApiClient } from '@vahidkaargar/customized-api-client';
|
|
550
|
+
import type { operations } from '@myorg/api-types';
|
|
551
|
+
|
|
552
|
+
const client = createApiClient({ baseURL: 'https://api.example.com/api/v1', getToken: async () => token });
|
|
553
|
+
|
|
554
|
+
type MeResponse = operations['getMe']['responses'][200]['content']['application/vnd.api+json'];
|
|
555
|
+
const me = await client.get<MeResponse>('/me');
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
You do not need an OpenAPI spec to use this client—string paths and manual types work fine.
|
|
559
|
+
|
|
560
|
+
---
|
|
561
|
+
|
|
562
|
+
## Advanced / low-level exports
|
|
563
|
+
|
|
564
|
+
For custom pipelines, tests, or wrappers:
|
|
565
|
+
|
|
566
|
+
| Export | Purpose |
|
|
567
|
+
|--------|---------|
|
|
568
|
+
| `normalizeAxiosResponse` | Map raw Axios response → `ClientSuccess` / throw |
|
|
569
|
+
| `parseJsonApiDocument` / `parseJsonApiErrorBody` | Parse success/error payloads |
|
|
570
|
+
| `dispatchWithRetry` | Retry wrapper around an `AxiosInstance` |
|
|
571
|
+
| `applyJsonApiHeaders` | Content-Type / Accept for JSON:API |
|
|
572
|
+
| `resolveResourcePath` | Mode A/B path resolution |
|
|
573
|
+
| `flattenAxiosHeaders` / `getHeader` | Header normalization |
|
|
574
|
+
| `parseMultiStatusBody` / `resolveAcceptedLocation` | 207 / 202 helpers |
|
|
575
|
+
| `parseRetryAfterSeconds` | Retry-After parsing |
|
|
576
|
+
| `assertValidIdempotencyKey` / `defaultIdempotencyKey` | Idempotency utilities |
|
|
577
|
+
|
|
578
|
+
---
|
|
579
|
+
|
|
580
|
+
## API reference (exports)
|
|
581
|
+
|
|
582
|
+
### Client
|
|
583
|
+
|
|
584
|
+
- `createApiClient(config)` → `ApiClient`
|
|
585
|
+
- Types: `ApiClient`, `ApiClientConfig`, `RequestCallOptions`, `AuthConfig`, `RetryOptions`, …
|
|
586
|
+
|
|
587
|
+
### Results & errors
|
|
588
|
+
|
|
589
|
+
- `ClientSuccess`, `JsonApiSuccessBody`, `NoContentBody`, `AcceptedBody`, `MultiStatusBody`
|
|
590
|
+
- `Result`, `OkResult`, `ErrResult`
|
|
591
|
+
- `ApiClientError`, `isApiClientError`
|
|
592
|
+
|
|
593
|
+
### JSON:API types
|
|
594
|
+
|
|
595
|
+
- `JsonApiDocument`, `JsonApiResourceObject`, `JsonApiErrorObject`, …
|
|
596
|
+
|
|
597
|
+
### Helpers
|
|
598
|
+
|
|
599
|
+
- Pagination: `getNextPageUrl`, `parsePaginationKind`
|
|
600
|
+
- Query: `buildJsonApiQuery`, `buildOffsetPageParams`, `buildCursorPageParams`
|
|
601
|
+
- Included: `indexIncluded`, `resolveIncluded`
|
|
602
|
+
- Version: `readResourceVersion`, `etagFromResponseHeaders`
|
|
603
|
+
- Forms: `groupValidationErrorsByPointer`
|
|
604
|
+
- Deprecation: `parseDeprecationHeaders`, `DeprecationInfo`
|
|
605
|
+
- Transform: `applyTransformKeys`
|
|
606
|
+
- Poll: `pollAsyncResult`
|
|
607
|
+
- Health: `createHealthCheck`
|
|
608
|
+
- Security: `redactHeaderRecord`, `truncateForLog`
|
|
609
|
+
|
|
610
|
+
---
|
|
611
|
+
|
|
612
|
+
## Development (this repository)
|
|
613
|
+
|
|
614
|
+
```bash
|
|
615
|
+
npm ci
|
|
616
|
+
npm run typecheck
|
|
617
|
+
npm run lint
|
|
618
|
+
npm run test:coverage
|
|
619
|
+
npm run build
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
---
|
|
623
|
+
|
|
624
|
+
## Publishing (maintainers — CI token)
|
|
625
|
+
|
|
626
|
+
Publishing uses a **Granular Access Token** and GitHub Actions so you don’t need `npm publish --otp` on your laptop:
|
|
627
|
+
|
|
628
|
+
1. On [npmjs.com → Access Tokens](https://www.npmjs.com/settings/~/tokens): **Generate New Token** → **Granular Access Token**.
|
|
629
|
+
2. Under packages, select **`@vahidkaargar/customized-api-client`** with **Read and write** (and **Automations** / **Bypass two-factor authentication** if npm shows that option for unattended publish — required when your account enforces 2FA on writes).
|
|
630
|
+
3. In this GitHub repo: **Settings** → **Secrets and variables** → **Actions** → create **`NPM_TOKEN`** with that token value.
|
|
631
|
+
4. Bump **`version`** in **`package.json`**, merge to **`main`**, then **Actions** → **Publish to npm** → **Run workflow**.
|
|
632
|
+
|
|
633
|
+
That workflow repeats the CI gate, then runs **`npm publish`** ( **`publishConfig.access`** is **`public`** for this scope).
|
|
634
|
+
|
|
635
|
+
---
|
|
636
|
+
|
|
637
|
+
## License
|
|
638
|
+
|
|
639
|
+
MIT
|