@vahidkaargar/customized-api-client 0.2.3 → 0.3.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 +36 -9
- package/dist/index.cjs +85 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -20
- package/dist/index.d.ts +39 -20
- package/dist/index.js +80 -17
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ TypeScript **Axios** client for **JSON:API v1.1** APIs — Bearer auth, mandator
|
|
|
18
18
|
- **Errors** — `ApiClientError` with `status`, `primaryCode`, `errors[]`, safe `toJSON()` for logs
|
|
19
19
|
- **Idempotency** — `Idempotency-Key` on POST/PATCH/PUT/DELETE (ULID by default)
|
|
20
20
|
- **Concurrency** — `If-Match: "v=<n>"` via `patchWithVersion` or `ifMatchVersion`
|
|
21
|
-
- **Retries** — explicit policy (safe GET/HEAD vs mutations); honors `Retry-After
|
|
21
|
+
- **Retries** — explicit policy (safe GET/HEAD vs mutations); honors `Retry-After`; optional mutation **5xx** retries
|
|
22
22
|
- **Two ergonomics** — throwing verbs **or** `safe*` methods returning `{ ok, value \| error }`
|
|
23
23
|
- **Cancellation** — optional `AbortSignal` per request
|
|
24
24
|
|
|
@@ -187,6 +187,19 @@ setTimeout(() => controller.abort(), 5_000);
|
|
|
187
187
|
await promise; // throws when aborted
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
+
### Non-JSON:API bodies (multipart)
|
|
191
|
+
|
|
192
|
+
For file uploads, send `FormData` — the client keeps `Accept: application/vnd.api+json`, omits JSON:API `Content-Type` (Axios sets the multipart boundary), and still sends `Idempotency-Key` on POST.
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
const fd = new FormData();
|
|
196
|
+
fd.append('file', file, file.name);
|
|
197
|
+
|
|
198
|
+
await client.request({ method: 'POST', url: '/media', data: fd });
|
|
199
|
+
// or:
|
|
200
|
+
await client.postFormData('/media', fd);
|
|
201
|
+
```
|
|
202
|
+
|
|
190
203
|
### Poll an async job (202)
|
|
191
204
|
|
|
192
205
|
```typescript
|
|
@@ -302,18 +315,23 @@ Synthetic codes when the body is missing or invalid: `EMPTY_ERROR_BODY`, `INVALI
|
|
|
302
315
|
| `isAuthenticationError` | 401 |
|
|
303
316
|
| `isForbiddenError` | 403 |
|
|
304
317
|
| `isValidationError` | 422 |
|
|
305
|
-
| `isPreconditionRequiredError` | 428 |
|
|
318
|
+
| `isPreconditionRequiredError` | any **428** |
|
|
319
|
+
| `isIdempotencyKeyRequiredError` | **428** + `IDEMPOTENCY_KEY_REQUIRED` |
|
|
320
|
+
| `isIfMatchRequiredError` | **428** + `IF_MATCH_REQUIRED` |
|
|
321
|
+
| `isMfaVerificationRequiredError` | **428** + `MFA_VERIFICATION_REQUIRED` |
|
|
322
|
+
| `hasErrorCode` / `isApiClientErrorWithCode` | matching `errors[].code` |
|
|
306
323
|
| `isPreconditionFailedError` | 412 |
|
|
307
324
|
| `isConflictError` | 409 |
|
|
308
325
|
| `isPayloadTooLargeError` | 413 |
|
|
309
|
-
| `isRetryablePerPolicy` | Would retry per client policy (UI hints) |
|
|
326
|
+
| `isRetryablePerPolicy` | Would retry per client policy (UI hints); pass `{ retryMutationsOnServerError: true }` to match clients that opt into mutation **5xx** retries |
|
|
310
327
|
|
|
311
328
|
---
|
|
312
329
|
|
|
313
330
|
## Idempotency
|
|
314
331
|
|
|
315
332
|
- **POST, PATCH, PUT, DELETE** send **`Idempotency-Key`** (ULID per request by default).
|
|
316
|
-
- Retries reuse the **same key and body
|
|
333
|
+
- Retries reuse the **same key and body** within one client call (`dispatchWithRetry`).
|
|
334
|
+
- For **separate** `post`/`patch`/… invocations, pass the same `idempotencyKey` yourself if they represent the same user intent.
|
|
317
335
|
- Server replay → header `Idempotent-Replayed: true` → `headers.idempotentReplayed` + optional `onIdempotencyReplay`.
|
|
318
336
|
- **GET / HEAD** never send idempotency keys.
|
|
319
337
|
|
|
@@ -341,17 +359,20 @@ Read version with `readResourceVersion(resource, etag)` — prefers `meta.versio
|
|
|
341
359
|
| `baseDelayMs` | `200` |
|
|
342
360
|
| `maxDelayMs` | `10000` |
|
|
343
361
|
| `jitterRatio` | `0.2` |
|
|
362
|
+
| `retryMutationsOnServerError` | `false` |
|
|
344
363
|
|
|
345
364
|
| Situation | Retried? |
|
|
346
365
|
|-----------|----------|
|
|
347
366
|
| Network error (no response) | Yes |
|
|
348
367
|
| GET/HEAD **408, 429, 5xx** | Yes |
|
|
349
368
|
| GET/HEAD **401, 403, 412, 428**, validation 4xx | No |
|
|
350
|
-
| Mutations **5xx / 429** | No |
|
|
369
|
+
| Mutations **5xx / 429** | No (set `retry: { retryMutationsOnServerError: true }` to retry **5xx** only; **429** stays off) |
|
|
351
370
|
| Mutation **409** `IDEMPOTENCY_REQUEST_IN_PROGRESS` | Yes |
|
|
352
371
|
| **409** `IDEMPOTENCY_KEY_REUSED` | No |
|
|
353
372
|
|
|
354
|
-
|
|
373
|
+
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.
|
|
374
|
+
|
|
375
|
+
Disable: `retry: { maxAttempts: 1 }`. Inspect logic: `retryAllowed({ … })` (include `retryMutationsOnServerError` when mirroring client config). For thrown errors: `isRetryablePerPolicy(err, { retryMutationsOnServerError: true })`.
|
|
355
376
|
|
|
356
377
|
---
|
|
357
378
|
|
|
@@ -435,13 +456,19 @@ This package ships **generic JSON:API types**, not endpoint-specific OpenAPI typ
|
|
|
435
456
|
3. Use generics at call sites:
|
|
436
457
|
|
|
437
458
|
```typescript
|
|
459
|
+
import type { JsonApiDocument, JsonApiResourceObject } from '@vahidkaargar/customized-api-client';
|
|
438
460
|
import type { operations } from '@myorg/api-types';
|
|
439
461
|
|
|
440
|
-
type
|
|
441
|
-
|
|
462
|
+
type MeDoc = JsonApiDocument<JsonApiResourceObject>;
|
|
463
|
+
// Or from OpenAPI: operations['getMe']['responses'][200]['content']['application/vnd.api+json']
|
|
464
|
+
|
|
465
|
+
const res = await client.get<MeDoc>('/me');
|
|
466
|
+
if (res.kind === 'jsonapi-success') {
|
|
467
|
+
const me = res.document.data; // document typed as MeDoc
|
|
468
|
+
}
|
|
442
469
|
```
|
|
443
470
|
|
|
444
|
-
String paths and manual types work without OpenAPI.
|
|
471
|
+
The generic narrows `document` on **`jsonapi-success`**; `accepted`, `no-content`, and `multi-status` shapes are unchanged. String paths and manual types work without OpenAPI.
|
|
445
472
|
|
|
446
473
|
---
|
|
447
474
|
|
package/dist/index.cjs
CHANGED
|
@@ -51,11 +51,16 @@ __export(index_exports, {
|
|
|
51
51
|
getHeader: () => getHeader,
|
|
52
52
|
getNextPageUrl: () => getNextPageUrl,
|
|
53
53
|
groupValidationErrorsByPointer: () => groupValidationErrorsByPointer,
|
|
54
|
+
hasErrorCode: () => hasErrorCode,
|
|
54
55
|
indexIncluded: () => indexIncluded,
|
|
55
56
|
isApiClientError: () => isApiClientError,
|
|
57
|
+
isApiClientErrorWithCode: () => isApiClientErrorWithCode,
|
|
56
58
|
isAuthenticationError: () => isAuthenticationError,
|
|
57
59
|
isConflictError: () => isConflictError,
|
|
58
60
|
isForbiddenError: () => isForbiddenError,
|
|
61
|
+
isIdempotencyKeyRequiredError: () => isIdempotencyKeyRequiredError,
|
|
62
|
+
isIfMatchRequiredError: () => isIfMatchRequiredError,
|
|
63
|
+
isMfaVerificationRequiredError: () => isMfaVerificationRequiredError,
|
|
59
64
|
isMutationMethod: () => isMutationMethod,
|
|
60
65
|
isPayloadTooLargeError: () => isPayloadTooLargeError,
|
|
61
66
|
isPreconditionFailedError: () => isPreconditionFailedError,
|
|
@@ -86,7 +91,7 @@ module.exports = __toCommonJS(index_exports);
|
|
|
86
91
|
// package.json
|
|
87
92
|
var package_default = {
|
|
88
93
|
name: "@vahidkaargar/customized-api-client",
|
|
89
|
-
version: "0.
|
|
94
|
+
version: "0.3.0",
|
|
90
95
|
description: "TypeScript Axios client for JSON:API v1.1 with idempotency, retries, and normalized results",
|
|
91
96
|
type: "module",
|
|
92
97
|
engines: {
|
|
@@ -170,14 +175,29 @@ function applyJsonApiHeaders(config, method) {
|
|
|
170
175
|
const m = method.toUpperCase();
|
|
171
176
|
const headers = { ...config.headers };
|
|
172
177
|
headers.Accept = headers.Accept ?? JSON_API;
|
|
173
|
-
if (
|
|
178
|
+
if (shouldSetJsonApiContentType(m, config)) {
|
|
174
179
|
headers["Content-Type"] = headers["Content-Type"] ?? JSON_API;
|
|
175
180
|
}
|
|
176
181
|
return { ...config, headers };
|
|
177
182
|
}
|
|
178
|
-
function
|
|
183
|
+
function shouldSetJsonApiContentType(method, config) {
|
|
179
184
|
if (!["POST", "PATCH", "PUT"].includes(method)) return false;
|
|
180
|
-
|
|
185
|
+
const data = config.data;
|
|
186
|
+
if (data === void 0 || data === null) return false;
|
|
187
|
+
return isJsonApiSerializableBody(data);
|
|
188
|
+
}
|
|
189
|
+
function isJsonApiSerializableBody(data) {
|
|
190
|
+
if (typeof data !== "object") return false;
|
|
191
|
+
if (Array.isArray(data)) return true;
|
|
192
|
+
if (typeof FormData !== "undefined" && data instanceof FormData) return false;
|
|
193
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) return false;
|
|
194
|
+
if (data instanceof ArrayBuffer) return false;
|
|
195
|
+
if (ArrayBuffer.isView(data)) return false;
|
|
196
|
+
if (typeof URLSearchParams !== "undefined" && data instanceof URLSearchParams) return false;
|
|
197
|
+
if (data instanceof Date) return false;
|
|
198
|
+
if (typeof ReadableStream !== "undefined" && data instanceof ReadableStream) return false;
|
|
199
|
+
const proto = Object.getPrototypeOf(data);
|
|
200
|
+
return proto === Object.prototype || proto === null;
|
|
181
201
|
}
|
|
182
202
|
|
|
183
203
|
// src/headers/auth.ts
|
|
@@ -261,6 +281,9 @@ function retryAllowed(ctx) {
|
|
|
261
281
|
return false;
|
|
262
282
|
}
|
|
263
283
|
if (!isRead) {
|
|
284
|
+
if (ctx.retryMutationsOnServerError === true && s >= 500 && s < 600) {
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
264
287
|
return false;
|
|
265
288
|
}
|
|
266
289
|
if (s === 408) return true;
|
|
@@ -325,7 +348,8 @@ async function dispatchWithRetry(instance, config, options) {
|
|
|
325
348
|
method: String(config.method ?? "GET"),
|
|
326
349
|
status,
|
|
327
350
|
primaryErrorCode: primary,
|
|
328
|
-
isNetworkError: false
|
|
351
|
+
isNetworkError: false,
|
|
352
|
+
retryMutationsOnServerError: options.retry?.retryMutationsOnServerError
|
|
329
353
|
});
|
|
330
354
|
if (allowed && attempt < max - 1) {
|
|
331
355
|
const retryAfter = parseRetryAfterSeconds(flat["retry-after"]);
|
|
@@ -345,7 +369,8 @@ async function dispatchWithRetry(instance, config, options) {
|
|
|
345
369
|
const isNet = isAxiosNetworkError(err);
|
|
346
370
|
const allowed = retryAllowed({
|
|
347
371
|
method: String(config.method ?? "GET"),
|
|
348
|
-
isNetworkError: isNet
|
|
372
|
+
isNetworkError: isNet,
|
|
373
|
+
retryMutationsOnServerError: options.retry?.retryMutationsOnServerError
|
|
349
374
|
});
|
|
350
375
|
if (!allowed || attempt >= max - 1) {
|
|
351
376
|
throw err;
|
|
@@ -840,6 +865,9 @@ function createApiClient(config) {
|
|
|
840
865
|
async post(path, data, opts) {
|
|
841
866
|
return perform("POST", resolvePath(path), { ...opts, data });
|
|
842
867
|
},
|
|
868
|
+
async postFormData(path, data, opts) {
|
|
869
|
+
return perform("POST", resolvePath(path), { ...opts, data });
|
|
870
|
+
},
|
|
843
871
|
async patch(path, data, opts) {
|
|
844
872
|
return perform("PATCH", resolvePath(path), { ...opts, data });
|
|
845
873
|
},
|
|
@@ -872,20 +900,42 @@ function createApiClient(config) {
|
|
|
872
900
|
ifMatchVersion: version
|
|
873
901
|
});
|
|
874
902
|
},
|
|
875
|
-
safeGet: (path, opts) => safe(() =>
|
|
876
|
-
safePost: (path, data, opts) => safe(() =>
|
|
877
|
-
safePatch: (path, data, opts) => safe(() =>
|
|
878
|
-
safePut: (path, data, opts) => safe(() =>
|
|
879
|
-
safeDelete: (path, opts) => safe(() =>
|
|
880
|
-
safeHead: (path, opts) => safe(() =>
|
|
881
|
-
safeRequest: (ax, opts) => safe(() =>
|
|
882
|
-
|
|
883
|
-
|
|
903
|
+
safeGet: (path, opts) => safe(() => perform("GET", resolvePath(path), opts ?? {})),
|
|
904
|
+
safePost: (path, data, opts) => safe(() => perform("POST", resolvePath(path), { ...opts, data })),
|
|
905
|
+
safePatch: (path, data, opts) => safe(() => perform("PATCH", resolvePath(path), { ...opts, data })),
|
|
906
|
+
safePut: (path, data, opts) => safe(() => perform("PUT", resolvePath(path), { ...opts, data })),
|
|
907
|
+
safeDelete: (path, opts) => safe(() => perform("DELETE", resolvePath(path), opts ?? {})),
|
|
908
|
+
safeHead: (path, opts) => safe(() => perform("HEAD", resolvePath(path), opts ?? {})),
|
|
909
|
+
safeRequest: (ax, opts) => safe(() => {
|
|
910
|
+
const method = (ax.method ?? "GET").toUpperCase();
|
|
911
|
+
const rawUrl = ax.url ?? "/";
|
|
912
|
+
const u = typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) ? rawUrl : resolvePath(rawUrl);
|
|
913
|
+
return perform(method, u, {
|
|
914
|
+
...opts,
|
|
915
|
+
data: ax.data,
|
|
916
|
+
idempotencyKey: opts?.idempotencyKey ?? readHeader(ax, "Idempotency-Key")
|
|
917
|
+
});
|
|
918
|
+
}),
|
|
919
|
+
safeGetByUrl: (fullUrl, opts) => safe(() => perform("GET", fullUrl, opts ?? {})),
|
|
920
|
+
safePatchWithVersion: (path, data, version, opts) => safe(
|
|
921
|
+
() => perform("PATCH", resolvePath(path), {
|
|
922
|
+
...opts,
|
|
923
|
+
data,
|
|
924
|
+
ifMatchVersion: version
|
|
925
|
+
})
|
|
926
|
+
)
|
|
884
927
|
};
|
|
885
928
|
return client;
|
|
886
929
|
}
|
|
887
930
|
|
|
888
931
|
// src/guards.ts
|
|
932
|
+
function hasErrorCode(error, code) {
|
|
933
|
+
if (!(error instanceof ApiClientError)) return false;
|
|
934
|
+
return error.errors.some((e) => e.code === code);
|
|
935
|
+
}
|
|
936
|
+
function isApiClientErrorWithCode(error, code) {
|
|
937
|
+
return hasErrorCode(error, code);
|
|
938
|
+
}
|
|
889
939
|
function isAuthenticationError(e) {
|
|
890
940
|
return isApiErr(e, 401);
|
|
891
941
|
}
|
|
@@ -895,6 +945,15 @@ function isForbiddenError(e) {
|
|
|
895
945
|
function isPreconditionRequiredError(e) {
|
|
896
946
|
return isApiErr(e, 428);
|
|
897
947
|
}
|
|
948
|
+
function isIdempotencyKeyRequiredError(e) {
|
|
949
|
+
return isApiErrWithCode(e, 428, "IDEMPOTENCY_KEY_REQUIRED");
|
|
950
|
+
}
|
|
951
|
+
function isIfMatchRequiredError(e) {
|
|
952
|
+
return isApiErrWithCode(e, 428, "IF_MATCH_REQUIRED");
|
|
953
|
+
}
|
|
954
|
+
function isMfaVerificationRequiredError(e) {
|
|
955
|
+
return isApiErrWithCode(e, 428, "MFA_VERIFICATION_REQUIRED");
|
|
956
|
+
}
|
|
898
957
|
function isPreconditionFailedError(e) {
|
|
899
958
|
return isApiErr(e, 412);
|
|
900
959
|
}
|
|
@@ -907,18 +966,22 @@ function isConflictError(e) {
|
|
|
907
966
|
function isPayloadTooLargeError(e) {
|
|
908
967
|
return isApiErr(e, 413);
|
|
909
968
|
}
|
|
910
|
-
function isRetryablePerPolicy(e) {
|
|
969
|
+
function isRetryablePerPolicy(e, policy) {
|
|
911
970
|
if (!(e instanceof ApiClientError)) return false;
|
|
912
971
|
return retryAllowed({
|
|
913
972
|
method: e.requestMethod ?? "GET",
|
|
914
973
|
status: e.status,
|
|
915
974
|
primaryErrorCode: e.primaryCode,
|
|
916
|
-
isNetworkError: false
|
|
975
|
+
isNetworkError: false,
|
|
976
|
+
retryMutationsOnServerError: policy?.retryMutationsOnServerError
|
|
917
977
|
});
|
|
918
978
|
}
|
|
919
979
|
function isApiErr(e, status) {
|
|
920
980
|
return e instanceof ApiClientError && e.status === status;
|
|
921
981
|
}
|
|
982
|
+
function isApiErrWithCode(e, status, code) {
|
|
983
|
+
return isApiErr(e, status) && hasErrorCode(e, code);
|
|
984
|
+
}
|
|
922
985
|
|
|
923
986
|
// src/parse/pagination.ts
|
|
924
987
|
function parsePaginationKind(meta, links) {
|
|
@@ -1131,11 +1194,16 @@ var PACKAGE_VERSION = package_default.version;
|
|
|
1131
1194
|
getHeader,
|
|
1132
1195
|
getNextPageUrl,
|
|
1133
1196
|
groupValidationErrorsByPointer,
|
|
1197
|
+
hasErrorCode,
|
|
1134
1198
|
indexIncluded,
|
|
1135
1199
|
isApiClientError,
|
|
1200
|
+
isApiClientErrorWithCode,
|
|
1136
1201
|
isAuthenticationError,
|
|
1137
1202
|
isConflictError,
|
|
1138
1203
|
isForbiddenError,
|
|
1204
|
+
isIdempotencyKeyRequiredError,
|
|
1205
|
+
isIfMatchRequiredError,
|
|
1206
|
+
isMfaVerificationRequiredError,
|
|
1139
1207
|
isMutationMethod,
|
|
1140
1208
|
isPayloadTooLargeError,
|
|
1141
1209
|
isPreconditionFailedError,
|