@vahidkaargar/customized-api-client 0.2.2 → 0.2.4

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