@vahidkaargar/customized-api-client 0.2.2 → 0.2.3

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`
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,269 +68,273 @@ 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) |
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**.
317
+ - Server replay → header `Idempotent-Replayed: true` → `headers.idempotentReplayed` + optional `onIdempotencyReplay`.
318
+ - **GET / HEAD** never send idempotency keys.
315
319
 
316
- ```typescript
317
- // Convenience helper
318
- await client.patchWithVersion('/widgets/42', body, 7);
320
+ ---
319
321
 
320
- // Or per call
321
- await client.patch('/widgets/42', body, { ifMatchVersion: 7 });
322
- ```
322
+ ## Optimistic concurrency
323
323
 
324
- Read version from a resource for the next update:
324
+ Send **`If-Match: "v=<n>"`** when the resource is versioned:
325
325
 
326
326
  ```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
327
+ await client.patchWithVersion('/widgets/42', body, version);
336
328
  ```
337
329
 
338
- Typical server responses: **428** (precondition required), **412** (conflict) — use [guards](#guards-and-validation-helpers); these are **not** auto-retried.
330
+ Read version with `readResourceVersion(resource, etag)`prefers `meta.version`, else ETag `v=n`.
331
+
332
+ **412** / **428** are not auto-retried.
339
333
 
340
334
  ---
341
335
 
342
336
  ## Retries
343
337
 
344
- Default `retry`:
345
-
346
338
  | Field | Default |
347
339
  |-------|---------|
348
340
  | `maxAttempts` | `4` |
@@ -350,265 +342,139 @@ Default `retry`:
350
342
  | `maxDelayMs` | `10000` |
351
343
  | `jitterRatio` | `0.2` |
352
344
 
353
- Honors **`Retry-After`** when present (seconds or HTTP-date).
354
-
355
- ### Policy summary
356
-
357
345
  | Situation | Retried? |
358
346
  |-----------|----------|
359
- | Network error (no response) | Yes (all methods) |
347
+ | Network error (no response) | Yes |
360
348
  | 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 |
349
+ | GET/HEAD **401, 403, 412, 428**, validation 4xx | No |
350
+ | Mutations **5xx / 429** | No |
351
+ | Mutation **409** `IDEMPOTENCY_REQUEST_IN_PROGRESS` | Yes |
364
352
  | **409** `IDEMPOTENCY_KEY_REUSED` | No |
365
- | **401 / 403 / 412 / 428 / 422** etc. | No |
366
-
367
- Disable retries: `retry: { maxAttempts: 1 }`.
368
353
 
369
- Inspect policy in tests or custom tooling: `retryAllowed({ method, status, primaryErrorCode, isNetworkError })`.
354
+ Disable: `retry: { maxAttempts: 1 }`. Inspect logic: `retryAllowed({ })`.
370
355
 
371
356
  ---
372
357
 
373
- ## Pagination and query parameters
358
+ ## More helpers
374
359
 
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
360
+ ### Pagination and query building
398
361
 
399
362
  ```typescript
400
363
  import {
401
364
  buildJsonApiQuery,
402
365
  buildOffsetPageParams,
403
366
  buildCursorPageParams,
404
- DEFAULT_PAGE_SIZE_CAP,
367
+ parsePaginationKind,
405
368
  } from '@vahidkaargar/customized-api-client';
406
369
 
407
370
  const params = {
408
371
  ...buildJsonApiQuery({
409
- filter: { status: 'active', owner_id: 1 },
410
- sort: ['-created_at', 'name'],
411
- fields: { widgets: ['name', 'status'] },
412
- include: ['owner', 'tags'],
372
+ filter: { status: 'active' },
373
+ sort: ['-created_at'],
374
+ include: ['owner'],
413
375
  }),
414
376
  ...buildOffsetPageParams({ number: 2, size: 50 }),
415
377
  };
416
- // page[size] is capped at DEFAULT_PAGE_SIZE_CAP (100)
417
378
 
418
- await client.request({
419
- method: 'GET',
420
- url: '/widgets',
421
- params,
422
- });
379
+ await client.request({ method: 'GET', url: '/widgets', params });
423
380
  ```
424
381
 
425
- ```typescript
426
- const cursorParams = buildCursorPageParams({ cursor: 'abc123', size: 25 });
427
- ```
428
-
429
- ---
430
-
431
- ## Included resources
382
+ ### Included resources
432
383
 
433
384
  ```typescript
434
385
  import { indexIncluded, resolveIncluded } from '@vahidkaargar/customized-api-client';
435
386
 
436
- const res = await client.get('/widgets/1?include=owner');
437
- if (res.kind !== 'jsonapi-success') return;
438
-
439
387
  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)
388
+ const owner = resolveIncluded({ type: 'users', id: '9' }, idx);
459
389
  ```
460
390
 
461
- `accepted.location` is absolute (resolved from `Location` relative to the request URL when needed).
462
-
463
- ---
464
-
465
- ## Bulk operations (207)
391
+ ### Bulk 207
466
392
 
467
393
  ```typescript
468
394
  const res = await client.post('/bulk/widgets', bulkPayload);
469
395
  if (res.kind === 'multi-status') {
470
396
  for (const item of res.items) {
471
- if (item.httpStatus >= 400) {
472
- // item.body may be a JSON:API error document
473
- }
397
+ if (item.httpStatus >= 400) { /* item.body may be errors */ }
474
398
  }
475
399
  }
476
400
  ```
477
401
 
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:
402
+ ### Response camelCase (opt-in)
483
403
 
484
404
  ```typescript
485
- const client = createApiClient({
486
- baseURL: 'https://api.example.com/api/v1',
405
+ createApiClient({
406
+ baseURL: '',
487
407
  transformResponseKeys: 'camelCase-attributes-meta',
488
408
  });
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
- }
409
+ // Request bodies are NOT transformed — send snake_case on the wire.
494
410
  ```
495
411
 
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) |
412
+ ### Security and logging
512
413
 
513
- ---
414
+ - Tokens only from your `getToken` / `getSecret` callbacks.
415
+ - `redactHeaderRecord`, `truncateForLog`, `ApiClientError.toJSON()` redact secrets.
416
+ - Non-HTTPS `baseURL` (except localhost) logs a one-time warning.
514
417
 
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
418
+ ### Health check
531
419
 
532
420
  ```typescript
533
421
  import { createHealthCheck } from '@vahidkaargar/customized-api-client';
534
422
 
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
423
+ const ping = createHealthCheck(client);
424
+ const ok = await ping(); // GET /health/live
537
425
  ```
538
426
 
539
427
  ---
540
428
 
541
429
  ## Typing your API
542
430
 
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:
431
+ This package ships **generic JSON:API types**, not endpoint-specific OpenAPI types.
546
432
 
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:
433
+ 1. Keep OpenAPI in your backend or `@myorg/api-types`.
434
+ 2. Codegen there (e.g. `openapi-typescript`).
435
+ 3. Use generics at call sites:
550
436
 
551
437
  ```typescript
552
- import { createApiClient } from '@vahidkaargar/customized-api-client';
553
438
  import type { operations } from '@myorg/api-types';
554
439
 
555
- const client = createApiClient({ baseURL: 'https://api.example.com/api/v1', getToken: async () => token });
556
-
557
440
  type MeResponse = operations['getMe']['responses'][200]['content']['application/vnd.api+json'];
558
441
  const me = await client.get<MeResponse>('/me');
559
442
  ```
560
443
 
561
- You do not need an OpenAPI spec to use this client—string paths and manual types work fine.
444
+ String paths and manual types work without OpenAPI.
562
445
 
563
446
  ---
564
447
 
565
- ## Advanced / low-level exports
448
+ ## Advanced exports
566
449
 
567
- For custom pipelines, tests, or wrappers:
450
+ For custom wrappers, tests, or pipelines:
568
451
 
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 |
452
+ `normalizeAxiosResponse`, `parseJsonApiDocument`, `parseJsonApiErrorBody`, `dispatchWithRetry`, `applyJsonApiHeaders`, `resolveResourcePath`, `parseMultiStatusBody`, `parseRetryAfterSeconds`, `assertValidIdempotencyKey`, …
580
453
 
581
- ---
454
+ Full list: [`src/index.ts`](./src/index.ts).
582
455
 
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
456
+ ---
601
457
 
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`
458
+ ## Table of contents (full reference)
459
+
460
+ 1. [What you get](#what-you-get)
461
+ 2. [Install](#install)
462
+ 3. [Quick start](#quick-start)
463
+ 4. [Guide for application developers](#guide-for-application-developers)
464
+ 5. [Recipes](#recipes)
465
+ 6. [Configuration](#configuration)
466
+ 7. [Making requests](#making-requests)
467
+ 8. [Success results](#success-results-clientsuccess)
468
+ 9. [Error handling](#error-handling)
469
+ 10. [Idempotency](#idempotency)
470
+ 11. [Optimistic concurrency](#optimistic-concurrency)
471
+ 12. [Retries](#retries)
472
+ 13. [More helpers](#more-helpers)
473
+ 14. [Typing your API](#typing-your-api)
474
+ 15. [Advanced exports](#advanced-exports)
475
+ 16. [Development](#development-this-repository)
476
+ 17. [Supply chain](#supply-chain)
477
+ 18. [Publishing](#publishing-maintainers)
612
478
 
613
479
  ---
614
480
 
@@ -625,34 +491,29 @@ npm run build
625
491
  npx vitest run --config vitest.postbuild.config.ts
626
492
  ```
627
493
 
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)).
494
+ CI uses **`node-version: '22.21'`** in [`ci.yml`](.github/workflows/ci.yml) plus global **npm** `^11.5.1`.
629
495
 
630
496
  ---
631
497
 
632
498
  ## Supply chain
633
499
 
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).
500
+ - [SECURITY.md](SECURITY.md) — reporting vulnerabilities
501
+ - [`.github/dependabot.yml`](.github/dependabot.yml) weekly dependency PRs
502
+ - CI runs **`npm audit --omit=dev --audit-level=moderate`** on production deps
503
+ - Publish uses **`npm publish --provenance`** (OIDC)
638
504
 
639
505
  ---
640
506
 
641
507
  ## Publishing (maintainers)
642
508
 
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.
509
+ 1. Bump **`version`** in **`package.json`** and update **[CHANGELOG.md](./CHANGELOG.md)**.
510
+ 2. Merge to **`main`**, then **Actions → Publish to npm → Run workflow**.
511
+ 3. Workflow fails fast if the version already exists on npm.
651
512
 
652
- [`publishConfig.access`](package.json) on this scope is **`public`**.
513
+ [`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
514
 
654
515
  ---
655
516
 
656
517
  ## License
657
518
 
658
- MIT
519
+ MIT — see [LICENSE](./LICENSE).