@svadmin/elysia 0.6.0 → 0.9.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/package.json +1 -1
- package/src/data-provider.test.ts +440 -0
- package/src/data-provider.ts +127 -44
package/package.json
CHANGED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// Tests for enhanced Elysia DataProvider
|
|
2
|
+
import { describe, it, expect, beforeEach, mock, afterEach } from 'bun:test';
|
|
3
|
+
import { createElysiaDataProvider } from './data-provider';
|
|
4
|
+
import type { DataProvider } from '@svadmin/core';
|
|
5
|
+
|
|
6
|
+
// ─── Mock fetch ──────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const originalFetch = globalThis.fetch;
|
|
9
|
+
let mockFetchFn: ReturnType<typeof mock>;
|
|
10
|
+
|
|
11
|
+
function setupMockFetch(response: unknown, status = 200, statusText = 'OK') {
|
|
12
|
+
mockFetchFn = mock(() =>
|
|
13
|
+
Promise.resolve({
|
|
14
|
+
ok: status >= 200 && status < 300,
|
|
15
|
+
status,
|
|
16
|
+
statusText,
|
|
17
|
+
json: () => Promise.resolve(response),
|
|
18
|
+
text: () => Promise.resolve(JSON.stringify(response)),
|
|
19
|
+
} as Response)
|
|
20
|
+
);
|
|
21
|
+
globalThis.fetch = mockFetchFn as unknown as typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
globalThis.fetch = originalFetch;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ─── Basic CRUD ──────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
describe('createElysiaDataProvider', () => {
|
|
31
|
+
let provider: DataProvider;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
provider = createElysiaDataProvider({ apiUrl: 'http://localhost:3000' });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should return the API URL', () => {
|
|
38
|
+
expect(provider.getApiUrl()).toBe('http://localhost:3000');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ─── getList ───────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe('getList', () => {
|
|
44
|
+
it('should fetch list with { items, total } format', async () => {
|
|
45
|
+
const mockData = { items: [{ id: 1, name: 'Test' }], total: 1 };
|
|
46
|
+
setupMockFetch(mockData);
|
|
47
|
+
|
|
48
|
+
const result = await provider.getList({ resource: 'posts' });
|
|
49
|
+
|
|
50
|
+
expect(result.data).toEqual([{ id: 1, name: 'Test' }]);
|
|
51
|
+
expect(result.total).toBe(1);
|
|
52
|
+
expect(mockFetchFn).toHaveBeenCalledTimes(1);
|
|
53
|
+
const [url] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
54
|
+
expect(url).toContain('http://localhost:3000/posts?');
|
|
55
|
+
expect(url).toContain('_page=1');
|
|
56
|
+
expect(url).toContain('_limit=10');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should fetch list with { data, total } format', async () => {
|
|
60
|
+
const mockData = { data: [{ id: 1 }, { id: 2 }], total: 42 };
|
|
61
|
+
setupMockFetch(mockData);
|
|
62
|
+
|
|
63
|
+
const result = await provider.getList({ resource: 'users' });
|
|
64
|
+
expect(result.data).toEqual([{ id: 1 }, { id: 2 }]);
|
|
65
|
+
expect(result.total).toBe(42);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should fetch list with raw array format', async () => {
|
|
69
|
+
const mockData = [{ id: 1 }, { id: 2 }, { id: 3 }];
|
|
70
|
+
setupMockFetch(mockData);
|
|
71
|
+
|
|
72
|
+
const result = await provider.getList({ resource: 'channels' });
|
|
73
|
+
expect(result.data).toEqual(mockData);
|
|
74
|
+
expect(result.total).toBe(3);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should apply pagination params', async () => {
|
|
78
|
+
setupMockFetch({ items: [], total: 0 });
|
|
79
|
+
|
|
80
|
+
await provider.getList({
|
|
81
|
+
resource: 'posts',
|
|
82
|
+
pagination: { current: 3, pageSize: 25 },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const [url] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
86
|
+
expect(url).toContain('_page=3');
|
|
87
|
+
expect(url).toContain('_limit=25');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should apply sorters', async () => {
|
|
91
|
+
setupMockFetch({ items: [], total: 0 });
|
|
92
|
+
|
|
93
|
+
await provider.getList({
|
|
94
|
+
resource: 'posts',
|
|
95
|
+
sorters: [{ field: 'name', order: 'asc' }],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const [url] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
99
|
+
expect(url).toContain('_sort=name');
|
|
100
|
+
expect(url).toContain('_order=asc');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should apply filters', async () => {
|
|
104
|
+
setupMockFetch({ items: [], total: 0 });
|
|
105
|
+
|
|
106
|
+
await provider.getList({
|
|
107
|
+
resource: 'posts',
|
|
108
|
+
filters: [
|
|
109
|
+
{ field: 'status', operator: 'eq', value: 'active' },
|
|
110
|
+
{ field: 'name', operator: 'contains', value: 'test' },
|
|
111
|
+
{ field: 'age', operator: 'gte', value: 18 },
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const [url] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
116
|
+
expect(url).toContain('status=active');
|
|
117
|
+
expect(url).toContain('name_like=test');
|
|
118
|
+
expect(url).toContain('age_gte=18');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ─── getOne ────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe('getOne', () => {
|
|
125
|
+
it('should fetch a single record', async () => {
|
|
126
|
+
const mockData = { id: 1, name: 'Test' };
|
|
127
|
+
setupMockFetch(mockData);
|
|
128
|
+
|
|
129
|
+
const result = await provider.getOne({ resource: 'posts', id: 1 });
|
|
130
|
+
expect(result.data).toEqual(mockData);
|
|
131
|
+
|
|
132
|
+
const [url] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
133
|
+
expect(url).toBe('http://localhost:3000/posts/1');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ─── create ────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe('create', () => {
|
|
140
|
+
it('should create a record with POST', async () => {
|
|
141
|
+
const mockData = { id: 1, name: 'New' };
|
|
142
|
+
setupMockFetch(mockData);
|
|
143
|
+
|
|
144
|
+
const result = await provider.create({
|
|
145
|
+
resource: 'posts',
|
|
146
|
+
variables: { name: 'New' },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(result.data).toEqual(mockData);
|
|
150
|
+
const [url, init] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
151
|
+
expect(url).toBe('http://localhost:3000/posts');
|
|
152
|
+
expect(init.method).toBe('POST');
|
|
153
|
+
expect(init.body).toBe('{"name":"New"}');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ─── update ────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
describe('update', () => {
|
|
160
|
+
it('should default to PATCH method', async () => {
|
|
161
|
+
const mockData = { id: 1, name: 'Updated' };
|
|
162
|
+
setupMockFetch(mockData);
|
|
163
|
+
|
|
164
|
+
await provider.update({
|
|
165
|
+
resource: 'posts',
|
|
166
|
+
id: 1,
|
|
167
|
+
variables: { name: 'Updated' },
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const [, init] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
171
|
+
expect(init.method).toBe('PATCH');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ─── deleteOne ─────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
describe('deleteOne', () => {
|
|
178
|
+
it('should delete a record', async () => {
|
|
179
|
+
setupMockFetch({ id: 1 });
|
|
180
|
+
|
|
181
|
+
const result = await provider.deleteOne({ resource: 'posts', id: 1 });
|
|
182
|
+
expect(result.data).toEqual({ id: 1 });
|
|
183
|
+
|
|
184
|
+
const [url, init] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
185
|
+
expect(url).toBe('http://localhost:3000/posts/1');
|
|
186
|
+
expect(init.method).toBe('DELETE');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ─── error handling ────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
describe('error handling', () => {
|
|
193
|
+
it('should throw on non-OK response', async () => {
|
|
194
|
+
setupMockFetch({ error: 'Not found' }, 404, 'Not Found');
|
|
195
|
+
|
|
196
|
+
await expect(
|
|
197
|
+
provider.getOne({ resource: 'posts', id: 999 })
|
|
198
|
+
).rejects.toThrow('HTTP 404');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ─── updateMethod option ─────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
describe('updateMethod: PUT', () => {
|
|
206
|
+
it('should use PUT for updates when configured', async () => {
|
|
207
|
+
const provider = createElysiaDataProvider({
|
|
208
|
+
apiUrl: 'http://localhost:3000',
|
|
209
|
+
updateMethod: 'PUT',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
setupMockFetch({ id: 1, name: 'Updated' });
|
|
213
|
+
|
|
214
|
+
await provider.update({
|
|
215
|
+
resource: 'channels',
|
|
216
|
+
id: 1,
|
|
217
|
+
variables: { name: 'Updated' },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const [url, init] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
221
|
+
expect(url).toBe('http://localhost:3000/channels/1');
|
|
222
|
+
expect(init.method).toBe('PUT');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should use PUT for updateMany when configured', async () => {
|
|
226
|
+
const provider = createElysiaDataProvider({
|
|
227
|
+
apiUrl: 'http://localhost:3000',
|
|
228
|
+
updateMethod: 'PUT',
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
setupMockFetch({ id: 1 });
|
|
232
|
+
|
|
233
|
+
await provider.updateMany!({
|
|
234
|
+
resource: 'channels',
|
|
235
|
+
ids: [1, 2],
|
|
236
|
+
variables: { status: 'active' },
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
for (const call of mockFetchFn.mock.calls) {
|
|
240
|
+
const [, init] = call as [string, RequestInit];
|
|
241
|
+
expect(init.method).toBe('PUT');
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ─── withCredentials option ──────────────────────────────────
|
|
247
|
+
|
|
248
|
+
describe('withCredentials', () => {
|
|
249
|
+
it('should include credentials when enabled', async () => {
|
|
250
|
+
const provider = createElysiaDataProvider({
|
|
251
|
+
apiUrl: 'http://localhost:3000',
|
|
252
|
+
withCredentials: true,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
setupMockFetch({ items: [], total: 0 });
|
|
256
|
+
|
|
257
|
+
await provider.getList({ resource: 'posts' });
|
|
258
|
+
|
|
259
|
+
const [, init] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
260
|
+
expect(init.credentials).toBe('include');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should not include credentials when disabled', async () => {
|
|
264
|
+
const provider = createElysiaDataProvider({
|
|
265
|
+
apiUrl: 'http://localhost:3000',
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
setupMockFetch({ items: [], total: 0 });
|
|
269
|
+
|
|
270
|
+
await provider.getList({ resource: 'posts' });
|
|
271
|
+
|
|
272
|
+
const [, init] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
273
|
+
expect(init.credentials).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ─── resourceUrlMap option ───────────────────────────────────
|
|
278
|
+
|
|
279
|
+
describe('resourceUrlMap', () => {
|
|
280
|
+
it('should map resource names to custom URL segments', async () => {
|
|
281
|
+
const provider = createElysiaDataProvider({
|
|
282
|
+
apiUrl: 'http://localhost:3000/admin',
|
|
283
|
+
resourceUrlMap: {
|
|
284
|
+
user_groups: 'user-groups',
|
|
285
|
+
rateLimits: 'rate-limits',
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
setupMockFetch([{ id: 1 }]);
|
|
290
|
+
|
|
291
|
+
await provider.getList({ resource: 'user_groups' });
|
|
292
|
+
const [url1] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
293
|
+
expect(url1).toContain('http://localhost:3000/admin/user-groups?');
|
|
294
|
+
|
|
295
|
+
setupMockFetch({ id: 1 });
|
|
296
|
+
|
|
297
|
+
await provider.getOne({ resource: 'rateLimits', id: 1 });
|
|
298
|
+
const [url2] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
299
|
+
expect(url2).toBe('http://localhost:3000/admin/rate-limits/1');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should use resource name as-is when no mapping exists', async () => {
|
|
303
|
+
const provider = createElysiaDataProvider({
|
|
304
|
+
apiUrl: 'http://localhost:3000',
|
|
305
|
+
resourceUrlMap: { mapped: 'mapped-url' },
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
setupMockFetch({ items: [], total: 0 });
|
|
309
|
+
|
|
310
|
+
await provider.getList({ resource: 'unmapped' });
|
|
311
|
+
const [url] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
312
|
+
expect(url).toContain('http://localhost:3000/unmapped?');
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ─── parseListResponse option ────────────────────────────────
|
|
317
|
+
|
|
318
|
+
describe('parseListResponse', () => {
|
|
319
|
+
it('should use custom parser when provided', async () => {
|
|
320
|
+
const provider = createElysiaDataProvider({
|
|
321
|
+
apiUrl: 'http://localhost:3000',
|
|
322
|
+
parseListResponse: <T>(json: unknown) => {
|
|
323
|
+
const obj = json as { results: T[]; count: number };
|
|
324
|
+
return { data: obj.results, total: obj.count };
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
setupMockFetch({ results: [{ id: 1 }], count: 100 });
|
|
329
|
+
|
|
330
|
+
const result = await provider.getList({ resource: 'posts' });
|
|
331
|
+
expect(result.data).toEqual([{ id: 1 }]);
|
|
332
|
+
expect(result.total).toBe(100);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ─── headers option ──────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
describe('headers', () => {
|
|
339
|
+
it('should support static headers', async () => {
|
|
340
|
+
const provider = createElysiaDataProvider({
|
|
341
|
+
apiUrl: 'http://localhost:3000',
|
|
342
|
+
headers: { 'Authorization': 'Bearer token123' },
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
setupMockFetch({ items: [], total: 0 });
|
|
346
|
+
|
|
347
|
+
await provider.getList({ resource: 'posts' });
|
|
348
|
+
|
|
349
|
+
const [, init] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
350
|
+
const headers = init.headers as Record<string, string>;
|
|
351
|
+
expect(headers['Authorization']).toBe('Bearer token123');
|
|
352
|
+
expect(headers['Content-Type']).toBe('application/json');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should support dynamic headers function', async () => {
|
|
356
|
+
let token = 'token1';
|
|
357
|
+
const provider = createElysiaDataProvider({
|
|
358
|
+
apiUrl: 'http://localhost:3000',
|
|
359
|
+
headers: () => ({ 'Authorization': `Bearer ${token}` }),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
setupMockFetch({ items: [], total: 0 });
|
|
363
|
+
|
|
364
|
+
await provider.getList({ resource: 'posts' });
|
|
365
|
+
let [, init] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
366
|
+
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer token1');
|
|
367
|
+
|
|
368
|
+
// Change token
|
|
369
|
+
token = 'token2';
|
|
370
|
+
setupMockFetch({ items: [], total: 0 });
|
|
371
|
+
|
|
372
|
+
await provider.getList({ resource: 'posts' });
|
|
373
|
+
[, init] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
374
|
+
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer token2');
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ─── custom method ───────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
describe('custom', () => {
|
|
381
|
+
it('should support custom API calls', async () => {
|
|
382
|
+
const provider = createElysiaDataProvider({
|
|
383
|
+
apiUrl: 'http://localhost:3000',
|
|
384
|
+
withCredentials: true,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
setupMockFetch({ success: true, modelsCount: 42 });
|
|
388
|
+
|
|
389
|
+
const result = await provider.custom!({
|
|
390
|
+
url: 'http://localhost:3000/channels/1/sync-models',
|
|
391
|
+
method: 'post',
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
expect(result.data).toEqual({ success: true, modelsCount: 42 });
|
|
395
|
+
const [url, init] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
396
|
+
expect(url).toBe('http://localhost:3000/channels/1/sync-models');
|
|
397
|
+
expect(init.method).toBe('POST');
|
|
398
|
+
expect(init.credentials).toBe('include');
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// ─── bulk operations ─────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
describe('bulk operations', () => {
|
|
405
|
+
it('should support getMany', async () => {
|
|
406
|
+
const provider = createElysiaDataProvider({ apiUrl: 'http://localhost:3000' });
|
|
407
|
+
setupMockFetch([{ id: 1 }, { id: 2 }]);
|
|
408
|
+
|
|
409
|
+
const result = await provider.getMany!({ resource: 'posts', ids: [1, 2] });
|
|
410
|
+
expect(result.data).toEqual([{ id: 1 }, { id: 2 }]);
|
|
411
|
+
|
|
412
|
+
const [url] = mockFetchFn.mock.calls[0] as [string, RequestInit];
|
|
413
|
+
expect(url).toContain('id=1&id=2');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('should support createMany', async () => {
|
|
417
|
+
const provider = createElysiaDataProvider({ apiUrl: 'http://localhost:3000' });
|
|
418
|
+
setupMockFetch({ id: 1, name: 'A' });
|
|
419
|
+
|
|
420
|
+
const result = await provider.createMany!({
|
|
421
|
+
resource: 'posts',
|
|
422
|
+
variables: [{ name: 'A' }, { name: 'B' }],
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
expect(mockFetchFn).toHaveBeenCalledTimes(2);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('should support deleteMany', async () => {
|
|
429
|
+
const provider = createElysiaDataProvider({ apiUrl: 'http://localhost:3000' });
|
|
430
|
+
setupMockFetch({ success: true });
|
|
431
|
+
|
|
432
|
+
await provider.deleteMany!({ resource: 'posts', ids: [1, 2, 3] });
|
|
433
|
+
|
|
434
|
+
expect(mockFetchFn).toHaveBeenCalledTimes(3);
|
|
435
|
+
for (const call of mockFetchFn.mock.calls) {
|
|
436
|
+
const [, init] = call as [string, RequestInit];
|
|
437
|
+
expect(init.method).toBe('DELETE');
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
});
|
package/src/data-provider.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Elysia DataProvider — CRUD convention compatible
|
|
2
2
|
// Expects backend routes following: GET /resource, GET /resource/:id, POST /resource, PATCH /resource/:id, DELETE /resource/:id
|
|
3
|
-
// Response format for lists: { items: T[], total: number }
|
|
3
|
+
// Response format for lists: { items: T[], total: number } (also supports raw arrays)
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
6
|
DataProvider, GetListParams, GetListResult, GetOneParams, GetOneResult,
|
|
@@ -15,16 +15,51 @@ export interface ElysiaDataProviderOptions {
|
|
|
15
15
|
apiUrl: string;
|
|
16
16
|
/** Static headers or a function returning headers (useful for auth tokens) */
|
|
17
17
|
headers?: Record<string, string> | (() => Record<string, string>);
|
|
18
|
+
/**
|
|
19
|
+
* HTTP method to use for update operations.
|
|
20
|
+
* @default 'PATCH'
|
|
21
|
+
*/
|
|
22
|
+
updateMethod?: 'PATCH' | 'PUT';
|
|
23
|
+
/**
|
|
24
|
+
* Whether to include credentials (cookies) in requests.
|
|
25
|
+
* Set to `true` for cookie-based authentication.
|
|
26
|
+
* @default false
|
|
27
|
+
*/
|
|
28
|
+
withCredentials?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Custom resource-to-URL segment mapping.
|
|
31
|
+
* Maps a resource name to a URL segment, e.g. `{ user_groups: 'user-groups' }`.
|
|
32
|
+
* When not provided, the resource name is used as-is.
|
|
33
|
+
*/
|
|
34
|
+
resourceUrlMap?: Record<string, string>;
|
|
35
|
+
/**
|
|
36
|
+
* Custom response parser for list endpoints.
|
|
37
|
+
* When provided, this function extracts `{ data, total }` from the raw JSON response.
|
|
38
|
+
* Use the `resource` parameter to apply different parsers per resource.
|
|
39
|
+
* Useful when the backend response format differs from `{ items, total }`.
|
|
40
|
+
*
|
|
41
|
+
* @default Handles `{ items, total }` and raw arrays automatically
|
|
42
|
+
*/
|
|
43
|
+
parseListResponse?: <T>(json: unknown, resource: string) => { data: T[]; total: number };
|
|
18
44
|
}
|
|
19
45
|
|
|
20
46
|
function resolveHeaders(opts: ElysiaDataProviderOptions): Record<string, string> {
|
|
21
|
-
const base = { 'Content-Type': 'application/json' };
|
|
47
|
+
const base: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
22
48
|
const extra = typeof opts.headers === 'function' ? opts.headers() : (opts.headers ?? {});
|
|
23
49
|
return { ...base, ...extra };
|
|
24
50
|
}
|
|
25
51
|
|
|
26
|
-
|
|
27
|
-
const
|
|
52
|
+
function resolveResourceUrl(opts: ElysiaDataProviderOptions, resource: string): string {
|
|
53
|
+
const segment = opts.resourceUrlMap?.[resource] ?? resource;
|
|
54
|
+
return `${opts.apiUrl}/${segment}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function request<T>(url: string, headers: Record<string, string>, init?: RequestInit, withCredentials?: boolean): Promise<T> {
|
|
58
|
+
const fetchInit: RequestInit = { ...init, headers: { ...headers, ...init?.headers } };
|
|
59
|
+
if (withCredentials) {
|
|
60
|
+
fetchInit.credentials = 'include';
|
|
61
|
+
}
|
|
62
|
+
const response = await fetch(url, fetchInit);
|
|
28
63
|
if (!response.ok) {
|
|
29
64
|
const body = await response.text().catch(() => '');
|
|
30
65
|
throw new Error(`HTTP ${response.status}: ${response.statusText}${body ? ` — ${body}` : ''}`);
|
|
@@ -32,14 +67,49 @@ async function request<T>(url: string, headers: Record<string, string>, init?: R
|
|
|
32
67
|
return response.json();
|
|
33
68
|
}
|
|
34
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Default list response parser.
|
|
72
|
+
* Supports:
|
|
73
|
+
* - `{ items: T[], total: number }` (standard)
|
|
74
|
+
* - `{ data: T[], total: number }` (common alternative)
|
|
75
|
+
* - `T[]` (raw array — total is inferred from array length)
|
|
76
|
+
*/
|
|
77
|
+
function defaultParseListResponse<T>(json: unknown): { data: T[]; total: number } {
|
|
78
|
+
if (Array.isArray(json)) {
|
|
79
|
+
return { data: json as T[], total: json.length };
|
|
80
|
+
}
|
|
81
|
+
const obj = json as Record<string, unknown>;
|
|
82
|
+
if (Array.isArray(obj.items)) {
|
|
83
|
+
return { data: obj.items as T[], total: obj.total !== undefined ? Number(obj.total) : obj.items.length };
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(obj.data)) {
|
|
86
|
+
return { data: obj.data as T[], total: obj.total !== undefined ? Number(obj.total) : obj.data.length };
|
|
87
|
+
}
|
|
88
|
+
throw new Error('Unrecognized list response format. Expected { items, total }, { data, total }, or an array.');
|
|
89
|
+
}
|
|
90
|
+
|
|
35
91
|
/**
|
|
36
92
|
* Creates a DataProvider for Elysia backends using the CRUD plugin convention.
|
|
37
93
|
*
|
|
38
|
-
* List responses
|
|
39
|
-
*
|
|
94
|
+
* List responses support multiple formats:
|
|
95
|
+
* - `{ items: T[], total: number }` (standard CRUD convention)
|
|
96
|
+
* - `{ data: T[], total: number }` (common alternative)
|
|
97
|
+
* - `T[]` (raw array — total is inferred from length)
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* const dataProvider = createElysiaDataProvider({
|
|
102
|
+
* apiUrl: 'http://localhost:3000/api',
|
|
103
|
+
* withCredentials: true, // cookie auth
|
|
104
|
+
* updateMethod: 'PUT', // use PUT instead of PATCH
|
|
105
|
+
* resourceUrlMap: {
|
|
106
|
+
* user_groups: 'user-groups',
|
|
107
|
+
* },
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
40
110
|
*/
|
|
41
111
|
export function createElysiaDataProvider(opts: ElysiaDataProviderOptions): DataProvider {
|
|
42
|
-
const { apiUrl } = opts;
|
|
112
|
+
const { apiUrl, updateMethod = 'PATCH', withCredentials = false } = opts;
|
|
43
113
|
|
|
44
114
|
return {
|
|
45
115
|
getApiUrl: () => apiUrl,
|
|
@@ -63,78 +133,91 @@ export function createElysiaDataProvider(opts: ElysiaDataProviderOptions): DataP
|
|
|
63
133
|
}
|
|
64
134
|
}
|
|
65
135
|
|
|
66
|
-
const
|
|
136
|
+
const baseUrl = resolveResourceUrl(opts, resource);
|
|
137
|
+
const url = `${baseUrl}?${params.toString()}`;
|
|
67
138
|
const headers = resolveHeaders(opts);
|
|
68
|
-
const json = await request<
|
|
69
|
-
|
|
139
|
+
const json = await request<unknown>(url, headers, undefined, withCredentials);
|
|
140
|
+
|
|
141
|
+
if (opts.parseListResponse) {
|
|
142
|
+
return opts.parseListResponse<TData>(json, resource);
|
|
143
|
+
}
|
|
144
|
+
return defaultParseListResponse<TData>(json);
|
|
70
145
|
},
|
|
71
146
|
|
|
72
147
|
async getOne<TData extends BaseRecord = BaseRecord>({ resource, id }: GetOneParams): Promise<GetOneResult<TData>> {
|
|
73
|
-
const
|
|
148
|
+
const baseUrl = resolveResourceUrl(opts, resource);
|
|
149
|
+
const data = await request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), undefined, withCredentials);
|
|
74
150
|
return { data };
|
|
75
151
|
},
|
|
76
152
|
|
|
77
153
|
async create<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, variables }: CreateParams<TVariables>): Promise<CreateResult<TData>> {
|
|
78
|
-
const
|
|
154
|
+
const baseUrl = resolveResourceUrl(opts, resource);
|
|
155
|
+
const data = await request<TData>(baseUrl, resolveHeaders(opts), {
|
|
79
156
|
method: 'POST',
|
|
80
157
|
body: JSON.stringify(variables),
|
|
81
|
-
});
|
|
158
|
+
}, withCredentials);
|
|
82
159
|
return { data };
|
|
83
160
|
},
|
|
84
161
|
|
|
85
162
|
async update<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, id, variables }: UpdateParams<TVariables>): Promise<UpdateResult<TData>> {
|
|
86
|
-
const
|
|
87
|
-
|
|
163
|
+
const baseUrl = resolveResourceUrl(opts, resource);
|
|
164
|
+
const data = await request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), {
|
|
165
|
+
method: updateMethod,
|
|
88
166
|
body: JSON.stringify(variables),
|
|
89
|
-
});
|
|
167
|
+
}, withCredentials);
|
|
90
168
|
return { data };
|
|
91
169
|
},
|
|
92
170
|
|
|
93
171
|
async deleteOne<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, id }: DeleteParams<TVariables>): Promise<DeleteResult<TData>> {
|
|
94
|
-
const
|
|
172
|
+
const baseUrl = resolveResourceUrl(opts, resource);
|
|
173
|
+
const data = await request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), {
|
|
95
174
|
method: 'DELETE',
|
|
96
|
-
});
|
|
175
|
+
}, withCredentials);
|
|
97
176
|
return { data };
|
|
98
177
|
},
|
|
99
178
|
|
|
100
179
|
async getMany<TData extends BaseRecord = BaseRecord>({ resource, ids }: GetManyParams): Promise<GetManyResult<TData>> {
|
|
101
|
-
const
|
|
102
|
-
const
|
|
180
|
+
const baseUrl = resolveResourceUrl(opts, resource);
|
|
181
|
+
const params = ids.map(id => `id=${encodeURIComponent(String(id))}`).join('&');
|
|
182
|
+
const data = await request<TData[]>(`${baseUrl}?${params}`, resolveHeaders(opts), undefined, withCredentials);
|
|
103
183
|
return { data };
|
|
104
184
|
},
|
|
105
185
|
|
|
106
186
|
async createMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, variables }: CreateManyParams<TVariables>): Promise<CreateManyResult<TData>> {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
187
|
+
const baseUrl = resolveResourceUrl(opts, resource);
|
|
188
|
+
const results = await Promise.all(
|
|
189
|
+
variables.map(vars =>
|
|
190
|
+
request<TData>(baseUrl, resolveHeaders(opts), {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
body: JSON.stringify(vars),
|
|
193
|
+
}, withCredentials)
|
|
194
|
+
)
|
|
195
|
+
);
|
|
115
196
|
return { data: results };
|
|
116
197
|
},
|
|
117
198
|
|
|
118
199
|
async updateMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, ids, variables }: UpdateManyParams<TVariables>): Promise<UpdateManyResult<TData>> {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
200
|
+
const baseUrl = resolveResourceUrl(opts, resource);
|
|
201
|
+
const results = await Promise.all(
|
|
202
|
+
ids.map(id =>
|
|
203
|
+
request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), {
|
|
204
|
+
method: updateMethod,
|
|
205
|
+
body: JSON.stringify(variables),
|
|
206
|
+
}, withCredentials)
|
|
207
|
+
)
|
|
208
|
+
);
|
|
127
209
|
return { data: results };
|
|
128
210
|
},
|
|
129
211
|
|
|
130
212
|
async deleteMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, ids }: DeleteManyParams<TVariables>): Promise<DeleteManyResult<TData>> {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
213
|
+
const baseUrl = resolveResourceUrl(opts, resource);
|
|
214
|
+
const results = await Promise.all(
|
|
215
|
+
ids.map(id =>
|
|
216
|
+
request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), {
|
|
217
|
+
method: 'DELETE',
|
|
218
|
+
}, withCredentials)
|
|
219
|
+
)
|
|
220
|
+
);
|
|
138
221
|
return { data: results };
|
|
139
222
|
},
|
|
140
223
|
|
|
@@ -142,7 +225,7 @@ export function createElysiaDataProvider(opts: ElysiaDataProviderOptions): DataP
|
|
|
142
225
|
const data = await request<TData>(url, { ...resolveHeaders(opts), ...headers }, {
|
|
143
226
|
method: method.toUpperCase(),
|
|
144
227
|
body: payload ? JSON.stringify(payload) : undefined,
|
|
145
|
-
});
|
|
228
|
+
}, withCredentials);
|
|
146
229
|
return { data };
|
|
147
230
|
},
|
|
148
231
|
};
|