@objectql/server 1.7.3 → 1.8.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,523 @@
1
+ import { IObjectQL, ObjectConfig, FieldConfig } from '@objectql/types';
2
+ import { ObjectQLServer } from '../server';
3
+ import { ErrorCode } from '../types';
4
+ import { IncomingMessage, ServerResponse } from 'http';
5
+ import { graphql, GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLInt, GraphQLFloat, GraphQLBoolean, GraphQLList, GraphQLNonNull, GraphQLInputObjectType, GraphQLFieldConfigMap, GraphQLOutputType, GraphQLInputType } from 'graphql';
6
+
7
+ /**
8
+ * Normalize ObjectQL response to use 'id' instead of '_id'
9
+ */
10
+ function normalizeId(data: unknown): unknown {
11
+ if (!data) return data;
12
+
13
+ if (Array.isArray(data)) {
14
+ return data.map(item => normalizeId(item));
15
+ }
16
+
17
+ if (typeof data === 'object') {
18
+ const normalized = { ...data as Record<string, unknown> };
19
+
20
+ // Map _id to id if present
21
+ if ('_id' in normalized) {
22
+ normalized.id = normalized._id;
23
+ delete normalized._id;
24
+ }
25
+
26
+ // Remove '@type' field as it's not needed in GraphQL
27
+ delete normalized['@type'];
28
+
29
+ return normalized;
30
+ }
31
+
32
+ return data;
33
+ }
34
+
35
+ /**
36
+ * Map ObjectQL field types to GraphQL types
37
+ */
38
+ function mapFieldTypeToGraphQL(field: FieldConfig, isInput: boolean = false): GraphQLOutputType | GraphQLInputType {
39
+ const type = field.type;
40
+
41
+ switch (type) {
42
+ case 'text':
43
+ case 'textarea':
44
+ case 'markdown':
45
+ case 'html':
46
+ case 'email':
47
+ case 'url':
48
+ case 'phone':
49
+ case 'password':
50
+ return GraphQLString;
51
+ case 'number':
52
+ case 'currency':
53
+ case 'percent':
54
+ return GraphQLFloat;
55
+ case 'auto_number':
56
+ return GraphQLInt;
57
+ case 'boolean':
58
+ return GraphQLBoolean;
59
+ case 'date':
60
+ case 'datetime':
61
+ case 'time':
62
+ return GraphQLString; // ISO 8601 string format
63
+ case 'select':
64
+ // For select fields, we could create an enum type, but for simplicity use String
65
+ return GraphQLString;
66
+ case 'lookup':
67
+ case 'master_detail':
68
+ // For relationships, return ID reference
69
+ return GraphQLString;
70
+ case 'file':
71
+ case 'image':
72
+ // File fields return metadata object (simplified as String for now)
73
+ return GraphQLString;
74
+ case 'object':
75
+ case 'formula':
76
+ case 'summary':
77
+ case 'location':
78
+ case 'vector':
79
+ case 'grid':
80
+ // Return as JSON string
81
+ return GraphQLString;
82
+ default:
83
+ return GraphQLString;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Sanitize field/object names to be valid GraphQL identifiers
89
+ * GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/
90
+ */
91
+ function sanitizeGraphQLName(name: string): string {
92
+ // Replace invalid characters with underscores
93
+ let sanitized = name.replace(/[^_a-zA-Z0-9]/g, '_');
94
+
95
+ // Ensure it starts with a letter or underscore
96
+ if (!/^[_a-zA-Z]/.test(sanitized)) {
97
+ sanitized = '_' + sanitized;
98
+ }
99
+
100
+ return sanitized;
101
+ }
102
+
103
+ /**
104
+ * Generate GraphQL schema from ObjectQL metadata
105
+ */
106
+ export function generateGraphQLSchema(app: IObjectQL): GraphQLSchema {
107
+ const objects = app.metadata.list<ObjectConfig>('object');
108
+
109
+ // Validate that there are objects to generate schema from
110
+ if (!objects || objects.length === 0) {
111
+ // Create a minimal schema with a dummy query to avoid GraphQL error
112
+ return new GraphQLSchema({
113
+ query: new GraphQLObjectType({
114
+ name: 'Query',
115
+ fields: {
116
+ _schema: {
117
+ type: GraphQLString,
118
+ description: 'Schema introspection placeholder',
119
+ resolve: () => 'No objects registered in ObjectQL metadata'
120
+ }
121
+ }
122
+ })
123
+ });
124
+ }
125
+
126
+ const typeMap: Record<string, GraphQLObjectType> = {};
127
+ const inputTypeMap: Record<string, GraphQLInputObjectType> = {};
128
+ const deleteResultTypeMap: Record<string, GraphQLObjectType> = {};
129
+
130
+ // Create a shared ObjectQL server instance to reuse across resolvers
131
+ // This is safe because ObjectQLServer is stateless - it only holds a reference to the app
132
+ // and creates fresh contexts for each request via handle()
133
+ const server = new ObjectQLServer(app);
134
+
135
+ // First pass: Create all object types
136
+ for (const config of objects) {
137
+ const objectName = config.name;
138
+
139
+ // Skip if no name or fields defined
140
+ if (!objectName || !config.fields || Object.keys(config.fields).length === 0) {
141
+ continue;
142
+ }
143
+
144
+ const sanitizedTypeName = sanitizeGraphQLName(objectName.charAt(0).toUpperCase() + objectName.slice(1));
145
+
146
+ // Create output type
147
+ const fields: GraphQLFieldConfigMap<any, any> = {
148
+ id: { type: new GraphQLNonNull(GraphQLString) }
149
+ };
150
+
151
+ for (const [fieldName, fieldConfig] of Object.entries(config.fields)) {
152
+ const sanitizedFieldName = sanitizeGraphQLName(fieldName);
153
+ const gqlType = mapFieldTypeToGraphQL(fieldConfig, false) as GraphQLOutputType;
154
+ fields[sanitizedFieldName] = {
155
+ type: fieldConfig.required ? new GraphQLNonNull(gqlType) : gqlType,
156
+ description: fieldConfig.label || fieldName
157
+ };
158
+ }
159
+
160
+ typeMap[objectName] = new GraphQLObjectType({
161
+ name: sanitizedTypeName,
162
+ description: config.label || objectName,
163
+ fields
164
+ });
165
+
166
+ // Create input type for mutations
167
+ const inputFields: Record<string, any> = {};
168
+
169
+ for (const [fieldName, fieldConfig] of Object.entries(config.fields)) {
170
+ const sanitizedFieldName = sanitizeGraphQLName(fieldName);
171
+ const gqlType = mapFieldTypeToGraphQL(fieldConfig, true) as GraphQLInputType;
172
+ inputFields[sanitizedFieldName] = {
173
+ type: gqlType,
174
+ description: fieldConfig.label || fieldName
175
+ };
176
+ }
177
+
178
+ inputTypeMap[objectName] = new GraphQLInputObjectType({
179
+ name: sanitizedTypeName + 'Input',
180
+ description: `Input type for ${config.label || objectName}`,
181
+ fields: inputFields
182
+ });
183
+
184
+ // Create delete result type (shared across all delete mutations for this object)
185
+ deleteResultTypeMap[objectName] = new GraphQLObjectType({
186
+ name: 'Delete' + sanitizedTypeName + 'Result',
187
+ fields: {
188
+ id: { type: new GraphQLNonNull(GraphQLString) },
189
+ deleted: { type: new GraphQLNonNull(GraphQLBoolean) }
190
+ }
191
+ });
192
+ }
193
+
194
+ // Build query root
195
+ const queryFields: GraphQLFieldConfigMap<any, any> = {};
196
+
197
+ for (const config of objects) {
198
+ const objectName = config.name;
199
+
200
+ if (!objectName || !typeMap[objectName]) continue;
201
+
202
+ // Query single record by ID
203
+ queryFields[objectName] = {
204
+ type: typeMap[objectName],
205
+ args: {
206
+ id: { type: new GraphQLNonNull(GraphQLString) }
207
+ },
208
+ resolve: async (_, args) => {
209
+ const result = await server.handle({
210
+ op: 'findOne',
211
+ object: objectName,
212
+ args: args.id
213
+ });
214
+
215
+ if (result.error) {
216
+ throw new Error(result.error.message);
217
+ }
218
+
219
+ return normalizeId(result);
220
+ }
221
+ };
222
+
223
+ // Query list of records
224
+ // Using 'List' suffix to avoid naming conflicts and handle irregular plurals
225
+ queryFields[objectName + 'List'] = {
226
+ type: new GraphQLList(typeMap[objectName]),
227
+ args: {
228
+ limit: { type: GraphQLInt },
229
+ skip: { type: GraphQLInt },
230
+ filters: { type: GraphQLString }, // JSON string
231
+ fields: { type: new GraphQLList(GraphQLString) },
232
+ sort: { type: GraphQLString } // JSON string
233
+ },
234
+ resolve: async (_, args) => {
235
+ const queryArgs: any = {};
236
+ if (args.limit) queryArgs.limit = args.limit;
237
+ if (args.skip) queryArgs.skip = args.skip;
238
+ if (args.fields) queryArgs.fields = args.fields;
239
+ if (args.filters) {
240
+ try {
241
+ queryArgs.filters = JSON.parse(args.filters);
242
+ } catch (e) {
243
+ throw new Error('Invalid filters JSON');
244
+ }
245
+ }
246
+ if (args.sort) {
247
+ try {
248
+ queryArgs.sort = JSON.parse(args.sort);
249
+ } catch (e) {
250
+ throw new Error('Invalid sort JSON');
251
+ }
252
+ }
253
+
254
+ const result = await server.handle({
255
+ op: 'find',
256
+ object: objectName,
257
+ args: queryArgs
258
+ });
259
+
260
+ if (result.error) {
261
+ throw new Error(result.error.message);
262
+ }
263
+
264
+ return normalizeId(result.items || []);
265
+ }
266
+ };
267
+ }
268
+
269
+ const queryType = new GraphQLObjectType({
270
+ name: 'Query',
271
+ fields: queryFields
272
+ });
273
+
274
+ // Build mutation root
275
+ const mutationFields: GraphQLFieldConfigMap<any, any> = {};
276
+
277
+ for (const config of objects) {
278
+ const objectName = config.name;
279
+
280
+ if (!objectName || !typeMap[objectName] || !inputTypeMap[objectName]) continue;
281
+
282
+ const capitalizedName = sanitizeGraphQLName(objectName.charAt(0).toUpperCase() + objectName.slice(1));
283
+
284
+ // Create mutation
285
+ mutationFields['create' + capitalizedName] = {
286
+ type: typeMap[objectName],
287
+ args: {
288
+ input: { type: new GraphQLNonNull(inputTypeMap[objectName]) }
289
+ },
290
+ resolve: async (_, args) => {
291
+ const result = await server.handle({
292
+ op: 'create',
293
+ object: objectName,
294
+ args: args.input
295
+ });
296
+
297
+ if (result.error) {
298
+ throw new Error(result.error.message);
299
+ }
300
+
301
+ return normalizeId(result);
302
+ }
303
+ };
304
+
305
+ // Update mutation
306
+ mutationFields['update' + capitalizedName] = {
307
+ type: typeMap[objectName],
308
+ args: {
309
+ id: { type: new GraphQLNonNull(GraphQLString) },
310
+ input: { type: new GraphQLNonNull(inputTypeMap[objectName]) }
311
+ },
312
+ resolve: async (_, args) => {
313
+ const result = await server.handle({
314
+ op: 'update',
315
+ object: objectName,
316
+ args: {
317
+ id: args.id,
318
+ data: args.input
319
+ }
320
+ });
321
+
322
+ if (result.error) {
323
+ throw new Error(result.error.message);
324
+ }
325
+
326
+ return normalizeId(result);
327
+ }
328
+ };
329
+
330
+ // Delete mutation - use shared delete result type
331
+ mutationFields['delete' + capitalizedName] = {
332
+ type: deleteResultTypeMap[objectName],
333
+ args: {
334
+ id: { type: new GraphQLNonNull(GraphQLString) }
335
+ },
336
+ resolve: async (_, args) => {
337
+ const result = await server.handle({
338
+ op: 'delete',
339
+ object: objectName,
340
+ args: { id: args.id }
341
+ });
342
+
343
+ if (result.error) {
344
+ throw new Error(result.error.message);
345
+ }
346
+
347
+ return result;
348
+ }
349
+ };
350
+ }
351
+
352
+ const mutationType = new GraphQLObjectType({
353
+ name: 'Mutation',
354
+ fields: mutationFields
355
+ });
356
+
357
+ return new GraphQLSchema({
358
+ query: queryType,
359
+ mutation: mutationType
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Parse GraphQL request body
365
+ */
366
+ function readBody(req: IncomingMessage): Promise<any> {
367
+ return new Promise((resolve, reject) => {
368
+ let body = '';
369
+ req.on('data', chunk => body += chunk.toString());
370
+ req.on('end', () => {
371
+ if (!body) return resolve({});
372
+ try {
373
+ resolve(JSON.parse(body));
374
+ } catch (e) {
375
+ reject(new Error('Invalid JSON'));
376
+ }
377
+ });
378
+ req.on('error', reject);
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Send JSON response
384
+ */
385
+ function sendJSON(res: ServerResponse, statusCode: number, data: any) {
386
+ res.setHeader('Content-Type', 'application/json');
387
+ res.statusCode = statusCode;
388
+ res.end(JSON.stringify(data));
389
+ }
390
+
391
+ /**
392
+ * Creates a GraphQL HTTP request handler for ObjectQL
393
+ *
394
+ * Endpoints:
395
+ * - POST /api/graphql - GraphQL queries and mutations
396
+ * - GET /api/graphql - GraphQL queries via URL parameters
397
+ */
398
+ export function createGraphQLHandler(app: IObjectQL) {
399
+ // Generate schema once - Note: Schema is static after handler creation.
400
+ // If metadata changes at runtime, create a new handler or regenerate the schema.
401
+ const schema = generateGraphQLSchema(app);
402
+
403
+ return async (req: IncomingMessage & { body?: any }, res: ServerResponse) => {
404
+ try {
405
+ // CORS headers
406
+ const requestOrigin = req.headers.origin;
407
+ const configuredOrigin = process.env.OBJECTQL_CORS_ORIGIN;
408
+ const isProduction = process.env.NODE_ENV === 'production';
409
+
410
+ if (!isProduction) {
411
+ res.setHeader('Access-Control-Allow-Origin', configuredOrigin || '*');
412
+ } else if (configuredOrigin && (!requestOrigin || requestOrigin === configuredOrigin)) {
413
+ res.setHeader('Access-Control-Allow-Origin', configuredOrigin);
414
+ }
415
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
416
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
417
+
418
+ if (req.method === 'OPTIONS') {
419
+ res.statusCode = 200;
420
+ res.end();
421
+ return;
422
+ }
423
+
424
+ const url = req.url || '';
425
+ const method = req.method || 'POST';
426
+
427
+ if (method !== 'GET' && method !== 'POST') {
428
+ sendJSON(res, 405, {
429
+ errors: [{
430
+ message: 'Method not allowed. Use GET or POST.'
431
+ }]
432
+ });
433
+ return;
434
+ }
435
+
436
+ let query: string = '';
437
+ let variables: any = null;
438
+ let operationName: string | null = null;
439
+
440
+ if (method === 'GET') {
441
+ // Parse query string for GET requests
442
+ const urlObj = new URL(url, `http://${req.headers.host || 'localhost'}`);
443
+ query = urlObj.searchParams.get('query') || '';
444
+ const varsParam = urlObj.searchParams.get('variables');
445
+ if (varsParam) {
446
+ try {
447
+ variables = JSON.parse(varsParam);
448
+ } catch (e) {
449
+ sendJSON(res, 400, {
450
+ errors: [{
451
+ message: 'Invalid variables JSON'
452
+ }]
453
+ });
454
+ return;
455
+ }
456
+ }
457
+ operationName = urlObj.searchParams.get('operationName');
458
+ } else {
459
+ // Parse body for POST requests
460
+ const body = req.body || await readBody(req);
461
+ query = body.query || '';
462
+ variables = body.variables || null;
463
+ operationName = body.operationName || null;
464
+ }
465
+
466
+ if (!query) {
467
+ sendJSON(res, 400, {
468
+ errors: [{
469
+ message: 'Must provide query string'
470
+ }]
471
+ });
472
+ return;
473
+ }
474
+
475
+ // Execute GraphQL query
476
+ const result = await graphql({
477
+ schema,
478
+ source: query,
479
+ variableValues: variables,
480
+ operationName,
481
+ contextValue: { app }
482
+ });
483
+
484
+ sendJSON(res, 200, result);
485
+
486
+ } catch (e: any) {
487
+ console.error('[GraphQL Handler] Error:', e);
488
+
489
+ const errorResponse: {
490
+ errors: Array<{
491
+ message: string;
492
+ extensions: {
493
+ code: ErrorCode;
494
+ debug?: {
495
+ message?: string;
496
+ stack?: string;
497
+ };
498
+ };
499
+ }>;
500
+ } = {
501
+ errors: [{
502
+ message: 'Internal server error',
503
+ extensions: {
504
+ code: ErrorCode.INTERNAL_ERROR
505
+ }
506
+ }]
507
+ };
508
+
509
+ // In non-production environments, include additional error details to aid debugging
510
+ if (typeof process !== 'undefined' &&
511
+ process.env &&
512
+ process.env.NODE_ENV !== 'production') {
513
+ const firstError = errorResponse.errors[0];
514
+ firstError.extensions.debug = {
515
+ message: e && typeof e.message === 'string' ? e.message : undefined,
516
+ stack: e && typeof e.stack === 'string' ? e.stack : undefined
517
+ };
518
+ }
519
+
520
+ sendJSON(res, 500, errorResponse);
521
+ }
522
+ };
523
+ }
@@ -64,11 +64,13 @@ function sendJSON(res: ServerResponse, statusCode: number, data: any) {
64
64
  * Creates a REST-style HTTP request handler for ObjectQL
65
65
  *
66
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
70
- * - PUT /api/data/:object/:id - Update record
71
- * - DELETE /api/data/:object/:id - Delete record
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
72
74
  */
73
75
  export function createRESTHandler(app: IObjectQL) {
74
76
  const server = new ObjectQLServer(app);
@@ -99,7 +101,7 @@ export function createRESTHandler(app: IObjectQL) {
99
101
  const url = req.url || '';
100
102
  const method = req.method || 'GET';
101
103
 
102
- // Parse URL: /api/data/:object or /api/data/:object/:id
104
+ // Parse URL: /api/data/:object or /api/data/:object/:id or /api/data/:object/bulk-*
103
105
  const match = url.match(/^\/api\/data\/([^\/\?]+)(?:\/([^\/\?]+))?(\?.*)?$/);
104
106
 
105
107
  if (!match) {
@@ -161,13 +163,43 @@ export function createRESTHandler(app: IObjectQL) {
161
163
  break;
162
164
 
163
165
  case 'POST':
164
- // POST /api/data/:object - Create record
165
166
  const createBody = req.body || await readBody(req);
166
- qlRequest = {
167
- op: 'create',
168
- object: objectName,
169
- args: createBody
170
- };
167
+
168
+ // Check for bulk operations
169
+ if (id === 'bulk-update') {
170
+ // POST /api/data/:object/bulk-update - Update many records
171
+ qlRequest = {
172
+ op: 'updateMany',
173
+ object: objectName,
174
+ args: {
175
+ filters: createBody.filters,
176
+ data: createBody.data
177
+ }
178
+ };
179
+ } else if (id === 'bulk-delete') {
180
+ // POST /api/data/:object/bulk-delete - Delete many records
181
+ qlRequest = {
182
+ op: 'deleteMany',
183
+ object: objectName,
184
+ args: {
185
+ filters: createBody.filters || {}
186
+ }
187
+ };
188
+ } else if (Array.isArray(createBody)) {
189
+ // POST /api/data/:object with array - Create many records
190
+ qlRequest = {
191
+ op: 'createMany',
192
+ object: objectName,
193
+ args: createBody
194
+ };
195
+ } else {
196
+ // POST /api/data/:object - Create single record
197
+ qlRequest = {
198
+ op: 'create',
199
+ object: objectName,
200
+ args: createBody
201
+ };
202
+ }
171
203
  break;
172
204
 
173
205
  case 'PUT':
@@ -252,8 +284,10 @@ export function createRESTHandler(app: IObjectQL) {
252
284
  default:
253
285
  statusCode = 500;
254
286
  }
255
- } else if (method === 'POST') {
256
- statusCode = 201; // Created
287
+ } else if (method === 'POST' && qlRequest.op === 'create') {
288
+ statusCode = 201; // Created - only for single create
289
+ } else if (method === 'POST' && qlRequest.op === 'createMany') {
290
+ statusCode = 201; // Created - for bulk create
257
291
  }
258
292
 
259
293
  sendJSON(res, statusCode, result);
package/src/index.ts CHANGED
@@ -8,3 +8,5 @@ export * from './studio';
8
8
  export * from './adapters/node';
9
9
  // Export REST adapter
10
10
  export * from './adapters/rest';
11
+ // Export GraphQL adapter
12
+ export * from './adapters/graphql';
package/src/server.ts CHANGED
@@ -107,6 +107,48 @@ export class ObjectQLServer {
107
107
  return { ...result, '@type': req.object };
108
108
  }
109
109
  return result;
110
+ case 'createMany':
111
+ // Bulk create operation
112
+ if (!Array.isArray(req.args)) {
113
+ return this.errorResponse(
114
+ ErrorCode.INVALID_REQUEST,
115
+ 'createMany expects args to be an array of records'
116
+ );
117
+ }
118
+ result = await repo.createMany(req.args);
119
+ return {
120
+ items: result,
121
+ count: Array.isArray(result) ? result.length : 0,
122
+ '@type': req.object
123
+ };
124
+ case 'updateMany':
125
+ // Bulk update operation
126
+ // args should be { filters, data }
127
+ if (!req.args || typeof req.args !== 'object' || !req.args.data) {
128
+ return this.errorResponse(
129
+ ErrorCode.INVALID_REQUEST,
130
+ 'updateMany expects args to be an object with { filters, data }'
131
+ );
132
+ }
133
+ result = await repo.updateMany(req.args.filters || {}, req.args.data);
134
+ return {
135
+ count: result,
136
+ '@type': req.object
137
+ };
138
+ case 'deleteMany':
139
+ // Bulk delete operation
140
+ // args should be { filters }
141
+ if (!req.args || typeof req.args !== 'object') {
142
+ return this.errorResponse(
143
+ ErrorCode.INVALID_REQUEST,
144
+ 'deleteMany expects args to be an object with { filters }'
145
+ );
146
+ }
147
+ result = await repo.deleteMany(req.args.filters || {});
148
+ return {
149
+ count: result,
150
+ '@type': req.object
151
+ };
110
152
  default:
111
153
  return this.errorResponse(
112
154
  ErrorCode.INVALID_REQUEST,
package/src/types.ts CHANGED
@@ -37,7 +37,7 @@ export interface ObjectQLRequest {
37
37
  };
38
38
 
39
39
  // The actual operation
40
- op: 'find' | 'findOne' | 'create' | 'update' | 'delete' | 'count' | 'action';
40
+ op: 'find' | 'findOne' | 'create' | 'update' | 'delete' | 'count' | 'action' | 'createMany' | 'updateMany' | 'deleteMany';
41
41
  object: string;
42
42
 
43
43
  // Arguments