In this section, you’ll learn how to create modular APIs using an Express Router. Organizing your APIs into Routers is a code-health technique that helps you reason about your code and facilitates refactoring and maintenance.
You can think of a Router like an Express subapplication. It has its own middleware stack and can contain routes.
With an Express Application, app, you can call app.use to delegate to a Router. Routers themselves can use other Routers in addition to having their own middleware and routes. This flexibility allows you to combine middleware and routes in a modular, maintainable way.
The APIs you’ll develop in this section will be similar to those you worked on in Chapter 7, Developing RESTful Web Services, but enhanced to only allow authenticated users to access protected endpoints. Let’s begin by setting up a module to house the book bundle Router.
The first thing to do is put together the basic outline of a module that returns an Express Router. This is the cornerstone of modular Express development.
To start, create a lib directory in your B4 project. Then open your favorite text editor and enter this to start the bundle.js file:
| | /** |
| | * Provides API endpoints for working with book bundles. |
| | */ |
| | 'use strict'; |
| | const express = require('express'); |
| | const rp = require('request-promise'); |
| | |
| | module.exports = es => { |
| | const url = `http://${es.host}:${es.port}/${es.bundles_index}/bundle`; |
| | |
| | const router = express.Router(); |
| | |
| | return router; |
| | }; |
This code sets up module.exports as a function that takes a configuration object for Elasticsearch and returns an Express Router instance.
After you save this file, open your server.js. At the bottom, right before the app.listen line, add this:
| | app.use('/api', require('./lib/bundle.js')(nconf.get('es'))); |
Here, we’re calling app.use and providing it two parameters. The first is the string /api and the second is the configured Router instance returned by the bundle.js module.
When given a path and a Router, app.use will delegate to the Router routes under that path. For example, in a minute we’ll add a /list-bundles route to the Router, which will have the application-level path /api/list-bundles.
Save your server.js file. nodemon should restart happily. Now let’s add authentication middleware to the Router.
Since a Router is basically an Express mini application, it can have its own middleware that applies to all of its routes. Recall that middleware is a great way to run code on every route. For the book-bundle routes, we want all of them to be accessible only to authenticated users.
To do this, start by opening your bundle.js file. Insert this block before the return router line:
| | /** |
| | * All of these APIs require the user to have authenticated. |
| | */ |
| | router.use((req, res, next) => { |
| | if (!req.isAuthenticated()) { |
| | res.status(403).json({ |
| | error: 'You must sign in to use this service.', |
| | }); |
| | return; |
| | } |
| | next(); |
| | }); |
This block introduces a custom middleware function to the Router. Remember that Passport adds an isAuthenticated method to the req object. We check that here, and if the user has not authenticated, we send her away with an HTTP 403 Forbidden status code. Now, any routes we add to the Router will be guarded against unauthenticated access.
Next let’s add a route to list the user’s bundles.
In the last chapter, when we needed a list of bundles we proxied a direct connection to Elasticsearch through the webpack-dev-server and queried for all bundles. Instead, now our Express server will reach out to Elasticsearch and bring back only those bundles that belong to the authenticated user.
Passport adds a user object to the Express Request object req, which we can use to look up book bundles belonging to that user. Let’s add a utility function to make it easy to get a user key based on the req.user object. Scroll to the top of your bundle.js file, then add the following right after the require lines:
| | const getUserKey = ({user:{provider, id}}) => `${provider}-${id}`; |
This terse arrow function uses nested destructuring assignment to pull out the user.provider and user.id properties from the Express Request instance passed in. The return value is a simple concatenation of these, separated by a hyphen. This will produce keys like facebook-1234512345.
Now that you have the getUserKey utility method defined, let’s add a route to list the user’s bundles.
With your bundle.js still open for editing, navigate to the bottom. Just before the return router line at the bottom of the file, insert this:
| | /** |
| | * List bundles for the currently authenticated user. |
| | */ |
| | router.get('/list-bundles', async (req, res) => { |
| | try { |
| | const esReqBody = { |
| | size: 1000, |
| | query: { |
| | match: { |
| | userKey: getUserKey(req), |
| | } |
| | }, |
| | }; |
| | |
| | const options = { |
| | url: `${url}/_search`, |
| | json: true, |
| | body: esReqBody, |
| | }; |
| | |
| | const esResBody = await rp(options); |
| | const bundles = esResBody.hits.hits.map(hit => ({ |
| | id: hit._id, |
| | name: hit._source.name, |
| | })); |
| | res.status(200).json(bundles); |
| | } catch (err) { |
| | res.status(err.statusCode || 502).json(err.error || err); |
| | } |
| | }); |
This code should look familiar to you based on its similarity to the API endpoints you developed during previous chapters. We use router.get to set up a handler for the /list-bundles route, which will be registered in server.js under /api.
Notice that the esReqBody performs an Elasticsearch query for documents (book bundles) whose userKey matches the user who initiated the incoming request. Next, let’s add an API for creating a new book bundle.
Use router.post to set up a handler for the route /bundle like so:
| | /** |
| | * Create a new bundle with the specified name. |
| | */ |
| | router.post('/bundle', async (req, res) => { |
| | try { |
| | const bundle = { |
| | name: req.query.name || '', |
| | userKey: getUserKey(req), |
| | books: [], |
| | }; |
| | |
| | const esResBody = await rp.post({url, body: bundle, json: true}); |
| | res.status(201).json(esResBody); |
| | } catch (err) { |
| | res.status(err.statusCode || 502).json(err.error || err); |
| | } |
| | }); |
Make sure to include the userKey field! This is what allows /list-bundles to find it later.
Next, after that route, append this code to get a bundle by its ID:
| | /** |
| | * Retrieve a given bundle. |
| | */ |
| | router.get('/bundle/:id', async (req, res) => { |
| | try { |
| | const options = { |
| | url: `${url}/${req.params.id}`, |
| | json: true, |
| | }; |
| | |
| | const {_source: bundle} = await rp(options); |
| | |
| » | if (bundle.userKey !== getUserKey(req)) { |
| | throw { |
| | statusCode: 403, |
| | error: 'You are not authorized to view this bundle.', |
| | }; |
| | } |
| | |
| | res.status(200).json({id: req.params.id, bundle}); |
| | } catch (err) { |
| | res.status(err.statusCode || 502).json(err.error || err); |
| | } |
| | }); |
The important part of this code is the part that checks whether the requested book bundle’s userKey matches the currently authenticated user. If not, we throw an error that’ll be picked up by the catch block below. The HTTP status code 403 Forbidden informs the caller she doesn’t have access to the bundle.
Depending on the sensitivity of the material, another status code like 404 Not Found may make more sense in your own applications. Whether a book bundle with a particular ID exists is not a big secret, especially because the IDs are unintelligible random strings generated by Elasticsearch. But be vigilant in your own applications so you don’t accidentally leak information by returning a 403 Forbidden in cases where knowledge of the existence of the document is in itself valuable.
Save your bundle.js file. With these routes in place, we have enough to build the UI on.