From c1138cd56871f7ec53fc2e692f760c0b65da3f56 Mon Sep 17 00:00:00 2001 From: Chaos Date: Mon, 1 Dec 2025 08:51:13 +0800 Subject: [PATCH] feat(api): enhance http client with formdata and sveltekit support - Add support for FormData in request body handling - Implement custom fetch option for SvelteKit integration - Fix content-type header handling for multipart requests - Improve error handling and logging - Support absolute URLs in API requests - Add proper typing for API result responses - Handle HTTP 204 no-content responses correctly - Update method signatures to accept FormData bodies - Normalize headers case-insensitively - Remove explicit body on GET/HEAD requests --- src/lib/api/httpClient.ts | 121 ++++++++++++++++++++++---------------- 1 file changed, 70 insertions(+), 51 deletions(-) diff --git a/src/lib/api/httpClient.ts b/src/lib/api/httpClient.ts index 0b441e2..caf375d 100644 --- a/src/lib/api/httpClient.ts +++ b/src/lib/api/httpClient.ts @@ -1,43 +1,53 @@ // src/lib/api/httpClient.ts -import type { HttpMethod, JsonObject, JsonValue } from '$lib/types/http.ts'; -import type { ApiResult } from '$lib/types/api.ts'; +import { browser } from '$app/environment'; // SvelteKit 环境变量 import { log } from '$lib/log.ts'; +// 1. 完善类型定义,确保 FormData 被允许 +// 假设 JsonValue 和 JsonObject 在你的 types/http.ts 中定义 +// 这里为了示例完整性,我做了一个简单的宽泛定义,你可以替换回你的 import +type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; +interface JsonObject { [key: string]: JsonValue } +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'; +export interface ApiResult { + code: number; + msg: string; + data: T; +} + +// 扩展 RequestOptions 以支持 SvelteKit 的 custom fetch interface RequestOptions extends Omit { body?: JsonObject | FormData | object; + customFetch?: typeof fetch; // 允许传入 SvelteKit 的 load fetch } + const API_BASE_URL = import.meta.env.VITE_PUBLIC_API_URL || 'http://localhost:18888/api'; +// 辅助函数:规范化 Headers +const normalizeHeaders = (headers?: HeadersInit): Record => { + const result: Record = {}; + if (!headers) return result; - -const normalizeHeaders = (headers?: HeadersInit):Record =>{ - const result:Record = {}; - if (!headers){ - return result; - } - - if (headers instanceof Headers){ + if (headers instanceof Headers) { headers.forEach((value, key) => { result[key.toLowerCase()] = value; }); - }else if (Array.isArray(headers)){ + } else if (Array.isArray(headers)) { headers.forEach(([key, value]) => { - result[key.toLowerCase()] = value; - }) - }else { + result[key.toLowerCase()] = value; + }); + } else { Object.keys(headers).forEach(key => { const value = (headers as Record)[key]; if (value !== undefined && value !== null) { result[key.toLowerCase()] = value; } - }) + }); } - - return result; -} +}; + export class HttpError extends Error { public status: number; public details: JsonValue | string; @@ -47,88 +57,97 @@ export class HttpError extends Error { this.name = 'HttpError'; this.status = status; this.details = details; - // 保持正确的原型链 - if (Error.captureStackTrace) { - Error.captureStackTrace(this, HttpError); - } } } - const httpRequest = async ( url: string, method: HttpMethod, options: RequestOptions = {} ): Promise> => { - const fullUrl = `${API_BASE_URL}${url}`; + // 拼接 URL + const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url}`; - log.info('API Request:', method, fullUrl) - const { body , headers, ...rest } = options; + // 2. 提取 customFetch,优先使用传入的 fetch (解决 SSR Cookie 问题) + const { body, headers, customFetch, ...rest } = options; + const fetchImpl = customFetch || fetch; const requestHeaders: Record = normalizeHeaders(headers); let requestBody: BodyInit | undefined; - const canHaveBody = method !== 'GET' ; + const canHaveBody = method !== 'GET' && method !== 'HEAD'; + // 3. 处理 Body 和 Content-Type 的核心逻辑 if (canHaveBody) { if (body instanceof FormData) { requestBody = body; + // 【关键修复】如果是 FormData,必须删除 Content-Type,让浏览器自动设置 boundary + delete requestHeaders['content-type']; } else if (body) { + // 如果是普通对象,强制设置为 JSON requestHeaders['content-type'] = 'application/json'; requestBody = JSON.stringify(body); } } - - try { + if (browser) { + console.debug(`[API] ${method} ${fullUrl}`, { body: requestBody, headers: requestHeaders }); + } - log.debug('API Request Body:', requestBody) - const response = await fetch(fullUrl, { + const response = await fetchImpl(fullUrl, { method, headers: requestHeaders, - // 【修改点 3】:确保 GET 请求的 body 显式为 undefined - // 虽然通常 undefined 是被允许的,但加上 canHaveBody 判断更加严谨 body: canHaveBody ? requestBody : undefined, ...rest }); - log.debug('API Response:', response) + if (!response.ok) { - let errorDetail; - try { errorDetail = await response.json(); - } catch (e) { - console.error('Error parsing JSON:', e); + log.error('Error parsing JSON response:', e) errorDetail = await response.text(); } const message = `HTTP Error ${response.status} (${response.statusText})`; - log.warn(message) + console.warn(message, errorDetail); throw new HttpError(message, response.status, errorDetail); } + // 处理空响应 + if (response.status === 204) { + return { code: 200, msg: 'OK', data: null as unknown as T }; + } + const contentType = response.headers.get('Content-Type'); if (contentType && contentType.includes('application/json')) { const res = await response.json(); - log.info('API Response:', res) - return (res) as ApiResult; + return res as ApiResult; } - return { code: 200, msg: 'OK', data: null } ; // 这里的 as any 是为了兼容 T 可能是 null 的情况 + return { code: 200, msg: 'OK', data: null as unknown as T }; - } catch (error) { - console.error(`API Request Failed to ${fullUrl}:`, error); - throw error; + } catch (err) { + console.error(`API Request Failed to ${fullUrl}:`, err); + throw err; } }; - +// 4. 更新 API 方法签名,允许 body 为 FormData export const api = { - get: (url: string, options?: RequestOptions) => httpRequest(url, 'GET', options), - post: (url: string, body: JsonObject, options?: RequestOptions) => httpRequest(url, 'POST', { ...options, body }), - put: (url: string, body: JsonObject, options?: RequestOptions) => httpRequest(url, 'PUT', { ...options, body }), - delete: (url: string, options?: RequestOptions) => httpRequest(url, 'DELETE', options), - patch: (url: string, body: JsonObject, options?: RequestOptions) => httpRequest(url, 'PATCH', { ...options, body }), + get: (url: string, options?: RequestOptions) => + httpRequest(url, 'GET', options), + + post: (url: string, body: JsonObject | FormData, options?: RequestOptions) => + httpRequest(url, 'POST', { ...options, body }), + + put: (url: string, body: JsonObject | FormData, options?: RequestOptions) => + httpRequest(url, 'PUT', { ...options, body }), + + delete: (url: string, options?: RequestOptions) => + httpRequest(url, 'DELETE', options), + + patch: (url: string, body: JsonObject | FormData, options?: RequestOptions) => + httpRequest(url, 'PATCH', { ...options, body }), }; \ No newline at end of file