Files
MoShiro/web/shared/api/client.ts
T

111 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { authHeaders, isRemote, resolveUrl } from './source'
/** 后端 API 客户端:默认同源请求(开发时由 Vite 代理到后端),Cookie 会话,修改类请求带 CSRF 令牌;指到别的后端时用 Bearer 令牌(见 source.ts)。 */
export class ApiError extends Error {
readonly status: number
readonly code: string
readonly detail: string
/** 字段级的校验错误:字段名 → 信息。 */
readonly errors: Record<string, string[]>
constructor(status: number, code: string, detail: string, errors: Record<string, string[]> = {}) {
super(detail || code)
this.status = status
this.code = code
this.detail = detail
this.errors = errors
}
}
let csrfToken: string | null = null
export function setCsrf(token: string | null) {
csrfToken = token
}
export type Query = Record<string, string | number | boolean | null | undefined>
export function withQuery(path: string, query?: Query): string {
if (!query) return path
const params = new URLSearchParams()
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null && v !== '') params.set(k, String(v))
}
const text = params.toString()
return text ? `${path}?${text}` : path
}
/**
* 敏感操作(加节点、吊销、改凭证)需要先触碰 passkey 开一个提权窗口:后端返回 403 `elevation_required`(detail 是窗口种类)时,
* 调用已注册的处理函数(弹出 passkey 触碰),成功后把原请求重试一次。
*/
let elevationHandler: ((elevation: string) => Promise<boolean>) | null = null
export function setElevationHandler(handler: ((elevation: string) => Promise<boolean>) | null) {
elevationHandler = handler
}
async function request<T>(method: string, path: string, body?: unknown, init?: RequestInit): Promise<T> {
try {
return await send<T>(method, path, body, init)
} catch (e) {
if (e instanceof ApiError && e.code === 'elevation_required' && elevationHandler) {
if (await elevationHandler(e.detail)) return send<T>(method, path, body, init)
throw new ApiError(403, e.code, '这个操作需要先用 Passkey 确认,已取消')
}
throw e
}
}
async function send<T>(method: string, path: string, body?: unknown, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() }
if (body !== undefined) headers['Content-Type'] = method === 'PATCH' ? 'application/merge-patch+json' : 'application/json'
if (method !== 'GET' && csrfToken) headers['X-CSRF-Token'] = csrfToken
const response = await fetch(resolveUrl(path), {
method,
headers,
credentials: isRemote() ? 'omit' : 'include',
body: body === undefined ? undefined : JSON.stringify(body),
...init,
})
if (response.status === 204) return undefined as T
const text = await response.text()
const data = text ? (JSON.parse(text) as unknown) : undefined
if (!response.ok) {
const problem = (data ?? {}) as { code?: string; detail?: string; title?: string; errors?: Record<string, string[]> | null }
throw new ApiError(response.status, problem.code ?? 'error', problem.detail ?? problem.title ?? response.statusText, problem.errors ?? {})
}
return data as T
}
/**
* 在另一个后端上用用户名和密码登录:不设 Cookie(跨域用不了),把会话令牌放在响应里返回(`return_session`),
* 调用方存进浏览器,之后每个请求带上。
*/
export async function loginTo(base: string, username: string, password: string): Promise<string> {
const response = await fetch(`${base}/api/admin/auth/login`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
credentials: 'omit',
body: JSON.stringify({ username, password, return_session: true }),
})
const text = await response.text()
const data = text ? (JSON.parse(text) as unknown) : undefined
if (!response.ok) {
const problem = (data ?? {}) as { code?: string; detail?: string; title?: string }
throw new ApiError(response.status, problem.code ?? 'error', problem.detail ?? problem.title ?? response.statusText)
}
const token = (data as { session_token?: string | null } | undefined)?.session_token
if (!token) throw new ApiError(502, 'no_session', '这个后端的版本还不支持跨域登录,请先升级后端')
return token
}
export const api = {
get: <T>(path: string, query?: Query, init?: RequestInit) => request<T>('GET', withQuery(path, query), undefined, init),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
del: <T>(path: string) => request<T>('DELETE', path),
}