@objectstack/rest 2.0.0 → 2.0.2
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/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +17 -0
- package/package.json +4 -4
- package/src/rest.test.ts +531 -0
- package/vitest.config.ts +10 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @objectstack/rest@2.0.
|
|
2
|
+
> @objectstack/rest@2.0.2 build /home/runner/work/spec/spec/packages/rest
|
|
3
3
|
> tsup --config ../../tsup.config.ts
|
|
4
4
|
|
|
5
5
|
[34mCLI[39m Building entry: src/index.ts
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
[34mCLI[39m Cleaning output folder
|
|
11
11
|
[34mESM[39m Build start
|
|
12
12
|
[34mCJS[39m Build start
|
|
13
|
-
[32mESM[39m [1mdist/index.js [22m[32m20.70 KB[39m
|
|
14
|
-
[32mESM[39m [1mdist/index.js.map [22m[32m50.10 KB[39m
|
|
15
|
-
[32mESM[39m ⚡️ Build success in 50ms
|
|
16
13
|
[32mCJS[39m [1mdist/index.cjs [22m[32m21.89 KB[39m
|
|
17
14
|
[32mCJS[39m [1mdist/index.cjs.map [22m[32m50.79 KB[39m
|
|
18
|
-
[32mCJS[39m ⚡️ Build success in
|
|
15
|
+
[32mCJS[39m ⚡️ Build success in 67ms
|
|
16
|
+
[32mESM[39m [1mdist/index.js [22m[32m20.70 KB[39m
|
|
17
|
+
[32mESM[39m [1mdist/index.js.map [22m[32m50.10 KB[39m
|
|
18
|
+
[32mESM[39m ⚡️ Build success in 68ms
|
|
19
19
|
[34mDTS[39m Build start
|
|
20
|
-
[32mDTS[39m ⚡️ Build success in
|
|
20
|
+
[32mDTS[39m ⚡️ Build success in 7125ms
|
|
21
21
|
[32mDTS[39m [1mdist/index.d.ts [22m[32m6.70 KB[39m
|
|
22
22
|
[32mDTS[39m [1mdist/index.d.cts [22m[32m6.70 KB[39m
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @objectstack/rest
|
|
2
2
|
|
|
3
|
+
## 2.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [1db8559]
|
|
8
|
+
- @objectstack/spec@2.0.2
|
|
9
|
+
- @objectstack/core@2.0.2
|
|
10
|
+
|
|
11
|
+
## 2.0.1
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Patch release for maintenance and stability improvements
|
|
16
|
+
- Updated dependencies
|
|
17
|
+
- @objectstack/spec@2.0.1
|
|
18
|
+
- @objectstack/core@2.0.1
|
|
19
|
+
|
|
3
20
|
## 2.0.0
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/rest",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "ObjectStack REST API Server - automatic REST endpoint generation from protocol",
|
|
6
6
|
"type": "module",
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"zod": "^3.
|
|
18
|
-
"@objectstack/core": "2.0.
|
|
19
|
-
"@objectstack/spec": "2.0.
|
|
17
|
+
"zod": "^4.3.6",
|
|
18
|
+
"@objectstack/core": "2.0.2",
|
|
19
|
+
"@objectstack/spec": "2.0.2"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"typescript": "^5.0.0",
|
package/src/rest.test.ts
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
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, createApiRegistryPlugin } 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
|
|
313
|
+
expect(paths).toContain('/api/v1');
|
|
314
|
+
// Metadata
|
|
315
|
+
expect(paths.some((p) => p.includes('/meta'))).toBe(true);
|
|
316
|
+
// CRUD
|
|
317
|
+
expect(paths.some((p) => p.includes('/data'))).toBe(true);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should use custom apiPath when specified', () => {
|
|
321
|
+
const rest = new RestServer(server as any, protocol as any, {
|
|
322
|
+
api: { apiPath: '/custom/path' },
|
|
323
|
+
} as any);
|
|
324
|
+
rest.registerRoutes();
|
|
325
|
+
|
|
326
|
+
const paths = rest.getRoutes().map((r) => r.path);
|
|
327
|
+
expect(paths.some((p) => p.startsWith('/custom/path'))).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('should skip CRUD routes when enableCrud is false', () => {
|
|
331
|
+
const rest = new RestServer(server as any, protocol as any, {
|
|
332
|
+
api: { enableCrud: false },
|
|
333
|
+
} as any);
|
|
334
|
+
rest.registerRoutes();
|
|
335
|
+
|
|
336
|
+
const tags = rest.getRoutes().flatMap((r) => r.metadata?.tags ?? []);
|
|
337
|
+
expect(tags).not.toContain('crud');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should skip metadata routes when enableMetadata is false', () => {
|
|
341
|
+
const rest = new RestServer(server as any, protocol as any, {
|
|
342
|
+
api: { enableMetadata: false },
|
|
343
|
+
} as any);
|
|
344
|
+
rest.registerRoutes();
|
|
345
|
+
|
|
346
|
+
const routes = rest.getRoutes();
|
|
347
|
+
// Only the PUT /meta/:type/:name is always registered, but enableMetadata=false
|
|
348
|
+
// skips the entire registerMetadataEndpoints call
|
|
349
|
+
expect(routes.every((r) => !r.path.includes('/meta'))).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should skip discovery when enableDiscovery is false', () => {
|
|
353
|
+
const rest = new RestServer(server as any, protocol as any, {
|
|
354
|
+
api: { enableDiscovery: false },
|
|
355
|
+
} as any);
|
|
356
|
+
rest.registerRoutes();
|
|
357
|
+
|
|
358
|
+
const routes = rest.getRoutes();
|
|
359
|
+
// Discovery route is the basePath itself (e.g. /api/v1)
|
|
360
|
+
const discoveryRoutes = routes.filter((r) =>
|
|
361
|
+
r.metadata?.tags?.includes('discovery'),
|
|
362
|
+
);
|
|
363
|
+
expect(discoveryRoutes).toHaveLength(0);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should skip batch routes when enableBatch is false', () => {
|
|
367
|
+
const rest = new RestServer(server as any, protocol as any, {
|
|
368
|
+
api: { enableBatch: false },
|
|
369
|
+
} as any);
|
|
370
|
+
rest.registerRoutes();
|
|
371
|
+
|
|
372
|
+
const tags = rest.getRoutes().flatMap((r) => r.metadata?.tags ?? []);
|
|
373
|
+
expect(tags).not.toContain('batch');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should register batch endpoints when protocol implements batch methods', () => {
|
|
377
|
+
protocol.batchData = vi.fn().mockResolvedValue({});
|
|
378
|
+
protocol.createManyData = vi.fn().mockResolvedValue([]);
|
|
379
|
+
protocol.updateManyData = vi.fn().mockResolvedValue([]);
|
|
380
|
+
protocol.deleteManyData = vi.fn().mockResolvedValue([]);
|
|
381
|
+
|
|
382
|
+
const rest = new RestServer(server as any, protocol as any);
|
|
383
|
+
rest.registerRoutes();
|
|
384
|
+
|
|
385
|
+
const batchRoutes = rest.getRoutes().filter((r) =>
|
|
386
|
+
r.metadata?.tags?.includes('batch'),
|
|
387
|
+
);
|
|
388
|
+
expect(batchRoutes.length).toBeGreaterThan(0);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('should register UI view endpoint when enableUi is true', () => {
|
|
392
|
+
const rest = new RestServer(server as any, protocol as any);
|
|
393
|
+
rest.registerRoutes();
|
|
394
|
+
|
|
395
|
+
const uiRoutes = rest.getRoutes().filter((r) =>
|
|
396
|
+
r.metadata?.tags?.includes('ui'),
|
|
397
|
+
);
|
|
398
|
+
expect(uiRoutes.length).toBeGreaterThan(0);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// -- getRouteManager / getRoutes ------------------------------------------
|
|
403
|
+
|
|
404
|
+
describe('getRouteManager', () => {
|
|
405
|
+
it('should return the internal RouteManager instance', () => {
|
|
406
|
+
const rest = new RestServer(server as any, protocol as any);
|
|
407
|
+
const rm = rest.getRouteManager();
|
|
408
|
+
expect(rm).toBeInstanceOf(RouteManager);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe('getRoutes', () => {
|
|
413
|
+
it('should return an empty array before registerRoutes is called', () => {
|
|
414
|
+
const rest = new RestServer(server as any, protocol as any);
|
|
415
|
+
expect(rest.getRoutes()).toEqual([]);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// createRestApiPlugin
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
describe('createRestApiPlugin', () => {
|
|
425
|
+
it('should return a plugin object with name and version', () => {
|
|
426
|
+
const plugin = createRestApiPlugin();
|
|
427
|
+
expect(plugin.name).toBe('com.objectstack.rest.api');
|
|
428
|
+
expect(plugin.version).toBe('1.0.0');
|
|
429
|
+
expect(typeof plugin.init).toBe('function');
|
|
430
|
+
expect(typeof plugin.start).toBe('function');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('should accept custom config', () => {
|
|
434
|
+
const cfg: RestApiPluginConfig = {
|
|
435
|
+
serverServiceName: 'my.server',
|
|
436
|
+
protocolServiceName: 'my.protocol',
|
|
437
|
+
};
|
|
438
|
+
const plugin = createRestApiPlugin(cfg);
|
|
439
|
+
expect(plugin.name).toBe('com.objectstack.rest.api');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe('init', () => {
|
|
443
|
+
it('should resolve without error', async () => {
|
|
444
|
+
const plugin = createRestApiPlugin();
|
|
445
|
+
const ctx = createMockPluginContext();
|
|
446
|
+
await expect(plugin.init(ctx as any)).resolves.toBeUndefined();
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe('start', () => {
|
|
451
|
+
it('should warn and skip when http server is not found', async () => {
|
|
452
|
+
const plugin = createRestApiPlugin();
|
|
453
|
+
const ctx = createMockPluginContext(); // no services
|
|
454
|
+
await plugin.start!(ctx as any);
|
|
455
|
+
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
|
456
|
+
expect.stringContaining('HTTP Server'),
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should warn and skip when protocol is not found', async () => {
|
|
461
|
+
const mockServer = createMockServer();
|
|
462
|
+
const ctx = createMockPluginContext({ 'http.server': mockServer });
|
|
463
|
+
const plugin = createRestApiPlugin();
|
|
464
|
+
await plugin.start!(ctx as any);
|
|
465
|
+
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
|
466
|
+
expect.stringContaining('Protocol'),
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('should register REST routes when both services are present', async () => {
|
|
471
|
+
const mockServer = createMockServer();
|
|
472
|
+
const mockProtocol = createMockProtocol();
|
|
473
|
+
const ctx = createMockPluginContext({
|
|
474
|
+
'http.server': mockServer,
|
|
475
|
+
protocol: mockProtocol,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const plugin = createRestApiPlugin();
|
|
479
|
+
await plugin.start!(ctx as any);
|
|
480
|
+
|
|
481
|
+
expect(ctx.logger.info).toHaveBeenCalledWith(
|
|
482
|
+
expect.stringContaining('REST API successfully registered'),
|
|
483
|
+
);
|
|
484
|
+
// CRUD routes should have been mounted
|
|
485
|
+
expect(mockServer.get).toHaveBeenCalled();
|
|
486
|
+
expect(mockServer.post).toHaveBeenCalled();
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('should use custom service names from config', async () => {
|
|
490
|
+
const mockServer = createMockServer();
|
|
491
|
+
const mockProtocol = createMockProtocol();
|
|
492
|
+
const ctx = createMockPluginContext({
|
|
493
|
+
'my.server': mockServer,
|
|
494
|
+
'my.protocol': mockProtocol,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const plugin = createRestApiPlugin({
|
|
498
|
+
serverServiceName: 'my.server',
|
|
499
|
+
protocolServiceName: 'my.protocol',
|
|
500
|
+
});
|
|
501
|
+
await plugin.start!(ctx as any);
|
|
502
|
+
|
|
503
|
+
expect(ctx.logger.info).toHaveBeenCalledWith(
|
|
504
|
+
expect.stringContaining('REST API successfully registered'),
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('should throw and log error when RestServer construction fails', async () => {
|
|
509
|
+
const badServer = {}; // missing methods → will throw
|
|
510
|
+
const mockProtocol = createMockProtocol();
|
|
511
|
+
const ctx = createMockPluginContext({
|
|
512
|
+
'http.server': badServer,
|
|
513
|
+
protocol: mockProtocol,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const plugin = createRestApiPlugin();
|
|
517
|
+
await expect(plugin.start!(ctx as any)).rejects.toThrow();
|
|
518
|
+
expect(ctx.logger.error).toHaveBeenCalled();
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// Backward-compatible aliases
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
describe('backward-compatible aliases', () => {
|
|
528
|
+
it('createApiRegistryPlugin should be the same function as createRestApiPlugin', () => {
|
|
529
|
+
expect(createApiRegistryPlugin).toBe(createRestApiPlugin);
|
|
530
|
+
});
|
|
531
|
+
});
|