@verbatims/sdk 1.0.0
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/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/client.d.ts +35 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +156 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +51 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/pagination.d.ts +9 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +16 -0
- package/dist/pagination.js.map +1 -0
- package/dist/resources/authors.d.ts +145 -0
- package/dist/resources/authors.d.ts.map +1 -0
- package/dist/resources/authors.js +48 -0
- package/dist/resources/authors.js.map +1 -0
- package/dist/resources/collections.d.ts +57 -0
- package/dist/resources/collections.d.ts.map +1 -0
- package/dist/resources/collections.js +32 -0
- package/dist/resources/collections.js.map +1 -0
- package/dist/resources/index.d.ts +7 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +7 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/resources/quotes.d.ts +226 -0
- package/dist/resources/quotes.d.ts.map +1 -0
- package/dist/resources/quotes.js +69 -0
- package/dist/resources/quotes.js.map +1 -0
- package/dist/resources/references.d.ts +135 -0
- package/dist/resources/references.d.ts.map +1 -0
- package/dist/resources/references.js +46 -0
- package/dist/resources/references.js.map +1 -0
- package/dist/resources/search.d.ts +36 -0
- package/dist/resources/search.d.ts.map +1 -0
- package/dist/resources/search.js +27 -0
- package/dist/resources/search.js.map +1 -0
- package/dist/resources/tags.d.ts +39 -0
- package/dist/resources/tags.d.ts.map +1 -0
- package/dist/resources/tags.js +26 -0
- package/dist/resources/tags.js.map +1 -0
- package/dist/types.d.ts +203 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +23 -0
- package/dist/types.js.map +1 -0
- package/nuxt/composables.ts +31 -0
- package/nuxt/module.ts +31 -0
- package/package.json +85 -0
- package/src/__tests__/client.test.ts +304 -0
- package/src/__tests__/errors.test.ts +85 -0
- package/src/__tests__/pagination.test.ts +100 -0
- package/src/client.ts +213 -0
- package/src/errors.ts +54 -0
- package/src/index.ts +58 -0
- package/src/pagination.ts +25 -0
- package/src/resources/__tests__/resources.test.ts +386 -0
- package/src/resources/authors.ts +58 -0
- package/src/resources/collections.ts +45 -0
- package/src/resources/index.ts +6 -0
- package/src/resources/quotes.ts +80 -0
- package/src/resources/references.ts +56 -0
- package/src/resources/search.ts +34 -0
- package/src/resources/tags.ts +32 -0
- package/src/types.ts +221 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { VerbatimsClient } from '../client'
|
|
3
|
+
import {
|
|
4
|
+
VerbatimsError,
|
|
5
|
+
NotFoundError,
|
|
6
|
+
RateLimitError,
|
|
7
|
+
ValidationError,
|
|
8
|
+
AuthError,
|
|
9
|
+
ForbiddenError,
|
|
10
|
+
} from '../errors'
|
|
11
|
+
|
|
12
|
+
function mockResponse(data: unknown, status = 200, headers?: Record<string, string>) {
|
|
13
|
+
return new Response(JSON.stringify(data), {
|
|
14
|
+
status,
|
|
15
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const BASE_URL = 'http://localhost:8080/api/v1'
|
|
20
|
+
|
|
21
|
+
describe('VerbatimsClient', () => {
|
|
22
|
+
let fetchFn: ReturnType<typeof vi.fn>
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
fetchFn = vi.fn()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function createClient(opts?: Partial<ConstructorParameters<typeof VerbatimsClient>[1]>) {
|
|
29
|
+
return new VerbatimsClient('vbt_test_key', {
|
|
30
|
+
baseUrl: BASE_URL,
|
|
31
|
+
retry: { maxRetries: 1, baseDelayMs: 5 },
|
|
32
|
+
...opts,
|
|
33
|
+
fetch: fetchFn,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('GET', () => {
|
|
38
|
+
it('sends GET request with Bearer auth', async () => {
|
|
39
|
+
fetchFn.mockResolvedValue(mockResponse({ success: true }))
|
|
40
|
+
await createClient().get('/test')
|
|
41
|
+
|
|
42
|
+
expect(fetchFn).toHaveBeenCalledTimes(1)
|
|
43
|
+
const [url, opts] = fetchFn.mock.calls[0]
|
|
44
|
+
expect(url).toContain('/api/v1/test')
|
|
45
|
+
expect(opts.method).toBe('GET')
|
|
46
|
+
expect(opts.headers['Authorization']).toBe('Bearer vbt_test_key')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('appends query parameters', async () => {
|
|
50
|
+
fetchFn.mockResolvedValue(mockResponse({ success: true }))
|
|
51
|
+
await createClient().get('/quotes', { params: { page: 1, limit: 10, language: 'fr' } })
|
|
52
|
+
|
|
53
|
+
const [url] = fetchFn.mock.calls[0]
|
|
54
|
+
const parsed = new URL(url, 'http://localhost')
|
|
55
|
+
expect(parsed.searchParams.get('page')).toBe('1')
|
|
56
|
+
expect(parsed.searchParams.get('limit')).toBe('10')
|
|
57
|
+
expect(parsed.searchParams.get('language')).toBe('fr')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('skips undefined and null params', async () => {
|
|
61
|
+
fetchFn.mockResolvedValue(mockResponse({ success: true }))
|
|
62
|
+
await createClient().get('/test', { params: { page: 1, limit: undefined, search: null } })
|
|
63
|
+
|
|
64
|
+
const [url] = fetchFn.mock.calls[0]
|
|
65
|
+
const parsed = new URL(url, 'http://localhost')
|
|
66
|
+
expect(parsed.searchParams.get('page')).toBe('1')
|
|
67
|
+
expect(parsed.searchParams.has('limit')).toBe(false)
|
|
68
|
+
expect(parsed.searchParams.has('search')).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('POST', () => {
|
|
73
|
+
it('sends POST with JSON body and Content-Type', async () => {
|
|
74
|
+
fetchFn.mockResolvedValue(mockResponse({ success: true }))
|
|
75
|
+
const body = { name: 'Test', author_id: 1 }
|
|
76
|
+
await createClient().post('/quotes', body)
|
|
77
|
+
|
|
78
|
+
const [url, opts] = fetchFn.mock.calls[0]
|
|
79
|
+
expect(url).toContain('/api/v1/quotes')
|
|
80
|
+
expect(opts.method).toBe('POST')
|
|
81
|
+
expect(JSON.parse(opts.body)).toEqual(body)
|
|
82
|
+
expect(opts.headers['Content-Type']).toBe('application/json')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('omits Content-Type when body is empty', async () => {
|
|
86
|
+
fetchFn.mockResolvedValue(mockResponse({ success: true }))
|
|
87
|
+
await createClient().post('/test', undefined)
|
|
88
|
+
|
|
89
|
+
const [, opts] = fetchFn.mock.calls[0]
|
|
90
|
+
expect(opts.headers['Content-Type']).toBeUndefined()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('PUT', () => {
|
|
95
|
+
it('sends PUT with JSON body', async () => {
|
|
96
|
+
fetchFn.mockResolvedValue(mockResponse({ success: true }))
|
|
97
|
+
await createClient().put('/quotes/1', { name: 'Updated' })
|
|
98
|
+
|
|
99
|
+
const [, opts] = fetchFn.mock.calls[0]
|
|
100
|
+
expect(opts.method).toBe('PUT')
|
|
101
|
+
expect(JSON.parse(opts.body)).toEqual({ name: 'Updated' })
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('DELETE', () => {
|
|
106
|
+
it('sends DELETE without body', async () => {
|
|
107
|
+
fetchFn.mockResolvedValue(mockResponse({ success: true }))
|
|
108
|
+
await createClient().delete('/quotes/1')
|
|
109
|
+
|
|
110
|
+
const [, opts] = fetchFn.mock.calls[0]
|
|
111
|
+
expect(opts.method).toBe('DELETE')
|
|
112
|
+
expect(opts.body).toBeUndefined()
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('error handling', () => {
|
|
117
|
+
it('throws ValidationError on 400', async () => {
|
|
118
|
+
fetchFn.mockResolvedValue(mockResponse({ success: false, message: 'Bad request', errors: ['invalid'] }, 400))
|
|
119
|
+
await expect(createClient().get('/test')).rejects.toThrow(ValidationError)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('throws AuthError on 401', async () => {
|
|
123
|
+
fetchFn.mockResolvedValue(mockResponse({ success: false }, 401))
|
|
124
|
+
await expect(createClient().get('/test')).rejects.toThrow(AuthError)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('throws ForbiddenError on 403', async () => {
|
|
128
|
+
fetchFn.mockResolvedValue(mockResponse({ success: false }, 403))
|
|
129
|
+
await expect(createClient().get('/test')).rejects.toThrow(ForbiddenError)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('throws NotFoundError on 404', async () => {
|
|
133
|
+
fetchFn.mockResolvedValue(mockResponse({ success: false, message: 'Not found' }, 404))
|
|
134
|
+
await expect(createClient().get('/test')).rejects.toThrow(NotFoundError)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('throws RateLimitError on 429 with headers', async () => {
|
|
138
|
+
const now = Math.floor(Date.now() / 1000)
|
|
139
|
+
fetchFn.mockResolvedValue(
|
|
140
|
+
mockResponse({ success: false, message: 'Rate limited' }, 429, {
|
|
141
|
+
'x-ratelimit-limit': '100',
|
|
142
|
+
'x-ratelimit-remaining': '0',
|
|
143
|
+
'x-ratelimit-reset': String(now + 30),
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
await expect(createClient({ retry: { maxRetries: 0, baseDelayMs: 5 } }).get('/test')).rejects.toThrow(RateLimitError)
|
|
147
|
+
expect(fetchFn).toHaveBeenCalledTimes(1)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('throws VerbatimsError on unexpected status', async () => {
|
|
151
|
+
fetchFn.mockResolvedValue(mockResponse({ success: false, message: 'Server error' }, 500))
|
|
152
|
+
await expect(createClient().get('/test')).rejects.toThrow(VerbatimsError)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('throws VerbatimsError on invalid JSON response', async () => {
|
|
156
|
+
fetchFn.mockResolvedValue(new Response('not json', { status: 200 }))
|
|
157
|
+
await expect(createClient().get('/test')).rejects.toThrow(VerbatimsError)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('sets error message from response body', async () => {
|
|
161
|
+
fetchFn.mockResolvedValue(mockResponse({ success: false, message: 'Custom error' }, 400))
|
|
162
|
+
await expect(createClient().get('/test')).rejects.toMatchObject({
|
|
163
|
+
message: 'Custom error',
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('retry logic', () => {
|
|
169
|
+
it('retries GET on network error', async () => {
|
|
170
|
+
fetchFn
|
|
171
|
+
.mockRejectedValueOnce(new Error('network error'))
|
|
172
|
+
.mockResolvedValueOnce(mockResponse({ success: true }))
|
|
173
|
+
|
|
174
|
+
await createClient({ retry: { maxRetries: 1, baseDelayMs: 5 } }).get('/test')
|
|
175
|
+
expect(fetchFn).toHaveBeenCalledTimes(2)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('retries POST once on network error (maxRetries capped at 1 for non-GET)', async () => {
|
|
179
|
+
fetchFn
|
|
180
|
+
.mockRejectedValueOnce(new Error('network error'))
|
|
181
|
+
.mockRejectedValueOnce(new Error('network error'))
|
|
182
|
+
|
|
183
|
+
await expect(
|
|
184
|
+
createClient({ retry: { maxRetries: 3, baseDelayMs: 5 } }).post('/test', {}),
|
|
185
|
+
).rejects.toThrow()
|
|
186
|
+
|
|
187
|
+
expect(fetchFn).toHaveBeenCalledTimes(2)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('retries PUT once on network error', async () => {
|
|
191
|
+
fetchFn
|
|
192
|
+
.mockRejectedValueOnce(new Error('network error'))
|
|
193
|
+
.mockRejectedValueOnce(new Error('network error'))
|
|
194
|
+
|
|
195
|
+
await expect(createClient().put('/test', {})).rejects.toThrow()
|
|
196
|
+
expect(fetchFn).toHaveBeenCalledTimes(2)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('retries DELETE once on network error', async () => {
|
|
200
|
+
fetchFn
|
|
201
|
+
.mockRejectedValueOnce(new Error('network error'))
|
|
202
|
+
.mockRejectedValueOnce(new Error('network error'))
|
|
203
|
+
|
|
204
|
+
await expect(createClient().delete('/test')).rejects.toThrow()
|
|
205
|
+
expect(fetchFn).toHaveBeenCalledTimes(2)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('retries on 429 and succeeds', async () => {
|
|
209
|
+
const now = Math.floor(Date.now() / 1000)
|
|
210
|
+
fetchFn
|
|
211
|
+
.mockResolvedValueOnce(
|
|
212
|
+
mockResponse({ success: false, message: 'Rate limited' }, 429, {
|
|
213
|
+
'x-ratelimit-limit': '100',
|
|
214
|
+
'x-ratelimit-remaining': '0',
|
|
215
|
+
'x-ratelimit-reset': String(now + 1),
|
|
216
|
+
}),
|
|
217
|
+
)
|
|
218
|
+
.mockResolvedValueOnce(mockResponse({ success: true }))
|
|
219
|
+
|
|
220
|
+
await createClient({ retry: { maxRetries: 1, baseDelayMs: 5 } }).get('/test')
|
|
221
|
+
expect(fetchFn).toHaveBeenCalledTimes(2)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('stops retrying after max retries exhausted', async () => {
|
|
225
|
+
fetchFn.mockRejectedValue(new Error('network error'))
|
|
226
|
+
|
|
227
|
+
await expect(
|
|
228
|
+
createClient({ retry: { maxRetries: 2, baseDelayMs: 5 } }).get('/test'),
|
|
229
|
+
).rejects.toThrow()
|
|
230
|
+
|
|
231
|
+
expect(fetchFn).toHaveBeenCalledTimes(3)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('does not retry non-retryable VerbatimsError', async () => {
|
|
235
|
+
fetchFn
|
|
236
|
+
.mockResolvedValueOnce(mockResponse({ success: false }, 403))
|
|
237
|
+
.mockResolvedValueOnce(mockResponse({ success: true }))
|
|
238
|
+
|
|
239
|
+
await expect(createClient().get('/test')).rejects.toThrow(ForbiddenError)
|
|
240
|
+
expect(fetchFn).toHaveBeenCalledTimes(1)
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
describe('custom fetch injection', () => {
|
|
245
|
+
it('uses injected fetch function', async () => {
|
|
246
|
+
fetchFn.mockResolvedValue(mockResponse({ success: true }))
|
|
247
|
+
const client = new VerbatimsClient('key', { fetch: fetchFn })
|
|
248
|
+
await client.get('/test')
|
|
249
|
+
expect(fetchFn).toHaveBeenCalled()
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
describe('baseUrl', () => {
|
|
254
|
+
it('defaults to /api/v1', async () => {
|
|
255
|
+
fetchFn.mockResolvedValue(mockResponse({ success: true }))
|
|
256
|
+
const client = new VerbatimsClient('key', { fetch: fetchFn })
|
|
257
|
+
await client.get('/quotes')
|
|
258
|
+
const [url] = fetchFn.mock.calls[0]
|
|
259
|
+
expect(url).toContain('/api/v1/quotes')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('uses custom baseUrl', async () => {
|
|
263
|
+
fetchFn.mockResolvedValue(mockResponse({ success: true }))
|
|
264
|
+
const client = new VerbatimsClient('key', { baseUrl: '/custom', fetch: fetchFn })
|
|
265
|
+
await client.get('/quotes')
|
|
266
|
+
const [url] = fetchFn.mock.calls[0]
|
|
267
|
+
expect(url).toContain('/custom/quotes')
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('timeout', () => {
|
|
272
|
+
it('throws on timeout', async () => {
|
|
273
|
+
fetchFn.mockImplementation((_url: string, opts?: RequestInit) => {
|
|
274
|
+
return new Promise((_resolve, reject) => {
|
|
275
|
+
if (opts?.signal) {
|
|
276
|
+
;(opts.signal as AbortSignal).addEventListener('abort', () => {
|
|
277
|
+
reject(new DOMException('The operation was aborted', 'AbortError'))
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
const client = new VerbatimsClient('key', {
|
|
283
|
+
fetch: fetchFn,
|
|
284
|
+
baseUrl: BASE_URL,
|
|
285
|
+
timeout: 50,
|
|
286
|
+
retry: { maxRetries: 0, baseDelayMs: 5 },
|
|
287
|
+
})
|
|
288
|
+
await expect(client.get('/test')).rejects.toThrow()
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
describe('schema validation', () => {
|
|
293
|
+
it('throws VerbatimsError when Zod schema rejects data', async () => {
|
|
294
|
+
fetchFn.mockResolvedValue(
|
|
295
|
+
mockResponse({ success: true, data: { id: 'not-a-number' } }),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
const { z } = await import('zod/v4')
|
|
299
|
+
const schema = z.object({ success: z.literal(true), data: z.object({ id: z.number() }) })
|
|
300
|
+
|
|
301
|
+
await expect(createClient().get('/test', {}, schema)).rejects.toThrow()
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
VerbatimsError,
|
|
4
|
+
NotFoundError,
|
|
5
|
+
RateLimitError,
|
|
6
|
+
ValidationError,
|
|
7
|
+
AuthError,
|
|
8
|
+
ForbiddenError,
|
|
9
|
+
} from '../errors'
|
|
10
|
+
|
|
11
|
+
describe('VerbatimsError', () => {
|
|
12
|
+
it('creates error with message, status code, and optional code', () => {
|
|
13
|
+
const err = new VerbatimsError('Something went wrong', 500, 'INTERNAL')
|
|
14
|
+
expect(err).toBeInstanceOf(Error)
|
|
15
|
+
expect(err.message).toBe('Something went wrong')
|
|
16
|
+
expect(err.statusCode).toBe(500)
|
|
17
|
+
expect(err.code).toBe('INTERNAL')
|
|
18
|
+
expect(err.name).toBe('VerbatimsError')
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('NotFoundError', () => {
|
|
23
|
+
it('creates 404 error with default message', () => {
|
|
24
|
+
const err = new NotFoundError()
|
|
25
|
+
expect(err).toBeInstanceOf(VerbatimsError)
|
|
26
|
+
expect(err.statusCode).toBe(404)
|
|
27
|
+
expect(err.code).toBe('NOT_FOUND')
|
|
28
|
+
expect(err.message).toBe('Resource not found')
|
|
29
|
+
expect(err.name).toBe('NotFoundError')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('creates 404 error with custom message', () => {
|
|
33
|
+
const err = new NotFoundError('Quote not found')
|
|
34
|
+
expect(err.message).toBe('Quote not found')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('RateLimitError', () => {
|
|
39
|
+
it('creates 429 error with rate limit metadata', () => {
|
|
40
|
+
const err = new RateLimitError('Too many requests', 30, 100, 0, 12345)
|
|
41
|
+
expect(err.statusCode).toBe(429)
|
|
42
|
+
expect(err.retryAfter).toBe(30)
|
|
43
|
+
expect(err.limit).toBe(100)
|
|
44
|
+
expect(err.remaining).toBe(0)
|
|
45
|
+
expect(err.reset).toBe(12345)
|
|
46
|
+
expect(err.code).toBe('RATE_LIMITED')
|
|
47
|
+
expect(err.name).toBe('RateLimitError')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('ValidationError', () => {
|
|
52
|
+
it('creates 400 error with errors array', () => {
|
|
53
|
+
const err = new ValidationError('Invalid data', ['name is required', 'email is invalid'])
|
|
54
|
+
expect(err.statusCode).toBe(400)
|
|
55
|
+
expect(err.errors).toEqual(['name is required', 'email is invalid'])
|
|
56
|
+
expect(err.code).toBe('VALIDATION_ERROR')
|
|
57
|
+
expect(err.name).toBe('ValidationError')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('creates 400 error without errors array', () => {
|
|
61
|
+
const err = new ValidationError('Invalid data')
|
|
62
|
+
expect(err.statusCode).toBe(400)
|
|
63
|
+
expect(err.errors).toBeUndefined()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('AuthError', () => {
|
|
68
|
+
it('creates 401 error with default message', () => {
|
|
69
|
+
const err = new AuthError()
|
|
70
|
+
expect(err.statusCode).toBe(401)
|
|
71
|
+
expect(err.code).toBe('AUTH_ERROR')
|
|
72
|
+
expect(err.message).toBe('Authentication required')
|
|
73
|
+
expect(err.name).toBe('AuthError')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('ForbiddenError', () => {
|
|
78
|
+
it('creates 403 error with default message', () => {
|
|
79
|
+
const err = new ForbiddenError()
|
|
80
|
+
expect(err.statusCode).toBe(403)
|
|
81
|
+
expect(err.code).toBe('FORBIDDEN')
|
|
82
|
+
expect(err.message).toBe('Access denied')
|
|
83
|
+
expect(err.name).toBe('ForbiddenError')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { paginate } from '../pagination'
|
|
3
|
+
|
|
4
|
+
describe('paginate', () => {
|
|
5
|
+
it('yields items from a single page', async () => {
|
|
6
|
+
const fetchPage = vi.fn().mockResolvedValue({
|
|
7
|
+
data: [{ id: 1 }, { id: 2 }],
|
|
8
|
+
pagination: { page: 1, limit: 10, total: 2, totalPages: 1, hasMore: false },
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const results: Array<{ id: number }> = []
|
|
12
|
+
for await (const item of paginate(fetchPage)) {
|
|
13
|
+
results.push(item)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
expect(results).toHaveLength(2)
|
|
17
|
+
expect(results[0]).toEqual({ id: 1 })
|
|
18
|
+
expect(results[1]).toEqual({ id: 2 })
|
|
19
|
+
expect(fetchPage).toHaveBeenCalledTimes(1)
|
|
20
|
+
expect(fetchPage).toHaveBeenCalledWith(1)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('iterates through multiple pages', async () => {
|
|
24
|
+
const fetchPage = vi.fn()
|
|
25
|
+
.mockResolvedValueOnce({
|
|
26
|
+
data: [{ id: 1 }],
|
|
27
|
+
pagination: { page: 1, limit: 1, total: 3, totalPages: 3, hasMore: true },
|
|
28
|
+
})
|
|
29
|
+
.mockResolvedValueOnce({
|
|
30
|
+
data: [{ id: 2 }],
|
|
31
|
+
pagination: { page: 2, limit: 1, total: 3, totalPages: 3, hasMore: true },
|
|
32
|
+
})
|
|
33
|
+
.mockResolvedValueOnce({
|
|
34
|
+
data: [{ id: 3 }],
|
|
35
|
+
pagination: { page: 3, limit: 1, total: 3, totalPages: 3, hasMore: false },
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const results: Array<{ id: number }> = []
|
|
39
|
+
for await (const item of paginate(fetchPage)) {
|
|
40
|
+
results.push(item)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
expect(results).toHaveLength(3)
|
|
44
|
+
expect(fetchPage).toHaveBeenCalledTimes(3)
|
|
45
|
+
expect(fetchPage).toHaveBeenNthCalledWith(1, 1)
|
|
46
|
+
expect(fetchPage).toHaveBeenNthCalledWith(2, 2)
|
|
47
|
+
expect(fetchPage).toHaveBeenNthCalledWith(3, 3)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('stops when data is empty', async () => {
|
|
51
|
+
const fetchPage = vi.fn().mockResolvedValue({
|
|
52
|
+
data: [],
|
|
53
|
+
pagination: { page: 1, limit: 10, total: 0, totalPages: 0, hasMore: false },
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const results: Array<{ id: number }> = []
|
|
57
|
+
for await (const item of paginate(fetchPage)) {
|
|
58
|
+
results.push(item)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
expect(results).toHaveLength(0)
|
|
62
|
+
expect(fetchPage).toHaveBeenCalledTimes(1)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('stops when data is undefined', async () => {
|
|
66
|
+
const fetchPage = vi.fn().mockResolvedValue({
|
|
67
|
+
data: undefined,
|
|
68
|
+
pagination: { page: 1, limit: 10, total: 0, totalPages: 0, hasMore: false },
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const results: Array<{ id: number }> = []
|
|
72
|
+
for await (const item of paginate(fetchPage)) {
|
|
73
|
+
results.push(item)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
expect(results).toHaveLength(0)
|
|
77
|
+
expect(fetchPage).toHaveBeenCalledTimes(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('handles multiple items per page', async () => {
|
|
81
|
+
const fetchPage = vi.fn()
|
|
82
|
+
.mockResolvedValueOnce({
|
|
83
|
+
data: [{ id: 1 }, { id: 2 }],
|
|
84
|
+
pagination: { page: 1, limit: 2, total: 4, totalPages: 2, hasMore: true },
|
|
85
|
+
})
|
|
86
|
+
.mockResolvedValueOnce({
|
|
87
|
+
data: [{ id: 3 }, { id: 4 }],
|
|
88
|
+
pagination: { page: 2, limit: 2, total: 4, totalPages: 2, hasMore: false },
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const results: Array<{ id: number }> = []
|
|
92
|
+
for await (const item of paginate(fetchPage)) {
|
|
93
|
+
results.push(item)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
expect(results).toHaveLength(4)
|
|
97
|
+
expect(results[0].id).toBe(1)
|
|
98
|
+
expect(results[3].id).toBe(4)
|
|
99
|
+
})
|
|
100
|
+
})
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { z } from 'zod/v4'
|
|
2
|
+
import { errorResponseSchema, paginationMetaSchema, apiResponseSchema } from './types'
|
|
3
|
+
import { VerbatimsError, NotFoundError, RateLimitError, ValidationError, AuthError, ForbiddenError } from './errors'
|
|
4
|
+
|
|
5
|
+
export interface ClientOptions {
|
|
6
|
+
baseUrl?: string
|
|
7
|
+
timeout?: number
|
|
8
|
+
retry?: RetryConfig
|
|
9
|
+
fetch?: typeof globalThis.fetch
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RetryConfig {
|
|
13
|
+
maxRetries: number
|
|
14
|
+
baseDelayMs: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const defaultRetry: RetryConfig = {
|
|
18
|
+
maxRetries: 3,
|
|
19
|
+
baseDelayMs: 500,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RequestOptions {
|
|
23
|
+
method?: string
|
|
24
|
+
body?: unknown
|
|
25
|
+
params?: Record<string, unknown>
|
|
26
|
+
signal?: AbortSignal
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class VerbatimsClient {
|
|
30
|
+
private baseUrl: string
|
|
31
|
+
private apiKey: string
|
|
32
|
+
private timeout: number
|
|
33
|
+
private retry: RetryConfig
|
|
34
|
+
private fetchFn: typeof globalThis.fetch
|
|
35
|
+
|
|
36
|
+
constructor(apiKey: string, opts: ClientOptions = {}) {
|
|
37
|
+
this.apiKey = apiKey
|
|
38
|
+
this.baseUrl = opts.baseUrl ?? '/api/v1'
|
|
39
|
+
this.timeout = opts.timeout ?? 15_000
|
|
40
|
+
this.retry = opts.retry ?? defaultRetry
|
|
41
|
+
this.fetchFn = opts.fetch ?? globalThis.fetch
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private buildUrl(path: string, params?: Record<string, unknown>): string {
|
|
45
|
+
const url = new URL(`${this.baseUrl}${path}`, 'http://localhost')
|
|
46
|
+
if (params) {
|
|
47
|
+
for (const [key, value] of Object.entries(params)) {
|
|
48
|
+
if (value !== undefined && value !== null) {
|
|
49
|
+
url.searchParams.set(key, String(value))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return url.pathname + url.search
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async request<T>(
|
|
57
|
+
path: string,
|
|
58
|
+
opts: RequestOptions = {},
|
|
59
|
+
schema: z.ZodType<T>,
|
|
60
|
+
): Promise<T> {
|
|
61
|
+
const url = this.buildUrl(path, opts.params)
|
|
62
|
+
const method = opts.method ?? 'GET'
|
|
63
|
+
const body = opts.body ? JSON.stringify(opts.body) : undefined
|
|
64
|
+
|
|
65
|
+
let lastError: Error | null = null
|
|
66
|
+
const maxRetries = method === 'GET' ? this.retry.maxRetries : 1
|
|
67
|
+
|
|
68
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
69
|
+
try {
|
|
70
|
+
const controller = new AbortController()
|
|
71
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
|
|
72
|
+
|
|
73
|
+
const signal = opts.signal
|
|
74
|
+
? anySignal([opts.signal, controller.signal])
|
|
75
|
+
: controller.signal
|
|
76
|
+
|
|
77
|
+
const response = await this.fetchFn(url, {
|
|
78
|
+
method,
|
|
79
|
+
headers: {
|
|
80
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
81
|
+
'Content-Type': body ? 'application/json' : undefined,
|
|
82
|
+
'Accept': 'application/json',
|
|
83
|
+
} as Record<string, string>,
|
|
84
|
+
body,
|
|
85
|
+
signal,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
clearTimeout(timeoutId)
|
|
89
|
+
|
|
90
|
+
const rateLimitHeaders = this.parseRateLimitHeaders(response.headers)
|
|
91
|
+
const text = await response.text()
|
|
92
|
+
let json: unknown
|
|
93
|
+
try {
|
|
94
|
+
json = JSON.parse(text)
|
|
95
|
+
} catch {
|
|
96
|
+
throw new VerbatimsError(`Invalid JSON response: ${text.slice(0, 200)}`, response.status)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
this.handleError(response.status, json, rateLimitHeaders)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const parsed = schema.parse(json)
|
|
104
|
+
return parsed
|
|
105
|
+
} catch (err) {
|
|
106
|
+
lastError = err instanceof Error ? err : new Error(String(err))
|
|
107
|
+
|
|
108
|
+
if (err instanceof VerbatimsError) {
|
|
109
|
+
if (err.statusCode === 429 && attempt < maxRetries) {
|
|
110
|
+
const retryAfter = err instanceof RateLimitError ? err.retryAfter : 1
|
|
111
|
+
await sleep(retryAfter * 1000)
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
throw err
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (attempt < maxRetries && isRetryableError(lastError)) {
|
|
118
|
+
const delay = this.retry.baseDelayMs * Math.pow(2, attempt)
|
|
119
|
+
await sleep(delay)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
throw lastError
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
throw lastError ?? new VerbatimsError('Request failed', 0)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private parseRateLimitHeaders(headers: Headers): {
|
|
131
|
+
limit: number
|
|
132
|
+
remaining: number
|
|
133
|
+
reset: number
|
|
134
|
+
} | null {
|
|
135
|
+
const limit = headers.get('x-ratelimit-limit')
|
|
136
|
+
const remaining = headers.get('x-ratelimit-remaining')
|
|
137
|
+
const reset = headers.get('x-ratelimit-reset')
|
|
138
|
+
if (limit && remaining && reset) {
|
|
139
|
+
return {
|
|
140
|
+
limit: Number(limit),
|
|
141
|
+
remaining: Number(remaining),
|
|
142
|
+
reset: Number(reset),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private handleError(status: number, json: unknown, rateLimit: { limit: number; remaining: number; reset: number } | null): never {
|
|
149
|
+
const parsed = errorResponseSchema.safeParse(json)
|
|
150
|
+
const message = parsed.data?.message ?? `HTTP ${status}`
|
|
151
|
+
const errors = parsed.data?.errors
|
|
152
|
+
|
|
153
|
+
switch (status) {
|
|
154
|
+
case 400:
|
|
155
|
+
throw new ValidationError(message, errors)
|
|
156
|
+
case 401:
|
|
157
|
+
throw new AuthError(message)
|
|
158
|
+
case 403:
|
|
159
|
+
throw new ForbiddenError(message)
|
|
160
|
+
case 404:
|
|
161
|
+
throw new NotFoundError(message)
|
|
162
|
+
case 429:
|
|
163
|
+
throw new RateLimitError(
|
|
164
|
+
message,
|
|
165
|
+
rateLimit?.reset ? rateLimit.reset - Math.floor(Date.now() / 1000) : 1,
|
|
166
|
+
rateLimit?.limit ?? 0,
|
|
167
|
+
rateLimit?.remaining ?? 0,
|
|
168
|
+
rateLimit?.reset ?? 0,
|
|
169
|
+
)
|
|
170
|
+
default:
|
|
171
|
+
throw new VerbatimsError(message, status)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- HTTP helpers ---
|
|
176
|
+
|
|
177
|
+
async get<T>(path: string, opts?: RequestOptions, schema?: z.ZodType<T>): Promise<T> {
|
|
178
|
+
return this.request(path, { ...opts, method: 'GET' }, schema ?? z.unknown() as z.ZodType<T>)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async post<T>(path: string, body: unknown, opts?: RequestOptions, schema?: z.ZodType<T>): Promise<T> {
|
|
182
|
+
return this.request(path, { ...opts, method: 'POST', body }, schema ?? z.unknown() as z.ZodType<T>)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async put<T>(path: string, body: unknown, opts?: RequestOptions, schema?: z.ZodType<T>): Promise<T> {
|
|
186
|
+
return this.request(path, { ...opts, method: 'PUT', body }, schema ?? z.unknown() as z.ZodType<T>)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async delete<T>(path: string, opts?: RequestOptions, schema?: z.ZodType<T>): Promise<T> {
|
|
190
|
+
return this.request(path, { ...opts, method: 'DELETE' }, schema ?? z.unknown() as z.ZodType<T>)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function anySignal(signals: AbortSignal[]): AbortSignal {
|
|
195
|
+
const controller = new AbortController()
|
|
196
|
+
for (const signal of signals) {
|
|
197
|
+
if (signal.aborted) {
|
|
198
|
+
controller.abort(signal.reason)
|
|
199
|
+
return controller.signal
|
|
200
|
+
}
|
|
201
|
+
signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true })
|
|
202
|
+
}
|
|
203
|
+
return controller.signal
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function sleep(ms: number): Promise<void> {
|
|
207
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isRetryableError(err: Error): boolean {
|
|
211
|
+
if (err instanceof VerbatimsError) return false
|
|
212
|
+
return err.name === 'AbortError' || err.message.includes('network') || err.message.includes('fetch')
|
|
213
|
+
}
|