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:
@@ -1,22 +1,33 @@
|
|||||||
// 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 normalizeHeaders = (headers?: HeadersInit): Record<string, string> => {
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
if (!headers){
|
if (!headers) return result;
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (headers instanceof Headers) {
|
if (headers instanceof Headers) {
|
||||||
headers.forEach((value, key) => {
|
headers.forEach((value, key) => {
|
||||||
@@ -25,19 +36,18 @@ const normalizeHeaders = (headers?: HeadersInit):Record<string, string> =>{
|
|||||||
} 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 }),
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user