├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .prettierrc.js ├── README.md ├── nodemon.json ├── package-lock.json ├── package.json ├── src ├── __tests__ │ └── server.spec.js ├── config │ ├── dev.js │ ├── index.js │ ├── prod.js │ └── testing.js ├── index.js ├── resources │ ├── item │ │ ├── __tests__ │ │ │ ├── item.controllers.spec..js │ │ │ ├── item.model.spec.js │ │ │ └── item.router.spec.js │ │ ├── item.controllers.js │ │ ├── item.model.js │ │ └── item.router.js │ ├── list │ │ ├── list.controllers.js │ │ ├── list.model.js │ │ └── list.router.js │ └── user │ │ ├── user.controllers.js │ │ ├── user.model.js │ │ └── user.router.js ├── server.js └── utils │ ├── __tests__ │ ├── auth.spec.js │ └── crud.spec.js │ ├── auth.js │ ├── crud.js │ └── db.js ├── test-db-setup.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", { 4 | "targets": { 5 | "node": "current" 6 | } 7 | }] 8 | ], 9 | "plugins": [ 10 | "@babel/plugin-proposal-class-properties", 11 | "@babel/plugin-proposal-object-rest-spread" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | sourceType: 'module' 4 | }, 5 | parser: 'babel-eslint', 6 | env: { 7 | node: true 8 | }, 9 | extends: [ 10 | 'standard', 11 | 'prettier', 12 | 'prettier/standard', 13 | 'plugin:jest/recommended' 14 | ], 15 | plugins: ['prettier', 'jest'], 16 | rules: { 17 | 'promise/catch-or-return': 'error', 18 | 'prettier/prettier': [ 19 | 'error', 20 | { 21 | 'singleQuote': true, 22 | 'semi': false 23 | } 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | dist 64 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!NOTE] 2 | > This repo is from an archived version of the course. Watch the latest version of the course on [frontendmasters.com](https://frontendmasters.com/courses/api-design-nodejs-v4/). 3 | 4 | # Course Code for [API design in Node.js with Express, v3](https://frontendmasters.com/courses/api-design-nodejs-v3/) 5 | > Scott Moss & Frontend Masters 6 | 7 | - [Resources](#resources) 8 | - [Course](#course) 9 | - [Exercises](#excercises) 10 | - [Hello world Express](#hello-world-express) 11 | - [Routing](#routing) 12 | - [Create Schemas](#create-schemas) 13 | - [Controllers](#controllers) 14 | - [Authentication](#authentication) 15 | - [Testing](#testing) 16 | 17 | ## Resources 18 | * [Slides](https://slides.com/scotups/api-design-in-node-with-express-v3/) 19 | * [Nodejs](https://nodejs.org/en/) 20 | * [Express](https://expressjs.com/) 21 | * [MongoDB](https://www.mongodb.com/) 22 | 23 | ## Course 24 | This course has two parts, slides and excercises. The slides describe the excerices in detail. Each excercise has a starting branch and solution branch. Example `lesson-1` and `lesson-1-solution`. 25 | ## Exercises 26 | ### Hello world Express 27 | * branch - `lesson-1` 28 | 29 | In this lesson you'll be creating a simple Express based API in node, just to get your feet wet. 30 | - [ ] install dependencies with yarn (prefered for version locking) or npm 31 | - [ ] create a route that sends back some json 32 | - [ ] create a route that accepts json and logs it 33 | - [ ] start the server 34 | 35 | ### Routing 36 | * branch - `lesson-2` 37 | * test command - `yarn test-routes` or `npm run test-routes` 38 | 39 | This exercise will have you creating routes and sub routers for our soon the be DB resources using Express routing and routers 40 | - [ ] create a router for the Item resource 41 | - [ ] create full crud routes and create placeholder controllers 42 | - [ ] mount router on the root server 43 | - [ ] ensure all tests pass by running test command 44 | 45 | ### Create Schemas 46 | * branch - `lesson-3` 47 | * test command - `yarn test-models` or `npm run test-models` 48 | 49 | In this exercise, you'll be taking what you learned about Mongoose and MongoDb to create a schema and model for the Item resource. 50 | 51 | - [ ] create a schema for the item resource 52 | - [ ] add the correct fields (look at test) 53 | - [ ] add the correct validations (look at test) 54 | - [ ] *extra* add compund index to ensure all tasks in a list have unique names 55 | - [ ] ensure all tests pass by running test command 56 | 57 | ### Controllers 58 | * branch - `lesson-4` 59 | * test command - `yarn test-controllers` or `npm run test-controllers` 60 | 61 | So far we have routes and models. Now we need to hook our routes up to our models so we can perfom CRUD on the models based on the routes + verbs. That's exactly what controllers do. 62 | 63 | - [ ] create CRUD resolvers in `utils/crud.js` 64 | - [ ] create controllers for the Item resources using the base crud resolvers 65 | - [ ] ensure all tests pass by running test command 66 | 67 | ### Authentication 68 | * branch - `lesson-5` 69 | * test command - `yarn test-auth` or `npm run test-auth` 70 | 71 | In this exercise you'll be locking down our API using JWT's. 72 | 73 | - [ ] create a signup controller 74 | - [ ] create a signin controller 75 | - [ ] create a protect middlware to lock down API routes 76 | - [ ] ensure all tests pass by running test command 77 | 78 | ### Testing 79 | THe other resources don't have any test, go ahead and write some! 80 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "restartable": "rs", 3 | "ignore": [ 4 | ".git", 5 | "node_modules/**/node_modules", 6 | "dist" 7 | ], 8 | "verbose": true, 9 | "execMap": { 10 | "js": "node" 11 | }, 12 | 13 | "runOnChangeOnly": false, 14 | "watch": [ 15 | "src/**/*.js", 16 | "src/**/*.graphql", 17 | "src/**/*.gql" 18 | ], 19 | "env": { 20 | "NODE_ENV": "development" 21 | }, 22 | "ext": "js,json,graphql" 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-design-v3", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Scott Moss ", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "babel src --out-dir dist", 9 | "test": "NODE_ENV=testing jest --forceExit --detectOpenHandles --silent", 10 | "test-routes": "yarn test -t router", 11 | "test-models": "yarn test -t model", 12 | "test-controllers": "yarn test -t controllers", 13 | "test-auth": "yarn test -t Authentication:", 14 | "dev": "nodemon --exec yarn restart", 15 | "restart": "rimraf dist && yarn build && yarn start", 16 | "start": "node dist/index.js" 17 | }, 18 | "dependencies": { 19 | "bcrypt": "^3.0.2", 20 | "body-parser": "^1.18.3", 21 | "cors": "^2.8.5", 22 | "cuid": "^2.1.4", 23 | "dotenv": "^6.1.0", 24 | "express": "^4.16.4", 25 | "jsonwebtoken": "^8.4.0", 26 | "lodash": "^4.17.11", 27 | "mongoose": "^5.3.13", 28 | "morgan": "^1.9.1", 29 | "validator": "^10.9.0" 30 | }, 31 | "devDependencies": { 32 | "@babel/cli": "^7.0.0", 33 | "@babel/core": "^7.0.0", 34 | "@babel/plugin-proposal-class-properties": "^7.0.0", 35 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0", 36 | "@babel/preset-env": "^7.0.0", 37 | "babel-core": "7.0.0-bridge.0", 38 | "babel-eslint": "^8.2.1", 39 | "babel-jest": "^23.4.2", 40 | "eslint": "^4.15.0", 41 | "eslint-config-prettier": "^2.9.0", 42 | "eslint-config-standard": "^11.0.0", 43 | "eslint-friendly-formatter": "^3.0.0", 44 | "eslint-loader": "^1.7.1", 45 | "eslint-plugin-import": "^2.13.0", 46 | "eslint-plugin-jest": "^21.15.1", 47 | "eslint-plugin-node": "^7.0.1", 48 | "eslint-plugin-prettier": "^2.6.2", 49 | "eslint-plugin-promise": "^3.8.0", 50 | "eslint-plugin-standard": "^3.1.0", 51 | "jest": "^23.6.0", 52 | "mock-req-res": "^1.0.2", 53 | "nodemon": "^1.18.3", 54 | "prettier": "^1.15.2", 55 | "rimraf": "^2.6.2", 56 | "supertest": "^3.3.0" 57 | }, 58 | "jest": { 59 | "verbose": true, 60 | "testURL": "http://localhost/", 61 | "testEnvironment": "node", 62 | "setupTestFrameworkScriptFile": "/test-db-setup.js", 63 | "testPathIgnorePatterns": [ 64 | "dist/" 65 | ], 66 | "restoreMocks": true 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/__tests__/server.spec.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { app } from '../server' 3 | import { User } from '../resources/user/user.model' 4 | import { newToken } from '../utils/auth' 5 | import mongoose from 'mongoose' 6 | 7 | describe('API Authentication:', () => { 8 | let token 9 | beforeEach(async () => { 10 | const user = await User.create({ email: 'a@a.com', password: 'hello' }) 11 | token = newToken(user) 12 | }) 13 | 14 | describe('api auth', () => { 15 | test('api should be locked down', async () => { 16 | let response = await request(app).get('/api/item') 17 | expect(response.statusCode).toBe(401) 18 | 19 | response = await request(app).get('/api/list') 20 | expect(response.statusCode).toBe(401) 21 | 22 | response = await request(app).get('/api/user') 23 | expect(response.statusCode).toBe(401) 24 | }) 25 | 26 | test('passes with JWT', async () => { 27 | const jwt = `Bearer ${token}` 28 | const id = mongoose.Types.ObjectId() 29 | const results = await Promise.all([ 30 | request(app) 31 | .get('/api/item') 32 | .set('Authorization', jwt), 33 | request(app) 34 | .get(`/api/item/${id}`) 35 | .set('Authorization', jwt), 36 | request(app) 37 | .post('/api/item') 38 | .set('Authorization', jwt), 39 | request(app) 40 | .put(`/api/item/${id}`) 41 | .set('Authorization', jwt), 42 | request(app) 43 | .delete(`/api/item/${id}`) 44 | .set('Authorization', jwt) 45 | ]) 46 | 47 | results.forEach(res => expect(res.statusCode).not.toBe(401)) 48 | }) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/config/dev.js: -------------------------------------------------------------------------------- 1 | export const config = { 2 | secrets: { 3 | jwt: 'learneverything' 4 | }, 5 | dbUrl: 'mongodb://localhost:27017/api-design' 6 | } 7 | -------------------------------------------------------------------------------- /src/config/index.js: -------------------------------------------------------------------------------- 1 | import { merge } from 'lodash' 2 | const env = process.env.NODE_ENV || 'development' 3 | 4 | const baseConfig = { 5 | env, 6 | isDev: env === 'development', 7 | isTest: env === 'testing', 8 | port: 3000, 9 | secrets: { 10 | jwt: process.env.JWT_SECRET, 11 | jwtExp: '100d' 12 | } 13 | } 14 | 15 | let envConfig = {} 16 | 17 | switch (env) { 18 | case 'dev': 19 | case 'development': 20 | envConfig = require('./dev').config 21 | break 22 | case 'test': 23 | case 'testing': 24 | envConfig = require('./testing').config 25 | break 26 | default: 27 | envConfig = require('./dev').config 28 | } 29 | 30 | export default merge(baseConfig, envConfig) 31 | -------------------------------------------------------------------------------- /src/config/prod.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FrontendMasters/api-design-node-v3/bf8add9e31e73f0eb309bbdbfc9d1dc370da5181/src/config/prod.js -------------------------------------------------------------------------------- /src/config/testing.js: -------------------------------------------------------------------------------- 1 | export const config = { 2 | secrets: { 3 | jwt: 'learneverything' 4 | }, 5 | dbUrl: 'mongodb://localhost:27017/api-design-test' 6 | } 7 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { start } from './server' 2 | start() 3 | -------------------------------------------------------------------------------- /src/resources/item/__tests__/item.controllers.spec..js: -------------------------------------------------------------------------------- 1 | import controllers from '../item.controllers' 2 | import { isFunction } from 'lodash' 3 | 4 | describe('item controllers', () => { 5 | test('has crud controllers', () => { 6 | const crudMethods = [ 7 | 'getOne', 8 | 'getMany', 9 | 'createOne', 10 | 'removeOne', 11 | 'updateOne' 12 | ] 13 | 14 | crudMethods.forEach(name => 15 | expect(isFunction(controllers[name])).toBe(true) 16 | ) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/resources/item/__tests__/item.model.spec.js: -------------------------------------------------------------------------------- 1 | import { Item } from '../item.model' 2 | import mongoose from 'mongoose' 3 | 4 | describe('Item model', () => { 5 | describe('schema', () => { 6 | test('name', () => { 7 | const name = Item.schema.obj.name 8 | expect(name).toEqual({ 9 | type: String, 10 | required: true, 11 | trim: true, 12 | maxlength: 50 13 | }) 14 | }) 15 | 16 | test('status', () => { 17 | const status = Item.schema.obj.status 18 | expect(status).toEqual({ 19 | type: String, 20 | required: true, 21 | enum: ['active', 'complete', 'pastdue'], 22 | default: 'active' 23 | }) 24 | }) 25 | 26 | test('notes', () => { 27 | const notes = Item.schema.obj.notes 28 | expect(notes).toEqual(String) 29 | }) 30 | 31 | test('due', () => { 32 | const due = Item.schema.obj.due 33 | expect(due).toEqual(Date) 34 | }) 35 | 36 | test('createdBy', () => { 37 | const createdBy = Item.schema.obj.createdBy 38 | expect(createdBy).toEqual({ 39 | type: mongoose.SchemaTypes.ObjectId, 40 | ref: 'user', 41 | required: true 42 | }) 43 | }) 44 | 45 | test('list', () => { 46 | const list = Item.schema.obj.list 47 | expect(list).toEqual({ 48 | type: mongoose.SchemaTypes.ObjectId, 49 | ref: 'list', 50 | required: true 51 | }) 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/resources/item/__tests__/item.router.spec.js: -------------------------------------------------------------------------------- 1 | import router from '../item.router' 2 | 3 | describe('item router', () => { 4 | test('has crud routes', () => { 5 | const routes = [ 6 | { path: '/', method: 'get' }, 7 | { path: '/:id', method: 'get' }, 8 | { path: '/:id', method: 'delete' }, 9 | { path: '/:id', method: 'put' }, 10 | { path: '/', method: 'post' } 11 | ] 12 | 13 | routes.forEach(route => { 14 | const match = router.stack.find( 15 | s => s.route.path === route.path && s.route.methods[route.method] 16 | ) 17 | expect(match).toBeTruthy() 18 | }) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /src/resources/item/item.controllers.js: -------------------------------------------------------------------------------- 1 | import { crudControllers } from '../../utils/crud' 2 | import { Item } from './item.model' 3 | 4 | export default crudControllers(Item) 5 | -------------------------------------------------------------------------------- /src/resources/item/item.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | 3 | const itemSchema = new mongoose.Schema( 4 | { 5 | name: { 6 | type: String, 7 | required: true, 8 | trim: true, 9 | maxlength: 50 10 | }, 11 | status: { 12 | type: String, 13 | required: true, 14 | enum: ['active', 'complete', 'pastdue'], 15 | default: 'active' 16 | }, 17 | notes: String, 18 | due: Date, 19 | createdBy: { 20 | type: mongoose.SchemaTypes.ObjectId, 21 | ref: 'user', 22 | required: true 23 | }, 24 | list: { 25 | type: mongoose.SchemaTypes.ObjectId, 26 | ref: 'list', 27 | required: true 28 | } 29 | }, 30 | { timestamps: true } 31 | ) 32 | 33 | itemSchema.index({ list: 1, name: 1 }, { unique: true }) 34 | 35 | export const Item = mongoose.model('item', itemSchema) 36 | -------------------------------------------------------------------------------- /src/resources/item/item.router.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import controllers from './item.controllers' 3 | 4 | const router = Router() 5 | 6 | // /api/item 7 | router 8 | .route('/') 9 | .get(controllers.getOne) 10 | .post(controllers.createOne) 11 | 12 | // /api/item/:id 13 | router 14 | .route('/:id') 15 | .get(controllers.getOne) 16 | .put(controllers.updateOne) 17 | .delete(controllers.removeOne) 18 | 19 | export default router 20 | -------------------------------------------------------------------------------- /src/resources/list/list.controllers.js: -------------------------------------------------------------------------------- 1 | import { crudControllers } from '../../utils/crud' 2 | import { List } from './list.model' 3 | 4 | export default crudControllers(List) 5 | -------------------------------------------------------------------------------- /src/resources/list/list.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | 3 | const listSchema = new mongoose.Schema( 4 | { 5 | name: { 6 | type: String, 7 | required: true, 8 | trim: true, 9 | maxlength: 50 10 | }, 11 | description: String, 12 | createdBy: { 13 | type: mongoose.SchemaTypes.ObjectId, 14 | ref: 'user', 15 | required: true 16 | } 17 | }, 18 | { timestamps: true } 19 | ) 20 | 21 | listSchema.index({ user: 1, name: 1 }, { unique: true }) 22 | 23 | export const List = mongoose.model('list', listSchema) 24 | -------------------------------------------------------------------------------- /src/resources/list/list.router.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import controllers from './list.controllers' 3 | 4 | const router = Router() 5 | 6 | // /api/list 7 | router 8 | .route('/') 9 | .get(controllers.getOne) 10 | .post(controllers.createOne) 11 | 12 | // /api/list/:id 13 | router 14 | .route('/:id') 15 | .get(controllers.getOne) 16 | .put(controllers.updateOne) 17 | .delete(controllers.removeOne) 18 | 19 | export default router 20 | -------------------------------------------------------------------------------- /src/resources/user/user.controllers.js: -------------------------------------------------------------------------------- 1 | import { User } from './user.model' 2 | 3 | export const me = (req, res) => { 4 | res.status(200).json({ data: req.user }) 5 | } 6 | 7 | export const updateMe = async (req, res) => { 8 | try { 9 | const user = await User.findByIdAndUpdate(req.user._id, req.body, { 10 | new: true 11 | }) 12 | .lean() 13 | .exec() 14 | 15 | res.status(200).json({ data: user }) 16 | } catch (e) { 17 | console.error(e) 18 | res.status(400).end() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/resources/user/user.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import bcrypt from 'bcrypt' 3 | 4 | const userSchema = new mongoose.Schema( 5 | { 6 | email: { 7 | type: String, 8 | required: true, 9 | unique: true, 10 | trim: true 11 | }, 12 | 13 | password: { 14 | type: String, 15 | required: true 16 | }, 17 | settings: { 18 | theme: { 19 | type: String, 20 | required: true, 21 | default: 'dark' 22 | }, 23 | notifications: { 24 | type: Boolean, 25 | required: true, 26 | default: true 27 | }, 28 | compactMode: { 29 | type: Boolean, 30 | required: true, 31 | default: false 32 | } 33 | } 34 | }, 35 | { timestamps: true } 36 | ) 37 | 38 | userSchema.pre('save', function(next) { 39 | if (!this.isModified('password')) { 40 | return next() 41 | } 42 | 43 | bcrypt.hash(this.password, 8, (err, hash) => { 44 | if (err) { 45 | return next(err) 46 | } 47 | 48 | this.password = hash 49 | next() 50 | }) 51 | }) 52 | 53 | userSchema.methods.checkPassword = function(password) { 54 | const passwordHash = this.password 55 | return new Promise((resolve, reject) => { 56 | bcrypt.compare(password, passwordHash, (err, same) => { 57 | if (err) { 58 | return reject(err) 59 | } 60 | 61 | resolve(same) 62 | }) 63 | }) 64 | } 65 | 66 | export const User = mongoose.model('user', userSchema) 67 | -------------------------------------------------------------------------------- /src/resources/user/user.router.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { me, updateMe } from './user.controllers' 3 | 4 | const router = Router() 5 | 6 | router.get('/', me) 7 | router.put('/', updateMe) 8 | 9 | export default router 10 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { json, urlencoded } from 'body-parser' 3 | import morgan from 'morgan' 4 | import config from './config' 5 | import cors from 'cors' 6 | import { signup, signin, protect } from './utils/auth' 7 | import { connect } from './utils/db' 8 | import userRouter from './resources/user/user.router' 9 | import itemRouter from './resources/item/item.router' 10 | import listRouter from './resources/list/list.router' 11 | 12 | export const app = express() 13 | 14 | app.disable('x-powered-by') 15 | 16 | app.use(cors()) 17 | app.use(json()) 18 | app.use(urlencoded({ extended: true })) 19 | app.use(morgan('dev')) 20 | 21 | app.post('/signup', signup) 22 | app.post('/signin', signin) 23 | 24 | app.use('/api', protect) 25 | app.use('/api/user', userRouter) 26 | app.use('/api/item', itemRouter) 27 | app.use('/api/list', listRouter) 28 | 29 | export const start = async () => { 30 | try { 31 | await connect() 32 | app.listen(config.port, () => { 33 | console.log(`REST API on http://localhost:${config.port}/api`) 34 | }) 35 | } catch (e) { 36 | console.error(e) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/__tests__/auth.spec.js: -------------------------------------------------------------------------------- 1 | import { newToken, verifyToken, signup, signin, protect } from '../auth' 2 | import mongoose from 'mongoose' 3 | import jwt from 'jsonwebtoken' 4 | import config from '../../config' 5 | import { User } from '../../resources/user/user.model' 6 | 7 | describe('Authentication:', () => { 8 | describe('newToken', () => { 9 | test('creates new jwt from user', () => { 10 | const id = 123 11 | const token = newToken({ id }) 12 | const user = jwt.verify(token, config.secrets.jwt) 13 | 14 | expect(user.id).toBe(id) 15 | }) 16 | }) 17 | 18 | describe('verifyToken', () => { 19 | test('validates jwt and returns payload', async () => { 20 | const id = 1234 21 | const token = jwt.sign({ id }, config.secrets.jwt) 22 | const user = await verifyToken(token) 23 | expect(user.id).toBe(id) 24 | }) 25 | }) 26 | 27 | describe('signup', () => { 28 | test('requires email and password', async () => { 29 | expect.assertions(2) 30 | 31 | const req = { body: {} } 32 | const res = { 33 | status(status) { 34 | expect(status).toBe(400) 35 | return this 36 | }, 37 | send(result) { 38 | expect(typeof result.message).toBe('string') 39 | } 40 | } 41 | 42 | await signup(req, res) 43 | }) 44 | 45 | test('creates user and and sends new token from user', async () => { 46 | expect.assertions(2) 47 | 48 | const req = { body: { email: 'hello@hello.com', password: '293jssh' } } 49 | const res = { 50 | status(status) { 51 | expect(status).toBe(201) 52 | return this 53 | }, 54 | async send(result) { 55 | let user = await verifyToken(result.token) 56 | user = await User.findById(user.id) 57 | .lean() 58 | .exec() 59 | expect(user.email).toBe('hello@hello.com') 60 | } 61 | } 62 | 63 | await signup(req, res) 64 | }) 65 | }) 66 | 67 | describe('signin', () => { 68 | test('requires email and password', async () => { 69 | expect.assertions(2) 70 | 71 | const req = { body: {} } 72 | const res = { 73 | status(status) { 74 | expect(status).toBe(400) 75 | return this 76 | }, 77 | send(result) { 78 | expect(typeof result.message).toBe('string') 79 | } 80 | } 81 | 82 | await signin(req, res) 83 | }) 84 | 85 | test('user must be real', async () => { 86 | expect.assertions(2) 87 | 88 | const req = { body: { email: 'hello@hello.com', password: '293jssh' } } 89 | const res = { 90 | status(status) { 91 | expect(status).toBe(401) 92 | return this 93 | }, 94 | send(result) { 95 | expect(typeof result.message).toBe('string') 96 | } 97 | } 98 | 99 | await signin(req, res) 100 | }) 101 | 102 | test('passwords must match', async () => { 103 | expect.assertions(2) 104 | 105 | await User.create({ 106 | email: 'hello@me.com', 107 | password: 'yoyoyo' 108 | }) 109 | 110 | const req = { body: { email: 'hello@me.com', password: 'wrong' } } 111 | const res = { 112 | status(status) { 113 | expect(status).toBe(401) 114 | return this 115 | }, 116 | send(result) { 117 | expect(typeof result.message).toBe('string') 118 | } 119 | } 120 | 121 | await signin(req, res) 122 | }) 123 | 124 | test('creates new token', async () => { 125 | expect.assertions(2) 126 | const fields = { 127 | email: 'hello@me.com', 128 | password: 'yoyoyo' 129 | } 130 | const savedUser = await User.create(fields) 131 | 132 | const req = { body: fields } 133 | const res = { 134 | status(status) { 135 | expect(status).toBe(201) 136 | return this 137 | }, 138 | async send(result) { 139 | let user = await verifyToken(result.token) 140 | user = await User.findById(user.id) 141 | .lean() 142 | .exec() 143 | expect(user._id.toString()).toBe(savedUser._id.toString()) 144 | } 145 | } 146 | 147 | await signin(req, res) 148 | }) 149 | }) 150 | 151 | describe('protect', () => { 152 | test('looks for Bearer token in headers', async () => { 153 | expect.assertions(2) 154 | 155 | const req = { headers: {} } 156 | const res = { 157 | status(status) { 158 | expect(status).toBe(401) 159 | return this 160 | }, 161 | end() { 162 | expect(true).toBe(true) 163 | } 164 | } 165 | 166 | await protect(req, res) 167 | }) 168 | 169 | test('token must have correct prefix', async () => { 170 | expect.assertions(2) 171 | 172 | let req = { headers: { authorization: newToken({ id: '123sfkj' }) } } 173 | let res = { 174 | status(status) { 175 | expect(status).toBe(401) 176 | return this 177 | }, 178 | end() { 179 | expect(true).toBe(true) 180 | } 181 | } 182 | 183 | await protect(req, res) 184 | }) 185 | 186 | test('must be a real user', async () => { 187 | const token = `Bearer ${newToken({ id: mongoose.Types.ObjectId() })}` 188 | const req = { headers: { authorization: token } } 189 | 190 | const res = { 191 | status(status) { 192 | expect(status).toBe(401) 193 | return this 194 | }, 195 | end() { 196 | expect(true).toBe(true) 197 | } 198 | } 199 | 200 | await protect(req, res) 201 | }) 202 | 203 | test('finds user form token and passes on', async () => { 204 | const user = await User.create({ 205 | email: 'hello@hello.com', 206 | password: '1234' 207 | }) 208 | const token = `Bearer ${newToken(user)}` 209 | const req = { headers: { authorization: token } } 210 | 211 | const next = () => {} 212 | await protect(req, {}, next) 213 | expect(req.user._id.toString()).toBe(user._id.toString()) 214 | expect(req.user).not.toHaveProperty('password') 215 | }) 216 | }) 217 | }) 218 | -------------------------------------------------------------------------------- /src/utils/__tests__/crud.spec.js: -------------------------------------------------------------------------------- 1 | import { getOne, getMany, createOne, updateOne, removeOne } from '../crud' 2 | import { List } from '../../resources/list/list.model' 3 | import { User } from '../../resources/user/user.model' 4 | import mongoose from 'mongoose' 5 | 6 | describe('crud controllers', () => { 7 | describe('getOne', async () => { 8 | test('finds by authenticated user and id', async () => { 9 | expect.assertions(2) 10 | 11 | const user = mongoose.Types.ObjectId() 12 | const list = await List.create({ name: 'list', createdBy: user }) 13 | 14 | const req = { 15 | params: { 16 | id: list._id 17 | }, 18 | user: { 19 | _id: user 20 | } 21 | } 22 | 23 | const res = { 24 | status(status) { 25 | expect(status).toBe(200) 26 | return this 27 | }, 28 | json(result) { 29 | expect(result.data._id.toString()).toBe(list._id.toString()) 30 | } 31 | } 32 | 33 | await getOne(List)(req, res) 34 | }) 35 | 36 | test('404 if no doc was found', async () => { 37 | expect.assertions(2) 38 | 39 | const user = mongoose.Types.ObjectId() 40 | 41 | const req = { 42 | params: { 43 | id: mongoose.Types.ObjectId() 44 | }, 45 | user: { 46 | _id: user 47 | } 48 | } 49 | 50 | const res = { 51 | status(status) { 52 | expect(status).toBe(400) 53 | return this 54 | }, 55 | end() { 56 | expect(true).toBe(true) 57 | } 58 | } 59 | 60 | await getOne(List)(req, res) 61 | }) 62 | }) 63 | 64 | describe('getMany', () => { 65 | test('finds array of docs by authenticated user', async () => { 66 | expect.assertions(4) 67 | 68 | const user = mongoose.Types.ObjectId() 69 | await List.create([ 70 | { name: 'list', createdBy: user }, 71 | { name: 'other', createdBy: user }, 72 | { name: 'list', createdBy: mongoose.Types.ObjectId() } 73 | ]) 74 | 75 | const req = { 76 | user: { 77 | _id: user 78 | } 79 | } 80 | 81 | const res = { 82 | status(status) { 83 | expect(status).toBe(200) 84 | return this 85 | }, 86 | json(result) { 87 | expect(result.data).toHaveLength(2) 88 | result.data.forEach(doc => expect(`${doc.createdBy}`).toBe(`${user}`)) 89 | } 90 | } 91 | 92 | await getMany(List)(req, res) 93 | }) 94 | }) 95 | 96 | describe('createOne', () => { 97 | test('creates a new doc', async () => { 98 | expect.assertions(2) 99 | 100 | const user = mongoose.Types.ObjectId() 101 | const body = { name: 'name' } 102 | 103 | const req = { 104 | user: { _id: user }, 105 | body 106 | } 107 | 108 | const res = { 109 | status(status) { 110 | expect(status).toBe(201) 111 | return this 112 | }, 113 | json(results) { 114 | expect(results.data.name).toBe(body.name) 115 | } 116 | } 117 | 118 | await createOne(List)(req, res) 119 | }) 120 | 121 | test('createdBy should be the authenticated user', async () => { 122 | expect.assertions(2) 123 | 124 | const user = mongoose.Types.ObjectId() 125 | const body = { name: 'name' } 126 | 127 | const req = { 128 | user: { _id: user }, 129 | body 130 | } 131 | 132 | const res = { 133 | status(status) { 134 | expect(status).toBe(201) 135 | return this 136 | }, 137 | json(results) { 138 | expect(`${results.data.createdBy}`).toBe(`${user}`) 139 | } 140 | } 141 | 142 | await createOne(List)(req, res) 143 | }) 144 | }) 145 | 146 | describe('updateOne', () => { 147 | test('finds doc by authenticated user and id to update', async () => { 148 | expect.assertions(3) 149 | 150 | const user = mongoose.Types.ObjectId() 151 | const list = await List.create({ name: 'name', createdBy: user }) 152 | const update = { name: 'hello' } 153 | 154 | const req = { 155 | params: { id: list._id }, 156 | user: { _id: user }, 157 | body: update 158 | } 159 | 160 | const res = { 161 | status(status) { 162 | expect(status).toBe(200) 163 | return this 164 | }, 165 | json(results) { 166 | expect(`${results.data._id}`).toBe(`${list._id}`) 167 | expect(results.data.name).toBe(update.name) 168 | } 169 | } 170 | 171 | await updateOne(List)(req, res) 172 | }) 173 | 174 | test('400 if no doc', async () => { 175 | expect.assertions(2) 176 | 177 | const user = mongoose.Types.ObjectId() 178 | const update = { name: 'hello' } 179 | 180 | const req = { 181 | params: { id: mongoose.Types.ObjectId() }, 182 | user: { _id: user }, 183 | body: update 184 | } 185 | 186 | const res = { 187 | status(status) { 188 | expect(status).toBe(400) 189 | return this 190 | }, 191 | end() { 192 | expect(true).toBe(true) 193 | } 194 | } 195 | 196 | await updateOne(List)(req, res) 197 | }) 198 | }) 199 | 200 | describe('removeOne', () => { 201 | test('first doc by authenticated user and id to remove', async () => { 202 | expect.assertions(2) 203 | 204 | const user = mongoose.Types.ObjectId() 205 | const list = await List.create({ name: 'name', createdBy: user }) 206 | 207 | const req = { 208 | params: { id: list._id }, 209 | user: { _id: user } 210 | } 211 | 212 | const res = { 213 | status(status) { 214 | expect(status).toBe(200) 215 | return this 216 | }, 217 | json(results) { 218 | expect(`${results.data._id}`).toBe(`${list._id}`) 219 | } 220 | } 221 | 222 | await removeOne(List)(req, res) 223 | }) 224 | 225 | test('400 if no doc', async () => { 226 | expect.assertions(2) 227 | const user = mongoose.Types.ObjectId() 228 | 229 | const req = { 230 | params: { id: mongoose.Types.ObjectId() }, 231 | user: { _id: user } 232 | } 233 | 234 | const res = { 235 | status(status) { 236 | expect(status).toBe(400) 237 | return this 238 | }, 239 | end() { 240 | expect(true).toBe(true) 241 | } 242 | } 243 | 244 | await removeOne(List)(req, res) 245 | }) 246 | }) 247 | }) 248 | -------------------------------------------------------------------------------- /src/utils/auth.js: -------------------------------------------------------------------------------- 1 | import config from '../config' 2 | import { User } from '../resources/user/user.model' 3 | import jwt from 'jsonwebtoken' 4 | 5 | export const newToken = user => { 6 | return jwt.sign({ id: user.id }, config.secrets.jwt, { 7 | expiresIn: config.secrets.jwtExp 8 | }) 9 | } 10 | 11 | export const verifyToken = token => 12 | new Promise((resolve, reject) => { 13 | jwt.verify(token, config.secrets.jwt, (err, payload) => { 14 | if (err) return reject(err) 15 | resolve(payload) 16 | }) 17 | }) 18 | 19 | export const signup = async (req, res) => { 20 | if (!req.body.email || !req.body.password) { 21 | return res.status(400).send({ message: 'need email and password' }) 22 | } 23 | 24 | try { 25 | const user = await User.create(req.body) 26 | const token = newToken(user) 27 | return res.status(201).send({ token }) 28 | } catch (e) { 29 | return res.status(500).end() 30 | } 31 | } 32 | 33 | export const signin = async (req, res) => { 34 | if (!req.body.email || !req.body.password) { 35 | return res.status(400).send({ message: 'need email and password' }) 36 | } 37 | 38 | const invalid = { message: 'Invalid email and passoword combination' } 39 | 40 | try { 41 | const user = await User.findOne({ email: req.body.email }) 42 | .select('email password') 43 | .exec() 44 | 45 | if (!user) { 46 | return res.status(401).send(invalid) 47 | } 48 | 49 | const match = await user.checkPassword(req.body.password) 50 | 51 | if (!match) { 52 | return res.status(401).send(invalid) 53 | } 54 | 55 | const token = newToken(user) 56 | return res.status(201).send({ token }) 57 | } catch (e) { 58 | console.error(e) 59 | res.status(500).end() 60 | } 61 | } 62 | 63 | export const protect = async (req, res, next) => { 64 | const bearer = req.headers.authorization 65 | 66 | if (!bearer || !bearer.startsWith('Bearer ')) { 67 | return res.status(401).end() 68 | } 69 | 70 | const token = bearer.split('Bearer ')[1].trim() 71 | let payload 72 | try { 73 | payload = await verifyToken(token) 74 | } catch (e) { 75 | return res.status(401).end() 76 | } 77 | 78 | const user = await User.findById(payload.id) 79 | .select('-password') 80 | .lean() 81 | .exec() 82 | 83 | if (!user) { 84 | return res.status(401).end() 85 | } 86 | 87 | req.user = user 88 | next() 89 | } 90 | -------------------------------------------------------------------------------- /src/utils/crud.js: -------------------------------------------------------------------------------- 1 | export const getOne = model => async (req, res) => { 2 | try { 3 | const doc = await model 4 | .findOne({ createdBy: req.user._id, _id: req.params.id }) 5 | .lean() 6 | .exec() 7 | 8 | if (!doc) { 9 | return res.status(400).end() 10 | } 11 | 12 | res.status(200).json({ data: doc }) 13 | } catch (e) { 14 | console.error(e) 15 | res.status(400).end() 16 | } 17 | } 18 | 19 | export const getMany = model => async (req, res) => { 20 | try { 21 | const docs = await model 22 | .find({ createdBy: req.user._id }) 23 | .lean() 24 | .exec() 25 | 26 | res.status(200).json({ data: docs }) 27 | } catch (e) { 28 | console.error(e) 29 | res.status(400).end() 30 | } 31 | } 32 | 33 | export const createOne = model => async (req, res) => { 34 | const createdBy = req.user._id 35 | try { 36 | const doc = await model.create({ ...req.body, createdBy }) 37 | res.status(201).json({ data: doc }) 38 | } catch (e) { 39 | console.error(e) 40 | res.status(400).end() 41 | } 42 | } 43 | 44 | export const updateOne = model => async (req, res) => { 45 | try { 46 | const updatedDoc = await model 47 | .findOneAndUpdate( 48 | { 49 | createdBy: req.user._id, 50 | _id: req.params.id 51 | }, 52 | req.body, 53 | { new: true } 54 | ) 55 | .lean() 56 | .exec() 57 | 58 | if (!updatedDoc) { 59 | return res.status(400).end() 60 | } 61 | 62 | res.status(200).json({ data: updatedDoc }) 63 | } catch (e) { 64 | console.error(e) 65 | res.status(400).end() 66 | } 67 | } 68 | 69 | export const removeOne = model => async (req, res) => { 70 | try { 71 | const removed = await model.findOneAndRemove({ 72 | createdBy: req.user._id, 73 | _id: req.params.id 74 | }) 75 | 76 | if (!removed) { 77 | return res.status(400).end() 78 | } 79 | 80 | return res.status(200).json({ data: removed }) 81 | } catch (e) { 82 | console.error(e) 83 | res.status(400).end() 84 | } 85 | } 86 | 87 | export const crudControllers = model => ({ 88 | removeOne: removeOne(model), 89 | updateOne: updateOne(model), 90 | getMany: getMany(model), 91 | getOne: getOne(model), 92 | createOne: createOne(model) 93 | }) 94 | -------------------------------------------------------------------------------- /src/utils/db.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import options from '../config' 3 | 4 | export const connect = (url = options.dbUrl, opts = {}) => { 5 | return mongoose.connect( 6 | url, 7 | { ...opts, useNewUrlParser: true } 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /test-db-setup.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import cuid from 'cuid' 3 | import _ from 'lodash' 4 | import { Item } from './src/resources/item/item.model' 5 | import { List } from './src/resources/list/list.model' 6 | import { User } from './src/resources/user/user.model' 7 | 8 | const models = { User, List, Item } 9 | 10 | const url = 11 | process.env.MONGODB_URI || 12 | process.env.DB_URL || 13 | 'mongodb://localhost:27017/tipe-devapi-testing' 14 | 15 | global.newId = () => { 16 | return mongoose.Types.ObjectId() 17 | } 18 | 19 | const remove = collection => 20 | new Promise((resolve, reject) => { 21 | collection.remove(err => { 22 | if (err) return reject(err) 23 | resolve() 24 | }) 25 | }) 26 | 27 | beforeEach(async done => { 28 | const db = cuid() 29 | function clearDB() { 30 | return Promise.all(_.map(mongoose.connection.collections, c => remove(c))) 31 | } 32 | 33 | if (mongoose.connection.readyState === 0) { 34 | try { 35 | await mongoose.connect( 36 | url + db, 37 | { 38 | useNewUrlParser: true, 39 | autoIndex: true 40 | } 41 | ) 42 | await clearDB() 43 | await Promise.all(Object.keys(models).map(name => models[name].init())) 44 | } catch (e) { 45 | console.log('connection error') 46 | console.error(e) 47 | throw e 48 | } 49 | } else { 50 | await clearDB() 51 | } 52 | done() 53 | }) 54 | afterEach(async done => { 55 | await mongoose.connection.db.dropDatabase() 56 | await mongoose.disconnect() 57 | return done() 58 | }) 59 | afterAll(done => { 60 | return done() 61 | }) 62 | --------------------------------------------------------------------------------