@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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @objectstack/rest@2.0.0 build /home/runner/work/spec/spec/packages/rest
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
  CLI Building entry: src/index.ts
@@ -10,13 +10,13 @@
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
12
  CJS Build start
13
- ESM dist/index.js 20.70 KB
14
- ESM dist/index.js.map 50.10 KB
15
- ESM ⚡️ Build success in 50ms
16
13
  CJS dist/index.cjs 21.89 KB
17
14
  CJS dist/index.cjs.map 50.79 KB
18
- CJS ⚡️ Build success in 55ms
15
+ CJS ⚡️ Build success in 67ms
16
+ ESM dist/index.js 20.70 KB
17
+ ESM dist/index.js.map 50.10 KB
18
+ ESM ⚡️ Build success in 68ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 10321ms
20
+ DTS ⚡️ Build success in 7125ms
21
21
  DTS dist/index.d.ts 6.70 KB
22
22
  DTS dist/index.d.cts 6.70 KB
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.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.24.1",
18
- "@objectstack/core": "2.0.0",
19
- "@objectstack/spec": "2.0.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",
@@ -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
+ });
@@ -0,0 +1,10 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { defineConfig } from 'vitest/config';
4
+
5
+ export default defineConfig({
6
+ test: {
7
+ globals: true,
8
+ environment: 'node',
9
+ },
10
+ });