@vida-global/core 2.0.0 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,315 @@
1
+ const Errors = require('../errors');
2
+
3
+
4
+ class StreamInProgressError extends Error {}
5
+ class NoActiveStreamError extends Error {}
6
+
7
+
8
+ const CONTENT_TYPE_BY_EXTENSION = {
9
+ html: 'text/html; charset=utf-8',
10
+ htm: 'text/html; charset=utf-8',
11
+ css: 'text/css; charset=utf-8',
12
+ js: 'application/javascript; charset=utf-8',
13
+ mjs: 'application/javascript; charset=utf-8',
14
+ json: 'application/json; charset=utf-8',
15
+ txt: 'text/plain; charset=utf-8',
16
+ png: 'image/png',
17
+ jpg: 'image/jpeg',
18
+ jpeg: 'image/jpeg',
19
+ gif: 'image/gif',
20
+ svg: 'image/svg+xml',
21
+ ico: 'image/x-icon',
22
+ webp: 'image/webp',
23
+ woff: 'font/woff',
24
+ woff2: 'font/woff2',
25
+ ttf: 'font/ttf',
26
+ otf: 'font/otf',
27
+ map: 'application/json; charset=utf-8',
28
+ };
29
+
30
+
31
+ function guessContentType(filePath) {
32
+ const match = /\.([a-z0-9]+)$/i.exec(`${filePath || ''}`);
33
+ const ext = match ? match[1].toLowerCase() : '';
34
+ return CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream';
35
+ }
36
+
37
+
38
+ function setSseHeaders(controller) {
39
+ controller.statusCode = 200;
40
+ controller.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
41
+ controller.setHeader('Cache-Control', 'no-cache');
42
+ controller.setHeader('Connection', 'keep-alive');
43
+ controller.setHeader('X-Accel-Buffering', 'no');
44
+ controller.flushHeaders();
45
+ }
46
+
47
+
48
+ const InstanceMethods = {
49
+ async render(body, options={}) {
50
+ if (this.rendered) return;
51
+ if (this.isStreaming) return;
52
+
53
+ if (typeof body == 'string') {
54
+ this._response.send(body);
55
+ } else {
56
+ body = await this.processJSONBody(body, options);
57
+
58
+ const errors = body.errors || null;
59
+ delete body.errors;
60
+
61
+ body = this.formatJSONBody(body, errors, options);
62
+ this._response.json(body);
63
+ }
64
+ this.markRendered();
65
+ },
66
+
67
+
68
+ async renderErrors(message, fields) {
69
+ const errors = {message: null, fields: {}};
70
+
71
+ if (message && !fields && typeof message == 'object') {
72
+ fields = message;
73
+ message = null;
74
+ }
75
+
76
+ if (fields) {
77
+ for (const [field, values] of Object.entries(fields)) {
78
+ if (!Array.isArray(values)) fields[field] = [values];
79
+ }
80
+ errors.fields = fields;
81
+ }
82
+
83
+ await this.renderBadRequest(message, { errors });
84
+ },
85
+
86
+
87
+ formatJSONBody(body, errors, options) {
88
+ if (options.standardize === false) return body;
89
+
90
+ const response = {data: body, status: this.statusText};
91
+ if (errors) response.errors = errors;
92
+
93
+ return response;
94
+ },
95
+
96
+
97
+ async processJSONBody(body, options) {
98
+ if (body === undefined) return null;
99
+ if (!body || typeof body != 'object') return body;
100
+
101
+ let processedBody;
102
+
103
+ if (body.toApiResponse) {
104
+ const formattedBody = await body.toApiResponse(options);
105
+ return await this.processJSONBody(formattedBody, options);
106
+
107
+ } else if (Array.isArray(body)) {
108
+ const processed = [];
109
+ for (const idx in body) {
110
+ processed.push(await this.processJSONBody(body[idx], options));
111
+ }
112
+ return processed;
113
+
114
+ } else if (body.constructor == Date) {
115
+ return body.getTime();
116
+
117
+ } else {
118
+ const processed = {};
119
+ for (let [key, val] of Object.entries(body)) {
120
+ processed[key] = await this.processJSONBody(val, options);
121
+ }
122
+ return processed;
123
+ }
124
+ },
125
+
126
+
127
+ _setupStatusRenderers() {
128
+ const successStatuses = {
129
+ CreationSuccessful: 201,
130
+ Accepted: 202,
131
+ NoConent: 204,
132
+ MovedPermanently: 301,
133
+ Found: 302,
134
+ NotModified: 304,
135
+ TemporaryRedirect: 307,
136
+ PermanentRedirect: 308,
137
+ }
138
+
139
+ for (let [type, statusCode] of Object.entries(Errors.Statuses)) {
140
+ if (type == 'Server') type = 'ServerError';
141
+ this[`render${type}`] = async function(message=null, body=null) {
142
+ return await this._renderError(statusCode, message, body);
143
+ }
144
+ }
145
+
146
+ for (let [type, statusCode] of Object.entries(successStatuses)) {
147
+ this[`render${type}`] = async function(body=null) {
148
+ return await this._renderSuccess(statusCode, body);
149
+ }
150
+ }
151
+ },
152
+
153
+
154
+ async _renderError(statusCode, message, body) {
155
+ this.statusCode = statusCode;
156
+ body = structuredClone(body || {});
157
+ body.errors = body.errors || {};
158
+ body.errors.message = message || null;
159
+ return await this.render(body);
160
+ },
161
+
162
+
163
+ async _renderSuccess(statusCode, body) {
164
+ this.statusCode = statusCode;
165
+ return await this.render(body);
166
+ },
167
+
168
+
169
+ setHeader(header, value) {
170
+ this._response.setHeader(header, value);
171
+ },
172
+
173
+
174
+ flushHeaders() {
175
+ this._response.flushHeaders();
176
+ },
177
+
178
+
179
+ /***********************************************************************************************
180
+ * FILE RENDERING
181
+ ***********************************************************************************************/
182
+ renderFile(filePath, { contentType, cacheControl, maxAge } = {}) {
183
+ if (!filePath) throw new Error('renderFile requires a filePath');
184
+ const resolvedType = contentType || guessContentType(filePath);
185
+ this._response.setHeader('Content-Type', resolvedType);
186
+ if (cacheControl) this._response.setHeader('Cache-Control', cacheControl);
187
+ this.markRendered();
188
+ return new Promise((resolve, reject) => {
189
+ this._response.sendFile(filePath, { maxAge }, (err) => {
190
+ if (err) reject(err);
191
+ else resolve();
192
+ });
193
+ });
194
+ },
195
+
196
+
197
+ /***********************************************************************************************
198
+ * STREAM
199
+ ***********************************************************************************************/
200
+ async streamSseResponse(handler, errorHandler, abortController=null) {
201
+ if (this._isStreaming) throw new StreamInProgressError();
202
+ this._isStreaming = true;
203
+
204
+ let closeHandler;
205
+ if (abortController) {
206
+ this._streamAbortController = abortController;
207
+ closeHandler = () => abortController.abort();
208
+ this._request.on('close', closeHandler);
209
+ }
210
+
211
+ setSseHeaders(this);
212
+ this.markRendered();
213
+ try {
214
+ await handler.bind(this)();
215
+ } catch(err) {
216
+ await errorHandler(err);
217
+ throw err;
218
+ } finally {
219
+ this.writeStreamEvent(null, '[DONE]');
220
+ this._response.end();
221
+
222
+ this._isStreaming = false;
223
+ this._streamAbortController = null;
224
+
225
+ if (closeHandler) this._request.off('close', closeHandler);
226
+ }
227
+ },
228
+
229
+
230
+ writeStreamEvent(evt, data) {
231
+ if (!this._isStreaming) throw new NoActiveStreamError();
232
+ if (this._streamAbortController?.signal?.aborted || this.writableEnded) return;
233
+
234
+ if (evt) {
235
+ this._response.write(`event: ${evt}\n`);
236
+ }
237
+
238
+ const msg = `data: ${data}\n\n`;
239
+ this._response.write(msg);
240
+ }
241
+ }
242
+
243
+
244
+ const Accessors = {
245
+ statusText: {
246
+ get() {
247
+ switch(this.statusCode) {
248
+ case 200:
249
+ return 'ok';
250
+ case 201:
251
+ return 'created';
252
+ case 202:
253
+ return 'accepted';
254
+ case 204:
255
+ return 'no content';
256
+ case 301:
257
+ return 'moved permanently';
258
+ case 302:
259
+ return 'found';
260
+ case 304:
261
+ return 'not modified';
262
+ case 307:
263
+ return 'temporary redirect';
264
+ case 308:
265
+ return 'permanent redirect';
266
+ case 400:
267
+ return 'bad request'
268
+ case 401:
269
+ return 'unauthorized';
270
+ case 402:
271
+ return 'payment required';
272
+ case 403:
273
+ return 'forbidden';
274
+ case 404:
275
+ return 'not found';
276
+ case 405:
277
+ return 'method not allowed';
278
+ case 408:
279
+ return 'request timeout';
280
+ case 409:
281
+ return 'conflict';
282
+ case 410:
283
+ return 'gone';
284
+ case 413:
285
+ return 'content too large';
286
+ case 415:
287
+ return 'unsupported media type';
288
+ case 422:
289
+ return 'unprocessable content';
290
+ case 423:
291
+ return 'locked';
292
+ case 429:
293
+ return 'too many requests';
294
+ case 500:
295
+ return 'server error';
296
+ case 501:
297
+ return 'not implemented';
298
+ case 502:
299
+ return 'bad gateway';
300
+ case 503:
301
+ return 'service unavailable';
302
+ case 504:
303
+ return 'gateway timeout';
304
+ }
305
+ },
306
+ }
307
+ }
308
+
309
+
310
+ module.exports = {
311
+ Accessors,
312
+ InstanceMethods,
313
+ NoActiveStreamError,
314
+ StreamInProgressError
315
+ }
@@ -0,0 +1,82 @@
1
+ function processRequestData(data) {
2
+ const processedData = {};
3
+ for (let [paramName, paramValue] of Object.entries(data)) {
4
+ processedData[paramName] = processedRequestDataValue(paramValue)
5
+ }
6
+
7
+ return processedData;
8
+ }
9
+
10
+
11
+ function processedRequestDataValue(value) {
12
+ try {
13
+ if (!/^\[|{/.test(value)) return value;
14
+ return JSON.parse(value);
15
+ } catch(err) {
16
+ return value;
17
+ }
18
+ }
19
+
20
+
21
+ const InstanceMethods = {
22
+ getCookie(name) {
23
+ if (!name) return null;
24
+ const value = this.cookies[name];
25
+ return value === undefined ? null : value;
26
+ },
27
+
28
+
29
+ setCookie(name, value, options = {}) {
30
+ if (!name) return;
31
+ this._response.cookie(name, value ?? '', options);
32
+ }
33
+ }
34
+
35
+
36
+ const Accessors = {
37
+ params: {
38
+ get() {
39
+ if (!this._params) {
40
+ const body = Buffer.isBuffer(this._request.body) ? {} : processRequestData(this._request.body || {});
41
+ this._params = {...this._request.params, ...this._request.query, ...body};
42
+ }
43
+ return structuredClone(this._params);
44
+ }
45
+ },
46
+
47
+
48
+ contentType: { get() { return this.requestHeaders['content-type'] }},
49
+ cookies: { get() { return this._request.cookies || {} }},
50
+ requestBody: { get() { return this._request.body }},
51
+ requestHeaders: { get() { return structuredClone(this._request.headers || {}) }},
52
+ requestId: { get() { return this._request.id }},
53
+ requestIp: { get() { return this._request.ip }},
54
+ requestMethod: { get() { return this._request.method }},
55
+ responseHeaders: { get() { return this._response.headers }},
56
+ url: { get() { return this._request.originalUrl }},
57
+ userAgent: { get() { return this.requestHeaders['user-agent'] }},
58
+
59
+
60
+ statusCode: {
61
+ get() { return this._response.statusCode },
62
+ set(_status) { this._response.statusCode = _status }
63
+ },
64
+
65
+
66
+ bearerToken: {
67
+ get() {
68
+ const auth = this.requestHeaders.authorization;
69
+ if (!auth) return null;
70
+
71
+ const match = /^\s*bearer\s+(.+)$/i.exec(auth);
72
+ if (!match) return null;
73
+ return match[1].trim();
74
+ }
75
+ },
76
+ }
77
+
78
+
79
+ module.exports = {
80
+ Accessors,
81
+ InstanceMethods
82
+ };
@@ -0,0 +1,108 @@
1
+ const { singularize } = require('inflection');
2
+
3
+
4
+ const actionStore = new WeakMap();
5
+
6
+
7
+ /***********************************************************************************************
8
+ * ACTION SETUP
9
+ ************************************************************************************************
10
+ * The controller looks for any methods that fit the pattern of `{httpMethod}ActionName`
11
+ * (e.g. getFooBar) and prepares server routes for them. By default, a method `getFooBar` on a
12
+ * `BazController` would have an endpoint of `GET /baz/fooBar`. The server will also prepend
13
+ * directory names, so if `bazController.js` is nested within the `ban` directory
14
+ * (e.g. `ban/bazController.js`), the endpoint will be `GET /ban/baz/fooBar`.
15
+ *
16
+ * Default routing logic can be overriden by defining custom routes in the `routes` getter. To
17
+ * provider a custom route for `getFooBar`, `routes` should return an object like:
18
+ * {getFooBar: '/ban/:accountIdd/foo/:userId/bar'}`
19
+ ***********************************************************************************************/
20
+ const StaticMethods = {
21
+ _constructAction(actionName) {
22
+ const routePrefix = this.routePrefix
23
+ const action = {};
24
+ const match = actionName.match(this.actionRegExp);
25
+
26
+ action.method = match.groups.method.toUpperCase();
27
+ action.action = actionName;
28
+ action.path = this._routeForAction(action.method, match.groups.path, routePrefix, actionName);
29
+ return action;
30
+ },
31
+
32
+
33
+ _routeForAction(method, path, routePrefix, actionName) {
34
+ if (this.routes?.[actionName]) return this.routes[actionName];
35
+
36
+ // If the path starts with Record, use :id as a path parameter
37
+ /*
38
+ class UsersController {
39
+ // Get /users
40
+ async getIndex() {}
41
+
42
+ // Get /user/:id
43
+ async getRecord() {}
44
+
45
+ // Get /user/:id/status
46
+ async getRecordStatus() {}
47
+ }
48
+ */
49
+ if (/^Record($|[A-Z])/.test(path)) {
50
+ routePrefix = `${singularize(routePrefix)}/:id`;
51
+ path = path == 'Record' ? 'Index' : path.replace(/^Record/, '');
52
+ }
53
+
54
+ if (path == 'Index') return routePrefix;
55
+
56
+ path = path.charAt(0).toLowerCase() + path.slice(1);
57
+ return `${routePrefix}/${path}`;
58
+ }
59
+ }
60
+
61
+
62
+ const StaticAccessors = {
63
+ actions: {
64
+ get() {
65
+ if (actionStore.has(this)) return actionStore.get(this);
66
+ const actionNames = this.actionNames;
67
+ const actions = actionNames.map((actionName => {
68
+ return this._constructAction(actionName);
69
+ }).bind(this));
70
+
71
+ actionStore.set(this, actions);
72
+ return actions;
73
+ }
74
+ },
75
+
76
+
77
+ routePrefix: {
78
+ get() {
79
+ const controllerName = this.name.replace(/Controller$/, '');
80
+ const controllerPrefix = controllerName.charAt(0).toLowerCase() + controllerName.slice(1);
81
+
82
+ return `${this.directoryPrefix || ''}/${controllerPrefix}`;
83
+ }
84
+ },
85
+
86
+
87
+ actionNames: {
88
+ get() {
89
+ const methodNames = Object.getOwnPropertyNames(this.prototype);
90
+ const nameRegExp = this.actionRegExp;
91
+ return methodNames.filter(name => nameRegExp.test(name));
92
+ }
93
+ },
94
+
95
+
96
+ actionRegExp: {
97
+ get() {
98
+ return new RegExp(/^(?<method>get|post|put|delete|patch|head)(?<path>[A-Z][a-zA-Z]+)$/);
99
+ }
100
+ }
101
+
102
+ }
103
+
104
+
105
+ module.exports = {
106
+ StaticAccessors,
107
+ StaticMethods,
108
+ }
@@ -0,0 +1,114 @@
1
+ const { camelize } = require('inflection');
2
+ const Errors = require('../errors');
3
+
4
+
5
+ const StaticMethods = {
6
+ parametersForAction(actionName) {
7
+ const methodName = this.parametersMethodForAction(actionName);
8
+ if (this[methodName]) return this[methodName]();
9
+ return null;
10
+ },
11
+
12
+
13
+ parametersMethodForAction(actionName) {
14
+ return `parametersFor${camelize(actionName)}`;
15
+ },
16
+ }
17
+
18
+
19
+ const InstanceMethods = {
20
+ async validateAction(actionName) {
21
+ const parameters = this.constructor.parametersForAction(actionName);
22
+ if (parameters) await this.validateParameters(parameters);
23
+ },
24
+
25
+
26
+ async validateParameters(validations) {
27
+ const errors = {};
28
+ for (const [parameterName, parameterValidations] of Object.entries(validations)) {
29
+ const parameterErrors = await this.validateParameter(this.params[parameterName], parameterValidations);
30
+ if (parameterErrors.length) errors[parameterName] = parameterErrors;
31
+ }
32
+
33
+ if (Object.keys(errors).length) throw new Errors.ValidationError('', errors);
34
+ },
35
+
36
+
37
+ async validateParameter(value, parameterValidations) {
38
+ const errors = [];
39
+
40
+ if (parameterValidations?.optional && value === undefined) return errors;
41
+
42
+ for (const [validationType, validationOptions] of Object.entries(parameterValidations)) {
43
+ const validationMethod = `validate${camelize(validationType)}`;
44
+ if (!this[validationMethod]) continue;
45
+ const error = await this[validationMethod](value, validationOptions);
46
+ if (error) errors.push(error);
47
+ }
48
+
49
+ return errors;
50
+ },
51
+
52
+
53
+ validatePresence(value) {
54
+ if (!value) return 'required';
55
+ },
56
+
57
+
58
+ validateIsInteger(value, options) {
59
+ const num = parseFloat(value);
60
+ if (!/^-?\d+$/.test(value) || isNaN(num) || !Number.isInteger(num)) return 'must be an integer';
61
+
62
+ if (options?.gte !== undefined) {
63
+ if (num < options.gte) return `must be greater than or equal to ${options.gte}`;
64
+ }
65
+ if (options?.lte !== undefined) {
66
+ if (num > options.lte) return `must be less than or equal to ${options.lte}`;
67
+ }
68
+ },
69
+
70
+
71
+ validateIsString(value, options) {
72
+ if (typeof value !== 'string') return 'must be a string';
73
+
74
+ if (options?.length?.gte !== undefined) {
75
+ if (value.length < options.length.gte) return `must be greater than or equal to ${options.length.gte} characters`;
76
+ }
77
+ if (options?.length?.lte !== undefined) {
78
+ if (value.length > options.length.lte) return `must be fewer than or equal to ${options.length.lte} characters`;
79
+ }
80
+ if (options?.regex !== undefined) {
81
+ if (!options.regex.test(value)) return `must match ${options.regex}`;
82
+ }
83
+ },
84
+
85
+
86
+ validateIsDateTime(value) {
87
+ if (new Date(value) == 'Invalid Date') return 'invalid date';
88
+ },
89
+
90
+
91
+ validateIsEnum(value, options) {
92
+ if (!options.enums.includes(value)) {
93
+ return options.error || `must be one of [${options.enums.filter(Boolean).join(', ')}]`;
94
+ }
95
+ },
96
+
97
+
98
+ validateIsBoolean(value, bool) {
99
+ if (typeof value == 'boolean' && value === bool) return;
100
+ return `must be ${bool}`;
101
+ },
102
+
103
+
104
+ async validateFunction(value, fnc) {
105
+ const error = await fnc.call(this, value);
106
+ if (error) return error;
107
+ },
108
+ }
109
+
110
+
111
+ module.exports = {
112
+ InstanceMethods,
113
+ StaticMethods
114
+ };
@@ -1,4 +1,5 @@
1
1
  const APM = require('../apm');
2
+ const cookieParser = require('cookie-parser');
2
3
  const { ControllerImporter } = require('./controllerImporter');
3
4
  const express = require('express');
4
5
  const { logger } = require('../logger');
@@ -77,6 +78,7 @@ class VidaServer {
77
78
  this.use(this.jsonParsingMiddleware);
78
79
  this.use(this.octetStreamParsingMiddleware);
79
80
  this.use(responseTime({suffix: true}))
81
+ this.use(cookieParser());
80
82
  this.use(express.static('public'))
81
83
  this.use(this.loggingMiddleware);
82
84
  this.use('/static', express.static(this.staticFilesDirectory));