@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/CHANGELOG.md +12 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +278 -0
- package/dist/index.js.map +1 -0
- package/dist/swagger/generator.d.ts +12 -0
- package/dist/swagger/generator.js +163 -0
- package/dist/swagger/generator.js.map +1 -0
- package/jest.config.js +5 -0
- package/package.json +20 -0
- package/src/index.ts +294 -0
- package/src/swagger/generator.ts +171 -0
- package/test/index.test.ts +150 -0
- package/tsconfig.json +8 -0
- package/tsconfig.tsbuildinfo +1 -0
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
|
+
});
|