diff --git a/.gitignore b/.gitignore index 3b9771e..130cd31 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,6 @@ bin/Release .idea # Mac files -.DS_Store \ No newline at end of file +.DS_Store + +.env diff --git a/app/.env.example b/app/.env.example new file mode 100644 index 0000000..f11a866 --- /dev/null +++ b/app/.env.example @@ -0,0 +1 @@ +MONGODB_URL=mongodb://127.0.0.1/test diff --git a/app/app.js b/app/app.js new file mode 100644 index 0000000..1ce600f --- /dev/null +++ b/app/app.js @@ -0,0 +1,54 @@ +const express = require('express'); +const path = require('path'); +const debug = require('debug')('app:server'); +const mongoose = require('mongoose'); +require('dotenv').config(); + +const indexRouter = require('./routes/index'); + +const app = express(); + +app.use(express.json()); + +// connect MongoDB +const url = process.env.MONGODB_URL || 'mongodb://127.0.0.1/test'; + +mongoose.connect(url, { + useNewUrlParser: true, + useUnifiedTopology: true +}).catch(console.error.bind(console, 'connection error:')); + +mongoose.connection.once('open', () => { + debug("MongoDB connected!"); +}); + +app.use('/', indexRouter); + +// catch mongoose ValidationError +app.use(function(err, req, res, next) { + if (err instanceof mongoose.Error.ValidationError) { + let errors = []; + + createError(err.errors['name']); + createError(err.errors['position']); + createError(err.errors['id']); + + res.status(400).json({ errors }); + + // create error + function createError(err) { + if (err) { + errors.push({ + kind: err.kind, + message: err.message, + path: err.path + }); + } + } + + } else { + next(err); + } +}); + +module.exports = app; diff --git a/app/bin/www b/app/bin/www new file mode 100755 index 0000000..a8c2d36 --- /dev/null +++ b/app/bin/www @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +var app = require('../app'); +var debug = require('debug')('app:server'); +var http = require('http'); + +/** + * Get port from environment and store in Express. + */ + +var port = normalizePort(process.env.PORT || '3000'); +app.set('port', port); + +/** + * Create HTTP server. + */ + +var server = http.createServer(app); + +/** + * Listen on provided port, on all network interfaces. + */ + +server.listen(port); +server.on('error', onError); +server.on('listening', onListening); + +/** + * Normalize a port into a number, string, or false. + */ + +function normalizePort(val) { + var port = parseInt(val, 10); + + if (isNaN(port)) { + // named pipe + return val; + } + + if (port >= 0) { + // port number + return port; + } + + return false; +} + +/** + * Event listener for HTTP server "error" event. + */ + +function onError(error) { + if (error.syscall !== 'listen') { + throw error; + } + + var bind = typeof port === 'string' + ? 'Pipe ' + port + : 'Port ' + port; + + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + console.error(bind + ' requires elevated privileges'); + process.exit(1); + break; + case 'EADDRINUSE': + console.error(bind + ' is already in use'); + process.exit(1); + break; + default: + throw error; + } +} + +/** + * Event listener for HTTP server "listening" event. + */ + +function onListening() { + var addr = server.address(); + var bind = typeof addr === 'string' + ? 'pipe ' + addr + : 'port ' + addr.port; + debug('Listening on ' + bind); +} diff --git a/app/controllers/playerController.js b/app/controllers/playerController.js new file mode 100644 index 0000000..f1f3fe1 --- /dev/null +++ b/app/controllers/playerController.js @@ -0,0 +1,83 @@ +const Player = require('../models/playerModel'); + +// show a player +exports.show = async function(req, res, next) { + let { id } = req.params; + + const player = await Player.findOne({ _id: id, deletedAt: undefined }); + + if (!player) { + return res.status(404).end('player not found'); + } + + res.json({ + id: player.id, + name: player.name, + position: player.position + }); +}; + +// create a player +exports.create = function(req, res, next) { + const { name, position } = req.body; + + const player = new Player({ name, position }); + + player.save((err, player) => { + if (err) return next(err); + + res.json({ + id: player.id, + name: player.name, + position: player.position + }); + }); +}; + +// update a player +exports.update = async function(req, res, next) { + let { id } = req.params; + let { name, position } = req.body; + + const player = await Player.findOne({ _id: id, deletedAt: undefined }); + + if (!player) { + return res.status(404).end('player not found'); + } + + player.name = name; + player.position = position; + + player.save((err, player) => { + if (err) return next(err); + + res.json({ + id: player.id, + name: player.name, + position: player.position + }); + }); +}; + +// soft delete a player +exports.delete = async function(req, res, next) { + let { id } = req.params; + + const player = await Player.findOne({ _id: id, deletedAt: undefined }); + + if (!player) { + return res.status(404).end('player not found'); + } + + player.deletedAt = new Date(); + + player.save((err, player) => { + if (err) return next(err); + + res.json({ + id: player.id, + name: player.name, + position: player.position + }); + }); +}; diff --git a/app/models/playerModel.js b/app/models/playerModel.js new file mode 100644 index 0000000..1b0b6d7 --- /dev/null +++ b/app/models/playerModel.js @@ -0,0 +1,9 @@ +const mongoose = require('mongoose'); + +const schema = new mongoose.Schema({ + name: { type: String, required: true }, + position: { type: String, enum: ['C', 'PF', 'SF', 'PG', 'SG'] }, + deletedAt: Date +}, { timestamps: true }); + +module.exports = mongoose.model('Player', schema); diff --git a/app/package.json b/app/package.json old mode 100755 new mode 100644 index 6b480b3..ea0ad1f --- a/app/package.json +++ b/app/package.json @@ -1,20 +1,20 @@ { - "private": true, "name": "node-examination", "version": "0.0.1", "description": "Building a RESTful CRUD API with Node.js, Express/Koa and MongoDB.", + "private": true, "main": "server.js", "scripts": { - "start": "NODE_ENV=development node server.js", - "start:prod": "NODE_ENV=production node server.js", - "test": "echo \"Error: no test specified\" && exit 1" + "start": "nodemon ./bin/www", + "start:prod": "NODE_ENV=production node ./bin/www", + "test": "mocha --exit" }, "dependencies": { - "express": "^4.17.1", - "mongoose": "^5.9.2" - }, - "devDependencies": { - "chai": "^4.2.0" + "debug": "~2.6.9", + "dotenv": "^8.2.0", + "express": "~4.16.1", + "mongoose": "^5.9.5", + "morgan": "~1.9.1" }, "engines": { "node": ">=10.15.0" @@ -24,5 +24,10 @@ "not dead", "not ie <= 11", "not op_mini all" - ] + ], + "devDependencies": { + "mocha": "^7.1.1", + "nodemon": "^2.0.2", + "supertest": "^4.0.2" + } } diff --git a/app/routes/index.js b/app/routes/index.js new file mode 100644 index 0000000..12c48ab --- /dev/null +++ b/app/routes/index.js @@ -0,0 +1,18 @@ +const express = require('express'); +const playerController = require('../controllers/playerController'); + +const router = express.Router(); + +/* GET home page. */ +router.get('/', function(req, res, next) { + res.json({ + message: 'Building a RESTful CRUD API with Node.js, Express and MongoDB.' + }); +}); + +router.get('/player/:id([0-9a-f]{24})', playerController.show); +router.post('/player', playerController.create); +router.put('/player/:id([0-9a-f]{24})', playerController.update); +router.delete('/player/:id([0-9a-f]{24})', playerController.delete); + +module.exports = router; diff --git a/app/server.js b/app/server.js deleted file mode 100755 index 72e5b39..0000000 --- a/app/server.js +++ /dev/null @@ -1,11 +0,0 @@ -const express = require('express'); - -const app = express(); - -app.get('/', (req, res) => { - res.json({"message": "Building a RESTful CRUD API with Node.js, Express/Koa and MongoDB."}); -}); - -app.listen(3000, () => { - console.log("Server is listening on port 3000"); -}); \ No newline at end of file diff --git a/app/test/test.js b/app/test/test.js new file mode 100644 index 0000000..bf6a612 --- /dev/null +++ b/app/test/test.js @@ -0,0 +1,240 @@ +const app = require('../app'); +const assert = require('assert'); +const Player = require('../models/playerModel'); +const request = require('supertest'); + +describe('/player', function() { + this.timeout(10000); + + // POST /player + describe('POST /player', function() { + + it('create a new player', function(done) { + request(app) + .post('/player') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + name: 'LeBron', + position: 'C' + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) return done(err); + + assert.strictEqual('LeBron', res.body.name); + assert.strictEqual('C', res.body.position); + + Player.findByIdAndDelete(res.body.id).exec(done); + }); + }); + + it('create a new player with name', function(done) { + request(app) + .post('/player') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ name: 'Perry' }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) return done(err); + + assert.strictEqual('Perry', res.body.name); + assert.strictEqual(undefined, res.body.position); + + Player.findByIdAndDelete(res.body.id).exec(done); + }); + }); + + it('create a new player with invalid name and postion', function(done) { + request(app) + .post('/player') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ position: 'unknow' }) + .expect('Content-Type', /json/) + .expect(400) + .end((err, res) => { + if (err) return done(err); + + assert.strictEqual(2, res.body.errors.length); + assert.deepStrictEqual(res.body.errors, [ + { + "kind": "required", + "message": "Path `name` is required.", + "path": "name" + }, + { + "kind": "enum", + "message": "`unknow` is not a valid enum value for path `position`.", + "path": "position" + } + ]); + done(); + }); + }); + }); + + + // GET /player + describe('GET /player', function() { + + let player = undefined; + + before(function(done) { + player = new Player({ + name: 'LeBron', + position: 'C' + }); + player.save(done); + }); + + after(function(done) { + Player.findByIdAndDelete(player.id).exec(done); + }); + + it('get a player', function(done) { + request(app) + .get(`/player/${player.id}`) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) return done(err); + + assert.strictEqual(player.id, res.body.id); + assert.strictEqual('LeBron', res.body.name); + assert.strictEqual('C', res.body.position); + done(); + }); + }); + + it('get an non-existent player', function(done) { + request(app) + .get(`/player/5e782274d801e125a41393f1`) + .set('Accept', 'application/json') + .expect(404, done); + }); + }); + + + // PUT /player + describe('PUT /player', function() { + + let player = undefined; + + beforeEach(function(done) { + player = new Player({ + name: 'LeBron', + position: 'C' + }); + player.save(done); + }); + + afterEach(function(done) { + Player.findByIdAndDelete(player.id).exec(done); + }); + + it('update a player', function(done) { + request(app) + .put(`/player/${player.id}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + name: 'Perry', + position: 'SF' + }) + .expect(200) + .end((err, res) => { + if (err) return done(err); + + assert.strictEqual(player.id, res.body.id); + assert.strictEqual('Perry', res.body.name); + assert.strictEqual('SF', res.body.position); + done(); + }); + }); + + it('update a player with invalid name and position', function(done) { + request(app) + .put(`/player/${player.id}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({ + name: '', + position: 'unknow' + }) + .expect(400) + .end((err, res) => { + if (err) return done(err); + + assert.strictEqual(2, res.body.errors.length); + assert.deepStrictEqual(res.body.errors, [ + { + "kind": "required", + "message": "Path `name` is required.", + "path": "name" + }, + { + "kind": "enum", + "message": "`unknow` is not a valid enum value for path `position`.", + "path": "position" + } + ]); + done(); + }); + }); + + it('update an non-existent player', function(done) { + request(app) + .put(`/player/5e782274d801e125a41393f1`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .expect(404, done); + }); + }); + + + // DELETE /player + describe('DELETE /player', function() { + + let player = undefined; + + before(function(done) { + player = new Player({ + name: 'LeBron', + position: 'C' + }); + player.save(done); + }); + + after(function(done) { + Player.findByIdAndDelete(player.id).exec(done); + }); + + it('delete a player', function(done) { + request(app) + .delete(`/player/${player.id}`) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) return next(err); + + assert.strictEqual(player.id, res.body.id); + assert.strictEqual('LeBron', res.body.name); + assert.strictEqual('C', res.body.position); + done(); + }); + }); + + it('delete an deleted player', function(done) { + request(app) + .delete(`/player/${player.id}`) + .set('Accept', 'application/json') + .expect(404, done); + }); + + }); + +});