@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
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'
|