Building a Blog API with Node.js: Implementing the API Routes (Part 4)

This is part 4 of the "Building a Blog API with Node.js" series. Now that we have our API design complete, the models set up, the database connection established and the authentication strategies in place, it's time to start building the actual API. In this article, we'll cover the following topics:

  • Implementing the routes for our API

  • Writing the controllers to handle requests and send responses

  • Testing the API with Postman

Let's get started!

Implementing the Routes

  1. Create a file called blog.routes.js in the /routes directory and write this code:

     const express = require("express");
     const { blogController } = require("../controllers");
    
     const blogRouter = express.Router();
    
     blogRouter.get("/", blogController.getPublishedArticles);
    
     blogRouter.get("/:articleId", blogController.getArticle);
    
     module.exports = blogRouter;
    

    The express.Router() function creates a new router object that can be used to define routes that will be used to handle HTTP requests to the server.

    The first route defined is a GET route at the root path of the router. This route will handle HTTP GET requests to the server and will use the getPublishedArticles function from the blogController to return a list of all published articles.

    The second route is a GET route that includes a parameter :articleId in the path. This route will handle HTTP GET requests to the server with a specific article ID in the path, and will use the getArticle function from the blogController to return the article with the specified ID.

  2. Create a file called author.routes.js in the /routes directory and write this code:

     const express = require("express"); 
     const { authorController } = require("../controllers");
     const {
         newArticleValidationMW,
         updateArticleValidationMW,
     } = require("../validators");
    
     const authorRouter = express.Router();
    
     // create a new article 
     authorRouter.post("/", newArticleValidationMW, authorController.createArticle);
    
     // change state 
     authorRouter.patch(
         "/edit/state/:articleId",
         updateArticleValidationMW,
         authorController.editState
     );
    
     // edit article
     authorRouter.patch(
         "/edit/:articleId",
         updateArticleValidationMW,
         authorController.editArticle
     );
    
     // delete article 
     authorRouter.delete("/delete/:articleId", authorController.deleteArticle);
    
     // get all articles created by the author 
     authorRouter.get("/", authorController.getArticlesByAuthor);
    
     module.exports = authorRouter;
    

    Here, we've imported the validation middleware we created in the previous article:

     const {
         newArticleValidationMW,
         updateArticleValidationMW,
     } = require("../validators");
    

    When a POST or PATCH request is made, the validation middleware checks if the request body is in the correct format:

     authorRouter.post("/", newArticleValidationMW, authorController.createArticle);
    
     authorRouter.patch(
         "/edit/state/:articleId",
         updateArticleValidationMW,
         authorController.editState
     );
     authorRouter.patch(
         "/edit/:articleId",
         updateArticleValidationMW,
         authorController.editArticle
     );
    
  3. Create a file called auth.routes.js in the /routes directory for the authentication routes:

     const express = require("express");
     const passport = require("passport");
     const { userValidator } = require("../validators");
    
     const { authController } = require("../controllers");
    
     const authRouter = express.Router();
    
     authRouter.post(
         "/signup",
         passport.authenticate("signup", { session: false }), userValidator,
         authController.signup
     );
    
     authRouter.post("/login", async (req, res, next) =>
         passport.authenticate("login", (err, user, info) => {
             authController.login(req, res, { err, user, info });
         })(req, res, next)
     );
    
     module.exports = authRouter;
    

    The signup route uses the passport.authenticate() function to authenticate the request using the signup strategy. It also specifies that session management should be disabled using the { session: false } option. If the request is successfully authenticated, it is passed to the authController.signup function for further processing.

    The login route is similar, but it uses the login strategy and passes the request to the authController.login function if it is successfully authenticated.

    Both routes also use the userValidator middleware to validate the request body before it is passed to the controller functions.

  4. Create an index.js file in /routes and export the routers:

     const blogRouter = require("./blog.routes"); 
     const authorRouter = require("./author.routes");
     const authRouter = require("./auth.routes");
    
     module.exports = { blogRouter, authorRouter, authRouter };
    
  5. Import the routers and use them in the app.js file.

     // {... some code ....}
     const { blogRouter, authRouter, authorRouter } = require("./src/routes");
    
     // Middleware
     // {... some code ....}
    
     // Routes
     app.use("/blog", blogRouter);
     app.use("/auth", authRouter);
     app.use(
         "/author/blog",
         passport.authenticate("jwt", { session: false }),
         authorRouter
     );
    
     // {... some code ....}
    

    Remember we separated the routes into blog routes and author routes because we wanted to protect the author routes. We are using passport to protect the /author/blog routes, so that only authenticated users can have access to the resources. Unauthenticated users can only have access to the /blog routes.

Writing the Controllers

Now that we have the routes defined, we need to write the code to handle the requests and send the appropriate responses. A controller is a function that is called when a route is matched, and it is responsible for handling the request and sending the response.

  1. Create a file called blog.controllers.js in the /controllers directory. In this file, define the functions that will be called by the blog routes:

     const { BlogModel, UserModel } = require("../models");
    
     // Get all published articles
     exports.getPublishedArticles = async (req, res, next) => {
         try {
             const {
                 author,
                 title,
                 tags,
                 order = "asc",
                 order_by = "timestamp,reading_time,read_count",
                 page = 1,
                 per_page = 20,
             } = req.query;
    
             // filter
             const findQuery = { state: "published" };
    
             if(author) {
               findQuery.author = author;
             }
             if (title) {
                 findQuery.title = title;
             }
             if (tags) {
               const searchTags = tags.split(",");
               findQuery.tags = { $in: searchTags };
             }
    
             // sort
             const sortQuery = {};
             const sortAttributes = order_by.split(",");
    
             for (const attribute of sortAttributes) {
                 if (order === "asc") {
                     sortQuery[attribute] = 1;
                 }
                 if (order === "desc" && order_by) {
                     sortQuery[attribute] = -1;
                 }
             }
    
             // get all published articles from the database
             const articles = await BlogModel.find(findQuery)
                 .populate("author", "firstname lastname email")
                 .sort(sortQuery)
                 .skip(page)
                 .limit(per_page);
    
             return res.status(200).json({
                 message: "Request successful",
                 articles: articles,
             });
         } catch (error) {
             return next(error);
         }
     };
    
     // Get article by ID
     exports.getArticle = async (req, res, next) => {
         const { articleId } = req.params;
         try {
             const article = await BlogModel.findById(articleId).populate(
                 "author",
                 "firstname lastname email"
             );
    
             article.readCount += 1; // increment read count
             await article.save();
    
             return res.status(200).json({
                 message: "Request successful",
                 article: article,
             });
         } catch (error) {
             return next(error);
         }
     };
    

    The getPublishedArticles function will be called when the GET /blog route is matched, and it will retrieve a list of all published articles from the database and send the response to the client. It begins by destructuring some query parameters from the req.query object. These query parameters are used to filter and sort the articles that are returned.

    The sorting in this function is done using the Mongoose .sort() method, which takes an object with the field names as keys and the sort order as values. The field names and sort orders are specified in the sortQuery object, which is constructed based on the order and order_by query parameters.

    The order_by query parameter is a string that can contain multiple field names, separated by commas. The sortAttributes variable is an array of these field names. For each field name in the array, the sortQuery object is updated with the field name as the key and the sort order as the value. If the order query parameter is "asc", the sort order is set to 1, and if the order query parameter is "desc", the sort order is set to -1.

    The find query is constructed using the findQuery object, which is initialized with a filter to only return published articles. The findQuery object is then updated with additional filters based on the author, title, and tags query parameters, if they are present.

    For example, if the author query parameter is present, the findQuery.author field is set to the value of the author query parameter. This will filter the results to only include articles whose author field is equal to the value of the author query parameter. Similarly, if the tags query parameter is present, the findQuery.tags field is set to a MongoDB operator that searches for articles with tags that match the search tags. The search tags are specified as a comma-separated string and are split into an array using the split method. The $in operator is used to search for articles with tags that are included in the array of search tags.

    The findQuery object is passed as an argument to the .find() method of the BlogModel object. This method returns a Mongoose query object that can be used to find documents in the blog collection that match the specified filter. The .populate() method is then called on the query object to populate the author field with the corresponding user document, and .sort() sorts the results using the sortQuery object. .skip() and .limit() are called to paginate the results. skip() specifies the number of documents to skip in the query, and limit() specifies the number of documents to return. The page and per_page query parameters are used to determine the values passed to skip() and limit(). By default, page is set to 1 and per_page is set to 20. This means that if these query parameters are not provided, the default behavior is to retrieve the first page of results with 20 documents per page.

    The getArticle function retrieves the article with the specified ID from the database and increments the article's read count before returning it to the client.

  2. Create a file called author.controllers.js in the /controllers directory and define the functions that will be called by the author routes as follows:

     const { BlogModel, UserModel } = require("../models");
     const { blogService } = require("../services");
     const moment = require("moment");
    
     // create a new article
     exports.createArticle = async (req, res, next) => {
         try {
             const newArticle = req.body;
    
             // calculate reading time
             const readingTime =
                 blogService.calculateReadingTime(newArticle.body);
    
             if (newArticle.tags) {
               newArticle.tags = newArticle.tags.split(",");
             }
             if (req.files) {
               const imageUrl = await blogService.uploadImage(req, res, next);
               newArticle.imageUrl = imageUrl;
             }
    
             const article = new BlogModel({
                 author: req.user._id,
                 timestamp: moment().toDate(),
                 readingTime: readingTime,
                 ...newArticle,
             });
    
             article.save((err, user) => {
                 if (err) {
                     console.log(`err: ${err}`);
                     return next(err);
                 }
             });
    
             // add article to user's articles array in the database
             const user = await UserModel.findById(req.user._id);
             user.articles.push(article._id);
             await user.save();
    
             return res.status(201).json({
                 message: "Article created successfully",
                 article: article,
             });
         } catch (error) {
             return next(error);
         }
     };
    
     // change state
     exports.editState = async (req, res, next) => {
         try {
             const { articleId } = req.params;
             const { state } = req.body;
    
             const article = await BlogModel.findById(articleId);
    
             // check if user is authorised to change state
             blogService.userAuth(req, res, next, article.author._id);
    
             // validate request
             if (state !== "published" && state !== "draft") {
                 return next({ status: 400, message: "Invalid state" });
             }
    
             if (article.state === state) {
                 return next({ 
                     status: 400, 
                     message: `Article is already in ${state} state` 
                 });
             }
    
             article.state = state;
             article.timestamp = moment().toDate();
    
             article.save();
    
             return res.status(200).json({
                 message: "State successfully updated",
                 article: article,
             });
         } catch (error) {
             return next(error);
         }
     };
    
     // edit article
     exports.editArticle = async (req, res, next) => {
         try {
             const { articleId } = req.params;
             const { title, body, tags, description } = req.body;
    
             // check if user is authorised to edit article
             const article = await BlogModel.findById(articleId);
             blogService.userAuth(req, res, next, article.author._id);
    
             // if params are provided, update them
             if (req.files) {
               const imageUrl = await blogService.uploadImage(req, res, next);
               article.imageUrl = imageUrl;
             }
             if (title) {
                 article.title = title;
             }
             if (body) {
                 article.body = body;
                 article.readingTime = blogService.calculateReadingTime(body);
             }
             if (tags) {
                 article.tags = tags.split(",");
             }
             if (description) {
                 article.description = description;
             }
             if (title || body || tags || description ) {
                 article.timestamp = moment().toDate();
             }
    
             article.save();
    
             return res.status(200).json({
                 message: "Article successfully edited and saved",
                 article: article,
             });
    
         } catch (error) {
             return next(error);
         }
     };
    
     // delete article
     exports.deleteArticle = async (req, res, next) => {
         try {
             const { articleId } = req.params;
    
             const article = await BlogModel.findById(articleId);
    
             // check if user is authorised to delete article
             blogService.userAuth(req, res, next, article.author._id);
    
             article.remove();
    
             return res.status(200).json({
                 message: "Article successfully deleted",
             });
         } catch (error) {
             return next(error);
         }
     };
    
     // get all articles created by the user
     exports.getArticlesByAuthor = async (req, res, next) => {
         try {
             const {
                 state,
                 order = "asc",
                 order_by = "timestamp",
                 page = 1,
                 per_page = 20,
             } = req.query;
    
             const findQuery = {};
             // check if state is valid and if it is, add it to the query
             if (state) {
                 if (state !== "published" && state !== "draft") {
                     return next({ status: 400, message: "Invalid state" });
                 } else {
                     findQuery.state = state;
                 }
             }
    
             // sort
             const sortQuery = {};
    
             if (order !== "asc" && order !== "desc") {
                 return next({ 
                     status: 400, 
                     message: "Invalid parameter order" 
                 });
             } else {
               sortQuery[order_by] = order;
             }
    
             // get user's articles
             const user = await UserModel.findById(req.user._id).populate({
                 path: "articles",
                 match: findQuery,
                 options: {
                     sort: sortQuery,
                     limit: parseInt(per_page),
                     skip: (page - 1) * per_page,
                 },
             });
    
             return res.status(200).json({
                 message: "Request successful",
                 articles: user.articles,
             });
         } catch (error) {
             return next(error);
         }
     };
    

    The createArticle function begins by destructuring the new article data from the req.body object and calculating the reading time for the article using the calculateReadingTime function from the blogService module which we'll create next to abstract some functionalities.

    Next, the function checks if the tags field is present in the newArticle data, and splits the tags string into an array using the split method. Recall that tags is defined as an array in the blog model.

    Next, If the user uploads a cover image, we want to access the file, upload it to a cloud storage service, get the URL for the uploaded image and save it to our database. The express-fileupload we installed will allow us to access the req.files object.

    Finally, the function creates a new BlogModel instance with the newArticle data and the current timestamp and user ID, and saves it to the database using the save method. We're using an npm module moment to handle the timestamp. You can install moment by running npm install moment.

    The author field is an ObjectId referencing the User model. Here, we assign the user's ID so we can use Mongoose .populate method to retrieve the author's details when we need to. Similarly, we want to be able to track all the articles created by the user, so next, we update the user's articles array in the database to include the ID of the new article before we return a response to the client.

    In the editState function, we retrieve the article from the database and check if the user is authorized to change the state of the article. If the user is authorized, we update the article's state and timestamp in the database.

    In the editArticle function, we retrieve the article from the database and check if the user is authorized to edit the article. If the user is authorized, we update the article's data in the database.

  3. Create a file called auth.controllers.js in the /controllers directory for the authentication routes:

     const jwt = require("jsonwebtoken");
     const { UserModel } = require("../models");
    
     exports.signup = async (req, res, next) => {
         const { firstname, lastname, email, password } = req.body;
    
         try {
             const user = new UserModel({
                 firstname: firstname,
                 lastname: lastname,
                 email: email,
                 password: password,
             });
    
             user.save((err, user) => {
                 if (err) {
                     return next(err);
                 }
             });
    
             delete user.password;
    
             return res.status(201).json({
                 message: "Signup successful",
                 user: user,
             });
         } catch (error) {
             return next(error);
         }
     };
    
     exports.login = (req, res, { err, user, info }) => {
         if (!user) {
             return res.status(401).json({ message: "email or password is incorrect" });
         }
    
         req.login(user, { session: false }, async (error) => {
             if (error) return res.status(401).json({ message: error });
    
             const body = { _id: user._id, email: user.email };
    
             const token = jwt.sign({ user: body }, process.env.JWT_SECRET, {
                 expiresIn: "1h",
             });
    
             return res.status(200).json({ 
                 message: "Login successful",
                 token: token 
             });
         });
     };
    

    The signup function creates a new user object with the provided first name, last name, email, and password. It then saves this user object to the database and returns a success message and the user object, minus the password field. You should never expose passwords.

    The login function is called when a user attempts to log in. If the provided email and password do not match a user in the database, it returns a 401 status code and an error message. Otherwise, it creates a JWT with the user's ID and email as the payload, and signs it with a secret key (which we stored in our .env file. Since we're not using session, logging out is tricky, so we've set an expiration time of one hour for the token. The function returns a success message and the JWT.

  4. Create a file called /index.js in the /controllers directory:

     const authController = require("./auth.controller"); 
     const blogController = require("./blog.controller"); 
     const authorController = require("./author.controller");
    
     module.exports = { 
         authController, 
         blogController, 
         authorController 
     };
    

Blog Service

We abstracted some of the logic for our author.controller.js to a blogService module. Let's look into that.

Before we begin, we're using Cloudinary, a media management platform that allows you to store, optimize, and deliver images and videos. Follow the instructions on the Cloudinary website to set up your account, then install the SDK by running this command:

npm install cloudinary

Now, in /services, create a file called blog.service.js with the following functions:

const cloudinary = require('cloudinary').v2;

cloudinary.config({
  secure: true
});

exports.userAuth = (req, res, next, authorId) => {
    // if author is not the same as the logged in user, throw error
    if (req.user._id !== authorId.toString()) {
        return next({
            status: 401,
            message: "You are not authorized to access this resource",
        });
    }
};

exports.calculateReadingTime = (text) => {
    const wordsPerMin = 200;
    const wordCount = text.trim().split(/\s+/).length;
    const readingTime = Math.ceil(wordCount / wordsPerMin);

    return readingTime > 1 ? `${readingTime} mins` : `${readingTime} min`;
};

exports.uploadImage = async (req, res, next) => {
  const filePath = req.files.file.tempFilePath;

  const options = {
    use_filename: true,
    unique_filename: false,
    overwrite: true,
  };

  try {
    const uploadedImage = await cloudinary.uploader.upload(filePath, options);

    return uploadedImage.secure_url;
  } catch (error) {
    return next(error);
  }

}

The userAuth function checks if the user making the request is the same as the author of the post being accessed. If the user is not the author, it throws an error with a status code of 401 (Unauthorized).

The calculateReadingTime function takes a piece of text as an argument and calculates the estimated reading time in minutes by dividing the number of words in the text by the average number of words per minute (the average reading speed for an adult is about 200wpm according to Marc Brysbaert from Ghent University in Belgium). It then rounds up to the nearest whole number and returns a string with the reading time.

The uploadImage function uploads an image to Cloudinary and returns a secure URL. It takes req, res and next as arguments, retrieves the file path of the uploaded image from the req object and stores it in a filePath variable (this is made possible by the express-fileupload npm package we installed). It then sets up options for the upload, including the use_filename and overwrite options, which specify that the uploaded file should use its original filename and overwrite any existing file with the same name. The function then uses the cloudinary.uploader.upload method to upload the file to Cloudinary. If the upload is successful, it returns the secure URL of the uploaded file.

In /services/index.js:

const blogService = require('./blog.service.js');

module.exports = {
  blogService
}

Testing the API with Postman

With the routes and controllers in place, we can now test our API to make sure it's working as expected. You can use Postman to test all of the routes in your API to make sure they are working as expected.

To test routes that require a request body, such as POST and PATCH routes, you'll need to specify the request body in the appropriate format in the Postman request form. You'll also need to provide a bearer token in the Authorisation header when testing protected routes.

For example, if you're sending a POST request to the /author/blog route, include the article data in the request body.

  1. First, you'll need to start the server by running the following command in the terminal:

     npm start
    
  2. Next, create a new user. Send a POST request to the /auth/signup route and include the user data in the request body like this:

     {
       "email": "doe@example.com",
       "password": "Password1",
       "firstname": "jon",
       "lastname": "doe",
     }
    

    If successful, you'll get a success response:

     {
         "message": "Signup successful",
         "user": {
             "firstname": "jon",
             "lastname": "doe",
             "email": "doe@example.com",
         }
     }
    
  3. Next, login. Send a POST request to the /auth/login route and include the user data in the request body like this:

     {
       "email": "doe@example.com",
       "password": "Password1",
     }
    

    If successful, you'll get a success response with a token:

     {
         "message": "Login successful",
         "token": "sjlkafjkldsf.jsdntehwkljyroyjoh.tenmntiw"
     }
    
  4. Finally, send POST request to the /author/blog route and include the article data in the request body like this:

     {
         "title": "testing the routes",
         "body": "This is the body of the article",
         "description": "An article",
         "tags": "blog,test",
     }
    

    You should get a 401(Unauthorised) status code. Try again, but add an Authorization header with a bearer token Bearer {token}. Replace {token} with the token you got after logging in. You should get a success response:

     {
         "message": "Article created successfully",
         "article": {
             "title": "testing the routes",
             "description": "An article",
             "tags": [
                 "blog",
                 "test"
             ],
             "author": "6366b10174282b915e1be028",
             "timestamp": "2022-11-05T20:52:40.573Z",
             "state": "draft",
             "readCount": 0,
             "readingTime": "1 min",
             "body": "This is the body of the article",
             "_id": "6366cd18b34b65410bc391db"
         }
     }
    
  5. Now, test the other routes.

That's it! With the API routes and controllers implemented and tested, we now have a fully-functional blog API. In the next article, we'll cover additional topics such as error logging and deploying our API. Stay tuned!