@objectql/server 1.7.2 → 1.8.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/CHANGELOG.md +21 -0
- package/LICENSE +1 -1
- package/README.md +93 -0
- package/dist/adapters/graphql.d.ts +17 -0
- package/dist/adapters/graphql.js +460 -0
- package/dist/adapters/graphql.js.map +1 -0
- package/dist/adapters/rest.d.ts +7 -5
- package/dist/adapters/rest.js +52 -15
- package/dist/adapters/rest.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/metadata.js +7 -7
- package/dist/metadata.js.map +1 -1
- package/dist/server.d.ts +4 -0
- package/dist/server.js +85 -10
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +15 -8
- package/package.json +6 -3
- package/src/adapters/graphql.ts +523 -0
- package/src/adapters/rest.ts +49 -15
- package/src/index.ts +2 -0
- package/src/metadata.ts +7 -7
- package/src/server.ts +100 -11
- package/src/types.ts +24 -8
- package/test/graphql.test.ts +439 -0
- package/test/integration-example.ts +247 -0
- package/test/node.test.ts +4 -2
- package/test/rest-advanced.test.ts +455 -0
- package/test/rest.test.ts +283 -7
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { ObjectQL } from '@objectql/core';
|
|
4
|
+
import { createRESTHandler } from '../src/adapters/rest';
|
|
5
|
+
import { Driver } from '@objectql/types';
|
|
6
|
+
|
|
7
|
+
// Simple Mock Driver
|
|
8
|
+
class MockDriver implements Driver {
|
|
9
|
+
private data: Record<string, any[]> = {};
|
|
10
|
+
private nextId = 1;
|
|
11
|
+
|
|
12
|
+
async init() {}
|
|
13
|
+
|
|
14
|
+
async find(objectName: string, query: any) {
|
|
15
|
+
let items = this.data[objectName] || [];
|
|
16
|
+
|
|
17
|
+
// Apply filters if provided
|
|
18
|
+
if (query?.filters && Array.isArray(query.filters)) {
|
|
19
|
+
for (const filter of query.filters) {
|
|
20
|
+
if (Array.isArray(filter) && filter.length === 3) {
|
|
21
|
+
const [field, operator, value] = filter;
|
|
22
|
+
items = items.filter(item => {
|
|
23
|
+
if (operator === '=') return item[field] === value;
|
|
24
|
+
if (operator === '!=') return item[field] !== value;
|
|
25
|
+
if (operator === '>') return item[field] > value;
|
|
26
|
+
if (operator === '>=') return item[field] >= value;
|
|
27
|
+
if (operator === '<') return item[field] < value;
|
|
28
|
+
if (operator === '<=') return item[field] <= value;
|
|
29
|
+
if (operator === 'in') return Array.isArray(value) && value.includes(item[field]);
|
|
30
|
+
return true;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Apply skip and limit
|
|
37
|
+
if (query?.skip) {
|
|
38
|
+
items = items.slice(query.skip);
|
|
39
|
+
}
|
|
40
|
+
if (query?.limit) {
|
|
41
|
+
items = items.slice(0, query.limit);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return items;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async findOne(objectName: string, id: string | number) {
|
|
48
|
+
const items = this.data[objectName] || [];
|
|
49
|
+
return items.find(item => item._id === String(id)) || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async create(objectName: string, data: any) {
|
|
53
|
+
const newItem = { _id: String(this.nextId++), ...data };
|
|
54
|
+
if (!this.data[objectName]) {
|
|
55
|
+
this.data[objectName] = [];
|
|
56
|
+
}
|
|
57
|
+
this.data[objectName].push(newItem);
|
|
58
|
+
return newItem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async update(objectName: string, id: string, data: any) {
|
|
62
|
+
const items = this.data[objectName] || [];
|
|
63
|
+
const index = items.findIndex(item => item._id === id);
|
|
64
|
+
if (index >= 0) {
|
|
65
|
+
this.data[objectName][index] = { ...items[index], ...data };
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async delete(objectName: string, id: string) {
|
|
72
|
+
const items = this.data[objectName] || [];
|
|
73
|
+
const index = items.findIndex(item => item._id === id);
|
|
74
|
+
if (index >= 0) {
|
|
75
|
+
this.data[objectName].splice(index, 1);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async count(objectName: string, query: any) {
|
|
82
|
+
const items = await this.find(objectName, query);
|
|
83
|
+
return items.length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async execute(sql: string) {}
|
|
87
|
+
|
|
88
|
+
// Helper to reset data
|
|
89
|
+
resetData() {
|
|
90
|
+
this.data = {};
|
|
91
|
+
this.nextId = 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Helper to seed data
|
|
95
|
+
seedData(objectName: string, items: any[]) {
|
|
96
|
+
this.data[objectName] = items.map(item => ({ _id: String(this.nextId++), ...item }));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('REST API Error Handling & Edge Cases', () => {
|
|
101
|
+
let app: ObjectQL;
|
|
102
|
+
let server: any;
|
|
103
|
+
let handler: any;
|
|
104
|
+
let driver: MockDriver;
|
|
105
|
+
|
|
106
|
+
beforeAll(async () => {
|
|
107
|
+
driver = new MockDriver();
|
|
108
|
+
app = new ObjectQL({
|
|
109
|
+
datasources: {
|
|
110
|
+
default: driver
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Register schemas
|
|
115
|
+
app.metadata.register('object', {
|
|
116
|
+
type: 'object',
|
|
117
|
+
id: 'task',
|
|
118
|
+
content: {
|
|
119
|
+
name: 'task',
|
|
120
|
+
fields: {
|
|
121
|
+
title: { type: 'text', required: true },
|
|
122
|
+
status: { type: 'select', options: ['todo', 'in_progress', 'done'] },
|
|
123
|
+
priority: { type: 'number' },
|
|
124
|
+
completed: { type: 'boolean' }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
handler = createRESTHandler(app);
|
|
130
|
+
server = createServer(handler);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
driver.resetData();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('Query Parameters', () => {
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
driver.seedData('task', [
|
|
140
|
+
{ title: 'Task 1', status: 'todo', priority: 1 },
|
|
141
|
+
{ title: 'Task 2', status: 'in_progress', priority: 2 },
|
|
142
|
+
{ title: 'Task 3', status: 'done', priority: 3 },
|
|
143
|
+
{ title: 'Task 4', status: 'todo', priority: 1 },
|
|
144
|
+
{ title: 'Task 5', status: 'done', priority: 2 }
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should handle basic query without filters', async () => {
|
|
149
|
+
const response = await request(server)
|
|
150
|
+
.get('/api/data/task')
|
|
151
|
+
.set('Accept', 'application/json');
|
|
152
|
+
|
|
153
|
+
expect(response.status).toBe(200);
|
|
154
|
+
expect(response.body.items).toBeDefined();
|
|
155
|
+
expect(Array.isArray(response.body.items)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should paginate results correctly', async () => {
|
|
159
|
+
const response = await request(server)
|
|
160
|
+
.get('/api/data/task')
|
|
161
|
+
.query({ limit: 2, skip: 0 })
|
|
162
|
+
.set('Accept', 'application/json');
|
|
163
|
+
|
|
164
|
+
expect(response.status).toBe(200);
|
|
165
|
+
expect(response.body.items).toHaveLength(2);
|
|
166
|
+
expect(response.body.meta.page).toBe(1);
|
|
167
|
+
expect(response.body.meta.size).toBe(2);
|
|
168
|
+
// has_next may or may not be present depending on implementation
|
|
169
|
+
if (response.body.meta.has_next !== undefined) {
|
|
170
|
+
expect(typeof response.body.meta.has_next).toBe('boolean');
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should handle last page pagination', async () => {
|
|
175
|
+
const response = await request(server)
|
|
176
|
+
.get('/api/data/task')
|
|
177
|
+
.query({ limit: 2, skip: 4 })
|
|
178
|
+
.set('Accept', 'application/json');
|
|
179
|
+
|
|
180
|
+
expect(response.status).toBe(200);
|
|
181
|
+
expect(response.body.items).toHaveLength(1);
|
|
182
|
+
expect(response.body.meta.page).toBe(3);
|
|
183
|
+
expect(response.body.meta.has_next).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should handle skip beyond total count', async () => {
|
|
187
|
+
const response = await request(server)
|
|
188
|
+
.get('/api/data/task')
|
|
189
|
+
.query({ limit: 10, skip: 100 })
|
|
190
|
+
.set('Accept', 'application/json');
|
|
191
|
+
|
|
192
|
+
expect(response.status).toBe(200);
|
|
193
|
+
expect(response.body.items).toHaveLength(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should handle zero limit', async () => {
|
|
197
|
+
const response = await request(server)
|
|
198
|
+
.get('/api/data/task')
|
|
199
|
+
.query({ limit: 0 })
|
|
200
|
+
.set('Accept', 'application/json');
|
|
201
|
+
|
|
202
|
+
expect(response.status).toBe(200);
|
|
203
|
+
// Zero limit may return all or none depending on implementation
|
|
204
|
+
expect(response.body.items).toBeDefined();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should handle negative skip as zero', async () => {
|
|
208
|
+
const response = await request(server)
|
|
209
|
+
.get('/api/data/task')
|
|
210
|
+
.query({ limit: 2, skip: -1 })
|
|
211
|
+
.set('Accept', 'application/json');
|
|
212
|
+
|
|
213
|
+
expect(response.status).toBe(200);
|
|
214
|
+
expect(response.body.items).toBeDefined();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('Error Responses', () => {
|
|
219
|
+
it('should return 404 or 500 for non-existent record', async () => {
|
|
220
|
+
const response = await request(server)
|
|
221
|
+
.get('/api/data/task/999')
|
|
222
|
+
.set('Accept', 'application/json');
|
|
223
|
+
|
|
224
|
+
// May return 404 or 500 depending on implementation
|
|
225
|
+
expect([404, 500]).toContain(response.status);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should return 404 when deleting non-existent record', async () => {
|
|
229
|
+
const response = await request(server)
|
|
230
|
+
.delete('/api/data/task/999')
|
|
231
|
+
.set('Accept', 'application/json');
|
|
232
|
+
|
|
233
|
+
expect(response.status).toBe(404);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should handle updating non-existent record', async () => {
|
|
237
|
+
const response = await request(server)
|
|
238
|
+
.put('/api/data/task/999')
|
|
239
|
+
.send({ title: 'Updated' })
|
|
240
|
+
.set('Accept', 'application/json');
|
|
241
|
+
|
|
242
|
+
// May succeed with 200 (creating) or fail with 404 depending on implementation
|
|
243
|
+
expect([200, 404, 500]).toContain(response.status);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should return 400 for missing required field in create', async () => {
|
|
247
|
+
const response = await request(server)
|
|
248
|
+
.post('/api/data/task')
|
|
249
|
+
.send({ status: 'todo' }) // Missing required 'title'
|
|
250
|
+
.set('Accept', 'application/json');
|
|
251
|
+
|
|
252
|
+
// Note: validation might be handled by ObjectQL core, this tests the flow
|
|
253
|
+
expect([400, 500]).toContain(response.status);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should return 400 for invalid method on collection endpoint', async () => {
|
|
257
|
+
const response = await request(server)
|
|
258
|
+
.patch('/api/data/task') // PATCH not supported on collection
|
|
259
|
+
.send({ title: 'Test' })
|
|
260
|
+
.set('Accept', 'application/json');
|
|
261
|
+
|
|
262
|
+
expect(response.status).toBe(400);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('Response Format', () => {
|
|
267
|
+
beforeEach(() => {
|
|
268
|
+
driver.seedData('task', [
|
|
269
|
+
{ title: 'Sample Task', status: 'todo', priority: 1 }
|
|
270
|
+
]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should include @type in single record response', async () => {
|
|
274
|
+
const response = await request(server)
|
|
275
|
+
.get('/api/data/task/1')
|
|
276
|
+
.set('Accept', 'application/json');
|
|
277
|
+
|
|
278
|
+
expect(response.status).toBe(200);
|
|
279
|
+
expect(response.body['@type']).toBe('task');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should include @type in created record response', async () => {
|
|
283
|
+
const response = await request(server)
|
|
284
|
+
.post('/api/data/task')
|
|
285
|
+
.send({ title: 'New Task', status: 'todo' })
|
|
286
|
+
.set('Accept', 'application/json');
|
|
287
|
+
|
|
288
|
+
expect(response.status).toBe(201);
|
|
289
|
+
expect(response.body['@type']).toBe('task');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('should return items array for list endpoint', async () => {
|
|
293
|
+
const response = await request(server)
|
|
294
|
+
.get('/api/data/task')
|
|
295
|
+
.set('Accept', 'application/json');
|
|
296
|
+
|
|
297
|
+
expect(response.status).toBe(200);
|
|
298
|
+
expect(Array.isArray(response.body.items)).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should include meta in list response', async () => {
|
|
302
|
+
const response = await request(server)
|
|
303
|
+
.get('/api/data/task')
|
|
304
|
+
.query({ limit: 10 })
|
|
305
|
+
.set('Accept', 'application/json');
|
|
306
|
+
|
|
307
|
+
expect(response.status).toBe(200);
|
|
308
|
+
expect(response.body.meta).toBeDefined();
|
|
309
|
+
expect(response.body.meta.total).toBeDefined();
|
|
310
|
+
expect(response.body.meta.page).toBeDefined();
|
|
311
|
+
expect(response.body.meta.size).toBeDefined();
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('CORS and Headers', () => {
|
|
316
|
+
it('should set correct content-type header', async () => {
|
|
317
|
+
const response = await request(server)
|
|
318
|
+
.get('/api/data/task')
|
|
319
|
+
.set('Accept', 'application/json');
|
|
320
|
+
|
|
321
|
+
expect(response.headers['content-type']).toMatch(/application\/json/);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should handle missing accept header', async () => {
|
|
325
|
+
const response = await request(server)
|
|
326
|
+
.get('/api/data/task');
|
|
327
|
+
|
|
328
|
+
expect(response.status).toBe(200);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('Edge Cases', () => {
|
|
333
|
+
it('should handle empty object name', async () => {
|
|
334
|
+
const response = await request(server)
|
|
335
|
+
.get('/api/data/')
|
|
336
|
+
.set('Accept', 'application/json');
|
|
337
|
+
|
|
338
|
+
expect([400, 404]).toContain(response.status);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should handle special characters in ID', async () => {
|
|
342
|
+
const response = await request(server)
|
|
343
|
+
.get('/api/data/task/abc-123-xyz')
|
|
344
|
+
.set('Accept', 'application/json');
|
|
345
|
+
|
|
346
|
+
expect([404, 500]).toContain(response.status);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('should handle empty request body for create', async () => {
|
|
350
|
+
const response = await request(server)
|
|
351
|
+
.post('/api/data/task')
|
|
352
|
+
.send({})
|
|
353
|
+
.set('Accept', 'application/json');
|
|
354
|
+
|
|
355
|
+
expect([400, 500]).toContain(response.status);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should handle null in request body', async () => {
|
|
359
|
+
const response = await request(server)
|
|
360
|
+
.post('/api/data/task')
|
|
361
|
+
.send('null') // Send as string instead of null
|
|
362
|
+
.set('Content-Type', 'application/json');
|
|
363
|
+
|
|
364
|
+
expect([400, 500]).toContain(response.status);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('should handle malformed JSON gracefully', async () => {
|
|
368
|
+
const response = await request(server)
|
|
369
|
+
.post('/api/data/task')
|
|
370
|
+
.set('Content-Type', 'application/json')
|
|
371
|
+
.send('{ invalid json }');
|
|
372
|
+
|
|
373
|
+
expect([400, 500]).toContain(response.status);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe('Bulk Operations', () => {
|
|
378
|
+
it('should handle creating multiple records sequentially', async () => {
|
|
379
|
+
const tasks = [
|
|
380
|
+
{ title: 'Task 1', status: 'todo' },
|
|
381
|
+
{ title: 'Task 2', status: 'in_progress' },
|
|
382
|
+
{ title: 'Task 3', status: 'done' }
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
for (const task of tasks) {
|
|
386
|
+
const response = await request(server)
|
|
387
|
+
.post('/api/data/task')
|
|
388
|
+
.send(task)
|
|
389
|
+
.set('Accept', 'application/json');
|
|
390
|
+
|
|
391
|
+
expect(response.status).toBe(201);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const listResponse = await request(server)
|
|
395
|
+
.get('/api/data/task')
|
|
396
|
+
.set('Accept', 'application/json');
|
|
397
|
+
|
|
398
|
+
expect(listResponse.body.items).toHaveLength(3);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('should handle updating same record multiple times', async () => {
|
|
402
|
+
driver.seedData('task', [{ title: 'Original', status: 'todo' }]);
|
|
403
|
+
|
|
404
|
+
await request(server)
|
|
405
|
+
.put('/api/data/task/1')
|
|
406
|
+
.send({ title: 'Update 1' })
|
|
407
|
+
.set('Accept', 'application/json');
|
|
408
|
+
|
|
409
|
+
const response = await request(server)
|
|
410
|
+
.put('/api/data/task/1')
|
|
411
|
+
.send({ title: 'Update 2', status: 'done' })
|
|
412
|
+
.set('Accept', 'application/json');
|
|
413
|
+
|
|
414
|
+
expect(response.status).toBe(200);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
describe('Count Endpoint', () => {
|
|
419
|
+
beforeEach(() => {
|
|
420
|
+
driver.seedData('task', [
|
|
421
|
+
{ title: 'Task 1', status: 'todo' },
|
|
422
|
+
{ title: 'Task 2', status: 'todo' },
|
|
423
|
+
{ title: 'Task 3', status: 'done' }
|
|
424
|
+
]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should return meta with total count', async () => {
|
|
428
|
+
const response = await request(server)
|
|
429
|
+
.get('/api/data/task')
|
|
430
|
+
.set('Accept', 'application/json');
|
|
431
|
+
|
|
432
|
+
expect(response.status).toBe(200);
|
|
433
|
+
if (response.body.meta) {
|
|
434
|
+
expect(response.body.meta.total).toBeDefined();
|
|
435
|
+
expect(typeof response.body.meta.total).toBe('number');
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should count filtered records', async () => {
|
|
440
|
+
const response = await request(server)
|
|
441
|
+
.get('/api/data/task')
|
|
442
|
+
.query({
|
|
443
|
+
'filters[][0]': 'status',
|
|
444
|
+
'filters[][1]': '=',
|
|
445
|
+
'filters[][2]': 'todo'
|
|
446
|
+
})
|
|
447
|
+
.set('Accept', 'application/json');
|
|
448
|
+
|
|
449
|
+
expect(response.status).toBe(200);
|
|
450
|
+
if (response.body.meta) {
|
|
451
|
+
expect(response.body.meta.total).toBeDefined();
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
});
|