pvanand commited on
Commit
408cdab
·
verified ·
1 Parent(s): cf9c29b

Upload 36 files

Browse files
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ Dockerfile
2
+ docker-compose.yml
3
+ node_modules
4
+ logos.png
5
+ LICENSE
6
+ README.md
7
+ npm-debug.log
8
+
.editorconfig ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ root = true
2
+
3
+ [*]
4
+ end_of_line = lf
5
+ charset = utf-8
6
+ trim_trailing_whitespace = true
7
+ insert_final_newline = true
8
+ indent_style = space
9
+ indent_size = 2
10
+
11
+ [*.md]
12
+ trim_trailing_whitespace = false
13
+
14
+ [*.js]
15
+ indent_style = space
16
+ indent_size = 2
.eslintrc.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "eslint:recommended",
3
+ "env": {
4
+ "es6": true,
5
+ "node": true,
6
+ "browser": false,
7
+ "jest": true
8
+ },
9
+ "parserOptions": {
10
+ "ecmaVersion": 2020
11
+ },
12
+ "rules": {
13
+ "no-unused-vars": "warn",
14
+ "no-console": "off"
15
+ }
16
+ }
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+
6
+ # Dependency directories
7
+ node_modules
8
+
9
+ # Optional npm cache directory
10
+ .npm
11
+
12
+ # Auto-Generated Jest MongoDB config
13
+ globalConfig.json
.nvmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ 16.13.1
.prettierrc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "trailingComma": "es5"
3
+ }
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:16
2
+ LABEL MAINTAINER Michael Hueter <mthueter@gmail.com>
3
+
4
+ RUN npm install pm2@latest --global --quiet
5
+
6
+ WORKDIR /usr/src/app
7
+ COPY package*.json ./
8
+
9
+ # Bundle app source
10
+ COPY . .
11
+
12
+ RUN npm ci --only=production
13
+
14
+ EXPOSE 8080
15
+
16
+ CMD ["pm2-runtime", "./config/pm2.json"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2018 Michael Hueter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,10 +1,61 @@
1
- ---
2
- title: Node Docker Boilerplate
3
- emoji: 📈
4
- colorFrom: red
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RESTful API Server Boilerplate
2
+
3
+ ## Featuring Docker, Node, Express, MongoDB, Mongoose, & NGINX
4
+
5
+ ![Tech Logos](./logos.png)
6
+
7
+ ## License & Purpose
8
+
9
+ MIT License. This is something I've used in production before with success that I found useful for quickly bootstrapping RESTful APIs. You can fork and clone and take this apart without giving me any credit for anything. If you like it, you can star the repo ⭐️ or follow me on GitHub.
10
+
11
+ Feel free to make an issue or PR if you want to suggest ideas / fixes.
12
+
13
+ ## About
14
+
15
+ This configuration is a backend [RESTful API](https://en.wikipedia.org/wiki/Representational_state_transfer) boilerplate with the following pieces:
16
+
17
+ - [Docker](https://www.docker.com/) as the container service to isolate the environment.
18
+ - [Node.js](https://nodejs.org/en/) (Long-Term-Support Version) as the run-time environment to run JavaScript.
19
+ - [Express.js](https://expressjs.com/) as the server framework / controller layer
20
+ - [MongoDB](https://www.mongodb.com/) as the database layer
21
+ - [Mongoose](https://mongoosejs.com/) as the "ODM" / model layer
22
+ - [NGINX](https://docs.nginx.com/nginx/admin-guide/content-cache/content-caching/) as a proxy / content-caching layer
23
+
24
+ ## How to Install & Run
25
+
26
+ You will need to first download and install [Docker Desktop](https://www.docker.com/products/docker-desktop) or [Linux equivalent](https://docs.docker.com/install/linux/docker-ce/ubuntu/).
27
+
28
+ 0. Fork/Clone the repo
29
+ 1. Run `docker-compose up` to start three containers:
30
+ - the MongoDB database container
31
+ - the Node.js app container
32
+ - the NGINX proxy container
33
+ 1. Server is accessible at `http://localhost:8080` if you have Docker for Windows or Mac. Use `http://localhost` without specifying the port to hit the NGINX proxy. On Linux, you may need to hit the IP Address of the docker-machine rather than `localhost` (port rules are the same.)
34
+
35
+ ## How to Run Tests
36
+
37
+ Currently, tests are run outside of the Docker container (unfortunately for now). The tests use an in-memory version of MongoDB. You should be able to run `npm install` followed by `npm test` to run everything (assuming you have the LTS version of Node installed on your machine).
38
+
39
+ ## App Structure
40
+
41
+ - the boilerplate entity is called "Thing" and all the routes are based on the thing resource. When you want to start building a real API, you can probably just do a global find and replace for thing, but mind the case-sensitivity.
42
+ - most folders have an `index.js` which simply exports the contents of all the files in those folders. This is to make importing things around the app slightly easier, since you can just `require` the folder name and destructure the functions you are looking for. Check out [this part of the Node.js docs](https://nodejs.org/api/modules.html#modules_folders_as_modules) for more info.
43
+
44
+ **\_\_tests\_\_**
45
+
46
+ - this folder contains unit and integration tests both run using `npm test` which in turn uses [Jest](https://jestjs.io/)
47
+
48
+ **./app**
49
+
50
+ - `handlers` are Express.js route handlers that have `request`, `response`, and `next` parameters.
51
+ - `helpers` are raw JS "classes" and utility functions for use across the app
52
+ - `models` are [Mongoose schema](https://mongoosejs.com/docs/guide.html) definitions and associated models
53
+ - `routers` are RESTful route declarations using [express.Router module](https://expressjs.com/en/guide/routing.html) that utilize the functions in `handlers`
54
+ - `schemas` are [JSONSchema](https://json-schema.org/understanding-json-schema/index.html) validation schemas for creating or updating a Thing. Pro-tip: use [JSONSchema.net](https://jsonschema.net/) to generate schemas based on examples for you.
55
+ - `app.js` is what builds and configures the express app
56
+ - `config.js` is the app-specific config that you will want to customize for your app
57
+ - `index.js` is the entrypoint that actually starts the Express server
58
+
59
+ **./config**
60
+
61
+ - config contains NGINX proxy configuration, the production pm2 configuration (the process-runner of choice), and the Jest configuration to run MongoDB in memory
__tests__/integration.js ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * These tests currently only work if you have a local MongoDB database
3
+ */
4
+ const request = require("supertest");
5
+ const mongoose = require("mongoose");
6
+ const app = require("../app/app");
7
+ const { Thing } = require("../app/models");
8
+
9
+ const exampleThing = {
10
+ name: "Example",
11
+ number: 5,
12
+ stuff: ["cats", "dogs"],
13
+ url: "https://google.com",
14
+ };
15
+
16
+ beforeEach(async () => {
17
+ const testThing = new Thing(exampleThing);
18
+ await testThing.save();
19
+ });
20
+
21
+ afterEach(async () => {
22
+ await mongoose.connection.dropCollection("things");
23
+ });
24
+
25
+ afterAll(async () => {
26
+ await mongoose.disconnect();
27
+ });
28
+
29
+ describe("GET /things", () => {
30
+ test("Get a list of things", async () => {
31
+ let response = await request(app).get("/things");
32
+ expect(response.body).toEqual([exampleThing]);
33
+ });
34
+ });
35
+
36
+ describe("POST /things", () => {
37
+ test("Create a mini new Thing", async () => {
38
+ let response = await request(app).post("/things").send({ name: "A Thing" });
39
+ expect(response.body).toEqual({ name: "A Thing", stuff: [] });
40
+ });
41
+ test("Create a full new Thing", async () => {
42
+ const fullThing = {
43
+ name: "Other Thing",
44
+ stuff: ["cats", "dogs"],
45
+ number: 5,
46
+ url: "http://google.com",
47
+ };
48
+ let response = await request(app).post("/things").send(fullThing);
49
+ expect(response.body).toEqual(fullThing);
50
+
51
+ let duplicateResponse = await request(app)
52
+ .post("/things")
53
+ .send({ name: "Other Thing" });
54
+ expect(duplicateResponse.status).toEqual(409);
55
+ });
56
+ });
57
+
58
+ describe("PATCH /things/:name", () => {
59
+ test("Update a thing's name", async () => {
60
+ let response = await request(app)
61
+ .patch("/things/Example")
62
+ .send({ name: "New Name" });
63
+ expect(response.body).toEqual({ ...exampleThing, name: "New Name" });
64
+ });
65
+ });
66
+
67
+ describe("DELETE /things/:name", () => {
68
+ test("Delete a thing name", async () => {
69
+ let response = await request(app).delete("/things/Example");
70
+ expect(response.body).toEqual(exampleThing);
71
+ });
72
+ });
__tests__/unit.js ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { APIError, parseSkipLimit } = require("../app/helpers");
2
+
3
+ describe("Helper Functions", () => {
4
+ describe("parseSkipLimit()", () => {
5
+ it("should return a number if a valid limit is passed", () => {
6
+ const limit = "6";
7
+ const validatedLimit = parseSkipLimit(limit);
8
+ expect(validatedLimit).toBe(6);
9
+ });
10
+ it("should return an API Error if a non-numeric limit is passed", () => {
11
+ const limit = "foo";
12
+ const validatedLimit = parseSkipLimit(limit);
13
+ expect(validatedLimit).toBeInstanceOf(APIError);
14
+ });
15
+ it('should return an API Error if zero is passed when type is "limit"', () => {
16
+ const limit = "0";
17
+ const validatedLimit = parseSkipLimit(limit);
18
+ expect(validatedLimit).toBeInstanceOf(APIError);
19
+ });
20
+ it('should return zero if zero is passed when the type is "skip"', () => {
21
+ const skip = "0";
22
+ const validatedLimit = parseSkipLimit(skip, 1000, "skip");
23
+ expect(validatedLimit).toBe(0);
24
+ });
25
+ it("should return an API Error if a negative limit is passed", () => {
26
+ const limit = "-1";
27
+ const validatedLimit = parseSkipLimit(limit);
28
+ expect(validatedLimit).toBeInstanceOf(APIError);
29
+ });
30
+ it("should return an API Error if a limit > max is passed", () => {
31
+ const maximum = 100;
32
+ const limit = "101";
33
+ const validatedLimit = parseSkipLimit(limit, maximum);
34
+ expect(validatedLimit).toBeInstanceOf(APIError);
35
+ });
36
+ it("should return a number if a valid numeric limit is passed", () => {
37
+ const limit = "5";
38
+ const validatedLimit = parseSkipLimit(limit);
39
+ expect(validatedLimit).toBe(5);
40
+ });
41
+ });
42
+ });
app/app.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // npm packages
2
+ const dotenv = require("dotenv");
3
+ const express = require("express");
4
+
5
+ // app imports
6
+ const { connectToDatabase, globalResponseHeaders } = require("./config");
7
+ const { errorHandler } = require("./handlers");
8
+ const { thingsRouter } = require("./routers");
9
+
10
+ // global constants
11
+ dotenv.config();
12
+ const app = express();
13
+ const {
14
+ bodyParserHandler,
15
+ globalErrorHandler,
16
+ fourOhFourHandler,
17
+ fourOhFiveHandler,
18
+ } = errorHandler;
19
+
20
+ // database
21
+ connectToDatabase();
22
+
23
+ // body parser setup
24
+ app.use(express.urlencoded({ extended: true }));
25
+ app.use(express.json({ type: "*/*" }));
26
+ app.use(bodyParserHandler); // error handling specific to body parser only
27
+
28
+ // response headers setup; CORS
29
+ app.use(globalResponseHeaders);
30
+
31
+ app.use("/things", thingsRouter);
32
+
33
+ // catch-all for 404 "Not Found" errors
34
+ app.get("*", fourOhFourHandler);
35
+ // catch-all for 405 "Method Not Allowed" errors
36
+ app.all("*", fourOhFiveHandler);
37
+
38
+ app.use(globalErrorHandler);
39
+
40
+ /**
41
+ * This file does NOT run the app. It merely builds and configures it then exports it.config
42
+ * This is for integration tests with supertest (see __tests__).
43
+ */
44
+ module.exports = app;
app/config.js ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const mongoose = require("mongoose");
2
+
3
+ const APP_NAME = "Boilerplate API";
4
+ const ENV = process.env.NODE_ENV;
5
+ const PORT = process.env.PORT || 8080;
6
+
7
+ /**
8
+ * Connect to mongoose asynchronously or bail out if it fails
9
+ */
10
+ async function connectToDatabase() {
11
+ const MONGODB_URI =
12
+ process.env.MONGODB_URI || "mongodb://mongodb/boilerplate";
13
+
14
+ mongoose.Promise = Promise;
15
+ if (ENV === "development" || ENV === "test") {
16
+ mongoose.set("debug", true);
17
+ }
18
+
19
+ try {
20
+ await mongoose.connect(MONGODB_URI, {
21
+ autoIndex: false,
22
+ useNewUrlParser: true,
23
+ useUnifiedTopology: true,
24
+ });
25
+ console.log(`${APP_NAME} successfully connected to database.`);
26
+ } catch (error) {
27
+ console.log(error);
28
+ process.exit(1);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Configuration middleware to enable cors and set some other allowed headers.
34
+ * You can also just use the 'cors' package.
35
+ */
36
+ function globalResponseHeaders(request, response, next) {
37
+ response.header("Access-Control-Allow-Origin", "*");
38
+ response.header(
39
+ "Access-Control-Allow-Headers",
40
+ "Origin, Accept, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers, Authorization"
41
+ );
42
+ response.header(
43
+ "Access-Control-Allow-Methods",
44
+ "POST,GET,PATCH,DELETE,OPTIONS"
45
+ );
46
+ response.header("Content-Type", "application/json");
47
+ return next();
48
+ }
49
+
50
+ module.exports = {
51
+ APP_NAME,
52
+ ENV,
53
+ PORT,
54
+ connectToDatabase,
55
+ globalResponseHeaders,
56
+ };
app/handlers/error.js ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { APP_NAME } = require("../config");
2
+ const { APIError } = require("../helpers");
3
+
4
+ function bodyParserHandler(error, request, response, next) {
5
+ if (error instanceof SyntaxError || error instanceof TypeError) {
6
+ // console.error(error);
7
+ return next(new APIError(400, "Bad Request", "Malformed JSON."));
8
+ }
9
+ }
10
+
11
+ function fourOhFourHandler(request, response, next) {
12
+ return next(
13
+ new APIError(
14
+ 404,
15
+ "Resource Not Found",
16
+ `${request.path} is not valid path to a ${APP_NAME} resource.`
17
+ )
18
+ );
19
+ }
20
+
21
+ function fourOhFiveHandler(request, response, next) {
22
+ return next(
23
+ new APIError(
24
+ 405,
25
+ "Method Not Allowed",
26
+ `${request.method} method is not supported at ${request.path}.`
27
+ )
28
+ );
29
+ }
30
+
31
+ function globalErrorHandler(error, request, response, next) {
32
+ let err = error;
33
+ if (!(error instanceof APIError)) {
34
+ err = new APIError(500, error.type, error.message);
35
+ }
36
+
37
+ return response.status(err.status).json(err);
38
+ }
39
+
40
+ module.exports = {
41
+ bodyParserHandler,
42
+ fourOhFourHandler,
43
+ fourOhFiveHandler,
44
+ globalErrorHandler
45
+ };
app/handlers/index.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ exports.errorHandler = require("./error");
2
+ exports.thingHandler = require("./thing");
3
+ exports.thingsHandler = require("./things");
app/handlers/thing.js ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // npm packages
2
+ const { validate } = require("jsonschema");
3
+
4
+ // app imports
5
+ const { Thing } = require("../models");
6
+ const { APIError } = require("../helpers");
7
+ const { thingNewSchema, thingUpdateSchema } = require("../schemas");
8
+
9
+ /**
10
+ * Validate the POST request body and create a new Thing
11
+ */
12
+ async function createThing(request, response, next) {
13
+ const validation = validate(request.body, thingNewSchema);
14
+ if (!validation.valid) {
15
+ return next(
16
+ new APIError(
17
+ 400,
18
+ "Bad Request",
19
+ validation.errors.map(e => e.stack).join(". ")
20
+ )
21
+ );
22
+ }
23
+
24
+ try {
25
+ const newThing = await Thing.createThing(new Thing(request.body));
26
+ return response.status(201).json(newThing);
27
+ } catch (err) {
28
+ return next(err);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Get a single thing
34
+ * @param {String} name - the name of the Thing to retrieve
35
+ */
36
+ async function readThing(request, response, next) {
37
+ const { name } = request.params;
38
+ try {
39
+ const thing = await Thing.readThing(name);
40
+ return response.json(thing);
41
+ } catch (err) {
42
+ return next(err);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Update a single thing
48
+ * @param {String} name - the name of the Thing to update
49
+ */
50
+ async function updateThing(request, response, next) {
51
+ const { name } = request.params;
52
+
53
+ const validation = validate(request.body, thingUpdateSchema);
54
+ if (!validation.valid) {
55
+ return next(
56
+ new APIError(
57
+ 400,
58
+ "Bad Request",
59
+ validation.errors.map(e => e.stack).join(". ")
60
+ )
61
+ );
62
+ }
63
+
64
+ try {
65
+ const thing = await Thing.updateThing(name, request.body);
66
+ return response.json(thing);
67
+ } catch (err) {
68
+ return next(err);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Remove a single thing
74
+ * @param {String} name - the name of the Thing to remove
75
+ */
76
+ async function deleteThing(request, response, next) {
77
+ const { name } = request.params;
78
+ try {
79
+ const deleteMsg = await Thing.deleteThing(name);
80
+ return response.json(deleteMsg);
81
+ } catch (err) {
82
+ return next(err);
83
+ }
84
+ }
85
+
86
+ module.exports = {
87
+ createThing,
88
+ readThing,
89
+ updateThing,
90
+ deleteThing
91
+ };
app/handlers/things.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // app imports
2
+ const { Thing } = require("../models");
3
+ const { APIError, parseSkipLimit } = require("../helpers");
4
+
5
+ /**
6
+ * List all the things. Query params ?skip=0&limit=1000 by default
7
+ */
8
+ async function readThings(request, response, next) {
9
+ /* pagination validation */
10
+ let skip = parseSkipLimit(request.query.skip) || 0;
11
+ let limit = parseSkipLimit(request.query.limit, 1000) || 1000;
12
+ if (skip instanceof APIError) {
13
+ return next(skip);
14
+ } else if (limit instanceof APIError) {
15
+ return next(limit);
16
+ }
17
+
18
+ try {
19
+ const things = await Thing.readThings({}, {}, skip, limit);
20
+ return response.json(things);
21
+ } catch (err) {
22
+ return next(err);
23
+ }
24
+ }
25
+
26
+ module.exports = {
27
+ readThings
28
+ };
app/helpers/APIError.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Class representing an API Error Response with a related HTTP Status Code **/
2
+ class APIError extends Error {
3
+ /**
4
+ * Create an Error Object
5
+ * @param {Number} status - The HTTP Status Code (e.g. 404)
6
+ * @param {String} title - The title corresponding to the Status Code (e.g. Bad Request)
7
+ * @param {String} message - Specific information about what caused the error
8
+ */
9
+ constructor(
10
+ status = 500,
11
+ title = "Internal Server Error",
12
+ message = "An unknown server error occurred."
13
+ ) {
14
+ super(message);
15
+ this.status = status;
16
+ this.title = title;
17
+ this.message = message;
18
+ }
19
+ toJSON() {
20
+ const { status, title, message } = this;
21
+ return {
22
+ error: {
23
+ status,
24
+ title,
25
+ message
26
+ }
27
+ };
28
+ }
29
+ }
30
+
31
+ module.exports = APIError;
app/helpers/index.js ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ exports.APIError = require("./APIError");
2
+ exports.parseSkipLimit = require("./parseSkipLimit");
app/helpers/parseSkipLimit.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const APIError = require("./APIError");
2
+ /**
3
+ * Validate the 'limit' and `skip` query params
4
+ * @param {String} val - limit or skip param
5
+ * @param {Number} max - the max value (default 1000)
6
+ * @param {String} type - what we're validating (skip or limit, default limit)
7
+ * @return {Number} numerical form of the val
8
+ */
9
+ function parseSkipLimit(val, max = 1000, type = "limit") {
10
+ if (!val) {
11
+ return null;
12
+ }
13
+ const min = type === "skip" ? 0 : 1;
14
+ const num = +val;
15
+
16
+ if (!Number.isInteger(num)) {
17
+ return new APIError(
18
+ 400,
19
+ "Bad Request",
20
+ `Invalid ${type}: '${val}', ${type} needs to be an integer.`
21
+ );
22
+ } else if (num < min || (max && num > max)) {
23
+ return new APIError(
24
+ 400,
25
+ "Bad Request",
26
+ `${num} is out of range for ${type} -- it should be between ${min} and ${max}.`
27
+ );
28
+ }
29
+
30
+ return num;
31
+ }
32
+
33
+ module.exports = parseSkipLimit;
app/models/Thing.js ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // npm packages
2
+ const mongoose = require("mongoose");
3
+
4
+ // app imports
5
+ const { APIError } = require("../helpers");
6
+
7
+ // globals
8
+ const Schema = mongoose.Schema;
9
+
10
+ const thingSchema = new Schema({
11
+ name: String,
12
+ number: Number,
13
+ stuff: [String],
14
+ url: String
15
+ });
16
+
17
+ thingSchema.statics = {
18
+ /**
19
+ * Create a Single New Thing
20
+ * @param {object} newThing - an instance of Thing
21
+ * @returns {Promise<Thing, APIError>}
22
+ */
23
+ async createThing(newThing) {
24
+ const duplicate = await this.findOne({ name: newThing.name });
25
+ if (duplicate) {
26
+ throw new APIError(
27
+ 409,
28
+ "Thing Already Exists",
29
+ `There is already a thing with name '${newThing.name}'.`
30
+ );
31
+ }
32
+ const thing = await newThing.save();
33
+ return thing.toObject();
34
+ },
35
+ /**
36
+ * Delete a single Thing
37
+ * @param {String} name - the Thing's name
38
+ * @returns {Promise<Thing, APIError>}
39
+ */
40
+ async deleteThing(name) {
41
+ const deleted = await this.findOneAndRemove({ name });
42
+ if (!deleted) {
43
+ throw new APIError(404, "Thing Not Found", `No thing '${name}' found.`);
44
+ }
45
+ return deleted.toObject();
46
+ },
47
+ /**
48
+ * Get a single Thing by name
49
+ * @param {String} name - the Thing's name
50
+ * @returns {Promise<Thing, APIError>}
51
+ */
52
+ async readThing(name) {
53
+ const thing = await this.findOne({ name });
54
+
55
+ if (!thing) {
56
+ throw new APIError(404, "Thing Not Found", `No thing '${name}' found.`);
57
+ }
58
+ return thing.toObject();
59
+ },
60
+ /**
61
+ * Get a list of Things
62
+ * @param {Object} query - pre-formatted query to retrieve things.
63
+ * @param {Object} fields - a list of fields to select or not in object form
64
+ * @param {String} skip - number of docs to skip (for pagination)
65
+ * @param {String} limit - number of docs to limit by (for pagination)
66
+ * @returns {Promise<Things, APIError>}
67
+ */
68
+ async readThings(query, fields, skip, limit) {
69
+ const things = await this.find(query, fields)
70
+ .skip(skip)
71
+ .limit(limit)
72
+ .sort({ name: 1 })
73
+ .exec();
74
+ if (!things.length) {
75
+ return [];
76
+ }
77
+ return things.map(thing => thing.toObject());
78
+ },
79
+ /**
80
+ * Patch/Update a single Thing
81
+ * @param {String} name - the Thing's name
82
+ * @param {Object} thingUpdate - the json containing the Thing attributes
83
+ * @returns {Promise<Thing, APIError>}
84
+ */
85
+ async updateThing(name, thingUpdate) {
86
+ const thing = await this.findOneAndUpdate({ name }, thingUpdate, {
87
+ new: true
88
+ });
89
+ if (!thing) {
90
+ throw new APIError(404, "Thing Not Found", `No thing '${name}' found.`);
91
+ }
92
+ return thing.toObject();
93
+ }
94
+ };
95
+
96
+ /* Transform with .toObject to remove __v and _id from response */
97
+ if (!thingSchema.options.toObject) thingSchema.options.toObject = {};
98
+ thingSchema.options.toObject.transform = (doc, ret) => {
99
+ const transformed = ret;
100
+ delete transformed._id;
101
+ delete transformed.__v;
102
+ return transformed;
103
+ };
104
+
105
+ /** Ensure MongoDB Indices **/
106
+ thingSchema.index({ name: 1, number: 1 }, { unique: true }); // example compound idx
107
+
108
+ module.exports = mongoose.model("Thing", thingSchema);
app/models/index.js ADDED
@@ -0,0 +1 @@
 
 
1
+ exports.Thing = require("./Thing");
app/routers/index.js ADDED
@@ -0,0 +1 @@
 
 
1
+ exports.thingsRouter = require("./things");
app/routers/things.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // npm packages
2
+ const express = require("express");
3
+
4
+ // app imports
5
+ const { thingHandler, thingsHandler } = require("../handlers");
6
+
7
+ // globals
8
+ const router = new express.Router();
9
+ const { readThings } = thingsHandler;
10
+ const { createThing, readThing, updateThing, deleteThing } = thingHandler;
11
+
12
+ /* All the Things Route */
13
+ router
14
+ .route("")
15
+ .get(readThings)
16
+ .post(createThing);
17
+
18
+ /* Single Thing by Name Route */
19
+ router
20
+ .route("/:name")
21
+ .get(readThing)
22
+ .patch(updateThing)
23
+ .delete(deleteThing);
24
+
25
+ module.exports = router;
app/schemas/index.js ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ exports.thingNewSchema = require('./thingNew');
2
+ exports.thingUpdateSchema = require('./thingUpdate');
app/schemas/thingNew.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "http://json-schema.org/draft-04/schema#",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "name": {
7
+ "type": "string"
8
+ },
9
+ "number": {
10
+ "type": "integer",
11
+ "minimum": 0
12
+ },
13
+ "stuff": {
14
+ "type": "array",
15
+ "uniqueItems": true,
16
+ "minItems": 1,
17
+ "items": {
18
+ "type": "string"
19
+ }
20
+ },
21
+ "url": {
22
+ "type": "string",
23
+ "format": "uri"
24
+ }
25
+ },
26
+ "required": ["name"]
27
+ }
app/schemas/thingUpdate.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "http://json-schema.org/draft-04/schema#",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "name": {
7
+ "type": "string"
8
+ },
9
+ "number": {
10
+ "type": "integer",
11
+ "minimum": 0
12
+ },
13
+ "stuff": {
14
+ "type": "array",
15
+ "uniqueItems": true,
16
+ "minItems": 1,
17
+ "items": {
18
+ "type": "string"
19
+ }
20
+ },
21
+ "url": {
22
+ "type": "string",
23
+ "format": "uri"
24
+ }
25
+ },
26
+ "required": ["name"]
27
+ }
app/server.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ const { APP_NAME, PORT } = require("./config");
2
+ const app = require("./app");
3
+
4
+ app.listen(PORT, () => {
5
+ console.log(`${APP_NAME} is listening on port ${PORT}...`);
6
+ });
config/jest/globalSetup.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ const { MongoMemoryServer } = require("mongodb-memory-server");
2
+
3
+ module.exports = async function () {
4
+ global.__MONGO_MEMORY_SERVER_INSTANCE__ = await MongoMemoryServer.create();
5
+ process.env.MONGODB_URI = `mongodb://localhost:27017/boilerplate-test`;
6
+ };
config/jest/globalTeardown.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ module.exports = async function () {
2
+ await global.__MONGO_MEMORY_SERVER_INSTANCE__.stop();
3
+ };
config/nginx/nginx.conf ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ worker_processes auto;
2
+ worker_rlimit_nofile 100000;
3
+ worker_cpu_affinity auto;
4
+ events {
5
+ worker_connections 1024;
6
+ use epoll;
7
+ multi_accept on;
8
+ }
9
+
10
+ http {
11
+ proxy_cache_path /etc/nginx/cache levels=1:2 keys_zone=cache:8m max_size=1000m inactive=600m;
12
+ proxy_temp_path /etc/nginx/cache/tmp;
13
+
14
+ server {
15
+ listen 80;
16
+ server_name boilerplate-api;
17
+
18
+ access_log /dev/stdout;
19
+ error_log /dev/stdout error;
20
+
21
+ # gzip Compression
22
+ gzip on;
23
+ gzip_proxied any;
24
+ gzip_min_length 1000;
25
+ gzip_types application/json;
26
+ gzip_vary on;
27
+
28
+ # Keepalive and Timeouts
29
+ client_body_timeout 30;
30
+ client_header_timeout 30;
31
+ keepalive_timeout 30;
32
+ keepalive_requests 100000;
33
+ reset_timedout_connection on;
34
+ send_timeout 30;
35
+
36
+ # Proxy Settings
37
+ # Left as defaults
38
+
39
+ # File Descriptor cache
40
+ open_file_cache max=200000 inactive=20s;
41
+ open_file_cache_valid 30s;
42
+ open_file_cache_min_uses 2;
43
+ open_file_cache_errors on;
44
+
45
+ # Other Settings
46
+ client_max_body_size 0;
47
+ underscores_in_headers on;
48
+ tcp_nopush on;
49
+ tcp_nodelay on;
50
+ sendfile on;
51
+
52
+ location /nginx_status {
53
+ stub_status on;
54
+ access_log off;
55
+ }
56
+
57
+ location / {
58
+ proxy_ignore_headers "Set-Cookie";
59
+ proxy_hide_header "Set-Cookie";
60
+
61
+ proxy_cache cache;
62
+ proxy_cache_key $scheme$proxy_host$uri$is_args$args;
63
+ proxy_cache_valid 200 10m;
64
+ proxy_cache_bypass $arg_nocache;
65
+
66
+ add_header X-Cache-Status $upstream_cache_status;
67
+
68
+ proxy_set_header Host $host;
69
+ proxy_set_header X-Real-IP $remote_addr;
70
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
71
+ proxy_set_header X-Forwarded-Host $server_name;
72
+ proxy_pass http://app:8080;
73
+ }
74
+
75
+ }
76
+ }
config/pm2.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "apps": [
3
+ {
4
+ "name": "Boilerplate-API",
5
+ "script": "./app/server.js",
6
+ "instances": "max",
7
+ "exec_mode": "cluster"
8
+ }
9
+ ]
10
+ }
docker-compose.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3"
2
+ services:
3
+ app:
4
+ build: .
5
+ command: pm2-dev ./app/server.js
6
+ environment:
7
+ NODE_ENV: development
8
+ depends_on:
9
+ - mongodb
10
+ ports:
11
+ - "8080:8080"
12
+ volumes:
13
+ - .:/usr/src/app
14
+ - ./node_modules:/home/nodejs/node_modules
15
+
16
+ nginx:
17
+ image: nginx:latest
18
+ links:
19
+ - app
20
+ ports:
21
+ - "80:80"
22
+ volumes:
23
+ - ./config/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
24
+
25
+ mongodb:
26
+ image: mongo:4
27
+ command: mongod
28
+ ports:
29
+ - "27017:27017"
logos.png ADDED
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "author": "Michael Hueter <mthueter@gmail.com>",
3
+ "version": "2.0.0",
4
+ "name": "docker-node-express-boilerplate",
5
+ "description": "Boilerplate Dockerized Express code to launch new RESTful APIs.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git@github.com:mhueter/docker-node-express-boilerplate.git"
9
+ },
10
+ "main": "app/app.js",
11
+ "scripts": {
12
+ "test": "jest"
13
+ },
14
+ "license": "MIT",
15
+ "dependencies": {
16
+ "dotenv": "10.0.0",
17
+ "express": "4.17.2",
18
+ "jsonschema": "1.4.0",
19
+ "mongoose": "6.1.4"
20
+ },
21
+ "devDependencies": {
22
+ "babel-eslint": "^10.1.0",
23
+ "babel-preset-es2017": "^6.24.1",
24
+ "eslint": "^8.5.0",
25
+ "jest": "^27.4.5",
26
+ "jest-environment-node": "^27.4.4",
27
+ "mongodb-memory-server": "^8.1.0",
28
+ "supertest": "^6.1.6"
29
+ },
30
+ "jest": {
31
+ "globalSetup": "./config/jest/globalSetup.js",
32
+ "globalTeardown": "./config/jest/globalTeardown.js"
33
+ }
34
+ }