@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/dist/index.d.ts CHANGED
@@ -26,11 +26,31 @@ interface IdempotencyReplayContext {
26
26
  readonly url?: string;
27
27
  readonly method?: string;
28
28
  }
29
+ interface LocaleMismatchContext {
30
+ /** Locale from `getLocale` before default omission. */
31
+ readonly requested?: string;
32
+ /** `Content-Language` from the response. */
33
+ readonly resolved: string;
34
+ readonly url?: string;
35
+ readonly method?: string;
36
+ }
37
+ interface LocaleClientOptions {
38
+ readonly getLocale?: () => string | null | undefined | Promise<string | null | undefined>;
39
+ /**
40
+ * When the resolved locale matches this value (primary subtag), `Accept-Language` is omitted.
41
+ */
42
+ readonly defaultLocale?: string;
43
+ readonly onLocaleMismatch?: 'warn' | ((ctx: Readonly<LocaleMismatchContext>) => void);
44
+ }
29
45
  interface ApiClientConfig {
30
46
  readonly baseURL: string;
31
47
  /** Default Mode B — see `BaseUrlMode`. */
32
48
  readonly baseUrlMode?: BaseUrlMode;
33
49
  readonly auth?: AuthConfig;
50
+ readonly locale?: LocaleClientOptions;
51
+ /**
52
+ * @deprecated Use `locale.getLocale` instead.
53
+ */
34
54
  readonly getAcceptLanguage?: () => string | null | undefined | Promise<string | null | undefined>;
35
55
  readonly defaultHeaders?: Readonly<Record<string, string>>;
36
56
  readonly timeout?: number;
@@ -191,6 +211,10 @@ declare function isMfaVerificationRequiredError(e: unknown): boolean;
191
211
  declare function isPreconditionFailedError(e: unknown): boolean;
192
212
  declare function isValidationError(e: unknown): boolean;
193
213
  declare function isConflictError(e: unknown): boolean;
214
+ /** HTTP **409** + primary `errors[].code === 'IDEMPOTENCY_KEY_REUSED'`. */
215
+ declare function isIdempotencyKeyReusedError(e: unknown): boolean;
216
+ /** HTTP **409** + primary `errors[].code === 'IDEMPOTENCY_REQUEST_IN_PROGRESS'`. */
217
+ declare function isIdempotencyInProgressError(e: unknown): boolean;
194
218
  declare function isPayloadTooLargeError(e: unknown): boolean;
195
219
  declare function isRetryablePerPolicy(e: unknown, policy?: Readonly<{
196
220
  retryMutationsOnServerError?: boolean;
@@ -212,9 +236,63 @@ declare function defaultIdempotencyKey(): string;
212
236
  declare function assertValidIdempotencyKey(key: string): void;
213
237
  declare function isMutationMethod(method: string): boolean;
214
238
 
239
+ /** Whether the next mutation should keep or replace the active idempotency key. */
240
+ type IdempotencyRotation = 'reuse' | 'rotate';
241
+ /** Caller-owned idempotency key lifecycle across separate client invocations. */
242
+ interface IdempotencyIntent {
243
+ readonly activeKey: string | null;
244
+ hasActiveIntent: () => boolean;
245
+ begin: () => string;
246
+ keyFor: (rotation: IdempotencyRotation) => string;
247
+ complete: () => void;
248
+ abandon: () => void;
249
+ }
250
+ interface CreateIdempotencyIntentOptions {
251
+ readonly generateKey?: () => string;
252
+ }
253
+ /**
254
+ * Track one user intent's idempotency key across multiple `client.post`/`patch`/… calls.
255
+ *
256
+ * Transport retries inside a single client call reuse the same key automatically; this helper
257
+ * covers **separate** invocations (e.g. user clicks Retry after a network error).
258
+ */
259
+ declare function createIdempotencyIntent(options?: CreateIdempotencyIntentOptions): IdempotencyIntent;
260
+ /**
261
+ * @deprecated Prefer {@link createIdempotencyIntent}. Alias for app-layer naming compatibility.
262
+ */
263
+ declare const createMutationIdempotency: typeof createIdempotencyIntent;
264
+
265
+ /**
266
+ * Suggested rotation when reacting to an error before retrying the same user action.
267
+ *
268
+ * - Network / abort / no response → reuse
269
+ * - `IDEMPOTENCY_REQUEST_IN_PROGRESS` → reuse (transport may already retry once)
270
+ * - `IDEMPOTENCY_KEY_REUSED` or validation **422** → rotate
271
+ * - Other `ApiClientError` → reuse when payload unchanged (caller may override)
272
+ *
273
+ * **412 If-Match** is not idempotency rotation — refresh version separately.
274
+ */
275
+ declare function idempotencyRotationForRetry(error: unknown): IdempotencyRotation;
276
+
215
277
  declare function formatIfMatch(version: number): string;
216
278
 
217
- declare function resolveAcceptLanguage(provider?: () => string | null | undefined | Promise<string | null | undefined>): Promise<string | undefined>;
279
+ type LocaleProvider = () => string | null | undefined | Promise<string | null | undefined>;
280
+ /** Primary language subtag only: `fr-FR` → `fr`. */
281
+ declare function normalizeLocaleCode(tag: string | undefined): string | undefined;
282
+ /** First language tag from a `Content-Language` (or similar) header value. */
283
+ declare function parseContentLanguage(header: string | undefined): string | undefined;
284
+ /** Compare primary subtags (e.g. `fr-FR` and `fr` match). */
285
+ declare function localesMatch(a: string | undefined, b: string | undefined): boolean;
286
+ declare function resolveLocaleProvider(getAcceptLanguage?: LocaleProvider, locale?: LocaleClientOptions): LocaleProvider | undefined;
287
+ declare function resolveRequestLocale(getAcceptLanguage?: LocaleProvider, locale?: LocaleClientOptions): Promise<string | undefined>;
288
+ /**
289
+ * Value to send as `Accept-Language`, or `undefined` to omit the header
290
+ * when the resolved locale matches `defaultLocale` (normalized).
291
+ */
292
+ declare function acceptLanguageForRequest(resolved: string | undefined, defaultLocale?: string): string | undefined;
293
+ declare function resolveAcceptLanguage(provider?: LocaleProvider): Promise<string | undefined>;
294
+ declare function readResponseContentLanguage(flatHeaders: Readonly<Record<string, string>>): string | undefined;
295
+ declare function notifyLocaleMismatch(locale: LocaleClientOptions | undefined, ctx: Readonly<LocaleMismatchContext>): void;
218
296
 
219
297
  declare function resolveAuthorizationHeader(auth: AuthConfig | undefined): Promise<string | undefined>;
220
298
 
@@ -336,4 +414,4 @@ declare function pollAsyncResult(client: ApiClient, initial: Extract<ClientSucce
336
414
 
337
415
  declare const PACKAGE_VERSION: string;
338
416
 
339
- export { type AcceptedBody, type ApiClient, type ApiClientConfig, ApiClientError, type AuthConfig, type BaseUrlMode, type ClientSuccess, type ClientSuccessWithDocument, type CursorPagination, DEFAULT_PAGE_SIZE_CAP, DEFAULT_TIMEOUT_MS, type DeprecationInfo, type ErrResult, IDEMPOTENCY_MAX_LENGTH, type IdempotencyReplayContext, type IncludedIndex, type JsonApiDocument, type JsonApiErrorDocument, type JsonApiErrorObject, type JsonApiPrimaryData, type JsonApiQueryInput, type JsonApiResourceLinkage, type JsonApiResourceObject, type JsonApiSuccessBody, type MultiStatusBody, type MultiStatusItem, type NoContentBody, type NormalizedResponseHeaders, type OffsetPagination, type OkResult, PACKAGE_VERSION, type PollOptions, type RequestCallOptions, type Result, type RetryOptions, type TokenProvider, type TransformResponseKeysMode, type UnknownPagination, type ValidationGroups, applyJsonApiHeaders, applyTransformKeys, assertValidIdempotencyKey, buildCursorPageParams, buildJsonApiQuery, buildOffsetPageParams, createApiClient, createHealthCheck, defaultIdempotencyKey, dispatchWithRetry, etagFromResponseHeaders, flattenAxiosHeaders, formatIfMatch, getHeader, getNextPageUrl, groupValidationErrorsByPointer, hasErrorCode, indexIncluded, isApiClientError, isApiClientErrorWithCode, isAuthenticationError, isConflictError, isForbiddenError, isIdempotencyKeyRequiredError, isIfMatchRequiredError, isMfaVerificationRequiredError, isMutationMethod, isPayloadTooLargeError, isPreconditionFailedError, isPreconditionRequiredError, isRetryablePerPolicy, isValidationError, normalizeAxiosResponse, normalizeHttpUrl, parseDeprecationHeaders, parseJsonApiDocument, parseJsonApiErrorBody, parseMultiStatusBody, parsePaginationKind, parseRetryAfterSeconds, pollAsyncResult, readResourceVersion, redactHeaderRecord, resolveAcceptLanguage, resolveAcceptedLocation, resolveAuthorizationHeader, resolveIncluded, resolveResourcePath, retryAllowed, truncateForLog };
417
+ export { type AcceptedBody, type ApiClient, type ApiClientConfig, ApiClientError, type AuthConfig, type BaseUrlMode, type ClientSuccess, type ClientSuccessWithDocument, type CreateIdempotencyIntentOptions, type CursorPagination, DEFAULT_PAGE_SIZE_CAP, DEFAULT_TIMEOUT_MS, type DeprecationInfo, type ErrResult, IDEMPOTENCY_MAX_LENGTH, type IdempotencyIntent, type IdempotencyReplayContext, type IdempotencyRotation, type IncludedIndex, type JsonApiDocument, type JsonApiErrorDocument, type JsonApiErrorObject, type JsonApiPrimaryData, type JsonApiQueryInput, type JsonApiResourceLinkage, type JsonApiResourceObject, type JsonApiSuccessBody, type LocaleClientOptions, type LocaleMismatchContext, type LocaleProvider, type MultiStatusBody, type MultiStatusItem, type NoContentBody, type NormalizedResponseHeaders, type OffsetPagination, type OkResult, PACKAGE_VERSION, type PollOptions, type RequestCallOptions, type Result, type RetryOptions, type TokenProvider, type TransformResponseKeysMode, type UnknownPagination, type ValidationGroups, acceptLanguageForRequest, applyJsonApiHeaders, applyTransformKeys, assertValidIdempotencyKey, buildCursorPageParams, buildJsonApiQuery, buildOffsetPageParams, createApiClient, createHealthCheck, createIdempotencyIntent, createMutationIdempotency, defaultIdempotencyKey, dispatchWithRetry, etagFromResponseHeaders, flattenAxiosHeaders, formatIfMatch, getHeader, getNextPageUrl, groupValidationErrorsByPointer, hasErrorCode, idempotencyRotationForRetry, indexIncluded, isApiClientError, isApiClientErrorWithCode, isAuthenticationError, isConflictError, isForbiddenError, isIdempotencyInProgressError, isIdempotencyKeyRequiredError, isIdempotencyKeyReusedError, isIfMatchRequiredError, isMfaVerificationRequiredError, isMutationMethod, isPayloadTooLargeError, isPreconditionFailedError, isPreconditionRequiredError, isRetryablePerPolicy, isValidationError, localesMatch, normalizeAxiosResponse, normalizeHttpUrl, normalizeLocaleCode, notifyLocaleMismatch, parseContentLanguage, parseDeprecationHeaders, parseJsonApiDocument, parseJsonApiErrorBody, parseMultiStatusBody, parsePaginationKind, parseRetryAfterSeconds, pollAsyncResult, readResourceVersion, readResponseContentLanguage, redactHeaderRecord, resolveAcceptLanguage, resolveAcceptedLocation, resolveAuthorizationHeader, resolveIncluded, resolveLocaleProvider, resolveRequestLocale, resolveResourcePath, retryAllowed, truncateForLog };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // package.json
2
2
  var package_default = {
3
3
  name: "@vahidkaargar/customized-api-client",
4
- version: "0.3.0",
4
+ version: "0.5.0",
5
5
  description: "TypeScript Axios client for JSON:API v1.1 with idempotency, retries, and normalized results",
6
6
  type: "module",
7
7
  engines: {
@@ -125,11 +125,85 @@ async function resolveAuthorizationHeader(auth) {
125
125
  return `Bearer ${s}`;
126
126
  }
127
127
 
128
+ // src/http/header-utils.ts
129
+ function flattenAxiosHeaders(headers) {
130
+ if (!headers) return {};
131
+ if (typeof headers.forEach === "function") {
132
+ const out2 = {};
133
+ headers.forEach((value, key) => {
134
+ out2[key.toLowerCase()] = value;
135
+ });
136
+ return out2;
137
+ }
138
+ const o = headers;
139
+ const out = {};
140
+ for (const [k, v] of Object.entries(o)) {
141
+ if (typeof v === "string") out[k.toLowerCase()] = v;
142
+ else if (Array.isArray(v) && v[0]) out[k.toLowerCase()] = v[0];
143
+ }
144
+ return out;
145
+ }
146
+ function getHeader(headers, name) {
147
+ return headers[name.toLowerCase()];
148
+ }
149
+
128
150
  // src/headers/locale.ts
151
+ function normalizeLocaleCode(tag) {
152
+ if (tag === void 0) return void 0;
153
+ const trimmed = tag.trim();
154
+ if (!trimmed) return void 0;
155
+ const base = trimmed.split(/[-_]/)[0]?.trim();
156
+ return base ? base.toLowerCase() : void 0;
157
+ }
158
+ function parseContentLanguage(header) {
159
+ if (header === void 0) return void 0;
160
+ const first = header.split(",")[0]?.trim();
161
+ if (!first) return void 0;
162
+ const tag = first.split(";")[0]?.trim();
163
+ return tag && tag.length > 0 ? tag : void 0;
164
+ }
165
+ function localesMatch(a, b) {
166
+ const na = normalizeLocaleCode(a);
167
+ const nb = normalizeLocaleCode(b);
168
+ if (na === void 0 || nb === void 0) return false;
169
+ return na === nb;
170
+ }
171
+ function resolveLocaleProvider(getAcceptLanguage, locale) {
172
+ return locale?.getLocale ?? getAcceptLanguage;
173
+ }
174
+ async function resolveRequestLocale(getAcceptLanguage, locale) {
175
+ return resolveAcceptLanguage(resolveLocaleProvider(getAcceptLanguage, locale));
176
+ }
177
+ function acceptLanguageForRequest(resolved, defaultLocale) {
178
+ if (resolved === void 0) return void 0;
179
+ if (defaultLocale !== void 0 && localesMatch(resolved, defaultLocale)) {
180
+ return void 0;
181
+ }
182
+ return resolved;
183
+ }
129
184
  async function resolveAcceptLanguage(provider) {
130
185
  if (!provider) return void 0;
131
186
  const v = await provider();
132
- return v ?? void 0;
187
+ if (v === null || v === void 0) return void 0;
188
+ const trimmed = v.trim();
189
+ return trimmed.length > 0 ? trimmed : void 0;
190
+ }
191
+ function readResponseContentLanguage(flatHeaders) {
192
+ return parseContentLanguage(getHeader(flatHeaders, "content-language"));
193
+ }
194
+ function notifyLocaleMismatch(locale, ctx) {
195
+ const handler = locale?.onLocaleMismatch;
196
+ if (!handler) return;
197
+ if (ctx.requested === void 0) return;
198
+ if (localesMatch(ctx.requested, ctx.resolved)) return;
199
+ if (handler === "warn") {
200
+ console.warn(
201
+ "[@vahidkaargar/customized-api-client] Content-Language mismatch",
202
+ ctx
203
+ );
204
+ return;
205
+ }
206
+ handler(ctx);
133
207
  }
134
208
 
135
209
  // src/headers/idempotency.ts
@@ -217,28 +291,6 @@ function parseRetryAfterSeconds(value) {
217
291
  return void 0;
218
292
  }
219
293
 
220
- // src/http/header-utils.ts
221
- function flattenAxiosHeaders(headers) {
222
- if (!headers) return {};
223
- if (typeof headers.forEach === "function") {
224
- const out2 = {};
225
- headers.forEach((value, key) => {
226
- out2[key.toLowerCase()] = value;
227
- });
228
- return out2;
229
- }
230
- const o = headers;
231
- const out = {};
232
- for (const [k, v] of Object.entries(o)) {
233
- if (typeof v === "string") out[k.toLowerCase()] = v;
234
- else if (Array.isArray(v) && v[0]) out[k.toLowerCase()] = v[0];
235
- }
236
- return out;
237
- }
238
- function getHeader(headers, name) {
239
- return headers[name.toLowerCase()];
240
- }
241
-
242
294
  // src/retry/execute-with-retry.ts
243
295
  var DEFAULT_MAX_ATTEMPTS = 4;
244
296
  var DEFAULT_BASE_MS = 200;
@@ -662,6 +714,7 @@ function createApiClient(config) {
662
714
  const mode = config.baseUrlMode ?? "modeB";
663
715
  const genKey = config.generateIdempotencyKey ?? defaultIdempotencyKey;
664
716
  warnInsecureBaseUrl(config.baseURL);
717
+ const localeByRequest = /* @__PURE__ */ new WeakMap();
665
718
  const instance = axios.create({
666
719
  baseURL: config.baseURL,
667
720
  timeout: config.timeout ?? DEFAULT_TIMEOUT_MS,
@@ -678,9 +731,15 @@ function createApiClient(config) {
678
731
  if (authHeader) {
679
732
  next.headers.Authorization = authHeader;
680
733
  }
681
- const lang = await resolveAcceptLanguage(config.getAcceptLanguage);
682
- if (lang) {
683
- next.headers["Accept-Language"] = lang;
734
+ const resolved = await resolveRequestLocale(
735
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- legacy `getAcceptLanguage` fallback
736
+ config.getAcceptLanguage,
737
+ config.locale
738
+ );
739
+ localeByRequest.set(next, resolved);
740
+ const toSend = acceptLanguageForRequest(resolved, config.locale?.defaultLocale);
741
+ if (toSend) {
742
+ next.headers["Accept-Language"] = toSend;
684
743
  }
685
744
  if (isMutationMethod(method)) {
686
745
  const h = next.headers;
@@ -706,6 +765,17 @@ function createApiClient(config) {
706
765
  if (dep && config.onDeprecated) {
707
766
  config.onDeprecated(dep);
708
767
  }
768
+ const contentLang = readResponseContentLanguage(flat);
769
+ if (contentLang) {
770
+ notifyLocaleMismatch(config.locale, {
771
+ requested: localeByRequest.get(res.config),
772
+ resolved: contentLang,
773
+ /* v8 ignore start -- @preserve axios config url/method are strings */
774
+ url: typeof res.config.url === "string" ? res.config.url : void 0,
775
+ method: typeof res.config.method === "string" ? res.config.method : void 0
776
+ /* v8 ignore stop -- @preserve */
777
+ });
778
+ }
709
779
  return res;
710
780
  },
711
781
  (err) => Promise.reject(
@@ -875,6 +945,12 @@ function isValidationError(e) {
875
945
  function isConflictError(e) {
876
946
  return isApiErr(e, 409);
877
947
  }
948
+ function isIdempotencyKeyReusedError(e) {
949
+ return isApiErrWithCode(e, 409, "IDEMPOTENCY_KEY_REUSED");
950
+ }
951
+ function isIdempotencyInProgressError(e) {
952
+ return isApiErrWithCode(e, 409, "IDEMPOTENCY_REQUEST_IN_PROGRESS");
953
+ }
878
954
  function isPayloadTooLargeError(e) {
879
955
  return isApiErr(e, 413);
880
956
  }
@@ -895,6 +971,59 @@ function isApiErrWithCode(e, status, code) {
895
971
  return isApiErr(e, status) && hasErrorCode(e, code);
896
972
  }
897
973
 
974
+ // src/idempotency/intent.ts
975
+ function createIdempotencyIntent(options) {
976
+ const generateKey = options?.generateKey ?? defaultIdempotencyKey;
977
+ let activeKey = null;
978
+ return {
979
+ get activeKey() {
980
+ return activeKey;
981
+ },
982
+ hasActiveIntent() {
983
+ return activeKey !== null;
984
+ },
985
+ begin() {
986
+ activeKey = generateKey();
987
+ return activeKey;
988
+ },
989
+ keyFor(rotation) {
990
+ if (rotation === "rotate") {
991
+ activeKey = generateKey();
992
+ return activeKey;
993
+ }
994
+ if (activeKey === null) {
995
+ activeKey = generateKey();
996
+ return activeKey;
997
+ }
998
+ return activeKey;
999
+ },
1000
+ complete() {
1001
+ activeKey = null;
1002
+ },
1003
+ abandon() {
1004
+ activeKey = null;
1005
+ }
1006
+ };
1007
+ }
1008
+ var createMutationIdempotency = createIdempotencyIntent;
1009
+
1010
+ // src/idempotency/rotation.ts
1011
+ function idempotencyRotationForRetry(error) {
1012
+ if (!(error instanceof ApiClientError)) {
1013
+ return "reuse";
1014
+ }
1015
+ if (isIdempotencyInProgressError(error)) {
1016
+ return "reuse";
1017
+ }
1018
+ if (isIdempotencyKeyReusedError(error)) {
1019
+ return "rotate";
1020
+ }
1021
+ if (isValidationError(error)) {
1022
+ return "rotate";
1023
+ }
1024
+ return "reuse";
1025
+ }
1026
+
898
1027
  // src/parse/pagination.ts
899
1028
  function parsePaginationKind(meta, links) {
900
1029
  const m = meta ?? {};
@@ -1089,6 +1218,7 @@ export {
1089
1218
  DEFAULT_TIMEOUT_MS,
1090
1219
  IDEMPOTENCY_MAX_LENGTH,
1091
1220
  PACKAGE_VERSION,
1221
+ acceptLanguageForRequest,
1092
1222
  applyJsonApiHeaders,
1093
1223
  applyTransformKeys,
1094
1224
  assertValidIdempotencyKey,
@@ -1097,6 +1227,8 @@ export {
1097
1227
  buildOffsetPageParams,
1098
1228
  createApiClient,
1099
1229
  createHealthCheck,
1230
+ createIdempotencyIntent,
1231
+ createMutationIdempotency,
1100
1232
  defaultIdempotencyKey,
1101
1233
  dispatchWithRetry,
1102
1234
  etagFromResponseHeaders,
@@ -1106,13 +1238,16 @@ export {
1106
1238
  getNextPageUrl,
1107
1239
  groupValidationErrorsByPointer,
1108
1240
  hasErrorCode,
1241
+ idempotencyRotationForRetry,
1109
1242
  indexIncluded,
1110
1243
  isApiClientError,
1111
1244
  isApiClientErrorWithCode,
1112
1245
  isAuthenticationError,
1113
1246
  isConflictError,
1114
1247
  isForbiddenError,
1248
+ isIdempotencyInProgressError,
1115
1249
  isIdempotencyKeyRequiredError,
1250
+ isIdempotencyKeyReusedError,
1116
1251
  isIfMatchRequiredError,
1117
1252
  isMfaVerificationRequiredError,
1118
1253
  isMutationMethod,
@@ -1121,8 +1256,12 @@ export {
1121
1256
  isPreconditionRequiredError,
1122
1257
  isRetryablePerPolicy,
1123
1258
  isValidationError,
1259
+ localesMatch,
1124
1260
  normalizeAxiosResponse,
1125
1261
  normalizeHttpUrl,
1262
+ normalizeLocaleCode,
1263
+ notifyLocaleMismatch,
1264
+ parseContentLanguage,
1126
1265
  parseDeprecationHeaders,
1127
1266
  parseJsonApiDocument,
1128
1267
  parseJsonApiErrorBody,
@@ -1131,11 +1270,14 @@ export {
1131
1270
  parseRetryAfterSeconds,
1132
1271
  pollAsyncResult,
1133
1272
  readResourceVersion,
1273
+ readResponseContentLanguage,
1134
1274
  redactHeaderRecord,
1135
1275
  resolveAcceptLanguage,
1136
1276
  resolveAcceptedLocation,
1137
1277
  resolveAuthorizationHeader,
1138
1278
  resolveIncluded,
1279
+ resolveLocaleProvider,
1280
+ resolveRequestLocale,
1139
1281
  resolveResourcePath,
1140
1282
  retryAllowed,
1141
1283
  truncateForLog