@morscherlab/mint-sdk 1.0.53 → 1.0.55
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 +18 -0
- package/dist/{ExperimentSelectorModal-BwPbQN1g.js → ExperimentSelectorModal-Cg4-LXBD.js} +1 -1
- package/dist/{ExperimentSelectorModal-B2qek_YG.js → ExperimentSelectorModal-DZeSd-yr.js} +2 -2
- package/dist/{ExperimentSelectorModal-B2qek_YG.js.map → ExperimentSelectorModal-DZeSd-yr.js.map} +1 -1
- package/dist/__tests__/composables/resolvePluginFrontendBase.test.d.ts +1 -0
- package/dist/components/index.js +2 -2
- package/dist/{components-Lk_h_rON.js → components-BQwOeyiy.js} +5 -5
- package/dist/{components-Lk_h_rON.js.map → components-BQwOeyiy.js.map} +1 -1
- package/dist/composables/index.d.ts +3 -2
- package/dist/composables/index.js +4 -4
- package/dist/composables/pluginFrontendBase.d.ts +7 -0
- package/dist/composables/useApi.d.ts +9 -0
- package/dist/composables/usePluginClient.d.ts +12 -2
- package/dist/{composables-DyfGpYZw.js → composables-TpXyhRZZ.js} +97 -30
- package/dist/composables-TpXyhRZZ.js.map +1 -0
- package/dist/index.js +6 -6
- package/dist/install.js +2 -2
- package/dist/{useExperimentSelector-DdCy5VNv.js → useExperimentSelector-DRbj6vse.js} +33 -3
- package/dist/useExperimentSelector-DRbj6vse.js.map +1 -0
- package/dist/{useJobsStatusTray-DAH999_N.js → useJobsStatusTray-CVecN6Ao.js} +2 -2
- package/dist/{useJobsStatusTray-DAH999_N.js.map → useJobsStatusTray-CVecN6Ao.js.map} +1 -1
- package/package.json +1 -1
- package/src/__tests__/composables/resolvePluginFrontendBase.test.ts +65 -0
- package/src/__tests__/composables/useApi.test.ts +76 -2
- package/src/__tests__/composables/usePluginClient.test.ts +158 -0
- package/src/composables/index.ts +8 -1
- package/src/composables/pluginEndpointBuilder.ts +13 -4
- package/src/composables/pluginFrontendBase.ts +22 -0
- package/src/composables/useApi.ts +48 -1
- package/src/composables/usePluginClient.ts +125 -21
- package/dist/composables-DyfGpYZw.js.map +0 -1
- package/dist/useExperimentSelector-DdCy5VNv.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morscherlab/mint-sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.55",
|
|
4
4
|
"description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { resolvePluginFrontendBase } from '../../composables/pluginFrontendBase'
|
|
4
|
+
|
|
5
|
+
type InjectedPlatformWindow = Window & {
|
|
6
|
+
__MINT_PLATFORM__?: unknown
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('resolvePluginFrontendBase', () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
delete (window as InjectedPlatformWindow).__MINT_PLATFORM__
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('should use the normalized route prefix when the platform context is integrated', () => {
|
|
15
|
+
;(window as InjectedPlatformWindow).__MINT_PLATFORM__ = {
|
|
16
|
+
isIntegrated: true,
|
|
17
|
+
theme: 'light',
|
|
18
|
+
plugin: {
|
|
19
|
+
id: 'leaf',
|
|
20
|
+
name: 'LEAF',
|
|
21
|
+
version: '0.7.0',
|
|
22
|
+
route_prefix: '/leaf///',
|
|
23
|
+
api_prefix: '/api/leaf',
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
expect(resolvePluginFrontendBase('./')).toBe('/leaf/')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should preserve the caller fallback when the platform context is absent', () => {
|
|
31
|
+
expect(resolvePluginFrontendBase('./')).toBe('./')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should preserve the caller fallback when the platform context is standalone', () => {
|
|
35
|
+
;(window as InjectedPlatformWindow).__MINT_PLATFORM__ = {
|
|
36
|
+
isIntegrated: false,
|
|
37
|
+
theme: 'light',
|
|
38
|
+
plugin: {
|
|
39
|
+
id: 'leaf',
|
|
40
|
+
name: 'LEAF',
|
|
41
|
+
version: '0.7.0',
|
|
42
|
+
route_prefix: '/leaf',
|
|
43
|
+
api_prefix: '/api/leaf',
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
expect(resolvePluginFrontendBase('/dev/')).toBe('/dev/')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should preserve root when the integrated route prefix is root', () => {
|
|
51
|
+
;(window as InjectedPlatformWindow).__MINT_PLATFORM__ = {
|
|
52
|
+
isIntegrated: true,
|
|
53
|
+
theme: 'light',
|
|
54
|
+
plugin: {
|
|
55
|
+
id: 'root',
|
|
56
|
+
name: 'Root',
|
|
57
|
+
version: '1.0.0',
|
|
58
|
+
route_prefix: '/',
|
|
59
|
+
api_prefix: '/api',
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
expect(resolvePluginFrontendBase('./')).toBe('/')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { createPinia, setActivePinia } from 'pinia'
|
|
3
|
-
import type
|
|
3
|
+
import { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
|
4
4
|
|
|
5
|
-
import { useApi } from '../../composables/useApi'
|
|
5
|
+
import { AuthenticationRequiredError, useApi } from '../../composables/useApi'
|
|
6
6
|
import { useAuthStore } from '../../stores/auth'
|
|
7
7
|
|
|
8
8
|
function readAuthorizationHeader(headers: InternalAxiosRequestConfig['headers']): unknown {
|
|
@@ -12,6 +12,23 @@ function readAuthorizationHeader(headers: InternalAxiosRequestConfig['headers'])
|
|
|
12
12
|
?? bag.authorization
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function unauthorizedAdapter(config: AxiosRequestConfig): Promise<never> {
|
|
16
|
+
const error = new AxiosError(
|
|
17
|
+
'Unauthorized',
|
|
18
|
+
AxiosError.ERR_BAD_REQUEST,
|
|
19
|
+
config as InternalAxiosRequestConfig,
|
|
20
|
+
undefined,
|
|
21
|
+
{
|
|
22
|
+
data: { detail: 'Authentication required' },
|
|
23
|
+
status: 401,
|
|
24
|
+
statusText: 'Unauthorized',
|
|
25
|
+
headers: {},
|
|
26
|
+
config: config as InternalAxiosRequestConfig,
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
return Promise.reject(error)
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
describe('useApi URL helpers', () => {
|
|
16
33
|
beforeEach(() => {
|
|
17
34
|
setActivePinia(createPinia())
|
|
@@ -72,4 +89,61 @@ describe('useApi URL helpers', () => {
|
|
|
72
89
|
expect(result).toEqual({ ok: true })
|
|
73
90
|
expect(authorization).toBeUndefined()
|
|
74
91
|
})
|
|
92
|
+
|
|
93
|
+
it('normalizes authenticated 401 responses and clears the rejected token', async () => {
|
|
94
|
+
const authStore = useAuthStore()
|
|
95
|
+
authStore.setToken('expired-token', 3600)
|
|
96
|
+
const api = useApi({ baseUrl: '/api' })
|
|
97
|
+
|
|
98
|
+
const adapter = vi.fn(unauthorizedAdapter)
|
|
99
|
+
const error = await api.get('/protected', { adapter }).catch((reason: unknown) => reason)
|
|
100
|
+
|
|
101
|
+
expect({ attempts: adapter.mock.calls.length, errorType: error?.constructor, token: authStore.token }).toEqual({
|
|
102
|
+
attempts: 1,
|
|
103
|
+
errorType: AuthenticationRequiredError,
|
|
104
|
+
token: null,
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('does not clear auth state for public requests that return 401', async () => {
|
|
109
|
+
const authStore = useAuthStore()
|
|
110
|
+
authStore.setToken('still-valid-token', 3600)
|
|
111
|
+
const api = useApi({ baseUrl: '/api', withAuth: false })
|
|
112
|
+
|
|
113
|
+
const error = await api.get('/public', { adapter: unauthorizedAdapter }).catch((reason: unknown) => reason)
|
|
114
|
+
|
|
115
|
+
expect({ normalized: error instanceof AuthenticationRequiredError, token: authStore.token }).toEqual({
|
|
116
|
+
normalized: false,
|
|
117
|
+
token: 'still-valid-token',
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('does not clear a replacement token after an older request returns 401', async () => {
|
|
122
|
+
const authStore = useAuthStore()
|
|
123
|
+
authStore.setToken('rejected-token', 3600)
|
|
124
|
+
const api = useApi({ baseUrl: '/api' })
|
|
125
|
+
|
|
126
|
+
await api.get('/protected', {
|
|
127
|
+
adapter: async (config) => {
|
|
128
|
+
authStore.setToken('replacement-token', 3600)
|
|
129
|
+
return unauthorizedAdapter(config)
|
|
130
|
+
},
|
|
131
|
+
}).catch(() => undefined)
|
|
132
|
+
|
|
133
|
+
expect(authStore.token).toBe('replacement-token')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('does not clear a token created while an unauthenticated request is in flight', async () => {
|
|
137
|
+
const authStore = useAuthStore()
|
|
138
|
+
const api = useApi({ baseUrl: '/api' })
|
|
139
|
+
|
|
140
|
+
await api.get('/protected', {
|
|
141
|
+
adapter: async (config) => {
|
|
142
|
+
authStore.setToken('new-login-token', 3600)
|
|
143
|
+
return unauthorizedAdapter(config)
|
|
144
|
+
},
|
|
145
|
+
}).catch(() => undefined)
|
|
146
|
+
|
|
147
|
+
expect(authStore.token).toBe('new-login-token')
|
|
148
|
+
})
|
|
75
149
|
})
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
usePluginSettings,
|
|
19
19
|
type PluginContract,
|
|
20
20
|
} from '../../composables/usePluginClient'
|
|
21
|
+
import { AuthenticationRequiredError } from '../../composables/useApi'
|
|
21
22
|
import { useAuthStore } from '../../stores/auth'
|
|
22
23
|
|
|
23
24
|
const contract: PluginContract = {
|
|
@@ -282,6 +283,21 @@ describe('usePluginClient', () => {
|
|
|
282
283
|
expect(url).toBe('/templates/plate%20map')
|
|
283
284
|
})
|
|
284
285
|
|
|
286
|
+
it('builds URLs from FastAPI converted path parameters', () => {
|
|
287
|
+
const url = buildPluginEndpointUrl(
|
|
288
|
+
contract,
|
|
289
|
+
{
|
|
290
|
+
method: 'get',
|
|
291
|
+
path: '/raw/{folder}/{filename:path}',
|
|
292
|
+
pathParams: ['folder', 'filename'],
|
|
293
|
+
},
|
|
294
|
+
{ folder: 'batch 1', filename: 'sample.mzML' },
|
|
295
|
+
{ includeBaseUrl: false },
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
expect(url).toBe('/raw/batch%201/sample.mzML')
|
|
299
|
+
})
|
|
300
|
+
|
|
285
301
|
it('preserves flat scalar query fields when no structured query object is used', () => {
|
|
286
302
|
const url = buildPluginEndpointUrl(
|
|
287
303
|
contract,
|
|
@@ -333,6 +349,29 @@ describe('usePluginClient', () => {
|
|
|
333
349
|
expect(form.get('dryRun')).toBe('false')
|
|
334
350
|
})
|
|
335
351
|
|
|
352
|
+
it('uploads generated octet-stream endpoint bodies without multipart conversion', async () => {
|
|
353
|
+
const body = new Uint8Array([1, 2, 3])
|
|
354
|
+
|
|
355
|
+
await uploadPluginEndpoint(
|
|
356
|
+
contract,
|
|
357
|
+
{
|
|
358
|
+
method: 'put',
|
|
359
|
+
path: '/chunks',
|
|
360
|
+
hasBody: true,
|
|
361
|
+
requestContentType: 'application/octet-stream',
|
|
362
|
+
},
|
|
363
|
+
body,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
expect({
|
|
367
|
+
body: requestBodies[0],
|
|
368
|
+
contentType: (requestConfigs[0]!.headers as Record<string, unknown>)['Content-Type'],
|
|
369
|
+
}).toEqual({
|
|
370
|
+
body,
|
|
371
|
+
contentType: 'application/octet-stream',
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
336
375
|
it('builds multipart form data from repeated scalar values', () => {
|
|
337
376
|
const form = pluginFormDataFromPayload({
|
|
338
377
|
tags: ['qc', 'plate'],
|
|
@@ -371,6 +410,96 @@ describe('usePluginClient', () => {
|
|
|
371
410
|
expect(requestConfigs[0]!.responseType).toBe('blob')
|
|
372
411
|
})
|
|
373
412
|
|
|
413
|
+
it.each([
|
|
414
|
+
new Blob(['chunk']),
|
|
415
|
+
new Uint8Array([1, 2, 3]),
|
|
416
|
+
new Uint8Array([4, 5, 6]).buffer,
|
|
417
|
+
])('sends generated octet-stream request bodies without encoding them', async (body) => {
|
|
418
|
+
const client = createPluginClient(contract, {
|
|
419
|
+
endpoints: {
|
|
420
|
+
uploadChunk: {
|
|
421
|
+
method: 'post',
|
|
422
|
+
path: '/chunks',
|
|
423
|
+
hasBody: true,
|
|
424
|
+
requestContentType: 'application/octet-stream',
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
await client.uploadChunk(body)
|
|
430
|
+
|
|
431
|
+
expect({
|
|
432
|
+
body: requestBodies[0],
|
|
433
|
+
contentType: (requestConfigs[0]!.headers as Record<string, unknown>)['Content-Type'],
|
|
434
|
+
}).toEqual({
|
|
435
|
+
body,
|
|
436
|
+
contentType: 'application/octet-stream',
|
|
437
|
+
})
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('returns generated octet-stream responses as blobs', async () => {
|
|
441
|
+
const expectedBlob = new Blob(['result'])
|
|
442
|
+
nextGetData = expectedBlob
|
|
443
|
+
const client = createPluginClient(contract, {
|
|
444
|
+
endpoints: {
|
|
445
|
+
downloadResult: {
|
|
446
|
+
method: 'get',
|
|
447
|
+
path: '/result',
|
|
448
|
+
responseContentType: 'application/octet-stream',
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
const result = await client.downloadResult()
|
|
454
|
+
|
|
455
|
+
expect({ result, responseType: requestConfigs[0]!.responseType }).toEqual({
|
|
456
|
+
result: expectedBlob,
|
|
457
|
+
responseType: 'blob',
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('passes AbortSignal through generated endpoint calls', async () => {
|
|
462
|
+
const controller = new AbortController()
|
|
463
|
+
const client = createPluginClient(contract, {
|
|
464
|
+
endpoints: {
|
|
465
|
+
health: { method: 'get', path: '/health' },
|
|
466
|
+
},
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
await client.health({ signal: controller.signal })
|
|
470
|
+
|
|
471
|
+
expect(requestConfigs[0]!.signal).toBe(controller.signal)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('encodes generated multipart endpoint bodies as FormData', async () => {
|
|
475
|
+
const file = new Blob(['csv'])
|
|
476
|
+
const client = createPluginClient(contract, {
|
|
477
|
+
endpoints: {
|
|
478
|
+
uploadFile: {
|
|
479
|
+
method: 'post',
|
|
480
|
+
path: '/files',
|
|
481
|
+
hasBody: true,
|
|
482
|
+
requestContentType: 'multipart/form-data',
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
await client.uploadFile({ file, label: 'sample' })
|
|
488
|
+
|
|
489
|
+
const body = requestBodies[0] as FormData
|
|
490
|
+
expect({
|
|
491
|
+
isFormData: body instanceof FormData,
|
|
492
|
+
fileIsBlob: body.get('file') instanceof Blob,
|
|
493
|
+
label: body.get('label'),
|
|
494
|
+
contentType: (requestConfigs[0]!.headers as Record<string, unknown>)['Content-Type'],
|
|
495
|
+
}).toEqual({
|
|
496
|
+
isFormData: true,
|
|
497
|
+
fileIsBlob: true,
|
|
498
|
+
label: 'sample',
|
|
499
|
+
contentType: undefined,
|
|
500
|
+
})
|
|
501
|
+
})
|
|
502
|
+
|
|
374
503
|
it('triggers browser downloads for existing blobs', () => {
|
|
375
504
|
vi.useFakeTimers()
|
|
376
505
|
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test')
|
|
@@ -551,6 +680,35 @@ describe('usePluginClient', () => {
|
|
|
551
680
|
expect(stream.lastEventId.value).toBe('7')
|
|
552
681
|
})
|
|
553
682
|
|
|
683
|
+
it('normalizes stream auth failures and does not reconnect rejected requests', async () => {
|
|
684
|
+
const authStore = useAuthStore()
|
|
685
|
+
authStore.setToken('expired-stream-token', 3600)
|
|
686
|
+
const onError = vi.fn()
|
|
687
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 401 }))
|
|
688
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
689
|
+
|
|
690
|
+
const stream = usePluginEventStream(
|
|
691
|
+
contract,
|
|
692
|
+
{ method: 'get', path: '/downloads/events' },
|
|
693
|
+
{ reconnectDelayMs: 1, onError },
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce())
|
|
697
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
698
|
+
|
|
699
|
+
expect({
|
|
700
|
+
attempts: fetchMock.mock.calls.length,
|
|
701
|
+
errorType: onError.mock.calls[0]![0]?.constructor,
|
|
702
|
+
message: stream.error.value,
|
|
703
|
+
token: authStore.token,
|
|
704
|
+
}).toEqual({
|
|
705
|
+
attempts: 1,
|
|
706
|
+
errorType: AuthenticationRequiredError,
|
|
707
|
+
message: 'Authentication required. Please sign in again.',
|
|
708
|
+
token: null,
|
|
709
|
+
})
|
|
710
|
+
})
|
|
711
|
+
|
|
554
712
|
it('parses generated typed event streams as JSON by default', async () => {
|
|
555
713
|
const onMessage = vi.fn()
|
|
556
714
|
const streamBody = new ReadableStream({
|
package/src/composables/index.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
AuthenticationRequiredError,
|
|
3
|
+
useApi,
|
|
4
|
+
type ApiClientOptions,
|
|
5
|
+
type UseApiReturn,
|
|
6
|
+
} from './useApi'
|
|
2
7
|
export { useAuth } from './useAuth'
|
|
3
8
|
export { usePasskey } from './usePasskey'
|
|
4
9
|
export { useTheme } from './useTheme'
|
|
5
10
|
export { useToast } from './useToast'
|
|
6
11
|
export { usePlatformContext } from './usePlatformContext'
|
|
12
|
+
export { resolvePluginFrontendBase } from './pluginFrontendBase'
|
|
7
13
|
export {
|
|
8
14
|
useForm,
|
|
9
15
|
type ValidationRule,
|
|
@@ -452,6 +458,7 @@ export {
|
|
|
452
458
|
type DownloadBlobOptions,
|
|
453
459
|
type DownloadPluginEndpointOptions,
|
|
454
460
|
type PluginContract,
|
|
461
|
+
type PluginClientRequestOptions,
|
|
455
462
|
type PluginEndpointContract,
|
|
456
463
|
type PluginEndpointDefinition,
|
|
457
464
|
type PluginHttpMethod,
|
|
@@ -30,6 +30,17 @@ function endpointParamFieldName(param: PluginEndpointParamDefinition): string {
|
|
|
30
30
|
return typeof param === 'string' ? param : param.fieldName ?? param.name
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function escapeRegExp(value: string): string {
|
|
34
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function replacePathParam(path: string, name: string, encoded: string): string {
|
|
38
|
+
const escapedName = escapeRegExp(name)
|
|
39
|
+
return path
|
|
40
|
+
.replace(new RegExp(`\\{${escapedName}(?::[^{}]+)?\\}`, 'g'), () => encoded)
|
|
41
|
+
.replace(new RegExp(`:${escapedName}(?=/|$)`, 'g'), () => encoded)
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
/** Resolve the runtime plugin API base URL from env, explicit options, platform injection, or contract metadata. */
|
|
34
45
|
export function resolvePluginBaseUrl(contract: PluginContract, explicitBaseUrl?: string): string {
|
|
35
46
|
const envPrefix = (import.meta.env?.VITE_API_PREFIX as string | undefined) || undefined
|
|
@@ -59,11 +70,9 @@ function encodePath(
|
|
|
59
70
|
throw new Error(`[MINT SDK] Missing path parameter '${fieldName}' for plugin endpoint ${path}`)
|
|
60
71
|
}
|
|
61
72
|
const encoded = encodeURIComponent(String(value))
|
|
62
|
-
nextPath = nextPath
|
|
63
|
-
nextPath = nextPath.replace(`:${name}`, encoded)
|
|
73
|
+
nextPath = replacePathParam(nextPath, name, encoded)
|
|
64
74
|
if (fieldName !== name) {
|
|
65
|
-
nextPath = nextPath
|
|
66
|
-
nextPath = nextPath.replace(`:${fieldName}`, encoded)
|
|
75
|
+
nextPath = replacePathParam(nextPath, fieldName, encoded)
|
|
67
76
|
}
|
|
68
77
|
}
|
|
69
78
|
return nextPath
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getInjectedPlatformContext } from './platformContextHelpers'
|
|
2
|
+
|
|
3
|
+
function normalizePluginRoutePrefix(prefix: string | undefined): string | undefined {
|
|
4
|
+
const candidate = prefix?.trim()
|
|
5
|
+
if (!candidate) return undefined
|
|
6
|
+
|
|
7
|
+
const path = candidate.split('/').filter(Boolean).join('/')
|
|
8
|
+
return path ? `/${path}/` : '/'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the Vue Router base for a plugin frontend.
|
|
13
|
+
*
|
|
14
|
+
* Integrated plugins use the platform-injected mount prefix. Standalone and
|
|
15
|
+
* development builds retain the caller's Vite base unchanged.
|
|
16
|
+
*/
|
|
17
|
+
export function resolvePluginFrontendBase(fallbackBase: string = '/'): string {
|
|
18
|
+
const context = getInjectedPlatformContext()
|
|
19
|
+
if (!context?.isIntegrated) return fallbackBase
|
|
20
|
+
|
|
21
|
+
return normalizePluginRoutePrefix(context.plugin?.route_prefix) ?? fallbackBase
|
|
22
|
+
}
|
|
@@ -7,10 +7,12 @@ let interceptorAttached = false
|
|
|
7
7
|
|
|
8
8
|
interface MintAxiosRequestConfig extends AxiosRequestConfig {
|
|
9
9
|
_mintSkipAuth?: boolean
|
|
10
|
+
_mintAuthToken?: string
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
interface MintInternalAxiosRequestConfig extends InternalAxiosRequestConfig {
|
|
13
14
|
_mintSkipAuth?: boolean
|
|
15
|
+
_mintAuthToken?: string
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
type MutableHeaders = Record<string, unknown> & {
|
|
@@ -92,6 +94,37 @@ function deleteAuthorizationHeader(headers: AxiosRequestConfig['headers']): void
|
|
|
92
94
|
}
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
function getAuthorizationHeader(headers: AxiosRequestConfig['headers']): unknown {
|
|
98
|
+
const bag = asMutableHeaders(headers) as (MutableHeaders & { get?: (header: string) => unknown }) | null
|
|
99
|
+
if (!bag) return undefined
|
|
100
|
+
if (typeof bag.get === 'function') return bag.get('Authorization')
|
|
101
|
+
const key = Object.keys(bag).find((candidate) => candidate.toLowerCase() === 'authorization')
|
|
102
|
+
return key ? bag[key] : undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Raised when an authenticated SDK request is rejected because the login is no longer valid. */
|
|
106
|
+
export class AuthenticationRequiredError extends Error {
|
|
107
|
+
readonly code = 'AUTHENTICATION_REQUIRED'
|
|
108
|
+
readonly status = 401
|
|
109
|
+
|
|
110
|
+
constructor(readonly cause?: unknown) {
|
|
111
|
+
super('Authentication required. Please sign in again.')
|
|
112
|
+
this.name = 'AuthenticationRequiredError'
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @internal Convert a rejected authenticated request into the SDK's public auth error. */
|
|
117
|
+
export function authenticationRequiredError(
|
|
118
|
+
cause: unknown,
|
|
119
|
+
rejectedToken?: string,
|
|
120
|
+
): AuthenticationRequiredError {
|
|
121
|
+
const currentAuthStore = useAuthStore()
|
|
122
|
+
if (rejectedToken && currentAuthStore.token === rejectedToken) {
|
|
123
|
+
currentAuthStore.clearToken()
|
|
124
|
+
}
|
|
125
|
+
return new AuthenticationRequiredError(cause)
|
|
126
|
+
}
|
|
127
|
+
|
|
95
128
|
function getApiClient(): AxiosInstance {
|
|
96
129
|
if (!apiClientInstance) {
|
|
97
130
|
apiClientInstance = axios.create({
|
|
@@ -138,7 +171,6 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
|
|
|
138
171
|
apiClient.interceptors.request.use((config) => {
|
|
139
172
|
const request = config as MintInternalAxiosRequestConfig
|
|
140
173
|
if (request._mintSkipAuth) {
|
|
141
|
-
delete request._mintSkipAuth
|
|
142
174
|
deleteAuthorizationHeader(request.headers)
|
|
143
175
|
return request
|
|
144
176
|
}
|
|
@@ -147,8 +179,23 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
|
|
|
147
179
|
if (currentAuthStore.token && config.headers && !hasAuthorizationHeader(config.headers)) {
|
|
148
180
|
setAuthorizationHeader(config.headers, `Bearer ${currentAuthStore.token}`)
|
|
149
181
|
}
|
|
182
|
+
if (getAuthorizationHeader(config.headers) === `Bearer ${currentAuthStore.token}`) {
|
|
183
|
+
request._mintAuthToken = currentAuthStore.token ?? undefined
|
|
184
|
+
}
|
|
150
185
|
return config
|
|
151
186
|
})
|
|
187
|
+
apiClient.interceptors.response.use(
|
|
188
|
+
(response) => response,
|
|
189
|
+
(error: unknown) => {
|
|
190
|
+
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
|
191
|
+
const request = error.config as MintInternalAxiosRequestConfig | undefined
|
|
192
|
+
if (request?._mintSkipAuth !== true) {
|
|
193
|
+
return Promise.reject(authenticationRequiredError(error, request?._mintAuthToken))
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return Promise.reject(error)
|
|
197
|
+
},
|
|
198
|
+
)
|
|
152
199
|
interceptorAttached = true
|
|
153
200
|
}
|
|
154
201
|
|