diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/README.md b/README.md index 0aaf9f2..994c94e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![cf](https://i.imgur.com/7v5ASc8.png) Lab 09: Vanilla REST API w/ Persistence +![cf](https://i.imgur.com/7v5ASc8.png) Lab 08: Vanilla REST API ====== ## Submission Instructions @@ -10,23 +10,48 @@ * write a question and observation on canvas ## Learning Objectives -* students will learn how to save resource data to the file system for a layer of data persistence -* students will learn how to refactor commonly used coding constructs into custom helper modules +* students will learn to use promise constructs to manage asynchronous code +* students will learn to create a vanilla RESTful API with in-memory persistence ## Requirements - #### Configuration -* `package.json` -* `.eslintrc` -* `.gitignore` -* `README.md` - * your `README.md` should include detailed instructions on how to use your API - * this should include documentation on how to access your API endpoints + * `.gitignore` + * `.eslintrc` + * `package.json` + * `README.md` #### Feature Tasks -* continue working on your vanilla REST API -* refactor your routes to be contained in a separate module (ex: `route/resource-route.js`) -* refactor your `res` messages & status codes to be contained in a separate module (ex: `response.js`) -* refactor the `storage.js` module to use file system persistence - * use the `fs` module to create and read the associated data files - * the name of the file should contain the related resource id +* create the following directories to organize your code: + * `lib` + * `model` + * `test` +* create an HTTP server using the native NodeJS `http` module +* create an object constructor that creates a _simple resource_ with at least 3 properties + * include an `id` property that is set to a unique id (**hint:** you'll need to use `node-uuid`) + * include two additional properties of your choice (ex: name, content, etc.) +* create a custom body parser module that uses promises to parse the JSON body of `POST` and `PUT` requests +* create a custom url parser module that returns a promise and uses the NodeJS `url` and `querystring` modules to parse the request url +* create a router constructor that handles requests to `GET`, `POST`, `PUT`, and `DELETE` requests +* create a storage module that will store resources by their schema type (ex: note) and id + +## Server Endpoints +### `/api/simple-resource-name` +* `POST` request + * pass data as stringifed JSON in the body of a **POST** request to create a new resource +* `GET` request + * pass `?id=` as a query string parameter to retrieve a specific resource (as JSON) +* `DELETE` request + * pass `?id=` in the query string to **DELETE** a specific resource + * this should return a 204 status code with no content in the body + +## Tests +* write a test to ensure that your api returns a status code of 404 for routes that have not been registered +* write tests to ensure the `/api/simple-resource-name` endpoint responds as described for each condition below: + * `GET`: test 404, it should respond with 'not found' for valid requests made with an id that was not found + * `GET`: test 400, it should respond with 'bad request' if no id was provided in the request + * `GET`: test 200, it should contain a response body for a request made with a valid id + * `POST`: test 400, it should respond with 'bad request' if no request body was provided or the body was invalid + * `POST`: test 200, it should respond with the body content for a post request with a valid body + +## Bonus +* **2pts:** a `GET` request to `/api/simple-resource-name` with no **?id=** should return an array of all of the ids for that resource diff --git a/data/hike/09b66e38-156c-41a7-906a-05675fb638c7.json b/data/hike/09b66e38-156c-41a7-906a-05675fb638c7.json new file mode 100644 index 0000000..1831f83 --- /dev/null +++ b/data/hike/09b66e38-156c-41a7-906a-05675fb638c7.json @@ -0,0 +1 @@ +{"id":"09b66e38-156c-41a7-906a-05675fb638c7","name":"some cool hike","distance":"3.4 miles","difficulty":"medium","description":"a cool hike"} \ No newline at end of file diff --git a/data/hike/09c55d68-ec12-4b57-90c1-3735ef74ac92.json b/data/hike/09c55d68-ec12-4b57-90c1-3735ef74ac92.json new file mode 100644 index 0000000..ba70b75 --- /dev/null +++ b/data/hike/09c55d68-ec12-4b57-90c1-3735ef74ac92.json @@ -0,0 +1 @@ +{"id":"09c55d68-ec12-4b57-90c1-3735ef74ac92","name":"some cool hike","distance":"3.4 miles","difficulty":"medium","description":"a cool hike"} \ No newline at end of file diff --git a/data/hike/106404cb-9a5c-42cd-87d1-447dcb1198d0.json b/data/hike/106404cb-9a5c-42cd-87d1-447dcb1198d0.json new file mode 100644 index 0000000..9353800 --- /dev/null +++ b/data/hike/106404cb-9a5c-42cd-87d1-447dcb1198d0.json @@ -0,0 +1 @@ +{"id":"106404cb-9a5c-42cd-87d1-447dcb1198d0","name":"some cool hike","distance":"3.4 miles","difficulty":"medium","description":"a cool hike"} \ No newline at end of file diff --git a/data/hike/78b02d3c-99e5-450a-9a57-97df43d0f88a.json b/data/hike/78b02d3c-99e5-450a-9a57-97df43d0f88a.json new file mode 100644 index 0000000..e0b9396 --- /dev/null +++ b/data/hike/78b02d3c-99e5-450a-9a57-97df43d0f88a.json @@ -0,0 +1 @@ +{"id":"78b02d3c-99e5-450a-9a57-97df43d0f88a","name":"some cool hike","distance":"3.4 miles","difficulty":"medium","description":"a cool hike"} \ No newline at end of file diff --git a/data/hike/976954a0-a510-426c-b0f8-e6269bef045e.json b/data/hike/976954a0-a510-426c-b0f8-e6269bef045e.json new file mode 100644 index 0000000..5fd28f7 --- /dev/null +++ b/data/hike/976954a0-a510-426c-b0f8-e6269bef045e.json @@ -0,0 +1 @@ +{"id":"976954a0-a510-426c-b0f8-e6269bef045e","name":"some cool hike","distance":"3.4 miles","difficulty":"medium","description":"a cool hike"} \ No newline at end of file diff --git a/lib/parse-json.js b/lib/parse-json.js new file mode 100644 index 0000000..660f59e --- /dev/null +++ b/lib/parse-json.js @@ -0,0 +1,31 @@ +'use strict'; + +module.exports = function(req){ + return new Promise((resolve, reject) => { + if (req.method === 'POST' || req.method === 'PUT') { + var body = ''; + req.on('data', data => { + body += data.toString(); + }); + + req.on('end', () => { + try { + req.body = JSON.parse(body); + resolve(req); + } catch (err) { + console.error(err); + reject(err); + } + }); + + req.on('error', err => { + console.error(err); + reject(err); + }); + + return; + } + + resolve(); + }); +}; diff --git a/lib/parse-url.js b/lib/parse-url.js new file mode 100644 index 0000000..f9c85e5 --- /dev/null +++ b/lib/parse-url.js @@ -0,0 +1,10 @@ +'use strict'; + +const parseUrl = require('url').parse; +const parseQuery = require('querystring').parse; + +module.exports = function(req){ + req.url = parseUrl(req.url); + req.url.query = parseQuery(req.url.query); + return Promise.resolve(req); +}; diff --git a/lib/response.js b/lib/response.js new file mode 100644 index 0000000..e9c9366 --- /dev/null +++ b/lib/response.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = exports = {}; + +exports.sendJSON = function(res,status,data) { + res.writeHead(status, { + 'Content-Type': 'application/json' + }); + + res.write(JSON.stringify(data)); + res.end(); +}; + +exports.sendText = function (res, status, msg){ + res.writeHead(status, { + 'Content-Type': 'text/plain' + }); + + res.write(msg); + res.end(); +}; diff --git a/lib/router.js b/lib/router.js new file mode 100644 index 0000000..d9610a9 --- /dev/null +++ b/lib/router.js @@ -0,0 +1,62 @@ +'use strict'; + +const parseUrl = require('./parse-url.js'); +const parseJSON = require('./parse-json.js'); + +const Router = module.exports = function(){ + this.routes = { + GET: {}, + POST: {}, + PUT: {}, + DELETE: {} + }; +}; + +Router.prototype.get = function(endpoint, callback){ + this.routes.GET[endpoint] = callback; +}; + +Router.prototype.post = function(endpoint, callback){ + this.routes.POST[endpoint] = callback; +}; + +Router.prototype.put = function(endpoint, callback){ + this.routes.PUT[endpoint] = callback; +}; + +Router.prototype.delete = function (endpoint, callback){ + this.routes.DELETE[endpoint] = callback; +}; + +Router.prototype.route = function(){ + return (req, res) => { + Promise.all([ + parseUrl(req), + parseJSON(req) + ]) + .then(()=> { + if(typeof this.routes[req.method][req.url.pathname] === 'function'){ + this.routes[req.method][req.url.pathname](req,res); + return; + } + console.error('route not found'); + + res.writeHead(404, { + 'Content-Type': 'text/plain' + }); + + res.write('route not found'); + res.end(); + }) + .catch(err => { + console.error(err); + + res.writeHead(400, { + 'Content-Type': 'text/plain' + }); + + res.write('bad result'); + res.end(); + }); + }; +}; diff --git a/lib/storage.js b/lib/storage.js new file mode 100644 index 0000000..1206b65 --- /dev/null +++ b/lib/storage.js @@ -0,0 +1,50 @@ +'use strict'; + +const Promise = require('bluebird'); +const fs = Promise.promisifyAll(require('fs'), { suffix: 'Prom'}); + +module.exports = exports = {}; + +exports.createItem = function(schemaName, item){ + if(!schemaName) return Promise.reject(new Error('expected schemaName')); + if(!item) return Promise.reject(new Error('expected item')); + + let json = JSON.stringify(item); + return fs.writeFileProm(`${__dirname}/../data/${schemaName}/${item.id}.json`, json) + .then( () => item) + .catch( err => Promise.reject(err)); +}; + +exports.fetchItem = function(schemaName, id){ + if(!schemaName) return Promise.reject(new Error('expected schema name')); + if(!id) return Promise.reject(new Error('expected id')); + + return fs.readFileProm(`${__dirname}/../data/${schemaName}/${id}.json`) + .then( data => { + try { + let item = JSON.parse(data.toString()); + return item; + } catch (err) { + return Promise.reject(err); + } + }) + .catch( err => Promise.reject()); +}; + + +// didn't know if i was supposed to rafactor this, was short on time so didn't, but kept it around just in case... +// exports.deleteItem = function(schemaName, id){ +// return new Promise((resolve, reject) => { +// if(!schemaName) return reject(new Error('expected schemaName')); +// if(!id) return reject(new Error('expected id')); +// +// var schema = storage[schemaName]; +// if(!schema) return reject(new Error('schema not found')); +// +// var item = schema[id]; +// if(!item) return reject(new Error('item not found')); +// +// delete storage[schemaName][id]; +// resolve(); +// }); +// }; diff --git a/model/hike.js b/model/hike.js new file mode 100644 index 0000000..edf0023 --- /dev/null +++ b/model/hike.js @@ -0,0 +1,16 @@ +'use strict'; + +const uuidv4 = require('uuid/v4'); + +module.exports = function(name, distance, difficulty, description){ + if(!name)throw new Error('expected name'); + if(!distance)throw new Error('expected distance'); + if(!difficulty)throw new Error('expected difficulty'); + if(!description)throw new Error('expected description'); + + this.id = uuidv4(); + this.name = name; + this.distance = distance; + this.difficulty = difficulty; + this.description = description; +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..63a713d --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "08-vanilla_rest_api", + "version": "1.0.0", + "description": "![cf](https://i.imgur.com/7v5ASc8.png) Lab 08: Vanilla REST API ======", + "main": "server.js", + "directories": { + "test": "test" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node server.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codefellows-javascript-401d17/08-vanilla_rest_api.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/codefellows-javascript-401d17/08-vanilla_rest_api/issues" + }, + "homepage": "https://github.com/codefellows-javascript-401d17/08-vanilla_rest_api#readme", + "dependencies": { + "bluebird": "^3.5.0", + "uuid": "^3.1.0" + }, + "devDependencies": { + "chai": "^4.1.0", + "mocha": "^3.4.2", + "superagent": "^3.5.2" + } +} diff --git a/route/hike-route.js b/route/hike-route.js new file mode 100644 index 0000000..a08468e --- /dev/null +++ b/route/hike-route.js @@ -0,0 +1,33 @@ +'use strict'; + +const storage = require('../lib/storage.js'); +const response = require('../lib/response.js'); +const Hike = require('../model/hike.js'); + +module.exports = function(router) { + router.get('/api/hike', function(req,res) { + if (req.url.query.id){ + storage.fetchItem('hike', req.url.query.id) + .then(hike => { + response.sendJSON(res, 200, hike); + }) + .catch( err => { + response.sendText(res, 404, 'not found'); + }); + + return; + } + response.sendText(res, 400, 'bad request'); + }); + + router.post('/api/hike', function(req, res){ + try { + var hike = new Hike(req.body.name, req.body.distance, req.body.difficulty, req.body.description); + storage.createItem('hike', hike); + response.sendJSON(res, 200, hike); + } catch (err) { + console.error(err); + response.sendText(res, 400, 'bad request'); + } + }); +}; diff --git a/server.js b/server.js new file mode 100644 index 0000000..011103b --- /dev/null +++ b/server.js @@ -0,0 +1,14 @@ +'use strict'; + +const http =require('http'); +const Router = require('./lib/router.js'); +const PORT = process.env.PORT || 3000; +const router = new Router(); + +require('./route/hike-route.js')(router); + +const server = http.createServer(router.route()); + +server.listen(PORT, () => { + console.log('listening on PORT:', PORT); +}); diff --git a/test/hike-route-test.js b/test/hike-route-test.js new file mode 100644 index 0000000..c030419 --- /dev/null +++ b/test/hike-route-test.js @@ -0,0 +1,73 @@ +'use strict'; + +const request = require('superagent'); +const expect = require('chai').expect; + +require('../server.js'); + +describe('Hike Routes', function(){ + var hike = null; + + describe('POST: /api/hike', function(){ + it('should return a hike', function(done){ + request.post('localhost:8000/api/hike') + .send({name:'some cool hike', distance:'3.4 miles', difficulty:'medium', description:'a cool hike'}) + .end((err, res) => { + if(err) return done(err); + expect(res.status).to.equal(200); + expect(res.body.name).to.equal('some cool hike'); + expect(res.body.distance).to.equal('3.4 miles'); + expect(res.body.difficulty).to.equal('medium'); + expect(res.body.description).to.equal('a cool hike'); + hike = res.body; + done(); + }); + }); + + it('should return bad request', function(done){ + request.post('localhost:8000/api/hike') + .send({name:'some cool hike', blue: '3.4 miles'}) + .end((err, res) => { + expect(res.status).to.equal(400); + expect(res.text).to.equal('bad request'); + done(); + }); + }); + }); + + //also do a 400, in the server too. + + describe('GET: /api/hike', function(){ + it('should return a hike', function(done){ + request.get(`localhost:8000/api/hike?id=${hike.id}`) + .send({name:'some cool hike', distance: '3.4 miles', difficulty: 'medium', description:'a cool hike'}) + .end((err, res) => { + if(err) return done(err); + expect(res.status).to.equal(200); + expect(res.body.name).to.equal('some cool hike'); + expect(res.body.distance).to.equal('3.4 miles'); + expect(res.body.difficulty).to.equal('medium'); + expect(res.body.description).to.equal('a cool hike'); + console.log('get request hike:', res.body); + done(); + }); + }); + it('should return an error(404)', function(done){ + request.get('localhost:8000/api/hike?id=123456') + .end((err, res) => { + expect(res.status).to.equal(404); + done(); + }); + }); + + it('should return bad request (400)', function(done){ + request.get('localhost:8000/api/hike?=1234567') + .end(function(err,res){ + expect(res.status).to.equal(400); + done(); + }); + }); + }); +}); + +//also 404 and 400