Spaces:
Sleeping
Sleeping
Upload 36 files
Browse files- .dockerignore +8 -0
- .editorconfig +16 -0
- .eslintrc.json +16 -0
- .gitignore +13 -0
- .nvmrc +1 -0
- .prettierrc +3 -0
- Dockerfile +16 -0
- LICENSE +21 -0
- README.md +61 -10
- __tests__/integration.js +72 -0
- __tests__/unit.js +42 -0
- app/app.js +44 -0
- app/config.js +56 -0
- app/handlers/error.js +45 -0
- app/handlers/index.js +3 -0
- app/handlers/thing.js +91 -0
- app/handlers/things.js +28 -0
- app/helpers/APIError.js +31 -0
- app/helpers/index.js +2 -0
- app/helpers/parseSkipLimit.js +33 -0
- app/models/Thing.js +108 -0
- app/models/index.js +1 -0
- app/routers/index.js +1 -0
- app/routers/things.js +25 -0
- app/schemas/index.js +2 -0
- app/schemas/thingNew.json +27 -0
- app/schemas/thingUpdate.json +27 -0
- app/server.js +6 -0
- config/jest/globalSetup.js +6 -0
- config/jest/globalTeardown.js +3 -0
- config/nginx/nginx.conf +76 -0
- config/pm2.json +10 -0
- docker-compose.yml +29 -0
- logos.png +0 -0
- package-lock.json +0 -0
- package.json +34 -0
.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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# RESTful API Server Boilerplate
|
| 2 |
+
|
| 3 |
+
## Featuring Docker, Node, Express, MongoDB, Mongoose, & NGINX
|
| 4 |
+
|
| 5 |
+

|
| 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 |
+
}
|