@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 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