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.jsin the/routesdirectory 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
getPublishedArticlesfunction from theblogControllerto return a list of all published articles.The second route is a GET route that includes a parameter
:articleIdin the path. This route will handle HTTP GET requests to the server with a specific article ID in the path, and will use thegetArticlefunction from theblogControllerto return the article with the specified ID. -
Create a file called
author.routes.jsin the/routesdirectory 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.jsin the/routesdirectory 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
signuproute uses thepassport.authenticate()function to authenticate the request using thesignupstrategy. 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.signupfunction for further processing.The
loginroute is similar, but it uses theloginstrategy and passes the request to theauthController.loginfunction if it is successfully authenticated.Both routes also use the
userValidatormiddleware to validate the request body before it is passed to the controller functions. -
Create an
index.jsfile in/routesand 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.jsfile.
// {... 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/blogroutes, so that only authenticated users can have access to the resources. Unauthenticated users can only have access to the/blogroutes.
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.jsin the/controllersdirectory. 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
getPublishedArticlesfunction will be called when theGET /blogroute 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.queryobject. 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 thesortQueryobject, which is constructed based on theorderandorder_byquery parameters.The
order_byquery parameter is a string that can contain multiple field names, separated by commas. ThesortAttributesvariable is an array of these field names. For each field name in the array, thesortQueryobject is updated with the field name as the key and the sort order as the value. If theorderquery 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
findQueryobject, which is initialized with a filter to only return published articles. ThefindQueryobject is then updated with additional filters based on theauthor,title, andtagsquery parameters, if they are present.For example, if the
authorquery parameter is present, thefindQuery.authorfield is set to the value of theauthorquery parameter. This will filter the results to only include articles whoseauthorfield is equal to the value of theauthorquery parameter. Similarly, if thetagsquery parameter is present, thefindQuery.tagsfield 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$inoperator is used to search for articles with tags that are included in the array of search tags.The
findQueryobject is passed as an argument to the.find()method of theBlogModelobject. 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 thesortQueryobject..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. Thepageandper_pagequery parameters are used to determine the values passed toskip()andlimit(). By default,pageis set to 1 andper_pageis 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
getArticlefunction retrieves the article with the specified ID from the database and increments the article's read count before returning it to the client.Both functions use the
asynckeyword and include atry/catchblock to handle any errors that may occur during the execution of the function. If an error occurs, thenextfunction is called with the error as an argument to pass the error to the error handling middleware which is set up in ourapp.js. -
Create a file called
author.controllers.jsin the/controllersdirectory 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
createArticlefunction begins by destructuring the new article data from thereq.bodyobject and calculating the reading time for the article using thecalculateReadingTimefunction from theblogServicemodule which we'll create next to abstract some functionalities.Next, the function checks if the
tagsfield is present in thenewArticledata, and splits the tags string into an array using thesplitmethod. Recall thattagsis 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-fileuploadwe installed will allow us to access thereq.filesobject.Finally, the function creates a new
BlogModelinstance with thenewArticledata and the current timestamp and user ID, and saves it to the database using thesavemethod. We're using an npm modulemomentto handle thetimestamp. You can install moment by runningnpm install moment.The
authorfield is anObjectIdreferencing theUsermodel. Here, we assign the user's ID so we can use Mongoose.populatemethod 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
articlesarray in the database to include the ID of the new article before we return a response to the client.In the
editStatefunction, 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
editArticlefunction, 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.jsin the/controllersdirectory 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
signupfunction creates a newuserobject with the provided first name, last name, email, and password. It then saves thisuserobject to the database and returns a success message and theuserobject, minus thepasswordfield. You should never expose passwords.The
loginfunction 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 aJWTwith the user's ID and email as the payload, and signs it with a secret key (which we stored in our.envfile. 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.jsin the/controllersdirectory:
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/signuproute 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/loginroute 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/blogroute 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"
}
}
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!
Top comments (2)
Good one abu. this is really cool stuff.
Thank you.