Chapter 6. Writing a REST API: Exposing the MongoDB database to the application

published book

This chapter covers

  • Examining the rules of REST APIs
  • Evaluating API patterns
  • Handling typical CRUD functions (create, read, update, delete)
  • Using Express and Mongoose to interact with MongoDB
  • Testing API endpoints

As you come into this chapter, you have a MongoDB database set up, but you can interact with it only through the MongoDB shell. During the course of this chapter, you’ll build a REST API so that you can interact with your database through HTTP calls and perform the common CRUD functions: create, read, update, and delete.

You’ll work mainly with Node and Express, using Mongoose to help with interactions. Figure 6.1 shows where this chapter fits into the overall architecture.

Figure 6.1. This chapter focuses on building the API that interacts with the database, exposing an interface for the applications to talk to.

You’ll start by looking at the rules of a REST API. We’ll discuss the importance of defining the URL structure properly, the different request methods (GET, POST, PUT, and DELETE) that should be used for different actions, and how an API should respond with data and an appropriate HTTP status code. When you have that knowledge under your belt, you’ll move on to building your API for Loc8r, covering all the typical CRUD operations. We’ll discuss Mongoose along the way and get into some Node programming and more Express routing.

Note

If you haven’t yet built the application from chapter 5, you can get the code from GitHub on the chapter-05 branch at https://github.com/cliveharber/gettingMean-2. In a fresh folder in terminal, enter the following commands to clone it and install the npm module dependencies:

$ git clone -b chapter-05 https://github.com/cliveharber/
  gettingMean-2.git
$ cd gettingMean-2
$ npm install
join today to enjoy all our content. all the time.
 

6.1. The rules of a REST API

We’ll start with a recap of what makes a REST API. From chapter 2, you may remember that

  • REST stands for REpresentational State Transfer, which is an architectural style rather than a strict protocol. REST is stateless; it has no idea of any current user state or history.
  • API is an abbreviation for application program interface, which enables applications to talk to one another.

A REST API is a stateless interface to your application. In the case of the MEAN stack, the REST API is used to create a stateless interface to your database, enabling a way for other applications to work with the data.

REST APIs have an associated set of standards. Although you don’t have to stick to these standards for your own API, it’s generally best to, as it means that any API you create will follow the same approach. It also means that you’re used to doing things the “right” way if you decide that you’re going to make your API public.

In basic terms, a REST API takes an incoming HTTP request, does some processing, and always sends back an HTTP response, as shown in figure 6.2.

Figure 6.2. A REST API takes incoming HTTP requests, does some processing, and returns HTTP responses.

The standards that you’ll follow for Loc8r revolve around the requests and the responses.

6.1.1. Request URLs

Request URLs for a REST API have a simple standard. Following this standard makes your API easy to pick up, use, and maintain.

The way to approach this task is to start thinking about the collections in your database, as you’ll typically have a set of API URLs for each collection. You may also have a set of URLs for each set of subdocuments. Each URL in a set has the same basic path, and some may have additional parameters.

Within a set of URLs, you need to cover several actions, generally based on the standard CRUD operations. The common actions you’ll likely want are

  • Create a new item
  • Read a list of several items
  • Read a specific item
  • Update a specific item
  • Delete a specific item

Using Loc8r as an example, the database has a Locations collection that you want to interact with. Table 6.1 shows how the URL paths might look for this collection. Note that all URLs have the same base path and, where used, have the same location ID parameter.

Table 6.1. URL paths and parameters for an API to the Locations collection

Action

URL path

Example

Create new location /locations http://loc8r.com/api/locations
Read list of locations /locations http://loc8r.com/api/locations
Read a specific location /locations/:locationid http://loc8r.com/api/locations/123
Update a specific location /locations/:locationid http://loc8r.com/api/locations/123
Delete a specific location /locations/:locationid http://loc8r.com/api/locations/123

As you can see from table 6.1, each action has the same URL path, and three of them expect the same parameter to specify a location. This situation poses an obvious question: how do you use the same URL to initiate different actions? The answer lies in request methods.

6.1.2. Request methods

HTTP requests can have different methods that essentially tell the server what type of action to take. The most common type of request is a GET request—the method used when you enter a URL in the address bar of your browser. Another common method is POST, often used for submitting form data.

Table 6.2 shows the methods you’ll be using in your API, their typical use cases, and what you’d expect to be returned.

Table 6.2. Four request methods used in a REST API

Request method

Use

Response

POST Create new data in the database New data object as seen in the database
GET Read data from the database Data object answering the request
PUT Update a document in the database Updated data object as seen in the database
DELETE Delete an object from the database Null

The four HTTP methods that you’ll use are POST, GET, PUT, and DELETE. If you look at the corresponding entries in the Use column, you’ll notice that each method performs a different CRUD operation.

The method is important, because a well-designed REST API often has the same URL for different actions. In these cases, the method tells the server which type of operation to perform. We’ll discuss how to build and organize the routes for methods in Express later in this chapter.

If you take the paths and parameters and map across the appropriate request method, you can put together a plan for your API, as shown in table 6.3.

Table 6.3. Request methods that link URLs to the desired actions, enabling the API to use the same URL for different actions

Action

Method

URL path

Example

Create new location POST /locations http://loc8r.com/api/locations
Read list of locations GET /locations http://loc8r.com/api/locations
Read a specific location GET /locations/:locationid http://loc8r.com/api/locations/123
Update a specific location PUT /locations/:locationid http://loc8r.com/api/locations/123
Delete a specific location DELETE /locations/:locationid http://loc8r.com/api/locations/123

Table 6.3 shows the paths and methods you’ll use for the requests to interact with the location data. There are five actions but only two URL patterns, so you can use the request methods to get the desired results.

Loc8r only has one collection right now, so this is your starting point. But the documents in the Locations collection do have reviews as subdocuments, so you’ll quickly map them out too.

Subdocuments are treated in a similar way but require an additional parameter. Each request needs to specify the ID of the location, and some requests also need to specify the ID of a review. Table 6.4 shows the list of actions and their associated methods, URL paths, and parameters.

Table 6.4. URL specifications for interacting with subdocuments; each base URL path must contain the ID of the parent document

Action

Method

URL path

Example

Create new review POST /locations/:locationid/reviews http://loc8r.com/api/locations/123/reviews
Read a specific review GET /locations/:locationid/reviews/:reviewid http://loc8r.com/api/locations/123/reviews/abc
Update a specific review PUT /locations/:locationid/reviews/:reviewid http://loc8r.com/api/locations/123/reviews/abc
Delete a specific review DELETE /locations/:locationid/reviews/:reviewid http://loc8r.com/api/locations/123/reviews/abc

You may have noticed that for the subdocuments, you don’t have a “read a list of reviews” action, because you’ll be retrieving the list of reviews as part of the main document. The preceding tables should give you an idea of how to create basic API request specifications. The URLs, parameters, and actions will be different from one application to the next, but the approach should remain consistent.

That’s the story on requests. The other half of the flow, before you get stuck in some code, is responses.

6.1.3. Responses and status codes

A good API is like a good friend. If you go for a high five, a good friend won’t leave you hanging. The same goes for a good API. If you make a request, a good API always responds and doesn’t leave you hanging. Every single API request should return a response. The contrast between a good API and a bad one is shown in figure 6.3.

Figure 6.3. A good API always returns a response and shouldn’t leave you hanging.

For a successful REST API, standardizing the responses is as important as standardizing the request format. There are two key components to a response:

  • The returned data
  • The HTTP status code

Combining the returned data with the appropriate status code should give the requester all the information required to continue.

Returning data from an API

Your API should return a consistent data format. Typical formats for a REST API are XML and/or JSON. You’ll use JSON for your API, because it’s the natural fit for the MEAN stack. MongoDB outputs JSON, which Node and Angular can both natively understand. JSON is, after all, the JavaScript way of transporting data. JSON is also more compact than XML, so it can help speed the response times and efficiency of an API by reducing the bandwidth required.

Your API will return one of three things for each request:

  • A JSON object containing data answering the request query
  • A JSON object containing error data
  • A null response

During this chapter, we’ll discuss how to do all these things as you build the Loc8r API. As well as responding with data, a REST API should return the correct HTTP status code.

Using HTTP status codes

A good REST API should return the correct HTTP status code. The status code most people are familiar with is 404, which is what a web server returns when a user requests a page that can’t be found. This error code is probably the most prevalent one on the internet, but there are dozens of other codes, relating to client errors, server errors, redirections, and successful requests. Table 6.5 shows the 10 most popular HTTP status codes and where they might be useful for building an API.

Table 6.5. Most popular HTTP status codes and how they might be used to send responses to an API request

Status code

Name

Use case

200 OK A successful GET or PUT request
201 Created A successful POST request
204 No content A successful DELETE request
400 Bad request An unsuccessful GET, POST, or PUT request due to invalid content
401 Unauthorized Requesting a restricted URL with incorrect credentials
403 Forbidden Making a request that isn’t allowed
404 Not found Unsuccessful request due to an incorrect parameter in the URL
405 Method not allowed Request method not allowed for the given URL
409 Conflict Unsuccessful POST request when another object with the same data already exists
500 Internal server error Problem with your server or the database server

As you go through this chapter and build the Loc8r API, you’ll use several of these status codes while returning the appropriate data.

Get Getting MEAN with Mongo, Express, Angular, and Node.js 2ED
add to cart

6.2. Setting up the API in Express

You’ve already got a good idea about the actions you want your API to perform and the URL paths needed to do so. As you know from chapter 4, to get Express to do something based on an incoming URL request, you need to set up controllers and routes. The controllers do the action, and the routes map the incoming requests to the appropriate controllers.

You have files for routes and controllers already set up in the application, so you could use those. A better option, though, is to keep the API code separate so that you don’t run the risk of confusion and complication in your application. In fact, this is one of the reasons for creating an API in the first place. Also, keeping the API code separate makes it easier to strip it out and put it into a separate application at a future point, should you choose to do so. You do want easy decoupling here.

The first thing you want to do is create a separate area inside the application for the files that will create the API. At the top level of the application, create a new folder called app_api. If you’ve been following along and building up the application as you go, this folder sits alongside the app_server folder.

This folder holds everything specific to the API: routes, controllers, and models. When you’ve got everything set up, take a look at some ways to test these API placeholders.

6.2.1. Creating the routes

As you did with the routes for the main Express application, you’ll have an index.js file in the app_api/routes folder that will hold all the routes you’ll use in the API. Start by referencing this file in the main application file app.js.

Including the routes in the application

The first step is telling your application that you’re adding more routes to look out for and when it should use them. You can duplicate a line in app.js to require the server application routes, and set the path to the API routes as follows:

const indexRouter = require('./app_server/routes/index');
const apiRouter = require('./app_api/routes/index');

You may also have a line in app.js that still brings the example user routes. You can delete this now, if so, because you don’t need it. Next, you need to tell the application when to use the routes. You currently have the following line in app.js telling the application to check the server application routes for all incoming requests:

app.use('/', indexRouter);

Notice the '/' as the first parameter. This parameter enables you to specify a subset of URLs for which the routes will apply. You’ll define all your API routes starting with /api/. By adding the line shown in the following code snippet, you can tell the application to use the API routes only when the route starts with /api:

app.use('/', indexRouter);
app.use('/api', apiRouter);

As before, you can delete the similar line for user routes if it’s there. Now it’s time to set up these URLs.

Specifying the request methods in the routes

Up to now, you’ve used only the GET method in the routes, as in the following code snippet from your main application routes:

router. get ('/location', ctrlLocations.locationInfo);


!@%STYLE%@!
{"css":"{\"css\": \"font-weight: bold;\"}","target":"[[{\"line\":0,\"ch\":8},{\"line\":0,\"ch\":11}]]"}
!@%STYLE%@!

Using the other methods—POST, PUT, and DELETE—is as simple as switching the get with the respective keywords post, put, and delete. The following code snippet shows an example using the POST method which creates a new location:

router.post('/locations', ctrlLocations.locationsCreate);

Note that you don’t specify /api at the front of the path. You specify in app.js that these routes should be used only if the path starts with /api, so it’s assumed that all routes specified in this file are prefixed with /api.

Specifying Required URL parameters

It’s common for API URLs to contain parameters for identifying specific documents or subdocuments—locations and reviews, in the case of Loc8r. Specifying these parameters in routes is simple; you prefix the name of the parameter with a colon when defining each route.

Suppose that you’re trying to access a review with the ID abc that belongs to a location with the ID 123. You’d have a URL path like this:

/api/locations/:locationid/reviews/:reviewid

Swapping out the IDs for the parameter names (with a colon prefix) gives you a path like this:

/api/locations/:locationid/reviews/:reviewid

With a path like this, Express matches only URLs that match that pattern. So a location ID must be specified and must be in the URL between locations/ and /reviews. Also, a review ID must be specified at the end of the URL. When a path like this is assigned to a controller, the parameters will be available to use in the code, with the names specified in the path (locationid and reviewid, in this case).

We’ll review exactly how you get to them in a moment, but first, you need to set up the routes for your Loc8r API.

Defining the Loc8r API routes

Now you know how to set up routes to accept parameters, and you also know what actions, methods, and paths you want to have in your API. You can combine all this knowledge to create the route definitions for the Loc8r API.

If you haven’t done so yet, you should create an index.js file in the app_api/routes folder. To keep the sizes of individual files under control, separate the locations and reviews controllers into different files.

You’ll also use a slightly different way of defining routes in Express, which is ideal for managing multiple methods on a single route. With this approach, you define the route first and then chain on the different HTTP methods. This process streamlines route definitions, making them much easier to read.

The following listing shows how the defined routes should look.

Listing 6.1. Routes defined in app_api/routes/index.js
const express = require('express');
const router = express.Router();
const ctrlLocations = require('../controllers/locations');    #1
const ctrlReviews = require('../controllers/reviews');        #1

// locations
router                                                        #2
  .route('/locations')                                        #2
  .get(ctrlLocations.locationsListByDistance)                 #2
  .post(ctrlLocations.locationsCreate);                       #2
router                                                        #2
  .route('/locations/:locationid')                            #2
  .get(ctrlLocations.locationsReadOne)                        #2
  .put(ctrlLocations.locationsUpdateOne)                      #2
  .delete(ctrlLocations.locationsDeleteOne);                  #2

// reviews
router                                                        #3
  .route('/locations/:locationid/reviews')                    #3
  .post(ctrlReviews.reviewsCreate);                           #3
router                                                        #3
  .route('/locations/:locationid/reviews/:reviewid')          #3
  .get(ctrlReviews.reviewsReadOne)                            #3
  .put(ctrlReviews.reviewsUpdateOne)                          #3
  .delete(ctrlReviews.reviewsDeleteOne);                      #3

module.exports = router;                                      #4

In this router file, you need to require the related controller files. You haven’t created these controller files yet and will do so in a moment. This method is a good way to approach it, because by defining all the routes and declaring the associated controller functions here, you develop a high-level view of what controllers are needed.

The application now has two sets of routes: the main Express application routes and the new API routes. The application won’t start at the moment, though, because none of the controllers referenced by the API routes exists.

6.2.2. Creating the controller placeholders

To enable the application to start, you can create placeholder functions for the controllers. These functions won’t do anything, but they stop the application from falling over while you’re building the API functionality.

The first step, of course, is creating the controller files. You know where these files should be and what they should be called because you’ve already declared them in the app_api/routes folder. You need two new files called locations.js and reviews.js in the app_api/controllers folder.

You can create a placeholder for each of the controller functions as an empty function, as in the following code snippet:

const locationsCreate = (req, res) => { };

Remember to put each controller in the correct file, depending on whether it’s for a location or a review, and export them at the bottom of the files, as in this example:

module.exports = {
  locationsListByDistance,
  locationsCreate,
  locationsReadOne,
  locationsUpdateOne,
  locationsDeleteOne
};

To test the routing and the functions, though, you need to return a response.

6.2.3. Returning JSON from an Express request

When building the Express application, you rendered a view template to send HTML to the browser, but with an API, you instead want to send a status code and some JSON data. Express makes this task easy with the following lines:

res                       #1
  .status(status)         #2
  .json(content);         #3

You can use these two commands in the placeholder functions to test the success, as shown in the following code snippet:

const locationsCreate = (req, res) => {
  res
    .status(200)
    .json({"status" : "success"});
};

As you build up your API, you’ll use this method a lot to send different status codes and data as the response.

6.2.4. Including the model

It’s vitally important that the API can talk to the database; without it, the API isn’t going to be of much use! To do this with Mongoose, you first need to require Mongoose into the controller files and then bring in the Location model. Right at the top of the controller files, above all the placeholder functions, add the following two lines:

const mongoose = require('mongoose');
const Loc = mongoose.model('Location');

The first line gives the controllers access to the database connection, and the second brings in the Location model so that you can interact with the Locations collection.

If you take a look at the file structure of your application, you see the /models folder containing the database connection, and the Mongoose setup is inside the app_server folder. But it’s the API that’s dealing with the database, not the main Express application. If the two applications were separate, the model would be kept part of the API, so that’s where it should live.

Move the /models folder from the app_server folder into the app_api folder, creating a folder structure like that shown in figure 6.4.

Figure 6.4. Folder structure of the application at this point. app_api has models, controllers, and routes, and app_server has views, controllers, and routes.

You need to tell the application that you’ve moved the app_api/models folder, of course, so you need to update the line in app.js that requires the model to point to the correct place:

require('./app_api/models/db');


!@%STYLE%@!
{"css":"{\"css\": \"font-weight: bold;\"}","target":"[[{\"line\":0,\"ch\":11},{\"line\":0,\"ch\":18}]]"}
!@%STYLE%@!

With that done, the application should start again and still connect to your database. The next question is how to test the API.

6.2.5. Testing the API

You can test the GET routes in your browser quickly by heading to the appropriate URL, such as http://localhost:3000/api/locations/1234. You should see the success response being delivered to the browser, as shown in figure 6.5.

Figure 6.5. Testing a GET request of the API in the browser

This is okay for testing GET requests, but it doesn’t get you far with the POST, PUT, and DELETE methods. A few tools can help you test API calls like this, but our current favorite is a free application called Postman REST Client, available as a standalone application or browser extension.

Postman enables you to test API URLs with several request methods, allowing you to specify additional query string parameters or form data. After you click the Send button, Postman makes a request to the URL you specified and displays the response data and status code.

Figure 6.6 shows a screenshot of Postman making a PUT request to the same URL as before.

Figure 6.6. Using the Postman REST Client to test a PUT request to the API

It’s a good idea to get Postman or another REST client up and running now. You’ll need to use one a lot during this chapter as you build up a REST API. In the next section, you’ll start on the workings of the API by using GET requests to read data from MongoDB.

Sign in for more free preview time

6.3. GET methods: Reading data from MongoDB

GET methods are all about querying the database and returning some data. In your routes for Loc8r, you have three GET requests doing different things, as listed in table 6.6.

Table 6.6. Three GET requests of the Loc8r API

Action

Method

URL path

Example

Read a list of locations GET /locations http://loc8r.com/api/locations
Read a specific location GET /locations/:locationid http://loc8r.com/api/locations/123
Read a specific review GET /locations/:locationid/reviews/:reviewid http://loc8r.com/api/locations/123/reviews/abc

You’ll look at how to find a single location first, because it provides a good introduction to the way Mongoose works. Next, you’ll locate a single document by using an ID, and then you’ll expand into searching for multiple documents.

6.3.1. Finding a single document in MongoDB using Mongoose

Mongoose interacts with the database through its models, which is why you imported the Location model as Loc at the top of the controller files. A Mongoose model has several associated methods to help manage the interactions, as noted in the sidebar “Mongoose query methods.”

Mongoose query methods

Mongoose models have several methods available to help with querying the database. Here are some of the key ones:

  • find—General search based on a supplied query object
  • findById—Looks for a specific ID
  • findOne—Gets the first document to match the supplied query
  • geoNear—Finds places geographically close to the provided latitude and longitude
  • geoSearch—Adds query functionality to a geoNear operation

You’ll use some but not all of these methods in this book.

For finding a single database document with a known ID in MongoDB, Mongoose has the findById() method.

Applying the findById method to the model

The findById() method is relatively straightforward, accepting a single parameter: the ID to look for. As it’s a model method, it’s applied to the model like this:

Loc.findById(locationid)

This method won’t start the database query operation; it tells the model what the query will be. To start the database query, Mongoose models have an exec method.

Running the query with the exec method

The exec method executes the query and passes a callback function that will run when the operation is complete. The callback function should accept two parameters: an error object and the instance of the found document. As it’s a callback function, the names of these parameters can be whatever you like.

The methods can be chained as follows:

Loc
  .findById(locationid)                    #1
  .exec((err, location) => {               #2
    console.log("findById complete");      #3
  });

This approach ensures that the database interaction is asynchronous and, therefore, doesn’t block the main Node process.

Using the findById method in a controller

The controller you’re working with to find a single location by ID is locationsReadOne(), in the locations.js file in app_api/controllers.

You know the basic construct of the operation: apply the findById() and exec methods to the Location model. To get this working in the context of the controller, you need to do two things:

  • Get the locationid parameter from the URL, and pass it to the findById() method.
  • Provide an output function to the exec method.

Express makes it easy to get the URL parameters you defined in the routes. The parameters are held inside a params object attached to the request object. With your route being defined like so

router
  .route('/api/locations/:locationid')

you can access the locationid parameter from inside the controller like this:

req.params.locationid

For the output function, you can use a simple callback that sends the found locations as a JSON response. Putting all this together gives you the following:

const locationsReadOne = (req, res) => {
  Loc
    .findById(req.params.locationid)          #1
    .exec((err, location) => {                #2
      res                                     #3
        .status(200)                          #3
        .json(location);                      #3
    });
};

Now you have a basic API controller. You can try it out by getting the ID of one of the locations in MongoDB and going to the URL in your browser or by calling it in Postman. To get one of the ID values, you can run the command db.locations.find () in the Mongo shell, and the command lists all the locations you have, each of which includes the _id value. When you’ve put the URL together, the output should be a full location object as stored in MongoDB; you should see something like figure 6.7.

Figure 6.7. A basic controller for finding a single location by ID returns a JSON object to the browser if the ID is found.

Did you try out the basic controller? Did you put an invalid location ID in the URL? If you did, you’ll have seen that you got nothing back—no warning, no message; a 200 status telling you that everything is okay, but no data returned.

Catching errors

The problem with that basic controller is that it outputs only a success response, regardless of whether it was successful. This behavior isn’t good for an API. A good API should respond with an error code when something goes wrong.

To respond with error messages, the controller needs to be set up to trap potential errors and send an appropriate response. Error trapping in this fashion typically involves if statements. Every if statement must have a corresponding else statement or include a return statement.

Tip

Your API code must never leave a request unanswered.

With your basic controller, you need to trap three errors:

  • The request parameters don’t include locationid.
  • The findById() method doesn’t return a location.
  • The findById() method returns an error.

The status code for an unsuccessful GET request is 404. Bearing this fact in mind, the final code for the controller to find and return a single location looks like the following listing.

Listing 6.2. locationsReadOne controller
const locationsReadOne = (req, res) => {
    Loc
      .findById(req.params.locationid)
      .exec((err, location) => {
        if (!location) {                           #1
          return res                               #1
            .status(404)                           #1
            .json({                                #1
              "message": "location not found"      #1
            });                                    #1
        } else if (err) {                          #2
          return res                               #2
            .status(404)                           #2
            .json(err);                            #2
        }
        res                                        #3
          .status(200)                             #3
          .json(location);                         #3
       });
};

Listing 6.2 uses both methods of trapping with if statements. Error trap 1 1 and error trap 2 2 use an if to check for an error returned by Mongoose. Each if includes a return statement, which prevents any following code in the callback scope from running. If no error was found, the return statement is ignored, and the code moves on to send the successful response 3.

Each of these traps provides a response for success and failure, leaving no room for the API to leave a requester hanging. If you want to, you can also throw in a few console.log() statements so that it’s easier to track what’s going on in terminal; the source code in GitHub has some.

Figure 6.8 shows the difference between a successful request and a failed request, using the Postman extension in Chrome.

Figure 6.8. Testing successful (left) and failed (right) API responses using Postman

That’s one complete API route dealt with. Now it’s time to look at the second GET request to return a single review.

6.3.2. Finding a single subdocument based on IDs

To find a subdocument, you first have to find the parent document, and then pinpoint the required location using its ID. When you’ve found the document, you can look for a specific subdocument. You can take the locationsReadOne() controller as the starting point, and add a few modifications to create the reviewsReadOne() controller. These modifications are

  • Accept and use an additional reviewid URL parameter.
  • Select only the name and reviews from the document rather than have MongoDB return the entire document.
  • Look for a review with a matching ID.
  • Return the appropriate JSON response.

To do these things, you can use a couple of new Mongoose methods.

Limiting the paths returned from MongoDB

When you retrieve a document from MongoDB, you don’t always need the full document; sometimes, you want some specific data. Limiting the data being passed around is also better for bandwidth consumption and speed.

Mongoose does this through a select() method chained to the model query. The following code snippet tells MongoDB that you want to get only the name and the reviews of a location:

Loc
  .findById(req.params.locationid)
  .select('name reviews')
  .exec();

The select() method accepts a space-separated string of the paths you want to retrieve.

Using Mongoose to find a specific subdocument

Mongoose also offers a helper method for finding a subdocument by ID. Given an array of subdocuments, Mongoose has an id method that accepts the ID you want to find. The id method returns the single matching subdocument, and it can be used as follows:

Loc
  .findById(req.params.locationid)
  .select('name reviews')
  .exec((err, location) => {
    const review = location.reviews.id(req.params.reviewid);      #1
  }
);

In this code snippet, a single review is returned to the review variable in the callback.

Adding some error trapping and putting it all together

Now you’ve got the ingredients needed to make the reviewsReadOne() controller. Starting with a copy of the locationsReadOne() controller, you can make the modifications required to return a single review.

The following listing shows the reviewsReadOne() controller in review.js (modifications in bold).

Listing 6.3. Controller for finding a single review
 const reviewsReadOne = (req, res) => {
    Loc
      .findById(req.params.locationid)
      .select('name reviews')                                                        #1

      .exec((err, location) => {
        if (!location) {
          return res
            .status(404)
            .json({
              "message": "location not found"
            });
        } else if (err) {
          return res
            .status(400)
            .json(err);
        }
        if (location.reviews && location.reviews.length > 0) {                          #2

          const review = location.reviews.id(req.params.reviewid);                      #3
          if (!review) {                                                                #4
            return res                                                                  #4
              .status(400)                                                              #4
              .json({                                                                   #4
                "message": "review not found"                                           #4
            });                                                                         #4
          } else {                                                                      #5
            response = {                                                                #5
              location : {                                                              #5
                name : location.name,                                                   #5
                id : req.params.locationid                                              #5
              },                                                                        #5
              review                                                                    #5
            };                                                                          #5
            return res                                                                  #5
              .status(200)                                                              #5
              .json(response);                                                          #5
          }                                                                             #5
        } else {                                                                        #6
          return res                                                                    #6
            .status(404)                                                                #6
            .json({                                                                     #6
              "message": "No reviews found"                                             #6
          });                                                                           #6
        }                                                                               #6
      }
    );
};

When this code is saved and ready, you can test it with Postman again. You need to have correct ID values, which you can get from the Postman query you made to check for a single location or directly from MongoDB via the Mongo shell. The Mongo command db.locations.find() return all the locations and their reviews. Remember that the URL is in the structure /locations/:locationid/reviews/:reviewid.

You can also test what happens if you put in a false ID for a location or a review or try a review ID from a different location.

6.3.3. Finding multiple documents with geospatial queries

The homepage of Loc8r should display a list of locations based on the user’s current geographical location. MongoDB and Mongoose have some special geospatial aggregation methods to help find nearby places.

Here, you’ll use the Mongoose aggregate $geoNear to find a list of locations close to a specified point, up to a specified maximum distance. $geoNear is an aggregation method that accepts multiple configuration options, of which of the following are required:

  • near as a geoJSON geographical point
  • A distanceField object option
  • A maxDistance object option

The following code snippet shows the basic construct:

Loc.aggregate([{$geoNear: {near: {}, distanceField: "distance",
maxDistance: 100}}]);

Like the findById method, the $geoNear aggregate returns a Promise, and its value can be obtained by using a callback, its exec method, or async/await.

Constructing a geoJSON point

The first parameter of $geoNear is a geoJSON point: a simple JSON object containing a latitude and a longitude in an array. The construct for a geoJSON point is shown in the following code snippet:

const point = {               #1
  type: "Point",              #2
  coordinates: [lng, lat]     #3
};

The route set up here to get a list of locations doesn’t have the coordinates in the URL parameters, meaning that they’ll have to be specified in a different way. A query string is ideal for this data type, so the request URL will look more like this:

api/locations?lng=-0.7992599&lat=51.378091

Express, of course, gives you access to the values in a query string, putting them in a query object attached to the request object, such as req.query.lng. The longitude and latitude values will be strings when retrieved, but they need to be added to the point object as numbers. JavaScript’s parseFloat() function can see to this. The following code snippet shows how to get the coordinates from the query string and create the geoJSON point required by the $geoNear aggregation:

const locationsListByDistance = async (req, res) => {
  const lng = parseFloat(req.query.lng);               #1
  const lat = parseFloat(req.query.lat);               #1
  const near = {                                       #2
    type: "Point",                                     #2
    coordinates: [lng, lat]                            #2
  };                                                   #2
  const geoOptions = {
    distanceField: "distance.calculated",
    spherical: true,                                   #3
    maxDistance: 20000,
    limit: 10
  };
  try {
    const results = await Loc.aggregate([              #4
      {
        $geoNear: {
          near,
          ...geoOptions                                #5
        }
      }
    ]);
  } catch (err) {
    console.log(err);
  }
};

Trying to execute this controller code won’t result in a response, as processing of the data has not been started. Remember that this code is returning a Promise object.

Spread operator

New in ES2015 is the spread operator. This operator takes an iterable (an array, string, or object) and allows it to be expanded into places where zero or more arguments (when used in a function call) or elements (for array literals) are expected.

In the case of the aggregate function in the preceding code block, it injects the object properties in geoOptions into the $geoNear object. The spread operator has many uses; details are available at http://mng.bz/wEya.

The spherical option in the Aggregation specification

The geoOptions object contains a spherical key. This value is required to be set to true, as you’ve already specified the search index in the MongoDB data store as 2dsphere. If you try to set it to false, the application throws an exception:

const geoOptions = {
  distanceField: "distance.calculated",
  spherical: true
};
Limiting geoNear results by number

You’ll often want to look after the API server—and the responsiveness seen by end users—by limiting the number of results when returning a list. In the $geoNear aggregate, adding the option num or limit does this. You specify the maximum number of results you want to have returned. You can specify both, but num is given priority over limit.

The following code snippet shows limit added to the previous geoOptions object, limiting the size of the returned dataset to 10 objects:

const geoOptions = {
  distanceField: "distance.calculated",
  spherical: true,
  limit: 10
};

Now the search brings back no more than the 10 closest results.

Limiting geoNear results by distance

When returning location-based data, another way to keep the processing of the API under control is to limit the list of results by distance from the central point. This is a case of adding another option called maxDistance. When you use the spherical option, MongoDB does the calculations in meters for you, making life simple. This wasn’t always the case. Older versions of MongoDB used radians, which made things much more complicated.

If you want to output in miles, you’ll need to do a little calculation, but you’ll stick to meters and kilometers. You’ll impose a limit of 20 km, which is 20,000 m. Now you can add the maxDistance value to the options and add these options to the controller as follows:

const locationsListByDistance = (req, res) => {
  const lng = parseFloat(req.query.lng);
  const lat = parseFloat(req.query.lat);
  const near = {
    type: "Point",
    coordinates: [lng, lat]
  };
  const geoOptions = {                       #1
    distanceField: "distance.calculated",
    spherical: true,                         #1
    maxDistance: 20000,                      #1
    num: 10                                  #1

  };
  ...                                        #2
};
Extra credit

Try taking the maximum distance from a query string value instead of hardcoding it into the function. The code on GitHub for this chapter has the answer.

That’s the last of the options you need for your $geoNear database search, so it’s time to start working with the output.

Looking at the $geoNear aggregate output

The result object for the $geoNear aggregate method is a list of the matched items from the database or an error object. If you were using the callback function, it would have the following signature: callback(err, result). As you’re using async/await, you use try/catch to perform the operation or catch the error.

With a successful query, the error object is undefined; the results object is a list of items, as previously stated. You’ll start by working with the successful query response before adding error trapping.

Following a successful $geoNear aggregation, MongoDB returns an array of objects. Each object contains a distance value (at the path specified by the distanceField) and a returned document from the database. In other words, MongoDB includes the distance in the data. The following code snippet shows an example of the returned data, truncated for brevity:

[ { _id: 5b2c166f5caddf7cd8cea46b,
    name: 'Starcups',
    address: '125 High Street, Reading, RG6 1PS',
    rating: 3,
    facilities: [ 'Hot drinks', 'Food', 'Premium wifi' ],
    coords: { type: 'Point', coordinates: [Array] },
    openingTimes: [ [Object], [Object], [Object] ],
    distance: { calculated: 5005.183015553589 } } ]

This array has only one object, but a successful query is likely to have several objects returned at once. The $geoNear aggregate returns the entire document contained in the data store, but the API shouldn’t return more data than is requested. So rather than send the returned data back as the response, you have some processing to do first.

Processing the $geoNear output

Before the API can send a response, you need to make sure that it’s sending the right thing and only what’s needed. You know what data the homepage listing needs; you’ve already built the homepage controller in app_server/controllers/location.js. The homelist() function sends several location objects, similar to the following example:

{
  id: 111,
  name: 'Starcups',
  address: '125 High Street, Reading, RG6 1PS',
  rating: 3,
  facilities: ['Hot drinks', 'Food', 'Premium wifi'],
  distance: '100m'
}

To create an object along these lines from the results, you need to iterate through the results and map the relevant data into a new array. Then this processed data can be returned with a status 200 response. The following code snippet shows how this result might look:

try {
    const results = await Loc.aggregate([
      {
        $geoNear: {
          near,
           ...geoOptions
        }
      }
    ]);
    const locations = results.map(result => {                    #1
      return {                                                   #2
        id: result._id,
        name: result.name,
        address: result.address,
        rating: result.rating,
        facilities: result.facilities,
        distance: `${result.distance.calculated.toFixed()}m`     #3
      }
    });
    return res                                                   #4
      .status(200)
      .json(locations);
  } catch (err) {
    ...

If you test this API route with Postman—remembering to add longitude and latitude coordinates to the query string—you’ll see something like figure 6.9.

Figure 6.9. Testing the locations list route in Postman should give a 200 status and a list of results, depending on the geographical coordinates sent in the query string.
Extra credit

Try passing the results to an external named function to build the list of locations. This function should return the processed list, which can then be passed into the JSON response.

If you test this by sending coordinates too far away from the test data, you should still get a 200 status, but the returned array will be empty.

Adding the error trapping

Once again, you’ve started by building the success functionality. Now you need to add some error traps to make sure that the API always sends the appropriate response.

The traps you need to set should check that

  • All the parameters have been sent correctly.
  • The $geoNear aggregate hasn’t returned an error condition.

The following listing shows the final controller, including these error traps.

Listing 6.4. Locations list controller locationsListByDistance
const locationsListByDistance = async(req, res) => {
  const lng = parseFloat(req.query.lng);
  const lat = parseFloat(req.query.lat);
  const near = {
    type: "Point",
    coordinates: [lng, lat]
  };
  const geoOptions = {
    distanceField: "distance.calculated",
    key: 'coords',
    spherical: true,
    maxDistance: 20000,
    limit: 10
  };
  if (!lng || !lat) {                                            #1
    return res                                                   #1
      .status(404)                                               #1
      .json({                                                    #1
      "message": "lng and lat query parameters are required"     #1
    });                                                          #1
  }                                                              #1
  try {
    const results = await Loc.aggregate([
      {
        $geoNear: {
          near,
          ...geoOptions
        }
      }
    ]);
    const locations = results.map(result => {
      return {
        id: result._id
        name: result.name,
        address: result.address,
        rating: result.rating,
        facilities: result.facilities,
        distance: `${result.distance.calculated.toFixed()}m`
      }
    });
    res
      .status(200)
      .json(locations);
  } catch (err) {
    res
      .status(404)                                               #2
      .json(err);
  }
};

This listing completes the GET requests that your API needs to service, so it’s time to tackle the POST requests.

join today to enjoy all our content. all the time.
 

6.4. POST methods: Adding data to MongoDB

POST methods are all about creating documents or subdocuments in the database and then returning the saved data as confirmation. In the routes for Loc8r, you have two POST requests doing different things, as listed in table 6.7.

Table 6.7. Two POST requests of the Loc8r API

Action

Method

URL path

Example

Create new location POST /locations http://api.loc8r.com/locations
Create new review POST /locations/:locationid/reviews http://api.loc8r.com/locations/123/reviews

POST methods work by taking form data posted to them and adding it to the database. In the same way that URL parameters are accessed via req.params and query strings are accessed via req.query, Express controllers access posted form data via req.body.

Start by looking at how to create documents.

6.4.1. Creating new documents in MongoDB

In the database for Loc8r, each location is a document, so you’ll create a document in this section. Mongoose couldn’t make the process of creating MongoDB documents much easier for you. You apply the create() method to your model, and send it some data and a callback function. This construct is minimal, as it would be attached to your Loc model:

That’s simple. The creation process has two main steps:

  1. Use the posted form data to create a JavaScript object that matches the schema.
  2. Send an appropriate response in the callback, depending on the success or failure of the create() operation.

Looking at step 1, you already know that you can get data sent to you in a form by using req.body, and step 2 should be familiar by now. Jump straight into the code.

The following listing shows the full locationsCreate() controller for creating a new document.

Listing 6.5. Complete controller for creating a new location
const locationsCreate = (req, res) => {
  Loc.create({                                     #1
    name: req.body.name,
    address: req.body.address,
    facilities: req.body.facilities.split(","),    #2
    coords: {                                      #3
     type: "Point",
     [
       parseFloat(req.body.lng),
       parseFloat(req.body.lat)
     ]
    }, {
      days: req.body.days2,
      opening: req.body.opening2,
      closing: req.body.closing2,
      closed: req.body.closed2,
    }]
  }, (err, location) => {                          #4
    if (err) {
      res
        .status(400)
        .json(err);
    } else {
      res
        .status(201)
        .json(location);
    }
  });
};

This listing shows how easy it can be to create a new document in MongoDB and save some data. For the sake of brevity, you’ve limited the openingTimes array to two entries, but this array could easily be extended or, better, put in a loop to check for the existence of the values.

You may also notice that no rating is set. Remember that in the schema, you set a default of 0, as in the following snippet:

rating: {
  type: Number,
  "default": 0,
  min: 0,
  max: 5
},


!@%STYLE%@!
{"css":"{\"css\": \"font-weight: bold;\"}","target":"[[{\"line\":2,\"ch\":2},{\"line\":2,\"ch\":15}]]"}
!@%STYLE%@!

This snippet is applied when the document is created, setting the initial value to 0. Something else about this code may be shouting out at you: there’s no validation!

6.4.2. Validating the data using Mongoose

This controller has no validation code inside it, so what’s to stop somebody from entering loads of empty or partial documents? Again, you started building validations in the Mongoose schemas. In the schemas, you set a required flag to true in a few of the paths. When this flag is set, Mongoose won’t send the data to MongoDB.

Given the following base schema for locations, for example, you can see that only name is a required field:

const locationSchema = new mongoose.Schema({
  name: {
    type: String,
    required: true
  },
  address: String,
  rating: {
    type: Number,
    'default': 0,
    min: 0,
    max: 5
  },
  facilities: [String],
  coords: {
    type: {type: String},
    coordinates: [Number]
  },
  openingTimes: [openingTimeSchema],
  reviews: [reviewSchema]
});


!@%STYLE%@!
{"css":"{\"css\": \"font-weight: bold;\"}","target":"[[{\"line\":3,\"ch\":4},{\"line\":3,\"ch\":18}]]"}
!@%STYLE%@!

If this field is missing, the create() method raises an error and doesn’t attempt to save the document to the database.

Testing this API route in Postman looks like figure 6.10. Note that the method is set to post and that the data type selected (above the list of names and values) is x-www-form-urlencoded. You’ll enter the keys and values to submit with your POST request in the Postman interface, as shown in that figure. Be careful not to have any blank spaces before or after the keys you type in the Postman fields, as spaces will result in unexpected inputs.

Figure 6.10. Testing a POST method in Postman, ensuring that the method and form data settings are correct

6.4.3. Creating new subdocuments in MongoDB

In the context of Loc8r locations, reviews are subdocuments. Subdocuments are created and saved through their parent document. Put another way, to create and save a new subdocument, you have to

  1. Find the correct parent document.
  2. Add a new subdocument.
  3. Save the parent document.

Finding the correct parent isn’t a problem, as you’ve already done that and can use it as the skeleton for the next controller, reviewsCreate(). When you’ve found the parent, you can call an external function to do the next part (you’ll write this function soon), as shown in the following listing.

Listing 6.6. Controller for creating a review
const reviewsCreate = (req, res) => {
  const locationId = req.params.locationid;
  if (locationId) {
    Loc
      .findById(locationId)
      .select('reviews')
      .exec((err, location) => {
        if (err) {
          res
            .status(400)
            .json(err);
        } else {
          doAddReview(req, res, location);        #1
         }
       });
  } else {
    res
      .status(404)
      .json({"message": "Location not found"});
  }
};

This code isn’t doing anything particularly new; you’ve seen it all before. By putting in a call to a new function, you can keep the code neater by reducing the amount of nesting and indentation, and also make it easier to test.

Adding and saving a subdocument

Having found the parent document and retrieved the existing list of subdocuments, you need to add a new one. Subdocuments are arrays of objects, and the easiest way to add a new object to an array is to create the data object and use the JavaScript push() method, as the following code snippet demonstrates:

location.reviews.push({
  author: req.body.author,
  rating: req.body.rating,
  reviewText: req.body.reviewText
});

This snippet is getting posted form data; hence, it uses req.body.

When the subdocument has been added, the parent document must be saved because subdocuments can’t be saved on their own. To save a document, Mongoose has a model method save(), which expects a callback with an error parameter and a returned object parameter. The following code snippet shows this method in action:

location.save((err, location) => {
  if (err) {
    res
      .status(400)
      .json(err);
  } else {
    let thisReview = location.reviews[location.reviews.length - 1];    #1
    res
      .status(201)
      .json(thisReview);
  }
});

The document returned by the save method is the full parent document, not the new subdocument alone. To return the correct data in the API response—that is, the subdocument—you need to retrieve the last subdocument from the array 1.

When adding documents and subdocuments, you need to keep in mind any effect this action may have on other data. In Loc8r, for example, adding a review adds a new rating, and this new rating affects the overall rating for the document. On the successful save of a review, you’ll call another function to update the average rating.

Putting everything you have together in the doAddReview() function, plus a little error trapping, gives you the following listing.

Listing 6.7. Adding and saving a subdocument
const doAddReview = (req, res, location) => {                    #1
  if (!location) {
    res
      .status(404)
      .json({"message": "Location not found"});
  } else {
    const {author, rating, reviewText} = req.body;
    location.reviews.push({                                      #2
      author,
      rating,
      reviewText
    });
    location.save((err, location) => {                           #3
      if (err) {
        res
          .status(400)
          .json(err);
      } else {
        updateAverageRating(location._id);                       #4
        const thisReview = location.reviews.slice(-1).pop();     #5
        res                                                      #5
          .status(201)
          .json(thisReview);
       }
     });
  }
};
Updating the average rating

Calculating the average rating isn’t particularly complicated, so we won’t dwell on it long. The steps are

  1. Find the correct document, given a provided ID.
  2. Add up the ratings from all the review subdocuments.
  3. Calculate the average rating value.
  4. Update the rating value of the parent document.
  5. Save the document.

Turning this list of steps into code gives you something along the lines of the following listing, which should be placed in the reviews.js controller file along with the review-based controllers.

Listing 6.8. Calculating and updating the average rating
const doSetAverageRating = (location) => {                        #1
  if (location.reviews && location.reviews.length > 0) {
    const count = location.reviews.length;
    const total = location.reviews.reduce((acc, {rating}) => {    #2
      return acc + rating;
    }, 0);
    location.rating = parseInt(total / count, 10);                #3
    location.save(err => {                                        #4
      if (err) {
        console.log(err);
      } else {
        console.log(`Average rating updated to ${location.rating}`);
       }
     });
  }
};
const updateAverageRating = (locationId) => {                     #5
  Loc.findById(locationId)
    .select('rating reviews')
    .exec((err, location) => {
      if (!err) {
        doSetAverageRating(location);
       }
     });
};

You may have noticed that you’re not sending any JSON response here, because you’ve already sent it. This entire operation is asynchronous and doesn’t need to affect sending the API response that confirms the saved review.

Adding a review isn’t the only time you’ll need to update the average rating, which is why it makes extra sense to make these functions accessible from the other controllers and not tightly coupled to the actions of creating a review.

What you’ve done here offers a sneak peek at using Mongoose to update data in MongoDB, so now you’ll move on to the PUT methods of the API.

Sign in for more free preview time

6.5. PUT methods: Updating data in MongoDB

PUT methods are all about updating existing documents or subdocuments in the database and returning the saved data as confirmation. In the routes for Loc8r, you have two PUT requests doing different things, as listed in table 6.8.

Table 6.8. Two PUT requests of the Loc8r API for updating locations and reviews

Action

Method

URL path

Example

Update a specific location PUT /locations/:locationid http://loc8r.com/api/locations/123
Update a specific review PUT /locations/:locationid/reviews/:reviewid http://loc8r.com/api/locations/123/reviews/abc

PUT methods are similar to POST methods, because they work by taking form data posted to them. But instead of using the data to create new documents in the database, PUT methods use the data to update existing documents.

6.5.1. Using Mongoose to update a document in MongoDB

In Loc8r, you may want to update a location to add new facilities, change the open times, or amend any of the other data. The approach to updating data in a document is probably starting to look familiar:

  1. Find the relevant document.
  2. Make some changes to the instance.
  3. Save the document.
  4. Send a JSON response.

This approach is made possible by the way an instance of a Mongoose model maps directly to a document in MongoDB. When your query finds the document, you get a model instance. If you make changes to this instance and then save it, Mongoose updates the original document in the database with your changes.

6.5.2. Using the Mongoose save method

You saw this method in action when you updated the average rating value. The save method is applied to the model instance that the find() function returns. It expects a callback with the standard parameters of an error object and a returned data object.

A cut-down skeleton of this approach is shown in the following code snippet:

  Loc
    .findById(req.params.locationid)      #1
    .exec((err, location) => {
      location.name = req.body.name;      #2
      location.save((err, loc) => {       #3
        if (err) {
          res
            .status(404)                  #4
            .json(err);                   #4
        } else {
          res
            .status(200)                  #4
            .json(loc);                   #4
         }
       });
    }
  );
};

Here, you can clearly see the separate steps of finding, updating, saving, and responding. Fleshing out this skeleton into the locationsUpdateOne() controller with some error trapping and the data you want to save gives you the following listing.

Listing 6.9. Making changes to an existing document in MongoDB
const locationsUpdateOne = (req, res) => {
  if (!req.params.locationid) {
    return res
      .status(404)
      .json({
        "message": "Not found, locationid is required"
      });
  }
  Loc
    .findById(req.params.locationid)                          #1
    .select('-reviews -rating')
    .exec((err, location) => {
      if (!location) {
        return res
          .json(404)
          .status({
            "message": "locationid not found"
          });
      } else if (err) {
        return res
          .status(400)
          .json(err);
      }
      location.name = req.body.name;                            #2
      location.address = req.body.address;                      #2
      location.facilities = req.body.facilities.split(',');     #2
      location.coords = {                                       #2
        type: "Point",                                          #2
        [                                                       #2
          parseFloat(req.body.lng),                             #2
          parseFloat(req.body.lat)                              #2
        ]                                                       #2
      };                                                        #2
      location.openingTimes = [{                                #2
        days: req.body.days1,                                   #2
        opening: req.body.opening1,                             #2
        closing: req.body.closing1,                             #2
        closed: req.body.closed1,                               #2
      }, {                                                      #2
        days: req.body.days2,                                   #2
        opening: req.body.opening2,                             #2
        closing: req.body.closing2,                             #2
        closed: req.body.closed2,                               #2
      }];
      location.save((err, loc) => {                             #3
        if (err) {
          res                                                   #4
            .status(404)
            .json(err);
        } else {
          res                                                   #4
            .status(200)
            .json(loc);
        }
      });
    }
  );
};

There’s clearly a lot more code here, now that it’s fully fleshed out, but you can still easily identify the key steps of the update process.

The eagle-eyed among you may have noticed something strange in the select statement:

.select('-reviews -rating')

Previously, you used the select() method to say which columns you do want to select. By adding a dash in front of a pathname, you’re stating that you don’t want to retrieve it from the database. So this select() statement says to retrieve everything except the reviews and the rating.

6.5.3. Updating an existing subdocument in MongoDB

Updating a subdocument is exactly the same as updating a document, with one exception: after finding the document, you have to find the correct subdocument to make your changes. Then the save method is applied to the document, not the subdocument. So the steps for updating an existing subdocument are

  1. Find the relevant document.
  2. Find the relevant subdocument.
  3. Make some changes in the subdocument.
  4. Save the document.
  5. Send a JSON response.

For Loc8r, the subdocuments you’re updating are reviews, so when a review is changed, you’ll have to remember to recalculate the average rating. That’s the only additional thing you’ll need to add above and beyond the five steps. The following listing shows everything put into place in the reviewsUpdateOne() controller.

Listing 6.10. Updating a subdocument in MongoDB
const reviewsUpdateOne = (req, res) => {
  if (!req.params.locationid || !req.params.reviewid) {
    return res
      .status(404)
      .json({
        "message": "Not found, locationid and reviewid are both required"
      });
  }
  Loc
    .findById(req.params.locationid)                                    #1
    .select('reviews')
    .exec((err, location) => {
      if (!location) {
        return res
          .status(404)
          .json({
            "message": "Location not found"
           });
      } else if (err) {
        return res
          .status(400)
          .json(err);
      }
      if (location.reviews && location.reviews.length > 0) {
        const thisReview = location.reviews.id(req.params.reviewid);    #2
        if (!thisReview) {
          res
            .status(404)
            .json({
              "message": "Review not found"
            });
        } else {

          thisReview.author = req.body.author;                          #3
          thisReview.rating = req.body.rating;                          #3
          thisReview.reviewText = req.body.reviewText;                  #3
          location.save((err, location) => {                            #4
            if (err) {
              res                                                       #5
                .status(404)
                .json(err);
            } else {
              updateAverageRating(location._id);
              res                                                       #5
                .status(200)
                .json(thisReview);
            }
          });
        }
      } else {
        res
          .status(404)
          .json({
            "message": "No review to update"
          });
      }
    }
  );
};

The five steps for updating are clear to see in this listing: find the document; find the subdocument; make changes; save; and respond. Once again, a lot of the code here is error trapping, but it’s vital for creating a stable, responsive API. You don’t want to save incorrect data, send the wrong responses, or delete data you don’t want to delete. Speaking of deleting data, you can now move on to the final of the four API methods you’re using: DELETE.

Tour livebook

Take our tour and find out more about liveBook's features:

  • Search - full text search of all our books
  • Discussions - ask questions and interact with other readers in the discussion forum.
  • Highlight, annotate, or bookmark.
take the tour

6.6. DELETE method: Deleting data from MongoDB

The DELETE method is, unsurprisingly, all about deleting existing documents or subdocuments in the database. In the routes for Loc8r, you have a DELETE request for deleting a location and another for deleting a review. The details are listed in table 6.9. Start by taking a look at deleting documents.

Table 6.9. Two DELETE requests of the Loc8r API for deleting locations and reviews

Action

Method

URL path

Example

Delete a specific location DELETE /locations/:locationid http://loc8r.com/api/locations/123
Delete a specific review DELETE /locations/:locationid/reviews/:reviewid http://loc8r.com/api/locations/123/reviews/abc

6.6.1. Deleting documents in MongoDB

Mongoose makes deleting a document in MongoDB extremely simple by giving you the method findByIdAndRemove(). This method expects a single parameter: the ID of the document to be deleted.

The API should respond with a 404 in case of an error and a 204 in case of success. The following listing shows everything in place in the locationsDeleteOne() controller.

Listing 6.11. Deleting a document from MongoDB, given an ID
const locationsDeleteOne = (req, res) =>  {
  const {locationid} = req.params;
  if (locationid) {
    Loc
      .findByIdAndRemove(locationid)          #1
      .exec((err, location) =>  {             #2
          if (err) {
            return res                        #3
              .status(404)
              .json(err);
          }
          res                                 #3
            .status(204)
            .json(null);
        }
    );
  } else {
    res
      .status(404)
      .json({
        "message": "No Location"
      });
  }
};

That’s the quick and easy way to delete a document, but you can break it into a two-step process or, if you prefer, find it and then delete it. This gives you the chance to do something with the document before deleting (if you need to). This is demonstrated in the following code snippet:

Loc
  .findById(locationid)
  .exec((err, location) => {
    // Do something with the document
    location.remove((err, loc) => {
      // Confirm success or failure
    });
  }
);

This snippet has an extra level of nesting, but with it comes an extra level of flexibility, should you need it.

6.6.2. Deleting a subdocument from MongoDB

The process for deleting a subdocument is no different from the other work you’ve done with subdocuments; everything is managed through the parent document. The steps for deleting a subdocument are

  1. Find the parent document.
  2. Find the relevant subdocument.
  3. Remove the subdocument.
  4. Save the parent document.
  5. Confirm success or failure of operation.

Deleting the subdocument itself is easy, as Mongoose gives you another helper method. You’ve already seen that you can find a subdocument by its ID with the id method like this:

location.reviews.id(reviewid)

Mongoose allows you to chain a remove method to the end of this statement like so:

location.reviews.id(reviewid).remove()

This instruction deletes the subdocument from the array. Remember to save the parent document to persist the change back to the database. Putting all the steps together—with a load of error trapping—into the reviewsDeleteOne() controller looks like the following listing.

Listing 6.12. Finding and deleting a subdocument from MongoDB
const reviewsDeleteOne = (req, res) => {
  const {locationid, reviewid} = req.params;
  if (!locationid || !reviewid) {
    return res
      .status(404)
      .json({'message': 'Not found, locationid and reviewid are both
     required'});
  }
  Loc
    .findById(locationid)                                  #1
    .select('reviews')
    .exec((err, location) => {
       if (!location) {
         return res
           .status(404)
           .json({'message': 'Location not found'});
      } else if (err) {
        return res
          .status(400)
          .json(err);
      }

      if (location.reviews && location.reviews.length > 0) {
        if (!location.reviews.id(reviewid)) {
          return res
            .status(404)
            .json({'message': 'Review not found'});
        } else {
          location.reviews.id(reviewid).remove();          #2
          location.save(err => {                           #3
            if (err) {
              return res                                   #4
                .status(404)
                .json(err);
            } else {
              updateAverageRating(location._id);
              res                                          #4
                .status(204)
                .json(null);
            }
          });
        }
      } else {
        res
          .status(404)
          .json({'message': 'No Review to delete'});
      }
    });
  };

Again, most of the code here is error trapping. The API could return seven possible responses, and only one is the successful one. Deleting the subdocument is easy; make absolutely sure that you’re deleting the right one.

As you’re deleting a review, which will have a rating associated to it, you also have to remember to call the updateAverageRating() function to recalculate the average rating for the location. This function should only be called if the delete operation is successful.

And that’s it. You’ve built a REST API in Express and Node that can accept GET, POST, PUT, and DELETE HTTP requests to perform CRUD operations on a MongoDB database.

Coming up in chapter 7, you’ll see how to use this API from inside the Express application, finally making the Loc8r site database-driven!

Summary

In this chapter, you learned

  • The best practices for creating a REST API, including URLs, request methods, and response codes
  • How the POST, GET, PUT, and DELETE HTTP request methods map to common CRUD operations
  • Mongoose helper methods for creating the helper methods
  • Ways to interact with the data through Mongoose models and how one instance of the model maps directly to one document in the database
  • How to manage subdocuments through their parent document
  • Some ways of making the API robust by checking for any possible errors you can think of so that a request is never left unanswered
sitemap

Unable to load book!

The book could not be loaded.

(try again in a couple of minutes)

manning.com homepage
Up next...
  • Calling an API from an Express application
  • Handling and using data returned by the API
  • Working with API response codes
  • Submitting data from the browser back to the API
  • Validating and trapping errors
{{{UNSCRAMBLE_INFO_CONTENT}}}