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
This commit is contained in:
Chaos
2025-12-01 08:51:13 +08:00
parent 87892951f6
commit c1138cd568

View File

@@ -1,43 +1,53 @@
// src/lib/api/httpClient.ts // src/lib/api/httpClient.ts
import type { HttpMethod, JsonObject, JsonValue } from '$lib/types/http.ts'; import { browser } from '$app/environment'; // SvelteKit 环境变量
import type { ApiResult } from '$lib/types/api.ts';
import { log } from '$lib/log.ts'; 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<T> {
code: number;
msg: string;
data: T;
}
// 扩展 RequestOptions 以支持 SvelteKit 的 custom fetch
interface RequestOptions extends Omit<RequestInit, 'method' | 'body'> { interface RequestOptions extends Omit<RequestInit, 'method' | 'body'> {
body?: JsonObject | FormData | object; 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'; const API_BASE_URL = import.meta.env.VITE_PUBLIC_API_URL || 'http://localhost:18888/api';
// 辅助函数:规范化 Headers
const normalizeHeaders = (headers?: HeadersInit): Record<string, string> => {
const result: Record<string, string> = {};
if (!headers) return result;
if (headers instanceof Headers) {
const normalizeHeaders = (headers?: HeadersInit):Record<string, string> =>{
const result:Record<string,string> = {};
if (!headers){
return result;
}
if (headers instanceof Headers){
headers.forEach((value, key) => { headers.forEach((value, key) => {
result[key.toLowerCase()] = value; result[key.toLowerCase()] = value;
}); });
}else if (Array.isArray(headers)){ } else if (Array.isArray(headers)) {
headers.forEach(([key, value]) => { headers.forEach(([key, value]) => {
result[key.toLowerCase()] = value; result[key.toLowerCase()] = value;
}) });
}else { } else {
Object.keys(headers).forEach(key => { Object.keys(headers).forEach(key => {
const value = (headers as Record<string, string>)[key]; const value = (headers as Record<string, string>)[key];
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
result[key.toLowerCase()] = value; result[key.toLowerCase()] = value;
} }
}) });
} }
return result; return result;
} };
export class HttpError extends Error { export class HttpError extends Error {
public status: number; public status: number;
public details: JsonValue | string; public details: JsonValue | string;
@@ -47,88 +57,97 @@ export class HttpError extends Error {
this.name = 'HttpError'; this.name = 'HttpError';
this.status = status; this.status = status;
this.details = details; this.details = details;
// 保持正确的原型链
if (Error.captureStackTrace) {
Error.captureStackTrace(this, HttpError);
}
} }
} }
const httpRequest = async <T>( const httpRequest = async <T>(
url: string, url: string,
method: HttpMethod, method: HttpMethod,
options: RequestOptions = {} options: RequestOptions = {}
): Promise<ApiResult<T>> => { ): Promise<ApiResult<T>> => {
const fullUrl = `${API_BASE_URL}${url}`; // 拼接 URL
const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url}`;
log.info('API Request:', method, fullUrl) // 2. 提取 customFetch优先使用传入的 fetch (解决 SSR Cookie 问题)
const { body , headers, ...rest } = options; const { body, headers, customFetch, ...rest } = options;
const fetchImpl = customFetch || fetch;
const requestHeaders: Record<string, string> = normalizeHeaders(headers); const requestHeaders: Record<string, string> = normalizeHeaders(headers);
let requestBody: BodyInit | undefined; let requestBody: BodyInit | undefined;
const canHaveBody = method !== 'GET' ; const canHaveBody = method !== 'GET' && method !== 'HEAD';
// 3. 处理 Body 和 Content-Type 的核心逻辑
if (canHaveBody) { if (canHaveBody) {
if (body instanceof FormData) { if (body instanceof FormData) {
requestBody = body; requestBody = body;
// 【关键修复】如果是 FormData必须删除 Content-Type让浏览器自动设置 boundary
delete requestHeaders['content-type'];
} else if (body) { } else if (body) {
// 如果是普通对象,强制设置为 JSON
requestHeaders['content-type'] = 'application/json'; requestHeaders['content-type'] = 'application/json';
requestBody = JSON.stringify(body); requestBody = JSON.stringify(body);
} }
} }
try { try {
if (browser) {
console.debug(`[API] ${method} ${fullUrl}`, { body: requestBody, headers: requestHeaders });
}
log.debug('API Request Body:', requestBody) const response = await fetchImpl(fullUrl, {
const response = await fetch(fullUrl, {
method, method,
headers: requestHeaders, headers: requestHeaders,
// 【修改点 3】确保 GET 请求的 body 显式为 undefined
// 虽然通常 undefined 是被允许的,但加上 canHaveBody 判断更加严谨
body: canHaveBody ? requestBody : undefined, body: canHaveBody ? requestBody : undefined,
...rest ...rest
}); });
log.debug('API Response:', response)
if (!response.ok) { if (!response.ok) {
let errorDetail; let errorDetail;
try { try {
errorDetail = await response.json(); errorDetail = await response.json();
} catch (e) { } catch (e) {
console.error('Error parsing JSON:', e); log.error('Error parsing JSON response:', e)
errorDetail = await response.text(); errorDetail = await response.text();
} }
const message = `HTTP Error ${response.status} (${response.statusText})`; const message = `HTTP Error ${response.status} (${response.statusText})`;
log.warn(message) console.warn(message, errorDetail);
throw new HttpError(message, response.status, 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'); const contentType = response.headers.get('Content-Type');
if (contentType && contentType.includes('application/json')) { if (contentType && contentType.includes('application/json')) {
const res = await response.json(); const res = await response.json();
log.info('API Response:', res) return res as ApiResult<T>;
return (res) as ApiResult<T>;
} }
return { code: 200, msg: 'OK', data: null } ; // 这里的 as any 是为了兼容 T 可能是 null 的情况 return { code: 200, msg: 'OK', data: null as unknown as T };
} catch (error) { } catch (err) {
console.error(`API Request Failed to ${fullUrl}:`, error); console.error(`API Request Failed to ${fullUrl}:`, err);
throw error; throw err;
} }
}; };
// 4. 更新 API 方法签名,允许 body 为 FormData
export const api = { export const api = {
get: <T>(url: string, options?: RequestOptions) => httpRequest<T>(url, 'GET', options), get: <T>(url: string, options?: RequestOptions) =>
post: <T>(url: string, body: JsonObject, options?: RequestOptions) => httpRequest<T>(url, 'POST', { ...options, body }), httpRequest<T>(url, 'GET', options),
put: <T>(url: string, body: JsonObject, options?: RequestOptions) => httpRequest<T>(url, 'PUT', { ...options, body }),
delete: <T>(url: string, options?: RequestOptions) => httpRequest<T>(url, 'DELETE', options), post: <T>(url: string, body: JsonObject | FormData, options?: RequestOptions) =>
patch: <T>(url: string, body: JsonObject, options?: RequestOptions) => httpRequest<T>(url, 'PATCH', { ...options, body }), httpRequest<T>(url, 'POST', { ...options, body }),
put: <T>(url: string, body: JsonObject | FormData, options?: RequestOptions) =>
httpRequest<T>(url, 'PUT', { ...options, body }),
delete: <T>(url: string, options?: RequestOptions) =>
httpRequest<T>(url, 'DELETE', options),
patch: <T>(url: string, body: JsonObject | FormData, options?: RequestOptions) =>
httpRequest<T>(url, 'PATCH', { ...options, body }),
}; };