@objectql/server 1.7.3 → 1.8.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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -118
- package/dist/adapters/graphql.d.ts +17 -0
- package/dist/adapters/graphql.js +460 -0
- package/dist/adapters/graphql.js.map +1 -0
- package/dist/adapters/rest.d.ts +7 -5
- package/dist/adapters/rest.js +51 -14
- package/dist/adapters/rest.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/server.js +33 -0
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +6 -4
- package/src/adapters/graphql.ts +523 -0
- package/src/adapters/rest.ts +48 -14
- package/src/index.ts +2 -0
- package/src/server.ts +42 -0
- package/src/types.ts +1 -1
- package/test/graphql.test.ts +439 -0
- package/test/rest-advanced.test.ts +455 -0
- package/test/rest.test.ts +236 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
}
|
package/src/adapters/rest.ts
CHANGED
|
@@ -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
|
|
68
|
-
* - GET /api/data/:object/:id
|
|
69
|
-
* - POST /api/data/:object
|
|
70
|
-
* -
|
|
71
|
-
* -
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
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
|