├── .babelrc ├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc ├── .gitignore ├── README.md ├── package.json ├── server ├── config.js ├── controllers │ └── cities.js ├── index.js ├── middlewares │ ├── authenticate.js │ └── jwt.js ├── models │ └── cities.js ├── routes │ ├── authenticate.js │ ├── cities.js │ └── index.js └── utils │ └── routesLoader.js └── test └── api.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": ["transform-async-to-generator","syntax-async-functions","add-module-exports"] 4 | } 5 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: node:8.0 6 | steps: 7 | - run: 8 | name: "Test" 9 | command: npm test 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # http://editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | indent_style = space 9 | indent_size = 2 10 | end_of_line = lf 11 | charset = utf-8 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": false, 4 | "mocha": false, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "parser": "babel-eslint", 9 | "extends": [ "airbnb/base", "prettier"], 10 | "rules": { 11 | "comma-dangle": ["error", "never"], 12 | "semi": [0, "always"], 13 | "no-console": 0, 14 | "class-methods-use-this": 0, 15 | "no-param-reassign" : 0 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | /dist 4 | npm-debug.log 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Koa 2 Restful Boilerplate 2 | 3 | ## Description 4 | 5 | Koa 2 RESTful API using : 6 | 7 | * Koa 2 8 | * Mongodb + Mongoose 9 | * Babel 10 | * Asynchronous Functions (Async/Await) 11 | 12 | ## Running 13 | 14 | Install dependencies 15 | 16 | ``` 17 | npm install 18 | ``` 19 | 20 | Start a Local Server 21 | 22 | ``` 23 | npm start 24 | ``` 25 | 26 | Run Test 27 | 28 | ``` 29 | npm test 30 | ``` 31 | 32 | Building and Running Production Server 33 | 34 | ``` 35 | npm run prod 36 | ``` 37 | 38 | **Note : Please make sure your MongoDB is running before using `npm start` or `npm run prod`** 39 | 40 | ## License 41 | 42 | MIT © [Thomas Blanc-Hector](https://github.com/jsnomad) 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koa-restful-boilerplate", 3 | "description": "Koa 2 RESTful API boilerplate", 4 | "version": "1.0.0", 5 | "author": "Thomas Blanc-Hector", 6 | "keywords": [ 7 | "koa", 8 | "rest", 9 | "api", 10 | "mongodb", 11 | "mongoose", 12 | "async", 13 | "es7" 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/jsnomad/koa-restful-boilerplate.git" 18 | }, 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/jsnomad/koa-restful-boilerplate/issues" 22 | }, 23 | "homepage": "https://github.com/jsnomad/koa-restful-boilerplate#readme", 24 | "main": "server/index.js", 25 | "scripts": { 26 | "start": "nodemon server/ --exec babel-node", 27 | "build": "babel server -d dist", 28 | "lint": "./node_modules/.bin/eslint ./server", 29 | "test": "npm run lint && npm run mocha", 30 | "prod": "npm run build && node dist/", 31 | "mocha": "./node_modules/.bin/mocha --require babel-register --require babel-polyfill --exit" 32 | }, 33 | "dependencies": { 34 | "babel-polyfill": "^6.5.0", 35 | "glob": "^7.1.4", 36 | "jsonwebtoken": "^8.5.1", 37 | "koa": "^2.7.0", 38 | "koa-bodyparser": "^4.2.1", 39 | "koa-helmet": "^4.0.0", 40 | "koa-jwt": "^3.6.0", 41 | "koa-logger": "^3.2.0", 42 | "koa-router": "^7.4.0", 43 | "mongoose": "^5.6.4" 44 | }, 45 | "devDependencies": { 46 | "babel-cli": "^6.26.0", 47 | "babel-eslint": "^10.0.2", 48 | "babel-plugin-add-module-exports": "^1.0.0", 49 | "babel-plugin-syntax-async-functions": "^6.5.0", 50 | "babel-plugin-transform-async-to-generator": "^6.5.0", 51 | "babel-preset-es2015": "^6.3.13", 52 | "babel-register": "^6.3.13", 53 | "chai": "^4.2.0", 54 | "eslint": "^6.0.1", 55 | "eslint-config-airbnb": "^17.1.1", 56 | "eslint-config-prettier": "^6.0.0", 57 | "eslint-plugin-import": "^2.18.0", 58 | "mocha": "^6.1.4", 59 | "nodemon": "^1.19.1", 60 | "should": "^13.2.3", 61 | "supertest": "^4.0.2" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | export const port = process.env.PORT || 3000; 2 | export const connexionString = 'mongodb://localhost/koa-boilerplate'; 3 | export const baseApi = 'api'; 4 | -------------------------------------------------------------------------------- /server/controllers/cities.js: -------------------------------------------------------------------------------- 1 | import City from '../models/cities'; 2 | 3 | class CitiesControllers { 4 | /* eslint-disable no-param-reassign */ 5 | 6 | /** 7 | * Get all cities 8 | * @param {ctx} Koa Context 9 | */ 10 | async find(ctx) { 11 | ctx.body = await City.find(); 12 | } 13 | 14 | /** 15 | * Find a city 16 | * @param {ctx} Koa Context 17 | */ 18 | async findById(ctx) { 19 | try { 20 | const city = await City.findById(ctx.params.id); 21 | if (!city) { 22 | ctx.throw(404); 23 | } 24 | ctx.body = city; 25 | } catch (err) { 26 | if (err.name === 'CastError' || err.name === 'NotFoundError') { 27 | ctx.throw(404); 28 | } 29 | ctx.throw(500); 30 | } 31 | } 32 | 33 | /** 34 | * Add a city 35 | * @param {ctx} Koa Context 36 | */ 37 | async add(ctx) { 38 | try { 39 | const city = await new City(ctx.request.body).save(); 40 | ctx.body = city; 41 | } catch (err) { 42 | ctx.throw(422); 43 | } 44 | } 45 | 46 | /** 47 | * Update a city 48 | * @param {ctx} Koa Context 49 | */ 50 | async update(ctx) { 51 | try { 52 | const city = await City.findByIdAndUpdate( 53 | ctx.params.id, 54 | ctx.request.body 55 | ); 56 | if (!city) { 57 | ctx.throw(404); 58 | } 59 | ctx.body = city; 60 | } catch (err) { 61 | if (err.name === 'CastError' || err.name === 'NotFoundError') { 62 | ctx.throw(404); 63 | } 64 | ctx.throw(500); 65 | } 66 | } 67 | 68 | /** 69 | * Delete a city 70 | * @param {ctx} Koa Context 71 | */ 72 | async delete(ctx) { 73 | try { 74 | const city = await City.findByIdAndRemove(ctx.params.id); 75 | if (!city) { 76 | ctx.throw(404); 77 | } 78 | ctx.body = city; 79 | } catch (err) { 80 | if (err.name === 'CastError' || err.name === 'NotFoundError') { 81 | ctx.throw(404); 82 | } 83 | ctx.throw(500); 84 | } 85 | } 86 | 87 | /* eslint-enable no-param-reassign */ 88 | } 89 | 90 | export default new CitiesControllers(); 91 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import bodyParser from 'koa-bodyparser'; 2 | import Koa from 'koa'; 3 | import logger from 'koa-logger'; 4 | import mongoose from 'mongoose'; 5 | import helmet from 'koa-helmet'; 6 | import routing from './routes'; 7 | import { port, connexionString } from './config'; 8 | 9 | mongoose.connect(connexionString); 10 | mongoose.connection.on('error', console.error); 11 | 12 | // Create Koa Application 13 | const app = new Koa(); 14 | 15 | app 16 | .use(logger()) 17 | .use(bodyParser()) 18 | .use(helmet()); 19 | 20 | routing(app); 21 | 22 | // Start the application 23 | app.listen(port, () => 24 | console.log(`✅ The server is running at http://localhost:${port}/`) 25 | ); 26 | export default app; 27 | -------------------------------------------------------------------------------- /server/middlewares/authenticate.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | 3 | export default ctx => { 4 | if (ctx.request.body.password === 'password') { 5 | ctx.status = 200; 6 | ctx.body = { 7 | token: jwt.sign( 8 | { 9 | role: 'admin' 10 | }, 11 | 'YourKey' 12 | ), // Store this key in an environment variable 13 | message: 'Successful Authentication' 14 | }; 15 | } else { 16 | ctx.status = 401; 17 | ctx.body = { 18 | message: 'Authentication Failed' 19 | }; 20 | } 21 | return ctx; 22 | }; 23 | -------------------------------------------------------------------------------- /server/middlewares/jwt.js: -------------------------------------------------------------------------------- 1 | import jwt from 'koa-jwt'; 2 | 3 | export default jwt({ 4 | secret: 'YourKey' 5 | }); 6 | -------------------------------------------------------------------------------- /server/models/cities.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const { Schema } = mongoose; 4 | 5 | // To fix https://github.com/Automattic/mongoose/issues/4291 6 | mongoose.Promise = global.Promise; 7 | 8 | const citySchema = new Schema({ 9 | name: { 10 | type: String, 11 | required: true 12 | }, 13 | totalPopulation: { 14 | type: Number, 15 | required: true 16 | }, 17 | country: String, 18 | zipCode: Number, 19 | updated: { 20 | type: Date, 21 | default: Date.now 22 | } 23 | }); 24 | 25 | export default mongoose.model('City', citySchema); 26 | -------------------------------------------------------------------------------- /server/routes/authenticate.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import Router from 'koa-router'; 3 | import { baseApi } from '../config'; 4 | import authenticate from '../middlewares/authenticate'; 5 | 6 | const api = 'authenticate'; 7 | 8 | const router = new Router(); 9 | 10 | router.prefix(`/${baseApi}/${api}`); 11 | 12 | // POST /api/authenticate 13 | router.post('/', authenticate); 14 | 15 | export default router; 16 | -------------------------------------------------------------------------------- /server/routes/cities.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import Router from 'koa-router'; 3 | import { baseApi } from '../config'; 4 | import jwt from '../middlewares/jwt'; 5 | import CitiesControllers from '../controllers/cities'; 6 | 7 | const api = 'cities'; 8 | 9 | const router = new Router(); 10 | 11 | router.prefix(`/${baseApi}/${api}`); 12 | 13 | // GET /api/cities 14 | router.get('/', CitiesControllers.find); 15 | 16 | // POST /api/cities 17 | // This route is protected, call POST /api/authenticate to get the token 18 | router.post('/', jwt, CitiesControllers.add); 19 | 20 | // GET /api/cities/id 21 | // This route is protected, call POST /api/authenticate to get the token 22 | router.get('/:id', jwt, CitiesControllers.findById); 23 | 24 | // PUT /api/cities/id 25 | // This route is protected, call POST /api/authenticate to get the token 26 | router.put('/:id', jwt, CitiesControllers.update); 27 | 28 | // DELETE /api/cities/id 29 | // This route is protected, call POST /api/authenticate to get the token 30 | router.delete('/:id', jwt, CitiesControllers.delete); 31 | 32 | export default router; 33 | -------------------------------------------------------------------------------- /server/routes/index.js: -------------------------------------------------------------------------------- 1 | import routesLoader from '../utils/routesLoader'; 2 | 3 | export default function(app) { 4 | routesLoader(`${__dirname}`).then(files => { 5 | files.forEach(route => { 6 | app.use(route.routes()).use( 7 | route.allowedMethods({ 8 | throw: true 9 | }) 10 | ); 11 | }); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /server/utils/routesLoader.js: -------------------------------------------------------------------------------- 1 | import glob from 'glob'; 2 | 3 | export default function(dirname) { 4 | return new Promise((resolve, reject) => { 5 | const routes = []; 6 | glob( 7 | `${dirname}/*`, 8 | { 9 | ignore: '**/index.js' 10 | }, 11 | (err, files) => { 12 | if (err) { 13 | return reject(err); 14 | } 15 | files.forEach(file => { 16 | const route = require(file); // eslint-disable-line global-require, import/no-dynamic-require 17 | routes.push(route); 18 | }); 19 | return resolve(routes); 20 | } 21 | ); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /test/api.test.js: -------------------------------------------------------------------------------- 1 | import app from '../server/'; 2 | import supertest from 'supertest'; 3 | import { expect, should } from 'chai'; 4 | 5 | const temp = {}; 6 | const request = supertest.agent(app.listen()); 7 | should(); 8 | 9 | describe('POST api/authenticate', () => { 10 | it('should get all cities', done => { 11 | request 12 | .post('/api/authenticate') 13 | .set('Accept', 'application/json') 14 | .send({ 15 | password: 'password' 16 | }) 17 | .expect(200, (err, res) => { 18 | temp.token = res.body.token; 19 | done(); 20 | }); 21 | }); 22 | }); 23 | 24 | describe('POST /city', () => { 25 | it('should add a city', done => { 26 | request 27 | .post('/api/cities') 28 | .set('Accept', 'application/json') 29 | .set('Authorization', `Bearer ${temp.token}`) 30 | .set('Accept', 'application/json') 31 | .send({ 32 | name: 'Bangkok', 33 | totalPopulation: 8249117, 34 | country: 'Thailand', 35 | zipCode: 1200 36 | }) 37 | .expect(200, (err, res) => { 38 | temp.idCity = res.body._id; 39 | done(); 40 | }); 41 | }); 42 | }); 43 | 44 | describe('GET /cities', () => { 45 | it('should get all cities', done => { 46 | request 47 | .get('/api/cities') 48 | .set('Authorization', `Bearer ${temp.token}`) 49 | .set('Accept', 'application/json') 50 | .expect(200, (err, res) => { 51 | expect(res.body.length).to.be.at.least(1); 52 | done(); 53 | }); 54 | }); 55 | }); 56 | 57 | describe('GET /cities/:id', () => { 58 | it('should get a city', done => { 59 | request 60 | .get(`/api/cities/${temp.idCity}`) 61 | .set('Authorization', `Bearer ${temp.token}`) 62 | .set('Accept', 'application/json') 63 | .expect(200, (err, res) => { 64 | res.body.name.should.equal('Bangkok'); 65 | res.body.totalPopulation.should.equal(8249117); 66 | res.body.country.should.equal('Thailand'); 67 | res.body.zipCode.should.equal(1200); 68 | res.body._id.should.equal(temp.idCity); 69 | done(); 70 | }); 71 | }); 72 | }); 73 | 74 | describe('PUT /cities', () => { 75 | it('should update a city', done => { 76 | request 77 | .put(`/api/cities/${temp.idCity}`) 78 | .set('Authorization', `Bearer ${temp.token}`) 79 | .set('Accept', 'application/json') 80 | .send({ 81 | name: 'Chiang Mai', 82 | totalPopulation: 148477, 83 | country: 'Thailand', 84 | zipCode: 50000 85 | }) 86 | .expect(200, (err, res) => { 87 | temp.idCity = res.body._id; 88 | done(); 89 | }); 90 | }); 91 | 92 | it('should get updated city', done => { 93 | request 94 | .get(`/api/cities/${temp.idCity}`) 95 | .set('Authorization', `Bearer ${temp.token}`) 96 | .set('Accept', 'application/json') 97 | .expect(200, (err, res) => { 98 | res.body.name.should.equal('Chiang Mai'); 99 | res.body.totalPopulation.should.equal(148477); 100 | res.body.country.should.equal('Thailand'); 101 | res.body.zipCode.should.equal(50000); 102 | res.body._id.should.equal(temp.idCity); 103 | done(); 104 | }); 105 | }); 106 | }); 107 | 108 | describe('DELETE /cities', () => { 109 | it('should delete a city', done => { 110 | request 111 | .delete(`/api/cities/${temp.idCity}`) 112 | .set('Authorization', `Bearer ${temp.token}`) 113 | .set('Accept', 'application/json') 114 | .expect(200, (err, res) => { 115 | done(); 116 | }); 117 | }); 118 | 119 | it('should get error', done => { 120 | request 121 | .get(`/api/cities/${temp.idCity}`) 122 | .set('Accept', 'application/json') 123 | .expect(404, () => { 124 | done(); 125 | }); 126 | }); 127 | }); 128 | --------------------------------------------------------------------------------