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
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 theblogController
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 thegetArticle
function from theblogController
to return the article with the specified ID.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 );
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 thepassport.authenticate()
function to authenticate the request using thesignup
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 theauthController.signup
function for further processing.The
login
route is similar, but it uses thelogin
strategy and passes the request to theauthController.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.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 };
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.
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 theGET /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 thereq.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 thesortQuery
object, which is constructed based on theorder
andorder_by
query parameters.The
order_by
query parameter is a string that can contain multiple field names, separated by commas. ThesortAttributes
variable is an array of these field names. For each field name in the array, thesortQuery
object is updated with the field name as the key and the sort order as the value. If theorder
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. ThefindQuery
object is then updated with additional filters based on theauthor
,title
, andtags
query parameters, if they are present.For example, if the
author
query parameter is present, thefindQuery.author
field is set to the value of theauthor
query parameter. This will filter the results to only include articles whoseauthor
field is equal to the value of theauthor
query parameter. Similarly, if thetags
query parameter is present, thefindQuery.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 theBlogModel
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 thesortQuery
object..skip()
and.limit()
are called to paginate the results.skip()
specifies the number of documents to skip in the query, andlimit()
specifies the number of documents to return. Thepage
andper_page
query parameters are used to determine the values passed toskip()
andlimit()
. By default,page
is set to 1 andper_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.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 thereq.body
object and calculating the reading time for the article using thecalculateReadingTime
function from theblogService
module which we'll create next to abstract some functionalities.Next, the function checks if the
tags
field is present in thenewArticle
data, and splits the tags string into an array using thesplit
method. Recall thattags
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 thereq.files
object.Finally, the function creates a new
BlogModel
instance with thenewArticle
data and the current timestamp and user ID, and saves it to the database using thesave
method. We're using an npm modulemoment
to handle thetimestamp
. You can install moment by runningnpm install moment
.The
author
field is anObjectId
referencing theUser
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'sarticles
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.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 newuser
object with the provided first name, last name, email, and password. It then saves thisuser
object to the database and returns a success message and theuser
object, minus thepassword
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 aJWT
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 theJWT
.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.
First, you'll need to start the server by running the following command in the terminal:
npm start
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", } }
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" }
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 tokenBearer {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" } }
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!