@objectstack/runtime 0.6.1 → 0.7.1

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,579 @@
1
+ import { IHttpServer } from '@objectstack/core';
2
+ import { RouteManager } from './route-manager';
3
+ import { RestServerConfig, CrudOperation, 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
+ enableBatch: boolean;
18
+ enableDiscovery: boolean;
19
+ documentation: RestApiConfig['documentation'];
20
+ responseFormat: RestApiConfig['responseFormat'];
21
+ };
22
+ crud: {
23
+ operations: {
24
+ create: boolean;
25
+ read: boolean;
26
+ update: boolean;
27
+ delete: boolean;
28
+ list: boolean;
29
+ };
30
+ patterns: CrudEndpointsConfig['patterns'];
31
+ dataPrefix: string;
32
+ objectParamStyle: 'path' | 'query';
33
+ };
34
+ metadata: {
35
+ prefix: string;
36
+ enableCache: boolean;
37
+ cacheTtl: number;
38
+ endpoints: {
39
+ types: boolean;
40
+ items: boolean;
41
+ item: boolean;
42
+ schema: boolean;
43
+ };
44
+ };
45
+ batch: {
46
+ maxBatchSize: number;
47
+ enableBatchEndpoint: boolean;
48
+ operations: {
49
+ createMany: boolean;
50
+ updateMany: boolean;
51
+ deleteMany: boolean;
52
+ upsertMany: boolean;
53
+ };
54
+ defaultAtomic: boolean;
55
+ };
56
+ routes: {
57
+ includeObjects: string[] | undefined;
58
+ excludeObjects: string[] | undefined;
59
+ nameTransform: 'none' | 'plural' | 'kebab-case' | 'camelCase';
60
+ overrides: RouteGenerationConfig['overrides'];
61
+ };
62
+ };
63
+
64
+ /**
65
+ * RestServer
66
+ *
67
+ * Provides automatic REST API endpoint generation for ObjectStack.
68
+ * Generates standard RESTful CRUD endpoints, metadata endpoints, and batch operations
69
+ * based on the configured protocol provider.
70
+ *
71
+ * Features:
72
+ * - Automatic CRUD endpoint generation (GET, POST, PUT, PATCH, DELETE)
73
+ * - Metadata API endpoints (/meta)
74
+ * - Batch operation endpoints (/batch, /createMany, /updateMany, /deleteMany)
75
+ * - Discovery endpoint
76
+ * - Configurable path prefixes and patterns
77
+ *
78
+ * @example
79
+ * const restServer = new RestServer(httpServer, protocolProvider, {
80
+ * api: {
81
+ * version: 'v1',
82
+ * basePath: '/api'
83
+ * },
84
+ * crud: {
85
+ * dataPrefix: '/data'
86
+ * }
87
+ * });
88
+ *
89
+ * restServer.registerRoutes();
90
+ */
91
+ export class RestServer {
92
+ private server: IHttpServer;
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.server = server;
103
+ this.protocol = protocol;
104
+ this.config = this.normalizeConfig(config);
105
+ this.routeManager = new RouteManager(server);
106
+ }
107
+
108
+ /**
109
+ * Normalize configuration with defaults
110
+ */
111
+ private normalizeConfig(config: RestServerConfig): NormalizedRestServerConfig {
112
+ const api = (config.api ?? {}) as Partial<RestApiConfig>;
113
+ const crud = (config.crud ?? {}) as Partial<CrudEndpointsConfig>;
114
+ const metadata = (config.metadata ?? {}) as Partial<MetadataEndpointsConfig>;
115
+ const batch = (config.batch ?? {}) as Partial<BatchEndpointsConfig>;
116
+ const routes = (config.routes ?? {}) as Partial<RouteGenerationConfig>;
117
+
118
+ return {
119
+ api: {
120
+ version: api.version ?? 'v1',
121
+ basePath: api.basePath ?? '/api',
122
+ apiPath: api.apiPath,
123
+ enableCrud: api.enableCrud ?? true,
124
+ enableMetadata: api.enableMetadata ?? 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
+ // CRUD endpoints
198
+ if (this.config.api.enableCrud) {
199
+ this.registerCrudEndpoints(basePath);
200
+ }
201
+
202
+ // Batch endpoints
203
+ if (this.config.api.enableBatch) {
204
+ this.registerBatchEndpoints(basePath);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Register discovery endpoints
210
+ */
211
+ private registerDiscoveryEndpoints(basePath: string): void {
212
+ this.routeManager.register({
213
+ method: 'GET',
214
+ path: basePath,
215
+ handler: async (req: any, res: any) => {
216
+ try {
217
+ const discovery = await this.protocol.getDiscovery({});
218
+ res.json(discovery);
219
+ } catch (error: any) {
220
+ res.status(500).json({ error: error.message });
221
+ }
222
+ },
223
+ metadata: {
224
+ summary: 'Get API discovery information',
225
+ tags: ['discovery'],
226
+ },
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Register metadata endpoints
232
+ */
233
+ private registerMetadataEndpoints(basePath: string): void {
234
+ const { metadata } = this.config;
235
+ const metaPath = `${basePath}${metadata.prefix}`;
236
+
237
+ // GET /meta - List all metadata types
238
+ if (metadata.endpoints.types !== false) {
239
+ this.routeManager.register({
240
+ method: 'GET',
241
+ path: metaPath,
242
+ handler: async (req: any, res: any) => {
243
+ try {
244
+ const types = await this.protocol.getMetaTypes({});
245
+ res.json(types);
246
+ } catch (error: any) {
247
+ res.status(500).json({ error: error.message });
248
+ }
249
+ },
250
+ metadata: {
251
+ summary: 'List all metadata types',
252
+ tags: ['metadata'],
253
+ },
254
+ });
255
+ }
256
+
257
+ // GET /meta/:type - List items of a type
258
+ if (metadata.endpoints.items !== false) {
259
+ this.routeManager.register({
260
+ method: 'GET',
261
+ path: `${metaPath}/:type`,
262
+ handler: async (req: any, res: any) => {
263
+ try {
264
+ const items = await this.protocol.getMetaItems({ type: req.params.type });
265
+ res.json(items);
266
+ } catch (error: any) {
267
+ res.status(404).json({ error: error.message });
268
+ }
269
+ },
270
+ metadata: {
271
+ summary: 'List metadata items of a type',
272
+ tags: ['metadata'],
273
+ },
274
+ });
275
+ }
276
+
277
+ // GET /meta/:type/:name - Get specific item
278
+ if (metadata.endpoints.item !== false) {
279
+ this.routeManager.register({
280
+ method: 'GET',
281
+ path: `${metaPath}/:type/:name`,
282
+ handler: async (req: any, res: any) => {
283
+ try {
284
+ // Check if cached version is available
285
+ if (metadata.enableCache && this.protocol.getMetaItemCached) {
286
+ const cacheRequest = {
287
+ ifNoneMatch: req.headers['if-none-match'] as string,
288
+ ifModifiedSince: req.headers['if-modified-since'] as string,
289
+ };
290
+
291
+ const result = await this.protocol.getMetaItemCached({
292
+ type: req.params.type,
293
+ name: req.params.name,
294
+ cacheRequest
295
+ });
296
+
297
+ if (result.notModified) {
298
+ res.status(304).send();
299
+ return;
300
+ }
301
+
302
+ // Set cache headers
303
+ if (result.etag) {
304
+ const etagValue = result.etag.weak
305
+ ? `W/"${result.etag.value}"`
306
+ : `"${result.etag.value}"`;
307
+ res.header('ETag', etagValue);
308
+ }
309
+ if (result.lastModified) {
310
+ res.header('Last-Modified', new Date(result.lastModified).toUTCString());
311
+ }
312
+ if (result.cacheControl) {
313
+ const directives = result.cacheControl.directives.join(', ');
314
+ const maxAge = result.cacheControl.maxAge
315
+ ? `, max-age=${result.cacheControl.maxAge}`
316
+ : '';
317
+ res.header('Cache-Control', directives + maxAge);
318
+ }
319
+
320
+ res.json(result.data);
321
+ } else {
322
+ // Non-cached version
323
+ const item = await this.protocol.getMetaItem({ type: req.params.type, name: req.params.name });
324
+ res.json(item);
325
+ }
326
+ } catch (error: any) {
327
+ res.status(404).json({ error: error.message });
328
+ }
329
+ },
330
+ metadata: {
331
+ summary: 'Get specific metadata item',
332
+ tags: ['metadata'],
333
+ },
334
+ });
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Register CRUD endpoints for data operations
340
+ */
341
+ private registerCrudEndpoints(basePath: string): void {
342
+ const { crud } = this.config;
343
+ const dataPath = `${basePath}${crud.dataPrefix}`;
344
+
345
+ const operations = crud.operations;
346
+
347
+ // GET /data/:object - List/query records
348
+ if (operations.list) {
349
+ this.routeManager.register({
350
+ method: 'GET',
351
+ path: `${dataPath}/:object`,
352
+ handler: async (req: any, res: any) => {
353
+ try {
354
+ const result = await this.protocol.findData({
355
+ object: req.params.object,
356
+ query: req.query
357
+ });
358
+ res.json(result);
359
+ } catch (error: any) {
360
+ res.status(400).json({ error: error.message });
361
+ }
362
+ },
363
+ metadata: {
364
+ summary: 'Query records',
365
+ tags: ['data', 'crud'],
366
+ },
367
+ });
368
+ }
369
+
370
+ // GET /data/:object/:id - Get single record
371
+ if (operations.read) {
372
+ this.routeManager.register({
373
+ method: 'GET',
374
+ path: `${dataPath}/:object/:id`,
375
+ handler: async (req: any, res: any) => {
376
+ try {
377
+ const result = await this.protocol.getData({
378
+ object: req.params.object,
379
+ id: req.params.id
380
+ });
381
+ res.json(result);
382
+ } catch (error: any) {
383
+ res.status(404).json({ error: error.message });
384
+ }
385
+ },
386
+ metadata: {
387
+ summary: 'Get record by ID',
388
+ tags: ['data', 'crud'],
389
+ },
390
+ });
391
+ }
392
+
393
+ // POST /data/:object - Create record
394
+ if (operations.create) {
395
+ this.routeManager.register({
396
+ method: 'POST',
397
+ path: `${dataPath}/:object`,
398
+ handler: async (req: any, res: any) => {
399
+ try {
400
+ const result = await this.protocol.createData({
401
+ object: req.params.object,
402
+ data: req.body
403
+ });
404
+ res.status(201).json(result);
405
+ } catch (error: any) {
406
+ res.status(400).json({ error: error.message });
407
+ }
408
+ },
409
+ metadata: {
410
+ summary: 'Create record',
411
+ tags: ['data', 'crud'],
412
+ },
413
+ });
414
+ }
415
+
416
+ // PATCH /data/:object/:id - Update record
417
+ if (operations.update) {
418
+ this.routeManager.register({
419
+ method: 'PATCH',
420
+ path: `${dataPath}/:object/:id`,
421
+ handler: async (req: any, res: any) => {
422
+ try {
423
+ const result = await this.protocol.updateData({
424
+ object: req.params.object,
425
+ id: req.params.id,
426
+ data: req.body
427
+ });
428
+ res.json(result);
429
+ } catch (error: any) {
430
+ res.status(400).json({ error: error.message });
431
+ }
432
+ },
433
+ metadata: {
434
+ summary: 'Update record',
435
+ tags: ['data', 'crud'],
436
+ },
437
+ });
438
+ }
439
+
440
+ // DELETE /data/:object/:id - Delete record
441
+ if (operations.delete) {
442
+ this.routeManager.register({
443
+ method: 'DELETE',
444
+ path: `${dataPath}/:object/:id`,
445
+ handler: async (req: any, res: any) => {
446
+ try {
447
+ const result = await this.protocol.deleteData({
448
+ object: req.params.object,
449
+ id: req.params.id
450
+ });
451
+ res.json(result);
452
+ } catch (error: any) {
453
+ res.status(400).json({ error: error.message });
454
+ }
455
+ },
456
+ metadata: {
457
+ summary: 'Delete record',
458
+ tags: ['data', 'crud'],
459
+ },
460
+ });
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Register batch operation endpoints
466
+ */
467
+ private registerBatchEndpoints(basePath: string): void {
468
+ const { crud, batch } = this.config;
469
+ const dataPath = `${basePath}${crud.dataPrefix}`;
470
+
471
+ const operations = batch.operations;
472
+
473
+ // POST /data/:object/batch - Generic batch endpoint
474
+ if (batch.enableBatchEndpoint && this.protocol.batchData) {
475
+ this.routeManager.register({
476
+ method: 'POST',
477
+ path: `${dataPath}/:object/batch`,
478
+ handler: async (req: any, res: any) => {
479
+ try {
480
+ const result = await this.protocol.batchData!({
481
+ object: req.params.object,
482
+ request: req.body
483
+ });
484
+ res.json(result);
485
+ } catch (error: any) {
486
+ res.status(400).json({ error: error.message });
487
+ }
488
+ },
489
+ metadata: {
490
+ summary: 'Batch operations',
491
+ tags: ['data', 'batch'],
492
+ },
493
+ });
494
+ }
495
+
496
+ // POST /data/:object/createMany - Bulk create
497
+ if (operations.createMany && this.protocol.createManyData) {
498
+ this.routeManager.register({
499
+ method: 'POST',
500
+ path: `${dataPath}/:object/createMany`,
501
+ handler: async (req: any, res: any) => {
502
+ try {
503
+ const result = await this.protocol.createManyData!({
504
+ object: req.params.object,
505
+ records: req.body || []
506
+ });
507
+ res.status(201).json(result);
508
+ } catch (error: any) {
509
+ res.status(400).json({ error: error.message });
510
+ }
511
+ },
512
+ metadata: {
513
+ summary: 'Create multiple records',
514
+ tags: ['data', 'batch'],
515
+ },
516
+ });
517
+ }
518
+
519
+ // POST /data/:object/updateMany - Bulk update
520
+ if (operations.updateMany && this.protocol.updateManyData) {
521
+ this.routeManager.register({
522
+ method: 'POST',
523
+ path: `${dataPath}/:object/updateMany`,
524
+ handler: async (req: any, res: any) => {
525
+ try {
526
+ const result = await this.protocol.updateManyData!({
527
+ object: req.params.object,
528
+ ...req.body
529
+ });
530
+ res.json(result);
531
+ } catch (error: any) {
532
+ res.status(400).json({ error: error.message });
533
+ }
534
+ },
535
+ metadata: {
536
+ summary: 'Update multiple records',
537
+ tags: ['data', 'batch'],
538
+ },
539
+ });
540
+ }
541
+
542
+ // POST /data/:object/deleteMany - Bulk delete
543
+ if (operations.deleteMany && this.protocol.deleteManyData) {
544
+ this.routeManager.register({
545
+ method: 'POST',
546
+ path: `${dataPath}/:object/deleteMany`,
547
+ handler: async (req: any, res: any) => {
548
+ try {
549
+ const result = await this.protocol.deleteManyData!({
550
+ object: req.params.object,
551
+ ...req.body
552
+ });
553
+ res.json(result);
554
+ } catch (error: any) {
555
+ res.status(400).json({ error: error.message });
556
+ }
557
+ },
558
+ metadata: {
559
+ summary: 'Delete multiple records',
560
+ tags: ['data', 'batch'],
561
+ },
562
+ });
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Get the route manager
568
+ */
569
+ getRouteManager(): RouteManager {
570
+ return this.routeManager;
571
+ }
572
+
573
+ /**
574
+ * Get all registered routes
575
+ */
576
+ getRoutes() {
577
+ return this.routeManager.getAll();
578
+ }
579
+ }