@vafast/api-client 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +202 -195
- package/TODO.md +83 -0
- package/example/auto-infer.ts +202 -0
- package/example/test-sse.ts +192 -0
- package/package.json +6 -7
- package/src/core/eden.ts +697 -0
- package/src/index.ts +34 -65
- package/src/types/index.ts +17 -116
- package/test/eden.test.ts +425 -0
- package/tsconfig.json +1 -1
- package/vitest.config.ts +8 -0
- package/example/index.ts +0 -255
- package/src/core/api-client.ts +0 -389
- package/src/core/typed-client.ts +0 -305
- package/src/utils/index.ts +0 -232
- package/src/websocket/websocket-client.ts +0 -347
- package/test/api-client.test.ts +0 -262
- package/test/basic.test.ts +0 -55
- package/test/typed-client.test.ts +0 -304
- package/test/utils.test.ts +0 -363
- package/test/websocket.test.ts +0 -434
package/src/index.ts
CHANGED
|
@@ -1,73 +1,42 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
} from '
|
|
1
|
+
/**
|
|
2
|
+
* Vafast API Client
|
|
3
|
+
*
|
|
4
|
+
* 类型安全的 Eden 风格 API 客户端
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { eden, InferEden } from '@vafast/api-client'
|
|
9
|
+
* import { defineRoutes, route, createHandler, Type } from 'vafast'
|
|
10
|
+
*
|
|
11
|
+
* // 定义路由(无需 as const)
|
|
12
|
+
* const routes = defineRoutes([
|
|
13
|
+
* route('GET', '/users', createHandler(...)),
|
|
14
|
+
* route('POST', '/users', createHandler(...))
|
|
15
|
+
* ])
|
|
16
|
+
*
|
|
17
|
+
* // 自动推断类型
|
|
18
|
+
* type Api = InferEden<typeof routes>
|
|
19
|
+
* const api = eden<Api>('http://localhost:3000')
|
|
20
|
+
*
|
|
21
|
+
* // 类型安全的调用
|
|
22
|
+
* const { data } = await api.users.get({ page: 1 })
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
9
25
|
|
|
10
|
-
//
|
|
11
|
-
export {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
26
|
+
// Eden 客户端(核心)
|
|
27
|
+
export {
|
|
28
|
+
eden,
|
|
29
|
+
type EdenConfig,
|
|
30
|
+
type EdenClient,
|
|
31
|
+
type InferEden,
|
|
32
|
+
type SSEEvent,
|
|
33
|
+
type SSESubscribeOptions,
|
|
34
|
+
type SSESubscription,
|
|
35
|
+
} from './core/eden'
|
|
16
36
|
|
|
17
37
|
// 类型定义
|
|
18
38
|
export type {
|
|
19
|
-
// 基础类型
|
|
20
39
|
HTTPMethod,
|
|
21
40
|
RequestConfig,
|
|
22
41
|
ApiResponse,
|
|
23
|
-
QueryParams,
|
|
24
|
-
PathParams,
|
|
25
|
-
RequestBody,
|
|
26
|
-
ApiClientConfig,
|
|
27
|
-
|
|
28
|
-
// 类型推断
|
|
29
|
-
InferRouteHandler,
|
|
30
|
-
InferServer,
|
|
31
|
-
RoutePath,
|
|
32
|
-
RouteMethod,
|
|
33
|
-
RouteHandlerType,
|
|
34
|
-
|
|
35
|
-
// WebSocket 类型
|
|
36
|
-
WebSocketEvent,
|
|
37
|
-
WebSocketClient,
|
|
38
|
-
|
|
39
|
-
// 文件类型
|
|
40
|
-
FileUpload,
|
|
41
|
-
ApiFormData,
|
|
42
|
-
|
|
43
|
-
// 中间件和拦截器
|
|
44
|
-
ApiMiddleware,
|
|
45
|
-
Interceptor,
|
|
46
|
-
|
|
47
|
-
// 配置类型
|
|
48
|
-
CacheConfig,
|
|
49
|
-
RetryConfig,
|
|
50
|
-
LogConfig
|
|
51
42
|
} from './types'
|
|
52
|
-
|
|
53
|
-
// 工具函数
|
|
54
|
-
export {
|
|
55
|
-
buildQueryString,
|
|
56
|
-
replacePathParams,
|
|
57
|
-
isFile,
|
|
58
|
-
isFileUpload,
|
|
59
|
-
hasFiles,
|
|
60
|
-
createFormData,
|
|
61
|
-
deepMerge,
|
|
62
|
-
delay,
|
|
63
|
-
exponentialBackoff,
|
|
64
|
-
validateStatus,
|
|
65
|
-
parseResponse,
|
|
66
|
-
createError,
|
|
67
|
-
cloneRequest,
|
|
68
|
-
isRetryableError
|
|
69
|
-
} from './utils'
|
|
70
|
-
|
|
71
|
-
// 默认导出
|
|
72
|
-
import { VafastApiClient } from './core/api-client'
|
|
73
|
-
export default VafastApiClient
|
package/src/types/index.ts
CHANGED
|
@@ -1,133 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// 简化的 vafast 类型定义
|
|
5
|
-
export interface Server {
|
|
6
|
-
routes: Record<string, Route>
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface Route {
|
|
10
|
-
method: string
|
|
11
|
-
path: string
|
|
12
|
-
handler: RouteHandler
|
|
13
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 类型定义
|
|
3
|
+
*/
|
|
14
4
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
5
|
+
/** HTTP 方法 */
|
|
6
|
+
export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS'
|
|
18
7
|
|
|
19
8
|
/**
|
|
20
9
|
* 请求配置
|
|
21
10
|
*/
|
|
22
11
|
export interface RequestConfig {
|
|
12
|
+
/** 请求头 */
|
|
23
13
|
headers?: Record<string, string>
|
|
14
|
+
/** 超时时间(毫秒) */
|
|
24
15
|
timeout?: number
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
body?: RequestBody
|
|
16
|
+
/** 取消信号 */
|
|
17
|
+
signal?: AbortSignal
|
|
28
18
|
}
|
|
29
19
|
|
|
30
|
-
|
|
20
|
+
/**
|
|
21
|
+
* API 响应
|
|
22
|
+
*/
|
|
31
23
|
export interface ApiResponse<T = unknown> {
|
|
24
|
+
/** 响应数据 */
|
|
32
25
|
data: T | null
|
|
26
|
+
/** 错误信息 */
|
|
33
27
|
error: Error | null
|
|
28
|
+
/** HTTP 状态码 */
|
|
34
29
|
status: number
|
|
30
|
+
/** 响应头 */
|
|
35
31
|
headers: Headers
|
|
32
|
+
/** 原始响应对象 */
|
|
36
33
|
response: Response
|
|
37
34
|
}
|
|
38
|
-
|
|
39
|
-
// 查询参数类型
|
|
40
|
-
export type QueryParams = Record<string, string | number | boolean | undefined | null>
|
|
41
|
-
|
|
42
|
-
// 路径参数类型
|
|
43
|
-
export type PathParams = Record<string, string | number>
|
|
44
|
-
|
|
45
|
-
// 请求体类型
|
|
46
|
-
export type RequestBody = unknown
|
|
47
|
-
|
|
48
|
-
// API 客户端配置
|
|
49
|
-
export interface ApiClientConfig extends RequestConfig {
|
|
50
|
-
baseURL?: string
|
|
51
|
-
defaultHeaders?: Record<string, string>
|
|
52
|
-
timeout?: number
|
|
53
|
-
retries?: number
|
|
54
|
-
retryDelay?: number
|
|
55
|
-
validateStatus?: (status: number) => boolean
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// 类型推断类型
|
|
59
|
-
export type InferRouteHandler<T> = T extends RouteHandler ? T : never
|
|
60
|
-
export type InferServer<T> = T extends Server ? T : never
|
|
61
|
-
export type RoutePath<T> = T extends Route ? T['path'] : never
|
|
62
|
-
export type RouteMethod<T> = T extends Route ? T['method'] : never
|
|
63
|
-
export type RouteHandlerType<T> = T extends Route ? T['handler'] : never
|
|
64
|
-
|
|
65
|
-
// WebSocket 事件类型
|
|
66
|
-
export interface WebSocketEvent<T = unknown> {
|
|
67
|
-
type: string
|
|
68
|
-
data: T
|
|
69
|
-
timestamp: number
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// WebSocket 客户端类型
|
|
73
|
-
export interface WebSocketClient {
|
|
74
|
-
connect(): Promise<void>
|
|
75
|
-
disconnect(): void
|
|
76
|
-
send(data: unknown): void
|
|
77
|
-
on(event: string, callback: (data: unknown) => void): void
|
|
78
|
-
off(event: string, callback: (data: unknown) => void): void
|
|
79
|
-
isConnected(): boolean
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// 文件上传类型
|
|
83
|
-
export interface FileUpload {
|
|
84
|
-
file: File | Blob
|
|
85
|
-
filename?: string
|
|
86
|
-
contentType?: string
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// 表单数据类型(重命名避免与全局 FormData 冲突)
|
|
90
|
-
export interface ApiFormData {
|
|
91
|
-
[key: string]: string | number | boolean | File | Blob | FileUpload | ApiFormData | (string | number | boolean | File | Blob | FileUpload | ApiFormData)[] | unknown
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// 中间件类型
|
|
95
|
-
export interface ApiMiddleware {
|
|
96
|
-
name: string
|
|
97
|
-
onRequest?: (request: Request, config: RequestConfig) => Request | Promise<Request>
|
|
98
|
-
onResponse?: (response: Response, config: RequestConfig) => Response | Promise<Response>
|
|
99
|
-
onError?: (error: Error, config: RequestConfig) => Error | Promise<Error>
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// 缓存配置类型
|
|
103
|
-
export interface CacheConfig {
|
|
104
|
-
enabled: boolean
|
|
105
|
-
ttl: number
|
|
106
|
-
maxSize: number
|
|
107
|
-
strategy: 'memory' | 'localStorage' | 'sessionStorage'
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// 重试配置类型
|
|
111
|
-
export interface RetryConfig {
|
|
112
|
-
enabled: boolean
|
|
113
|
-
maxRetries: number
|
|
114
|
-
retryDelay: number
|
|
115
|
-
backoffMultiplier: number
|
|
116
|
-
retryableStatuses: number[]
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// 拦截器类型
|
|
120
|
-
export interface Interceptor {
|
|
121
|
-
request?: (config: RequestConfig) => RequestConfig | Promise<RequestConfig>
|
|
122
|
-
response?: (response: Response) => Response | Promise<Response>
|
|
123
|
-
error?: (error: Error) => Error | Promise<Error>
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// 日志配置类型
|
|
127
|
-
export interface LogConfig {
|
|
128
|
-
enabled: boolean
|
|
129
|
-
level: 'debug' | 'info' | 'warn' | 'error'
|
|
130
|
-
format: 'json' | 'text'
|
|
131
|
-
includeHeaders: boolean
|
|
132
|
-
includeBody: boolean
|
|
133
|
-
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'
|
|
2
|
+
import { eden } from '../src'
|
|
3
|
+
|
|
4
|
+
// Mock fetch
|
|
5
|
+
const mockFetch = vi.fn()
|
|
6
|
+
globalThis.fetch = mockFetch
|
|
7
|
+
|
|
8
|
+
describe('Eden Client', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
mockFetch.mockReset()
|
|
11
|
+
mockFetch.mockResolvedValue(
|
|
12
|
+
new Response(JSON.stringify({ id: '1', name: 'John' }), {
|
|
13
|
+
status: 200,
|
|
14
|
+
headers: { 'Content-Type': 'application/json' }
|
|
15
|
+
})
|
|
16
|
+
)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// ============= 类型定义 =============
|
|
20
|
+
|
|
21
|
+
interface TestContract {
|
|
22
|
+
users: {
|
|
23
|
+
get: { query: { page?: number }; return: { id: string }[] }
|
|
24
|
+
post: { body: { name: string }; return: { id: string } }
|
|
25
|
+
':id': {
|
|
26
|
+
get: { return: { id: string; name: string } }
|
|
27
|
+
put: { body: { name: string }; return: { id: string; name: string } }
|
|
28
|
+
delete: { return: { success: boolean } }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
chat: {
|
|
32
|
+
stream: {
|
|
33
|
+
get: { query: { prompt: string }; return: unknown; sse: true }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============= 基础请求测试 =============
|
|
39
|
+
|
|
40
|
+
describe('基础请求', () => {
|
|
41
|
+
it('应该发送 GET 请求', async () => {
|
|
42
|
+
const api = eden<TestContract>('http://localhost:3000')
|
|
43
|
+
|
|
44
|
+
const result = await api.users.get({ page: 1 })
|
|
45
|
+
|
|
46
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
47
|
+
const req = mockFetch.mock.calls[0][0] as Request
|
|
48
|
+
expect(req.url).toContain('/users')
|
|
49
|
+
expect(req.url).toContain('page=1')
|
|
50
|
+
expect(req.method).toBe('GET')
|
|
51
|
+
expect(result.status).toBe(200)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('应该发送 POST 请求', async () => {
|
|
55
|
+
const api = eden<TestContract>('http://localhost:3000')
|
|
56
|
+
|
|
57
|
+
await api.users.post({ name: 'John' })
|
|
58
|
+
|
|
59
|
+
const req = mockFetch.mock.calls[0][0] as Request
|
|
60
|
+
expect(req.method).toBe('POST')
|
|
61
|
+
expect(req.url).toContain('/users')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('应该正确设置请求头', async () => {
|
|
65
|
+
const api = eden<TestContract>('http://localhost:3000', {
|
|
66
|
+
headers: { 'Authorization': 'Bearer token123' }
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
await api.users.get()
|
|
70
|
+
|
|
71
|
+
const req = mockFetch.mock.calls[0][0] as Request
|
|
72
|
+
expect(req.headers.get('Authorization')).toBe('Bearer token123')
|
|
73
|
+
expect(req.headers.get('Content-Type')).toBe('application/json')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// ============= 参数化路由测试 =============
|
|
78
|
+
|
|
79
|
+
describe('参数化路由', () => {
|
|
80
|
+
it('应该通过函数调用处理路径参数', async () => {
|
|
81
|
+
const api = eden<TestContract>('http://localhost:3000')
|
|
82
|
+
|
|
83
|
+
await api.users({ id: '123' }).get()
|
|
84
|
+
|
|
85
|
+
const req = mockFetch.mock.calls[0][0] as Request
|
|
86
|
+
expect(req.url).toBe('http://localhost:3000/users/123')
|
|
87
|
+
expect(req.method).toBe('GET')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('应该处理 PUT 请求和路径参数', async () => {
|
|
91
|
+
const api = eden<TestContract>('http://localhost:3000')
|
|
92
|
+
|
|
93
|
+
await api.users({ id: '123' }).put({ name: 'Jane' })
|
|
94
|
+
|
|
95
|
+
const req = mockFetch.mock.calls[0][0] as Request
|
|
96
|
+
expect(req.url).toBe('http://localhost:3000/users/123')
|
|
97
|
+
expect(req.method).toBe('PUT')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('应该处理 DELETE 请求和路径参数', async () => {
|
|
101
|
+
const api = eden<TestContract>('http://localhost:3000')
|
|
102
|
+
|
|
103
|
+
await api.users({ id: '456' }).delete()
|
|
104
|
+
|
|
105
|
+
const req = mockFetch.mock.calls[0][0] as Request
|
|
106
|
+
expect(req.url).toBe('http://localhost:3000/users/456')
|
|
107
|
+
expect(req.method).toBe('DELETE')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('应该对路径参数进行 URL 编码', async () => {
|
|
111
|
+
const api = eden<TestContract>('http://localhost:3000')
|
|
112
|
+
|
|
113
|
+
await api.users({ id: 'user/123' }).get()
|
|
114
|
+
|
|
115
|
+
const req = mockFetch.mock.calls[0][0] as Request
|
|
116
|
+
expect(req.url).toBe('http://localhost:3000/users/user%2F123')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ============= 嵌套路由测试 =============
|
|
121
|
+
|
|
122
|
+
describe('嵌套路由', () => {
|
|
123
|
+
interface NestedContract {
|
|
124
|
+
users: {
|
|
125
|
+
':id': {
|
|
126
|
+
posts: {
|
|
127
|
+
get: { return: { id: string }[] }
|
|
128
|
+
':id': {
|
|
129
|
+
get: { return: { id: string; title: string } }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
it('应该处理嵌套路径', async () => {
|
|
137
|
+
const api = eden<NestedContract>('http://localhost:3000')
|
|
138
|
+
|
|
139
|
+
await api.users({ id: '123' }).posts.get()
|
|
140
|
+
|
|
141
|
+
const req = mockFetch.mock.calls[0][0] as Request
|
|
142
|
+
expect(req.url).toBe('http://localhost:3000/users/123/posts')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('应该处理多层嵌套路径参数', async () => {
|
|
146
|
+
const api = eden<NestedContract>('http://localhost:3000')
|
|
147
|
+
|
|
148
|
+
// 动态参数统一使用 :id
|
|
149
|
+
await api.users({ id: '123' }).posts({ id: '456' }).get()
|
|
150
|
+
|
|
151
|
+
const req = mockFetch.mock.calls[0][0] as Request
|
|
152
|
+
expect(req.url).toBe('http://localhost:3000/users/123/posts/456')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// ============= 请求取消测试 =============
|
|
157
|
+
|
|
158
|
+
describe('请求取消', () => {
|
|
159
|
+
it('应该支持通过 AbortController 取消请求', async () => {
|
|
160
|
+
const controller = new AbortController()
|
|
161
|
+
|
|
162
|
+
mockFetch.mockImplementation(() => {
|
|
163
|
+
return new Promise((_, reject) => {
|
|
164
|
+
controller.signal.addEventListener('abort', () => {
|
|
165
|
+
const error = new Error('This operation was aborted')
|
|
166
|
+
error.name = 'AbortError'
|
|
167
|
+
reject(error)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const api = eden<TestContract>('http://localhost:3000')
|
|
173
|
+
|
|
174
|
+
const promise = api.users.get(undefined, { signal: controller.signal })
|
|
175
|
+
controller.abort()
|
|
176
|
+
|
|
177
|
+
const result = await promise
|
|
178
|
+
expect(result.error).toBeTruthy()
|
|
179
|
+
expect(result.status).toBe(0)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('应该在超时后自动取消请求', async () => {
|
|
183
|
+
mockFetch.mockImplementation((input: Request | string) => {
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
const timeoutId = setTimeout(() => {
|
|
186
|
+
resolve(new Response(JSON.stringify({}), { status: 200 }))
|
|
187
|
+
}, 5000)
|
|
188
|
+
|
|
189
|
+
// 从 Request 对象获取 signal
|
|
190
|
+
const signal = input instanceof Request ? input.signal : undefined
|
|
191
|
+
signal?.addEventListener('abort', () => {
|
|
192
|
+
clearTimeout(timeoutId)
|
|
193
|
+
const error = new Error('This operation was aborted')
|
|
194
|
+
error.name = 'AbortError'
|
|
195
|
+
reject(error)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
const api = eden<TestContract>('http://localhost:3000', { timeout: 100 })
|
|
201
|
+
|
|
202
|
+
const result = await api.users.get()
|
|
203
|
+
|
|
204
|
+
expect(result.error).toBeTruthy()
|
|
205
|
+
expect(result.status).toBe(0)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// ============= 拦截器测试 =============
|
|
210
|
+
|
|
211
|
+
describe('拦截器', () => {
|
|
212
|
+
it('应该执行 onRequest 拦截器', async () => {
|
|
213
|
+
const onRequest = vi.fn((req: Request) => {
|
|
214
|
+
return new Request(req.url, {
|
|
215
|
+
...req,
|
|
216
|
+
headers: { ...Object.fromEntries(req.headers), 'X-Custom': 'value' }
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const api = eden<TestContract>('http://localhost:3000', { onRequest })
|
|
221
|
+
|
|
222
|
+
await api.users.get()
|
|
223
|
+
|
|
224
|
+
expect(onRequest).toHaveBeenCalled()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('应该执行 onResponse 拦截器', async () => {
|
|
228
|
+
const onResponse = vi.fn((response) => response)
|
|
229
|
+
|
|
230
|
+
const api = eden<TestContract>('http://localhost:3000', { onResponse })
|
|
231
|
+
|
|
232
|
+
await api.users.get()
|
|
233
|
+
|
|
234
|
+
expect(onResponse).toHaveBeenCalled()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('应该在错误时执行 onError 回调', async () => {
|
|
238
|
+
mockFetch.mockResolvedValue(
|
|
239
|
+
new Response(JSON.stringify({ error: 'Not found' }), {
|
|
240
|
+
status: 404,
|
|
241
|
+
headers: { 'Content-Type': 'application/json' }
|
|
242
|
+
})
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
const onError = vi.fn()
|
|
246
|
+
const api = eden<TestContract>('http://localhost:3000', { onError })
|
|
247
|
+
|
|
248
|
+
await api.users.get()
|
|
249
|
+
|
|
250
|
+
expect(onError).toHaveBeenCalled()
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// ============= 响应处理测试 =============
|
|
255
|
+
|
|
256
|
+
describe('响应处理', () => {
|
|
257
|
+
it('应该正确解析 JSON 响应', async () => {
|
|
258
|
+
mockFetch.mockResolvedValue(
|
|
259
|
+
new Response(JSON.stringify({ users: [{ id: '1' }], total: 10 }), {
|
|
260
|
+
status: 200,
|
|
261
|
+
headers: { 'Content-Type': 'application/json' }
|
|
262
|
+
})
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
const api = eden<TestContract>('http://localhost:3000')
|
|
266
|
+
const result = await api.users.get()
|
|
267
|
+
|
|
268
|
+
expect(result.data).toEqual({ users: [{ id: '1' }], total: 10 })
|
|
269
|
+
expect(result.status).toBe(200)
|
|
270
|
+
expect(result.error).toBeNull()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('应该正确解析文本响应', async () => {
|
|
274
|
+
mockFetch.mockResolvedValue(
|
|
275
|
+
new Response('Hello World', {
|
|
276
|
+
status: 200,
|
|
277
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
278
|
+
})
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
const api = eden<TestContract>('http://localhost:3000')
|
|
282
|
+
const result = await api.users.get()
|
|
283
|
+
|
|
284
|
+
expect(result.data).toBe('Hello World')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('应该处理 HTTP 错误状态', async () => {
|
|
288
|
+
mockFetch.mockResolvedValue(
|
|
289
|
+
new Response(JSON.stringify({ message: 'Unauthorized' }), {
|
|
290
|
+
status: 401,
|
|
291
|
+
headers: { 'Content-Type': 'application/json' }
|
|
292
|
+
})
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
const api = eden<TestContract>('http://localhost:3000')
|
|
296
|
+
const result = await api.users.get()
|
|
297
|
+
|
|
298
|
+
expect(result.status).toBe(401)
|
|
299
|
+
expect(result.error).toBeTruthy()
|
|
300
|
+
expect(result.error?.message).toContain('401')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('应该处理网络错误', async () => {
|
|
304
|
+
mockFetch.mockRejectedValue(new Error('Network error'))
|
|
305
|
+
|
|
306
|
+
const api = eden<TestContract>('http://localhost:3000')
|
|
307
|
+
const result = await api.users.get()
|
|
308
|
+
|
|
309
|
+
expect(result.error?.message).toBe('Network error')
|
|
310
|
+
expect(result.status).toBe(0)
|
|
311
|
+
expect(result.data).toBeNull()
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// ============= Query 参数测试 =============
|
|
316
|
+
|
|
317
|
+
describe('Query 参数', () => {
|
|
318
|
+
it('应该正确构建查询字符串', async () => {
|
|
319
|
+
const api = eden<TestContract>('http://localhost:3000')
|
|
320
|
+
|
|
321
|
+
await api.users.get({ page: 2 })
|
|
322
|
+
|
|
323
|
+
const req = mockFetch.mock.calls[0][0] as Request
|
|
324
|
+
const url = new URL(req.url)
|
|
325
|
+
expect(url.searchParams.get('page')).toBe('2')
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('应该忽略 undefined 和 null 值', async () => {
|
|
329
|
+
// 直接使用 TestContract,测试 users.get
|
|
330
|
+
const api = eden<TestContract>('http://localhost:3000')
|
|
331
|
+
|
|
332
|
+
await api.users.get({ page: undefined })
|
|
333
|
+
|
|
334
|
+
const req = mockFetch.mock.calls[0][0] as Request
|
|
335
|
+
const url = new URL(req.url)
|
|
336
|
+
// undefined 值不应该出现在查询字符串中
|
|
337
|
+
expect(url.searchParams.has('page')).toBe(false)
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
// ============= SSE 测试 =============
|
|
343
|
+
|
|
344
|
+
describe('SSE 订阅', () => {
|
|
345
|
+
beforeEach(() => {
|
|
346
|
+
mockFetch.mockReset()
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('应该调用 subscribe 方法', async () => {
|
|
350
|
+
// 创建模拟 SSE 流
|
|
351
|
+
const encoder = new TextEncoder()
|
|
352
|
+
const stream = new ReadableStream({
|
|
353
|
+
start(controller) {
|
|
354
|
+
controller.enqueue(encoder.encode('data: {"message":"hello"}\n\n'))
|
|
355
|
+
controller.close()
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
mockFetch.mockResolvedValue(
|
|
360
|
+
new Response(stream, {
|
|
361
|
+
status: 200,
|
|
362
|
+
headers: { 'Content-Type': 'text/event-stream' }
|
|
363
|
+
})
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
interface SSEContract {
|
|
367
|
+
events: {
|
|
368
|
+
get: { query: { channel: string }; return: unknown; sse: { readonly __brand: 'SSE' } }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const api = eden<SSEContract>('http://localhost:3000')
|
|
373
|
+
const onMessage = vi.fn()
|
|
374
|
+
const onClose = vi.fn()
|
|
375
|
+
|
|
376
|
+
await new Promise<void>((resolve) => {
|
|
377
|
+
api.events.subscribe(
|
|
378
|
+
{ channel: 'test' },
|
|
379
|
+
{
|
|
380
|
+
onMessage,
|
|
381
|
+
onClose: () => {
|
|
382
|
+
onClose()
|
|
383
|
+
resolve()
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
390
|
+
expect(onMessage).toHaveBeenCalledWith({ message: 'hello' })
|
|
391
|
+
expect(onClose).toHaveBeenCalled()
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('应该支持取消订阅', async () => {
|
|
395
|
+
const stream = new ReadableStream({
|
|
396
|
+
start(controller) {
|
|
397
|
+
// 永不关闭的流
|
|
398
|
+
}
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
mockFetch.mockResolvedValue(
|
|
402
|
+
new Response(stream, {
|
|
403
|
+
status: 200,
|
|
404
|
+
headers: { 'Content-Type': 'text/event-stream' }
|
|
405
|
+
})
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
interface SSEContract {
|
|
409
|
+
events: {
|
|
410
|
+
get: { return: unknown; sse: { readonly __brand: 'SSE' } }
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const api = eden<SSEContract>('http://localhost:3000')
|
|
415
|
+
|
|
416
|
+
const sub = api.events.subscribe({
|
|
417
|
+
onMessage: () => {}
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
expect(sub.connected).toBe(false) // 还在连接中
|
|
421
|
+
|
|
422
|
+
sub.unsubscribe()
|
|
423
|
+
expect(sub.connected).toBe(false)
|
|
424
|
+
})
|
|
425
|
+
})
|
package/tsconfig.json
CHANGED