import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' import { useSettingsStore } from '../stores/settings' import { useAuthStore } from '../stores/auth' import { MintApiError, mintApiErrorFromResponse } from '../apiErrors' let apiClientInstance: AxiosInstance | null = null let interceptorAttached = false interface MintAxiosRequestConfig extends AxiosRequestConfig { _mintSkipAuth?: boolean _mintAuthToken?: string _mintTypedErrors?: boolean } interface MintInternalAxiosRequestConfig extends InternalAxiosRequestConfig { _mintSkipAuth?: boolean _mintAuthToken?: string _mintTypedErrors?: boolean } type MutableHeaders = Record & { has?: (header: string) => boolean set?: (header: string, value: string) => void delete?: (header: string) => void } function joinUrlPath(baseUrl: string, path: string): string { if (/^https?:\/\//i.test(path)) return path if (!path) return baseUrl if (path.startsWith('?') || path.startsWith('#')) return `${baseUrl.replace(/\/+$/, '')}${path}` const normalizedBase = baseUrl.replace(/\/+$/, '') const normalizedPath = path.replace(/^\/+/, '/') return `${normalizedBase}${normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`}` } function getBasePath(baseUrl: string): string { if (!baseUrl) return '/' try { const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost' return new URL(baseUrl, origin).pathname.replace(/\/+$/, '') || '/' } catch { const path = baseUrl.replace(/^https?:\/\/[^/]+/i, '') return path.replace(/\/+$/, '') || '/' } } function normalizeRequestUrl(baseUrl: string, url: string): string { if (!url || /^https?:\/\//.test(url)) return url const basePath = getBasePath(baseUrl) if (basePath === '/') return url const normalizedUrl = url.startsWith('/') ? url : `/${url}` if ( normalizedUrl === basePath || normalizedUrl.startsWith(`${basePath}?`) || normalizedUrl.startsWith(`${basePath}#`) ) { return normalizedUrl.slice(basePath.length) } if (normalizedUrl.startsWith(`${basePath}/`)) { return normalizedUrl.slice(basePath.length) || '' } return url } function asMutableHeaders(headers: AxiosRequestConfig['headers']): MutableHeaders | null { return headers ? (headers as MutableHeaders) : null } function hasAuthorizationHeader(headers: AxiosRequestConfig['headers']): boolean { const bag = asMutableHeaders(headers) if (!bag) return false if (typeof bag.has === 'function') return bag.has('Authorization') return Object.keys(bag).some((key) => key.toLowerCase() === 'authorization') } function setAuthorizationHeader(headers: AxiosRequestConfig['headers'], value: string): void { const bag = asMutableHeaders(headers) if (!bag) return if (typeof bag.set === 'function') { bag.set('Authorization', value) return } bag.Authorization = value } function deleteAuthorizationHeader(headers: AxiosRequestConfig['headers']): void { const bag = asMutableHeaders(headers) if (!bag) return if (typeof bag.delete === 'function') { bag.delete('Authorization') return } for (const key of Object.keys(bag)) { if (key.toLowerCase() === 'authorization') delete bag[key] } } function getAuthorizationHeader(headers: AxiosRequestConfig['headers']): unknown { const bag = asMutableHeaders(headers) as (MutableHeaders & { get?: (header: string) => unknown }) | null if (!bag) return undefined if (typeof bag.get === 'function') return bag.get('Authorization') const key = Object.keys(bag).find((candidate) => candidate.toLowerCase() === 'authorization') return key ? bag[key] : undefined } /** * Raised when an authenticated SDK request is rejected because the login is no * longer valid. It is also a `MintApiError` (status 401), so typed-error callers * and `instanceof AuthenticationRequiredError` callers both recognise it. */ export class AuthenticationRequiredError extends MintApiError { override readonly name: string = 'AuthenticationRequiredError' declare readonly code: 'AUTHENTICATION_REQUIRED' declare readonly status: 401 constructor(cause?: unknown, envelope?: MintApiError) { super( 'Authentication required. Please sign in again.', 'AUTHENTICATION_REQUIRED', 401, envelope?.requestId ?? null, envelope?.details ?? null, envelope?.detail, envelope?.body ?? null, cause, ) } } /** @internal Convert a rejected authenticated request into the SDK's public auth error. */ export function authenticationRequiredError( cause: unknown, rejectedToken?: string, envelope?: MintApiError, ): AuthenticationRequiredError { const currentAuthStore = useAuthStore() if (rejectedToken && currentAuthStore.token === rejectedToken) { currentAuthStore.clearToken() } return new AuthenticationRequiredError(cause, envelope) } function getApiClient(): AxiosInstance { if (!apiClientInstance) { apiClientInstance = axios.create({ headers: { 'Content-Type': 'application/json', }, }) } return apiClientInstance } export interface ApiClientOptions { baseUrl?: string timeout?: number withAuth?: boolean /** Normalize failed responses into MintApiError. Generated clients enable this by default. */ typedErrors?: boolean } export interface UseApiReturn { client: AxiosInstance get: (url: string, config?: AxiosRequestConfig) => Promise post: (url: string, data?: unknown, config?: AxiosRequestConfig) => Promise put: (url: string, data?: unknown, config?: AxiosRequestConfig) => Promise patch: (url: string, data?: unknown, config?: AxiosRequestConfig) => Promise delete: (url: string, config?: AxiosRequestConfig) => Promise upload: (url: string, file: File, fieldName?: string, additionalData?: Record) => Promise download: (url: string, filename?: string) => Promise buildUrl: (path: string) => string buildWsUrl: (path: string) => string } /** Axios-based API client that injects the plugin base URL and JWT auth header on every request. */ export function useApi(options: ApiClientOptions = {}): UseApiReturn { const settingsStore = useSettingsStore() const authStore = useAuthStore() const apiClient = getApiClient() // Ensure auth store is initialized (reads token from localStorage) if (!authStore.isInitialized) { authStore.initialize() } // Attach auth interceptor only once (reads token dynamically, not from closure) if (!interceptorAttached) { apiClient.interceptors.request.use((config) => { const request = config as MintInternalAxiosRequestConfig if (request._mintSkipAuth) { deleteAuthorizationHeader(request.headers) return request } const currentAuthStore = useAuthStore() if (currentAuthStore.token && config.headers && !hasAuthorizationHeader(config.headers)) { setAuthorizationHeader(config.headers, `Bearer ${currentAuthStore.token}`) } if (getAuthorizationHeader(config.headers) === `Bearer ${currentAuthStore.token}`) { request._mintAuthToken = currentAuthStore.token ?? undefined } return config }) apiClient.interceptors.response.use( (response) => response, async (error: unknown) => { if (axios.isAxiosError(error) && error.response) { const request = error.config as MintInternalAxiosRequestConfig | undefined if (request?._mintTypedErrors) { let payload: unknown = error.response.data if (payload instanceof Blob) { try { payload = JSON.parse(await payload.text()) } catch { // Keep non-JSON download errors available as the original body. } } const typed = mintApiErrorFromResponse( error.response.status, payload, { requestId: String(error.response.headers?.['x-request-id'] ?? '') || null, cause: error, }, ) if (error.response.status === 401 && request._mintSkipAuth !== true) { return Promise.reject(authenticationRequiredError(error, request._mintAuthToken, typed)) } return Promise.reject(typed) } if (error.response.status === 401) { if (request?._mintSkipAuth !== true) { return Promise.reject(authenticationRequiredError(error, request?._mintAuthToken)) } } } return Promise.reject(error) }, ) interceptorAttached = true } function getBaseUrl(): string { return options.baseUrl ?? settingsStore.getApiBaseUrl() } function normalizeUrl(url: string): string { return normalizeRequestUrl(getBaseUrl(), url) } // Build per-request config that applies this caller's options function requestConfig(config?: AxiosRequestConfig): MintAxiosRequestConfig { const base: MintAxiosRequestConfig = { baseURL: getBaseUrl(), timeout: options.timeout ?? settingsStore.requestTimeout, _mintTypedErrors: options.typedErrors, ...config, } // Strip auth header if explicitly disabled if (options.withAuth === false) { base._mintSkipAuth = true deleteAuthorizationHeader(base.headers) } return base } // Generic request methods async function get(url: string, config?: AxiosRequestConfig): Promise { const response = await apiClient.get(normalizeUrl(url), requestConfig(config)) return response.data } async function post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { const response = await apiClient.post(normalizeUrl(url), data, requestConfig(config)) return response.data } async function put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { const response = await apiClient.put(normalizeUrl(url), data, requestConfig(config)) return response.data } async function patch(url: string, data?: unknown, config?: AxiosRequestConfig): Promise { const response = await apiClient.patch(normalizeUrl(url), data, requestConfig(config)) return response.data } async function del(url: string, config?: AxiosRequestConfig): Promise { const response = await apiClient.delete(normalizeUrl(url), requestConfig(config)) return response.data } // File upload helper async function upload(url: string, file: File, fieldName = 'file', additionalData?: Record): Promise { const formData = new FormData() formData.append(fieldName, file) if (additionalData) { Object.entries(additionalData).forEach(([key, value]) => { if (value !== undefined && value !== null) { formData.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value)) } }) } const response = await apiClient.post(normalizeUrl(url), formData, requestConfig({ // Let Axios set Content-Type with the correct multipart boundary headers: { 'Content-Type': undefined }, })) return response.data } // Download helper - returns blob URL async function download(url: string, filename?: string): Promise { const response = await apiClient.get(normalizeUrl(url), requestConfig({ responseType: 'blob' })) const blob = new Blob([response.data]) const blobUrl = URL.createObjectURL(blob) // Optionally trigger download if (filename) { const link = document.createElement('a') link.href = blobUrl link.download = filename document.body.appendChild(link) link.click() document.body.removeChild(link) // Revoke after a short delay to allow download to start; // return empty string since the URL will be invalidated setTimeout(() => URL.revokeObjectURL(blobUrl), 100) return '' } // Caller is responsible for revoking when no filename is provided return blobUrl } // Build full URL for external use (e.g., ) function buildUrl(path: string): string { const baseUrl = getBaseUrl() return joinUrlPath(baseUrl, normalizeRequestUrl(baseUrl, path)) } // WebSocket URL builder function buildWsUrl(path: string): string { return joinUrlPath(settingsStore.getWsBaseUrl(), path) } return { client: apiClient, get, post, put, patch, delete: del, upload, download, buildUrl, buildWsUrl, } }