@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 CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@stanlemon/server",
3
- "version": "0.1.1",
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": ">=17.0"
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": "11.0.0",
21
- "express": "^4.17.1",
22
- "express-rate-limit": "^5.5.1",
23
- "helmet": "^4.6.0",
24
- "http-proxy-middleware": "^2.0.1",
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
  },
@@ -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.message });
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.message });
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.message });
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.message });
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.message });
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
  }
@@ -8,59 +8,58 @@ import { createProxyMiddleware } from "http-proxy-middleware";
8
8
 
9
9
  dotenv.config();
10
10
 
11
- const DEVELOPMENT = "development";
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(opts = { port: 3000 }) {
16
- const app = express();
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.use(limiter);
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 !== DEVELOPMENT) {
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: WEBPACK_URL,
40
+ target: webpack,
44
41
  changeOrigin: true,
45
42
  })
46
43
  );
47
44
 
48
45
  app.get(
49
- "/ws",
46
+ "/",
50
47
  createProxyMiddleware({
51
- target: WEBPACK_URL.replace("http://", "ws://"),
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: WEBPACK_URL,
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
- const server = app.listen(opts.port);
71
+ if (start) {
72
+ const server = app.listen(port);
73
73
 
74
- /* eslint-disable no-console */
75
- console.log("Starting in %s mode", NODE_ENV);
76
- console.log(
77
- "Listening at http://%s:%s",
78
- server.address().address === "::" ? "localhost" : server.address().address,
79
- server.address().port
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
  }
@@ -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
  }
@@ -1,7 +1,16 @@
1
- import { camelCase, isArray, isDate } from "lodash-es";
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(obj) {
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
+ }