@vahidkaargar/customized-api-client 0.3.0 → 0.5.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
@@ -229,7 +229,8 @@ Client-level options for `createApiClient({ … })`:
229
229
  | `defaultHeaders` | — | Merged into every request |
230
230
  | `retry` | see [Retries](#retries) | `maxAttempts`, backoff, jitter |
231
231
  | `generateIdempotencyKey` | ULID | Factory for mutation keys |
232
- | `getAcceptLanguage` | — | Sets `Accept-Language` when non-empty |
232
+ | `locale` | — | `getLocale`, `defaultLocale`, `onLocaleMismatch` see [Locale](#locale-accept-language--content-language) |
233
+ | `getAcceptLanguage` | — | **Deprecated** — use `locale.getLocale`; sets `Accept-Language` when non-empty |
233
234
  | `onIdempotencyReplay` | — | Fired when `Idempotent-Replayed: true` |
234
235
  | `onUnauthorized` | — | Fired on normalized **401** |
235
236
  | `onDeprecated` | — | Deprecation / sunset headers |
@@ -247,6 +248,30 @@ auth: { type: 'partner-bearer', getSecret: () => process.env.PARTNER_SECRET }
247
248
 
248
249
  If `getToken` / `getSecret` returns `null` or `undefined`, no `Authorization` header is sent.
249
250
 
251
+ ### Locale (Accept-Language / Content-Language)
252
+
253
+ Configure locale once on the client (e.g. for backends with `SetLocaleMiddleware`). This package only sets HTTP headers and optional mismatch reporting — not vue-i18n, `GET /locales`, or `GET /translations`.
254
+
255
+ ```typescript
256
+ const client = createApiClient({
257
+ baseURL: 'https://api.example.com/api/v1',
258
+ locale: {
259
+ getLocale: () => getStoredLocale(), // 'en' | 'fr' | 'fa'
260
+ defaultLocale: 'en', // omit Accept-Language when UI locale is English (server default)
261
+ onLocaleMismatch: import.meta.env.DEV ? 'warn' : undefined,
262
+ },
263
+ });
264
+ ```
265
+
266
+ | Behavior | Detail |
267
+ |----------|--------|
268
+ | **Accept-Language** | From `locale.getLocale()` on every request via this client |
269
+ | **Omit for default** | When resolved locale matches `defaultLocale` (primary subtag), header is not sent |
270
+ | **Content-Language** | Exposed on success as `res.headers.contentLanguage` |
271
+ | **Mismatch** | If response `Content-Language` differs from requested locale (base tag: `fr` vs `fr-FR` match), `'warn'` or your callback runs — UI locale is never changed |
272
+
273
+ Legacy `getAcceptLanguage` still works; `locale.getLocale` takes precedence when both are set.
274
+
250
275
  ---
251
276
 
252
277
  ## Making requests
@@ -322,6 +347,8 @@ Synthetic codes when the body is missing or invalid: `EMPTY_ERROR_BODY`, `INVALI
322
347
  | `hasErrorCode` / `isApiClientErrorWithCode` | matching `errors[].code` |
323
348
  | `isPreconditionFailedError` | 412 |
324
349
  | `isConflictError` | 409 |
350
+ | `isIdempotencyKeyReusedError` | **409** + `IDEMPOTENCY_KEY_REUSED` |
351
+ | `isIdempotencyInProgressError` | **409** + `IDEMPOTENCY_REQUEST_IN_PROGRESS` |
325
352
  | `isPayloadTooLargeError` | 413 |
326
353
  | `isRetryablePerPolicy` | Would retry per client policy (UI hints); pass `{ retryMutationsOnServerError: true }` to match clients that opt into mutation **5xx** retries |
327
354
 
@@ -329,12 +356,46 @@ Synthetic codes when the body is missing or invalid: `EMPTY_ERROR_BODY`, `INVALI
329
356
 
330
357
  ## Idempotency
331
358
 
359
+ ### Transport (this client)
360
+
332
361
  - **POST, PATCH, PUT, DELETE** send **`Idempotency-Key`** (ULID per request by default).
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.
362
+ - Retries **inside one** `client.post()` / `patch()` / … reuse the **same key and body** (`dispatchWithRetry`).
335
363
  - Server replay → header `Idempotent-Replayed: true` → `headers.idempotentReplayed` + optional `onIdempotencyReplay`.
336
364
  - **GET / HEAD** never send idempotency keys.
337
365
 
366
+ ### Intent (your app)
367
+
368
+ Separate user actions (Save, Retry button, confirm dialog) are **separate client calls**. Reuse the same key only when retrying the **same intent** after abort/network/`IDEMPOTENCY_REQUEST_IN_PROGRESS`; rotate after validation fixes or conflicting payload.
369
+
370
+ ```typescript
371
+ import {
372
+ createApiClient,
373
+ createIdempotencyIntent,
374
+ idempotencyRotationForRetry,
375
+ } from '@vahidkaargar/customized-api-client';
376
+
377
+ const client = createApiClient({ baseURL: '…' });
378
+ const intent = createIdempotencyIntent();
379
+
380
+ async function save(rotation: 'reuse' | 'rotate' = 'rotate') {
381
+ await client.patch('/widgets/42', payload, {
382
+ idempotencyKey: intent.keyFor(rotation),
383
+ });
384
+ intent.complete();
385
+ }
386
+
387
+ // UI Retry after network → save('reuse')
388
+ // Next Save after 422 → save('rotate') or save(idempotencyRotationForRetry(lastError))
389
+ ```
390
+
391
+ | Helper | Role |
392
+ |--------|------|
393
+ | `createIdempotencyIntent()` | `begin` / `keyFor('reuse'\|'rotate')` / `complete` / `abandon` |
394
+ | `idempotencyRotationForRetry(error)` | Suggested `'reuse'` vs `'rotate'` from an error |
395
+ | `createMutationIdempotency` | Deprecated alias of `createIdempotencyIntent` |
396
+
397
+ **412 If-Match** is not idempotency rotation — refresh `If-Match` version first, then retry with `'reuse'` if the payload is unchanged.
398
+
338
399
  ---
339
400
 
340
401
  ## Optimistic concurrency
package/dist/index.cjs CHANGED
@@ -35,6 +35,7 @@ __export(index_exports, {
35
35
  DEFAULT_TIMEOUT_MS: () => DEFAULT_TIMEOUT_MS,
36
36
  IDEMPOTENCY_MAX_LENGTH: () => IDEMPOTENCY_MAX_LENGTH,
37
37
  PACKAGE_VERSION: () => PACKAGE_VERSION,
38
+ acceptLanguageForRequest: () => acceptLanguageForRequest,
38
39
  applyJsonApiHeaders: () => applyJsonApiHeaders,
39
40
  applyTransformKeys: () => applyTransformKeys,
40
41
  assertValidIdempotencyKey: () => assertValidIdempotencyKey,
@@ -43,6 +44,8 @@ __export(index_exports, {
43
44
  buildOffsetPageParams: () => buildOffsetPageParams,
44
45
  createApiClient: () => createApiClient,
45
46
  createHealthCheck: () => createHealthCheck,
47
+ createIdempotencyIntent: () => createIdempotencyIntent,
48
+ createMutationIdempotency: () => createMutationIdempotency,
46
49
  defaultIdempotencyKey: () => defaultIdempotencyKey,
47
50
  dispatchWithRetry: () => dispatchWithRetry,
48
51
  etagFromResponseHeaders: () => etagFromResponseHeaders,
@@ -52,13 +55,16 @@ __export(index_exports, {
52
55
  getNextPageUrl: () => getNextPageUrl,
53
56
  groupValidationErrorsByPointer: () => groupValidationErrorsByPointer,
54
57
  hasErrorCode: () => hasErrorCode,
58
+ idempotencyRotationForRetry: () => idempotencyRotationForRetry,
55
59
  indexIncluded: () => indexIncluded,
56
60
  isApiClientError: () => isApiClientError,
57
61
  isApiClientErrorWithCode: () => isApiClientErrorWithCode,
58
62
  isAuthenticationError: () => isAuthenticationError,
59
63
  isConflictError: () => isConflictError,
60
64
  isForbiddenError: () => isForbiddenError,
65
+ isIdempotencyInProgressError: () => isIdempotencyInProgressError,
61
66
  isIdempotencyKeyRequiredError: () => isIdempotencyKeyRequiredError,
67
+ isIdempotencyKeyReusedError: () => isIdempotencyKeyReusedError,
62
68
  isIfMatchRequiredError: () => isIfMatchRequiredError,
63
69
  isMfaVerificationRequiredError: () => isMfaVerificationRequiredError,
64
70
  isMutationMethod: () => isMutationMethod,
@@ -67,8 +73,12 @@ __export(index_exports, {
67
73
  isPreconditionRequiredError: () => isPreconditionRequiredError,
68
74
  isRetryablePerPolicy: () => isRetryablePerPolicy,
69
75
  isValidationError: () => isValidationError,
76
+ localesMatch: () => localesMatch,
70
77
  normalizeAxiosResponse: () => normalizeAxiosResponse,
71
78
  normalizeHttpUrl: () => normalizeHttpUrl,
79
+ normalizeLocaleCode: () => normalizeLocaleCode,
80
+ notifyLocaleMismatch: () => notifyLocaleMismatch,
81
+ parseContentLanguage: () => parseContentLanguage,
72
82
  parseDeprecationHeaders: () => parseDeprecationHeaders,
73
83
  parseJsonApiDocument: () => parseJsonApiDocument,
74
84
  parseJsonApiErrorBody: () => parseJsonApiErrorBody,
@@ -77,11 +87,14 @@ __export(index_exports, {
77
87
  parseRetryAfterSeconds: () => parseRetryAfterSeconds,
78
88
  pollAsyncResult: () => pollAsyncResult,
79
89
  readResourceVersion: () => readResourceVersion,
90
+ readResponseContentLanguage: () => readResponseContentLanguage,
80
91
  redactHeaderRecord: () => redactHeaderRecord,
81
92
  resolveAcceptLanguage: () => resolveAcceptLanguage,
82
93
  resolveAcceptedLocation: () => resolveAcceptedLocation,
83
94
  resolveAuthorizationHeader: () => resolveAuthorizationHeader,
84
95
  resolveIncluded: () => resolveIncluded,
96
+ resolveLocaleProvider: () => resolveLocaleProvider,
97
+ resolveRequestLocale: () => resolveRequestLocale,
85
98
  resolveResourcePath: () => resolveResourcePath,
86
99
  retryAllowed: () => retryAllowed,
87
100
  truncateForLog: () => truncateForLog
@@ -91,7 +104,7 @@ module.exports = __toCommonJS(index_exports);
91
104
  // package.json
92
105
  var package_default = {
93
106
  name: "@vahidkaargar/customized-api-client",
94
- version: "0.3.0",
107
+ version: "0.5.0",
95
108
  description: "TypeScript Axios client for JSON:API v1.1 with idempotency, retries, and normalized results",
96
109
  type: "module",
97
110
  engines: {
@@ -213,11 +226,85 @@ async function resolveAuthorizationHeader(auth) {
213
226
  return `Bearer ${s}`;
214
227
  }
215
228
 
229
+ // src/http/header-utils.ts
230
+ function flattenAxiosHeaders(headers) {
231
+ if (!headers) return {};
232
+ if (typeof headers.forEach === "function") {
233
+ const out2 = {};
234
+ headers.forEach((value, key) => {
235
+ out2[key.toLowerCase()] = value;
236
+ });
237
+ return out2;
238
+ }
239
+ const o = headers;
240
+ const out = {};
241
+ for (const [k, v] of Object.entries(o)) {
242
+ if (typeof v === "string") out[k.toLowerCase()] = v;
243
+ else if (Array.isArray(v) && v[0]) out[k.toLowerCase()] = v[0];
244
+ }
245
+ return out;
246
+ }
247
+ function getHeader(headers, name) {
248
+ return headers[name.toLowerCase()];
249
+ }
250
+
216
251
  // src/headers/locale.ts
252
+ function normalizeLocaleCode(tag) {
253
+ if (tag === void 0) return void 0;
254
+ const trimmed = tag.trim();
255
+ if (!trimmed) return void 0;
256
+ const base = trimmed.split(/[-_]/)[0]?.trim();
257
+ return base ? base.toLowerCase() : void 0;
258
+ }
259
+ function parseContentLanguage(header) {
260
+ if (header === void 0) return void 0;
261
+ const first = header.split(",")[0]?.trim();
262
+ if (!first) return void 0;
263
+ const tag = first.split(";")[0]?.trim();
264
+ return tag && tag.length > 0 ? tag : void 0;
265
+ }
266
+ function localesMatch(a, b) {
267
+ const na = normalizeLocaleCode(a);
268
+ const nb = normalizeLocaleCode(b);
269
+ if (na === void 0 || nb === void 0) return false;
270
+ return na === nb;
271
+ }
272
+ function resolveLocaleProvider(getAcceptLanguage, locale) {
273
+ return locale?.getLocale ?? getAcceptLanguage;
274
+ }
275
+ async function resolveRequestLocale(getAcceptLanguage, locale) {
276
+ return resolveAcceptLanguage(resolveLocaleProvider(getAcceptLanguage, locale));
277
+ }
278
+ function acceptLanguageForRequest(resolved, defaultLocale) {
279
+ if (resolved === void 0) return void 0;
280
+ if (defaultLocale !== void 0 && localesMatch(resolved, defaultLocale)) {
281
+ return void 0;
282
+ }
283
+ return resolved;
284
+ }
217
285
  async function resolveAcceptLanguage(provider) {
218
286
  if (!provider) return void 0;
219
287
  const v = await provider();
220
- return v ?? void 0;
288
+ if (v === null || v === void 0) return void 0;
289
+ const trimmed = v.trim();
290
+ return trimmed.length > 0 ? trimmed : void 0;
291
+ }
292
+ function readResponseContentLanguage(flatHeaders) {
293
+ return parseContentLanguage(getHeader(flatHeaders, "content-language"));
294
+ }
295
+ function notifyLocaleMismatch(locale, ctx) {
296
+ const handler = locale?.onLocaleMismatch;
297
+ if (!handler) return;
298
+ if (ctx.requested === void 0) return;
299
+ if (localesMatch(ctx.requested, ctx.resolved)) return;
300
+ if (handler === "warn") {
301
+ console.warn(
302
+ "[@vahidkaargar/customized-api-client] Content-Language mismatch",
303
+ ctx
304
+ );
305
+ return;
306
+ }
307
+ handler(ctx);
221
308
  }
222
309
 
223
310
  // src/headers/idempotency.ts
@@ -305,28 +392,6 @@ function parseRetryAfterSeconds(value) {
305
392
  return void 0;
306
393
  }
307
394
 
308
- // src/http/header-utils.ts
309
- function flattenAxiosHeaders(headers) {
310
- if (!headers) return {};
311
- if (typeof headers.forEach === "function") {
312
- const out2 = {};
313
- headers.forEach((value, key) => {
314
- out2[key.toLowerCase()] = value;
315
- });
316
- return out2;
317
- }
318
- const o = headers;
319
- const out = {};
320
- for (const [k, v] of Object.entries(o)) {
321
- if (typeof v === "string") out[k.toLowerCase()] = v;
322
- else if (Array.isArray(v) && v[0]) out[k.toLowerCase()] = v[0];
323
- }
324
- return out;
325
- }
326
- function getHeader(headers, name) {
327
- return headers[name.toLowerCase()];
328
- }
329
-
330
395
  // src/retry/execute-with-retry.ts
331
396
  var DEFAULT_MAX_ATTEMPTS = 4;
332
397
  var DEFAULT_BASE_MS = 200;
@@ -750,6 +815,7 @@ function createApiClient(config) {
750
815
  const mode = config.baseUrlMode ?? "modeB";
751
816
  const genKey = config.generateIdempotencyKey ?? defaultIdempotencyKey;
752
817
  warnInsecureBaseUrl(config.baseURL);
818
+ const localeByRequest = /* @__PURE__ */ new WeakMap();
753
819
  const instance = import_axios.default.create({
754
820
  baseURL: config.baseURL,
755
821
  timeout: config.timeout ?? DEFAULT_TIMEOUT_MS,
@@ -766,9 +832,15 @@ function createApiClient(config) {
766
832
  if (authHeader) {
767
833
  next.headers.Authorization = authHeader;
768
834
  }
769
- const lang = await resolveAcceptLanguage(config.getAcceptLanguage);
770
- if (lang) {
771
- next.headers["Accept-Language"] = lang;
835
+ const resolved = await resolveRequestLocale(
836
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- legacy `getAcceptLanguage` fallback
837
+ config.getAcceptLanguage,
838
+ config.locale
839
+ );
840
+ localeByRequest.set(next, resolved);
841
+ const toSend = acceptLanguageForRequest(resolved, config.locale?.defaultLocale);
842
+ if (toSend) {
843
+ next.headers["Accept-Language"] = toSend;
772
844
  }
773
845
  if (isMutationMethod(method)) {
774
846
  const h = next.headers;
@@ -794,6 +866,17 @@ function createApiClient(config) {
794
866
  if (dep && config.onDeprecated) {
795
867
  config.onDeprecated(dep);
796
868
  }
869
+ const contentLang = readResponseContentLanguage(flat);
870
+ if (contentLang) {
871
+ notifyLocaleMismatch(config.locale, {
872
+ requested: localeByRequest.get(res.config),
873
+ resolved: contentLang,
874
+ /* v8 ignore start -- @preserve axios config url/method are strings */
875
+ url: typeof res.config.url === "string" ? res.config.url : void 0,
876
+ method: typeof res.config.method === "string" ? res.config.method : void 0
877
+ /* v8 ignore stop -- @preserve */
878
+ });
879
+ }
797
880
  return res;
798
881
  },
799
882
  (err) => Promise.reject(
@@ -963,6 +1046,12 @@ function isValidationError(e) {
963
1046
  function isConflictError(e) {
964
1047
  return isApiErr(e, 409);
965
1048
  }
1049
+ function isIdempotencyKeyReusedError(e) {
1050
+ return isApiErrWithCode(e, 409, "IDEMPOTENCY_KEY_REUSED");
1051
+ }
1052
+ function isIdempotencyInProgressError(e) {
1053
+ return isApiErrWithCode(e, 409, "IDEMPOTENCY_REQUEST_IN_PROGRESS");
1054
+ }
966
1055
  function isPayloadTooLargeError(e) {
967
1056
  return isApiErr(e, 413);
968
1057
  }
@@ -983,6 +1072,59 @@ function isApiErrWithCode(e, status, code) {
983
1072
  return isApiErr(e, status) && hasErrorCode(e, code);
984
1073
  }
985
1074
 
1075
+ // src/idempotency/intent.ts
1076
+ function createIdempotencyIntent(options) {
1077
+ const generateKey = options?.generateKey ?? defaultIdempotencyKey;
1078
+ let activeKey = null;
1079
+ return {
1080
+ get activeKey() {
1081
+ return activeKey;
1082
+ },
1083
+ hasActiveIntent() {
1084
+ return activeKey !== null;
1085
+ },
1086
+ begin() {
1087
+ activeKey = generateKey();
1088
+ return activeKey;
1089
+ },
1090
+ keyFor(rotation) {
1091
+ if (rotation === "rotate") {
1092
+ activeKey = generateKey();
1093
+ return activeKey;
1094
+ }
1095
+ if (activeKey === null) {
1096
+ activeKey = generateKey();
1097
+ return activeKey;
1098
+ }
1099
+ return activeKey;
1100
+ },
1101
+ complete() {
1102
+ activeKey = null;
1103
+ },
1104
+ abandon() {
1105
+ activeKey = null;
1106
+ }
1107
+ };
1108
+ }
1109
+ var createMutationIdempotency = createIdempotencyIntent;
1110
+
1111
+ // src/idempotency/rotation.ts
1112
+ function idempotencyRotationForRetry(error) {
1113
+ if (!(error instanceof ApiClientError)) {
1114
+ return "reuse";
1115
+ }
1116
+ if (isIdempotencyInProgressError(error)) {
1117
+ return "reuse";
1118
+ }
1119
+ if (isIdempotencyKeyReusedError(error)) {
1120
+ return "rotate";
1121
+ }
1122
+ if (isValidationError(error)) {
1123
+ return "rotate";
1124
+ }
1125
+ return "reuse";
1126
+ }
1127
+
986
1128
  // src/parse/pagination.ts
987
1129
  function parsePaginationKind(meta, links) {
988
1130
  const m = meta ?? {};
@@ -1178,6 +1320,7 @@ var PACKAGE_VERSION = package_default.version;
1178
1320
  DEFAULT_TIMEOUT_MS,
1179
1321
  IDEMPOTENCY_MAX_LENGTH,
1180
1322
  PACKAGE_VERSION,
1323
+ acceptLanguageForRequest,
1181
1324
  applyJsonApiHeaders,
1182
1325
  applyTransformKeys,
1183
1326
  assertValidIdempotencyKey,
@@ -1186,6 +1329,8 @@ var PACKAGE_VERSION = package_default.version;
1186
1329
  buildOffsetPageParams,
1187
1330
  createApiClient,
1188
1331
  createHealthCheck,
1332
+ createIdempotencyIntent,
1333
+ createMutationIdempotency,
1189
1334
  defaultIdempotencyKey,
1190
1335
  dispatchWithRetry,
1191
1336
  etagFromResponseHeaders,
@@ -1195,13 +1340,16 @@ var PACKAGE_VERSION = package_default.version;
1195
1340
  getNextPageUrl,
1196
1341
  groupValidationErrorsByPointer,
1197
1342
  hasErrorCode,
1343
+ idempotencyRotationForRetry,
1198
1344
  indexIncluded,
1199
1345
  isApiClientError,
1200
1346
  isApiClientErrorWithCode,
1201
1347
  isAuthenticationError,
1202
1348
  isConflictError,
1203
1349
  isForbiddenError,
1350
+ isIdempotencyInProgressError,
1204
1351
  isIdempotencyKeyRequiredError,
1352
+ isIdempotencyKeyReusedError,
1205
1353
  isIfMatchRequiredError,
1206
1354
  isMfaVerificationRequiredError,
1207
1355
  isMutationMethod,
@@ -1210,8 +1358,12 @@ var PACKAGE_VERSION = package_default.version;
1210
1358
  isPreconditionRequiredError,
1211
1359
  isRetryablePerPolicy,
1212
1360
  isValidationError,
1361
+ localesMatch,
1213
1362
  normalizeAxiosResponse,
1214
1363
  normalizeHttpUrl,
1364
+ normalizeLocaleCode,
1365
+ notifyLocaleMismatch,
1366
+ parseContentLanguage,
1215
1367
  parseDeprecationHeaders,
1216
1368
  parseJsonApiDocument,
1217
1369
  parseJsonApiErrorBody,
@@ -1220,11 +1372,14 @@ var PACKAGE_VERSION = package_default.version;
1220
1372
  parseRetryAfterSeconds,
1221
1373
  pollAsyncResult,
1222
1374
  readResourceVersion,
1375
+ readResponseContentLanguage,
1223
1376
  redactHeaderRecord,
1224
1377
  resolveAcceptLanguage,
1225
1378
  resolveAcceptedLocation,
1226
1379
  resolveAuthorizationHeader,
1227
1380
  resolveIncluded,
1381
+ resolveLocaleProvider,
1382
+ resolveRequestLocale,
1228
1383
  resolveResourcePath,
1229
1384
  retryAllowed,
1230
1385
  truncateForLog