@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 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
- Disable: `retry: { maxAttempts: 1 }`. Inspect logic: `retryAllowed({ })`.
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 MeResponse = operations['getMe']['responses'][200]['content']['application/vnd.api+json'];
441
- const me = await client.get<MeResponse>('/me');
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.2.3",
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 (hasJsonBody(m, config)) {
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 hasJsonBody(method, config) {
183
+ function shouldSetJsonApiContentType(method, config) {
179
184
  if (!["POST", "PATCH", "PUT"].includes(method)) return false;
180
- return config.data !== void 0 && config.data !== null;
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(() => client.get(path, opts)),
876
- safePost: (path, data, opts) => safe(() => client.post(path, data, opts)),
877
- safePatch: (path, data, opts) => safe(() => client.patch(path, data, opts)),
878
- safePut: (path, data, opts) => safe(() => client.put(path, data, opts)),
879
- safeDelete: (path, opts) => safe(() => client.delete(path, opts)),
880
- safeHead: (path, opts) => safe(() => client.head(path, opts)),
881
- safeRequest: (ax, opts) => safe(() => client.request(ax, opts)),
882
- safeGetByUrl: (url, opts) => safe(() => client.getByUrl(url, opts)),
883
- safePatchWithVersion: (path, data, version, opts) => safe(() => client.patchWithVersion(path, data, version, opts))
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,