@stanlemon/server 0.1.1 → 0.2.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/package.json +8 -7
- package/src/asyncJsonHandler.js +39 -19
- package/src/createAppServer.js +34 -31
- package/src/formatInput.js +4 -1
- package/src/formatOutput.js +11 -2
- package/src/index.js +2 -1
- package/src/schemaHandler.js +44 -0
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stanlemon/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "A basic express web server setup.",
|
|
5
5
|
"author": "Stan Lemon <stanlemon@users.noreply.github.com>",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"engines": {
|
|
8
|
-
"node": ">=
|
|
8
|
+
"node": ">=16.13.0"
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
11
|
"main": "./src/index.js",
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"compression": "^1.7.4",
|
|
20
|
-
"dotenv": "
|
|
21
|
-
"express": "^4.17.
|
|
22
|
-
"express-rate-limit": "^
|
|
23
|
-
"helmet": "^
|
|
24
|
-
"http-proxy-middleware": "^2.0.
|
|
20
|
+
"dotenv": "16.0.0",
|
|
21
|
+
"express": "^4.17.3",
|
|
22
|
+
"express-rate-limit": "^6.3.0",
|
|
23
|
+
"helmet": "^5.0.2",
|
|
24
|
+
"http-proxy-middleware": "^2.0.4",
|
|
25
|
+
"joi": "^17.6.0",
|
|
25
26
|
"lodash-es": "^4.17.21",
|
|
26
27
|
"morgan": "^1.10.0"
|
|
27
28
|
},
|
package/src/asyncJsonHandler.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPlainObject } from "lodash-es";
|
|
1
2
|
import { formatInput } from "./index.js";
|
|
2
3
|
import { formatOutput } from "./index.js";
|
|
3
4
|
|
|
@@ -5,16 +6,7 @@ export default asyncJsonHandler;
|
|
|
5
6
|
|
|
6
7
|
export function asyncJsonHandler(fn) {
|
|
7
8
|
return async (req, res /*, next */) => {
|
|
8
|
-
const input =
|
|
9
|
-
// For POST & PUT requests we'll use the body
|
|
10
|
-
// For everything else, it's query string parameters
|
|
11
|
-
...(req.method === "POST" || req.method === "PUT"
|
|
12
|
-
? formatInput(req.body)
|
|
13
|
-
: req.query || {}),
|
|
14
|
-
// Always make sure the request parameters override everything else
|
|
15
|
-
// eg. a param for 'id' is not overridden by a query string 'id' or 'req.body.id'
|
|
16
|
-
...req.params,
|
|
17
|
-
};
|
|
9
|
+
const input = buildInput(req);
|
|
18
10
|
|
|
19
11
|
try {
|
|
20
12
|
const output = await fn(input);
|
|
@@ -26,22 +18,22 @@ export function asyncJsonHandler(fn) {
|
|
|
26
18
|
} catch (ex) {
|
|
27
19
|
// TODO: Add better support for validation errors
|
|
28
20
|
if (ex.message === "Bad Request") {
|
|
29
|
-
res.status(400).json({ error: ex
|
|
21
|
+
res.status(400).json({ error: formatError(ex) });
|
|
30
22
|
return;
|
|
31
23
|
}
|
|
32
24
|
|
|
33
25
|
if (ex.message === "Not Authorized") {
|
|
34
|
-
res.status(403).json({ error: ex
|
|
26
|
+
res.status(403).json({ error: formatError(ex) });
|
|
35
27
|
return;
|
|
36
28
|
}
|
|
37
29
|
|
|
38
30
|
if (ex.message === "Not Found") {
|
|
39
|
-
res.status(404).json({ error: ex
|
|
31
|
+
res.status(404).json({ error: formatError(ex) });
|
|
40
32
|
return;
|
|
41
33
|
}
|
|
42
34
|
|
|
43
35
|
if (ex.message === "Already Exists") {
|
|
44
|
-
res.status(409).json({ error: ex
|
|
36
|
+
res.status(409).json({ error: formatError(ex) });
|
|
45
37
|
return;
|
|
46
38
|
}
|
|
47
39
|
|
|
@@ -52,7 +44,7 @@ export function asyncJsonHandler(fn) {
|
|
|
52
44
|
// eslint-disable-next-line no-console
|
|
53
45
|
console.error(ex);
|
|
54
46
|
|
|
55
|
-
res.status(500).json({ error: ex
|
|
47
|
+
res.status(500).json({ error: formatError(ex) });
|
|
56
48
|
} else {
|
|
57
49
|
res.status(500).json({ error: "Something went wrong" });
|
|
58
50
|
}
|
|
@@ -60,14 +52,39 @@ export function asyncJsonHandler(fn) {
|
|
|
60
52
|
};
|
|
61
53
|
}
|
|
62
54
|
|
|
55
|
+
function buildInput(req) {
|
|
56
|
+
const query = isPlainObject(req.query) ? req.query : {};
|
|
57
|
+
const params = isPlainObject(req.params) ? req.params : {};
|
|
58
|
+
|
|
59
|
+
if (req.method === "POST" || req.method === "PUT") {
|
|
60
|
+
const body = formatInput(req.body);
|
|
61
|
+
|
|
62
|
+
// This is a weird payload, don't try any of our append magic
|
|
63
|
+
if (!isPlainObject(body)) {
|
|
64
|
+
return body;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Always make sure the request parameters override everything else
|
|
68
|
+
// eg. a param for 'id' is not overridden by a query string 'id' or 'req.body.id'
|
|
69
|
+
return { ...body, ...query, ...params };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return params;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatError(ex) {
|
|
76
|
+
return ex.message + (ex.details ? ": " + ex.details : "");
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
export class BadRequestException extends Error {
|
|
64
80
|
static MESSAGE = "Bad Request";
|
|
65
81
|
static CODE = 400;
|
|
66
82
|
|
|
67
|
-
constructor() {
|
|
83
|
+
constructor(details = null) {
|
|
68
84
|
super(BadRequestException.MESSAGE);
|
|
69
85
|
this.name = "BadRequestException";
|
|
70
86
|
this.code = BadRequestException.CODE;
|
|
87
|
+
this.details = details;
|
|
71
88
|
}
|
|
72
89
|
}
|
|
73
90
|
|
|
@@ -75,10 +92,11 @@ export class NotAuthorizedException extends Error {
|
|
|
75
92
|
static MESSAGE = "Not Authorized";
|
|
76
93
|
static CODE = 403;
|
|
77
94
|
|
|
78
|
-
constructor() {
|
|
95
|
+
constructor(details = null) {
|
|
79
96
|
super(NotAuthorizedException.MESSAGE);
|
|
80
97
|
this.name = "NotAuthorizedException";
|
|
81
98
|
this.code = NotAuthorizedException.CODE;
|
|
99
|
+
this.details = details;
|
|
82
100
|
}
|
|
83
101
|
}
|
|
84
102
|
|
|
@@ -86,10 +104,11 @@ export class NotFoundException extends Error {
|
|
|
86
104
|
static MESSAGE = "Not Found";
|
|
87
105
|
static CODE = 404;
|
|
88
106
|
|
|
89
|
-
constructor() {
|
|
107
|
+
constructor(details = null) {
|
|
90
108
|
super(NotFoundException.MESSAGE);
|
|
91
109
|
this.name = "NotFoundException";
|
|
92
110
|
this.code = NotFoundException.CODE;
|
|
111
|
+
this.details = details;
|
|
93
112
|
}
|
|
94
113
|
}
|
|
95
114
|
|
|
@@ -97,9 +116,10 @@ export class AlreadyExistsException extends Error {
|
|
|
97
116
|
static MESSAGE = "Already Exists";
|
|
98
117
|
static CODE = 409;
|
|
99
118
|
|
|
100
|
-
constructor() {
|
|
119
|
+
constructor(details = null) {
|
|
101
120
|
super(AlreadyExistsException.MESSAGE);
|
|
102
121
|
this.name = "AlreadyExistsException";
|
|
103
122
|
this.code = AlreadyExistsException.CODE;
|
|
123
|
+
this.details = details;
|
|
104
124
|
}
|
|
105
125
|
}
|
package/src/createAppServer.js
CHANGED
|
@@ -8,59 +8,58 @@ import { createProxyMiddleware } from "http-proxy-middleware";
|
|
|
8
8
|
|
|
9
9
|
dotenv.config();
|
|
10
10
|
|
|
11
|
-
const
|
|
12
|
-
const { NODE_ENV = DEVELOPMENT, WEBPACK_URL = "http://localhost:8080" } =
|
|
13
|
-
process.env;
|
|
11
|
+
export const DEFAULTS = { port: 3000, webpack: false, start: true };
|
|
14
12
|
|
|
15
|
-
export default function createAppServer(
|
|
16
|
-
const
|
|
17
|
-
app.use(express.json());
|
|
18
|
-
|
|
19
|
-
const limiter = rateLimit({
|
|
20
|
-
windowMs: 15 * 60 * 1000, // X minutes
|
|
21
|
-
max: 100, // limit each IP to Y requests per windowMs
|
|
22
|
-
});
|
|
13
|
+
export default function createAppServer(options) {
|
|
14
|
+
const { port, webpack, start } = { ...DEFAULTS, ...options };
|
|
23
15
|
|
|
24
|
-
app
|
|
16
|
+
const app = express();
|
|
17
|
+
app.use(express.json({ strict: false }));
|
|
25
18
|
|
|
26
19
|
if (process.env.NODE_ENV !== "test") {
|
|
27
20
|
app.use(morgan("combined"));
|
|
28
21
|
}
|
|
29
22
|
|
|
30
23
|
if (process.env.NODE_ENV === "production") {
|
|
24
|
+
const limiter = rateLimit({
|
|
25
|
+
windowMs: 5 * 60 * 1000, // 5 minutes
|
|
26
|
+
max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
|
|
27
|
+
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
|
28
|
+
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
app.use(limiter);
|
|
31
32
|
app.use(compression());
|
|
32
33
|
app.use(helmet());
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
if (NODE_ENV !==
|
|
36
|
-
app.use(express.static("./dist"));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (NODE_ENV === DEVELOPMENT && WEBPACK_URL) {
|
|
36
|
+
if (webpack !== false && process.env.NODE_ENV !== "production") {
|
|
40
37
|
app.get(
|
|
41
38
|
"/*.js",
|
|
42
39
|
createProxyMiddleware({
|
|
43
|
-
target:
|
|
40
|
+
target: webpack,
|
|
44
41
|
changeOrigin: true,
|
|
45
42
|
})
|
|
46
43
|
);
|
|
47
44
|
|
|
48
45
|
app.get(
|
|
49
|
-
"/
|
|
46
|
+
"/",
|
|
50
47
|
createProxyMiddleware({
|
|
51
|
-
target:
|
|
48
|
+
target: webpack,
|
|
52
49
|
changeOrigin: true,
|
|
53
|
-
ws: true,
|
|
54
50
|
})
|
|
55
51
|
);
|
|
56
52
|
|
|
57
53
|
app.get(
|
|
58
|
-
"/",
|
|
54
|
+
"/ws",
|
|
59
55
|
createProxyMiddleware({
|
|
60
|
-
target:
|
|
56
|
+
target: webpack,
|
|
61
57
|
changeOrigin: true,
|
|
58
|
+
ws: true,
|
|
62
59
|
})
|
|
63
60
|
);
|
|
61
|
+
} else if (webpack !== false) {
|
|
62
|
+
app.use(express.static("./dist"));
|
|
64
63
|
}
|
|
65
64
|
|
|
66
65
|
app.get("/health", (req, res) => {
|
|
@@ -69,15 +68,19 @@ export default function createAppServer(opts = { port: 3000 }) {
|
|
|
69
68
|
});
|
|
70
69
|
});
|
|
71
70
|
|
|
72
|
-
|
|
71
|
+
if (start) {
|
|
72
|
+
const server = app.listen(port);
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
/* eslint-disable no-console */
|
|
75
|
+
console.log("Starting in %s mode", process.env.NODE_ENV);
|
|
76
|
+
console.log(
|
|
77
|
+
"Listening at http://%s:%s",
|
|
78
|
+
server.address().address === "::"
|
|
79
|
+
? "localhost"
|
|
80
|
+
: server.address().address,
|
|
81
|
+
server.address().port
|
|
82
|
+
);
|
|
83
|
+
}
|
|
81
84
|
|
|
82
85
|
return app;
|
|
83
86
|
}
|
package/src/formatInput.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { snakeCase } from "lodash-es";
|
|
1
|
+
import { isArray, snakeCase } from "lodash-es";
|
|
2
2
|
import convertCase from "./convertCase.js";
|
|
3
3
|
|
|
4
4
|
export default function formatInput(obj) {
|
|
5
|
+
if (isArray(obj)) {
|
|
6
|
+
return obj.map((v) => formatInput(v));
|
|
7
|
+
}
|
|
5
8
|
return convertCase(obj, formatInput, snakeCase);
|
|
6
9
|
}
|
package/src/formatOutput.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
camelCase,
|
|
3
|
+
isPlainObject,
|
|
4
|
+
isArray,
|
|
5
|
+
isDate,
|
|
6
|
+
isEmpty,
|
|
7
|
+
omit,
|
|
8
|
+
} from "lodash-es";
|
|
2
9
|
import convertCase from "./convertCase.js";
|
|
3
10
|
|
|
4
|
-
export default function formatOutput(
|
|
11
|
+
export default function formatOutput(o, omittedFields = []) {
|
|
12
|
+
const obj =
|
|
13
|
+
isPlainObject(o) && !isEmpty(omittedFields) ? omit(o, omittedFields) : o;
|
|
5
14
|
if (isDate(obj)) {
|
|
6
15
|
return obj.toISOString();
|
|
7
16
|
}
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { default as createAppServer } from "./createAppServer.js";
|
|
1
|
+
export { default as createAppServer, DEFAULTS } from "./createAppServer.js";
|
|
2
2
|
export { default as convertCase } from "./convertCase.js";
|
|
3
3
|
export { default as formatInput } from "./formatInput.js";
|
|
4
4
|
export { default as formatOutput } from "./formatOutput.js";
|
|
5
5
|
export * from "./asyncJsonHandler.js";
|
|
6
|
+
export { default as schemaHandler } from "./schemaHandler.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import asyncJsonHandler from "./asyncJsonHandler.js";
|
|
2
|
+
|
|
3
|
+
export default function schemaHandler(schema, fn) {
|
|
4
|
+
return async (req, res, next) => {
|
|
5
|
+
// Validate the input schema
|
|
6
|
+
try {
|
|
7
|
+
const value = await schema.validateAsync(req.body, {
|
|
8
|
+
// False here will not allow keys that are not part of the schema
|
|
9
|
+
allowUnknown: false,
|
|
10
|
+
// True here will strip the unknown keys from the returned value
|
|
11
|
+
stripUnknown: true,
|
|
12
|
+
// Ensure that all rules are evaluated, by default Joi stops on the first error
|
|
13
|
+
abortEarly: false,
|
|
14
|
+
// Customized error messages
|
|
15
|
+
messages: {
|
|
16
|
+
"any.invalid": "{{#label}} is invalid",
|
|
17
|
+
"any.required": "{{#label}} is required",
|
|
18
|
+
"boolean.base": "{{#label}} must be true or false",
|
|
19
|
+
"string.empty": "{{#label}} is required",
|
|
20
|
+
"string.email": "{{#label}} must be a valid email address",
|
|
21
|
+
"string.min":
|
|
22
|
+
"{{#label}} must be at least {{#limit}} characters long",
|
|
23
|
+
"string.max":
|
|
24
|
+
"{{#label}} cannot be more than {{#limit}} characters long",
|
|
25
|
+
"number.base": "{{#label}} must be a number",
|
|
26
|
+
"date.base": "{{#label}} must be a valid date",
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// eslint-disable-next-line require-atomic-updates
|
|
31
|
+
req.body = value;
|
|
32
|
+
|
|
33
|
+
// Wrap all of these in our async handler
|
|
34
|
+
await asyncJsonHandler(fn)(req, res, next);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
res.status(400).json({
|
|
37
|
+
errors: Object.assign.apply(
|
|
38
|
+
null,
|
|
39
|
+
error.details.map((d) => ({ [d.path]: d.message }))
|
|
40
|
+
),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|