feat(canary): axios client with Turnstile header interceptor
- api/client.ts: axios instance with baseURL from VITE_API_URL env
(falls back to /api) and 15s timeout.
- Request interceptor adds CF-Turnstile-Response header from a
pluggable provider (setTurnstileTokenProvider). Backend's
middleware/turnstile.go reads header first then body field; using
the header keeps the request body shape clean and matches the plan.
- Response interceptor routes axios errors through transformAxiosError
(canary envelope-aware) so callers see typed ApiError everywhere.
- Envelope-aware helpers apiGet / apiPost / apiDelete:
- apiGet/apiPost unwrap {success:true,data:T} via successEnvelope
factory; on shape mismatch throw ApiError(PARSE_ERROR).
- apiDelete returns void for 204 responses; envelope error path
handled by the interceptor.
- Plumbed into api/index.ts barrel.
This commit is contained in:
parent
db98a0d497
commit
2d13e649a6
|
|
@ -0,0 +1,86 @@
|
||||||
|
// ===================
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// client.ts
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
|
||||||
|
import type { ZodType } from 'zod'
|
||||||
|
import { ApiError, ApiErrorCode, transformAxiosError } from '@/core/api'
|
||||||
|
import { successEnvelope } from './types/error'
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 15000
|
||||||
|
const TURNSTILE_HEADER_NAME = 'CF-Turnstile-Response'
|
||||||
|
|
||||||
|
const resolveBaseURL = (): string => {
|
||||||
|
const fromEnv = import.meta.env.VITE_API_URL
|
||||||
|
if (typeof fromEnv === 'string' && fromEnv.length > 0) {
|
||||||
|
return fromEnv
|
||||||
|
}
|
||||||
|
return '/api'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: resolveBaseURL(),
|
||||||
|
timeout: REQUEST_TIMEOUT_MS,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TurnstileTokenProvider = () => string | null | undefined
|
||||||
|
|
||||||
|
let turnstileProvider: TurnstileTokenProvider | null = null
|
||||||
|
|
||||||
|
export function setTurnstileTokenProvider(
|
||||||
|
provider: TurnstileTokenProvider | null
|
||||||
|
): void {
|
||||||
|
turnstileProvider = provider
|
||||||
|
}
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
|
||||||
|
const token = turnstileProvider?.()
|
||||||
|
if (typeof token === 'string' && token.length > 0) {
|
||||||
|
config.headers.set(TURNSTILE_HEADER_NAME, token)
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error: unknown) => {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
return Promise.reject(transformAxiosError(error))
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function unwrapEnvelope<T>(data: unknown, schema: ZodType<T>, status: number): T {
|
||||||
|
const parsed = successEnvelope(schema).safeParse(data)
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new ApiError(
|
||||||
|
'response shape mismatch',
|
||||||
|
ApiErrorCode.PARSE_ERROR,
|
||||||
|
status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return parsed.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGet<T>(path: string, schema: ZodType<T>): Promise<T> {
|
||||||
|
const response = await apiClient.get<unknown>(path)
|
||||||
|
return unwrapEnvelope(response.data, schema, response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost<T>(
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
schema: ZodType<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await apiClient.post<unknown>(path, body)
|
||||||
|
return unwrapEnvelope(response.data, schema, response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDelete(path: string): Promise<void> {
|
||||||
|
await apiClient.delete(path)
|
||||||
|
}
|
||||||
|
|
@ -3,5 +3,6 @@
|
||||||
// index.ts
|
// index.ts
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
|
export * from './client'
|
||||||
export * from './hooks'
|
export * from './hooks'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue