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