@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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/client.d.ts +35 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +156 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/errors.d.ts +26 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +51 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/index.d.ts +22 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +27 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/pagination.d.ts +9 -0
  16. package/dist/pagination.d.ts.map +1 -0
  17. package/dist/pagination.js +16 -0
  18. package/dist/pagination.js.map +1 -0
  19. package/dist/resources/authors.d.ts +145 -0
  20. package/dist/resources/authors.d.ts.map +1 -0
  21. package/dist/resources/authors.js +48 -0
  22. package/dist/resources/authors.js.map +1 -0
  23. package/dist/resources/collections.d.ts +57 -0
  24. package/dist/resources/collections.d.ts.map +1 -0
  25. package/dist/resources/collections.js +32 -0
  26. package/dist/resources/collections.js.map +1 -0
  27. package/dist/resources/index.d.ts +7 -0
  28. package/dist/resources/index.d.ts.map +1 -0
  29. package/dist/resources/index.js +7 -0
  30. package/dist/resources/index.js.map +1 -0
  31. package/dist/resources/quotes.d.ts +226 -0
  32. package/dist/resources/quotes.d.ts.map +1 -0
  33. package/dist/resources/quotes.js +69 -0
  34. package/dist/resources/quotes.js.map +1 -0
  35. package/dist/resources/references.d.ts +135 -0
  36. package/dist/resources/references.d.ts.map +1 -0
  37. package/dist/resources/references.js +46 -0
  38. package/dist/resources/references.js.map +1 -0
  39. package/dist/resources/search.d.ts +36 -0
  40. package/dist/resources/search.d.ts.map +1 -0
  41. package/dist/resources/search.js +27 -0
  42. package/dist/resources/search.js.map +1 -0
  43. package/dist/resources/tags.d.ts +39 -0
  44. package/dist/resources/tags.d.ts.map +1 -0
  45. package/dist/resources/tags.js +26 -0
  46. package/dist/resources/tags.js.map +1 -0
  47. package/dist/types.d.ts +203 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +23 -0
  50. package/dist/types.js.map +1 -0
  51. package/nuxt/composables.ts +31 -0
  52. package/nuxt/module.ts +31 -0
  53. package/package.json +85 -0
  54. package/src/__tests__/client.test.ts +304 -0
  55. package/src/__tests__/errors.test.ts +85 -0
  56. package/src/__tests__/pagination.test.ts +100 -0
  57. package/src/client.ts +213 -0
  58. package/src/errors.ts +54 -0
  59. package/src/index.ts +58 -0
  60. package/src/pagination.ts +25 -0
  61. package/src/resources/__tests__/resources.test.ts +386 -0
  62. package/src/resources/authors.ts +58 -0
  63. package/src/resources/collections.ts +45 -0
  64. package/src/resources/index.ts +6 -0
  65. package/src/resources/quotes.ts +80 -0
  66. package/src/resources/references.ts +56 -0
  67. package/src/resources/search.ts +34 -0
  68. package/src/resources/tags.ts +32 -0
  69. 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
+ }