@objectstack/rest 4.0.3 → 4.0.5
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/README.md +94 -17
- package/dist/index.cjs +722 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +133 -2
- package/dist/index.d.ts +133 -2
- package/dist/index.js +712 -60
- package/dist/index.js.map +1 -1
- package/package.json +32 -6
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -473
- package/src/index.ts +0 -12
- package/src/rest-api-plugin.ts +0 -72
- package/src/rest-server.ts +0 -691
- package/src/rest.test.ts +0 -672
- package/src/route-manager.ts +0 -308
- package/tsconfig.json +0 -9
- package/vitest.config.ts +0 -10
package/src/rest.test.ts
DELETED
|
@@ -1,672 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
4
|
-
import { RouteManager, RouteGroupBuilder } from './route-manager';
|
|
5
|
-
import { RestServer } from './rest-server';
|
|
6
|
-
import { createRestApiPlugin } from './rest-api-plugin';
|
|
7
|
-
import type { RestApiPluginConfig } from './rest-api-plugin';
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Mocks & Helpers
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
/** Minimal IHttpServer mock */
|
|
14
|
-
function createMockServer() {
|
|
15
|
-
return {
|
|
16
|
-
get: vi.fn(),
|
|
17
|
-
post: vi.fn(),
|
|
18
|
-
put: vi.fn(),
|
|
19
|
-
delete: vi.fn(),
|
|
20
|
-
patch: vi.fn(),
|
|
21
|
-
use: vi.fn(),
|
|
22
|
-
listen: vi.fn().mockResolvedValue(undefined),
|
|
23
|
-
close: vi.fn().mockResolvedValue(undefined),
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Minimal ObjectStackProtocol mock */
|
|
28
|
-
function createMockProtocol() {
|
|
29
|
-
return {
|
|
30
|
-
getDiscovery: vi.fn().mockResolvedValue({
|
|
31
|
-
version: 'v0',
|
|
32
|
-
endpoints: { data: '', metadata: '', ui: '', auth: '/auth' },
|
|
33
|
-
}),
|
|
34
|
-
getMetaTypes: vi.fn().mockResolvedValue([]),
|
|
35
|
-
getMetaItems: vi.fn().mockResolvedValue([]),
|
|
36
|
-
getMetaItem: vi.fn().mockResolvedValue({}),
|
|
37
|
-
getMetaItemCached: undefined as any,
|
|
38
|
-
saveMetaItem: undefined as any,
|
|
39
|
-
getUiView: undefined as any,
|
|
40
|
-
findData: vi.fn().mockResolvedValue([]),
|
|
41
|
-
getData: vi.fn().mockResolvedValue({}),
|
|
42
|
-
createData: vi.fn().mockResolvedValue({ id: '1' }),
|
|
43
|
-
updateData: vi.fn().mockResolvedValue({}),
|
|
44
|
-
deleteData: vi.fn().mockResolvedValue({ success: true }),
|
|
45
|
-
batchData: undefined as any,
|
|
46
|
-
createManyData: undefined as any,
|
|
47
|
-
updateManyData: undefined as any,
|
|
48
|
-
deleteManyData: undefined as any,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Minimal PluginContext mock */
|
|
53
|
-
function createMockPluginContext(services: Record<string, any> = {}) {
|
|
54
|
-
return {
|
|
55
|
-
registerService: vi.fn(),
|
|
56
|
-
getService: vi.fn((name: string) => {
|
|
57
|
-
if (services[name]) return services[name];
|
|
58
|
-
throw new Error(`Service '${name}' not found`);
|
|
59
|
-
}),
|
|
60
|
-
getServices: vi.fn(() => new Map(Object.entries(services))),
|
|
61
|
-
hook: vi.fn(),
|
|
62
|
-
trigger: vi.fn().mockResolvedValue(undefined),
|
|
63
|
-
logger: {
|
|
64
|
-
debug: vi.fn(),
|
|
65
|
-
info: vi.fn(),
|
|
66
|
-
warn: vi.fn(),
|
|
67
|
-
error: vi.fn(),
|
|
68
|
-
},
|
|
69
|
-
getKernel: vi.fn(),
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Dummy handler */
|
|
74
|
-
const noop = vi.fn();
|
|
75
|
-
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
// RouteManager
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
|
|
80
|
-
describe('RouteManager', () => {
|
|
81
|
-
let server: ReturnType<typeof createMockServer>;
|
|
82
|
-
let manager: RouteManager;
|
|
83
|
-
|
|
84
|
-
beforeEach(() => {
|
|
85
|
-
server = createMockServer();
|
|
86
|
-
manager = new RouteManager(server as any);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
// -- Registration --------------------------------------------------------
|
|
90
|
-
|
|
91
|
-
describe('register', () => {
|
|
92
|
-
it('should register a GET route and delegate to server.get', () => {
|
|
93
|
-
manager.register({ method: 'GET', path: '/users', handler: noop });
|
|
94
|
-
expect(server.get).toHaveBeenCalledWith('/users', noop);
|
|
95
|
-
expect(manager.count()).toBe(1);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('should register POST, PUT, PATCH, DELETE routes', () => {
|
|
99
|
-
manager.register({ method: 'POST', path: '/a', handler: noop });
|
|
100
|
-
manager.register({ method: 'PUT', path: '/b', handler: noop });
|
|
101
|
-
manager.register({ method: 'PATCH', path: '/c', handler: noop });
|
|
102
|
-
manager.register({ method: 'DELETE', path: '/d', handler: noop });
|
|
103
|
-
|
|
104
|
-
expect(server.post).toHaveBeenCalledWith('/a', noop);
|
|
105
|
-
expect(server.put).toHaveBeenCalledWith('/b', noop);
|
|
106
|
-
expect(server.patch).toHaveBeenCalledWith('/c', noop);
|
|
107
|
-
expect(server.delete).toHaveBeenCalledWith('/d', noop);
|
|
108
|
-
expect(manager.count()).toBe(4);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('should store metadata on the route entry', () => {
|
|
112
|
-
manager.register({
|
|
113
|
-
method: 'GET',
|
|
114
|
-
path: '/items',
|
|
115
|
-
handler: noop,
|
|
116
|
-
metadata: { summary: 'List items', tags: ['items'] },
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
const entry = manager.get('GET', '/items');
|
|
120
|
-
expect(entry).toBeDefined();
|
|
121
|
-
expect(entry!.metadata?.summary).toBe('List items');
|
|
122
|
-
expect(entry!.metadata?.tags).toContain('items');
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('should throw when a string handler is provided', () => {
|
|
126
|
-
expect(() =>
|
|
127
|
-
manager.register({ method: 'GET', path: '/x', handler: 'someHandler' }),
|
|
128
|
-
).toThrow(/String-based route handlers/);
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// -- registerMany --------------------------------------------------------
|
|
133
|
-
|
|
134
|
-
describe('registerMany', () => {
|
|
135
|
-
it('should register multiple routes at once', () => {
|
|
136
|
-
manager.registerMany([
|
|
137
|
-
{ method: 'GET', path: '/a', handler: noop },
|
|
138
|
-
{ method: 'POST', path: '/b', handler: noop },
|
|
139
|
-
]);
|
|
140
|
-
expect(manager.count()).toBe(2);
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
// -- Lookup / query -------------------------------------------------------
|
|
145
|
-
|
|
146
|
-
describe('get', () => {
|
|
147
|
-
it('should return undefined for unregistered route', () => {
|
|
148
|
-
expect(manager.get('GET', '/nothing')).toBeUndefined();
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('should return the entry for a registered route', () => {
|
|
152
|
-
manager.register({ method: 'GET', path: '/users', handler: noop });
|
|
153
|
-
const entry = manager.get('GET', '/users');
|
|
154
|
-
expect(entry).toBeDefined();
|
|
155
|
-
expect(entry!.path).toBe('/users');
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe('getAll', () => {
|
|
160
|
-
it('should return all registered routes', () => {
|
|
161
|
-
manager.register({ method: 'GET', path: '/a', handler: noop });
|
|
162
|
-
manager.register({ method: 'POST', path: '/b', handler: noop });
|
|
163
|
-
expect(manager.getAll()).toHaveLength(2);
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
describe('getByMethod', () => {
|
|
168
|
-
it('should filter routes by HTTP method', () => {
|
|
169
|
-
manager.register({ method: 'GET', path: '/a', handler: noop });
|
|
170
|
-
manager.register({ method: 'GET', path: '/b', handler: noop });
|
|
171
|
-
manager.register({ method: 'POST', path: '/c', handler: noop });
|
|
172
|
-
|
|
173
|
-
expect(manager.getByMethod('GET')).toHaveLength(2);
|
|
174
|
-
expect(manager.getByMethod('POST')).toHaveLength(1);
|
|
175
|
-
expect(manager.getByMethod('DELETE')).toHaveLength(0);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
describe('getByPrefix', () => {
|
|
180
|
-
it('should filter routes by path prefix', () => {
|
|
181
|
-
manager.register({ method: 'GET', path: '/api/users', handler: noop });
|
|
182
|
-
manager.register({ method: 'GET', path: '/api/items', handler: noop });
|
|
183
|
-
manager.register({ method: 'GET', path: '/other', handler: noop });
|
|
184
|
-
|
|
185
|
-
expect(manager.getByPrefix('/api')).toHaveLength(2);
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
describe('getByTag', () => {
|
|
190
|
-
it('should filter routes by metadata tag', () => {
|
|
191
|
-
manager.register({
|
|
192
|
-
method: 'GET', path: '/a', handler: noop,
|
|
193
|
-
metadata: { tags: ['users'] },
|
|
194
|
-
});
|
|
195
|
-
manager.register({
|
|
196
|
-
method: 'GET', path: '/b', handler: noop,
|
|
197
|
-
metadata: { tags: ['items'] },
|
|
198
|
-
});
|
|
199
|
-
manager.register({ method: 'GET', path: '/c', handler: noop });
|
|
200
|
-
|
|
201
|
-
expect(manager.getByTag('users')).toHaveLength(1);
|
|
202
|
-
expect(manager.getByTag('missing')).toHaveLength(0);
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
// -- Unregister -----------------------------------------------------------
|
|
207
|
-
|
|
208
|
-
describe('unregister', () => {
|
|
209
|
-
it('should remove a route from the registry', () => {
|
|
210
|
-
manager.register({ method: 'GET', path: '/x', handler: noop });
|
|
211
|
-
expect(manager.count()).toBe(1);
|
|
212
|
-
|
|
213
|
-
manager.unregister('GET', '/x');
|
|
214
|
-
expect(manager.count()).toBe(0);
|
|
215
|
-
expect(manager.get('GET', '/x')).toBeUndefined();
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// -- Clear ----------------------------------------------------------------
|
|
220
|
-
|
|
221
|
-
describe('clear', () => {
|
|
222
|
-
it('should remove all routes', () => {
|
|
223
|
-
manager.registerMany([
|
|
224
|
-
{ method: 'GET', path: '/a', handler: noop },
|
|
225
|
-
{ method: 'POST', path: '/b', handler: noop },
|
|
226
|
-
]);
|
|
227
|
-
manager.clear();
|
|
228
|
-
expect(manager.count()).toBe(0);
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
// -- Group ----------------------------------------------------------------
|
|
233
|
-
|
|
234
|
-
describe('group', () => {
|
|
235
|
-
it('should create routes with the prefix prepended', () => {
|
|
236
|
-
manager.group('/api/v1', (group) => {
|
|
237
|
-
group.get('/users', noop);
|
|
238
|
-
group.post('/users', noop);
|
|
239
|
-
group.put('/users/:id', noop);
|
|
240
|
-
group.patch('/users/:id', noop);
|
|
241
|
-
group.delete('/users/:id', noop);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
expect(manager.count()).toBe(5);
|
|
245
|
-
expect(manager.get('GET', '/api/v1/users')).toBeDefined();
|
|
246
|
-
expect(manager.get('POST', '/api/v1/users')).toBeDefined();
|
|
247
|
-
expect(manager.get('PUT', '/api/v1/users/:id')).toBeDefined();
|
|
248
|
-
expect(manager.get('PATCH', '/api/v1/users/:id')).toBeDefined();
|
|
249
|
-
expect(manager.get('DELETE', '/api/v1/users/:id')).toBeDefined();
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it('should normalize paths (strip trailing slash on prefix, ensure leading slash on path)', () => {
|
|
253
|
-
manager.group('/api/', (group) => {
|
|
254
|
-
group.get('items', noop);
|
|
255
|
-
});
|
|
256
|
-
expect(manager.get('GET', '/api/items')).toBeDefined();
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('should allow chaining on group builder methods', () => {
|
|
260
|
-
manager.group('/api', (group) => {
|
|
261
|
-
const result = group
|
|
262
|
-
.get('/a', noop)
|
|
263
|
-
.post('/b', noop);
|
|
264
|
-
expect(result).toBe(group);
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
// ---------------------------------------------------------------------------
|
|
271
|
-
// RestServer
|
|
272
|
-
// ---------------------------------------------------------------------------
|
|
273
|
-
|
|
274
|
-
describe('RestServer', () => {
|
|
275
|
-
let server: ReturnType<typeof createMockServer>;
|
|
276
|
-
let protocol: ReturnType<typeof createMockProtocol>;
|
|
277
|
-
|
|
278
|
-
beforeEach(() => {
|
|
279
|
-
server = createMockServer();
|
|
280
|
-
protocol = createMockProtocol();
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
// -- Constructor & defaults -----------------------------------------------
|
|
284
|
-
|
|
285
|
-
describe('constructor', () => {
|
|
286
|
-
it('should create a RestServer with default config', () => {
|
|
287
|
-
const rest = new RestServer(server as any, protocol as any);
|
|
288
|
-
expect(rest).toBeDefined();
|
|
289
|
-
expect(rest.getRouteManager()).toBeInstanceOf(RouteManager);
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it('should accept custom config', () => {
|
|
293
|
-
const rest = new RestServer(server as any, protocol as any, {
|
|
294
|
-
api: { version: 'v2', basePath: '/custom' },
|
|
295
|
-
} as any);
|
|
296
|
-
expect(rest).toBeDefined();
|
|
297
|
-
});
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
// -- registerRoutes -------------------------------------------------------
|
|
301
|
-
|
|
302
|
-
describe('registerRoutes', () => {
|
|
303
|
-
it('should register discovery, metadata, UI, CRUD, and batch routes by default', () => {
|
|
304
|
-
const rest = new RestServer(server as any, protocol as any);
|
|
305
|
-
rest.registerRoutes();
|
|
306
|
-
|
|
307
|
-
const routes = rest.getRoutes();
|
|
308
|
-
expect(routes.length).toBeGreaterThan(0);
|
|
309
|
-
|
|
310
|
-
// Expect at least discovery + metadata + CRUD routes
|
|
311
|
-
const paths = routes.map((r) => r.path);
|
|
312
|
-
// Discovery (both basePath and basePath/discovery)
|
|
313
|
-
expect(paths).toContain('/api/v1');
|
|
314
|
-
expect(paths).toContain('/api/v1/discovery');
|
|
315
|
-
// Metadata
|
|
316
|
-
expect(paths.some((p) => p.includes('/meta'))).toBe(true);
|
|
317
|
-
// CRUD
|
|
318
|
-
expect(paths.some((p) => p.includes('/data'))).toBe(true);
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
it('should use custom apiPath when specified', () => {
|
|
322
|
-
const rest = new RestServer(server as any, protocol as any, {
|
|
323
|
-
api: { apiPath: '/custom/path' },
|
|
324
|
-
} as any);
|
|
325
|
-
rest.registerRoutes();
|
|
326
|
-
|
|
327
|
-
const paths = rest.getRoutes().map((r) => r.path);
|
|
328
|
-
expect(paths.some((p) => p.startsWith('/custom/path'))).toBe(true);
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it('should skip CRUD routes when enableCrud is false', () => {
|
|
332
|
-
const rest = new RestServer(server as any, protocol as any, {
|
|
333
|
-
api: { enableCrud: false },
|
|
334
|
-
} as any);
|
|
335
|
-
rest.registerRoutes();
|
|
336
|
-
|
|
337
|
-
const tags = rest.getRoutes().flatMap((r) => r.metadata?.tags ?? []);
|
|
338
|
-
expect(tags).not.toContain('crud');
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
it('should skip metadata routes when enableMetadata is false', () => {
|
|
342
|
-
const rest = new RestServer(server as any, protocol as any, {
|
|
343
|
-
api: { enableMetadata: false },
|
|
344
|
-
} as any);
|
|
345
|
-
rest.registerRoutes();
|
|
346
|
-
|
|
347
|
-
const routes = rest.getRoutes();
|
|
348
|
-
// Only the PUT /meta/:type/:name is always registered, but enableMetadata=false
|
|
349
|
-
// skips the entire registerMetadataEndpoints call
|
|
350
|
-
expect(routes.every((r) => !r.path.includes('/meta'))).toBe(true);
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
it('should skip discovery when enableDiscovery is false', () => {
|
|
354
|
-
const rest = new RestServer(server as any, protocol as any, {
|
|
355
|
-
api: { enableDiscovery: false },
|
|
356
|
-
} as any);
|
|
357
|
-
rest.registerRoutes();
|
|
358
|
-
|
|
359
|
-
const routes = rest.getRoutes();
|
|
360
|
-
// Neither basePath nor basePath/discovery should be registered
|
|
361
|
-
const discoveryRoutes = routes.filter((r) =>
|
|
362
|
-
r.metadata?.tags?.includes('discovery'),
|
|
363
|
-
);
|
|
364
|
-
expect(discoveryRoutes).toHaveLength(0);
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
it('should skip batch routes when enableBatch is false', () => {
|
|
368
|
-
const rest = new RestServer(server as any, protocol as any, {
|
|
369
|
-
api: { enableBatch: false },
|
|
370
|
-
} as any);
|
|
371
|
-
rest.registerRoutes();
|
|
372
|
-
|
|
373
|
-
const tags = rest.getRoutes().flatMap((r) => r.metadata?.tags ?? []);
|
|
374
|
-
expect(tags).not.toContain('batch');
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
it('should register batch endpoints when protocol implements batch methods', () => {
|
|
378
|
-
protocol.batchData = vi.fn().mockResolvedValue({});
|
|
379
|
-
protocol.createManyData = vi.fn().mockResolvedValue([]);
|
|
380
|
-
protocol.updateManyData = vi.fn().mockResolvedValue([]);
|
|
381
|
-
protocol.deleteManyData = vi.fn().mockResolvedValue([]);
|
|
382
|
-
|
|
383
|
-
const rest = new RestServer(server as any, protocol as any);
|
|
384
|
-
rest.registerRoutes();
|
|
385
|
-
|
|
386
|
-
const batchRoutes = rest.getRoutes().filter((r) =>
|
|
387
|
-
r.metadata?.tags?.includes('batch'),
|
|
388
|
-
);
|
|
389
|
-
expect(batchRoutes.length).toBeGreaterThan(0);
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
it('should register UI view endpoint when enableUi is true', () => {
|
|
393
|
-
const rest = new RestServer(server as any, protocol as any);
|
|
394
|
-
rest.registerRoutes();
|
|
395
|
-
|
|
396
|
-
const uiRoutes = rest.getRoutes().filter((r) =>
|
|
397
|
-
r.metadata?.tags?.includes('ui'),
|
|
398
|
-
);
|
|
399
|
-
expect(uiRoutes.length).toBeGreaterThan(0);
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
it('should not register i18n endpoints (i18n routes are self-registered by service-i18n)', () => {
|
|
403
|
-
const rest = new RestServer(server as any, protocol as any);
|
|
404
|
-
rest.registerRoutes();
|
|
405
|
-
|
|
406
|
-
const i18nRoutes = rest.getRoutes().filter((r) =>
|
|
407
|
-
r.metadata?.tags?.includes('i18n'),
|
|
408
|
-
);
|
|
409
|
-
expect(i18nRoutes).toHaveLength(0);
|
|
410
|
-
});
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
// -- getRouteManager / getRoutes ------------------------------------------
|
|
414
|
-
|
|
415
|
-
describe('getRouteManager', () => {
|
|
416
|
-
it('should return the internal RouteManager instance', () => {
|
|
417
|
-
const rest = new RestServer(server as any, protocol as any);
|
|
418
|
-
const rm = rest.getRouteManager();
|
|
419
|
-
expect(rm).toBeInstanceOf(RouteManager);
|
|
420
|
-
});
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
describe('getRoutes', () => {
|
|
424
|
-
it('should return an empty array before registerRoutes is called', () => {
|
|
425
|
-
const rest = new RestServer(server as any, protocol as any);
|
|
426
|
-
expect(rest.getRoutes()).toEqual([]);
|
|
427
|
-
});
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
describe('getData handler expand/select forwarding', () => {
|
|
431
|
-
function getRoute(rest: any, pathSuffix: string) {
|
|
432
|
-
const routes = rest.getRoutes();
|
|
433
|
-
return routes.find(
|
|
434
|
-
(r: any) => r.method === 'GET' && r.path === `/api/v1/data/${pathSuffix}`,
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
it('should pass expand and select query params to protocol.getData', async () => {
|
|
439
|
-
const rest = new RestServer(server as any, protocol as any);
|
|
440
|
-
rest.registerRoutes();
|
|
441
|
-
|
|
442
|
-
const getByIdRoute = getRoute(rest, ':object/:id');
|
|
443
|
-
expect(getByIdRoute).toBeDefined();
|
|
444
|
-
|
|
445
|
-
// Simulate request with expand and select query params
|
|
446
|
-
const mockReq = {
|
|
447
|
-
params: { object: 'order_item', id: 'oi_123' },
|
|
448
|
-
query: { expand: 'order,product', select: 'name,total' },
|
|
449
|
-
};
|
|
450
|
-
const mockRes = {
|
|
451
|
-
json: vi.fn(),
|
|
452
|
-
status: vi.fn().mockReturnThis(),
|
|
453
|
-
};
|
|
454
|
-
|
|
455
|
-
protocol.getData.mockResolvedValue({
|
|
456
|
-
object: 'order_item',
|
|
457
|
-
id: 'oi_123',
|
|
458
|
-
record: { id: 'oi_123', name: 'Item 1' },
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
await getByIdRoute!.handler(mockReq, mockRes);
|
|
462
|
-
|
|
463
|
-
expect(protocol.getData).toHaveBeenCalledWith(
|
|
464
|
-
expect.objectContaining({
|
|
465
|
-
object: 'order_item',
|
|
466
|
-
id: 'oi_123',
|
|
467
|
-
expand: 'order,product',
|
|
468
|
-
select: 'name,total',
|
|
469
|
-
}),
|
|
470
|
-
);
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
it('should omit expand/select when not present in query', async () => {
|
|
474
|
-
const rest = new RestServer(server as any, protocol as any);
|
|
475
|
-
rest.registerRoutes();
|
|
476
|
-
|
|
477
|
-
const getByIdRoute = getRoute(rest, ':object/:id');
|
|
478
|
-
|
|
479
|
-
const mockReq = {
|
|
480
|
-
params: { object: 'contact', id: 'c_1' },
|
|
481
|
-
query: {},
|
|
482
|
-
};
|
|
483
|
-
const mockRes = {
|
|
484
|
-
json: vi.fn(),
|
|
485
|
-
status: vi.fn().mockReturnThis(),
|
|
486
|
-
};
|
|
487
|
-
|
|
488
|
-
protocol.getData.mockResolvedValue({
|
|
489
|
-
object: 'contact',
|
|
490
|
-
id: 'c_1',
|
|
491
|
-
record: { id: 'c_1' },
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
await getByIdRoute!.handler(mockReq, mockRes);
|
|
495
|
-
|
|
496
|
-
// Should NOT have expand or select keys in the call
|
|
497
|
-
const callArg = protocol.getData.mock.calls[protocol.getData.mock.calls.length - 1][0];
|
|
498
|
-
expect(callArg).toEqual({ object: 'contact', id: 'c_1' });
|
|
499
|
-
});
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
describe('findData handler expand/populate forwarding', () => {
|
|
503
|
-
function getListRoute(rest: any) {
|
|
504
|
-
const routes = rest.getRoutes();
|
|
505
|
-
return routes.find(
|
|
506
|
-
(r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object',
|
|
507
|
-
);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
it('should pass query params including expand to protocol.findData', async () => {
|
|
511
|
-
const rest = new RestServer(server as any, protocol as any);
|
|
512
|
-
rest.registerRoutes();
|
|
513
|
-
|
|
514
|
-
const listRoute = getListRoute(rest);
|
|
515
|
-
expect(listRoute).toBeDefined();
|
|
516
|
-
|
|
517
|
-
const mockReq = {
|
|
518
|
-
params: { object: 'order_item' },
|
|
519
|
-
query: { expand: 'order,product', top: '10' },
|
|
520
|
-
};
|
|
521
|
-
const mockRes = {
|
|
522
|
-
json: vi.fn(),
|
|
523
|
-
status: vi.fn().mockReturnThis(),
|
|
524
|
-
};
|
|
525
|
-
|
|
526
|
-
protocol.findData.mockResolvedValue({
|
|
527
|
-
object: 'order_item',
|
|
528
|
-
records: [],
|
|
529
|
-
total: 0,
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
await listRoute!.handler(mockReq, mockRes);
|
|
533
|
-
|
|
534
|
-
expect(protocol.findData).toHaveBeenCalledWith({
|
|
535
|
-
object: 'order_item',
|
|
536
|
-
query: { expand: 'order,product', top: '10' },
|
|
537
|
-
});
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
it('should pass populate query param to protocol.findData', async () => {
|
|
541
|
-
const rest = new RestServer(server as any, protocol as any);
|
|
542
|
-
rest.registerRoutes();
|
|
543
|
-
|
|
544
|
-
const listRoute = getListRoute(rest);
|
|
545
|
-
|
|
546
|
-
const mockReq = {
|
|
547
|
-
params: { object: 'task' },
|
|
548
|
-
query: { populate: 'assignee,project' },
|
|
549
|
-
};
|
|
550
|
-
const mockRes = {
|
|
551
|
-
json: vi.fn(),
|
|
552
|
-
status: vi.fn().mockReturnThis(),
|
|
553
|
-
};
|
|
554
|
-
|
|
555
|
-
protocol.findData.mockResolvedValue({
|
|
556
|
-
object: 'task',
|
|
557
|
-
records: [],
|
|
558
|
-
total: 0,
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
await listRoute!.handler(mockReq, mockRes);
|
|
562
|
-
|
|
563
|
-
expect(protocol.findData).toHaveBeenCalledWith({
|
|
564
|
-
object: 'task',
|
|
565
|
-
query: { populate: 'assignee,project' },
|
|
566
|
-
});
|
|
567
|
-
});
|
|
568
|
-
});
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
// ---------------------------------------------------------------------------
|
|
572
|
-
// createRestApiPlugin
|
|
573
|
-
// ---------------------------------------------------------------------------
|
|
574
|
-
|
|
575
|
-
describe('createRestApiPlugin', () => {
|
|
576
|
-
it('should return a plugin object with name and version', () => {
|
|
577
|
-
const plugin = createRestApiPlugin();
|
|
578
|
-
expect(plugin.name).toBe('com.objectstack.rest.api');
|
|
579
|
-
expect(plugin.version).toBe('1.0.0');
|
|
580
|
-
expect(typeof plugin.init).toBe('function');
|
|
581
|
-
expect(typeof plugin.start).toBe('function');
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
it('should accept custom config', () => {
|
|
585
|
-
const cfg: RestApiPluginConfig = {
|
|
586
|
-
serverServiceName: 'my.server',
|
|
587
|
-
protocolServiceName: 'my.protocol',
|
|
588
|
-
};
|
|
589
|
-
const plugin = createRestApiPlugin(cfg);
|
|
590
|
-
expect(plugin.name).toBe('com.objectstack.rest.api');
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
describe('init', () => {
|
|
594
|
-
it('should resolve without error', async () => {
|
|
595
|
-
const plugin = createRestApiPlugin();
|
|
596
|
-
const ctx = createMockPluginContext();
|
|
597
|
-
await expect(plugin.init(ctx as any)).resolves.toBeUndefined();
|
|
598
|
-
});
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
describe('start', () => {
|
|
602
|
-
it('should warn and skip when http server is not found', async () => {
|
|
603
|
-
const plugin = createRestApiPlugin();
|
|
604
|
-
const ctx = createMockPluginContext(); // no services
|
|
605
|
-
await plugin.start!(ctx as any);
|
|
606
|
-
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
|
607
|
-
expect.stringContaining('HTTP Server'),
|
|
608
|
-
);
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
it('should warn and skip when protocol is not found', async () => {
|
|
612
|
-
const mockServer = createMockServer();
|
|
613
|
-
const ctx = createMockPluginContext({ 'http.server': mockServer });
|
|
614
|
-
const plugin = createRestApiPlugin();
|
|
615
|
-
await plugin.start!(ctx as any);
|
|
616
|
-
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
|
617
|
-
expect.stringContaining('Protocol'),
|
|
618
|
-
);
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
it('should register REST routes when both services are present', async () => {
|
|
622
|
-
const mockServer = createMockServer();
|
|
623
|
-
const mockProtocol = createMockProtocol();
|
|
624
|
-
const ctx = createMockPluginContext({
|
|
625
|
-
'http.server': mockServer,
|
|
626
|
-
protocol: mockProtocol,
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
const plugin = createRestApiPlugin();
|
|
630
|
-
await plugin.start!(ctx as any);
|
|
631
|
-
|
|
632
|
-
expect(ctx.logger.info).toHaveBeenCalledWith(
|
|
633
|
-
expect.stringContaining('REST API successfully registered'),
|
|
634
|
-
);
|
|
635
|
-
// CRUD routes should have been mounted
|
|
636
|
-
expect(mockServer.get).toHaveBeenCalled();
|
|
637
|
-
expect(mockServer.post).toHaveBeenCalled();
|
|
638
|
-
});
|
|
639
|
-
|
|
640
|
-
it('should use custom service names from config', async () => {
|
|
641
|
-
const mockServer = createMockServer();
|
|
642
|
-
const mockProtocol = createMockProtocol();
|
|
643
|
-
const ctx = createMockPluginContext({
|
|
644
|
-
'my.server': mockServer,
|
|
645
|
-
'my.protocol': mockProtocol,
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
const plugin = createRestApiPlugin({
|
|
649
|
-
serverServiceName: 'my.server',
|
|
650
|
-
protocolServiceName: 'my.protocol',
|
|
651
|
-
});
|
|
652
|
-
await plugin.start!(ctx as any);
|
|
653
|
-
|
|
654
|
-
expect(ctx.logger.info).toHaveBeenCalledWith(
|
|
655
|
-
expect.stringContaining('REST API successfully registered'),
|
|
656
|
-
);
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
it('should throw and log error when RestServer construction fails', async () => {
|
|
660
|
-
const badServer = {}; // missing methods → will throw
|
|
661
|
-
const mockProtocol = createMockProtocol();
|
|
662
|
-
const ctx = createMockPluginContext({
|
|
663
|
-
'http.server': badServer,
|
|
664
|
-
protocol: mockProtocol,
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
const plugin = createRestApiPlugin();
|
|
668
|
-
await expect(plugin.start!(ctx as any)).rejects.toThrow();
|
|
669
|
-
expect(ctx.logger.error).toHaveBeenCalled();
|
|
670
|
-
});
|
|
671
|
-
});
|
|
672
|
-
});
|