├── .editorconfig ├── .eslintrc ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── DOCUMENTATION.md ├── README.md ├── bin ├── runMocha.js └── runServer.js ├── config ├── development.config.js ├── production.config.js └── test.config.js ├── doc └── .keep ├── package.json ├── src ├── app.js ├── middleware │ ├── handleErrors.js │ ├── handleNotFound.js │ ├── index.js │ ├── validateAccessToken.js │ └── validateUserRole.js ├── utils │ ├── db.js │ └── errors.js └── v1 │ ├── endpoints │ ├── items │ │ ├── _apidoc.js │ │ ├── index.js │ │ └── schemas.js │ └── user │ │ ├── _apidoc.js │ │ ├── index.js │ │ └── schemas.js │ ├── index.js │ └── models │ ├── Item.js │ └── User.js └── test ├── e2e ├── .keep ├── items.spec.js └── user.spec.js ├── mocha.opts ├── setup.js ├── testUtils.js └── unit ├── .keep └── user.spec.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.json] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | // http://eslint.org/docs/rules 2 | { 3 | "parser": "babel-eslint", 4 | "env": { 5 | "node": true, 6 | "mocha": true 7 | }, 8 | "rules": { 9 | "strict": 0, 10 | "quotes": [2, "single"], 11 | "no-underscore-dangle": 0, 12 | "no-unused-vars": 1, 13 | "no-unused-expressions": 0, 14 | "new-cap": 0, 15 | "no-extra-boolean-cast": 0, 16 | "yoda": 0, 17 | "no-use-before-define": 0, 18 | "no-shadow": 0 19 | }, 20 | "globals": { 21 | "expect": true, 22 | "should": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | *.log 4 | *.map 5 | 6 | example 7 | examples 8 | 9 | doc/v1 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 4 | - 0.12 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Coding style 2 | 3 | > All code in any code-base should look like a single person typed it, no matter how many people contributed. 4 | 5 | This section describes coding style guide of the repo. You might not agree with it and that's fine but **if you're going to send PRs treat this guide as a law**. 6 | 7 | This module uses latest ES6/ES7 features with the help of [Babel transpiler](http://babeljs.io/). 8 | 9 | ##### There are not too much of rules to follow: 10 | 11 | - indent style is 4 spaces 12 | - always use single quotes 13 | - one space after `if`, `for`, `while`, etc. 14 | - no spaces between `(`,`)` and statement content 15 | - use one `let`/`const` per variable unless you don't assign any values to it (and it's short enough) 16 | - always use strict comparisons: `===` and `!==` 17 | - use semicolons 18 | - don't use comma-first notation 19 | 20 | ##### These tools will help your IDE to remind you with some of the rules listed above: 21 | 22 | - [EditorConfig](http://editorconfig.org) 23 | - [ESLint](http://eslint.org) 24 | -------------------------------------------------------------------------------- /DOCUMENTATION.md: -------------------------------------------------------------------------------- 1 | # Express API Sample Documentation v0.0.0 2 | 3 | Sample API ready to be used in different client app boilerplates and playgrounds. 4 | 5 | - [Item](#item) 6 | - [Create item](#create-item) 7 | - [Delete item](#delete-item) 8 | - [Read item](#read-item) 9 | - [Update item](#update-item) 10 | 11 | - [User](#user) 12 | - [Get User](#get-user) 13 | - [Get User Items](#get-user-items) 14 | - [Login User](#login-user) 15 | - [Signup User](#signup-user) 16 | 17 | 18 | 19 | # Item 20 | 21 | ## Create item 22 | 23 |

Creates new artist's item. Only artists can add items.

24 | 25 | POST /items 26 | 27 | ### Headers 28 | 29 | | Name | Type | Description | 30 | |---------|-----------|--------------------------------------| 31 | | X-Access-Token | String |

Required Access token of logged in user

| 32 | 33 | ### Parameters 34 | 35 | | Name | Type | Description | 36 | |---------|-----------|--------------------------------------| 37 | | title |

String

|

Item title text

| 38 | | description |

String

| **optional**

Item description text

| 39 | | isPublic |

Boolean

| **optional**

Is item visible for listeners

| 40 | 41 | ### Success Response 42 | 43 | Success-Response 44 | 45 | ``` 46 | HTTP/1.1 200 OK 47 | { 48 | "_id": "T4laEftw4kF4Hjx3", 49 | "owner": "john.doe@example.com", 50 | "title": "Basket Case", 51 | "description": "Seventh track and third single from Green Day third album, Dookie (1994).", 52 | "isPublic": true 53 | } 54 | ``` 55 | ## Delete item 56 | 57 |

Delete artist's item by id.

58 | 59 | DELETE /items/:id 60 | 61 | ### Headers 62 | 63 | | Name | Type | Description | 64 | |---------|-----------|--------------------------------------| 65 | | X-Access-Token | String |

Required Access token of logged in user

| 66 | 67 | ### Success Response 68 | 69 | Success-Response 70 | 71 | ``` 72 | HTTP/1.1 204 OK 73 | ``` 74 | ## Read item 75 | 76 |

Get artist's item by id.

77 | 78 | GET /items/:id 79 | 80 | ### Headers 81 | 82 | | Name | Type | Description | 83 | |---------|-----------|--------------------------------------| 84 | | X-Access-Token | String |

Required Access token of logged in user

| 85 | 86 | ### Success Response 87 | 88 | Success-Response 89 | 90 | ``` 91 | HTTP/1.1 200 OK 92 | { 93 | "_id": "T4laEftw4kF4Hjx3", 94 | "owner": "john.doe@example.com", 95 | "title": "Basket Case", 96 | "description": "Seventh track and third single from Green Day third album, Dookie (1994).", 97 | "isPublic": true 98 | } 99 | ``` 100 | ## Update item 101 | 102 |

Update artist's item by id.

103 | 104 | PUT /items/:id 105 | 106 | ### Headers 107 | 108 | | Name | Type | Description | 109 | |---------|-----------|--------------------------------------| 110 | | X-Access-Token | String |

Required Access token of logged in user

| 111 | 112 | ### Parameters 113 | 114 | | Name | Type | Description | 115 | |---------|-----------|--------------------------------------| 116 | | title |

String

|

Item title text

| 117 | | description |

String

| **optional**

Item description text

| 118 | | isPublic |

Boolean

| **optional**

Is item visible for listeners

| 119 | 120 | ### Success Response 121 | 122 | Success-Response 123 | 124 | ``` 125 | HTTP/1.1 200 OK 126 | { 127 | "_id": "T4laEftw4kF4Hjx3", 128 | "owner": "john.doe@example.com", 129 | "title": "Basket Case", 130 | "description": "Seventh track and third single from Green Day third album, Dookie (1994).", 131 | "isPublic": true 132 | } 133 | ``` 134 | # User 135 | 136 | ## Get User 137 | 138 |

Returns authenticated user info.

139 | 140 | GET /user/me 141 | 142 | 143 | ### Success Response 144 | 145 | Success-Response 146 | 147 | ``` 148 | HTTP/1.1 200 OK 149 | { 150 | "accessToken": "ZG1tbTJAZXhhbXBsZS5jb207MTQzNTE0NzYyOTAxNjs1Yzc0MTdmNmM3MGQ2MmYzMjFhNWE4NGYwODQ5ZmU5NTM1Nzg5NTE2", 151 | "user": { 152 | "email": "john.doe@example.com", 153 | "firstName": "John", 154 | "lastName": "Doe", 155 | "role": "artist" 156 | } 157 | } 158 | ``` 159 | ## Get User Items 160 | 161 |

Returns user with role "artist" created items.

162 | 163 | GET /user/items 164 | 165 | 166 | ### Success Response 167 | 168 | Success-Response 169 | 170 | ``` 171 | HTTP/1.1 200 OK 172 | [{ 173 | "_id": "T4laEftw4kF4Hjx3", 174 | "owner": "john.doe@example.com", 175 | "title": "Basket Case", 176 | "description": "Seventh track and third single from Green Day third album, Dookie (1994).", 177 | "isPublic": true 178 | }] 179 | ``` 180 | ## Login User 181 | 182 |

Logins already created user.

183 | 184 | POST /user/login 185 | 186 | 187 | ### Parameters 188 | 189 | | Name | Type | Description | 190 | |---------|-----------|--------------------------------------| 191 | | email |

String

|

Required User email

| 192 | | password |

String

|

Required User password

| 193 | 194 | ### Success Response 195 | 196 | Success-Response 197 | 198 | ``` 199 | HTTP/1.1 200 OK 200 | { 201 | "accessToken": "ZG1tbTJAZXhhbXBsZS5jb207MTQzNTE0NzYyOTAxNjs1Yzc0MTdmNmM3MGQ2MmYzMjFhNWE4NGYwODQ5ZmU5NTM1Nzg5NTE2", 202 | "user": { 203 | "email": "john.doe@example.com", 204 | "firstName": "John", 205 | "lastName": "Doe", 206 | "role": "artist" 207 | } 208 | } 209 | ``` 210 | ## Signup User 211 | 212 |

Adds new user with role "artist" or "listener" to database and sends welcome email

213 | 214 | POST /user/signup 215 | 216 | 217 | ### Parameters 218 | 219 | | Name | Type | Description | 220 | |---------|-----------|--------------------------------------| 221 | | email |

String

|

Required User email

| 222 | | password |

String

|

Required User password

| 223 | | firstName |

String

|

Required User first name

| 224 | | lastName |

String

|

Required User last name

| 225 | | role |

String

|

Required User role

| 226 | 227 | ### Success Response 228 | 229 | Success-Response 230 | 231 | ``` 232 | HTTP/1.1 200 OK 233 | { 234 | "accessToken": "ZG1tbTJAZXhhbXBsZS5jb207MTQzNTE0NzYyOTAxNjs1Yzc0MTdmNmM3MGQ2MmYzMjFhNWE4NGYwODQ5ZmU5NTM1Nzg5NTE2", 235 | "user": { 236 | "email": "john.doe@example.com", 237 | "firstName": "John", 238 | "lastName": "Doe", 239 | "role": "artist" 240 | } 241 | } 242 | ``` 243 | 244 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Express API Sample 2 | 3 | [![build status](http://img.shields.io/travis/voronianski/express-api-sample.svg?style=flat)](https://travis-ci.org/voronianski/express-api-sample.js) 4 | [![Dependency Status](http://david-dm.org/voronianski/express-api-sample.svg)](http://david-dm.org/voronianski/express-api-sample) 5 | 6 | > Sample API server powered by express.js which provides login/password user authentication and basic "items" CRUD interface. Easy to use for different playgrounds and client app boilerplates. 7 | 8 | ## Usage 9 | 10 | Just clone this repository, install dependencies and start application: 11 | 12 | ```bash 13 | git clone git@github.com:voronianski/express-api-sample.git 14 | cd express-api-sample 15 | npm install 16 | npm run start-dev # with watcher turned on 17 | ``` 18 | 19 | It will generate documentation of all endpoints and start server with [nodemon]() on a port specified in environment config. Default urls will be `http://localhost:8081/v1` for API endpoints and `http://localhost:8081/doc` for documentation. 20 | 21 | ### [Documentation in markdown](https://github.com/voronianski/express-api-sample/blob/master/DOCUMENTATION.md) 22 | 23 | ## What's inside? 24 | 25 | - [express.js](http://expressjs.com) framework 26 | - [nedb](https://github.com/louischatriot/nedb) as **in-memory** data storage 27 | - endpoints schema validation by [is-express-schema-valid](https://github.com/voronianski/is-express-schema-valid) 28 | - app configuration by [c0nfig](https://github.com/voronianski/c0nfig) 29 | - all code is written ES6/ES7 and transpiled by [babel](http://babeljs.io) 30 | - [npm scripts](https://github.com/voronianski/express-api-sample/blob/master/package.json#L6) for task automation 31 | - well tested with [mocha](http://mochajs.org), [chai](http://chaijs.com) and [supertest](https://github.com/visionmedia/supertest) 32 | - [apidoc](http://apidocjs.com) for documentation generation 33 | 34 | --- 35 | 36 | **MIT Licensed** 37 | -------------------------------------------------------------------------------- /bin/runMocha.js: -------------------------------------------------------------------------------- 1 | require('./runServer'); 2 | require('../node_modules/mocha/bin/_mocha'); 3 | -------------------------------------------------------------------------------- /bin/runServer.js: -------------------------------------------------------------------------------- 1 | require('babel/register')({ stage: 0 }); 2 | require('../src/app'); 3 | -------------------------------------------------------------------------------- /config/development.config.js: -------------------------------------------------------------------------------- 1 | var host = require('network-address')(); 2 | 3 | module.exports = { 4 | port: process.env.PORT || process.env.NODE_PORT || 8081, 5 | host: host, 6 | apiVersion: process.env.API_VERSION || 1, 7 | appUrl: 'http://$(host):$(port)', 8 | apiUrl: '$(appUrl)/v$(apiVersion)', 9 | bcrypt: { 10 | hashRounds: 10 11 | }, 12 | auth: { 13 | cookieName: 'auth_token', 14 | signKey: 'c88afe1f6aa4b3c7982695ddd1cdd200bcd96662', 15 | tokenTTL: 1000 * 60 * 60 * 24 * 30 // 30 days 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /config/production.config.js: -------------------------------------------------------------------------------- 1 | var host = require('network-address')(); 2 | 3 | module.exports = { 4 | port: process.env.PORT || process.env.NODE_PORT || 8081, 5 | host: host, 6 | apiVersion: process.env.API_VERSION || 1, 7 | appUrl: 'http://$(host):$(port)', 8 | apiUrl: '$(appUrl)/v$(apiVersion)', 9 | bcrypt: { 10 | hashRounds: 10 11 | }, 12 | auth: { 13 | cookieName: 'auth_token', 14 | signKey: 'c88afe1f6aa4b3c7982695ddd1cdd200bcd96662', 15 | tokenTTL: 1000 * 60 * 60 * 24 * 30 // 30 days 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /config/test.config.js: -------------------------------------------------------------------------------- 1 | var host = require('network-address')(); 2 | 3 | module.exports = { 4 | port: process.env.PORT || process.env.NODE_PORT || 8081, 5 | host: host, 6 | apiVersion: process.env.API_VERSION || 1, 7 | appUrl: 'http://$(host):$(port)', 8 | apiUrl: '$(appUrl)/v$(apiVersion)', 9 | bcrypt: { 10 | hashRounds: 1 11 | }, 12 | auth: { 13 | cookieName: 'auth_token', 14 | signKey: 'c88afe1f6aa4b3c7982695ddd1cdd200bcd96662', 15 | tokenTTL: 1000 * 60 * 60 * 24 * 30 // 30 days 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /doc/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voronianski/express-api-sample/d91cf37c28b31e6d076074d54abf8f092a47583a/doc/.keep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-api-sample", 3 | "version": "0.0.0", 4 | "description": "Express API Sample", 5 | "main": "bin/runServer.js", 6 | "scripts": { 7 | "nodemon": "nodemon -w ./src bin/runServer -e js,jsx,json", 8 | "start-dev": "npm run doc && npm run nodemon", 9 | "start": "npm run doc && node bin/runServer", 10 | "test": "NODE_ENV=test node bin/runMocha", 11 | "test-win": "set NODE_ENV=test&& node bin/runMocha", 12 | "doc": "apidoc -i ./src/v1/endpoints -o ./doc/v1", 13 | "doc-md": "npm run doc && apidoc-markdown -p ./doc/v1 -o DOCUMENTATION.md" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/voronianski/express-api-sample.git" 18 | }, 19 | "license": "MIT", 20 | "homepage": "https://github.com/voronianski/express-api-sample", 21 | "dependencies": { 22 | "babel": "^5.5.8", 23 | "bcrypt": "^0.8.3", 24 | "body-parser": "^1.13.1", 25 | "c0nfig": "^0.2.2", 26 | "cookie-parser": "^1.3.5", 27 | "cors": "^2.7.1", 28 | "express": "^4.12.4", 29 | "is-express-schema-valid": "^0.1.3", 30 | "is-my-json-valid": "^2.12.0", 31 | "moment": "^2.10.3", 32 | "morgan": "^1.6.0", 33 | "nedb": "^1.1.2", 34 | "network-address": "^1.0.0" 35 | }, 36 | "devDependencies": { 37 | "apidoc": "^0.13.1", 38 | "apidoc-markdown": "^0.2.0", 39 | "chai": "^3.0.0", 40 | "mocha": "^2.2.5", 41 | "sinon": "^1.15.3", 42 | "supertest": "^1.0.1" 43 | }, 44 | "apidoc": { 45 | "name": "Express API Sample Documentation", 46 | "title": "Express API Sample Documentation", 47 | "description": "Sample API ready to be used in different client app boilerplates and playgrounds.", 48 | "url": "http://localhost:8081/v1", 49 | "template": { 50 | "forceLanguage": "en" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import path from 'path'; 3 | import express from 'express'; 4 | import logger from 'morgan'; 5 | import cors from 'cors'; 6 | import bodyParser from 'body-parser'; 7 | import cookieParser from 'cookie-parser'; 8 | import { host, port, env } from 'c0nfig'; 9 | 10 | import apiFirstVersion from './v1'; 11 | import * as middleware from './middleware'; 12 | 13 | const app = express(); 14 | 15 | if ('test' !== env) { 16 | app.use(logger('dev')); 17 | } 18 | 19 | app.use(cors()); 20 | app.use(bodyParser.json()); 21 | app.use(cookieParser()); 22 | 23 | app.use('/doc', express.static(path.join(__dirname, '../doc/v1'))); 24 | app.use('/v1', apiFirstVersion()); 25 | app.use(middleware.handleNotFound); 26 | app.use(middleware.handleErrors); 27 | 28 | http.createServer(app).listen(port, () => { 29 | console.log(`Example API is listening on http://${host}:${port} env=${env}`); 30 | }); 31 | -------------------------------------------------------------------------------- /src/middleware/handleErrors.js: -------------------------------------------------------------------------------- 1 | const ErrorHandlers = { 2 | 'SchemaValidationError'(err) { 3 | let { errors } = err; 4 | 5 | errors = Object.keys(errors) 6 | .reduce((memo, key) => { 7 | return memo.concat(errors[key]); 8 | }, []) 9 | .map(err => { 10 | return { field: err.key, message: err.message }; 11 | }); 12 | 13 | return { status: 400, errors }; 14 | } 15 | }; 16 | 17 | function defaultHandler (err) { 18 | let status = err.status || 500; 19 | let errors = Array.isArray(err) ? err : [err]; 20 | 21 | if (status === 500) { 22 | console.error(err.stack); 23 | errors = [{message: 'Internal Server Error'}]; 24 | } 25 | 26 | return { status, errors }; 27 | } 28 | 29 | // http://jsonapi.org/format/#errors 30 | export default function (err, req, res, next) { 31 | let errorHandler = ErrorHandlers[err.name] || defaultHandler; 32 | let { status, errors } = errorHandler(err); 33 | res.status(status).json({ errors }); 34 | } 35 | -------------------------------------------------------------------------------- /src/middleware/handleNotFound.js: -------------------------------------------------------------------------------- 1 | import errors from '../utils/errors'; 2 | 3 | export default function (req, res, next) { 4 | return next(new errors.NotFound()); 5 | } 6 | -------------------------------------------------------------------------------- /src/middleware/index.js: -------------------------------------------------------------------------------- 1 | export handleErrors from './handleErrors'; 2 | export handleNotFound from './handleNotFound'; 3 | export validateUserRole from './validateUserRole'; 4 | export validateAccessToken from './validateAccessToken'; 5 | -------------------------------------------------------------------------------- /src/middleware/validateAccessToken.js: -------------------------------------------------------------------------------- 1 | import User from '../v1/models/User'; 2 | import errors from '../utils/errors'; 3 | import config from 'c0nfig'; 4 | 5 | const authCookieName = config.auth.cookieName; 6 | 7 | export default function (req, res, next) { 8 | let token = req.headers['x-access-token'] || req.query.accessToken || (authCookieName && req.cookies[authCookieName]); 9 | if (!token) { 10 | return next(new errors.Unauthorized('Access token is missing')); 11 | } 12 | 13 | const email = User.validateAccessToken(token); 14 | if (!email) { 15 | return next(new errors.Unauthorized('User is not authorized')); 16 | } 17 | 18 | req.email = email; 19 | 20 | next(); 21 | } 22 | -------------------------------------------------------------------------------- /src/middleware/validateUserRole.js: -------------------------------------------------------------------------------- 1 | import errors from '../utils/errors'; 2 | import User from '../v1/models/User'; 3 | 4 | export default function (roles) { 5 | if (!Array.isArray(roles)) { 6 | roles = [roles]; 7 | } 8 | return async (req, res, next) => { 9 | try { 10 | req.user = await User.findByEmail(req.email); 11 | if (roles.indexOf(req.user.role) === -1) { 12 | let rolesFormatted = roles.length > 1 ? roles.join(', ') : roles[0]; 13 | return next(new errors.Forbidden(`Only ${rolesFormatted} have permission to execute this operation`)); 14 | } 15 | next(); 16 | } catch (err) { 17 | next(err); 18 | } 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/db.js: -------------------------------------------------------------------------------- 1 | /** 2 | * All datastores are in-memory 3 | */ 4 | 5 | import Datastore from 'nedb'; 6 | 7 | const items = new Datastore(); 8 | const users = new Datastore(); 9 | 10 | users.ensureIndex({fieldName: 'email', unique: true}); 11 | 12 | export default { 13 | items, 14 | users 15 | }; 16 | -------------------------------------------------------------------------------- /src/utils/errors.js: -------------------------------------------------------------------------------- 1 | class NotFound extends Error { 2 | constructor(msg) { 3 | super(msg); 4 | this.message = msg || 'Not Found'; 5 | this.status = 404; 6 | Error.captureStackTrace(this); 7 | } 8 | } 9 | 10 | class Forbidden extends Error { 11 | constructor(msg) { 12 | super(msg); 13 | this.message = msg || 'Forbidden'; 14 | this.status = 403; 15 | Error.captureStackTrace(this); 16 | } 17 | } 18 | 19 | class Unauthorized extends Error { 20 | constructor(msg) { 21 | super(msg); 22 | this.message = msg || 'Unauthorized'; 23 | this.status = 401; 24 | Error.captureStackTrace(this); 25 | } 26 | } 27 | 28 | class BadRequest extends Error { 29 | constructor(msg) { 30 | super(msg); 31 | this.message = msg || 'Bad Request'; 32 | this.status = 400; 33 | Error.captureStackTrace(this); 34 | } 35 | } 36 | 37 | export default { Unauthorized, BadRequest, Forbidden, NotFound }; 38 | -------------------------------------------------------------------------------- /src/v1/endpoints/items/_apidoc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @apiDefine AuthHeader 3 | * 4 | * @apiHeader {String} X-Access-Token Required Access token of logged in user 5 | * @apiHeaderExample {json} Header-Example: 6 | * { 7 | * "X-Access-Token": "ZG1pdHJpLnZvcm9uaWFuc2tpQGdtYWlsLmNvbTsxNDM1NjU5NjEzMDQyOzM4NzhmMmJiMTc0YmE1MzIwNGUxZTZmYmFiYTIwYzdiZGFlNjRlZWM=" 8 | * } 9 | */ 10 | 11 | /** 12 | * @apiDefine SendItemPayload 13 | * @apiParam {String} title Item title text 14 | * @apiParam {String} [description] Item description text 15 | * @apiParam {Boolean} [isPublic=true] Is item visible for listeners 16 | * @apiParamExample {json} Request-Example 17 | * { 18 | * "title": "Basket Case", 19 | * "description": "Seventh track and third single from Green Day third album, Dookie (1994).", 20 | * "isPublic": true 21 | * } 22 | */ 23 | 24 | /** 25 | * @apiDefine GetItemSuccessResponse 26 | * 27 | * @apiSuccess {String} _id Unique item id 28 | * @apiSuccess {String} owner Artist email address 29 | * @apiSuccess {String} title Item title text 30 | * @apiSuccess {String} description Item description text 31 | * @apiSuccess {Boolean} isPublic Is item visible for listeners 32 | * @apiSuccessExample {json} Success-Response 33 | * HTTP/1.1 200 OK 34 | * { 35 | * "_id": "T4laEftw4kF4Hjx3", 36 | * "owner": "john.doe@example.com", 37 | * "title": "Basket Case", 38 | * "description": "Seventh track and third single from Green Day third album, Dookie (1994).", 39 | * "isPublic": true 40 | * } 41 | */ 42 | 43 | /** 44 | * @api {post} /items Create item 45 | * @apiVersion 0.0.0 46 | * @apiGroup Item 47 | * @apiName CreateItem 48 | * @apiPermission user 49 | * @apiDescription Creates new artist's item. Only artists can add items. 50 | * 51 | * @apiUse AuthHeader 52 | * @apiUse GetItemSuccessResponse 53 | * @apiUse SendItemPayload 54 | */ 55 | 56 | /** 57 | * @api {get} /items/:id Read item 58 | * @apiVersion 0.0.0 59 | * @apiGroup Item 60 | * @apiName ReadItem 61 | * @apiPermission user 62 | * @apiDescription Get artist's item by id. 63 | * 64 | * @apiUse AuthHeader 65 | * @apiUse GetItemSuccessResponse 66 | */ 67 | 68 | /** 69 | * @api {put} /items/:id Update item 70 | * @apiVersion 0.0.0 71 | * @apiGroup Item 72 | * @apiName UpdateItem 73 | * @apiPermission user 74 | * @apiDescription Update artist's item by id. 75 | * 76 | * @apiUse AuthHeader 77 | * @apiUse GetItemSuccessResponse 78 | * @apiUse SendItemPayload 79 | */ 80 | 81 | /** 82 | * @api {delete} /items/:id Delete item 83 | * @apiVersion 0.0.0 84 | * @apiGroup Item 85 | * @apiName DeleteItem 86 | * @apiPermission user 87 | * @apiDescription Delete artist's item by id. 88 | * 89 | * @apiUse AuthHeader 90 | * 91 | * @apiSuccessExample {json} Success-Response 92 | * HTTP/1.1 204 OK 93 | */ 94 | -------------------------------------------------------------------------------- /src/v1/endpoints/items/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import validate from 'is-express-schema-valid'; 3 | 4 | import Item from '../../models/Item'; 5 | import errors from '../../../utils/errors'; 6 | import validateAccessToken from '../../../middleware/validateAccessToken'; 7 | import validateUserRole from '../../../middleware/validateUserRole'; 8 | 9 | import { itemSchema } from './schemas'; 10 | 11 | export default function () { 12 | var router = express.Router(); 13 | 14 | router.post('/', 15 | validateAccessToken, 16 | validateUserRole('artist'), 17 | validate(itemSchema), 18 | createItem, 19 | returnItem 20 | ); 21 | 22 | router.get('/:id', 23 | validateAccessToken, 24 | findItemById, 25 | returnItem 26 | ); 27 | 28 | router.put('/:id', 29 | validateAccessToken, 30 | validateUserRole('artist'), 31 | validate(itemSchema), 32 | findItemById, 33 | updateItem, 34 | returnItem 35 | ); 36 | 37 | router.delete('/:id', 38 | validateAccessToken, 39 | validateUserRole('artist'), 40 | findItemById, 41 | deleteItem 42 | ); 43 | 44 | async function createItem (req, res, next) { 45 | try { 46 | const itemData = Object.assign({}, req.body, {owner: req.email}); 47 | req.item = await Item.create(itemData); 48 | next(); 49 | } catch (err) { 50 | next(err); 51 | } 52 | } 53 | 54 | async function findItemById (req, res, next) { 55 | try { 56 | req.item = await Item.findById(req.params.id); 57 | if (!req.item) { 58 | return next(new errors.NotFound('Item not found')); 59 | } 60 | next(); 61 | } catch (err) { 62 | next(err); 63 | } 64 | } 65 | 66 | async function updateItem (req, res, next) { 67 | try { 68 | req.item = await Item.update(req.item, req.body); 69 | next(); 70 | } catch (err) { 71 | next(err); 72 | } 73 | } 74 | 75 | async function deleteItem (req, res, next) { 76 | try { 77 | await Item.remove(req.params.id); 78 | res.sendStatus(204); 79 | } catch (err) { 80 | next(err); 81 | } 82 | 83 | } 84 | 85 | function returnItem (req, res) { 86 | res.json(Item.transformResponse(req.item)); 87 | } 88 | 89 | return router; 90 | } 91 | -------------------------------------------------------------------------------- /src/v1/endpoints/items/schemas.js: -------------------------------------------------------------------------------- 1 | export const itemSchema = { 2 | payload: { 3 | title: { 4 | type: 'string', 5 | required: true 6 | }, 7 | description: { 8 | type: 'string' 9 | }, 10 | isPublic: { 11 | type: 'boolean' 12 | } 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/v1/endpoints/user/_apidoc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @apiDefine UserSuccessAuthResponseObject 3 | * 4 | * @apiSuccess {String} accessToken Unique accessToken for requesting private data 5 | * @apiSuccess {Object} user User profile information 6 | * @apiSuccess {String} user.email User email 7 | * @apiSuccess {String} user.firstName User first name 8 | * @apiSuccess {String} user.lastName User last name 9 | * @apiSuccess {String="artist","listener"} user.role User role 10 | * @apiSuccessExample {json} Success-Response 11 | * HTTP/1.1 200 OK 12 | * { 13 | * "accessToken": "ZG1tbTJAZXhhbXBsZS5jb207MTQzNTE0NzYyOTAxNjs1Yzc0MTdmNmM3MGQ2MmYzMjFhNWE4NGYwODQ5ZmU5NTM1Nzg5NTE2", 14 | * "user": { 15 | * "email": "john.doe@example.com", 16 | * "firstName": "John", 17 | * "lastName": "Doe", 18 | * "role": "artist" 19 | * } 20 | * } 21 | */ 22 | 23 | /** 24 | * @api {post} /user/signup Signup User 25 | * @apiVersion 0.0.0 26 | * @apiGroup User 27 | * @apiName SignupUser 28 | * @apiPermission public 29 | * @apiDescription Adds new user with role "artist" or "listener" to database and sends welcome email 30 | * 31 | * @apiUse UserSuccessAuthResponseObject 32 | * 33 | * @apiParam {String} email Required User email 34 | * @apiParam {String} password Required User password 35 | * @apiParam {String} firstName Required User first name 36 | * @apiParam {String} lastName Required User last name 37 | * @apiParam {String="artist","listener"} role Required User role 38 | * @apiParamExample {json} Request-Example 39 | * { 40 | * "email": "john.doe@example.com", 41 | * "password": "qwerty", 42 | * "firstName": "John", 43 | * "lastName": "Doe", 44 | * "role": "artist" 45 | * } 46 | */ 47 | 48 | /** 49 | * @api {post} /user/login Login User 50 | * @apiVersion 0.0.0 51 | * @apiGroup User 52 | * @apiName LoginUser 53 | * @apiPermission public 54 | * @apiDescription Logins already created user. 55 | * 56 | * @apiUse UserSuccessAuthResponseObject 57 | * 58 | * @apiParam {String} email Required User email 59 | * @apiParam {String} password Required User password 60 | * @apiParamExample {json} Request-Example 61 | * { 62 | * "email": "john.doe@example.com", 63 | * "password": "qwerty" 64 | * } 65 | */ 66 | 67 | /** 68 | * @api {get} /user/me Get User 69 | * @apiVersion 0.0.0 70 | * @apiGroup User 71 | * @apiName GetUser 72 | * @apiPermission user 73 | * @apiDescription Returns authenticated user info. 74 | * 75 | * @apiUse UserSuccessAuthResponseObject 76 | * 77 | */ 78 | 79 | /** 80 | * @api {get} /user/items Get User Items 81 | * @apiVersion 0.0.0 82 | * @apiGroup User 83 | * @apiName GetUserItems 84 | * @apiPermission user 85 | * @apiDescription Returns user with role "artist" created items. 86 | * 87 | * @apiSuccess {Object[]} items List of user items. 88 | * @apiSuccess {String} items._id Unique item id 89 | * @apiSuccess {String} items.owner Artist email address 90 | * @apiSuccess {String} items.title Item title text 91 | * @apiSuccess {String} items.description Item description text 92 | * @apiSuccess {Boolean} items.isPublic Is item visible for listeners 93 | * @apiSuccessExample {json} Success-Response 94 | * HTTP/1.1 200 OK 95 | * [{ 96 | * "_id": "T4laEftw4kF4Hjx3", 97 | * "owner": "john.doe@example.com", 98 | * "title": "Basket Case", 99 | * "description": "Seventh track and third single from Green Day third album, Dookie (1994).", 100 | * "isPublic": true 101 | * }] 102 | * 103 | */ 104 | -------------------------------------------------------------------------------- /src/v1/endpoints/user/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import validate from 'is-express-schema-valid'; 3 | 4 | import User from '../../models/User'; 5 | import Item from '../../models/Item'; 6 | import errors from '../../../utils/errors'; 7 | import { validateAccessToken, validateUserRole } from '../../../middleware'; 8 | 9 | import { 10 | signupSchema, 11 | loginSchema 12 | } from './schemas'; 13 | 14 | export default function () { 15 | var router = express.Router(); 16 | 17 | router.post('/signup', 18 | validate(signupSchema), 19 | findUserByEmail, 20 | signupUser, 21 | generateAccessToken, 22 | returnUser 23 | ); 24 | 25 | router.post('/login', 26 | validate(loginSchema), 27 | findUserByEmail, 28 | loginUser, 29 | generateAccessToken, 30 | returnUser 31 | ); 32 | 33 | router.get('/me', 34 | validateAccessToken, 35 | findUserByEmail, 36 | returnUser 37 | ); 38 | 39 | router.get('/items', 40 | validateAccessToken, 41 | validateUserRole('artist'), 42 | findUserItems, 43 | returnItems 44 | ); 45 | 46 | async function findUserByEmail (req, res, next) { 47 | try { 48 | const email = req.email || req.body.email; 49 | req.user = await User.findByEmail(email); 50 | next(); 51 | } catch (err) { 52 | next(err); 53 | } 54 | } 55 | 56 | async function signupUser (req, res, next) { 57 | try { 58 | if (req.user) { 59 | return next(new errors.BadRequest('Email is already registered')); 60 | } 61 | req.user = await User.create(req.body); 62 | next(); 63 | } catch (err) { 64 | next(err); 65 | } 66 | } 67 | 68 | async function loginUser (req, res, next) { 69 | try { 70 | if (!req.user) { 71 | return next(); 72 | } 73 | 74 | const same = await User.comparePassword(req.body.password, req.user.password); 75 | if (!same) { 76 | return next(new errors.BadRequest('Password is not matching email address')); 77 | } 78 | next(); 79 | } catch (err) { 80 | next(err); 81 | } 82 | } 83 | 84 | async function findUserItems (req, res, next) { 85 | try { 86 | if (!req.user) { 87 | return next(); 88 | } 89 | 90 | req.items = await Item.getArtistItems(req.email); 91 | next(); 92 | } catch (err) { 93 | next(err); 94 | } 95 | } 96 | 97 | async function generateAccessToken (req, res, next) { 98 | try { 99 | if (!req.user) { 100 | return next(); 101 | } 102 | 103 | req.accessToken = await User.generateAccessToken(req.user.email); 104 | next(); 105 | } catch (err) { 106 | next(err); 107 | } 108 | } 109 | 110 | function returnItems (req, res) { 111 | res.json(req.items.map(Item.transformResponse)); 112 | } 113 | 114 | function returnUser (req, res, next) { 115 | if (!req.user) { 116 | return next(new errors.NotFound('User with this email is not found')); 117 | } 118 | const user = User.transformResponse(req.user); 119 | const data = req.accessToken ? { accessToken: req.accessToken, user } : user; 120 | res.json(data); 121 | } 122 | 123 | return router; 124 | } 125 | -------------------------------------------------------------------------------- /src/v1/endpoints/user/schemas.js: -------------------------------------------------------------------------------- 1 | const emailSchema = { 2 | type: 'string', 3 | format: 'email', 4 | required: true 5 | }; 6 | const notEmptyStringSchema = { 7 | type: 'string', 8 | required: true, 9 | minLength: 1 10 | }; 11 | 12 | export const loginSchema = { 13 | payload: { 14 | email: emailSchema, 15 | password: notEmptyStringSchema 16 | } 17 | }; 18 | 19 | export const signupSchema = { 20 | payload: { 21 | email: emailSchema, 22 | password: notEmptyStringSchema, 23 | firstName: notEmptyStringSchema, 24 | lastName: notEmptyStringSchema, 25 | role: { 26 | type: 'string', 27 | required: true, 28 | enum: ['artist', 'listener'] 29 | } 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/v1/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import user from './endpoints/user'; 4 | import items from './endpoints/items'; 5 | 6 | export default function () { 7 | var router = express.Router(); 8 | 9 | router.use('/user', user()); 10 | router.use('/items', items()); 11 | 12 | return router; 13 | } 14 | -------------------------------------------------------------------------------- /src/v1/models/Item.js: -------------------------------------------------------------------------------- 1 | import { items } from '../../utils/db'; 2 | 3 | function create (doc) { 4 | return new Promise((resolve, reject) => { 5 | const item = Object.assign({}, doc, { 6 | isPublic: doc.isPublic || true, 7 | createdAt: Date.now() 8 | }); 9 | items.insert(item, (err, saved) => { 10 | err ? reject(err) : resolve(saved); 11 | }); 12 | }); 13 | } 14 | 15 | function findById (_id) { 16 | return new Promise((resolve, reject) => { 17 | items.findOne({ _id }, (err, item) => { 18 | err ? reject(err) : resolve(item); 19 | }); 20 | }); 21 | } 22 | 23 | function update (oldItem, doc) { 24 | return new Promise((resolve, reject) => { 25 | const newItem = Object.assign({}, oldItem, doc); 26 | items.update({_id: oldItem._id }, newItem, {}, (err) => { 27 | err ? reject(err) : resolve(newItem); 28 | }); 29 | }); 30 | } 31 | 32 | function remove (_id) { 33 | return new Promise((resolve, reject) => { 34 | items.remove({ _id }, {}, (err, num, item) => { 35 | err ? reject(err) : resolve(item); 36 | }); 37 | }); 38 | } 39 | 40 | function getArtistItems (owner) { 41 | return new Promise((resolve, reject) => { 42 | items.find({ owner }, (err, items) => { 43 | err ? reject(err) : resolve(items); 44 | }); 45 | }); 46 | } 47 | 48 | function transformResponse (item) { 49 | const { _id, owner, title, description, isPublic } = item; 50 | return Object.assign({}, { _id, owner, title, description, isPublic }); 51 | } 52 | 53 | export default { 54 | create, 55 | findById, 56 | update, 57 | remove, 58 | getArtistItems, 59 | transformResponse 60 | }; 61 | -------------------------------------------------------------------------------- /src/v1/models/User.js: -------------------------------------------------------------------------------- 1 | import { users } from '../../utils/db'; 2 | import bcrypt from 'bcrypt'; 3 | import crypto from 'crypto'; 4 | import moment from 'moment'; 5 | import config from 'c0nfig'; 6 | 7 | const { hashRounds } = config.bcrypt; 8 | const { signKey, tokenTTL } = config.auth; 9 | 10 | function create (doc) { 11 | return new Promise((resolve, reject) => { 12 | storePassword(doc.password, (err, hash) => { 13 | if (err) { 14 | return reject(err); 15 | } 16 | 17 | let user = Object.assign({}, doc, { 18 | password: hash, 19 | createdAt: Date.now() 20 | }); 21 | users.insert(user, (err, saved) => { 22 | err ? reject(err) : resolve(saved); 23 | }); 24 | }); 25 | }); 26 | } 27 | 28 | function findByEmail (email) { 29 | return new Promise((resolve, reject) => { 30 | users.findOne({ email }, (err, user) => { 31 | err ? reject(err) : resolve(user); 32 | }); 33 | }); 34 | } 35 | 36 | function storePassword (password, callback) { 37 | bcrypt.genSalt(hashRounds, (err, salt) => { 38 | if (err) { 39 | return callback(err); 40 | } 41 | bcrypt.hash(password, salt, (err, hash) => { 42 | if (err) { 43 | return callback(err); 44 | } 45 | callback(null, hash); 46 | }); 47 | }); 48 | } 49 | 50 | function comparePassword (passwordToCompare, actualPassword) { 51 | return new Promise((resolve, reject) => { 52 | if (!passwordToCompare) { 53 | return resolve(false); 54 | } 55 | 56 | bcrypt.compare(passwordToCompare, actualPassword, (err, same) => { 57 | return err ? reject(err) : resolve(same); 58 | }); 59 | }); 60 | } 61 | 62 | function generateAccessToken (email) { 63 | const timestamp = moment(); 64 | const message = `${email};${timestamp.valueOf()}`; 65 | const hmac = crypto.createHmac('sha1', signKey).update(message).digest('hex'); 66 | const token = `${message};${hmac}`; 67 | const tokenBase64 = new Buffer(token).toString('base64'); 68 | 69 | return tokenBase64; 70 | } 71 | 72 | function validateAccessToken (token) { 73 | const decoded = new Buffer(token, 'base64').toString(); 74 | const parsed = decoded.split(';'); 75 | 76 | if (parsed.length !== 3) { 77 | return false; 78 | } 79 | 80 | const [ email, timestamp, receivedHmac ] = parsed; 81 | const message = `${email};${timestamp}`; 82 | const computedHmac = crypto.createHmac('sha1', signKey).update(message).digest('hex'); 83 | 84 | if (receivedHmac !== computedHmac) { 85 | return false; 86 | } 87 | 88 | const currentTimestamp = moment(); 89 | const receivedTimestamp = moment(+timestamp); 90 | const tokenLife = currentTimestamp.diff(receivedTimestamp); 91 | 92 | if (tokenLife >= tokenTTL) { 93 | return false; 94 | } 95 | 96 | return email; 97 | } 98 | 99 | function transformResponse (user) { 100 | const { email, firstName, lastName, role } = user; 101 | return Object.assign({}, { email, firstName, lastName, role }); 102 | } 103 | 104 | export default { 105 | create, 106 | findByEmail, 107 | comparePassword, 108 | generateAccessToken, 109 | validateAccessToken, 110 | transformResponse 111 | }; 112 | -------------------------------------------------------------------------------- /test/e2e/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voronianski/express-api-sample/d91cf37c28b31e6d076074d54abf8f092a47583a/test/e2e/.keep -------------------------------------------------------------------------------- /test/e2e/items.spec.js: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | 3 | import { apiUrl } from 'c0nfig'; 4 | import { getTestUser, getUserAccessToken } from '../testUtils'; 5 | 6 | let request = supertest(`${apiUrl}/items`); 7 | let authHeaders; 8 | let authUser; 9 | 10 | let itemData = { 11 | title: 'Basket Case', 12 | description: 'Seventh track and third single from Green Day third album, Dookie (1994).', 13 | isPublic: true 14 | }; 15 | 16 | describe('/items endpoints', () => { 17 | before(async done => { 18 | try { 19 | const { user, accessToken } = await getTestUser(); 20 | authUser = user; 21 | authHeaders = {'X-Access-Token': accessToken}; 22 | done(); 23 | } catch (err) { 24 | done(err); 25 | } 26 | }); 27 | 28 | describe('POST /', () => { 29 | describe('when creating item as a listener', () => { 30 | let invalidAuthHeaders; 31 | 32 | before(async done => { 33 | try { 34 | const accessToken = await getUserAccessToken('listener'); 35 | invalidAuthHeaders = {'X-Access-Token': accessToken}; 36 | done(); 37 | } catch (err) { 38 | done(err); 39 | } 40 | }); 41 | 42 | it('should return a proper error', done => { 43 | request 44 | .post('/') 45 | .set(invalidAuthHeaders) 46 | .send(itemData) 47 | .expect(403) 48 | .expect(res => { 49 | expect(res.body).to.have.keys('errors'); 50 | expect(res.body.errors.length).to.be.equal(1); 51 | expect(res.body.errors[0].message).to.be.equal('Only artist have permission to execute this operation'); 52 | }) 53 | .end(done); 54 | }); 55 | }); 56 | 57 | describe('when creating item as an artist', () => { 58 | let createdItem; 59 | 60 | it('should create new item successfully', done => { 61 | request 62 | .post('/') 63 | .set(authHeaders) 64 | .send(itemData) 65 | .expect(200) 66 | .expect(res => { 67 | const keys = Object.keys(itemData).concat(['_id', 'owner']); 68 | expect(res.body).to.have.keys(keys); 69 | expect(res.body.owner).to.be.equal(authUser.email); 70 | 71 | createdItem = res.body; 72 | }) 73 | .end(done); 74 | }); 75 | 76 | describe('GET /:id', () => { 77 | describe('when getting created item', () => { 78 | it('should return item successfully', done => { 79 | request 80 | .get(`/${createdItem._id}`) 81 | .set(authHeaders) 82 | .expect(200) 83 | .expect(res => { 84 | const keys = Object.keys(itemData).concat(['_id', 'owner']); 85 | expect(res.body).to.have.keys(keys); 86 | expect(res.body._id).to.be.equal(createdItem._id); 87 | expect(res.body.owner).to.be.equal(authUser.email); 88 | }) 89 | .end(done); 90 | }); 91 | 92 | describe('PUT /:id', () => { 93 | let itemUpdatedData = Object.assign({}, itemData, { 94 | title: 'Basket Case (Single)' 95 | }); 96 | 97 | describe('when updating created item', () => { 98 | it('should return updated successfully', done => { 99 | request 100 | .put(`/${createdItem._id}`) 101 | .set(authHeaders) 102 | .send(itemUpdatedData) 103 | .expect(200) 104 | .expect(res => { 105 | const keys = Object.keys(itemData).concat(['_id', 'owner']); 106 | expect(res.body).to.have.keys(keys); 107 | expect(res.body.owner).to.be.equal(authUser.email); 108 | expect(res.body.title).to.be.equal(itemUpdatedData.title); 109 | }) 110 | .end(done); 111 | }); 112 | 113 | describe('when getting updated item', () => { 114 | it('should return item successfully', done => { 115 | request 116 | .get(`/${createdItem._id}`) 117 | .set(authHeaders) 118 | .expect(200) 119 | .expect(res => { 120 | const keys = Object.keys(itemData).concat(['_id', 'owner']); 121 | expect(res.body).to.have.keys(keys); 122 | expect(res.body.owner).to.be.equal(authUser.email); 123 | expect(res.body.title).to.be.equal(itemUpdatedData.title); 124 | }) 125 | .end(done); 126 | }); 127 | 128 | describe('DELETE /:id', () => { 129 | describe('when deleting created item', () => { 130 | it('should delete item successfully', done => { 131 | request 132 | .del(`/${createdItem._id}`) 133 | .set(authHeaders) 134 | .expect(204) 135 | .end(done); 136 | }); 137 | 138 | describe('when getting deleted item', () => { 139 | it('should return a proper error', done => { 140 | request 141 | .get(`/${createdItem._id}`) 142 | .set(authHeaders) 143 | .expect(404) 144 | .expect(res => { 145 | expect(res.body).to.have.keys('errors'); 146 | expect(res.body.errors.length).to.be.equal(1); 147 | expect(res.body.errors[0].message).to.be.equal('Item not found'); 148 | }) 149 | .end(done); 150 | }); 151 | }); 152 | }); 153 | }); 154 | }); 155 | }); 156 | }); 157 | }); 158 | }); 159 | }); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /test/e2e/user.spec.js: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | 3 | import { apiUrl } from 'c0nfig'; 4 | import { createTestEmail, getTestUser, generateItems } from '../testUtils'; 5 | 6 | let request = supertest(`${apiUrl}/user`); 7 | let userData = { 8 | email: createTestEmail(), 9 | password: 'qwerty', 10 | firstName: 'John', 11 | lastName: 'Doe', 12 | role: 'artist' 13 | }; 14 | 15 | describe('/user endpoints', () => { 16 | describe('POST /signup', () => { 17 | describe('when sending not valid user data', () => { 18 | describe('when email is not valid', () => { 19 | it('should not register new user', done => { 20 | request 21 | .post('/signup') 22 | .send(Object.assign({}, userData, {email: 'notvalid'})) 23 | .expect(400) 24 | .expect(res => { 25 | expect(res.body).to.have.keys('errors'); 26 | 27 | let [ error ] = res.body.errors; 28 | expect(error.message).to.equal('must be email format'); 29 | }) 30 | .end(done); 31 | }); 32 | }); 33 | 34 | describe('when some field is missed', () => { 35 | it('should not register new user', done => { 36 | request 37 | .post('/signup') 38 | .send({email: 'valid@example.com', 'password': 'qwerty'}) 39 | .expect(400) 40 | .expect(res => { 41 | expect(res.body).to.have.keys('errors'); 42 | expect(res.body.errors.length).to.equal(3); 43 | }) 44 | .end(done); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('when creating non-existed user', () => { 50 | it('should register and return new user with access token', done => { 51 | request 52 | .post('/signup') 53 | .send(userData) 54 | .expect(200) 55 | .expect(res => { 56 | expect(res.body).to.have.keys('accessToken', 'user'); 57 | expect(res.body.user).to.have.keys('email', 'firstName', 'lastName', 'role'); 58 | expect(res.body.user.email).to.equal(userData.email); 59 | }) 60 | .end(done); 61 | }); 62 | 63 | describe('when trying to create existed user', () => { 64 | it('should return error', done => { 65 | request 66 | .post('/signup') 67 | .send(userData) 68 | .expect(400) 69 | .expect(res => { 70 | expect(res.body).to.have.keys('errors'); 71 | }) 72 | .end(done); 73 | }); 74 | }); 75 | 76 | describe('POST /login', () => { 77 | describe('when login with valid credentianls', () => { 78 | let validAccessToken; 79 | 80 | it('should return user and access token', done => { 81 | request 82 | .post('/login') 83 | .send({email: userData.email, password: userData.password}) 84 | .expect(200) 85 | .expect(res => { 86 | expect(res.body).to.have.keys('accessToken', 'user'); 87 | expect(res.body.user).to.have.keys('email', 'firstName', 'lastName', 'role'); 88 | expect(res.body.user.email).to.equal(userData.email); 89 | 90 | validAccessToken = res.body.accessToken; 91 | }) 92 | .end(done); 93 | }); 94 | 95 | describe('GET /me', () => { 96 | describe('when requesting self info as authorized user', () => { 97 | it('should return user and access token', done => { 98 | request 99 | .get('/me') 100 | .set({'X-Access-Token': validAccessToken}) 101 | .expect(200) 102 | .expect(res => { 103 | expect(res.body).to.have.keys('email', 'firstName', 'lastName', 'role'); 104 | expect(res.body.email).to.equal(userData.email); 105 | }) 106 | .end(done); 107 | }); 108 | }); 109 | 110 | describe('when requesting self info as non-authorized user', () => { 111 | it('should return user and access token', done => { 112 | request 113 | .get('/me') 114 | .set({'X-Access-Token': '12345'}) 115 | .expect(401) 116 | .expect(res => { 117 | expect(res.body).to.have.keys('errors'); 118 | expect(res.body.errors.length).to.equal(1); 119 | expect(res.body.errors[0].message).to.equal('User is not authorized'); 120 | }) 121 | .end(done); 122 | }); 123 | }); 124 | }); 125 | }); 126 | 127 | describe('when trying to login with invalid email', () => { 128 | it('should return error', done => { 129 | request 130 | .post('/login') 131 | .send({email: 'notvalid', password: 'qwerty'}) 132 | .expect(400) 133 | .expect(res => { 134 | expect(res.body).to.have.keys('errors'); 135 | expect(res.body.errors.length).to.equal(1); 136 | }) 137 | .end(done); 138 | }); 139 | }); 140 | 141 | describe('when trying to login with invalid password', () => { 142 | it('should return error', done => { 143 | request 144 | .post('/login') 145 | .send({email: userData.email, password: 'foo'}) 146 | .expect(400) 147 | .expect(res => { 148 | expect(res.body).to.have.keys('errors'); 149 | expect(res.body.errors.length).to.equal(1); 150 | }) 151 | .end(done); 152 | }); 153 | }); 154 | }); 155 | }); 156 | }); 157 | 158 | describe('GET /items', () => { 159 | let testUser, authHeaders; 160 | 161 | describe('signup a new user and create some items', () => { 162 | before(async done => { 163 | try { 164 | let { user, accessToken } = await getTestUser(); 165 | authHeaders = {'X-Access-Token': accessToken}; 166 | testUser = user; 167 | done(); 168 | } catch (err) { 169 | done(err); 170 | } 171 | }); 172 | 173 | before(async done => { 174 | try { 175 | await generateItems(testUser.email); 176 | done(); 177 | } catch (err) { 178 | done(err); 179 | } 180 | }); 181 | 182 | describe('when getting items and user not logged in', () => { 183 | it('should return an error', done => { 184 | request 185 | .get('/items') 186 | .expect(401) 187 | .end(done); 188 | }); 189 | }); 190 | 191 | describe('when getting items for logged in user', () => { 192 | it('should return an array of items', done => { 193 | request 194 | .get('/items') 195 | .set(authHeaders) 196 | .expect(200) 197 | .expect(res => { 198 | expect(res.body).to.be.an('array'); 199 | expect(res.body).to.have.length(5); 200 | expect(res.body[0].owner).to.equal(testUser.email); 201 | }) 202 | .end(done); 203 | }); 204 | }); 205 | }); 206 | }); 207 | }); 208 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --compilers js:babel/register 2 | --require ./test/setup.js 3 | --reporter spec 4 | --ui bdd 5 | --recursive 6 | --colors 7 | --timeout 60000 8 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | global.sinon = require('sinon'); 2 | global.chai = require('chai'); 3 | global.expect = global.chai.expect; 4 | global.should = global.chai.should; 5 | -------------------------------------------------------------------------------- /test/testUtils.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import User from '../src/v1/models/User'; 3 | import Item from '../src/v1/models/Item'; 4 | 5 | export function createTestEmail () { 6 | return moment().valueOf() + '@tests.com'; 7 | } 8 | 9 | export async function getTestUser (role = 'artist') { 10 | const user = await User.create({ 11 | email: createTestEmail(), 12 | password: 'qwerty', 13 | firstName: 'John', 14 | lastName: 'Doe', 15 | role 16 | }); 17 | const accessToken = User.generateAccessToken(user.email); 18 | return { accessToken, user }; 19 | } 20 | 21 | export async function getUserAccessToken (role) { 22 | const { accessToken } = await getTestUser(role); 23 | return accessToken; 24 | } 25 | 26 | export function generateAccessToken () { 27 | return User.generateAccessToken(createTestEmail()); 28 | } 29 | 30 | export async function generateItems (email, num = 5) { 31 | for (let i = 0; i < num; i++) { 32 | await Item.create({ 33 | owner: email, 34 | title: `title${i}` 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/unit/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voronianski/express-api-sample/d91cf37c28b31e6d076074d54abf8f092a47583a/test/unit/.keep -------------------------------------------------------------------------------- /test/unit/user.spec.js: -------------------------------------------------------------------------------- 1 | import User from '../../src/v1/models/User'; 2 | import { createTestEmail } from '../testUtils'; 3 | 4 | describe('User model static methods', () => { 5 | let email = createTestEmail(); 6 | let token; 7 | 8 | describe('when generating access token', () => { 9 | before(() => token = User.generateAccessToken(email)); 10 | 11 | it('should return access token string', () => { 12 | expect(token).to.be.a('string'); 13 | }); 14 | 15 | describe('when validating access token', function () { 16 | let validEmail; 17 | 18 | before(() => validEmail = User.validateAccessToken(token)); 19 | 20 | it('should return email used for this token', () => { 21 | expect(validEmail).to.equal(email); 22 | }); 23 | }); 24 | }); 25 | }); 26 | --------------------------------------------------------------------------------