@quazardous/qdadm 2.0.0 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Kernel-level default API client (qdadm #1198)
3
+ *
4
+ * Apps already build an axios-compatible HttpClient (base URL + auth
5
+ * interceptors) for their ApiStorages. Registering it once on the kernel
6
+ * makes it available to composables/widgets that need ad-hoc API calls
7
+ * (useOptionsLookup endpoint mode, ScopeEditor), so relative endpoints
8
+ * get base URL + auth applied without per-call wiring:
9
+ *
10
+ * new Kernel({ apiClient: myAxios })
11
+ */
12
+ import { inject } from 'vue'
13
+ import type { HttpClient } from '../entity/storage/ApiStorage'
14
+
15
+ /** Injection key under which the kernel provides the default API client. */
16
+ export const API_CLIENT_INJECTION_KEY = 'qdadmApiClient'
17
+
18
+ /** Accepted forms of the kernel `apiClient` option. */
19
+ export type ApiClientSource = HttpClient | (() => HttpClient)
20
+
21
+ /** Resolve an ApiClientSource (direct instance or factory) to a client. */
22
+ export function resolveApiClient(source: ApiClientSource | null | undefined): HttpClient | null {
23
+ if (!source) return null
24
+ return typeof source === 'function' ? source() : source
25
+ }
26
+
27
+ /**
28
+ * Inject the kernel-registered default API client, or null when none is
29
+ * registered. Must be called from a setup context (like any inject).
30
+ */
31
+ export function useApiClient(): HttpClient | null {
32
+ const source = inject<ApiClientSource | null>(API_CLIENT_INJECTION_KEY, null)
33
+ return resolveApiClient(source)
34
+ }
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed, onMounted, watch, inject } from 'vue'
3
+ import type { HttpClient } from '../../entity/storage/ApiStorage'
3
4
  import AutoComplete from 'primevue/autocomplete'
4
5
  import Select from 'primevue/select'
5
6
  import Button from 'primevue/button'
@@ -43,8 +44,10 @@ const props = withDefaults(defineProps<Props>(), {
43
44
  defaultActions: null
44
45
  })
45
46
 
46
- // Get API adapter (optional)
47
+ // Get API adapter (optional); the kernel default client (Kernel({ apiClient }))
48
+ // serves as fallback so scope endpoints get base URL + auth without app wiring.
47
49
  const api = inject<ApiAdapter | null>('apiAdapter', null)
50
+ const kernelApiClient = inject<HttpClient | (() => HttpClient) | null>('qdadmApiClient', null)
48
51
 
49
52
  // Get global scope config (optional) - allows app-level configuration
50
53
  const globalScopeConfig = inject<ScopeConfig>('scopeConfig', {})
@@ -178,8 +181,17 @@ function isRowComplete(row: ScopeRow): boolean {
178
181
  async function loadScopeDefinition(): Promise<void> {
179
182
  loading.value = true
180
183
  try {
181
- if (!api) {
182
- // No API adapter, use defaults
184
+ let data: ScopeDefinition | null = null
185
+ if (api) {
186
+ data = await api.request('GET', config.value.endpoint)
187
+ } else if (kernelApiClient) {
188
+ // Fallback: kernel default API client (base URL + auth applied)
189
+ const client = typeof kernelApiClient === 'function' ? kernelApiClient() : kernelApiClient
190
+ data = (await client.get<ScopeDefinition>(config.value.endpoint)).data
191
+ }
192
+
193
+ if (!data) {
194
+ // No API layer available, use defaults
183
195
  scopeDefinition.value = {
184
196
  resources: [...config.value.resources],
185
197
  actions: [...config.value.actions],
@@ -187,7 +199,6 @@ async function loadScopeDefinition(): Promise<void> {
187
199
  return
188
200
  }
189
201
 
190
- const data = await api.request('GET', config.value.endpoint)
191
202
  scopeDefinition.value = {
192
203
  resources: data.resources || [...config.value.resources],
193
204
  actions: data.actions || [...config.value.actions],
@@ -42,6 +42,7 @@
42
42
  */
43
43
  import { ref, onMounted, type Ref } from 'vue'
44
44
  import { useOrchestrator } from '../orchestrator/useOrchestrator.js'
45
+ import { useApiClient } from '../api/apiClient'
45
46
 
46
47
  export interface OptionsLookupItem {
47
48
  label: string
@@ -66,11 +67,27 @@ export interface UseOptionsLookupConfig {
66
67
  entity?: string
67
68
  /** Extract distinct values from a specific field (entity mode only) */
68
69
  field?: string
69
- /** Fetch options from a raw API endpoint */
70
+ /**
71
+ * Fetch options from an API endpoint.
72
+ *
73
+ * Routing (#1198): a **relative** endpoint goes through the kernel's
74
+ * default API client when one is registered (`Kernel({ apiClient })`) —
75
+ * base URL + auth applied, no per-call wiring. An **absolute** URL is
76
+ * fetched raw (escape hatch for third-party APIs, combine with `headers`).
77
+ * A relative endpoint with no registered client falls back to a raw fetch
78
+ * (legacy) with a console warning.
79
+ */
70
80
  endpoint?: string
81
+ /**
82
+ * Route the endpoint request through the named entity's storage
83
+ * (`storage.request('GET', endpoint)`) instead of the kernel client —
84
+ * explicit refinement for multi-API apps. Takes precedence over the
85
+ * kernel client.
86
+ */
87
+ via?: string
71
88
  /** When using endpoint: pick a sub-key from the response data */
72
89
  pick?: string
73
- /** Extra headers for endpoint fetch (e.g. Authorization) */
90
+ /** Extra headers for the raw-fetch paths (e.g. Authorization) */
74
91
  headers?: Record<string, string> | (() => Record<string, string>)
75
92
  /** Static options (no fetch needed) */
76
93
  static?: unknown[]
@@ -164,10 +181,16 @@ export function useOptionsLookup(config: UseOptionsLookupConfig): UseOptionsLook
164
181
  : defaultDecode
165
182
  )
166
183
 
167
- // Resolve orchestrator lazily (only if entity mode)
168
- let getManager: ((name: string) => { list: (params?: Record<string, unknown>) => Promise<{ items: unknown[] }> }) | null = null
184
+ // Resolve orchestrator lazily (entity mode, or via:'entityName' routing)
185
+ type LookupManager = {
186
+ list: (params?: Record<string, unknown>) => Promise<{ items: unknown[] }>
187
+ storage?: {
188
+ request?: (method: string, path: string, options?: Record<string, unknown>) => Promise<unknown>
189
+ }
190
+ }
191
+ let getManager: ((name: string) => LookupManager) | null = null
169
192
 
170
- if (config.entity) {
193
+ if (config.entity || config.via) {
171
194
  try {
172
195
  const orc = useOrchestrator()
173
196
  // Generic-vs-non-generic: getManager is parameterised by an
@@ -180,6 +203,105 @@ export function useOptionsLookup(config: UseOptionsLookupConfig): UseOptionsLook
180
203
  }
181
204
  }
182
205
 
206
+ // Kernel default API client (Kernel({ apiClient })) — used for relative
207
+ // endpoints so base URL + auth apply without per-call wiring (#1198).
208
+ let apiClient: ReturnType<typeof useApiClient> = null
209
+ if (config.endpoint) {
210
+ try {
211
+ apiClient = useApiClient()
212
+ } catch {
213
+ // Outside a setup/injection context
214
+ }
215
+ }
216
+
217
+ // Bare-relative warning: once per composable instance, not per load
218
+ let warnedBareRelative = false
219
+
220
+ function warnLookup(msg: string): void {
221
+ console.warn(`[qdadm] useOptionsLookup(endpoint: '${config.endpoint}'): ${msg}`)
222
+ }
223
+
224
+ /** Shared normalization for every endpoint path: unwrap data, pick, array-check. */
225
+ function normalizeEndpointPayload(json: unknown): unknown[] {
226
+ let data: unknown = (json as { data?: unknown })?.data ?? json
227
+ if (config.pick && typeof data === 'object' && data !== null && !Array.isArray(data)) {
228
+ data = (data as Record<string, unknown>)[config.pick]
229
+ }
230
+ if (!Array.isArray(data)) {
231
+ warnLookup('response contained no usable array (after data/pick unwrap) — check the endpoint payload shape')
232
+ return []
233
+ }
234
+ return data
235
+ }
236
+
237
+ /** Endpoint routing (#1198): via storage > relative→kernel client > raw fetch. */
238
+ async function loadFromEndpoint(): Promise<unknown[]> {
239
+ const endpoint = config.endpoint!
240
+ const isAbsolute = /^https?:\/\//i.test(endpoint)
241
+
242
+ // 1. via:'entityName' → that entity's storage (explicit refinement)
243
+ if (config.via && getManager) {
244
+ const storage = getManager(config.via)?.storage
245
+ if (storage?.request) {
246
+ try {
247
+ const json = await storage.request('GET', endpoint)
248
+ return normalizeEndpointPayload(json)
249
+ } catch (err) {
250
+ warnLookup(`request via '${config.via}' storage failed: ${err instanceof Error ? err.message : err}`)
251
+ throw err
252
+ }
253
+ }
254
+ warnLookup(`via: '${config.via}' has no storage.request() — falling back to the default routing`)
255
+ }
256
+
257
+ // 2. relative endpoint → kernel default API client (base URL + auth)
258
+ if (!isAbsolute && apiClient) {
259
+ try {
260
+ const { data } = await apiClient.get(endpoint)
261
+ return normalizeEndpointPayload(data)
262
+ } catch (err) {
263
+ warnLookup(`request through the kernel apiClient failed: ${err instanceof Error ? err.message : err}`)
264
+ throw err
265
+ }
266
+ }
267
+
268
+ // 3. raw fetch — absolute URL (escape hatch) or legacy relative call
269
+ if (!isAbsolute && !warnedBareRelative) {
270
+ warnedBareRelative = true
271
+ warnLookup(
272
+ 'relative endpoint fetched bare — no API base URL or auth header applied. ' +
273
+ 'Register Kernel({ apiClient }) (recommended), use via: \'entityName\', or pass an absolute URL + headers.'
274
+ )
275
+ }
276
+ const extraHeaders = typeof config.headers === 'function' ? config.headers() : config.headers
277
+ const response = await fetch(endpoint, {
278
+ credentials: 'include',
279
+ headers: { 'Accept': 'application/json', ...extraHeaders },
280
+ })
281
+ if (!response.ok) {
282
+ const authHint = response.status === 401 || response.status === 403
283
+ ? ' — missing auth? register Kernel({ apiClient }) or pass headers'
284
+ : ''
285
+ warnLookup(`HTTP ${response.status}${authHint}`)
286
+ throw new Error(`HTTP ${response.status}`)
287
+ }
288
+ const contentType = response.headers.get('content-type') || ''
289
+ if (!contentType.includes('json')) {
290
+ warnLookup(
291
+ `response is not JSON (content-type: '${contentType || 'unknown'}') — the relative URL likely resolved ` +
292
+ 'to the app origin (HTML page). Register Kernel({ apiClient }) or use an absolute URL.'
293
+ )
294
+ }
295
+ let json: unknown
296
+ try {
297
+ json = await response.json()
298
+ } catch (err) {
299
+ warnLookup(`response body is not parseable JSON: ${err instanceof Error ? err.message : err}`)
300
+ throw err
301
+ }
302
+ return normalizeEndpointPayload(json)
303
+ }
304
+
183
305
  // Encoded strings cache for mapped mode (rebuilt on processItems)
184
306
  let encodedStrings: string[] = []
185
307
 
@@ -255,21 +377,7 @@ export function useOptionsLookup(config: UseOptionsLookupConfig): UseOptionsLook
255
377
  items = rawItems
256
378
  }
257
379
  } else if (config.endpoint) {
258
- const extraHeaders = typeof config.headers === 'function' ? config.headers() : config.headers
259
- const response = await fetch(config.endpoint, {
260
- credentials: 'include',
261
- headers: { 'Accept': 'application/json', ...extraHeaders },
262
- })
263
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
264
- const json = await response.json()
265
-
266
- let data = json.data ?? json
267
-
268
- if (config.pick && typeof data === 'object' && !Array.isArray(data)) {
269
- data = (data as Record<string, unknown>)[config.pick]
270
- }
271
-
272
- items = Array.isArray(data) ? data : []
380
+ items = await loadFromEndpoint()
273
381
  }
274
382
 
275
383
  processItems(items)
package/src/index.ts CHANGED
@@ -293,6 +293,12 @@ export {
293
293
  type OptionsLookupItem,
294
294
  type OptionsDisplayMode,
295
295
  } from './composables/useOptionsLookup'
296
+ export {
297
+ useApiClient,
298
+ resolveApiClient,
299
+ API_CLIENT_INJECTION_KEY,
300
+ type ApiClientSource,
301
+ } from './api/apiClient'
296
302
  export { useChildPage, type UseChildPageReturn } from './composables/useChildPage'
297
303
  export { useSidebarState, SIDEBAR_COLLAPSED_KEY } from './composables/useSidebarState'
298
304
 
@@ -11,6 +11,7 @@ import type { EntityManager } from '../entity/EntityManager'
11
11
  import type { EntityAuthAdapter } from '../entity/auth/EntityAuthAdapter'
12
12
  import type { RoleProvider } from '../security/RolesProvider'
13
13
  import type { I18nOptions } from '../i18n/types'
14
+ import type { ApiClientSource } from '../api/apiClient'
14
15
 
15
16
  /**
16
17
  * Auth adapter interface (app-level authentication)
@@ -183,6 +184,14 @@ export interface KernelOptions {
183
184
  debug?: boolean
184
185
  onAuthExpired?: (payload: unknown) => void
185
186
  i18n?: I18nOptions
187
+ /**
188
+ * Default API HTTP client (axios-compatible `HttpClient`, or a factory),
189
+ * provided to composables/widgets as `qdadmApiClient`. Relative-endpoint
190
+ * lookups (`useOptionsLookup` endpoint mode, `ScopeEditor` fallback) route
191
+ * through it so the app's base URL + auth headers apply without per-call
192
+ * wiring. Typically the same client your ApiStorages use.
193
+ */
194
+ apiClient?: ApiClientSource
186
195
  /**
187
196
  * Existing Vue app to install qdadm onto. When provided, the Kernel
188
197
  * skips its own `createApp(WrappedRoot)` and reuses the supplied
@@ -205,6 +205,12 @@ export function applyVueMethods(KernelClass: { prototype: any }): void {
205
205
  app.provide('qdadmActiveStack', this.activeStack)
206
206
  app.provide('qdadmStackHydrator', this.stackHydrator)
207
207
 
208
+ if (this.options.apiClient) {
209
+ // Default API client for composables/widgets (useOptionsLookup,
210
+ // ScopeEditor) — relative endpoints get base URL + auth (#1198).
211
+ app.provide('qdadmApiClient', this.options.apiClient)
212
+ }
213
+
208
214
  if (this.options.debug && typeof window !== 'undefined') {
209
215
  const self = this
210
216
  // Single namespaced surface for browser-console & agent introspection.