@objectstack/rest 4.0.4 → 4.1.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.
@@ -1,691 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { IHttpServer } from '@objectstack/core';
4
- import { RouteManager } from './route-manager.js';
5
- import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api';
6
- import { ObjectStackProtocol } from '@objectstack/spec/api';
7
-
8
- /**
9
- * Normalized REST Server Configuration
10
- * All nested properties are required after normalization
11
- */
12
- type NormalizedRestServerConfig = {
13
- api: {
14
- version: string;
15
- basePath: string;
16
- apiPath: string | undefined;
17
- enableCrud: boolean;
18
- enableMetadata: boolean;
19
- enableUi: boolean;
20
- enableBatch: boolean;
21
- enableDiscovery: boolean;
22
- documentation: RestApiConfig['documentation'];
23
- responseFormat: RestApiConfig['responseFormat'];
24
- };
25
- crud: {
26
- operations: {
27
- create: boolean;
28
- read: boolean;
29
- update: boolean;
30
- delete: boolean;
31
- list: boolean;
32
- };
33
- patterns: CrudEndpointsConfig['patterns'];
34
- dataPrefix: string;
35
- objectParamStyle: 'path' | 'query';
36
- };
37
- metadata: {
38
- prefix: string;
39
- enableCache: boolean;
40
- cacheTtl: number;
41
- endpoints: {
42
- types: boolean;
43
- items: boolean;
44
- item: boolean;
45
- schema: boolean;
46
- };
47
- };
48
- batch: {
49
- maxBatchSize: number;
50
- enableBatchEndpoint: boolean;
51
- operations: {
52
- createMany: boolean;
53
- updateMany: boolean;
54
- deleteMany: boolean;
55
- upsertMany: boolean;
56
- };
57
- defaultAtomic: boolean;
58
- };
59
- routes: {
60
- includeObjects: string[] | undefined;
61
- excludeObjects: string[] | undefined;
62
- nameTransform: 'none' | 'plural' | 'kebab-case' | 'camelCase';
63
- overrides: RouteGenerationConfig['overrides'];
64
- };
65
- };
66
-
67
- /**
68
- * RestServer
69
- *
70
- * Provides automatic REST API endpoint generation for ObjectStack.
71
- * Generates standard RESTful CRUD endpoints, metadata endpoints, and batch operations
72
- * based on the configured protocol provider.
73
- *
74
- * Features:
75
- * - Automatic CRUD endpoint generation (GET, POST, PUT, PATCH, DELETE)
76
- * - Metadata API endpoints (/meta)
77
- * - Batch operation endpoints (/batch, /createMany, /updateMany, /deleteMany)
78
- * - Discovery endpoint
79
- * - Configurable path prefixes and patterns
80
- *
81
- * @example
82
- * const restServer = new RestServer(httpServer, protocolProvider, {
83
- * api: {
84
- * version: 'v1',
85
- * basePath: '/api'
86
- * },
87
- * crud: {
88
- * dataPrefix: '/data'
89
- * }
90
- * });
91
- *
92
- * restServer.registerRoutes();
93
- */
94
- export class RestServer {
95
- private protocol: ObjectStackProtocol;
96
- private config: NormalizedRestServerConfig;
97
- private routeManager: RouteManager;
98
-
99
- constructor(
100
- server: IHttpServer,
101
- protocol: ObjectStackProtocol,
102
- config: RestServerConfig = {}
103
- ) {
104
- this.protocol = protocol;
105
- this.config = this.normalizeConfig(config);
106
- this.routeManager = new RouteManager(server);
107
- }
108
-
109
- /**
110
- * Normalize configuration with defaults
111
- */
112
- private normalizeConfig(config: RestServerConfig): NormalizedRestServerConfig {
113
- const api = (config.api ?? {}) as Partial<RestApiConfig>;
114
- const crud = (config.crud ?? {}) as Partial<CrudEndpointsConfig>;
115
- const metadata = (config.metadata ?? {}) as Partial<MetadataEndpointsConfig>;
116
- const batch = (config.batch ?? {}) as Partial<BatchEndpointsConfig>;
117
- const routes = (config.routes ?? {}) as Partial<RouteGenerationConfig>;
118
-
119
- return {
120
- api: {
121
- version: api.version ?? 'v1',
122
- basePath: api.basePath ?? '/api',
123
- apiPath: api.apiPath,
124
- enableCrud: api.enableCrud ?? true,
125
- enableMetadata: api.enableMetadata ?? true,
126
- enableUi: api.enableUi ?? true,
127
- enableBatch: api.enableBatch ?? true,
128
- enableDiscovery: api.enableDiscovery ?? true,
129
- documentation: api.documentation,
130
- responseFormat: api.responseFormat,
131
- },
132
- crud: {
133
- operations: crud.operations ?? {
134
- create: true,
135
- read: true,
136
- update: true,
137
- delete: true,
138
- list: true,
139
- },
140
- patterns: crud.patterns,
141
- dataPrefix: crud.dataPrefix ?? '/data',
142
- objectParamStyle: crud.objectParamStyle ?? 'path',
143
- },
144
- metadata: {
145
- prefix: metadata.prefix ?? '/meta',
146
- enableCache: metadata.enableCache ?? true,
147
- cacheTtl: metadata.cacheTtl ?? 3600,
148
- endpoints: metadata.endpoints ?? {
149
- types: true,
150
- items: true,
151
- item: true,
152
- schema: true,
153
- },
154
- },
155
- batch: {
156
- maxBatchSize: batch.maxBatchSize ?? 200,
157
- enableBatchEndpoint: batch.enableBatchEndpoint ?? true,
158
- operations: batch.operations ?? {
159
- createMany: true,
160
- updateMany: true,
161
- deleteMany: true,
162
- upsertMany: true,
163
- },
164
- defaultAtomic: batch.defaultAtomic ?? true,
165
- },
166
- routes: {
167
- includeObjects: routes.includeObjects,
168
- excludeObjects: routes.excludeObjects,
169
- nameTransform: routes.nameTransform ?? 'none',
170
- overrides: routes.overrides,
171
- },
172
- };
173
- }
174
-
175
- /**
176
- * Get the full API base path
177
- */
178
- private getApiBasePath(): string {
179
- const { api } = this.config;
180
- return api.apiPath ?? `${api.basePath}/${api.version}`;
181
- }
182
-
183
- /**
184
- * Register all REST API routes
185
- */
186
- registerRoutes(): void {
187
- const basePath = this.getApiBasePath();
188
-
189
- // Discovery endpoint
190
- if (this.config.api.enableDiscovery) {
191
- this.registerDiscoveryEndpoints(basePath);
192
- }
193
-
194
- // Metadata endpoints
195
- if (this.config.api.enableMetadata) {
196
- this.registerMetadataEndpoints(basePath);
197
- }
198
-
199
- // UI endpoints
200
- if (this.config.api.enableUi) {
201
- this.registerUiEndpoints(basePath);
202
- }
203
-
204
- // CRUD endpoints
205
- if (this.config.api.enableCrud) {
206
- this.registerCrudEndpoints(basePath);
207
- }
208
-
209
- // Batch endpoints
210
- if (this.config.api.enableBatch) {
211
- this.registerBatchEndpoints(basePath);
212
- }
213
- }
214
-
215
- /**
216
- * Register discovery endpoints
217
- */
218
- private registerDiscoveryEndpoints(basePath: string): void {
219
- const discoveryHandler = async (_req: any, res: any) => {
220
- try {
221
- const discovery = await this.protocol.getDiscovery();
222
-
223
- // Override discovery information with actual server configuration
224
- discovery.version = this.config.api.version;
225
-
226
- if (discovery.routes) {
227
- // Ensure routes match the actual mounted paths
228
- if (this.config.api.enableCrud) {
229
- discovery.routes.data = `${basePath}${this.config.crud.dataPrefix}`;
230
- }
231
-
232
- if (this.config.api.enableMetadata) {
233
- discovery.routes.metadata = `${basePath}${this.config.metadata.prefix}`;
234
- }
235
-
236
- if (this.config.api.enableUi) {
237
- discovery.routes.ui = `${basePath}/ui`;
238
- }
239
-
240
- // Align auth route with the versioned base path if present
241
- if (discovery.routes.auth) {
242
- discovery.routes.auth = `${basePath}/auth`;
243
- }
244
- }
245
-
246
- res.json(discovery);
247
- } catch (error: any) {
248
- res.status(500).json({ error: error.message });
249
- }
250
- };
251
-
252
- // Register at basePath (e.g. /api/v1)
253
- this.routeManager.register({
254
- method: 'GET',
255
- path: basePath,
256
- handler: discoveryHandler,
257
- metadata: {
258
- summary: 'Get API discovery information',
259
- tags: ['discovery'],
260
- },
261
- });
262
-
263
- // Register at basePath/discovery (e.g. /api/v1/discovery)
264
- this.routeManager.register({
265
- method: 'GET',
266
- path: `${basePath}/discovery`,
267
- handler: discoveryHandler,
268
- metadata: {
269
- summary: 'Get API discovery information',
270
- tags: ['discovery'],
271
- },
272
- });
273
- }
274
-
275
- /**
276
- * Register metadata endpoints
277
- */
278
- private registerMetadataEndpoints(basePath: string): void {
279
- const { metadata } = this.config;
280
- const metaPath = `${basePath}${metadata.prefix}`;
281
-
282
- // GET /meta - List all metadata types
283
- if (metadata.endpoints.types !== false) {
284
- this.routeManager.register({
285
- method: 'GET',
286
- path: metaPath,
287
- handler: async (_req: any, res: any) => {
288
- try {
289
- const types = await this.protocol.getMetaTypes();
290
- res.json(types);
291
- } catch (error: any) {
292
- res.status(500).json({ error: error.message });
293
- }
294
- },
295
- metadata: {
296
- summary: 'List all metadata types',
297
- tags: ['metadata'],
298
- },
299
- });
300
- }
301
-
302
- // GET /meta/:type - List items of a type
303
- if (metadata.endpoints.items !== false) {
304
- this.routeManager.register({
305
- method: 'GET',
306
- path: `${metaPath}/:type`,
307
- handler: async (req: any, res: any) => {
308
- try {
309
- const packageId = req.query?.package || undefined;
310
- const items = await this.protocol.getMetaItems({ type: req.params.type, packageId });
311
- res.json(items);
312
- } catch (error: any) {
313
- res.status(404).json({ error: error.message });
314
- }
315
- },
316
- metadata: {
317
- summary: 'List metadata items of a type',
318
- tags: ['metadata'],
319
- },
320
- });
321
- }
322
-
323
- // GET /meta/:type/:name - Get specific item
324
- if (metadata.endpoints.item !== false) {
325
- this.routeManager.register({
326
- method: 'GET',
327
- path: `${metaPath}/:type/:name`,
328
- handler: async (req: any, res: any) => {
329
- try {
330
- // Check if cached version is available
331
- if (metadata.enableCache && this.protocol.getMetaItemCached) {
332
- const cacheRequest = {
333
- ifNoneMatch: req.headers['if-none-match'] as string,
334
- ifModifiedSince: req.headers['if-modified-since'] as string,
335
- };
336
-
337
- const result = await this.protocol.getMetaItemCached({
338
- type: req.params.type,
339
- name: req.params.name,
340
- cacheRequest
341
- });
342
-
343
- if (result.notModified) {
344
- res.status(304).send();
345
- return;
346
- }
347
-
348
- // Set cache headers
349
- if (result.etag) {
350
- const etagValue = result.etag.weak
351
- ? `W/"${result.etag.value}"`
352
- : `"${result.etag.value}"`;
353
- res.header('ETag', etagValue);
354
- }
355
- if (result.lastModified) {
356
- res.header('Last-Modified', new Date(result.lastModified).toUTCString());
357
- }
358
- if (result.cacheControl) {
359
- const directives = result.cacheControl.directives.join(', ');
360
- const maxAge = result.cacheControl.maxAge
361
- ? `, max-age=${result.cacheControl.maxAge}`
362
- : '';
363
- res.header('Cache-Control', directives + maxAge);
364
- }
365
-
366
- res.json(result.data);
367
- } else {
368
- // Non-cached version
369
- const packageId = req.query?.package || undefined;
370
- const item = await this.protocol.getMetaItem({ type: req.params.type, name: req.params.name, packageId });
371
- res.json(item);
372
- }
373
- } catch (error: any) {
374
- res.status(404).json({ error: error.message });
375
- }
376
- },
377
- metadata: {
378
- summary: 'Get specific metadata item',
379
- tags: ['metadata'],
380
- },
381
- });
382
- }
383
-
384
- // PUT /meta/:type/:name - Save metadata item
385
- // We always register this route, but return 501 if protocol doesn't support it
386
- // This makes it discoverable even if not implemented
387
- this.routeManager.register({
388
- method: 'PUT',
389
- path: `${metaPath}/:type/:name`,
390
- handler: async (req: any, res: any) => {
391
- try {
392
- if (!this.protocol.saveMetaItem) {
393
- res.status(501).json({ error: 'Save operation not supported by protocol implementation' });
394
- return;
395
- }
396
-
397
- const result = await this.protocol.saveMetaItem({
398
- type: req.params.type,
399
- name: req.params.name,
400
- item: req.body
401
- });
402
- res.json(result);
403
- } catch (error: any) {
404
- res.status(400).json({ error: error.message });
405
- }
406
- },
407
- metadata: {
408
- summary: 'Save specific metadata item',
409
- tags: ['metadata'],
410
- },
411
- });
412
- }
413
-
414
- /**
415
- * Register UI endpoints
416
- */
417
- private registerUiEndpoints(basePath: string): void {
418
- const uiPath = `${basePath}/ui`;
419
-
420
- // GET /ui/view/:object/:type - Resolve view for object
421
- this.routeManager.register({
422
- method: 'GET',
423
- path: `${uiPath}/view/:object/:type`,
424
- handler: async (req: any, res: any) => {
425
- try {
426
- if (this.protocol.getUiView) {
427
- const view = await this.protocol.getUiView({
428
- object: req.params.object,
429
- type: req.params.type as any
430
- });
431
- res.json(view);
432
- } else {
433
- res.status(501).json({ error: 'UI View resolution not supported by protocol implementation' });
434
- }
435
- } catch (error: any) {
436
- res.status(404).json({ error: error.message });
437
- }
438
- },
439
- metadata: {
440
- summary: 'Resolve UI View for object',
441
- tags: ['ui'],
442
- },
443
- });
444
- }
445
-
446
- /**
447
- * Register CRUD endpoints for data operations
448
- */
449
- private registerCrudEndpoints(basePath: string): void {
450
- const { crud } = this.config;
451
- const dataPath = `${basePath}${crud.dataPrefix}`;
452
-
453
- const operations = crud.operations;
454
-
455
- // GET /data/:object - List/query records
456
- if (operations.list) {
457
- this.routeManager.register({
458
- method: 'GET',
459
- path: `${dataPath}/:object`,
460
- handler: async (req: any, res: any) => {
461
- try {
462
- const result = await this.protocol.findData({
463
- object: req.params.object,
464
- query: req.query
465
- });
466
- res.json(result);
467
- } catch (error: any) {
468
- res.status(400).json({ error: error.message });
469
- }
470
- },
471
- metadata: {
472
- summary: 'Query records',
473
- tags: ['data', 'crud'],
474
- },
475
- });
476
- }
477
-
478
- // GET /data/:object/:id - Get single record
479
- if (operations.read) {
480
- this.routeManager.register({
481
- method: 'GET',
482
- path: `${dataPath}/:object/:id`,
483
- handler: async (req: any, res: any) => {
484
- try {
485
- const { select, expand } = req.query || {};
486
- const result = await this.protocol.getData({
487
- object: req.params.object,
488
- id: req.params.id,
489
- ...(select != null ? { select } : {}),
490
- ...(expand != null ? { expand } : {}),
491
- });
492
- res.json(result);
493
- } catch (error: any) {
494
- res.status(404).json({ error: error.message });
495
- }
496
- },
497
- metadata: {
498
- summary: 'Get record by ID',
499
- tags: ['data', 'crud'],
500
- },
501
- });
502
- }
503
-
504
- // POST /data/:object - Create record
505
- if (operations.create) {
506
- this.routeManager.register({
507
- method: 'POST',
508
- path: `${dataPath}/:object`,
509
- handler: async (req: any, res: any) => {
510
- try {
511
- const result = await this.protocol.createData({
512
- object: req.params.object,
513
- data: req.body
514
- });
515
- res.status(201).json(result);
516
- } catch (error: any) {
517
- res.status(400).json({ error: error.message });
518
- }
519
- },
520
- metadata: {
521
- summary: 'Create record',
522
- tags: ['data', 'crud'],
523
- },
524
- });
525
- }
526
-
527
- // PATCH /data/:object/:id - Update record
528
- if (operations.update) {
529
- this.routeManager.register({
530
- method: 'PATCH',
531
- path: `${dataPath}/:object/:id`,
532
- handler: async (req: any, res: any) => {
533
- try {
534
- const result = await this.protocol.updateData({
535
- object: req.params.object,
536
- id: req.params.id,
537
- data: req.body
538
- });
539
- res.json(result);
540
- } catch (error: any) {
541
- res.status(400).json({ error: error.message });
542
- }
543
- },
544
- metadata: {
545
- summary: 'Update record',
546
- tags: ['data', 'crud'],
547
- },
548
- });
549
- }
550
-
551
- // DELETE /data/:object/:id - Delete record
552
- if (operations.delete) {
553
- this.routeManager.register({
554
- method: 'DELETE',
555
- path: `${dataPath}/:object/:id`,
556
- handler: async (req: any, res: any) => {
557
- try {
558
- const result = await this.protocol.deleteData({
559
- object: req.params.object,
560
- id: req.params.id
561
- });
562
- res.json(result);
563
- } catch (error: any) {
564
- res.status(400).json({ error: error.message });
565
- }
566
- },
567
- metadata: {
568
- summary: 'Delete record',
569
- tags: ['data', 'crud'],
570
- },
571
- });
572
- }
573
- }
574
-
575
- /**
576
- * Register batch operation endpoints
577
- */
578
- private registerBatchEndpoints(basePath: string): void {
579
- const { crud, batch } = this.config;
580
- const dataPath = `${basePath}${crud.dataPrefix}`;
581
-
582
- const operations = batch.operations;
583
-
584
- // POST /data/:object/batch - Generic batch endpoint
585
- if (batch.enableBatchEndpoint && this.protocol.batchData) {
586
- this.routeManager.register({
587
- method: 'POST',
588
- path: `${dataPath}/:object/batch`,
589
- handler: async (req: any, res: any) => {
590
- try {
591
- const result = await this.protocol.batchData!({
592
- object: req.params.object,
593
- request: req.body
594
- });
595
- res.json(result);
596
- } catch (error: any) {
597
- res.status(400).json({ error: error.message });
598
- }
599
- },
600
- metadata: {
601
- summary: 'Batch operations',
602
- tags: ['data', 'batch'],
603
- },
604
- });
605
- }
606
-
607
- // POST /data/:object/createMany - Bulk create
608
- if (operations.createMany && this.protocol.createManyData) {
609
- this.routeManager.register({
610
- method: 'POST',
611
- path: `${dataPath}/:object/createMany`,
612
- handler: async (req: any, res: any) => {
613
- try {
614
- const result = await this.protocol.createManyData!({
615
- object: req.params.object,
616
- records: req.body || []
617
- });
618
- res.status(201).json(result);
619
- } catch (error: any) {
620
- res.status(400).json({ error: error.message });
621
- }
622
- },
623
- metadata: {
624
- summary: 'Create multiple records',
625
- tags: ['data', 'batch'],
626
- },
627
- });
628
- }
629
-
630
- // POST /data/:object/updateMany - Bulk update
631
- if (operations.updateMany && this.protocol.updateManyData) {
632
- this.routeManager.register({
633
- method: 'POST',
634
- path: `${dataPath}/:object/updateMany`,
635
- handler: async (req: any, res: any) => {
636
- try {
637
- const result = await this.protocol.updateManyData!({
638
- object: req.params.object,
639
- ...req.body
640
- });
641
- res.json(result);
642
- } catch (error: any) {
643
- res.status(400).json({ error: error.message });
644
- }
645
- },
646
- metadata: {
647
- summary: 'Update multiple records',
648
- tags: ['data', 'batch'],
649
- },
650
- });
651
- }
652
-
653
- // POST /data/:object/deleteMany - Bulk delete
654
- if (operations.deleteMany && this.protocol.deleteManyData) {
655
- this.routeManager.register({
656
- method: 'POST',
657
- path: `${dataPath}/:object/deleteMany`,
658
- handler: async (req: any, res: any) => {
659
- try {
660
- const result = await this.protocol.deleteManyData!({
661
- object: req.params.object,
662
- ...req.body
663
- });
664
- res.json(result);
665
- } catch (error: any) {
666
- res.status(400).json({ error: error.message });
667
- }
668
- },
669
- metadata: {
670
- summary: 'Delete multiple records',
671
- tags: ['data', 'batch'],
672
- },
673
- });
674
- }
675
- }
676
-
677
-
678
- /**
679
- * Get the route manager
680
- */
681
- getRouteManager(): RouteManager {
682
- return this.routeManager;
683
- }
684
-
685
- /**
686
- * Get all registered routes
687
- */
688
- getRoutes() {
689
- return this.routeManager.getAll();
690
- }
691
- }