@objectql/server 1.8.3 → 1.9.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,7 +1,8 @@
1
- import { IObjectQL } from '@objectql/types';
1
+ import { IObjectQL, ApiRouteConfig, resolveApiRoutes } from '@objectql/types';
2
2
  import { ObjectQLServer } from '../server';
3
3
  import { ObjectQLRequest, ErrorCode } from '../types';
4
4
  import { IncomingMessage, ServerResponse } from 'http';
5
+ import { escapeRegexPath } from '../utils';
5
6
 
6
7
  /**
7
8
  * Parse query string parameters
@@ -60,20 +61,33 @@ function sendJSON(res: ServerResponse, statusCode: number, data: any) {
60
61
  res.end(JSON.stringify(data));
61
62
  }
62
63
 
64
+ /**
65
+ * Options for createRESTHandler
66
+ */
67
+ export interface RESTHandlerOptions {
68
+ /** Custom API route configuration */
69
+ routes?: ApiRouteConfig;
70
+ }
71
+
63
72
  /**
64
73
  * Creates a REST-style HTTP request handler for ObjectQL
65
74
  *
66
- * Endpoints:
67
- * - GET /api/data/:object - List records
68
- * - GET /api/data/:object/:id - Get single record
69
- * - POST /api/data/:object - Create record (or create many if array)
70
- * - POST /api/data/:object/bulk-update - Update many records
71
- * - POST /api/data/:object/bulk-delete - Delete many records
72
- * - PUT /api/data/:object/:id - Update record
73
- * - DELETE /api/data/:object/:id - Delete record
75
+ * Default Endpoints (configurable via routes option):
76
+ * - GET {dataPath}/:object - List records
77
+ * - GET {dataPath}/:object/:id - Get single record
78
+ * - POST {dataPath}/:object - Create record (or create many if array)
79
+ * - POST {dataPath}/:object/bulk-update - Update many records
80
+ * - POST {dataPath}/:object/bulk-delete - Delete many records
81
+ * - PUT {dataPath}/:object/:id - Update record
82
+ * - DELETE {dataPath}/:object/:id - Delete record
83
+ *
84
+ * @param app - ObjectQL application instance
85
+ * @param options - Optional configuration including custom routes
74
86
  */
75
- export function createRESTHandler(app: IObjectQL) {
87
+ export function createRESTHandler(app: IObjectQL, options?: RESTHandlerOptions) {
76
88
  const server = new ObjectQLServer(app);
89
+ const routes = resolveApiRoutes(options?.routes);
90
+ const dataPath = routes.data;
77
91
 
78
92
  return async (req: IncomingMessage & { body?: any }, res: ServerResponse) => {
79
93
  try {
@@ -101,8 +115,9 @@ export function createRESTHandler(app: IObjectQL) {
101
115
  const url = req.url || '';
102
116
  const method = req.method || 'GET';
103
117
 
104
- // Parse URL: /api/data/:object or /api/data/:object/:id or /api/data/:object/bulk-*
105
- const match = url.match(/^\/api\/data\/([^\/\?]+)(?:\/([^\/\?]+))?(\?.*)?$/);
118
+ // Parse URL: {dataPath}/:object or {dataPath}/:object/:id or {dataPath}/:object/bulk-*
119
+ const escapedPath = escapeRegexPath(dataPath);
120
+ const match = url.match(new RegExp(`^${escapedPath}/([^/\\?]+)(?:/([^/\\?]+))?(\\?.*)?$`));
106
121
 
107
122
  if (!match) {
108
123
  sendJSON(res, 404, {
@@ -129,7 +144,7 @@ export function createRESTHandler(app: IObjectQL) {
129
144
  args: id
130
145
  };
131
146
  } else {
132
- // GET /api/data/:object - List records
147
+ // GET {dataPath}/:object - List records
133
148
  const args: any = {};
134
149
 
135
150
  // Parse query parameters
@@ -167,7 +182,7 @@ export function createRESTHandler(app: IObjectQL) {
167
182
 
168
183
  // Check for bulk operations
169
184
  if (id === 'bulk-update') {
170
- // POST /api/data/:object/bulk-update - Update many records
185
+ // POST {dataPath}/:object/bulk-update - Update many records
171
186
  qlRequest = {
172
187
  op: 'updateMany',
173
188
  object: objectName,
@@ -177,7 +192,7 @@ export function createRESTHandler(app: IObjectQL) {
177
192
  }
178
193
  };
179
194
  } else if (id === 'bulk-delete') {
180
- // POST /api/data/:object/bulk-delete - Delete many records
195
+ // POST {dataPath}/:object/bulk-delete - Delete many records
181
196
  qlRequest = {
182
197
  op: 'deleteMany',
183
198
  object: objectName,
@@ -186,14 +201,14 @@ export function createRESTHandler(app: IObjectQL) {
186
201
  }
187
202
  };
188
203
  } else if (Array.isArray(createBody)) {
189
- // POST /api/data/:object with array - Create many records
204
+ // POST {dataPath}/:object with array - Create many records
190
205
  qlRequest = {
191
206
  op: 'createMany',
192
207
  object: objectName,
193
208
  args: createBody
194
209
  };
195
210
  } else {
196
- // POST /api/data/:object - Create single record
211
+ // POST {dataPath}/:object - Create single record
197
212
  qlRequest = {
198
213
  op: 'create',
199
214
  object: objectName,
@@ -204,7 +219,7 @@ export function createRESTHandler(app: IObjectQL) {
204
219
 
205
220
  case 'PUT':
206
221
  case 'PATCH':
207
- // PUT /api/data/:object/:id - Update record
222
+ // PUT {dataPath}/:object/:id - Update record
208
223
  if (!id) {
209
224
  sendJSON(res, 400, {
210
225
  error: {
@@ -227,7 +242,7 @@ export function createRESTHandler(app: IObjectQL) {
227
242
  break;
228
243
 
229
244
  case 'DELETE':
230
- // DELETE /api/data/:object/:id - Delete record
245
+ // DELETE {dataPath}/:object/:id - Delete record
231
246
  if (!id) {
232
247
  sendJSON(res, 400, {
233
248
  error: {
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export * from './types';
2
+ export * from './utils';
2
3
  export * from './openapi';
3
4
  export * from './server';
4
5
  export * from './metadata';
5
- export * from './studio';
6
6
  export * from './storage';
7
7
  export * from './file-handler';
8
8
  // We export createNodeHandler from root for convenience,
package/src/metadata.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { IObjectQL } from '@objectql/types';
1
+ import { IObjectQL, ApiRouteConfig, resolveApiRoutes } from '@objectql/types';
2
2
  import { IncomingMessage, ServerResponse } from 'http';
3
3
  import { ErrorCode } from './types';
4
+ import { escapeRegexPath } from './utils';
4
5
 
5
6
  function readBody(req: IncomingMessage): Promise<any> {
6
7
  return new Promise((resolve, reject) => {
@@ -18,11 +19,24 @@ function readBody(req: IncomingMessage): Promise<any> {
18
19
  });
19
20
  }
20
21
 
22
+ /**
23
+ * Options for createMetadataHandler
24
+ */
25
+ export interface MetadataHandlerOptions {
26
+ /** Custom API route configuration */
27
+ routes?: ApiRouteConfig;
28
+ }
29
+
21
30
  /**
22
31
  * Creates a handler for metadata endpoints.
23
32
  * These endpoints expose information about registered objects and other metadata.
33
+ *
34
+ * @param app - ObjectQL application instance
35
+ * @param options - Optional configuration including custom routes
24
36
  */
25
- export function createMetadataHandler(app: IObjectQL) {
37
+ export function createMetadataHandler(app: IObjectQL, options?: MetadataHandlerOptions) {
38
+ const routes = resolveApiRoutes(options?.routes);
39
+ const metadataPath = routes.metadata;
26
40
  return async (req: IncomingMessage, res: ServerResponse) => {
27
41
  // Parse the URL
28
42
  const url = req.url || '';
@@ -52,13 +66,14 @@ export function createMetadataHandler(app: IObjectQL) {
52
66
  };
53
67
 
54
68
  // ---------------------------------------------------------
55
- // 1. List Entries (GET /api/metadata/:type)
69
+ // 1. List Entries (GET {metadataPath}/:type)
56
70
  // ---------------------------------------------------------
57
71
 
58
- // Generic List: /api/metadata/:type
59
- // Also handles legacy /api/metadata (defaults to objects)
60
- const listMatch = url.match(/^\/api\/metadata\/([^\/]+)$/);
61
- const isRootMetadata = url === '/api/metadata';
72
+ // Generic List: {metadataPath}/:type
73
+ // Also handles legacy {metadataPath} (defaults to objects)
74
+ const escapedPath = escapeRegexPath(metadataPath);
75
+ const listMatch = url.match(new RegExp(`^${escapedPath}/([^/]+)$`));
76
+ const isRootMetadata = url === metadataPath;
62
77
 
63
78
  if (method === 'GET' && (listMatch || isRootMetadata)) {
64
79
  let type = isRootMetadata ? 'object' : listMatch![1];
@@ -85,10 +100,10 @@ export function createMetadataHandler(app: IObjectQL) {
85
100
  }
86
101
 
87
102
  // ---------------------------------------------------------
88
- // 2. Get Single Entry (GET /api/metadata/:type/:id)
103
+ // 2. Get Single Entry (GET {metadataPath}/:type/:id)
89
104
  // ---------------------------------------------------------
90
105
 
91
- const detailMatch = url.match(/^\/api\/metadata\/([^\/]+)\/([^\/\?]+)$/);
106
+ const detailMatch = url.match(new RegExp(`^${escapedPath}/([^/]+)/([^/\\?]+)$`));
92
107
 
93
108
  if (method === 'GET' && detailMatch) {
94
109
  let [, type, id] = detailMatch;
@@ -127,7 +142,7 @@ export function createMetadataHandler(app: IObjectQL) {
127
142
  }
128
143
 
129
144
  // ---------------------------------------------------------
130
- // 3. Update Entry (POST/PUT /api/metadata/:type/:id)
145
+ // 3. Update Entry (POST/PUT {metadataPath}/:type/:id)
131
146
  // ---------------------------------------------------------
132
147
  if ((method === 'POST' || method === 'PUT') && detailMatch) {
133
148
  let [, type, id] = detailMatch;
@@ -152,9 +167,9 @@ export function createMetadataHandler(app: IObjectQL) {
152
167
  // 4. Object Sub-resources (Fields, Actions)
153
168
  // ---------------------------------------------------------
154
169
 
155
- // GET /api/metadata/object/:name/fields/:field
170
+ // GET {metadataPath}/object/:name/fields/:field
156
171
  // Legacy path support.
157
- const fieldMatch = url.match(/^\/api\/metadata\/(?:objects|object)\/([^\/]+)\/fields\/([^\/\?]+)$/);
172
+ const fieldMatch = url.match(new RegExp(`^${escapedPath}/(?:objects|object)/([^/]+)/fields/([^/\\?]+)$`));
158
173
  if (method === 'GET' && fieldMatch) {
159
174
  const [, objectName, fieldName] = fieldMatch;
160
175
  const metadata = app.getObject(objectName);
@@ -180,8 +195,8 @@ export function createMetadataHandler(app: IObjectQL) {
180
195
  });
181
196
  }
182
197
 
183
- // GET /api/metadata/object/:name/actions
184
- const actionsMatch = url.match(/^\/api\/metadata\/(?:objects|object)\/([^\/]+)\/actions$/);
198
+ // GET {metadataPath}/object/:name/actions
199
+ const actionsMatch = url.match(new RegExp(`^${escapedPath}/(?:objects|object)/([^/]+)/actions$`));
185
200
  if (method === 'GET' && actionsMatch) {
186
201
  const [, objectName] = actionsMatch;
187
202
  const metadata = app.getObject(objectName);
package/src/openapi.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { IObjectQL, ObjectConfig, FieldType, FieldConfig } from '@objectql/types';
1
+ import { IObjectQL, ObjectConfig, FieldConfig, ApiRouteConfig, resolveApiRoutes } from '@objectql/types';
2
2
 
3
3
  interface OpenAPISchema {
4
4
  openapi: string;
@@ -12,16 +12,17 @@ interface OpenAPISchema {
12
12
  };
13
13
  }
14
14
 
15
- export function generateOpenAPI(app: IObjectQL): OpenAPISchema {
15
+ export function generateOpenAPI(app: IObjectQL, routeConfig?: ApiRouteConfig): OpenAPISchema {
16
16
  const registry = (app as any).metadata; // Direct access or via interface
17
17
  const objects = registry.list('object') as ObjectConfig[];
18
+ const routes = resolveApiRoutes(routeConfig);
18
19
 
19
20
  const paths: Record<string, any> = {};
20
21
  const schemas: Record<string, any> = {};
21
22
 
22
23
 
23
24
  // 1. JSON-RPC Endpoint
24
- paths['/api/objectql'] = {
25
+ paths[routes.rpc] = {
25
26
  post: {
26
27
  summary: 'JSON-RPC Entry Point',
27
28
  description: 'Execute any ObjectQL operation via a JSON body.',
@@ -72,9 +73,9 @@ export function generateOpenAPI(app: IObjectQL): OpenAPISchema {
72
73
  // 3. REST API Paths
73
74
  for (const obj of objects) {
74
75
  const name = obj.name;
75
- const basePath = `/api/data/${name}`; // Standard REST Path
76
+ const basePath = `${routes.data}/${name}`; // Standard REST Path
76
77
 
77
- // GET /api/data/:name (List)
78
+ // GET {dataPath}/:name (List)
78
79
  paths[basePath] = {
79
80
  get: {
80
81
  summary: `List ${name}`,
package/src/utils.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Utility functions for server operations
3
+ */
4
+
5
+ /**
6
+ * Escapes special regex characters in a path string for use in RegExp
7
+ * @param path - The path string to escape
8
+ * @returns Escaped path string safe for use in RegExp
9
+ */
10
+ export function escapeRegexPath(path: string): string {
11
+ return path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12
+ }
13
+
14
+ /**
15
+ * Normalizes a path to ensure it starts with a forward slash
16
+ * @param path - The path string to normalize
17
+ * @returns Normalized path string starting with '/'
18
+ */
19
+ export function normalizePath(path: string): string {
20
+ return path.startsWith('/') ? path : `/${path}`;
21
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Tests for Custom API Route Configuration
3
+ *
4
+ * Validates that API routes can be configured at initialization time
5
+ * instead of being hardcoded.
6
+ */
7
+
8
+ import request from 'supertest';
9
+ import { createServer } from 'http';
10
+ import { ObjectQL } from '@objectql/core';
11
+ import { createNodeHandler, createRESTHandler, createMetadataHandler } from '../src';
12
+ import { Driver } from '@objectql/types';
13
+
14
+ // Simple Mock Driver for testing
15
+ class MockDriver implements Driver {
16
+ private data: Record<string, any[]> = {
17
+ user: [
18
+ { _id: '1', name: 'Alice', email: 'alice@example.com' },
19
+ { _id: '2', name: 'Bob', email: 'bob@example.com' }
20
+ ]
21
+ };
22
+ private nextId = 3;
23
+
24
+ async init() {}
25
+
26
+ async find(objectName: string, query: any) {
27
+ return this.data[objectName] || [];
28
+ }
29
+
30
+ async findOne(objectName: string, id: string | number) {
31
+ const items = this.data[objectName] || [];
32
+ return items.find(item => item._id === String(id)) || null;
33
+ }
34
+
35
+ async create(objectName: string, data: any) {
36
+ const newItem = { _id: String(this.nextId++), ...data };
37
+ if (!this.data[objectName]) {
38
+ this.data[objectName] = [];
39
+ }
40
+ this.data[objectName].push(newItem);
41
+ return newItem;
42
+ }
43
+
44
+ async update(objectName: string, id: string, data: any) {
45
+ const items = this.data[objectName] || [];
46
+ const index = items.findIndex(item => item._id === id);
47
+ if (index >= 0) {
48
+ this.data[objectName][index] = { ...items[index], ...data };
49
+ return 1;
50
+ }
51
+ return 0;
52
+ }
53
+
54
+ async delete(objectName: string, id: string) {
55
+ const items = this.data[objectName] || [];
56
+ const index = items.findIndex(item => item._id === id);
57
+ if (index >= 0) {
58
+ this.data[objectName].splice(index, 1);
59
+ return 1;
60
+ }
61
+ return 0;
62
+ }
63
+
64
+ async count(objectName: string) {
65
+ return (this.data[objectName] || []).length;
66
+ }
67
+
68
+ async createMany(objectName: string, data: any[]) {
69
+ const results = [];
70
+ for (const item of data) {
71
+ results.push(await this.create(objectName, item));
72
+ }
73
+ return results;
74
+ }
75
+
76
+ async updateMany(objectName: string, filters: any, data: any) {
77
+ return 0;
78
+ }
79
+
80
+ async deleteMany(objectName: string, filters: any) {
81
+ return 0;
82
+ }
83
+ }
84
+
85
+ describe('Custom API Routes', () => {
86
+ let app: ObjectQL;
87
+
88
+ beforeEach(async () => {
89
+ app = new ObjectQL({
90
+ datasources: {
91
+ default: new MockDriver()
92
+ }
93
+ });
94
+
95
+ app.registerObject({
96
+ name: 'user',
97
+ label: 'User',
98
+ fields: {
99
+ name: { type: 'text', label: 'Name' },
100
+ email: { type: 'email', label: 'Email' }
101
+ }
102
+ });
103
+
104
+ await app.init();
105
+ });
106
+
107
+ describe('REST Handler with Custom Routes', () => {
108
+ it('should work with custom data path /v1/resources', async () => {
109
+ const customRoutes = {
110
+ data: '/v1/resources'
111
+ };
112
+
113
+ const handler = createRESTHandler(app, { routes: customRoutes });
114
+ const server = createServer(handler);
115
+
116
+ const res = await request(server)
117
+ .get('/v1/resources/user')
118
+ .expect(200);
119
+
120
+ expect(res.body.items).toBeDefined();
121
+ expect(res.body.items.length).toBe(2);
122
+ });
123
+
124
+ it('should not respond to default path when custom path is set', async () => {
125
+ const customRoutes = {
126
+ data: '/v1/resources'
127
+ };
128
+
129
+ const handler = createRESTHandler(app, { routes: customRoutes });
130
+ const server = createServer(handler);
131
+
132
+ await request(server)
133
+ .get('/api/data/user')
134
+ .expect(404);
135
+ });
136
+
137
+ it('should support multiple custom paths', async () => {
138
+ const customRoutes = {
139
+ data: '/api/v2/data',
140
+ metadata: '/api/v2/metadata'
141
+ };
142
+
143
+ const restHandler = createRESTHandler(app, { routes: customRoutes });
144
+ const restServer = createServer(restHandler);
145
+
146
+ const metadataHandler = createMetadataHandler(app, { routes: customRoutes });
147
+ const metadataServer = createServer(metadataHandler);
148
+
149
+ // Test REST API with custom path
150
+ const restRes = await request(restServer)
151
+ .get('/api/v2/data/user')
152
+ .expect(200);
153
+ expect(restRes.body.items).toBeDefined();
154
+
155
+ // Test Metadata API with custom path
156
+ const metadataRes = await request(metadataServer)
157
+ .get('/api/v2/metadata/objects')
158
+ .expect(200);
159
+ expect(metadataRes.body.items).toBeDefined();
160
+ });
161
+ });
162
+
163
+ describe('Node Handler with Custom Routes', () => {
164
+ it('should work with custom RPC path /v1/rpc', async () => {
165
+ const customRoutes = {
166
+ rpc: '/v1/rpc',
167
+ data: '/v1/data'
168
+ };
169
+
170
+ const handler = createNodeHandler(app, { routes: customRoutes });
171
+ const server = createServer(handler);
172
+
173
+ // Test custom RPC endpoint
174
+ const res = await request(server)
175
+ .post('/v1/rpc')
176
+ .send({
177
+ op: 'find',
178
+ object: 'user',
179
+ args: {}
180
+ })
181
+ .expect(200);
182
+
183
+ // NodeHandler returns the full ObjectQLResponse
184
+ expect(res.body.items || res.body.data).toBeDefined();
185
+ });
186
+
187
+ it('should work with custom data path for REST operations', async () => {
188
+ const customRoutes = {
189
+ rpc: '/v1/rpc',
190
+ data: '/v1/data'
191
+ };
192
+
193
+ const handler = createNodeHandler(app, { routes: customRoutes });
194
+ const server = createServer(handler);
195
+
196
+ // Test custom REST endpoint
197
+ const res = await request(server)
198
+ .get('/v1/data/user')
199
+ .expect(200);
200
+
201
+ expect(res.body.items).toBeDefined();
202
+ });
203
+
204
+ it('should work with custom files path', async () => {
205
+ const customRoutes = {
206
+ rpc: '/v1/rpc',
207
+ data: '/v1/data',
208
+ files: '/v1/storage'
209
+ };
210
+
211
+ const handler = createNodeHandler(app, { routes: customRoutes });
212
+ const server = createServer(handler);
213
+
214
+ // Test that file upload path is recognized (will fail without multipart body, but path is recognized)
215
+ const res = await request(server)
216
+ .post('/v1/storage/upload');
217
+
218
+ // Should not be 404 (not found), but may be 400 (bad request) or 500 (server error)
219
+ expect(res.status).not.toBe(404);
220
+ });
221
+ });
222
+
223
+ describe('Metadata Handler with Custom Routes', () => {
224
+ it('should work with custom metadata path /v1/schema', async () => {
225
+ const customRoutes = {
226
+ metadata: '/v1/schema'
227
+ };
228
+
229
+ const handler = createMetadataHandler(app, { routes: customRoutes });
230
+ const server = createServer(handler);
231
+
232
+ const res = await request(server)
233
+ .get('/v1/schema/objects')
234
+ .expect(200);
235
+
236
+ expect(res.body.items).toBeDefined();
237
+ expect(res.body.items.length).toBeGreaterThan(0);
238
+ });
239
+
240
+ it('should support object detail endpoint with custom path', async () => {
241
+ const customRoutes = {
242
+ metadata: '/v1/schema'
243
+ };
244
+
245
+ const handler = createMetadataHandler(app, { routes: customRoutes });
246
+ const server = createServer(handler);
247
+
248
+ const res = await request(server)
249
+ .get('/v1/schema/object/user')
250
+ .expect(200);
251
+
252
+ expect(res.body.name).toBe('user');
253
+ expect(res.body.fields).toBeDefined();
254
+ });
255
+ });
256
+
257
+ describe('Default Routes (Backward Compatibility)', () => {
258
+ it('should use default routes when no custom routes provided', async () => {
259
+ const handler = createRESTHandler(app);
260
+ const server = createServer(handler);
261
+
262
+ const res = await request(server)
263
+ .get('/api/data/user')
264
+ .expect(200);
265
+
266
+ expect(res.body.items).toBeDefined();
267
+ });
268
+
269
+ it('should use default RPC route when no custom routes provided', async () => {
270
+ const handler = createNodeHandler(app);
271
+ const server = createServer(handler);
272
+
273
+ const res = await request(server)
274
+ .post('/api/objectql')
275
+ .send({
276
+ op: 'find',
277
+ object: 'user',
278
+ args: {}
279
+ })
280
+ .expect(200);
281
+
282
+ // NodeHandler returns the full ObjectQLResponse
283
+ expect(res.body.items || res.body.data).toBeDefined();
284
+ });
285
+
286
+ it('should use default metadata route when no custom routes provided', async () => {
287
+ const handler = createMetadataHandler(app);
288
+ const server = createServer(handler);
289
+
290
+ const res = await request(server)
291
+ .get('/api/metadata/objects')
292
+ .expect(200);
293
+
294
+ expect(res.body.items).toBeDefined();
295
+ });
296
+ });
297
+ });