@objectql/server 1.8.3 → 1.8.4

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.
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
+ });