@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
package/src/errors.ts ADDED
@@ -0,0 +1,54 @@
1
+ export class VerbatimsError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public statusCode: number,
5
+ public code?: string,
6
+ ) {
7
+ super(message)
8
+ this.name = 'VerbatimsError'
9
+ }
10
+ }
11
+
12
+ export class NotFoundError extends VerbatimsError {
13
+ constructor(message = 'Resource not found') {
14
+ super(message, 404, 'NOT_FOUND')
15
+ this.name = 'NotFoundError'
16
+ }
17
+ }
18
+
19
+ export class RateLimitError extends VerbatimsError {
20
+ constructor(
21
+ message: string,
22
+ public retryAfter: number,
23
+ public limit: number,
24
+ public remaining: number,
25
+ public reset: number,
26
+ ) {
27
+ super(message, 429, 'RATE_LIMITED')
28
+ this.name = 'RateLimitError'
29
+ }
30
+ }
31
+
32
+ export class ValidationError extends VerbatimsError {
33
+ constructor(
34
+ message: string,
35
+ public errors?: string[],
36
+ ) {
37
+ super(message, 400, 'VALIDATION_ERROR')
38
+ this.name = 'ValidationError'
39
+ }
40
+ }
41
+
42
+ export class AuthError extends VerbatimsError {
43
+ constructor(message = 'Authentication required') {
44
+ super(message, 401, 'AUTH_ERROR')
45
+ this.name = 'AuthError'
46
+ }
47
+ }
48
+
49
+ export class ForbiddenError extends VerbatimsError {
50
+ constructor(message = 'Access denied') {
51
+ super(message, 403, 'FORBIDDEN')
52
+ this.name = 'ForbiddenError'
53
+ }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { VerbatimsClient as BaseClient } from './client'
2
+ import { QuotesResource } from './resources/quotes'
3
+ import { AuthorsResource } from './resources/authors'
4
+ import { ReferencesResource } from './resources/references'
5
+ import { TagsResource } from './resources/tags'
6
+ import { CollectionsResource } from './resources/collections'
7
+ import { SearchResource } from './resources/search'
8
+
9
+ export type { ClientOptions } from './client'
10
+ export { paginate } from './pagination'
11
+ export type { PageFetcher } from './pagination'
12
+
13
+ export {
14
+ VerbatimsError,
15
+ NotFoundError,
16
+ RateLimitError,
17
+ ValidationError,
18
+ AuthError,
19
+ ForbiddenError,
20
+ } from './errors'
21
+
22
+ export type {
23
+ QuoteWithRelations,
24
+ Author,
25
+ QuoteReference,
26
+ PaginationMeta,
27
+ ApiResponse,
28
+ ListQuotesParams,
29
+ ListAuthorsParams,
30
+ ListReferencesParams,
31
+ SearchParams,
32
+ CreateQuoteData,
33
+ UpdateQuoteData,
34
+ CreateAuthorData,
35
+ UpdateAuthorData,
36
+ CreateReferenceData,
37
+ UpdateReferenceData,
38
+ CreateCollectionData,
39
+ } from './types'
40
+
41
+ export class VerbatimsClient extends BaseClient {
42
+ quotes: QuotesResource
43
+ authors: AuthorsResource
44
+ references: ReferencesResource
45
+ tags: TagsResource
46
+ collections: CollectionsResource
47
+ search: SearchResource
48
+
49
+ constructor(apiKey: string, opts?: ConstructorParameters<typeof BaseClient>[1]) {
50
+ super(apiKey, opts)
51
+ this.quotes = new QuotesResource(this)
52
+ this.authors = new AuthorsResource(this)
53
+ this.references = new ReferencesResource(this)
54
+ this.tags = new TagsResource(this)
55
+ this.collections = new CollectionsResource(this)
56
+ this.search = new SearchResource(this)
57
+ }
58
+ }
@@ -0,0 +1,25 @@
1
+ import type { PaginationMeta } from './types'
2
+
3
+ export interface PageFetcher<T> {
4
+ (page: number): Promise<{
5
+ data?: T[]
6
+ pagination?: PaginationMeta
7
+ }>
8
+ }
9
+
10
+ export async function* paginate<T>(fetchPage: PageFetcher<T>): AsyncGenerator<T> {
11
+ let page = 1
12
+
13
+ while (true) {
14
+ const result = await fetchPage(page)
15
+ const items = result.data
16
+ if (!items || items.length === 0) break
17
+
18
+ for (const item of items) {
19
+ yield item
20
+ }
21
+
22
+ if (!result.pagination?.hasMore) break
23
+ page++
24
+ }
25
+ }
@@ -0,0 +1,386 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { VerbatimsClient } from '../../client'
3
+ import { QuotesResource } from '../quotes'
4
+ import { AuthorsResource } from '../authors'
5
+ import { ReferencesResource } from '../references'
6
+ import { TagsResource } from '../tags'
7
+ import { CollectionsResource } from '../collections'
8
+ import { SearchResource } from '../search'
9
+
10
+ function mockResponse(data: unknown, status = 200) {
11
+ return new Response(JSON.stringify(data), {
12
+ status,
13
+ headers: { 'Content-Type': 'application/json' },
14
+ })
15
+ }
16
+
17
+ function createMockClient() {
18
+ const fetchFn = vi.fn()
19
+ const client = new VerbatimsClient('test_key', {
20
+ baseUrl: '/api/v1',
21
+ fetch: fetchFn,
22
+ retry: { maxRetries: 0, baseDelayMs: 5 },
23
+ })
24
+ return { fetchFn, client }
25
+ }
26
+
27
+ describe('QuotesResource', () => {
28
+ let fetchFn: ReturnType<typeof vi.fn>
29
+ let quotes: QuotesResource
30
+
31
+ beforeEach(() => {
32
+ const mock = createMockClient()
33
+ fetchFn = mock.fetchFn
34
+ quotes = new QuotesResource(mock.client)
35
+ })
36
+
37
+ describe('list', () => {
38
+ it('calls GET /quotes with params', async () => {
39
+ fetchFn.mockResolvedValue(mockResponse({
40
+ success: true,
41
+ data: [{ id: 1, name: 'Test', language: 'fr', status: 'published', views_count: 0, likes_count: 0, shares_count: 0, created_at: '2024-01-01', updated_at: '2024-01-01' }],
42
+ pagination: { page: 1, limit: 10, total: 1, totalPages: 1, hasMore: false },
43
+ }))
44
+
45
+ const result = await quotes.list({ language: 'fr', limit: 10 })
46
+ expect(fetchFn).toHaveBeenCalledTimes(1)
47
+
48
+ const [url] = fetchFn.mock.calls[0]
49
+ expect(url).toContain('/quotes')
50
+ const parsed = new URL(url, 'http://localhost')
51
+ expect(parsed.searchParams.get('language')).toBe('fr')
52
+ expect(parsed.searchParams.get('limit')).toBe('10')
53
+ expect(result.success).toBe(true)
54
+ expect(result.data).toHaveLength(1)
55
+ expect(result.data![0].name).toBe('Test')
56
+ })
57
+ })
58
+
59
+ describe('get', () => {
60
+ it('calls GET /quotes/:id', async () => {
61
+ fetchFn.mockResolvedValue(mockResponse({
62
+ success: true,
63
+ data: { id: 42, name: 'Quote 42', language: 'en', status: 'published', views_count: 10, likes_count: 5, shares_count: 2, created_at: '2024-01-01', updated_at: '2024-01-01' },
64
+ }))
65
+
66
+ const result = await quotes.get(42)
67
+ const [url] = fetchFn.mock.calls[0]
68
+ expect(url).toContain('/quotes/42')
69
+ expect(result.data!.id).toBe(42)
70
+ })
71
+ })
72
+
73
+ describe('create', () => {
74
+ it('calls POST /quotes with body', async () => {
75
+ fetchFn.mockResolvedValue(mockResponse({
76
+ success: true,
77
+ data: { id: 1, name: 'New quote', language: 'en', status: 'published', views_count: 0, likes_count: 0, shares_count: 0, created_at: '2024-01-01', updated_at: '2024-01-01' },
78
+ }))
79
+
80
+ await quotes.create({ name: 'New quote', language: 'en' })
81
+ const [, opts] = fetchFn.mock.calls[0]
82
+ expect(opts.method).toBe('POST')
83
+ expect(JSON.parse(opts.body)).toEqual({ name: 'New quote', language: 'en' })
84
+ })
85
+ })
86
+
87
+ describe('update', () => {
88
+ it('calls PUT /quotes/:id with body', async () => {
89
+ fetchFn.mockResolvedValue(mockResponse({
90
+ success: true,
91
+ data: { id: 1, name: 'Updated', language: 'en', status: 'published', views_count: 0, likes_count: 0, shares_count: 0, created_at: '2024-01-01', updated_at: '2024-01-01' },
92
+ }))
93
+
94
+ await quotes.update(1, { name: 'Updated' })
95
+ const [, opts] = fetchFn.mock.calls[0]
96
+ expect(opts.method).toBe('PUT')
97
+ expect(JSON.parse(opts.body)).toEqual({ name: 'Updated' })
98
+ })
99
+ })
100
+
101
+ describe('delete', () => {
102
+ it('calls DELETE /quotes/:id', async () => {
103
+ fetchFn.mockResolvedValue(mockResponse({ success: true }))
104
+ await quotes.delete(1)
105
+ const [url, opts] = fetchFn.mock.calls[0]
106
+ expect(url).toContain('/quotes/1')
107
+ expect(opts.method).toBe('DELETE')
108
+ })
109
+ })
110
+
111
+ describe('paginate', () => {
112
+ it('yields items across pages', async () => {
113
+ fetchFn
114
+ .mockResolvedValueOnce(mockResponse({
115
+ success: true,
116
+ data: [{ id: 1, name: 'A', language: 'fr', status: 'published', views_count: 0, likes_count: 0, shares_count: 0, created_at: '2024-01-01', updated_at: '2024-01-01' }],
117
+ pagination: { page: 1, limit: 1, total: 2, totalPages: 2, hasMore: true },
118
+ }))
119
+ .mockResolvedValueOnce(mockResponse({
120
+ success: true,
121
+ data: [{ id: 2, name: 'B', language: 'fr', status: 'published', views_count: 0, likes_count: 0, shares_count: 0, created_at: '2024-01-01', updated_at: '2024-01-01' }],
122
+ pagination: { page: 2, limit: 1, total: 2, totalPages: 2, hasMore: false },
123
+ }))
124
+
125
+ const results = []
126
+ for await (const item of quotes.paginate({ language: 'fr' })) {
127
+ results.push(item)
128
+ }
129
+
130
+ expect(results).toHaveLength(2)
131
+ expect(results[0].id).toBe(1)
132
+ expect(results[1].id).toBe(2)
133
+ expect(fetchFn).toHaveBeenCalledTimes(2)
134
+ })
135
+ })
136
+ })
137
+
138
+ describe('AuthorsResource', () => {
139
+ let fetchFn: ReturnType<typeof vi.fn>
140
+ let authors: AuthorsResource
141
+
142
+ beforeEach(() => {
143
+ const mock = createMockClient()
144
+ fetchFn = mock.fetchFn
145
+ authors = new AuthorsResource(mock.client)
146
+ })
147
+
148
+ it('list calls GET /authors', async () => {
149
+ fetchFn.mockResolvedValue(mockResponse({
150
+ success: true,
151
+ data: [{ id: 1, name: 'Author', views_count: 0, likes_count: 0, shares_count: 0, created_at: '2024-01-01', updated_at: '2024-01-01' }],
152
+ pagination: { page: 1, limit: 10, total: 1, totalPages: 1, hasMore: false },
153
+ }))
154
+
155
+ const result = await authors.list({ search: 'test' })
156
+ const [url] = fetchFn.mock.calls[0]
157
+ expect(url).toContain('/authors')
158
+ const parsed = new URL(url, 'http://localhost')
159
+ expect(parsed.searchParams.get('search')).toBe('test')
160
+ expect(result.data).toHaveLength(1)
161
+ })
162
+
163
+ it('get calls GET /authors/:id', async () => {
164
+ fetchFn.mockResolvedValue(mockResponse({
165
+ success: true,
166
+ data: { id: 5, name: 'Author 5', views_count: 0, likes_count: 0, shares_count: 0, created_at: '2024-01-01', updated_at: '2024-01-01' },
167
+ }))
168
+
169
+ await authors.get(5)
170
+ const [url] = fetchFn.mock.calls[0]
171
+ expect(url).toContain('/authors/5')
172
+ })
173
+
174
+ it('create calls POST /authors', async () => {
175
+ fetchFn.mockResolvedValue(mockResponse({
176
+ success: true,
177
+ data: { id: 1, name: 'New Author', views_count: 0, likes_count: 0, shares_count: 0, created_at: '2024-01-01', updated_at: '2024-01-01' },
178
+ }))
179
+
180
+ await authors.create({ name: 'New Author', is_fictional: true })
181
+ const [, opts] = fetchFn.mock.calls[0]
182
+ expect(opts.method).toBe('POST')
183
+ expect(JSON.parse(opts.body).is_fictional).toBe(true)
184
+ })
185
+
186
+ it('update calls PUT /authors/:id', async () => {
187
+ fetchFn.mockResolvedValue(mockResponse({
188
+ success: true,
189
+ data: { id: 1, name: 'Updated', views_count: 0, likes_count: 0, shares_count: 0, created_at: '2024-01-01', updated_at: '2024-01-01' },
190
+ }))
191
+
192
+ await authors.update(1, { name: 'Updated' })
193
+ const [, opts] = fetchFn.mock.calls[0]
194
+ expect(opts.method).toBe('PUT')
195
+ })
196
+ })
197
+
198
+ describe('ReferencesResource', () => {
199
+ let fetchFn: ReturnType<typeof vi.fn>
200
+ let references: ReferencesResource
201
+
202
+ beforeEach(() => {
203
+ const mock = createMockClient()
204
+ fetchFn = mock.fetchFn
205
+ references = new ReferencesResource(mock.client)
206
+ })
207
+
208
+ it('list calls GET /references', async () => {
209
+ fetchFn.mockResolvedValue(mockResponse({
210
+ success: true,
211
+ data: [{ id: 1, name: 'Ref', primary_type: 'book', views_count: 0, likes_count: 0, shares_count: 0, created_at: '2024-01-01', updated_at: '2024-01-01' }],
212
+ pagination: { page: 1, limit: 10, total: 1, totalPages: 1, hasMore: false },
213
+ }))
214
+
215
+ await references.list({ type: 'book' })
216
+ const [url] = fetchFn.mock.calls[0]
217
+ expect(url).toContain('/references')
218
+ const parsed = new URL(url, 'http://localhost')
219
+ expect(parsed.searchParams.get('type')).toBe('book')
220
+ })
221
+
222
+ it('get calls GET /references/:id', async () => {
223
+ fetchFn.mockResolvedValue(mockResponse({
224
+ success: true,
225
+ data: { id: 3, name: 'Ref 3', primary_type: 'book', views_count: 0, likes_count: 0, shares_count: 0, created_at: '2024-01-01', updated_at: '2024-01-01' },
226
+ }))
227
+
228
+ await references.get(3)
229
+ const [url] = fetchFn.mock.calls[0]
230
+ expect(url).toContain('/references/3')
231
+ })
232
+
233
+ it('create calls POST /references', async () => {
234
+ fetchFn.mockResolvedValue(mockResponse({
235
+ success: true,
236
+ data: { id: 1, name: 'New Ref', primary_type: 'movie', views_count: 0, likes_count: 0, shares_count: 0, created_at: '2024-01-01', updated_at: '2024-01-01' },
237
+ }))
238
+
239
+ await references.create({ name: 'New Ref', primary_type: 'movie' })
240
+ const [, opts] = fetchFn.mock.calls[0]
241
+ expect(opts.method).toBe('POST')
242
+ })
243
+
244
+ it('update calls PUT /references/:id', async () => {
245
+ fetchFn.mockResolvedValue(mockResponse({
246
+ success: true,
247
+ data: { id: 1, name: 'Updated', primary_type: 'book', views_count: 0, likes_count: 0, shares_count: 0, created_at: '2024-01-01', updated_at: '2024-01-01' },
248
+ }))
249
+
250
+ await references.update(1, { name: 'Updated' })
251
+ const [, opts] = fetchFn.mock.calls[0]
252
+ expect(opts.method).toBe('PUT')
253
+ })
254
+ })
255
+
256
+ describe('TagsResource', () => {
257
+ let fetchFn: ReturnType<typeof vi.fn>
258
+ let tags: TagsResource
259
+
260
+ beforeEach(() => {
261
+ const mock = createMockClient()
262
+ fetchFn = mock.fetchFn
263
+ tags = new TagsResource(mock.client)
264
+ })
265
+
266
+ it('list calls GET /tags', async () => {
267
+ fetchFn.mockResolvedValue(mockResponse({
268
+ success: true,
269
+ data: [{ id: 1, name: 'wisdom' }],
270
+ pagination: { page: 1, limit: 10, total: 1, totalPages: 1, hasMore: false },
271
+ }))
272
+
273
+ const result = await tags.list()
274
+ const [url] = fetchFn.mock.calls[0]
275
+ expect(url).toContain('/tags')
276
+ expect(result.data![0].name).toBe('wisdom')
277
+ })
278
+
279
+ it('paginate yields tags across pages', async () => {
280
+ fetchFn
281
+ .mockResolvedValueOnce(mockResponse({
282
+ success: true,
283
+ data: [{ id: 1, name: 'tag1' }],
284
+ pagination: { page: 1, limit: 1, total: 2, totalPages: 2, hasMore: true },
285
+ }))
286
+ .mockResolvedValueOnce(mockResponse({
287
+ success: true,
288
+ data: [{ id: 2, name: 'tag2' }],
289
+ pagination: { page: 2, limit: 1, total: 2, totalPages: 2, hasMore: false },
290
+ }))
291
+
292
+ const results = []
293
+ for await (const item of tags.paginate()) {
294
+ results.push(item)
295
+ }
296
+ expect(results).toHaveLength(2)
297
+ })
298
+ })
299
+
300
+ describe('CollectionsResource', () => {
301
+ let fetchFn: ReturnType<typeof vi.fn>
302
+ let collections: CollectionsResource
303
+
304
+ beforeEach(() => {
305
+ const mock = createMockClient()
306
+ fetchFn = mock.fetchFn
307
+ collections = new CollectionsResource(mock.client)
308
+ })
309
+
310
+ it('create calls POST /collections', async () => {
311
+ fetchFn.mockResolvedValue(mockResponse({
312
+ success: true,
313
+ data: { id: 1, name: 'My Collection', created_at: '2024-01-01', updated_at: '2024-01-01' },
314
+ }))
315
+
316
+ await collections.create({ name: 'My Collection', is_public: true })
317
+ const [url, opts] = fetchFn.mock.calls[0]
318
+ expect(url).toContain('/collections')
319
+ expect(opts.method).toBe('POST')
320
+ expect(JSON.parse(opts.body).name).toBe('My Collection')
321
+ })
322
+
323
+ it('addQuote calls POST /collections/:id/quotes', async () => {
324
+ fetchFn.mockResolvedValue(mockResponse({ success: true }))
325
+ await collections.addQuote(1, 42)
326
+ const [url, opts] = fetchFn.mock.calls[0]
327
+ expect(url).toContain('/collections/1/quotes')
328
+ expect(opts.method).toBe('POST')
329
+ expect(JSON.parse(opts.body).quote_id).toBe(42)
330
+ })
331
+
332
+ it('removeQuote calls DELETE /collections/:id/quotes/:quoteId', async () => {
333
+ fetchFn.mockResolvedValue(mockResponse({ success: true }))
334
+ await collections.removeQuote(1, 42)
335
+ const [url, opts] = fetchFn.mock.calls[0]
336
+ expect(url).toContain('/collections/1/quotes/42')
337
+ expect(opts.method).toBe('DELETE')
338
+ })
339
+ })
340
+
341
+ describe('SearchResource', () => {
342
+ let fetchFn: ReturnType<typeof vi.fn>
343
+ let search: SearchResource
344
+
345
+ beforeEach(() => {
346
+ const mock = createMockClient()
347
+ fetchFn = mock.fetchFn
348
+ search = new SearchResource(mock.client)
349
+ })
350
+
351
+ it('query calls GET /search with q param', async () => {
352
+ fetchFn.mockResolvedValue(mockResponse({
353
+ success: true,
354
+ data: [{ id: 1, type: 'quote', name: 'Life is...' }],
355
+ pagination: { page: 1, limit: 10, total: 1, totalPages: 1, hasMore: false },
356
+ }))
357
+
358
+ const result = await search.query({ q: 'life', type: 'quotes' })
359
+ const [url] = fetchFn.mock.calls[0]
360
+ expect(url).toContain('/search')
361
+ const parsed = new URL(url, 'http://localhost')
362
+ expect(parsed.searchParams.get('q')).toBe('life')
363
+ expect(parsed.searchParams.get('type')).toBe('quotes')
364
+ expect(result.data![0].name).toBe('Life is...')
365
+ })
366
+
367
+ it('paginate searches across pages', async () => {
368
+ fetchFn
369
+ .mockResolvedValueOnce(mockResponse({
370
+ success: true,
371
+ data: [{ id: 1, type: 'quote', name: 'A' }],
372
+ pagination: { page: 1, limit: 1, total: 2, totalPages: 2, hasMore: true },
373
+ }))
374
+ .mockResolvedValueOnce(mockResponse({
375
+ success: true,
376
+ data: [{ id: 2, type: 'quote', name: 'B' }],
377
+ pagination: { page: 2, limit: 1, total: 2, totalPages: 2, hasMore: false },
378
+ }))
379
+
380
+ const results = []
381
+ for await (const item of search.paginate({ q: 'life' })) {
382
+ results.push(item)
383
+ }
384
+ expect(results).toHaveLength(2)
385
+ })
386
+ })
@@ -0,0 +1,58 @@
1
+ import { z } from 'zod/v4'
2
+ import type { VerbatimsClient } from '../client'
3
+ import { apiResponseSchema } from '../types'
4
+ import { paginate } from '../pagination'
5
+ import type { Author, ListAuthorsParams, CreateAuthorData, UpdateAuthorData } from '../types'
6
+
7
+ const authorSchema = z.object({
8
+ id: z.number(),
9
+ name: z.string(),
10
+ is_fictional: z.boolean().optional(),
11
+ image_url: z.string().nullable().optional(),
12
+ job: z.string().nullable().optional(),
13
+ description: z.string().nullable().optional(),
14
+ birth_date: z.string().nullable().optional(),
15
+ birth_location: z.string().nullable().optional(),
16
+ death_date: z.string().nullable().optional(),
17
+ death_location: z.string().nullable().optional(),
18
+ views_count: z.number(),
19
+ likes_count: z.number(),
20
+ shares_count: z.number(),
21
+ quotes_count: z.number().optional(),
22
+ created_at: z.string(),
23
+ updated_at: z.string(),
24
+ })
25
+
26
+ const authorListResponseSchema = apiResponseSchema(z.array(authorSchema))
27
+ const authorSingleResponseSchema = apiResponseSchema(authorSchema)
28
+
29
+ type AuthorItem = z.infer<typeof authorSchema>
30
+
31
+ export class AuthorsResource {
32
+ constructor(private client: VerbatimsClient) {}
33
+
34
+ async list(params?: ListAuthorsParams) {
35
+ return this.client.get('/authors', { params: params as Record<string, unknown> }, authorListResponseSchema)
36
+ }
37
+
38
+ paginate(params?: ListAuthorsParams): AsyncGenerator<AuthorItem> {
39
+ return paginate<AuthorItem>((page) =>
40
+ this.list({ ...params, page }).then(r => ({
41
+ data: r.data,
42
+ pagination: r.pagination,
43
+ }))
44
+ )
45
+ }
46
+
47
+ async get(id: number) {
48
+ return this.client.get(`/authors/${id}`, {}, authorSingleResponseSchema)
49
+ }
50
+
51
+ async create(data: CreateAuthorData) {
52
+ return this.client.post('/authors', data, {}, authorSingleResponseSchema)
53
+ }
54
+
55
+ async update(id: number, data: UpdateAuthorData) {
56
+ return this.client.put(`/authors/${id}`, data, {}, authorSingleResponseSchema)
57
+ }
58
+ }
@@ -0,0 +1,45 @@
1
+ import { z } from 'zod/v4'
2
+ import type { VerbatimsClient } from '../client'
3
+ import { apiResponseSchema } from '../types'
4
+ import type { CreateCollectionData } from '../types'
5
+
6
+ const collectionSchema = z.object({
7
+ id: z.number(),
8
+ name: z.string(),
9
+ description: z.string().nullable().optional(),
10
+ is_public: z.boolean().optional(),
11
+ quote_count: z.number().optional(),
12
+ created_at: z.string(),
13
+ updated_at: z.string(),
14
+ })
15
+
16
+ const collectionResponseSchema = apiResponseSchema(collectionSchema)
17
+ const collectionActionResponseSchema = apiResponseSchema(z.undefined()).or(z.object({
18
+ success: z.literal(true),
19
+ message: z.string().optional(),
20
+ }))
21
+
22
+ export class CollectionsResource {
23
+ constructor(private client: VerbatimsClient) {}
24
+
25
+ async create(data: CreateCollectionData) {
26
+ return this.client.post('/collections', data, {}, collectionResponseSchema)
27
+ }
28
+
29
+ async addQuote(collectionId: number, quoteId: number) {
30
+ return this.client.post(
31
+ `/collections/${collectionId}/quotes`,
32
+ { quote_id: quoteId },
33
+ {},
34
+ collectionActionResponseSchema,
35
+ )
36
+ }
37
+
38
+ async removeQuote(collectionId: number, quoteId: number) {
39
+ return this.client.delete(
40
+ `/collections/${collectionId}/quotes/${quoteId}`,
41
+ {},
42
+ collectionActionResponseSchema,
43
+ )
44
+ }
45
+ }
@@ -0,0 +1,6 @@
1
+ export { QuotesResource } from './quotes'
2
+ export { AuthorsResource } from './authors'
3
+ export { ReferencesResource } from './references'
4
+ export { TagsResource } from './tags'
5
+ export { CollectionsResource } from './collections'
6
+ export { SearchResource } from './search'