@ramathibodi/nuxt-commons 4.0.8 → 4.0.10

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/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": "^4.3.1"
6
6
  },
7
- "version": "4.0.8",
7
+ "version": "4.0.10",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -48,6 +48,13 @@ const module$1 = defineNuxtModule({
48
48
  ...runtimeConfig,
49
49
  ..._options
50
50
  };
51
+ const publicConfig = _nuxt.options.runtimeConfig.public;
52
+ if (publicConfig.IDEMPOTENCY_HEADER == null) {
53
+ publicConfig.IDEMPOTENCY_HEADER = "Idempotency-Key";
54
+ }
55
+ if (publicConfig.IDEMPOTENCY_ENABLED == null) {
56
+ publicConfig.IDEMPOTENCY_ENABLED = true;
57
+ }
51
58
  _nuxt.options.vite.optimizeDeps ||= {};
52
59
  _nuxt.options.vite.optimizeDeps.include ||= [];
53
60
  _nuxt.options.vite.optimizeDeps.include.push("painterro");
@@ -21,6 +21,27 @@
21
21
  * so users can see stale empty / outdated lists for the full TTL window even after
22
22
  * creating data themselves.
23
23
  *
24
+ * Idempotency: every authenticated POST (REST and GraphQL via useGraphQl()) automatically
25
+ * receives an `Idempotency-Key` HTTP header. The value is computed client-side as
26
+ *
27
+ * SHA-256(`${floor(Date.now()/1000)}|${username}|${stableStringify(body ?? {})}`)
28
+ *
29
+ * in lowercase hex. The recipe is intentionally identical to the backend's server-side
30
+ * fallback in rama-spring-starter's @IdempotentMutation aspect, so a header-present and
31
+ * a header-absent request from the same user with the same body within the same wall-clock
32
+ * second resolve to the same dedup signature.
33
+ *
34
+ * Per-call escape hatch: pass `{ idempotent: false }` in the options argument to skip
35
+ * injection for a single call (e.g. a long-running mutation the caller wants to retry
36
+ * intentionally with the same body). A caller-supplied `Idempotency-Key` in
37
+ * `options.headers` always wins and is never overwritten.
38
+ *
39
+ * Configuration (consumer's `nuxt.config` `runtimeConfig.public`, mirroring the
40
+ * unprefixed `WS_API` / `WS_GRAPHQL` precedent):
41
+ * - IDEMPOTENCY_HEADER (default 'Idempotency-Key') — must match the backend's
42
+ * `rama.idempotency.header-name` setting if the consumer overrides it there.
43
+ * - IDEMPOTENCY_ENABLED (default true) — global kill switch.
44
+ *
24
45
  * This doc block is consumed by vue-docgen for generated API documentation.
25
46
  */
26
47
  import type { UseFetchOptions } from 'nuxt/app';
@@ -35,6 +56,16 @@ export type CacheOption = boolean | number | {
35
56
  } | {
36
57
  ttlMs: number;
37
58
  };
59
+ /**
60
+ * Per-call options accepted by useApi alongside Nuxt's UseFetchOptions.
61
+ *
62
+ * `idempotent: false` skips Idempotency-Key header injection for this single call.
63
+ * Default is `true`. See the useApi() docblock for the full idempotency contract.
64
+ */
65
+ export interface ApiIdempotencyOption {
66
+ idempotent?: boolean;
67
+ }
68
+ export type ApiFetchOptions = UseFetchOptions<unknown> & ApiIdempotencyOption;
38
69
  export declare function _resetLegacyHeuristicWarning(): void;
39
70
  /**
40
71
  * Resolve a cache option to seconds. Pure function, exported for tests.
@@ -60,10 +91,10 @@ export declare function resolveCacheTtlSeconds(cache: CacheOption | undefined |
60
91
  declare function invalidateCache(prefix?: string): number;
61
92
  export declare function useApi(): {
62
93
  urlBuilder: (url: string | string[]) => string;
63
- get: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: UseFetchOptions<unknown>, cache?: CacheOption) => Promise<T>;
64
- getPromise: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: UseFetchOptions<unknown>, cache?: CacheOption) => Promise<T>;
65
- post: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: UseFetchOptions<unknown>, cache?: CacheOption) => Promise<T>;
66
- postPromise: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: UseFetchOptions<unknown>, cache?: CacheOption) => Promise<T>;
94
+ get: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: ApiFetchOptions, cache?: CacheOption) => Promise<T>;
95
+ getPromise: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: ApiFetchOptions, cache?: CacheOption) => Promise<T>;
96
+ post: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: ApiFetchOptions, cache?: CacheOption) => Promise<T>;
97
+ postPromise: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: ApiFetchOptions, cache?: CacheOption) => Promise<T>;
67
98
  hashKey: (data: any) => Promise<string>;
68
99
  invalidate: typeof invalidateCache;
69
100
  };
@@ -7,6 +7,7 @@ import { useRuntimeConfig } from "#imports";
7
7
  import { useAuthentication } from "../bridges/authentication.js";
8
8
  const DEFAULT_TTL_SECONDS_FOR_TRUE = 5 * 60;
9
9
  const CACHE_PREFIX = "api-cache-";
10
+ const IDEMPOTENCY_HEADER_DEFAULT = "Idempotency-Key";
10
11
  let _legacyHeuristicWarned = false;
11
12
  function warnLegacyHeuristic(value, resolvedSeconds) {
12
13
  if (_legacyHeuristicWarned) return;
@@ -77,7 +78,7 @@ export function useApi() {
77
78
  if (returnUrl.startsWith("http://") || returnUrl.startsWith("https://")) return returnUrl;
78
79
  return trimEnd(config?.public.WS_API, "/") + "/" + trimStart(returnUrl, "/");
79
80
  }
80
- function optionBuilder(method, body, params, options = {}) {
81
+ async function optionBuilder(method, body, params, options = {}) {
81
82
  const headers = {
82
83
  "Content-Type": "application/json",
83
84
  Accept: "application/json"
@@ -85,9 +86,24 @@ export function useApi() {
85
86
  const auth = useAuthentication();
86
87
  const token = auth.keycloak?.token || auth.token;
87
88
  if (token) {
88
- ;
89
89
  headers["Authorization"] = `Bearer ${token}`;
90
90
  }
91
+ const callerHeaders = {
92
+ ...options.headers || {}
93
+ };
94
+ const enabledConfig = config?.public?.IDEMPOTENCY_ENABLED;
95
+ const idempotencyEnabled = enabledConfig !== false;
96
+ const headerName = config?.public?.IDEMPOTENCY_HEADER || IDEMPOTENCY_HEADER_DEFAULT;
97
+ const callerProvidedHeader = Object.keys(callerHeaders).some(
98
+ (k) => k.toLowerCase() === headerName.toLowerCase()
99
+ );
100
+ if (method === "POST" && idempotencyEnabled && options.idempotent !== false && !callerProvidedHeader) {
101
+ const username = (typeof auth.getUsername === "function" ? auth.getUsername() : auth.userProfile?.username) ?? "";
102
+ const second = Math.floor(Date.now() / 1e3).toString();
103
+ const bodyJson = stableStringify(body ?? {});
104
+ headers[headerName] = await sha256(`${second}|${username}|${bodyJson}`);
105
+ }
106
+ const { idempotent: _omitIdempotent, ...passThroughOptions } = options;
91
107
  const baseOptions = {
92
108
  method,
93
109
  body,
@@ -96,9 +112,9 @@ export function useApi() {
96
112
  };
97
113
  const finalHeaders = {
98
114
  ...headers,
99
- ...options.headers || {}
115
+ ...callerHeaders
100
116
  };
101
- Object.assign(baseOptions, options);
117
+ Object.assign(baseOptions, passThroughOptions);
102
118
  return {
103
119
  ...baseOptions,
104
120
  headers: finalHeaders
@@ -132,7 +148,7 @@ export function useApi() {
132
148
  const builtUrl = urlBuilder(url);
133
149
  const ttl = resolveCacheTtlSeconds(cache);
134
150
  if (ttl === 0) {
135
- return ofetch(builtUrl, optionBuilder(method, body, params, options));
151
+ return ofetch(builtUrl, await optionBuilder(method, body, params, options));
136
152
  }
137
153
  const keyData = { url: builtUrl, method, body, params, headers: options?.headers };
138
154
  const key = CACHE_PREFIX + await hashKey(keyData);
@@ -140,7 +156,7 @@ export function useApi() {
140
156
  if (cached !== null) {
141
157
  return cached;
142
158
  }
143
- const result = await ofetch(builtUrl, optionBuilder(method, body, params, options));
159
+ const result = await ofetch(builtUrl, await optionBuilder(method, body, params, options));
144
160
  ls.set(key, result, { ttl });
145
161
  return result;
146
162
  }
@@ -31,15 +31,15 @@ export function useLookupListMaster(props) {
31
31
  );
32
32
  });
33
33
  const formatItemTitle = (item) => {
34
+ const raw = item?.raw ?? item ?? {};
35
+ const resolvedTitle = item?.title || raw?.[itemTitleField.value] || raw?.itemValue || raw?.itemCode;
34
36
  if (props.meilisearch) {
35
- const raw = item?._formatted ?? {};
36
- const code = raw?.itemCode;
37
- const title = raw?.[itemTitleField.value] ?? raw?.itemValue ?? raw?.itemCode;
37
+ const formatted = raw?._formatted ?? {};
38
+ const code = formatted?.itemCode ?? raw?.itemCode;
39
+ const title = formatted?.[itemTitleField.value] || formatted?.itemValue || formatted?.itemCode || resolvedTitle;
38
40
  return (props.showCode ? (code ?? "") + "-" : "") + (title ?? "");
39
41
  } else {
40
- const code = item?.itemCode;
41
- const title = item.title ?? item?.itemValue ?? item?.itemCode;
42
- return (props.showCode ? (code ?? "") + "-" : "") + (title ?? "");
42
+ return (props.showCode ? (raw?.itemCode ?? "") + "-" : "") + (resolvedTitle ?? "");
43
43
  }
44
44
  };
45
45
  const computedNoDataText = computed(() => {
@@ -1,6 +1,8 @@
1
1
  export interface graphqlVariable {
2
2
  name: string
3
- list?: boolean
3
+ // `true` for `[T]`, `[true]` for `[T!]` — matches gql-query-builder's
4
+ // VariableOptions shape so element non-null survives into the query string.
5
+ list?: boolean | [boolean]
4
6
  required?: boolean
5
7
  type?: string
6
8
  value?: any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramathibodi/nuxt-commons",
3
- "version": "4.0.8",
3
+ "version": "4.0.10",
4
4
  "description": "Ramathibodi Nuxt modules for common components",
5
5
  "repository": {
6
6
  "type": "git",
@@ -6,14 +6,21 @@ function capitalizeFirstLetter(string) {
6
6
  return string.charAt(0).toUpperCase() + string.slice(1)
7
7
  }
8
8
 
9
- function inputTypeToObject(inputType, result) {
9
+ // `inList` flips to true the moment we descend into a ListType, so a NonNullType
10
+ // nested under a List is recognized as element-non-null (`[T!]`) rather than
11
+ // overwriting the outer list's `required` flag. The element-non-null is encoded
12
+ // as `list: [true]` — the shape gql-query-builder reads to emit `[T!]`.
13
+ function inputTypeToObject(inputType, result, inList = false) {
10
14
  if (inputType.kind === 'NonNullType') {
11
- result['required'] = true
12
- inputTypeToObject(inputType.type, result)
15
+ if (inList) result['list'] = [true]
16
+ else result['required'] = true
17
+ inputTypeToObject(inputType.type, result, inList)
18
+ return
13
19
  }
14
20
  if (inputType.kind === 'ListType') {
15
- result['list'] = true
16
- inputTypeToObject(inputType.type, result)
21
+ if (!Array.isArray(result['list'])) result['list'] = true
22
+ inputTypeToObject(inputType.type, result, true)
23
+ return
17
24
  }
18
25
  if (inputType.kind === 'NamedType') {
19
26
  result['type'] = inputType.name.value
@@ -21,6 +28,7 @@ function inputTypeToObject(inputType, result) {
21
28
  }
22
29
 
23
30
  module.exports = {
31
+ inputTypeToObject,
24
32
  plugin(schema, _documents, _config) {
25
33
  const astNode = getCachedDocumentNodeFromSchema(schema) // Transforms the GraphQLSchema into ASTNode
26
34