diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 13e7a06..34d7cb5 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -37,7 +37,7 @@ pages -> features -> entities -> shared | `pages` | 八个路由页面 | | `features` | 用户操作:角色设置、生成、审核、导出;以及流程推进 `workflow-controller` | | `entities` | 上表业务模块 | -| `shared` | 无业务语义的形状,目前只有分页 | +| `shared` | 无业务语义的分页形状、HTTP 传输与通用 UI | `app` 只做启动和路由,不构造服务、不向下注入。 @@ -50,6 +50,8 @@ pages -> features -> entities -> shared 3. 跨模块只从模块目录的 `index.ts` 进入;`entities` 统一从 `@/entities` 使用。 4. `entities` 内部模块之间可以互相导入,对外仍是一个门。 +`shared/api` 只处理后端所有模块共用的传输契约:从环境读取 API 地址、附加调用方提供的 access token、解包统一响应、识别业务码并转换分页字段。登录模块通过 `registerApiAccessTokenProvider` 注册惰性读取函数,各业务 API 统一使用 `getApiAccessToken`;公共层只保存读取函数,不保存、刷新或解析 token。各 `XxxApis` 的路径、字段映射与实例仍跟随对应 `entities` 模块。 + --- ## 3. 接口命名 diff --git a/frontend/README.md b/frontend/README.md index 2976fd5..f64163f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -29,4 +29,8 @@ CI 按上面顺序全跑一遍。 页面自己决定宽度与留白,`AppShell` 只提供顶栏,不再统一夹一个居中容器。 +`shared/api` 提供后续业务接口可复用的公共 HTTP 请求能力。 + +运行项目前需要配置 `VITE_API_BASE_URL`。Bearer token 由登录模块取得后,通过 `registerApiAccessTokenProvider` 注册读取函数;业务请求统一从该边界读取。本轮不定义 token 的保存方式。 + 与后端尚未对齐的接口见 `API_CONTRACT.md`。 diff --git a/frontend/src/shared/README.md b/frontend/src/shared/README.md index 1b3dddd..1e7743d 100644 --- a/frontend/src/shared/README.md +++ b/frontend/src/shared/README.md @@ -9,13 +9,16 @@ ## 现有内容 - `pagination/` —— 与传输协议无关的分页请求与结果形状。 +- `api/` —— 后端公共 HTTP 客户端:统一响应解包、业务码、分页、Bearer 请求头和传输错误。 + +`api/` 默认从 `VITE_API_BASE_URL` 读取服务地址,并在发出请求时调用可选的 `getAccessToken`。业务 API 统一使用 `getApiAccessToken`,后续登录模块通过 `registerApiAccessTokenProvider` 注册实际读取函数。公共层只保存这个函数,不决定 token 如何登录取得、保存或刷新。 ## 后续允许放入 - `ui/` —— 按钮、弹窗、加载状态等不含业务含义的展示组件 - `hooks/` —— 通用浏览器或 React 行为,例如媒体查询、键盘快捷键 - `utils/` —— 纯函数工具,例如日期格式化、文件大小显示 -- `config/` —— 前端通用常量与运行时配置读取 +- `config/` —— `api/` 之外的前端通用常量与运行时配置读取 **这些目录只在出现真实代码时创建,不为占位提前建空文件。** diff --git a/frontend/src/shared/api/index.test.ts b/frontend/src/shared/api/index.test.ts new file mode 100644 index 0000000..2c2dfe4 --- /dev/null +++ b/frontend/src/shared/api/index.test.ts @@ -0,0 +1,268 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + ApiError, + createApiClient, + getApiAccessToken, + registerApiAccessTokenProvider, +} from './index' + +afterEach(() => vi.unstubAllEnvs()) + +describe('createApiClient', () => { + it('reads the latest registered token provider and restores the previous provider', () => { + const unregisterFirst = registerApiAccessTokenProvider(() => 'first-token') + const unregisterSecond = registerApiAccessTokenProvider(() => 'second-token') + + expect(getApiAccessToken()).toBe('second-token') + unregisterSecond() + expect(getApiAccessToken()).toBe('first-token') + unregisterFirst() + expect(getApiAccessToken()).toBeUndefined() + }) + + it('returns data from a successful backend response envelope', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: { id: 7 }, + }), + { headers: { 'content-type': 'application/json' } }, + ), + }) + + await expect(client.request<{ id: number }>('/resources/7')).resolves.toEqual({ id: 7 }) + }) + + it('rejects a backend business error even when HTTP status is 200', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response( + JSON.stringify({ + code: 400, + message: '请求参数错误', + data: { field: 'name' }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + }) + + const error = await client.request('/resources').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ + kind: 'business', + code: 400, + status: 200, + message: '请求参数错误', + data: { field: 'name' }, + }) + }) + + it('maps a successful list response to a camel-case list result', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: [{ id: 7 }, { id: 8 }], + total: 42, + page: 2, + page_size: 20, + }), + { headers: { 'content-type': 'application/json' } }, + ), + }) + + await expect(client.requestList<{ id: number }>('/resources')).resolves.toEqual({ + items: [{ id: 7 }, { id: 8 }], + total: 42, + page: 2, + pageSize: 20, + }) + }) + + it('serializes query values and a JSON request body', async () => { + let capturedRequest: Request | undefined + const client = createApiClient({ + baseUrl: 'https://api.windup.test/', + fetchFn: async (input, init) => { + capturedRequest = new Request(input, init) + return new Response(JSON.stringify({ code: 200, message: 'success', data: null })) + }, + }) + + await client.request('/resources', { + method: 'POST', + query: { page: 2, page_size: 20, user_id: null }, + json: { name: 'sample' }, + }) + + if (!capturedRequest) throw new Error('request was not sent') + expect(capturedRequest.url).toBe('https://api.windup.test/resources?page=2&page_size=20') + expect(capturedRequest.headers.get('content-type')).toBe('application/json') + await expect(capturedRequest.json()).resolves.toEqual({ name: 'sample' }) + }) + + it('adds the current access token as a Bearer authorization header', async () => { + let authorization: string | null = null + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + getAccessToken: () => 'access-token', + fetchFn: async (input, init) => { + authorization = new Request(input, init).headers.get('authorization') + return new Response(JSON.stringify({ code: 200, message: 'success', data: null })) + }, + }) + + await client.request('/auth/me') + + expect(authorization).toBe('Bearer access-token') + }) + + it('wraps a rejected fetch as a network ApiError', async () => { + const connectionError = new TypeError('Failed to fetch') + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => Promise.reject(connectionError), + }) + + const error = await client.request('/resources').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ + kind: 'network', + code: null, + status: null, + message: '网络请求失败', + cause: connectionError, + }) + }) + + it('rejects a successful HTTP response that does not match the backend envelope', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ data: { id: 7 } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + }) + + const error = await client.request('/resources/7').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ + kind: 'invalid-response', + code: null, + status: 200, + message: '后端响应格式无效', + }) + }) + + it('rejects a list response with invalid pagination fields', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: [{ id: 7 }], + total: 1, + page: 1, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + }) + + const error = await client.requestList('/resources').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ + kind: 'invalid-response', + status: 200, + message: '后端列表响应格式无效', + }) + }) + + it('reports a non-envelope HTTP failure as an HTTP ApiError', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => new Response('gateway unavailable', { status: 503 }), + }) + + const error = await client.request('/resources').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ + kind: 'http', + code: null, + status: 503, + }) + }) + + it('prioritizes an HTTP failure when the response also has a non-200 business code', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response( + JSON.stringify({ + code: 500, + message: '服务暂时不可用', + data: { request_id: 'request-7' }, + }), + { status: 503, headers: { 'content-type': 'application/json' } }, + ), + }) + + const error = await client.request('/resources').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ + kind: 'http', + code: null, + status: 503, + message: 'HTTP 请求失败', + data: { request_id: 'request-7' }, + }) + }) + + it('does not accept a success envelope carried by a failed HTTP response', async () => { + const client = createApiClient({ + baseUrl: 'https://api.windup.test', + fetchFn: async () => + new Response(JSON.stringify({ code: 200, message: 'success', data: { id: 7 } }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }), + }) + + const error = await client.request('/resources/7').catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ kind: 'http', code: null, status: 500 }) + }) + + it('uses VITE_API_BASE_URL when an explicit base URL is not provided', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test/root/') + let requestUrl = '' + const client = createApiClient({ + fetchFn: async (input) => { + requestUrl = String(input) + return new Response(JSON.stringify({ code: 200, message: 'success', data: null })) + }, + }) + + await client.request('/resources') + + expect(requestUrl).toBe('https://api.windup.test/root/resources') + }) +}) diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts new file mode 100644 index 0000000..06f8ad8 --- /dev/null +++ b/frontend/src/shared/api/index.ts @@ -0,0 +1,235 @@ +export type ApiQueryValue = string | number | boolean | null | undefined + +export type ApiRequestOptions = Omit & { + body?: BodyInit | null + json?: unknown + query?: Record +} + +/** 后端 ListResponse 解包后的传输结果。 */ +export interface ApiListResult { + items: T[] + total: number + page: number + pageSize: number +} + +export interface ApiClient { + request(path: string, options?: ApiRequestOptions): Promise + requestList(path: string, options?: ApiRequestOptions): Promise> +} + +export interface ApiClientOptions { + /** 默认读取 VITE_API_BASE_URL;测试或独立环境可显式覆盖。 */ + baseUrl?: string + fetchFn?: typeof fetch + /** 只在请求发出时读取;token 的取得、保存与刷新由调用方负责。 */ + getAccessToken?: () => string | null | undefined +} + +export type ApiAccessTokenProvider = NonNullable + +const accessTokenProviders: ApiAccessTokenProvider[] = [] + +/** + * 注册登录模块持有的 token 读取函数;这里只保存读取函数,不保存、刷新或解析 token。 + * 返回值用于模块卸载或测试结束时撤销本次注册。 + */ +export function registerApiAccessTokenProvider(provider: ApiAccessTokenProvider): () => void { + accessTokenProviders.push(provider) + return () => { + const index = accessTokenProviders.lastIndexOf(provider) + if (index >= 0) accessTokenProviders.splice(index, 1) + } +} + +/** 业务 API 实例统一传给 createApiClient 的惰性 token 读取边界。 */ +export function getApiAccessToken(): string | null | undefined { + return accessTokenProviders.at(-1)?.() +} + +export type ApiErrorKind = 'business' | 'http' | 'invalid-response' | 'network' + +/** 后端业务错误与传输错误统一进入这一种前端错误。 */ +export class ApiError extends Error { + readonly kind: ApiErrorKind + readonly code: number | null + readonly status: number | null + readonly data: unknown + + constructor( + message: string, + options: { + kind: ApiErrorKind + code?: number | null + status?: number | null + data?: unknown + cause?: unknown + }, + ) { + super(message, { cause: options.cause }) + this.name = 'ApiError' + this.kind = options.kind + this.code = options.code ?? null + this.status = options.status ?? null + this.data = options.data ?? null + } +} + +interface ApiEnvelope { + code: number + message: string + data: unknown + [key: string]: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +async function readEnvelope(response: Response): Promise { + let value: unknown + try { + value = await response.json() + } catch (cause) { + throw new ApiError(response.ok ? '后端响应格式无效' : 'HTTP 请求失败', { + kind: response.ok ? 'invalid-response' : 'http', + status: response.status, + cause, + }) + } + + if ( + !isRecord(value) || + typeof value.code !== 'number' || + typeof value.message !== 'string' || + !Object.hasOwn(value, 'data') + ) { + throw new ApiError(response.ok ? '后端响应格式无效' : 'HTTP 请求失败', { + kind: response.ok ? 'invalid-response' : 'http', + status: response.status, + data: value, + }) + } + + return value as ApiEnvelope +} + +/** 后端业务异常仍返回 HTTP 200,因此不能只依赖 Response.ok。 */ +function assertSuccessfulEnvelope(response: Response, envelope: ApiEnvelope): void { + if (!response.ok) { + throw new ApiError('HTTP 请求失败', { + kind: 'http', + status: response.status, + data: envelope.data, + }) + } + + if (envelope.code !== 200) { + throw new ApiError(envelope.message, { + kind: 'business', + code: envelope.code, + status: response.status, + data: envelope.data, + }) + } +} + +function buildUrl( + baseUrl: string, + path: string, + query: Record | undefined, +): string { + const url = `${baseUrl}/${path.replace(/^\/+/, '')}` + if (!query) return url + + const search = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + if (value !== null && value !== undefined) search.set(key, String(value)) + } + + const serialized = search.toString() + if (!serialized) return url + return `${url}${url.includes('?') ? '&' : '?'}${serialized}` +} + +function buildRequestInit( + options: ApiRequestOptions | undefined, + accessToken: string | null | undefined, +): RequestInit | undefined { + if (!options && !accessToken) return undefined + + const { json, query: _query, headers: inputHeaders, ...init } = options ?? {} + const headers = new Headers(inputHeaders) + if (accessToken && !headers.has('authorization')) { + headers.set('authorization', `Bearer ${accessToken}`) + } + if (json === undefined) return { ...init, headers } + + if (!headers.has('content-type')) headers.set('content-type', 'application/json') + return { ...init, headers, body: JSON.stringify(json) } +} + +export function resolveApiBaseUrl(baseUrl = import.meta.env.VITE_API_BASE_URL): string { + const normalized = baseUrl?.trim().replace(/\/+$/, '') + if (!normalized) throw new Error('VITE_API_BASE_URL 未配置') + return normalized +} + +/** 创建一个只负责 HTTP 传输与公共响应解包的客户端。 */ +export function createApiClient({ + baseUrl, + fetchFn = globalThis.fetch, + getAccessToken, +}: ApiClientOptions): ApiClient { + const normalizedBaseUrl = resolveApiBaseUrl(baseUrl) + + async function send(path: string, options: ApiRequestOptions | undefined): Promise { + const url = buildUrl(normalizedBaseUrl, path, options?.query) + const init = buildRequestInit(options, getAccessToken?.()) + try { + return await fetchFn(url, init) + } catch (cause) { + throw new ApiError('网络请求失败', { kind: 'network', cause }) + } + } + + return { + async request(path: string, options?: ApiRequestOptions) { + const response = await send(path, options) + const envelope = await readEnvelope(response) + assertSuccessfulEnvelope(response, envelope) + + return envelope.data as T + }, + /** + * 后端 ListResponse 固定为 { data: T[], total, page, page_size }; + * 分页字段与 data 同级,不在 data 内再嵌套分页对象。 + */ + async requestList(path: string, options?: ApiRequestOptions) { + const response = await send(path, options) + const envelope = await readEnvelope(response) + assertSuccessfulEnvelope(response, envelope) + + if ( + !Array.isArray(envelope.data) || + !Number.isInteger(envelope.total) || + !Number.isInteger(envelope.page) || + !Number.isInteger(envelope.page_size) + ) { + throw new ApiError('后端列表响应格式无效', { + kind: 'invalid-response', + status: response.status, + data: envelope, + }) + } + + return { + items: envelope.data as T[], + total: envelope.total as number, + page: envelope.page as number, + pageSize: envelope.page_size as number, + } + }, + } +}