OpenJS Node.js Service Developer
07 September, 2022
Resources
Notes
Setting up
- Don’t install using OS package manager or installer.
- OS package manager or installer lead to usage
sudo
. - Recommended to use a version manager to install node.
- Use
nvm
for macOS and Linux. - Install version 12 of node
nvm install 12
- Use version 12
nvm use 12
- Use
nvs
to install Node on windows - nvs add 12
- nvs use 12
- Check node version
node -v
- Check npm version
npm -v
Creating a web server
Core
- not recommended to use Node core http/https module for server or services
// node -e "fs.mkdirSync('http-web-server')" // cd http-web-server // server.js use strict; const http = require('http'); const PORT = process.env.PORT || 3000; const hello = `<html> <head> <style> body { background:# 333; margin: 1.25rem; } h1 { color: #eee; font-family: sans-serif; } </style> </head> <body> <h1>Hello World</h1> </body> </html>`; const server = http.createServer((req, res) => { res.setHeader('Content-Type': 'text/html'); res.end(hello); }); server.listen(PORT); // node server.js
- This will create a server that listen on port 3000 and responds to every request the same (regardless of the Method or the route).
- The function passed to createServer is called every time there is a http request. This function receives the request and response object.
res.setHeader
sets the response http header of the response that would eventually be sent.res.end
closes the connection which is a stream.res
inherits from http.ServerResponse which inherits from http.OutgoingMessage which inherits from stream.Stream.createServer
returns an object on which we call the listen method to bind the server to a port.
'use strict'; const http = require('http'); const url = require('url'); const PORT = process.env.PORT || 3000; const {STATUS_CODES } = http; const hello = `<html> <head> <style> body { background:# 333; margin: 1.25rem; } h1 { color: #eee; font-family: sans-serif; } </style> </head> <body> <h1>Hello World</h1> </body> </html>`; const root = `<html> <head> <style> body { background:# 333; margin: 1.25rem; } a { color: yellow; font-size: 2rem; font-family: sans-serif; } </style> </head> <body> <a href="/hello">Hello</a> </body> </html>`; const server = http.createServer((req, res) => { res.setHeader('Content-Type': 'text/html'); if (req.method !== 'GET') { res.statusCode = 405; res.end(STATUS_CODES[res.statusCode] + '\r\n'); return } const {pathname} = url.parse(req.url); if (pathname === '/') { res.end(root); return; } if (pathname === '/hello') { res.end(hello); return; } res.statusCode = 404; res.end(STATUS_CODES[res.statusCode] + '\r\n'); }); server.listen(PORT); // node -e 'http.request('http://localhost:3000', {method: 'POST'}, (res) => res.pipe(process.stdout)).end()"
- We use
req.method
to check the http verb. - We set the response status code using
res.statusCode = 404
- We get the textual translation of the code from the http.STATUS_CODE.
- We get the pathname by parsing the
req.url
likeurl.parse(req.url).pathname
. req.url
only holds to the relative part of the URL not the whole thing.url.parse
helps remove query string or search parameters.- The default status code is 200 and we don’t need to explicitly set it.
Express
- Mostly used for serving dynamic HTML content compared to RESTful JSON content.
// setup directories and files node -e "fs.mkdirSync('express-web-server')" cd express-web-server node -e "fs.mkdirSync('routes')" node -e "fs.mkdirSync('bin')" node -e "fs.openSync('app.js', 'w')" cd routes node -e "fs.openSync('index.js', 'w')" node -e "fs.openSync('hello.js', 'w')" cd ../bin node -e "fs.openSync('www', 'w')" cd.. // create a package.json npm init -y // install packages npm install express@4 http-errors@1 // Add to package.json { "start": "node ./bin/www" }
// app.js 'use strict'; const express = require('express'); const app = express(); module.exports = app;
- We are requiring the express module
- then we are instantiating an express instance
- final we export the instance from the module
#!/usr/bin/env node 'use strict' const app = require('../app'); const http = require('http'); const PORT = process.env.PORT || 3000; const server = http.createServer(app); server.listen(PORT);
- bin/www is the entry point to start the sever.
- we pass the app instance we created before to
createServer
. app
is an express instance that accepts request objects and a response object fromcreateServer
.- To start the server run
npm start
. The current server will respond with 404 for all request.
'use strict'; const express = require('express'); const createError = require('http-errors') const app = express(); app.use((req, res, next) => { if (req.method !== 'GET') { next(createError(405)); return } next(createError(404)) }) app.use((err, req, res, next) => { res.status(err.status || 500); res.send(err.message); }) module.exports = app;
app.use
is used to configure express behaviour. The function passed toapp.use
is called for every incoming request.- The difference between
app.use
andcreateServer
is the additional parameternext
. Called error first callback. - Instead of passing one big function to
createServer
we pass multiple functions toapp.use
. - functions will be called in the order they were registered using
app.use
. - If
next
function is not called, then the request handling ends there and none of the ensuing registered functions are call for that request. This is know as the middleware pattern. - 404 is the default for GET requests as there is no route registered.
- The last middle should always have 4 parameters. This receives the error object as the first parameter
- res.send is a function and it tries to figure out the content-type based on the input.
- Express’ decorator approach on core API is considered as a mistake as it conflates node’s core API with express.
// routes/index.js 'use strict'; const { Router } = require('express'); const router = Router() const root = `<html> <head> <style> body {background: #333; margin: 1.25rem} a {color: yellow; font-size: 2rem; font-family: sans-serif;} </style> </head> <body> <a href="/hello">Hello</a> </body> </html> `; router.get('/', (req,res) => { res.send(root); }); module.exports = router;
- Better to use templating language to generate HTML rather than inlining here.
- We use express Router to create routes.
- We define a GET route using
router.get
. It also supportsPOST, PUT, and others
. - We define the pathname by providing the method it as the first argument
router.get('/'
- The second argument to the route is the middleware function.
// routes/hello.js 'use strict'; const { Router } = require('express'); const router = Router() const hello = `<html> <head> <style> body {background: #333; margin: 1.25rem} a {color: yellow; font-size: 2rem; font-family: sans-serif;} </style> </head> <body> <h1>Hello World</h1> </body> </html> `; router.get('/hello', (req,res) => { res.send(hello); }); module.exports = router;
- Here we changed the pathname and the response content.
'use strict'; const express = require('express'); const createError = require('http-errors'); const indexRoutes = require('./routes'); const helloRoutes = require('./routes/hello'); const app = express(); app.use('/', indexRoutes); app.use('/hello', helloRoutes); app.use((req, res, next) => { if (req.method !== 'GET') { next(createError(405)); return } next(createError(404)) }) app.use((err, req, res, next) => { res.status(err.status || 500); res.send(err.message); }) module.exports = app;
- We use
app.use(path, exportedFN)
to register a route. - The routes defined in
index
andhello
will be set under the registered pathname. - These routes are registered before 404/405 error handler middleware.
Fastify
- specifically geared toward RESTful JSON services
- Instead of middleware Fastify supports a plugin based pattern
- Provides express integration using fastify-express plugin.
node -e "fs.mkdirSync('fastify-web-server')" cd fastify-web-server npm init fastify
npm init fastify
generates a new fastify project.- package.json is already configured for the project.
- npm start uses the fastify-cli in the background to the server.
- npm run dev starts the application with pretty logs and in watch mode
- routes are exported as plugins
- unlike express middleware, the fasitfy plugins are only called at the initialisation time.
- fastify plugins are always async (cb, or promise)
'use strict'; module.exports = async function (fastify, opts) { fastify.get('/', async function(request, reply) { return {root: true}; } }
- We are exporting a plugin which define a GET route at the
/
path. - The plugin takes in fastify instance as an argument.
- All methods can be called like
.get, .post, .put, etc
. - Second argument is the route handler which accepts the request and reply. These have same object as http or express parameters.
- Route handler can be async or sync. Return value is sent as the content of the http response.
- We can also use reply.send method
reply.send({root: true})
. Since it’s an object, fastify automatically converts it into JSON response.
// routes/root.js 'use strict'; const root = `<html> <head> <style> body {background: #333; margin: 1.25rem} a {color: yellow; font-size: 2rem; font-family: sans-serif;} </style> </head> <body> <a href="/hello">Hello</a> </body> </html> `; module.exports = async function (fastify, opts) { fastify.get('/', async function (request, reply) { reply.type('text/html') return root; }) }
- Here we are explicitly setting the response content type using
reply.type('text/html')
and then we return the inline markup.
// routes/example/index.js 'use strict'; module.exports = function (fastify, opts) { fastify.get('/', async function (request, reply) { return 'this is an example'; }) }
- This registers a route at
/hello
by renaming/example
as routes are defined per sub folder.
cd routes node -e "fs.renameSync('example', 'hello')" cd ..
- fastify comes with the default 404 route.
// routes/hello/index.js 'use strict'; const hello = `<html> <head> <style> body { background: #333; margin: 1.25rem; } h1 { color: #eee; font-family: sans-serif; } </style> </head> <body> <h1>Hello World</h1> </body> </html> `; module.exports = async function (fastify, opts) { fastify.get('/', async function (request, reply) { reply.type('text/html'); return hello; }) }
- here we are sending inline html to the user
'use strict'; const path = require('path'); const AutoLoad = require('fastify-autoload'); module.exports = async function (fastify, opts) { fastify.register(AutoLoad, { dir: path.join(__dirname, 'plugins') options: Object.assign({}, opts); }); fastify.register(AutoLoad, { dir: path.join(__dirname, 'routes'), options: Object.assign({}, opts) }); fastify.setNotFoundHandler((request, reply) => { if (request.method !== 'GET') { reply.status(405); return 'Method Not Allowed\n' } return 'Not Found\n'; }) }
setNotFoundHandler
acts similar to router. Based on details we can change the statusCode, but it defaults to 404.
Execise
const http = require('http'); const url = require('url'); const PORT = process.env.PORT || 3000; const data = require('./data'); const server = http.createServer(async (req, res) => { const {pathname} = url.parse(req.url); const METHOD = req.method; console.log(pathname); if (pathname === '/' && METHOD === 'GET') { const response = await data(); res.end(response); return } res.statusCode = 404; res.end(http.STATUS_CODES[404]); return; }) server.listen(PORT);
const http = require('http') const url = require('url') const PORT = process.env.PORT || 3000; const server = http.createServer((req, res) => { const {pathname} = url.parse(req.url); const METHOD = req.method; if (pathname !== "/") { res.statusCode = 503; res.end(http.STATUS_CODES[res.statusCode]); return } if (METHOD === "GET") { res.statusCode = 200; res.end(http.STATUS_CODES[res.statusCode]); return } if (METHOD === "POST") { res.statusCode = 405; res.end(http.STATUS_CODES[res.statusCode]); return } }) server.listen(PORT);
Serving Web Content
Static content shouldn’t be served using Node. Instead use CDN or caching reverse proxy
Node.js is better suited for serving dynamic content, for evented I/O work
Use Fastify-static to serve content using Fastify. Install it as devDependency so that in production you can use CDN or infrastructure based solution.
'use strict'; const path = require('path') const AutoLoad = require('fastify-autoload') const dev = process.env.NODE_ENV !== 'production' const fastifyStatic = dev && require('fastify-static'); module.exports = async function (fastify, opts) { if (dev) { fastify.register(fastifyStatic, { root: path.join(__dirname, 'public') }) } fastify.register(AutoLoad, { dir: path.join(__dirname, 'plugins'), options: Object.assign({}, opts) }); fastify.register(AutoLoad, { dir: path.join(__dirname, 'routes'), options: Object.assign({}, opts) }); fastify.setNotFoundHandler((request, reply) => { if (request.method !== 'GET') { reply.status(405); return 'Method not Allowed\n'; } return 'Not Found\n'; }); }
To use a Fastify plugin we will use
fastify.register
and pass it plugin name and its options.We can then link HTML files stored in the static directory using
<a href=“/hello.html”>HELLO</a>
.We can respond back with file directly rather than link it using
reply.sendFile(‘hello.html’)
To use templates with fastify we need to install
point-of-view
and a templating language likehandlebars
.'use strict'; const path = require('path') const AutoLoad = require('fastify-autoload') const dev = process.env.NODE_ENV !== 'production' const fastifyStatic = dev && require('fastify-static'); const pointOfView = require('point-of-view'); const handlebars = require('handlebars'); module.exports = async function (fastify, opts) { if (dev) { fastify.register(fastifyStatic, { root: path.join(__dirname, 'public') }) } fastify.register(pointOfView, { engine: { handlebars }, root: path.join(__dirname, 'views'), layout: 'layout.hbs', }); fastify.register(AutoLoad, { dir: path.join(__dirname, 'plugins'), options: Object.assign({}, opts) }); fastify.register(AutoLoad, { dir: path.join(__dirname, 'routes'), options: Object.assign({}, opts) }); fastify.setNotFoundHandler((request, reply) => { if (request.method !== 'GET') { reply.status(405); return 'Method not Allowed\n'; } return 'Not Found\n'; }); }
To send back the template we can use the
reply.view(‘index.hbs’)
.reply.view
should be used in an async functionWe can pass a second argument to
reply.view(‘index.hbs, {name: ‘bassam’})
to pass data to templates.We can get query parameters from
request.query
.To setup a new express project use
npm i -g express-generator@4
Use
express —-hbs express-web-server
to generate a new projectconst express = require('express') const path = require('path'); const app = express(); app.set('view', path.join(__dirname, 'views')); app.set('view engine', 'hbs'); app.use(express.static(path.join(__dirname, 'public'))
We set the static file directory to
public
however we can use it for specific environmentif (process.env.NODE_ENV !== 'production') { app.use(express.static(path.join(__dirname, 'public'))); }
We setup the view directory to
views
and the view engine tohbs
.app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'hbs');
We render the template in express using
res.render(‘index’)
, similar to fastify, the second argument would be the data that would be sent to template.In express we can access query parameters using
req.query
router.get('/', (req, res, next) => { var greeting = req.query ?? 'Hello'; res.render('hello', { greeting }); }) module.exports = router;
Streamed content has a
Transfer-Encoding
header set to chunked.Streamed response allows us to send data in chunks rather than waiting for the whole payload to be processed.
Fastify out of the box supports returning streaming response.
reply.send(stream)
can be passed a stream response.Express doesn’t support stream response out of the box.
router.get('/', function (req, res, next) { const { amount, type } = req.query; if (type === 'html') res.type('text/html') if (type === 'json') res.type('application/json') const stream = hnLatestStream(amount, type); stream.pipe(res, {end:false}) finished(stream, err => { if (err) { next(err) return } res.end() }) }) module.exports = router;
We specifically tell the pipe that when the stream content completes don’t end the
res
stream.We instead use
finished
to end the stream while passing the error handling callback which triggers errorReadable-stream can be used instead of finished in case of older node versions.
Finished sends an error when connection is interrupted while streaming. Fastify instead set HTTP status code to 500 and outputs JSON error.
fastify.register(require('point-of-view', { engine: {handlebar: require('handlebar')}, root: path.join(__dirname, 'view'), }); module.exports = function (fastify, opts) { fastify.get('/me', async (request, reply) => { reply.view('layout.hbs'); }) }
const {Readable, Transform} = require('stream'); function stream() { const readable = Readable.from(['this', 'is', 'a', 'stream', 'of', 'data'].map(s => s + '<br>')) const delay = new Transform(({ transform(chunk, enc, cb) { setTimeout(cb, 500, null, chunk) } }))) return readable.pipe(delay); } module.exports = function (fastify, opts) { fastify.get('/data', async (request, reply) => { return stream(); }); } router.get('/data', async (req, res) => { const s = stream(); s.pipe(res, {end: false}); finished(s, (err) => { if (err) { next(err) return } res.end() }) })
Creating RESTful JSON services
It’s important to have the port picked from PORT environment variable and
npm start
starts our serverRunning
npm init fastify -- --integrate
will update an existing project to update package.json and add required modules. Also generating required files.We can use
fastify-sensible
plugin to add sane defaults like response and error functions.fastify.register(require('fasitfy-sensible'))
Dummy async datasource
'use strict'; module.exports = { bicycle: function() { const db = {1: {brand: 'Veloretti', color: 'green'}}, function read(id, cb) { if (!(db.hasOwnProperty(id))) { const err = Error('not found'); setImeediate(() => cb(err)) return } setImmediate(() => cb(null, db[id])) } } }
// routes/bicyle/index.js const {bicycle} = require('../../model') module.exports = async function (fastify, opts) { fastify.get('/:id', (request, reply) => { const {id} = request.params; bicycle.read(id, (err, result) => { if (err) { if (err.message = 'not found') reply.notFound() // 404: comes from reply.notFound else reply.send(err) // 500: send back an error } else reply.send(result) }) }) } // bicyle/1 => {brand: 'Veloretti', color: 'green'} // bicyle/2 => {statusCode: 404, error: not found, message: not found}
- if there is ID we send back the result
- If there is no ID we send by 404
- if there other err we send back 500
- We can continue using fastify async route handlers with cb functions
// routes/bicyle/index.js const {bicycle} = require('../../model') module.exports = async function (fastify, opts) { fastify.get('/:id', async (request, reply) => { const {id} = request.params; bicycle.read(id, (err, result) => { if (err) { if (err.message = 'not found') reply.notFound() // 404: comes from reply.notFound else reply.send(err) // 500: send back an error } else reply.send(result) }) await reply }) }
- Converting callback to async function
'use strict'; const {promisify} = require('util') const {bicycle} = require('../../model') const read = promisify(bicycle.read) module.exports = async (fastify, opts) => { const {notFound} = fastify.httpErrors; fastify.get('/:id', async (request, reply) => { const { id } = request.params; try { return await read(id) } catch(err) { if (err.message === 'not found') throw notFound() // 404 throw err // 500 } }) }
- we convert our data source to a promise api using
util.promisify
- throwing err from an async function leads to 500
- returning data leads to respective content-type and 200
- we use
fastify.httpErrors.notFound
for generating the 404 status code and message
node -e "http.get('http://localhost:3000/bicycle/1', ({headers, statusCode}) => console.log({headers, statusCode});
- Generate an express app using
express-generator
⇒express my-express-service
- Add the dummy data source, same as above
// routes/bicycle.js const express = require('express') const router = express.Router(); const model = require('../model') router.get('/:id', function(req, res, next) { model.bicycle.read(req.params.id, (err, result) => { if (err) { if (err.message === 'not found') next(); else next(err) } else { res.send(result) } }) }) module.exports = router; // app.js app.use('/bicycle', require('./routes/bicycle'));
Express based on the response content sets the content-type and the status code to 200
In case of data not existing it calls the next function which returns a 404 using the middleware
app.use(function(req, res, next) { next(createError(404) }) // catches all not found pages
In case of error other than 404, we are explicitly passing the error to the next function which sets the error details and status code based on the err or to 500
- Instead of JSON error, we use HTML.
- We print stack trace based on development environment.
app.use(function(err, req, res, next) { res.locals.message = err.message; res.locals.error = req.app.get('env') === 'development' ? err : {}; res.status(err.status || 500) res.render('error') })
We can override the default behaviour to respond back with JSON instead of HTML similar to fastify
app.use(function(err, req, res, next) { res.status(err.status || 500) res.send({ type: 'error', status: err.status, message: err.message, stack: req.app.get('env') === 'development' ? err.stack : undefined }); })
Manipulating Data with RESTful services
POST is not idempotent which means identical operations doesn’t lead to same result. Creates multiple entries.
PUT on the other hand, is idempotent. Updates the same entry multiple times.
POST and PUT can be used interchangeably but it’s not the expected behaviour.
POST create a new entity without providing an ID as it’s auto generated.
PUT needs an ID and data to update the existing data of the entry at that ID.
'use strict' const {promisify} = require('util'); const {bicycle} = require('../../model'); const {uid} = bicycle; const read = promisify(bicycle.read) const create = promisify(bicycle.create) const update = promisify(bicycle.update) module.exports = async (fastify, opts) => { const {notFound} = fastify.httpErrors; fastify.post('/', async (request, reply) => { const {data} = request.body; const id = uid(); await create(id, data); reply.code(201); return {id} }) fastify.post('/:id/update', async (request, reply) => { const {id} = request.params; const {data} = request.body; try { await update(id, data); reply.code(204); return {id} } catch(err) { if (err.message === 'not found') throw notFound() throw err; } }) fastify.get('/:id', async (request, reply) => { const {id} = request.params; try { return await read(id); } catch(err) { if (err.message === 'not found') throw notFound() throw err } }); }
We use the uid method to create a new id
We pass the id and data to create a new POST
On success for a POST request the response code is set to 201
Fastify by default supports
application/json
POST requests.fastify-multipart
plugin allows to support multipart/formdata request andfastify-formbody
plugins allows support forapplication/x-www-form-urlencoded
POST requests.Sending a post request to
/bicycle
should create a new post responding with the new ID.We can use the ID we received from the POST request and send a GET request to
/bicycle/{ID}
to validate the data has been set.Similarly we setup
/bicycle/:id/update
to update a post. It receives id and data to update the existing entry.The POST update responds with and 204 status (No Content). In case the id provided doesn’t exist, there is a 404 error thrown.
We can validate that the POST update request was successfully by send a GET request to the same id.
It is advised to use PUT instead of POST to update existing data.
fastify.put('/:id', async (request, reply) => { const {id} = request.params; const {data} = request.body; try { await create(id, data) reply.code(201) return {} } catch(err) { if (err.message === 'resource exists') { await update(id, data) reply.code(204) } elswe { throw err } })
We use PUT to create and update entity.
If the post creation fails with resource exists error, then the post is updated.
If the post is create the statusCode is 201, otherwise, in case of update, it’s 204.
We can validate by sending a GET request to check if the post was create or updated.
fastify.delete('/:id', async (request, reply) => { const {id} = request.params; try { await del(id) reply.code(204) } catch(err) { if (err.message ===== 'not found') throw notFound() throw err } })
We use the HTTP verb delete to delete a given ID.
If the ID exists, the del method deletes the post and returns a 204.
If there is no post an error (404 or 500) is thrown.
We can send a GET request and if there is a 404, then it means the post was deleted.
router.get('/:id', function (req, res, next) { model.bicycle.read(req.params.id, (err, result) => { if (err) { if (err.message === 'not found') next() else next(err) } else { res.send(result); } }) })
Based on the ID we get the node else we send back an 404 or 500 errors. The error depends on which middleware is called.
router.post('/', function(req, res, next) { const id = model.bicycle.uid(); model.bicycle.create(id, req.body.data, err => { if (err) next(err) else res.status(201).send({id}) }) })
We use post at
/bicycle
to create a new post, we first create a new id. If there is an err the middleware catches it and send back an 500 status, else the response is an id and 201 status code.router.post('/:id/update', function(req, res, next) { model.bicycle.update(req.params.id, req.body.data, err => { if (err) { if (err.message === 'not found') next() else next(err) } else { res.status(204).send() } }) })
We use POST to update a post, if the ID isn’t found we send a 404 or 500 based on the middleware which is called. If the post was updated then an empty response with 204 status is sent back.
router.put('/:id', function (req, res, next) { model.bicycle.create(req.params.id, req.body.data, err => { if (err) { if (err.message === 'resource exists') { model.bicycle.update(req.params.id, req.body.data, err => { if (err) next(err) else res.status(204).send(); }); } else { res.status(201).send({}); } }) })
we use PUT at
/:id
to first create a new post, if that file as the ID exists, then we update the entity and return 204 status. If the create is successful, we send back an empty response with 201 status.router.delete('/:id', function(req, res, next) { model.bicycle.del(req.params.id, err => { if (err) { if (err.message === 'not found') next(); else next(err) } else { res.status(204).send() } }) })
We use the delete HTTP verb to delete a post, if the post doesn’t exist we throw a 404 or 500. We return an empty response and 204 status in case the delete request was successful.
Instead of async functions, express uses callback functions
With express don’t send res after return or throw as it leads to memory leaks as express doesn’t handle promise rejections and it holds state in case the request doesn’t finish
In case wrong method is used like
res.dend()
it will throw an error. To address this we can monkey patch express or use try/catch in every route handle and then pass caught errors to next callback. So always use callback based API with ExpressIn case of callback api with fastify, always use reply to send response and not return values.
// lab 1.1 fastify.post('/', async(request, reply) => { const {data}= request.body; const id = model.boat.uid(); model.boat.create(id, data, (err) => { if (err) { reply.send(err); } reply.code(201) reply.send({id}) }); await reply })
// lab 1.1 router.post('/', (req, res, next) => { console.log('called') const {data} = req.body; const id = model.boat.uid(); model.boat.create(id, data, (err) => { if (err) { return next(err); } return res.status(201).send({id}) }) }
Express: Use
res.status(204).send({id})
to send data with non-200 status.Fastify: Use
reply.code(204).send()
to send empty response with 204 status.// lab 1.2 fastify.delete('/:id', async(request, reply) => { const {id} = request.params model.boat.del(id, (err) => { if (err) { if (err.message === 'not found') reply.notFound() else reply.send(err) } reply.code(204).end(); }) await reply })
// lab 1.2 router.delete('/:id', (req, res, next) => { const {id} = req.params; model.boat.del(id, (err) => { if (err) { if (err.message == 'not found') return next(); else return next(err); } res.status(204).end() }) })
Consuming and Aggregating services
service discovery involves: custom IP addresses, service meshes, DNS discovery, distributed hash tables, etc.
Provision to inject values into the process at deployment time allows to create flexibility and reconfiguration possibilities.
We could use environment variables to inject urls or ports to make services configurable.
// bicycle service 'use strict'; const http = require('http'); const url = require('url'); const colors = ['Yellow', 'Red', 'Orange', 'Green', 'Blue', 'Indigo'] const MISSING = 2 const server = http.createServer((req, res) => { const {pathname} = url.parse(req.url); let id = pathname.match(/^\/(\d+)$/); if (!id) { res.statusCode = 400; return void res.send(); } id = Number(id[1]) if (id === MISSING) { res.statusCode = 404; return void res.end(); } res.setHeader('Content-Type', 'application/json') res.send(JSON.stringify({ id, color: colors[id % colors.length] })); }) server.listen(process.env.PORT||0, () => { console.log('Bicycle service listening on localhost on port: ' + server.address().port); })
// brand service 'use strict'; const http = require('http'); const url = require('url'); const brands = ['Gazelle', 'Batavus', 'Azor', 'Cortina', 'Giant', 'Sparta'] const MISSING = 3 const server = http.createServer((req, res) => { const {pathname} = url.parse(req.url); let id = pathname.match(/^\/(\d+)$/); if (!id) { res.statusCode = 400; return void res.send(); } id = Number(id[1]) if (id === MISSING) { res.statusCode = 404; return void res.end(); } res.setHeader('Content-Type', 'application/json') res.send(JSON.stringify({ id, name: brands[id % brands.length] })); }) server.listen(process.env.PORT||0, () => { console.log('Brands service listening on localhost on port: ' + server.address().port); })
We use PORT 0 incase the environment variable isn’t set which means that it will use one of the free ports.
To inject port we could do
PORT=4000 node bicycle-server.js
The core http/https module aren’t ergonomically the best modules
got
is a good option for node http client that supports async/awaitnpm init fastify npm install npm install got // routes/root.js 'use strict' const got = require('got') const {BICYCLE_SERVICE_PORT = 4000} = process.env; const bicycleServiceURL = `http://localhost:{BICYCLE_SERVICE_PORT}`; module.exports = async function (fastify, opts) { fastify.get('/:id', async(request, reply) => { const {id} = request.params; const bicycle = await got(`${bicycleServiceURL}/${id}`).json(); return bicycle; }) }
We are setting the bicycle service url based on the environment variable for the service port. We use got to request data and return it back from the route handler. Since we are returning an object, fastify will set the
Content-Type
toapplication/json
.'use strict' const got = require('got') const {BICYCLE_SERVICE_PORT = 4000, BRAND_SERVICE_PORT = 5000} = process.env; const bicycleServiceURL = `http://localhost:{BICYCLE_SERVICE_PORT}`; const brandServiceURL = `http://localhost:{BRAND_SERVICE_PORT}`; module.exports = async function (fastify, opts) { fastify.get('/:id', async(request, reply) => { const {id} = request.params; const bicycle = await got(`${bicycleServiceURL}/${id}`).json(); const brand = await got(`${brandServiceURL}/${id}`).json(); return { id: bicycle.id, color: bicycle.color, brand: brand.name, } }) }
In this case we are fetching data from both the services using environment variables. And we are returning data with three keys: id, color, and brand.
We are returning an id different than the request.param as it helps prevent XSS vulnerabilities.
Instead of request data from the services one after another, we can call the concurrently using
Promise.all
const [bicycle, brand] = await Promise.all([ got(`${bicycleServiceURL}/${id}`).json(), got(`${brandServiceURL}/${id}`).json() ])
How we handle errors in case one or both services are down is highly dependent on the problem we are solving. We could send back errors or ignore data.
fastify-sensible
helps handling these problems.'use strict' const got = require('got') const {BICYCLE_SERVICE_PORT = 4000, BRAND_SERVICE_PORT = 5000} = process.env; const bicycleServiceURL = `http://localhost:{BICYCLE_SERVICE_PORT}`; const brandServiceURL = `http://localhost:{BRAND_SERVICE_PORT}`; module.exports = async function (fastify, opts) { fastify.get('/:id', async(request, reply) => { const {id} = request.params; try { const bicycle = await got(`${bicycleServiceURL}/${id}`).json(); const brand = await got(`${brandServiceURL}/${id}`).json(); return { id: bicycle.id, color: bicycle.color, brand: brand.name, } } catch(err) { if (!err.response) throw err if (err.response.statusCode === 404) { throw httpErrors.notFound() } throw err } }) }
In case the promise.all, if there is a non-200 status, the promise is rejected. If there is response we check the statusCode or we directly throw a 404
We can send bad request response in case the data spent for query is incorrect. For example, instead of a number, we send a string. These errors are from the consumer service
catch(err) { if (!err.response) throw err if (err.response.statusCode === 404) { throw httpErrors.notFound() } if (err.response.statusCode === 400) { throw httpErros.badRequest() } throw err }
// lab 7.1 'use strict' const got = require('got') const {BRAND_SERVICE_PORT, BOAT_SERVICE_PORT} = process.env; module.exports = async function (fastify, opts) { fastify.get('/:id', async function (request, reply) { let {id} = request.params id = Number(id); try { const boat = await got(`http://localhost:${BOAT_SERVICE_PORT}/${id}`, {timeout: 600, retry: 0}).json(); const brand = await got(`http://localhost:${BRAND_SERVICE_PORT}/${boat.brand}`, {timeout: 600, retry: 0}).json(); return { id, brand: brand.name, color: boat.color, } } catch(err) { if (err.response.statusCode === 404) { throw fastify.httpErrors.notFound(); } if (err.response.statusCode === 400) { throw fastify.httpErrors.badRequest(); } throw err } }) }
Proxying HTTP Request
- proxying is an alternative to fetching data from another service.
- With fastify we can use
fastify-reply-from
// register fastify.registry(require('fastify-reply-from')) module.export = async function (fastify, opts) { fastify.get('/', async function (request, reply) { const { url } = request.query try { new URL(url) } catch(err) { throw fastify.httpErrors.badRequest() } return reply.from(url) }) })
- We can stream the proxies response using async iterable and streams.
'use strict'; const {Readable} = require('stream') async function * upper(res) { for await (const chunk of res) { yield chunk.toString().toUpperCase() } } module.exports = async function (fastify, opts) { fastify.get('/', async function (request, reply) { const {url} = request.query; try { new URL(url) } catch (err) { throw fastify.httpErrors.badRequest() } return reply.from(url, { onResponse(request, reply, res) { reply.send(Readable.from(upper(res)) } }); }) }
- We pass a second argument to reply.from, an object which contains a onResponse method which takes the
res
From the proxy and using the upper method convert it into async iterable. We then convert the async iterable to Stream usingReadable.from
. - Instead of using something like querystring, we can map every path and http method. For this we could use
fastify-http-proxy
'use strict'; const proxy = require('fastify-http-proxy') module.exports = async function (fastify, opts) { fastify.register(proxy, { upstream: 'https://news.ycombinator.com' }); })
- We use upstream to set the base url to the proxy url. Request to
/newest
would map tohttps://news.ycombinator.com/newest
.
Web Security: Handling User Input
- Parameter pollution exploits a bug that’s often created when handling query parameters. It can lead to the service slowing down by generating exception. The application could crash because of unhandled exceptions. This is a form of DDOS attack. Understanding how query-string parsing happens will help with addressing is.
- Express supports square bracket denotation syntax
?name[]=bob
- querystring allows for
name=bassam&name=baheej
which results in{name: ['bassam, 'baheej'}
router.get('/', (req, res, next) => { someAsyncOp(() => { if (!req.query.name) { var err = new Error('Bad Request') err.status = 400 next(err) return } var parts = req.query.name.split(' '); var last = parts.pop(); var first = parts.shift(); res.send({first, last}) }) })
- In case multiple name query params are provided the route will break as the req.query.name would be an object and not a string.
- To address this, we should check if the query parms is an array and if so we should first convert it into a string.
if (Array.isArray(req.query.name)) { res.send(req.query.name.map(convert)) } else { res.send(convert(req.query.name)) }
- For fastify, route validation should be done using the schema option. JSONSchema is supported to validate incoming and outgoing data. At times, this can have negative effect on the performance, however, JSONSchema is a fast option.
fastify.post('/', async (request, reply) => { const {data} = request.body; const id = uid() await create(id, data) reply.code(201) return {id} })
- Here we have a post route that accepts data and stores it, then responds with 201, and id. The data should look like
{data: {brand, color}}
fastify.post('/', { schema: { body: { type: 'object', required: ['data'], additionalProperties: false, properties: { data: { type: 'object', required: ['brand', 'color'], additionalProperties: false, properties: { brand: {type: 'string'}, color: {type: 'string'} } } } } } }, async (request, reply) => { const {data} = request.body; const id = uid() await create(id, data) reply.code(201) return {id} })
- schema object details out every aspect of the request body. It supports body, query, headers, and response validation as well. Besides checking that body is present, we also check if all the inner properties are also included.additionalProperties allows to remove additional properties, which removes security risks.
- schema object fields include:
type:string, required: array, additionalProperties: boolean, properties: object
- In case wrong data is provided, a 400 response is sent.
const paramsSchema = { id: { type: 'integer' } } const bodySchema = { type: 'object', required: ['data'], additionalProperties: false, properties: { data: { type: 'object', required: ['brand', 'color'], additionalProperties: false, properties: { brand: {type: 'string'}, color: {type: 'string'} } } } } fastify.put('/:id', {schema: {body: bodySchema, params: paramsSchema}}, (request, reply) => { ... })
- We can validate the param for it being present and it being right type using the schema. In case there is no id, it would throw a 404, however, if validation fails, it would be a 400 (bad request)
const idSchema = { type: 'integer' } fastify.post('/', schema: { body: bodySchema, response: { 201: { id: idSchema } } }, async (reques, reply) => { ... })
- We can also validate the response using schema. Here we are checking the response to have 201 status and id of schemaId which expects it to be type number. If the schema validation for response fails, there is a 500 error.’
- There is a package that provides fluent api to generate JSON schema
fluent-schema
- To make the application secure we should also have an extensive test suite
- Express doesn’t provide any validator out of the box or as a part of core framework. There are a bunch of modules that support with this and
express-validator
is a good choice. Choice of validator can have a serious impact on performance.
function hasOwnProperty(o, p) { return Object.prototype.hasOwnProperty.call(o, p) }
- we use a different hasOwnProperty method because the object passed might have the property overridden.
function validateData (o) { var valid = o !== null && typeof o === 'object'; valid = valid && hasOwnProperty(o, 'brand'); valid = valid && hasOwnProperty(o, 'color'); valid = valid && typeof o.brand === 'string'; valid = valid && typeof o.color === 'string'; return valid && { brand: o.brand, color: o.color }; } function validateBody (o) { var valid = o !== null && typeof o === 'object'; valid = valid && hasOwnProperty(o, 'data'); valid = valid && o.data !== null && typeof o.data === 'object'; var data = valid && validateData(o.data); return valid && data && { data: data }; }
- Here we are using vanilla JS to validate all the input and output. It can be used to validate the data, params, queries, and response. However, it can lead to a lot of validation in each route.
- We return 400 in case the request fails validation, and 500 if the response fails validation.
// lab 9.1 router.get('/', (req, res) => { if (Array.isArray(req.query.un)) { res.statusCode = 400; res.send(req.query.un.map((i) => i.toUpperCase())); return; } setTimeout(() => { res.send((req.query.un || '').toUpperCase()); }, 1000); });
// lab 9.2 fastify.post( '/', { schema: { body: { type: 'object', required: ['data'], additionalProperties: false, properties: { data: { type: 'object', additionalProperties: false, required: ['brand', 'color'], properties: { brand: { type: 'string' }, color: { type: 'string' }, }, }, }, }, }, }, async (request, reply) => { const { data } = request.body; const id = uid(); await create(id, data); reply.code(201); return { id }; } );
Web security: Mitigating Attacks
- One common form of disruption is DDOS attack which involves automating a large amount of traffice on your service
- One way to handle this is by blocking the ip address. This should however be considered as the last resort
- In express we can get the IP address
req.socket.remoteAddress
. we can create a middleware to address this
app.use(function(req, res, next) { if (req.socket.remoteAddress === '127.0.0.1') { const err = new Error('Forbidden'); err.status = 403 next(err) return } next(); })
- if we get request from blacklisted ip we throw an error with a status of http we want to respond with. However, at times its a good idea to throw incorrect response so as to not leak complete details about the server.
'use strict' const fp = require('fastify-plugin') module.exports = fp(async function (fastify, opts) { fastify.addHook('onRequest', async function (request) { if (request.ip === '127.0.0.1') { const err = new Error('Forbidden') err.status = 403; throw err; // throw fastify.httpErrors.forbidden() using fastify-sensible } }) })
- With fastify we create a new plugin that uses the onRequest hook to check the blacklisted ip. If found we respond with 403 error.
- Here we use the fastify-plugin wich makes the hook apply to the whole service.
// lab 10.1 app.use((req, res, next) => { console.log(req.socket.remoteAddress); if (req.socket.remoteAddress === '111.34.55.211') { const err = new Error('Forbidden'); err.status = 403; next(err); } next(); });
// lab 10.2 const fp = require('fastify-plugin'); module.exports = fp(async function (fastify, opts) { fastify.addHook('onRequest', async (request) => { if (request.ip === '211.133.33.113') { const err = new Error('Forbidden'); err.status = 403; throw err; } }); });