@objectql/api 1.0.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.
package/src/index.ts ADDED
@@ -0,0 +1,294 @@
1
+ import { Router, Request, Response, NextFunction } from 'express';
2
+ import { IObjectQL, ObjectQLContext, UnifiedQuery } from '@objectql/core';
3
+ import * as path from 'path';
4
+ import swaggerUi from 'swagger-ui-express';
5
+ import { generateOpenApiSpec } from './swagger/generator';
6
+
7
+ export interface ObjectQLServerOptions {
8
+ objectql: IObjectQL;
9
+ getContext?: (req: Request, res: Response) => Promise<ObjectQLContext> | ObjectQLContext;
10
+ swagger?: {
11
+ enabled: boolean;
12
+ path?: string; // default: /docs
13
+ }
14
+ }
15
+
16
+ export function createObjectQLRouter(options: ObjectQLServerOptions): Router {
17
+ const router = Router();
18
+ const { objectql, getContext, swagger } = options;
19
+
20
+ if (swagger && swagger.enabled) {
21
+ const docPath = swagger.path || '/docs';
22
+ const spec = generateOpenApiSpec(objectql);
23
+ router.use(docPath, swaggerUi.serve, swaggerUi.setup(spec));
24
+ console.log(`Swagger UI available at ${docPath}`);
25
+ }
26
+
27
+ const getCtx = async (req: Request, res: Response): Promise<ObjectQLContext> => {
28
+ if (getContext) {
29
+ return await getContext(req, res);
30
+ }
31
+ return {
32
+ roles: [],
33
+ object: (name: string) => { throw new Error("Not implemented in default context stub"); },
34
+ transaction: async (cb: any) => cb({} as any),
35
+ sudo: () => ({} as any)
36
+ } as unknown as ObjectQLContext;
37
+ };
38
+
39
+ const getRepo = async (req: Request, res: Response, objectName: string) => {
40
+ const ctx = await getCtx(req, res);
41
+ return ctx.object(objectName);
42
+ };
43
+
44
+ // Helper: Parse UnifiedQuery from Request
45
+ const parseQuery = (req: Request): UnifiedQuery => {
46
+ const query: UnifiedQuery = {};
47
+ const q = req.query;
48
+
49
+ // 1. Fields
50
+ if (q.fields) {
51
+ if (typeof q.fields === 'string') {
52
+ query.fields = q.fields.split(',');
53
+ }
54
+ }
55
+
56
+ // 2. Filters
57
+ if (q.filters) {
58
+ try {
59
+ if (typeof q.filters === 'string') {
60
+ query.filters = JSON.parse(q.filters);
61
+ }
62
+ } catch (e) {
63
+ throw new Error("Invalid filters JSON");
64
+ }
65
+ }
66
+
67
+ // 3. Sort
68
+ // Format: ?sort=item1:asc,item2:desc OR ?sort=[["item1","asc"]]
69
+ if (q.sort) {
70
+ if (typeof q.sort === 'string') {
71
+ // Check if JSON
72
+ if (q.sort.startsWith('[')) {
73
+ try {
74
+ query.sort = JSON.parse(q.sort);
75
+ } catch {}
76
+ } else {
77
+ // split by comma
78
+ query.sort = q.sort.split(',').map(part => {
79
+ const [field, order] = part.split(':');
80
+ return [field, (order || 'asc').toLowerCase() === 'desc' ? 'desc' : 'asc'];
81
+ });
82
+ }
83
+ }
84
+ }
85
+
86
+ // 4. Pagination
87
+ if (q.top || q.limit) {
88
+ query.limit = parseInt((q.top || q.limit) as string);
89
+ }
90
+ if (q.skip || q.offset) {
91
+ query.skip = parseInt((q.skip || q.offset) as string);
92
+ }
93
+
94
+ // 5. Expand
95
+ // ?expand=items,details OR ?expand={"items":{"fields":["name"]}}
96
+ if (q.expand) {
97
+ if (typeof q.expand === 'string') {
98
+ if (q.expand.startsWith('{')) {
99
+ try {
100
+ query.expand = JSON.parse(q.expand);
101
+ } catch {}
102
+ } else {
103
+ query.expand = {};
104
+ q.expand.split(',').forEach(field => {
105
+ // Simple expand implies selecting all or default fields
106
+ // UnifiedQuery expects sub-query
107
+ query.expand![field] = {};
108
+ });
109
+ }
110
+ }
111
+ }
112
+
113
+ return query;
114
+ };
115
+
116
+ // === Special Endpoints (Must come before /:objectName) ===
117
+
118
+ router.get('/_schema', async (req: Request, res: Response) => {
119
+ try {
120
+ const configs = objectql.getConfigs();
121
+ res.json(configs);
122
+ } catch (e: any) {
123
+ res.status(500).json({ error: e.message });
124
+ }
125
+ });
126
+
127
+ // NONE for now, as :objectName is the root.
128
+ // However, we might want /:objectName/count or /:objectName/aggregate.
129
+
130
+
131
+ // === Collection Routes ===
132
+
133
+ // count
134
+ router.get('/:objectName/count', async (req: Request, res: Response) => {
135
+ try {
136
+ const { objectName } = req.params;
137
+ const repo = await getRepo(req, res, objectName);
138
+ let filters = undefined;
139
+ if (req.query.filters) {
140
+ try {
141
+ filters = JSON.parse(req.query.filters as string);
142
+ } catch {
143
+ return res.status(400).json({ error: "Invalid filters JSON" });
144
+ }
145
+ }
146
+ const count = await repo.count(filters);
147
+ res.json({ count });
148
+ } catch (e: any) {
149
+ res.status(500).json({ error: e.message });
150
+ }
151
+ });
152
+
153
+ // aggregate
154
+ router.post('/:objectName/aggregate', async (req: Request, res: Response) => {
155
+ try {
156
+ const { objectName } = req.params;
157
+ const repo = await getRepo(req, res, objectName);
158
+ const pipeline = req.body;
159
+ if (!Array.isArray(pipeline)) {
160
+ return res.status(400).json({ error: "Pipeline must be an array" });
161
+ }
162
+ const result = await repo.aggregate(pipeline);
163
+ res.json(result);
164
+ } catch (e: any) {
165
+ res.status(500).json({ error: e.message });
166
+ }
167
+ });
168
+
169
+ // delete many
170
+ router.delete('/:objectName', async (req: Request, res: Response) => {
171
+ try {
172
+ const { objectName } = req.params;
173
+ if (!req.query.filters) {
174
+ return res.status(400).json({ error: "filters parameter is required for bulk delete" });
175
+ }
176
+ const repo = await getRepo(req, res, objectName);
177
+ let filters;
178
+ try {
179
+ filters = JSON.parse(req.query.filters as string);
180
+ } catch {
181
+ return res.status(400).json({ error: "Invalid filters JSON" });
182
+ }
183
+ const result = await repo.deleteMany(filters);
184
+ res.json(result);
185
+ } catch (e: any) {
186
+ res.status(500).json({ error: e.message });
187
+ }
188
+ });
189
+
190
+ // list
191
+ router.get('/:objectName', async (req: Request, res: Response) => {
192
+ try {
193
+ const { objectName } = req.params;
194
+ const repo = await getRepo(req, res, objectName);
195
+ const query = parseQuery(req);
196
+ const results = await repo.find(query);
197
+ res.json(results);
198
+ } catch (e: any) {
199
+ res.status(500).json({ error: e.message });
200
+ }
201
+ });
202
+
203
+ // create (one or many)
204
+ router.post('/:objectName', async (req: Request, res: Response) => {
205
+ try {
206
+ const { objectName } = req.params;
207
+ const repo = await getRepo(req, res, objectName);
208
+ if (Array.isArray(req.body)) {
209
+ const results = await repo.createMany(req.body);
210
+ res.status(201).json(results);
211
+ } else {
212
+ const result = await repo.create(req.body);
213
+ res.status(201).json(result);
214
+ }
215
+ } catch (e: any) {
216
+ res.status(500).json({ error: e.message });
217
+ }
218
+ });
219
+
220
+ // === Item Routes ===
221
+
222
+ // run action
223
+ router.post('/:objectName/:id/:actionName', async (req: Request, res: Response) => {
224
+ try {
225
+ const { objectName, id, actionName } = req.params;
226
+ const repo = await getRepo(req, res, objectName);
227
+ // Params merging body and id? Usually action needs specific params.
228
+ // Let's pass body as params.
229
+ const params = {
230
+ id,
231
+ ...req.body
232
+ };
233
+ const result = await repo.call(actionName, params);
234
+ res.json(result);
235
+ } catch (e: any) {
236
+ res.status(500).json({ error: e.message });
237
+ }
238
+ });
239
+
240
+ router.get('/:objectName/:id', async (req: Request, res: Response) => {
241
+ try {
242
+ const { objectName, id } = req.params;
243
+ const repo = await getRepo(req, res, objectName);
244
+
245
+ // Allow expand here too?
246
+ // findOne takes id or query.
247
+ // If expand is needed, we should construct a query with filters: {_id: id}
248
+
249
+ if (req.query.expand || req.query.fields) {
250
+ const query = parseQuery(req);
251
+ // Force filter by ID
252
+ // Note: normalized ID vs string ID.
253
+ query.filters = [['_id', '=', id]]; // or 'id' depending on driver
254
+ const results = await repo.find(query);
255
+ if (!results.length) {
256
+ return res.status(404).json({ error: "Not found" });
257
+ }
258
+ return res.json(results[0]);
259
+ }
260
+
261
+ const result = await repo.findOne(id);
262
+ if (!result) {
263
+ return res.status(404).json({ error: "Not found" });
264
+ }
265
+ res.json(result);
266
+ } catch (e: any) {
267
+ res.status(500).json({ error: e.message });
268
+ }
269
+ });
270
+
271
+ router.put('/:objectName/:id', async (req: Request, res: Response) => {
272
+ try {
273
+ const { objectName, id } = req.params;
274
+ const repo = await getRepo(req, res, objectName);
275
+ const result = await repo.update(id, req.body);
276
+ res.json(result);
277
+ } catch (e: any) {
278
+ res.status(500).json({ error: e.message });
279
+ }
280
+ });
281
+
282
+ router.delete('/:objectName/:id', async (req: Request, res: Response) => {
283
+ try {
284
+ const { objectName, id } = req.params;
285
+ const repo = await getRepo(req, res, objectName);
286
+ await repo.delete(id);
287
+ res.status(204).send();
288
+ } catch (e: any) {
289
+ res.status(500).json({ error: e.message });
290
+ }
291
+ });
292
+
293
+ return router;
294
+ }
@@ -0,0 +1,171 @@
1
+ import { IObjectQL, ObjectConfig, FieldConfig } from '@objectql/core';
2
+
3
+ export function generateOpenApiSpec(objectql: IObjectQL) {
4
+ const configs = objectql.getConfigs();
5
+
6
+ const paths: any = {};
7
+ const schemas: any = {};
8
+
9
+ Object.values(configs).forEach((config) => {
10
+ const objectName = config.name;
11
+ const schemaName = objectName;
12
+
13
+ // 1. Define Schema
14
+ schemas[schemaName] = {
15
+ type: 'object',
16
+ properties: mapFieldsToProperties(config.fields)
17
+ };
18
+
19
+ // 2. Define Paths
20
+ const basePath = `/api/${objectName}`;
21
+
22
+ // GET /api/{obj} - List
23
+ paths[basePath] = {
24
+ get: {
25
+ summary: `List ${config.label || objectName}`,
26
+ tags: [config.label || objectName],
27
+ parameters: [
28
+ {
29
+ name: 'fields',
30
+ in: 'query',
31
+ description: 'Comma separated fields to return',
32
+ schema: { type: 'string' }
33
+ },
34
+ {
35
+ name: 'filters',
36
+ in: 'query',
37
+ description: 'JSON string of filters',
38
+ schema: { type: 'string' }
39
+ },
40
+ ],
41
+ responses: {
42
+ 200: {
43
+ description: 'Success',
44
+ content: {
45
+ 'application/json': {
46
+ schema: {
47
+ type: 'array',
48
+ items: { $ref: `#/components/schemas/${schemaName}` }
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ },
55
+ // POST /api/{obj} - Create
56
+ post: {
57
+ summary: `Create ${config.label || objectName}`,
58
+ tags: [config.label || objectName],
59
+ requestBody: {
60
+ required: true,
61
+ content: {
62
+ 'application/json': {
63
+ schema: { $ref: `#/components/schemas/${schemaName}` }
64
+ }
65
+ }
66
+ },
67
+ responses: {
68
+ 201: {
69
+ description: 'Created',
70
+ content: {
71
+ 'application/json': {
72
+ schema: { $ref: `#/components/schemas/${schemaName}` }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ };
79
+
80
+ // /{id} operations
81
+ const itemPath = `${basePath}/{id}`;
82
+ paths[itemPath] = {
83
+ // GET - Retrieve
84
+ get: {
85
+ summary: `Get ${config.label || objectName}`,
86
+ tags: [config.label || objectName],
87
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
88
+ responses: {
89
+ 200: {
90
+ description: 'Success',
91
+ content: {
92
+ 'application/json': {
93
+ schema: { $ref: `#/components/schemas/${schemaName}` }
94
+ }
95
+ }
96
+ }
97
+ }
98
+ },
99
+ // PUT - Update
100
+ put: {
101
+ summary: `Update ${config.label || objectName}`,
102
+ tags: [config.label || objectName],
103
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
104
+ requestBody: {
105
+ required: true,
106
+ content: {
107
+ 'application/json': {
108
+ schema: {
109
+ type: 'object',
110
+ properties: mapFieldsToProperties(config.fields)
111
+ }
112
+ }
113
+ }
114
+ },
115
+ responses: {
116
+ 200: { description: 'Updated' }
117
+ }
118
+ },
119
+ // DELETE
120
+ delete: {
121
+ summary: `Delete ${config.label || objectName}`,
122
+ tags: [config.label || objectName],
123
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
124
+ responses: {
125
+ 200: { description: 'Deleted' }
126
+ }
127
+ }
128
+ };
129
+
130
+ });
131
+
132
+ return {
133
+ openapi: '3.0.0',
134
+ info: {
135
+ title: 'ObjectQL API',
136
+ version: '1.0.0',
137
+ },
138
+ paths,
139
+ components: {
140
+ schemas
141
+ }
142
+ };
143
+ }
144
+
145
+ function mapFieldsToProperties(fields: Record<string, FieldConfig>) {
146
+ const props: any = {};
147
+ Object.entries(fields).forEach(([key, field]) => {
148
+ props[key] = {
149
+ type: mapType(field.type),
150
+ description: field.label
151
+ };
152
+ });
153
+ // Add _id
154
+ props['_id'] = { type: 'string' };
155
+ return props;
156
+ }
157
+
158
+ function mapType(objectType: string) {
159
+ switch (objectType) {
160
+ case 'number':
161
+ case 'currency':
162
+ return 'number';
163
+ case 'boolean':
164
+ return 'boolean';
165
+ case 'date':
166
+ case 'datetime':
167
+ return 'string'; // format: date-time ideally
168
+ default:
169
+ return 'string';
170
+ }
171
+ }
@@ -0,0 +1,150 @@
1
+ import { createObjectQLRouter } from '../src';
2
+ import { IObjectQL } from '@objectql/core';
3
+ import express from 'express';
4
+ import request from 'supertest';
5
+
6
+ describe('createObjectQLRouter', () => {
7
+ let mockObjectQL: any;
8
+ let mockRepo: any;
9
+ let mockGetContext: jest.Mock;
10
+ let app: express.Express;
11
+
12
+ beforeEach(() => {
13
+ // Mock Repository methods
14
+ mockRepo = {
15
+ find: jest.fn().mockResolvedValue([{ id: 1, name: 'Test' }]),
16
+ findOne: jest.fn().mockResolvedValue({ id: 1, name: 'Test' }),
17
+ count: jest.fn().mockResolvedValue(10),
18
+ aggregate: jest.fn().mockResolvedValue([]),
19
+ create: jest.fn().mockResolvedValue({ id: 2, name: 'New' }),
20
+ createMany: jest.fn().mockResolvedValue([{ id: 2, name: 'New' }]),
21
+ update: jest.fn().mockResolvedValue({ id: 1, name: 'Updated' }),
22
+ delete: jest.fn().mockResolvedValue(undefined),
23
+ deleteMany: jest.fn().mockResolvedValue({ deletedCount: 5 }),
24
+ call: jest.fn().mockResolvedValue({ result: 'ok' })
25
+ };
26
+
27
+ mockObjectQL = {
28
+ init: jest.fn(),
29
+ getConfigs: jest.fn().mockReturnValue([{ name: 'user', fields: {} }])
30
+ };
31
+
32
+ // Mock getContext to return a context that returns our mockRepo
33
+ mockGetContext = jest.fn().mockResolvedValue({
34
+ object: jest.fn().mockReturnValue(mockRepo),
35
+ roles: [],
36
+ transaction: jest.fn(),
37
+ sudo: jest.fn()
38
+ });
39
+
40
+ app = express();
41
+ app.use(express.json()); // Important for POST/PUT tests
42
+ });
43
+
44
+ it('should create a router', () => {
45
+ const router = createObjectQLRouter({ objectql: mockObjectQL });
46
+ expect(router).toBeDefined();
47
+ expect(typeof router).toBe('function');
48
+ });
49
+
50
+ it('should mount swagger if enabled', async () => {
51
+ const router = createObjectQLRouter({
52
+ objectql: mockObjectQL,
53
+ swagger: { enabled: true }
54
+ });
55
+
56
+ app.use(router);
57
+ expect(router).toBeDefined();
58
+ });
59
+
60
+ describe('REST API Endpoints', () => {
61
+ beforeEach(() => {
62
+ const router = createObjectQLRouter({
63
+ objectql: mockObjectQL,
64
+ getContext: mockGetContext
65
+ });
66
+ app.use('/api', router);
67
+ });
68
+
69
+ it('GET /_schema should return configs', async () => {
70
+ const res = await request(app).get('/api/_schema');
71
+ expect(res.status).toBe(200);
72
+ expect(res.body).toEqual([{ name: 'user', fields: {} }]);
73
+ expect(mockObjectQL.getConfigs).toHaveBeenCalled();
74
+ });
75
+
76
+ it('GET /:objectName/count should return count', async () => {
77
+ const res = await request(app)
78
+ .get('/api/users/count')
79
+ .query({ filters: JSON.stringify([['age', '>', 20]]) });
80
+
81
+ expect(res.status).toBe(200);
82
+ expect(res.body).toEqual({ count: 10 });
83
+ expect(mockRepo.count).toHaveBeenCalledWith([['age', '>', 20]]);
84
+ });
85
+
86
+ it('GET /:objectName should list objects', async () => {
87
+ const res = await request(app)
88
+ .get('/api/users')
89
+ .query({
90
+ limit: 10,
91
+ skip: 0,
92
+ sort: 'name:asc',
93
+ fields: 'id,name'
94
+ });
95
+
96
+ expect(res.status).toBe(200);
97
+ expect(res.body).toHaveLength(1);
98
+ expect(mockRepo.find).toHaveBeenCalledWith(expect.objectContaining({
99
+ limit: 10,
100
+ skip: 0,
101
+ // sort parsing: 'name:asc' -> [['name', 'asc']]
102
+ sort: [['name', 'asc']],
103
+ fields: ['id', 'name']
104
+ }));
105
+ });
106
+
107
+ it('GET /:objectName/:id should get one object', async () => {
108
+ const res = await request(app).get('/api/users/1');
109
+ expect(res.status).toBe(200);
110
+ expect(res.body).toEqual({ id: 1, name: 'Test' });
111
+ expect(mockRepo.findOne).toHaveBeenCalledWith('1');
112
+ });
113
+
114
+ it('POST /:objectName should create object', async () => {
115
+ const res = await request(app)
116
+ .post('/api/users')
117
+ .send({ name: 'New' });
118
+
119
+ expect(res.status).toBe(201);
120
+ expect(res.body).toEqual({ id: 2, name: 'New' });
121
+ expect(mockRepo.create).toHaveBeenCalledWith({ name: 'New' });
122
+ });
123
+
124
+ it('PUT /:objectName/:id should update object', async () => {
125
+ const res = await request(app)
126
+ .put('/api/users/1')
127
+ .send({ name: 'Updated' });
128
+
129
+ expect(res.status).toBe(200);
130
+ expect(res.body).toEqual({ id: 1, name: 'Updated' });
131
+ expect(mockRepo.update).toHaveBeenCalledWith('1', { name: 'Updated' });
132
+ });
133
+
134
+ it('DELETE /:objectName/:id should delete object', async () => {
135
+ const res = await request(app).delete('/api/users/1');
136
+ expect(res.status).toBe(204);
137
+ expect(mockRepo.delete).toHaveBeenCalledWith('1');
138
+ });
139
+
140
+ it('POST /:objectName/:id/:actionName should execute action', async () => {
141
+ const res = await request(app)
142
+ .post('/api/users/1/activate')
143
+ .send({ reason: 'testing' });
144
+
145
+ expect(res.status).toBe(200);
146
+ expect(res.body).toEqual({ result: 'ok' });
147
+ expect(mockRepo.call).toHaveBeenCalledWith('activate', { id: '1', reason: 'testing' });
148
+ });
149
+ });
150
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"]
8
+ }