@quatrain/api-server-express 1.1.6
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/README.md +13 -0
- package/dist/adapters/ExpressAdapter.d.ts +28 -0
- package/dist/adapters/ExpressAdapter.js +122 -0
- package/dist/endpoints/CrudEndpoint.d.ts +3 -0
- package/dist/endpoints/CrudEndpoint.js +101 -0
- package/dist/endpoints/ListEndpoint.d.ts +3 -0
- package/dist/endpoints/ListEndpoint.js +27 -0
- package/dist/endpoints/ValuesEndpoint.d.ts +3 -0
- package/dist/endpoints/ValuesEndpoint.js +59 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +11 -0
- package/package.json +43 -0
- package/src/adapters/ExpressAdapter.ts +129 -0
- package/src/endpoints/CrudEndpoint.ts +106 -0
- package/src/endpoints/ListEndpoint.ts +30 -0
- package/src/endpoints/ValuesEndpoint.ts +66 -0
- package/src/index.ts +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @quatrain/api-server-express
|
|
2
|
+
|
|
3
|
+
The `@quatrain/api-server-express` package provides the backend REST layer for Quatrain, exposing the underlying DataObjects and Backend engines through HTTP endpoints.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
- **Express Adapter**: A lightweight wrapper around Express to bootstrap APIs instantly.
|
|
7
|
+
- **Auto-generated Endpoints**: Pre-built `CrudEndpoint` and `ListEndpoint` handlers to instantly expose a Quatrain model (e.g. `StudioProperty`, `StudioModel`) as a complete REST API.
|
|
8
|
+
- **Middleware Support**: Pluggable Express middlewares.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
## Getting Started
|
|
12
|
+
|
|
13
|
+
Refer to the `HOWTO.md` file for usage examples.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { ServerAdapter, ApiHandler, EndpointHandler, EndpointOptions } from '@quatrain/api';
|
|
3
|
+
export declare class ExpressAdapter implements ServerAdapter {
|
|
4
|
+
private appOrRouter;
|
|
5
|
+
private config;
|
|
6
|
+
constructor(appOrRouter?: express.Application | express.Router, config?: {
|
|
7
|
+
apiPrefix?: string;
|
|
8
|
+
});
|
|
9
|
+
private wrapHandler;
|
|
10
|
+
get(path: string, handler: ApiHandler): void;
|
|
11
|
+
post(path: string, handler: ApiHandler): void;
|
|
12
|
+
put(path: string, handler: ApiHandler): void;
|
|
13
|
+
delete(path: string, handler: ApiHandler): void;
|
|
14
|
+
use(middleware: any): void;
|
|
15
|
+
createRouter(path: string): ServerAdapter;
|
|
16
|
+
start(port: number, callback?: () => void): void;
|
|
17
|
+
getNativeInstance(): any;
|
|
18
|
+
/**
|
|
19
|
+
* Configures the server to serve static files from a specified folder.
|
|
20
|
+
* It also sets up a fallback route for SPA (Single Page Application) navigation,
|
|
21
|
+
* ensuring that non-API routes return the main index.html file.
|
|
22
|
+
*
|
|
23
|
+
* @param folderPath The absolute path to the directory containing static files (e.g. built frontend).
|
|
24
|
+
* @param apiPrefix The prefix used for API routes, which will be ignored by the SPA fallback. Defaults to '/api'.
|
|
25
|
+
*/
|
|
26
|
+
serveStatic(folderPath: string, apiPrefix?: string): void;
|
|
27
|
+
addEndpoint(handler: EndpointHandler, endpointRoot: string, options?: EndpointOptions): void;
|
|
28
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ExpressAdapter = void 0;
|
|
7
|
+
const express_1 = __importDefault(require("express"));
|
|
8
|
+
class ExpressAdapter {
|
|
9
|
+
appOrRouter;
|
|
10
|
+
config;
|
|
11
|
+
constructor(appOrRouter = (0, express_1.default)(), config = {}) {
|
|
12
|
+
this.appOrRouter = appOrRouter;
|
|
13
|
+
this.config = config;
|
|
14
|
+
if ('listen' in this.appOrRouter && typeof this.appOrRouter.listen === 'function') {
|
|
15
|
+
// Default global middlewares for top-level application
|
|
16
|
+
this.appOrRouter.disable('x-powered-by');
|
|
17
|
+
this.appOrRouter.use(express_1.default.json());
|
|
18
|
+
this.appOrRouter.use((req, res, next) => {
|
|
19
|
+
res.header('Access-Control-Allow-Origin', '*');
|
|
20
|
+
res.header('Access-Control-Allow-Methods', 'GET, PUT, POST, DELETE, OPTIONS');
|
|
21
|
+
res.header('Access-Control-Allow-Headers', '*');
|
|
22
|
+
if (req.method === 'OPTIONS') {
|
|
23
|
+
return res.sendStatus(200);
|
|
24
|
+
}
|
|
25
|
+
next();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
wrapHandler(handler) {
|
|
30
|
+
return async (req, res, next) => {
|
|
31
|
+
try {
|
|
32
|
+
const apiReq = {
|
|
33
|
+
body: req.body,
|
|
34
|
+
params: req.params,
|
|
35
|
+
query: req.query
|
|
36
|
+
};
|
|
37
|
+
const apiRes = {
|
|
38
|
+
status: (code) => {
|
|
39
|
+
res.status(code);
|
|
40
|
+
return apiRes;
|
|
41
|
+
},
|
|
42
|
+
json: (data) => {
|
|
43
|
+
res.json(data);
|
|
44
|
+
},
|
|
45
|
+
send: (data) => {
|
|
46
|
+
res.send(data);
|
|
47
|
+
},
|
|
48
|
+
setHeader: (name, value) => {
|
|
49
|
+
res.setHeader(name, value);
|
|
50
|
+
},
|
|
51
|
+
write: (data) => {
|
|
52
|
+
res.write(data);
|
|
53
|
+
},
|
|
54
|
+
end: () => {
|
|
55
|
+
res.end();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
await handler(apiReq, apiRes);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
next(err);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
get(path, handler) {
|
|
66
|
+
this.appOrRouter.get(path, this.wrapHandler(handler));
|
|
67
|
+
}
|
|
68
|
+
post(path, handler) {
|
|
69
|
+
this.appOrRouter.post(path, this.wrapHandler(handler));
|
|
70
|
+
}
|
|
71
|
+
put(path, handler) {
|
|
72
|
+
this.appOrRouter.put(path, this.wrapHandler(handler));
|
|
73
|
+
}
|
|
74
|
+
delete(path, handler) {
|
|
75
|
+
this.appOrRouter.delete(path, this.wrapHandler(handler));
|
|
76
|
+
}
|
|
77
|
+
use(middleware) {
|
|
78
|
+
this.appOrRouter.use(middleware);
|
|
79
|
+
}
|
|
80
|
+
createRouter(path) {
|
|
81
|
+
const router = express_1.default.Router();
|
|
82
|
+
this.appOrRouter.use(path, router);
|
|
83
|
+
return new ExpressAdapter(router);
|
|
84
|
+
}
|
|
85
|
+
start(port, callback) {
|
|
86
|
+
if ('listen' in this.appOrRouter && typeof this.appOrRouter.listen === 'function') {
|
|
87
|
+
this.appOrRouter.listen(port, callback);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
throw new Error("Cannot start a server on a Router instance.");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
getNativeInstance() {
|
|
94
|
+
return this.appOrRouter;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Configures the server to serve static files from a specified folder.
|
|
98
|
+
* It also sets up a fallback route for SPA (Single Page Application) navigation,
|
|
99
|
+
* ensuring that non-API routes return the main index.html file.
|
|
100
|
+
*
|
|
101
|
+
* @param folderPath The absolute path to the directory containing static files (e.g. built frontend).
|
|
102
|
+
* @param apiPrefix The prefix used for API routes, which will be ignored by the SPA fallback. Defaults to '/api'.
|
|
103
|
+
*/
|
|
104
|
+
serveStatic(folderPath, apiPrefix = '/api') {
|
|
105
|
+
const path = require('node:path');
|
|
106
|
+
this.use(express_1.default.static(folderPath));
|
|
107
|
+
this.appOrRouter.get('*', (req, res, next) => {
|
|
108
|
+
if (req.path.startsWith(apiPrefix))
|
|
109
|
+
return next();
|
|
110
|
+
res.sendFile(path.join(folderPath, 'index.html'));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
addEndpoint(handler, endpointRoot, options = {}) {
|
|
114
|
+
const fullPath = this.config.apiPrefix ? `${this.config.apiPrefix}${endpointRoot}` : endpointRoot;
|
|
115
|
+
const router = this.createRouter(fullPath);
|
|
116
|
+
if (options.middlewares && options.middlewares.length > 0) {
|
|
117
|
+
options.middlewares.forEach((mw) => router.use(mw));
|
|
118
|
+
}
|
|
119
|
+
handler(router, '/', options);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
exports.ExpressAdapter = ExpressAdapter;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CrudEndpoint = void 0;
|
|
4
|
+
const backend_1 = require("@quatrain/backend");
|
|
5
|
+
const CrudEndpoint = (ModelClass) => {
|
|
6
|
+
return (router, path, options) => {
|
|
7
|
+
const methods = options.methods || ['CREATE', 'READ', 'UPDATE', 'DELETE'];
|
|
8
|
+
// C: Create
|
|
9
|
+
if (methods.includes('CREATE')) {
|
|
10
|
+
router.post(path, async (req, res) => {
|
|
11
|
+
try {
|
|
12
|
+
const data = req.body;
|
|
13
|
+
const newObj = await ModelClass.factory();
|
|
14
|
+
Object.keys(data).forEach((key) => {
|
|
15
|
+
if (newObj.has(key)) {
|
|
16
|
+
newObj.set(key, data[key]);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
newObj.validate();
|
|
20
|
+
await newObj.save();
|
|
21
|
+
res.status(201).json(newObj.dataObject.toJSON());
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
if (e.name === 'ValidationError') {
|
|
25
|
+
res.status(400).json({ error: e.message, validationErrors: e.errors });
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
backend_1.Backend.error(`[API Error] POST: ${e.message}`);
|
|
29
|
+
res.status(400).json({ error: e.message });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
// R: Read (Single by ID)
|
|
35
|
+
if (methods.includes('READ')) {
|
|
36
|
+
const getPath = path.endsWith('/') ? `${path}:id` : `${path}/:id`;
|
|
37
|
+
router.get(getPath, async (req, res) => {
|
|
38
|
+
try {
|
|
39
|
+
const obj = await ModelClass.fromBackend(req.params.id);
|
|
40
|
+
if (!obj) {
|
|
41
|
+
return res.status(404).json({ error: 'Object not found' });
|
|
42
|
+
}
|
|
43
|
+
res.json(obj.dataObject.toJSON());
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
backend_1.Backend.error(`[API Error] GET /${req.params.id}: ${e.message}`);
|
|
47
|
+
res.status(500).json({ error: e.message });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// U: Update
|
|
52
|
+
if (methods.includes('UPDATE')) {
|
|
53
|
+
const putPath = path.endsWith('/') ? `${path}:id` : `${path}/:id`;
|
|
54
|
+
router.put(putPath, async (req, res) => {
|
|
55
|
+
try {
|
|
56
|
+
const data = req.body;
|
|
57
|
+
const obj = await ModelClass.fromBackend(req.params.id);
|
|
58
|
+
if (!obj) {
|
|
59
|
+
return res.status(404).json({ error: 'Object not found' });
|
|
60
|
+
}
|
|
61
|
+
Object.keys(data).forEach((key) => {
|
|
62
|
+
if (obj.has(key)) {
|
|
63
|
+
obj.set(key, data[key]);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
obj.validate();
|
|
67
|
+
await obj.save();
|
|
68
|
+
res.json(obj.dataObject.toJSON());
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
if (e.name === 'ValidationError') {
|
|
72
|
+
res.status(400).json({ error: e.message, validationErrors: e.errors });
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
backend_1.Backend.error(`[API Error] PUT /${req.params.id}: ${e.message}`);
|
|
76
|
+
res.status(400).json({ error: e.message });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// D: Delete
|
|
82
|
+
if (methods.includes('DELETE')) {
|
|
83
|
+
const deletePath = path.endsWith('/') ? `${path}:id` : `${path}/:id`;
|
|
84
|
+
router.delete(deletePath, async (req, res) => {
|
|
85
|
+
try {
|
|
86
|
+
const obj = await ModelClass.fromBackend(req.params.id);
|
|
87
|
+
if (!obj) {
|
|
88
|
+
return res.status(404).json({ error: 'Object not found' });
|
|
89
|
+
}
|
|
90
|
+
await obj.delete();
|
|
91
|
+
res.json({ success: true });
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
backend_1.Backend.error(`[API Error] DELETE /${req.params.id}: ${e.message}`);
|
|
95
|
+
res.status(500).json({ error: e.message });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
exports.CrudEndpoint = CrudEndpoint;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ListEndpoint = void 0;
|
|
4
|
+
const backend_1 = require("@quatrain/backend");
|
|
5
|
+
const ListEndpoint = (ModelClass) => {
|
|
6
|
+
return (router, path, options) => {
|
|
7
|
+
router.get(path, async (req, res) => {
|
|
8
|
+
try {
|
|
9
|
+
let q = ModelClass.query();
|
|
10
|
+
// Apply all query parameters as exact match filters
|
|
11
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
12
|
+
q = q.where(key, value, 'equals');
|
|
13
|
+
}
|
|
14
|
+
const results = await q.execute('dataObjects');
|
|
15
|
+
res.json({
|
|
16
|
+
items: results.items.map((r) => r.toJSON()),
|
|
17
|
+
meta: results.meta
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
backend_1.Backend.error(`[API Error] GET (List): ${e.message}`);
|
|
22
|
+
res.status(500).json({ error: e.message });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
exports.ListEndpoint = ListEndpoint;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ValuesEndpoint = void 0;
|
|
4
|
+
const backend_1 = require("@quatrain/backend");
|
|
5
|
+
const ValuesEndpoint = (ModelClass) => {
|
|
6
|
+
return (router, path, options) => {
|
|
7
|
+
// Create the endpoint route, assuming 'path' is something like '/api/users'
|
|
8
|
+
// We'll map it to '/api/users/values'
|
|
9
|
+
const valuesPath = path.endsWith('/') ? `${path}values` : `${path}/values`;
|
|
10
|
+
router.get(valuesPath, async (req, res) => {
|
|
11
|
+
try {
|
|
12
|
+
const results = await ModelClass.query()
|
|
13
|
+
.where('status', 'deleted', 'notEquals')
|
|
14
|
+
.execute('dataObjects');
|
|
15
|
+
// Identify exposed fields from PROPS_DEFINITION
|
|
16
|
+
const propsDef = ModelClass.PROPS_DEFINITION || [];
|
|
17
|
+
const exposedFields = propsDef
|
|
18
|
+
.filter((p) => p.htmlType && p.htmlType !== 'off')
|
|
19
|
+
.map((p) => p.name);
|
|
20
|
+
const searchFields = ['name', ...propsDef
|
|
21
|
+
.filter((p) => p.type === 'StringProperty' && p.fullSearch === true)
|
|
22
|
+
.map((p) => p.name)];
|
|
23
|
+
let values = results.items.map((dataObject) => {
|
|
24
|
+
const payload = {
|
|
25
|
+
value: dataObject.uid,
|
|
26
|
+
name: dataObject.has('name') ? dataObject.get('name') : dataObject.uid
|
|
27
|
+
};
|
|
28
|
+
for (const field of exposedFields) {
|
|
29
|
+
if (dataObject.has(field)) {
|
|
30
|
+
payload[field] = dataObject.get(field);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const searchVals = searchFields.map(f => {
|
|
34
|
+
const val = dataObject.get(f);
|
|
35
|
+
return typeof val === 'string' ? val : '';
|
|
36
|
+
}).filter(Boolean);
|
|
37
|
+
payload._search = searchVals.join(' ').toLowerCase();
|
|
38
|
+
return payload;
|
|
39
|
+
});
|
|
40
|
+
const qParam = req.query.q;
|
|
41
|
+
if (qParam) {
|
|
42
|
+
const searchStr = qParam.toLowerCase();
|
|
43
|
+
values = values.filter((payload) => {
|
|
44
|
+
return searchFields.some(field => {
|
|
45
|
+
const val = payload[field];
|
|
46
|
+
return typeof val === 'string' && val.toLowerCase().includes(searchStr);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
res.json(values);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
backend_1.Backend.error(`[API Error] GET ${valuesPath}: ${e.message}`);
|
|
54
|
+
res.status(500).json({ error: e.message });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
exports.ValuesEndpoint = ValuesEndpoint;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ValuesEndpoint = exports.ListEndpoint = exports.CrudEndpoint = exports.ExpressAdapter = void 0;
|
|
4
|
+
var ExpressAdapter_1 = require("./adapters/ExpressAdapter");
|
|
5
|
+
Object.defineProperty(exports, "ExpressAdapter", { enumerable: true, get: function () { return ExpressAdapter_1.ExpressAdapter; } });
|
|
6
|
+
var CrudEndpoint_1 = require("./endpoints/CrudEndpoint");
|
|
7
|
+
Object.defineProperty(exports, "CrudEndpoint", { enumerable: true, get: function () { return CrudEndpoint_1.CrudEndpoint; } });
|
|
8
|
+
var ListEndpoint_1 = require("./endpoints/ListEndpoint");
|
|
9
|
+
Object.defineProperty(exports, "ListEndpoint", { enumerable: true, get: function () { return ListEndpoint_1.ListEndpoint; } });
|
|
10
|
+
var ValuesEndpoint_1 = require("./endpoints/ValuesEndpoint");
|
|
11
|
+
Object.defineProperty(exports, "ValuesEndpoint", { enumerable: true, get: function () { return ValuesEndpoint_1.ValuesEndpoint; } });
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quatrain/api-server-express",
|
|
3
|
+
"version": "1.1.6",
|
|
4
|
+
"description": "API Server Utilities for Quatrain",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"bun": "src/index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"LICENSE.md",
|
|
10
|
+
"dist/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"NOTICE.md"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/Quatrain/Core.git",
|
|
18
|
+
"directory": "packages/api-server"
|
|
19
|
+
},
|
|
20
|
+
"author": "Quatrain Développement SAS <developers@quatrain.com>",
|
|
21
|
+
"license": "AGPL-3.0-only",
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@tsconfig/recommended": "^1.0.1",
|
|
24
|
+
"@types/express": "^4.17.21",
|
|
25
|
+
"@types/jest": "^29.5.12",
|
|
26
|
+
"@types/node": "^22.10.1",
|
|
27
|
+
"jest": "^29.7.0",
|
|
28
|
+
"ts-jest": "^29.4.6",
|
|
29
|
+
"typescript": "^5.2.2"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@quatrain/api": "^1.1.3",
|
|
33
|
+
"@quatrain/backend": "^1.2.5",
|
|
34
|
+
"@quatrain/core": "^1.2.5",
|
|
35
|
+
"express": "^4.19.2"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test-ci": "jest --runInBand",
|
|
39
|
+
"build": "tsc",
|
|
40
|
+
"wbuild": "tsc --watch",
|
|
41
|
+
"bump-to": "yarn version"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import { ServerAdapter, ApiHandler, ApiRequest, ApiResponse, EndpointHandler, EndpointOptions } from '@quatrain/api'
|
|
3
|
+
|
|
4
|
+
export class ExpressAdapter implements ServerAdapter {
|
|
5
|
+
constructor(
|
|
6
|
+
private appOrRouter: express.Application | express.Router = express(),
|
|
7
|
+
private config: { apiPrefix?: string } = {}
|
|
8
|
+
) {
|
|
9
|
+
if ('listen' in this.appOrRouter && typeof this.appOrRouter.listen === 'function') {
|
|
10
|
+
// Default global middlewares for top-level application
|
|
11
|
+
(this.appOrRouter as express.Application).disable('x-powered-by')
|
|
12
|
+
this.appOrRouter.use(express.json())
|
|
13
|
+
this.appOrRouter.use((req, res, next) => {
|
|
14
|
+
res.header('Access-Control-Allow-Origin', '*')
|
|
15
|
+
res.header('Access-Control-Allow-Methods', 'GET, PUT, POST, DELETE, OPTIONS')
|
|
16
|
+
res.header('Access-Control-Allow-Headers', '*')
|
|
17
|
+
if (req.method === 'OPTIONS') {
|
|
18
|
+
return res.sendStatus(200)
|
|
19
|
+
}
|
|
20
|
+
next()
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private wrapHandler(handler: ApiHandler): express.RequestHandler {
|
|
26
|
+
return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
27
|
+
try {
|
|
28
|
+
const apiReq: ApiRequest = {
|
|
29
|
+
body: req.body,
|
|
30
|
+
params: req.params,
|
|
31
|
+
query: req.query
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const apiRes: ApiResponse = {
|
|
35
|
+
status: (code: number) => {
|
|
36
|
+
res.status(code)
|
|
37
|
+
return apiRes
|
|
38
|
+
},
|
|
39
|
+
json: (data: any) => {
|
|
40
|
+
res.json(data)
|
|
41
|
+
},
|
|
42
|
+
send: (data: string) => {
|
|
43
|
+
res.send(data)
|
|
44
|
+
},
|
|
45
|
+
setHeader: (name: string, value: string) => {
|
|
46
|
+
res.setHeader(name, value)
|
|
47
|
+
},
|
|
48
|
+
write: (data: string) => {
|
|
49
|
+
res.write(data)
|
|
50
|
+
},
|
|
51
|
+
end: () => {
|
|
52
|
+
res.end()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await handler(apiReq, apiRes)
|
|
57
|
+
} catch (err) {
|
|
58
|
+
next(err)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get(path: string, handler: ApiHandler): void {
|
|
64
|
+
(this.appOrRouter as express.Router).get(path, this.wrapHandler(handler))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
post(path: string, handler: ApiHandler): void {
|
|
68
|
+
(this.appOrRouter as express.Router).post(path, this.wrapHandler(handler))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
put(path: string, handler: ApiHandler): void {
|
|
72
|
+
(this.appOrRouter as express.Router).put(path, this.wrapHandler(handler))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
delete(path: string, handler: ApiHandler): void {
|
|
76
|
+
(this.appOrRouter as express.Router).delete(path, this.wrapHandler(handler))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
use(middleware: any): void {
|
|
80
|
+
(this.appOrRouter as express.Router).use(middleware)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
createRouter(path: string): ServerAdapter {
|
|
84
|
+
const router = express.Router()
|
|
85
|
+
;(this.appOrRouter as express.Router).use(path, router)
|
|
86
|
+
return new ExpressAdapter(router)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
start(port: number, callback?: () => void): void {
|
|
90
|
+
if ('listen' in this.appOrRouter && typeof this.appOrRouter.listen === 'function') {
|
|
91
|
+
this.appOrRouter.listen(port, callback)
|
|
92
|
+
} else {
|
|
93
|
+
throw new Error("Cannot start a server on a Router instance.")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
getNativeInstance(): any {
|
|
98
|
+
return this.appOrRouter
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Configures the server to serve static files from a specified folder.
|
|
103
|
+
* It also sets up a fallback route for SPA (Single Page Application) navigation,
|
|
104
|
+
* ensuring that non-API routes return the main index.html file.
|
|
105
|
+
*
|
|
106
|
+
* @param folderPath The absolute path to the directory containing static files (e.g. built frontend).
|
|
107
|
+
* @param apiPrefix The prefix used for API routes, which will be ignored by the SPA fallback. Defaults to '/api'.
|
|
108
|
+
*/
|
|
109
|
+
serveStatic(folderPath: string, apiPrefix: string = '/api'): void {
|
|
110
|
+
const path = require('node:path')
|
|
111
|
+
this.use(express.static(folderPath))
|
|
112
|
+
|
|
113
|
+
;(this.appOrRouter as express.Router).get('*', (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
114
|
+
if (req.path.startsWith(apiPrefix)) return next()
|
|
115
|
+
res.sendFile(path.join(folderPath, 'index.html'))
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
addEndpoint(handler: EndpointHandler, endpointRoot: string, options: EndpointOptions = {}): void {
|
|
120
|
+
const fullPath = this.config.apiPrefix ? `${this.config.apiPrefix}${endpointRoot}` : endpointRoot
|
|
121
|
+
const router = this.createRouter(fullPath)
|
|
122
|
+
|
|
123
|
+
if (options.middlewares && options.middlewares.length > 0) {
|
|
124
|
+
options.middlewares.forEach((mw) => router.use(mw))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
handler(router, '/', options)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { EndpointHandler, ServerAdapter, EndpointOptions } from '@quatrain/api'
|
|
2
|
+
import { BaseObject, DataObjectClass } from '@quatrain/core'
|
|
3
|
+
import { Backend } from '@quatrain/backend'
|
|
4
|
+
import { ValidationError } from '@quatrain/core'
|
|
5
|
+
|
|
6
|
+
export const CrudEndpoint = (ModelClass: typeof BaseObject): EndpointHandler => {
|
|
7
|
+
return (router: ServerAdapter, path: string, options: EndpointOptions) => {
|
|
8
|
+
const methods = options.methods || ['CREATE', 'READ', 'UPDATE', 'DELETE']
|
|
9
|
+
|
|
10
|
+
// C: Create
|
|
11
|
+
if (methods.includes('CREATE')) {
|
|
12
|
+
router.post(path, async (req, res) => {
|
|
13
|
+
try {
|
|
14
|
+
const data = req.body
|
|
15
|
+
const newObj = await ModelClass.factory()
|
|
16
|
+
|
|
17
|
+
Object.keys(data).forEach((key) => {
|
|
18
|
+
if (newObj.has(key)) {
|
|
19
|
+
newObj.set(key, data[key])
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
(newObj as any).validate()
|
|
24
|
+
await (newObj as any).save()
|
|
25
|
+
res.status(201).json(newObj.dataObject.toJSON())
|
|
26
|
+
} catch (e: any) {
|
|
27
|
+
if (e.name === 'ValidationError') {
|
|
28
|
+
res.status(400).json({ error: e.message, validationErrors: e.errors })
|
|
29
|
+
} else {
|
|
30
|
+
Backend.error(`[API Error] POST: ${e.message}`)
|
|
31
|
+
res.status(400).json({ error: e.message })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// R: Read (Single by ID)
|
|
38
|
+
if (methods.includes('READ')) {
|
|
39
|
+
const getPath = path.endsWith('/') ? `${path}:id` : `${path}/:id`
|
|
40
|
+
router.get(getPath, async (req, res) => {
|
|
41
|
+
try {
|
|
42
|
+
const obj = await (ModelClass as any).fromBackend(req.params.id)
|
|
43
|
+
if (!obj) {
|
|
44
|
+
return res.status(404).json({ error: 'Object not found' })
|
|
45
|
+
}
|
|
46
|
+
res.json(obj.dataObject.toJSON())
|
|
47
|
+
} catch (e) {
|
|
48
|
+
Backend.error(`[API Error] GET /${req.params.id}: ${(e as Error).message}`)
|
|
49
|
+
res.status(500).json({ error: (e as Error).message })
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// U: Update
|
|
55
|
+
if (methods.includes('UPDATE')) {
|
|
56
|
+
const putPath = path.endsWith('/') ? `${path}:id` : `${path}/:id`
|
|
57
|
+
router.put(putPath, async (req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
const data = req.body
|
|
60
|
+
const obj = await (ModelClass as any).fromBackend(req.params.id)
|
|
61
|
+
|
|
62
|
+
if (!obj) {
|
|
63
|
+
return res.status(404).json({ error: 'Object not found' })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
Object.keys(data).forEach((key) => {
|
|
67
|
+
if (obj.has(key)) {
|
|
68
|
+
obj.set(key, data[key])
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
obj.validate()
|
|
73
|
+
await obj.save()
|
|
74
|
+
res.json(obj.dataObject.toJSON())
|
|
75
|
+
} catch (e: any) {
|
|
76
|
+
if (e.name === 'ValidationError') {
|
|
77
|
+
res.status(400).json({ error: e.message, validationErrors: e.errors })
|
|
78
|
+
} else {
|
|
79
|
+
Backend.error(`[API Error] PUT /${req.params.id}: ${e.message}`)
|
|
80
|
+
res.status(400).json({ error: e.message })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// D: Delete
|
|
87
|
+
if (methods.includes('DELETE')) {
|
|
88
|
+
const deletePath = path.endsWith('/') ? `${path}:id` : `${path}/:id`
|
|
89
|
+
router.delete(deletePath, async (req, res) => {
|
|
90
|
+
try {
|
|
91
|
+
const obj = await (ModelClass as any).fromBackend(req.params.id)
|
|
92
|
+
|
|
93
|
+
if (!obj) {
|
|
94
|
+
return res.status(404).json({ error: 'Object not found' })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await obj.delete()
|
|
98
|
+
res.json({ success: true })
|
|
99
|
+
} catch (e) {
|
|
100
|
+
Backend.error(`[API Error] DELETE /${req.params.id}: ${(e as Error).message}`)
|
|
101
|
+
res.status(500).json({ error: (e as Error).message })
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { EndpointHandler, ServerAdapter, EndpointOptions } from '@quatrain/api'
|
|
2
|
+
import { BaseObject, DataObjectClass } from '@quatrain/core'
|
|
3
|
+
import { Backend } from '@quatrain/backend'
|
|
4
|
+
|
|
5
|
+
export const ListEndpoint = (ModelClass: typeof BaseObject): EndpointHandler => {
|
|
6
|
+
return (router: ServerAdapter, path: string, options: EndpointOptions) => {
|
|
7
|
+
|
|
8
|
+
router.get(path, async (req, res) => {
|
|
9
|
+
try {
|
|
10
|
+
let q = (ModelClass as any).query()
|
|
11
|
+
|
|
12
|
+
// Apply all query parameters as exact match filters
|
|
13
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
14
|
+
q = q.where(key, value, 'equals')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const results = await q.execute('dataObjects')
|
|
18
|
+
|
|
19
|
+
res.json({
|
|
20
|
+
items: results.items.map((r: DataObjectClass<any>) => r.toJSON()),
|
|
21
|
+
meta: results.meta
|
|
22
|
+
})
|
|
23
|
+
} catch (e) {
|
|
24
|
+
Backend.error(`[API Error] GET (List): ${(e as Error).message}`)
|
|
25
|
+
res.status(500).json({ error: (e as Error).message })
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { EndpointHandler, ServerAdapter, EndpointOptions } from '@quatrain/api'
|
|
2
|
+
import { BaseObject, DataObjectClass } from '@quatrain/core'
|
|
3
|
+
import { Backend } from '@quatrain/backend'
|
|
4
|
+
|
|
5
|
+
export const ValuesEndpoint = (ModelClass: typeof BaseObject): EndpointHandler => {
|
|
6
|
+
return (router: ServerAdapter, path: string, options: EndpointOptions) => {
|
|
7
|
+
// Create the endpoint route, assuming 'path' is something like '/api/users'
|
|
8
|
+
// We'll map it to '/api/users/values'
|
|
9
|
+
const valuesPath = path.endsWith('/') ? `${path}values` : `${path}/values`
|
|
10
|
+
|
|
11
|
+
router.get(valuesPath, async (req, res) => {
|
|
12
|
+
try {
|
|
13
|
+
const results = await (ModelClass as any).query()
|
|
14
|
+
.where('status', 'deleted', 'notEquals')
|
|
15
|
+
.execute('dataObjects')
|
|
16
|
+
|
|
17
|
+
// Identify exposed fields from PROPS_DEFINITION
|
|
18
|
+
const propsDef = (ModelClass as any).PROPS_DEFINITION || []
|
|
19
|
+
const exposedFields = propsDef
|
|
20
|
+
.filter((p: any) => p.htmlType && p.htmlType !== 'off')
|
|
21
|
+
.map((p: any) => p.name)
|
|
22
|
+
|
|
23
|
+
const searchFields = ['name', ...propsDef
|
|
24
|
+
.filter((p: any) => p.type === 'StringProperty' && p.fullSearch === true)
|
|
25
|
+
.map((p: any) => p.name)]
|
|
26
|
+
|
|
27
|
+
let values = results.items.map((dataObject: DataObjectClass<any>) => {
|
|
28
|
+
const payload: any = {
|
|
29
|
+
value: dataObject.uid,
|
|
30
|
+
name: dataObject.has('name') ? dataObject.get('name') : dataObject.uid
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const field of exposedFields) {
|
|
34
|
+
if (dataObject.has(field)) {
|
|
35
|
+
payload[field] = dataObject.get(field)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const searchVals = searchFields.map(f => {
|
|
40
|
+
const val = dataObject.get(f)
|
|
41
|
+
return typeof val === 'string' ? val : ''
|
|
42
|
+
}).filter(Boolean)
|
|
43
|
+
payload._search = searchVals.join(' ').toLowerCase()
|
|
44
|
+
|
|
45
|
+
return payload
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const qParam = req.query.q as string
|
|
49
|
+
if (qParam) {
|
|
50
|
+
const searchStr = qParam.toLowerCase()
|
|
51
|
+
values = values.filter((payload: any) => {
|
|
52
|
+
return searchFields.some(field => {
|
|
53
|
+
const val = payload[field]
|
|
54
|
+
return typeof val === 'string' && val.toLowerCase().includes(searchStr)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
res.json(values)
|
|
60
|
+
} catch (e) {
|
|
61
|
+
Backend.error(`[API Error] GET ${valuesPath}: ${(e as Error).message}`)
|
|
62
|
+
res.status(500).json({ error: (e as Error).message })
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|