Introduction
Hello there,
In this tutorial, you are going to learn how to build a Blog API with Node.js and Express.js Framework. Anyone should be able to view and read published articles but only logged-in users can perform tasks like creating, editing and deleting an article.
Major Functionalities:
- Users can register and login.
- Users can view a list of published articles.
- Users can read a published article.
- Only logged-in users can create an article.
- A Logged-in user can publish their own article.
- A logged-in user can edit their own article.
- A logged-in user can delete their own article.
- A logged-in user can view a list of their own articles.
Prerequisites
I assumed that you are already familiar with JavaScript, Node.js & Express.js, MongoDB & Mongoose and how a Web Server works.
You'll also need
- VS Code
- Node.js
- MongoDB Server - MongoDB Atlas or MongoDB Compass
- API Testing Platform - Thunder Client or Postman
Environment Setup
Project Initialization
We start by creating a directory that will contain the project files. Open your Terminal app (Linux/Mac) or Command Line on Windows. If you're on Windows I'll recommend that you use Git Bash instead of the default command line.
I'm going to name the project as blog_api
but you can give it any name you want. In your terminal, enter the following command to create the project folder, mkdir blog_api
.
Navigate to the project folder you just created with the following command, cd blog_api
.
Now that we're in the project folder which I'll subsequently refer to as root directory, let's initialize a Node.js project by executing this command, npm init-y
. A package.json
file is created in the root directory.
Still, in the root directory create the following files .env
and .gitignore.
Later in .env
we're going to store sensitive information about our API. These include any information we do not want to make public such as DATABASE URL, JWT SECRET and so on. While .gitignore
would contain names of files that should not be exposed to the public, for example, your .env
file.
Install Dependencies
Dependencies are programs usually referred to as modules libraries or packages that perform a specific task and can be installed and used in our programs. By using the appropriate dependencies we would not have to code everything from scratch, and so the development duration is shortened considerably.
Development Dependencies (Dev dependencies) are a category of dependencies that are used only during development.
Here is a list of dependencies that we'll use in this project:
express
- For creating our web server and handing routes.
mongoose
- An Object Document Mapper (ODM, that helps to simplify working with MongoDB.
bcrypt
- Provides APIs for encrypting and decrypting passwords. We don't want to save user passwords as plain text in the database as this could lead to serious security issues.
passport
- Authentication library which provides a variety of methods known as strategies for implementing authentication such as local, jwt, and even supports authentication via a Social Identity Provider (IdP) like Google and GitHub.
jsonwebtoken
- We'll use it to generate and verify JSON Web Token (JWT) that will be used for authenticating users.
passport-jwt
- Enable us to implement a passport jwtStrategy
.
passport-local
- Enable us to implement a passport local Strategy
.
dotenv
- A library for getting environment variables from a .env
file.
nodemon
- Auto refreshes the server so that we don't have to manually restart it whenever there is a change in any file. We're going to install nodemon
as a Dev Dependency.
Enter the command below to install normal dependencies in one go:
npm install express mongoose bycrpt passport jsonwebtoken passport-jwt passport-local dotenv
Next, install nodemon as a devDependency like this, npm install nodemon
As an aside, since you are likely going to be using nodemon
in other projects it is a common practice to install it globally as done below.
npm install nodemon -g
After setting up our project environment and installing the all dependencies we are ready to start writing the codes for our Blog API.
The first thing we are going to do is to create a server using express
.
Create Server
Create a new file app.js
in the root directory. The file app.js would serve as the entry point to our API, it is the file that gets executed when we start the server.
Let's proceed by writing the following code in the app.js file.
//app.js
const express = require('express');
//Create server
const app = express();
app.use(express.json()); //Parse payload
app.use(express.urlencoded({extended: false}));
//Home Route
app.get('/', (req, res) => {
return res.status(200).send("Hello, welcome to Blog API. This API allow users to create or read articles created by others. Please go to /api/articles to get a list of published articles or checkout the README.md file in the GitHub Repository to learn more about how it works and how to run or test it. Thank you!");
});
//Error Handler
app.use((error, req, res, next) => {
if (error) {
return res.status(500).json({ message: "Internal Server Error." });
}
next();
});
//Catch-all route
app.get('*', (req, res) => {
return res.status(404).json({ message: "Not Found"});
});
module.exports = app;
We imported express
and created an express
object. The express object is stored in a variable app
. Next, we added the following middleware express.json
and express.urlencoded
to extract and properly format data contained in the request body. Then we created the home route /
. This will be the default route of our API.
Next, we added an error handler middleware and a catch-all route. A catch-all route is used to prevent errors in cases where a request has a route that is not defined in the API.
Finally, we exported the app
variable.
Database Connection
In the root directory create a new folder src
. In src
create a file db.js
. The file db.js
is going to contain codes for connecting to the database.
/* /src/db.js */
const mongoose = require('mongoose');
require('dotenv').config();
class Database {
constructor() {
this._connect();
}
_connect() {
mongoose.connect(process.env.DATABASE_URI, {useNewUrlParser: true, useUnifiedTopology: true})
.then(()=> {
console.log('Connected to database successfully.');
})
.catch(() => {
console.log('Database connection failure.');
})
}
}
module.exports = new Database();
We imported mongoose
for connecting to the database and dotenv
to get the DATABASE_URL
from .env
file.
The major feature of this file is a class Database
which contains a method _connect
that runs when an instance of the Database
is created. Within _connect
call we mongoose.connect
and passed in DATABASE_URL
. The result is a promise and like any promise, it has a .then
method that runs if there are no errors and a .catch
method that runs otherwise.
Finally, we create an instance of Database
and exported it.
Models
Previously, we installed mongoose
as a Dev Dependency and I explained that mongoose is an ODM for MongoDB. We are going to use mongoose
to create our database schemas. You can imagine a schema as a blueprint that can be used create a certain type of record. A model in this context is an instance of a record created using the schema that we defined.
We going to create two schemas, userSchema
, and articleSchema
.
In src create a new folder, models
. The create two new files, user.model.js and article.model.js in models
.
User Schema
/* src/models/user.model.js */
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const Schema = mongoose.Schema;
const userSchema = new Schema({
//Mandatory fields
firstName: {
type: String,
required: true
},
lastName: {
type: String,
required: true
},
email: {
type: String,
lowercase: true,
required: true,
unique: true
},
password: {
type: String,
required: true,
},
createdAt: {
type: Date,
default: Date.now()
}
}
);
//Hash password before saving to database.
userSchema.pre('save', async function(next) {
const hash = await bcrypt.hash(this.password, 10);
this.password = hash;
next();
});
//Validate that user password stored in the DB matches that which is provided during signin process.
userSchema.methods.validatePassword = async function(password) {
const result = await bcrypt.compare(password, this.password);
return result;
}
module.exports = mongoose.model("users", userSchema);
We imported mongoose
and bcrypt
. Next, we created a Schema
object with mongoose
. Then we defined the fields for a user record as an object and the object into mongoose.Schema
.
The next part is a pre-save hook, in which we called bcrypt.hash
, a function that can convert a plain text password to a hashed or encrypted string before saving a user record to the database.
We also defined an instance method userSchema.methods.validatePassword
, which calls bcrypt.compare
, a function that checks if a given plain text password matches the hashed password in a user record coming from the database.
Finally, we created a "user model", mongoose.model("users", userSchema)
and exported it.
Article Schema
/* src/models/src/article.model.js */
const { Schema, model } = require('mongoose');
const articleSchema = new Schema(
{
title: {
type: String,
required: true
},
description: String,
author: String,
state: {
type: String,
enum: ["draft", "published"],
default: "draft",
},
body: {
type: String,
required: true
},
readCount: {
type: Number,
default: 0
},
readingTime: Number,
tags: {
type: Array,
lowercase: true
},
timestamp: {
type: Date,
default: Date.now()
}
}
);
//Calculate article readingTime
articleSchema.methods.calculateReadingTime = function(content) {
const wordCount = this.body.split(' ').length;
const readingTime = wordCount / 250; //250 words per minute is average reading speed
const readingTimeMinutes = Math.ceil(readingTime);
return readingTimeMinutes;
};
module.exports = model("articles", articleSchema);
We started by importing Schema
and model
from mongoose
by de-structuring. Next, we created aticlesSchema
with Schema
. Then we defined the fields for an article record as an object and we passed the object to Schema
.
We also defined an instance method, articleSchema.methods.calculateReadingTime
, which can calculate the reading time of an article based on the length of the article's content.
Lastly, we created an article model, model("articles", articleSchema) and exported it.
Routes
An HTTP request typically embeds certain information in its headers. One of those information is the intended destination of the request in the server. Routing can be defined as the process of determining the intended destination of a request sent to a server.
Express comes with a Router
method that makes it easy to create routes in our server and APIs.
In this project, we'll define two routes /auth
and /articles
. As the names suggest /auth
will handle any request that pertains to user authentication while /articles
will handle any request that is about the management of articles.
Before going ahead to create our routes, we need to create a set of authentication middleware. In the routes that we'll create later, specific authentication middleware will perform a specific actions.
Authentication Middleware
A middleware is a function that gets executed between a request and a response. For this project, we are going to create three authentication middlewares. The first middleware is responsible for extracting JWT tokens from the request headers. The JWT token will be used to authenticate users when they try to access certain protected routes. The remaining two middlewares handle user registration and login respectively.
In the src folder create a new folder and call it middlewares
. In middlewares, create a new file and call it auth.middleware.js. Write the following code in the auth.middleware.js file:
/* src/middlewares/auth.middleware */
const passport = require('passport');
const jwtStrategy = require('passport-jwt').Strategy;
const ExtractJWT = require('passport-jwt').ExtractJwt;
const localStrategy = require('passport-local');
const UserModel = require('../models/user.model');
require('dotenv').config();
//Retrieve and varify JWT token
passport.use(
new jwtStrategy(
{
secretOrKey: process.env.JWT_SECRET,
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken()
},
async(token, done) => {
try {
const user = await token.user;
return done(null, user);
} catch(error) {
done(error);
}
}
)
);
//Passport local strategy signup
passport.use('signup',
new localStrategy(
{
usernameField: 'email',
passwordField: 'password',
passReqToCallback: true
},
async(req, email, password, done) => {
try {
//First, check whether user with the email provided already exists.
const oldUser = await UserModel.findOne({ email });
if (oldUser) {
return done(null, false, { message: "A user with this email already exists."});
} else {
//If the email does not exist, signup user.
const body = req.body;
let user = await UserModel.create({ firstName: body.firstName, lastName: body.lastName, email, password });
user = user.toObject();
delete user.password;
return done(null, user, { message: "Signup was successful."});
}
} catch(error) {
done(error);
}
}
)
);
//Passport local strategy signin
passport.use('signin',
new localStrategy(
{
usernameField: 'email',
passwordField: 'password'
},
async(email, password, done) => {
try {
const user = await UserModel.findOne({ email });
if (!user) {
return done(null, false, { message: "User not found." });
}
const isValidPassword = await user.validatePassword(password);
if (!isValidPassword) {
return done(null, false, {message: "Password is incorrect." });
}
return done(null, user, { message: `Welcome back ${user.firstName}.`});
} catch(error) {
done(error);
}
}
)
);
We imported passport
, passport-jwt
to create a jwt strategy, passport-jwt
again to extract JWT tokens from the headers, passport-local to create a local strategy, user.model
and dotenv
.
Retrieve and Verify Token
This middleware implements a jwtStrategy
with passport. The jwtStrategy
strategy takes two arguments; an object and a callback.
In the object, we defined the following properties; secretOrKey
which is set to the JWT_SECRET
that is stored in .env
file, jwtFromRequest
which specifies where to get the JWT token from.
The callback takes two parameters, token
and done
. Here, token
represents the decrypted JWT token extracted from the request headers while done
is a function which operates similarly as next
function. When the callback is executed user
object is extracted from token
and passed into done
as a second argument after null
. Any error is caught and passed into done
.
Signup Middleware
The middleware "signup"
, uses passport localStrategy
to add a new user to the database. We are going to add it to /signin
route later. We passed two arguments, an object and a callback into localStrategy
.
The object argument has the following properties; usernameField
equal to "email"
, passwordField
equal to "password"
and passReqToCallback
equal to true. By default, only usernameField
and passwordField
fields are passed to the callback but the inclusion of passReqToCallback
will ensure that the req
object is passed as well so that other fields are also accessible inside the callback.
The callback takes four parameters; req
, email
, password
and done
. If a user with same email already exists in the database, send back a message explaining that the given email has already been registered. Otherwise, get user data
from req
. Then create a new user in the database by passing user data
to userModel.create
. Finally, return user data as an object.
Signin Middleware
The "signin" middleware also uses passport localStrategy
. However, it does not create a new user but rather helps in checking the validity of a user's credentials when they want to sign-in. When we create /singin
route later we're going to add "signin"
as a middleware.
The localStrategy
object takes two arguments; an object and a callback.
The object argument has usernameField
equal to "email" and "passwordField"
equal to "password"
.
We passed in three parameter, email
, password
and done
to the callback. We start by searching the database for the user whose email matches what was provided in the request. If a match was found based on email we then proceed to compare the password property of the match and what was provided in the request.
We defined validatePassword
as an instance method in the user schema. If password comparison is successful call done and pass in null, the matched user and a message. Otherwise, call done and pass in null, false and a message.
After we finish writing the code for the various authentication middleware, we are set to start creating the routes.
In the src folder create a new folder and call it routes. In routes create the following files auth.route.js and articles.route.js.
Starting with /auth route, we are going create two endpoints; "/auth/signup" and "/auth/signin". Open auth.route.js
file.
Import modules
const authRoute = require('express').Router();
const jwt = require('jsonwebtoken');
const passport = require('passport');
require('dotenv').config();
We imported the following; express
so that we can create a Router with the Router
method, jsonwebtoken
for creating JWT token, passport
and dotenv
.
/signup
//Signup route
authRoute.post('/signup', async(req, res) => {
passport.authenticate('signup', {session: false}, async(err, user, info) => {
if (!user) {
return res.status(400).json(info)
} else return res.status(201).json({info, user });
})(req, res)
});
This route has a POST method. The request handler function calls passport.authenticate
which takes three arguments. The first argument is "signup" (references signup middleware that was defined in auth.middleware.js
). The second is an option that prevents the signup process from saving a session, and lastly a callback.
When a request comes to /signup route the "signup" middleware is executed, it returns three values err, user and info which are accessible inside the callback.
The next steps are completed inside the callback. If user is invalid return info with a 400 status code. Otherwise, return user and info with a 201 status code.
/signin
//Signin route
authRoute.post('/signin', async(req, res, next) => {
passport.authenticate('signin', async(err, user, info) => {
try {
if (!user) {
return res.status(400).json(info);
}
req.login(
user,
{session: false},
async(error) => {
if (error) {
next(error);
}
const body = { _id: user._id, email: user.email };
const token = jwt.sign({ user: body }, process.env.JWT_SECRET, { expiresIn: '1hr'});
return res.json({ info, token });
}
)
} catch(error) {
next(error);
}
})(req, res, next);
})
Just like "/signup", the "/signin" route has a POST method. The request handler calls passport.authenticate
with two arguments, namely "signin" (references "signup" middleware) and a callback.
When a request comes to "/signin" route, "signin" middleware is executed and the following values are returned; err, user and info which are then accessible inside the callback.
Now, let's examine the callback in more details. First, check whether user is invalid and return info with a status of 400 if so. If user is valid call req.login
with the arguments as shown in the example above. The login method is automatically added to the req object by passport.
One of the arguments taken by login is another callback. Inside that callback is where we're going to create a JWT token. The JWT token is created by extracting certain fields from the user object. Usually any number of fields can be extracted but sensitive ones like the password field should be excluded.
In this example, I'm going to use the id and email fields. We need to create a user object with those fields. The user object can be used to create a JWT token. This is achieved by passing user object as an argument to jwt.sign
along with our JWT_SECRET and a third argument that sets the expiration period of the token. I've set the token to expire in 1hr but you can use a different value. Lastly, we return an object that contains the signed JWT token.
Finally, export the router.
The content of /src/routes/auth.route.js should now look similar to the code below:
/* src/routes/auth.route.js */
const authRoute = require('express').Router();
const jwt = require('jsonwebtoken');
const passport = require('passport');
require('dotenv').config();
//Signup route
authRoute.post('/signup', async(req, res) => {
passport.authenticate('signup', {session: false}, async(err, user, info) => {
if (!user) {
return res.status(400).json(info)
} else return res.status(201).json({info, user });
})(req, res)
});
//Signin route
authRoute.post('/signin', async(req, res, next) => {
passport.authenticate('signin', async(err, user, info) => {
try {
if (!user) {
return res.status(400).json(info);
}
req.login(
user,
{session: false},
async(error) => {
if (error) {
next(error);
}
const body = { _id: user._id, email: user.email };
const token = jwt.sign({ user: body }, process.env.JWT_SECRET, { expiresIn: '1hr'});
return res.json({ info, token });
}
)
} catch(error) {
next(error);
}
})(req, res, next);
})
module.exports = authRoute;
Articles Routes
Create a new file articles.route.js
inside the routes
folder. We are going to create our /articles routes in this folder. Some of the endpoints for /articles
routes would be protected and accessible to only signed-in users while others would be accessible to anyone.
/* src/routes/articles.route.js */
const router = require('express').Router();
const articlesController = require('../controllers/articles.controller');
const passport = require('passport');
router.get('/', async(req, res, next) => {
try {
await articlesController.getPublishedArticles(req, res);
} catch(error) {
next(error);
}
});
router.get('/read/:id', async(req, res, next) => {
try {
await articlesController.getPublishedArticleById(req, res);
} catch(error) {
next(error);
}
})
//PROTECTED ROUTES (User must be authenticated)
router.use(passport.authenticate('jwt', { session: false }));
//Get list of own articles (draft and published)
router.get('/my-articles', async(req, res, next) => {
try {
await articlesController.getOwnArticles(req, res);
} catch(error) {
next(error);
}
})
//Create an article
router.post('/create', async(req, res, next) => {
try {
await articlesController.createArticle(req, res);
} catch(error) {
next(error);
}
});
//Update own article state
router.patch('/publish/:id', async(req, res, next) => {
try {
await articlesController.publishOwnArticle(req, res)
} catch(error) {
next(error);
}
})
//Edit own article
router.patch('/edit/:id', async(req, res, next) => {
try {
await articlesController.editOwnArticle(req, res);
} catch(error) {
next(error);
}
});
We imported the following; express
and called the Router
method to create a Router, articlesController
and passport
for authenticating users when they try to access a protected endpoint.
A controller is basically a file where we can define the request handlers for our routes.
Putting the handlers in a separate file helps us to better organize our routes.
The following endpoints are defined for the /articles route.
Unprotected Endpoints
/
route: It has a GET
method, calls getPublishedArticles
controller.
/read/:id
- It has GET
method, calls getPublishedArticleById
controller.
Protected Endpoints
Preceding these set of endpoints is a middleware passport.authenticate
. It verifies the JWT token and ensures that only logged in users are able to access the endpoints immediately below it.
/my-articles
- It has a GET
method, calls getOwnArticles
controller.
/create
- Has POST
method, calls createArticle
controller.
/publish/:id
- Has a PATCH
method, calls publishOwnArticle
controller.
/edit/:id
- Has a PATCH
method and calls editOwnArticle
controller.
/delete/:id
- It has a DELETE
method and calls deleteOwnArticle
controller.
At the end of the file we exported our route.
Controllers
Create a new folder controllers
inside the src
folder. Then create a new file articles.controller.js
inside controllers
folder.
/* src/controllers/articles.controller.js */
const articlesService = require('../services/articles.service');
//Optional query parameters:
//Filtering (author, title and tags)
//Ordering (readCount, readingTime and timestamp)
const getPublishedArticles = async(req, res) => {
let articles;
const query = req.query;
if (query) {
articles = await articlesService.getPublishedArticles(query);
} else {
articles = await articlesService.getPublishedArticles(null);
}
return res.status(200).json(articles);
}
const getPublishedArticleById = async(req, res) => {
const id = req.params.id;
const articleAndAuthor = await articlesService.getPublishedArticleById(id);
return res.status(200).json(articleAndAuthor);
}
const createArticle = async(req, res) => {
const body = req.body;
const user = req.user.email; //Email of signed in user derived from decrypted jwt token
const article = await articlesService.createArticle(body, user);
return res.status(201).json({message: "New draft article created.", article });
}
const publishOwnArticle = async(req, res) => {
const id = req.params.id;
const user = req.user.email;
const article = await articlesService.publishOwnArticle(id, user);
if (article) {
return res.status(200).json({message: "Congratulations, your article was successfully published.", article })
}
}
const editOwnArticle = async(req, res) => {
const id = req.params.id;
const body = req.body;
const user = req.user.email;
const article = await articlesService.editOwnArticle(id, body, user);
if (article) {
return res.status(200).json({message: "Your changes have been saved.", article })
} else {
return res.status(401).json({ message: "You don't have the permission to edit this article."})
}
}
const deleteOwnArticle = async(req, res) => {
const id = req.params.id;
const user = req.user.email;
const article = await articlesService.deleteOwnArticle(id, user);
if (article) {
return res.status(200).json({ message: "Deleted one (1) article."})
} else {
return res.status(401).json({message: "You don't have the permission to delete this article."})
}
}
const getOwnArticles = async(req, res) => {
const user = req.user.email;
let state;
let articles
if (req.query) {
state = req.query.state;
articles = await articlesService.getOwnArticles(user, state);
} else {
articles = await articlesService.getOwnArticles(user, null);
}
if (articles) {
return res.status(200).json(articles);
} else {
return res.status(401).json({message: "Please signin to see a list of your articles."})
}
}
module.exports = {
getPublishedArticles,
getPublishedArticleById,
createArticle,
publishOwnArticle,
editOwnArticle,
deleteOwnArticle,
getOwnArticles,
}
First, We imported articlesService
which contain functions that defines the logic for manipulating the database.
Articles Controllers
Each controller function calls a corresponding service function.
At the end of the file we exported the controller functions.
Services
As mentioned above, the service module is where we're going to define a group of functions that will contain all the logic for manipulating the database.
Create a new folder services
inside the src
folder. Then create a new file articles.service.js
inside services
.
Importing modules
const ArticleModel = require('../models/article.model');
const UserModel = require('../models/user.model');
We imported the following modules; ArticleModel
and UserModel
.
Get published Articles
const getPublishedArticles = async(query) => {
let articles;
if (query) {
//
const searchBy = {};
const orderBy = {};
searchBy.state = "published" //Ensure that only published articles are returned
//Filter parameters (author, title and tags)
if (query.author) {
searchBy.author = query.author;
}
if (query.title) {
searchBy.title = query.title;
}
if (query.tags) {
let tags = query.tags.split(',');
//Ensure that all the tags are in lowercase to match with structure of saved documents
tags = tags.map((tag) => {
return tag.toLowerCase();
});
searchBy.tags = {$in: tags};
}
//Order parameters (readCount, readingTime and timestamp)
if (query.readCount) {
if (query.readCount == 'asc') {
orderBy.readCount = 1;
} else if (query.readCount == 'desc') {
orderBy.readCount = -1;
}
}
if (query.readingTime) {
if (query.readingTime == 'asc') {
orderBy.readingTime = 1;
} else if (query.readingTime == 'desc') {
orderBy.readingTime = -1;
}
}
if (query.timestamp) {
if (query.timestamp == 'asc') {
orderBy.timestamp = 1;
} else if (query.timestamp == 'desc') {
orderBy.timestamp = -1;
}
}
articles = await ArticleModel.find(searchBy)
.where("state").equals("published")
.limit(20)
.sort(orderBy);
} else {
articles = await ArticleModel.find({ state: "published" })
.limit(20);
}
return articles;
};
In the above function, we will perform the following actions;
Search the database for articles with a state
property of published
. Then we will filter the result by (author, title and tags). Next, we're going to sort the result in ascending or descending order by (readCount, readingTime and timestamp). Finally, we will limit the number of articles to be returned to 20 and return the result.
Get Published Article by ID
const getPublishedArticleById = async(id) => {
let article = await ArticleModel.findById(id);
if (article && article.state == 'published') { //Confirm that the article is published
const readCount = article.readCount + 1; //increment article read count by 1
article = await ArticleModel.findByIdAndUpdate(id, {readCount}, ({new: true})); //Update article readCount and return the updated article
//Return author information with the article
const authorEmail = article.author;
let author = await UserModel.findOne({ email: authorEmail });
author = author.toObject();
delete author.password; //Delete password from author information
return {author, article};
}
}
As the name implies, the function above searches for a published article by its Id. If an the article is found, the value of the article's readCount
property is incremented by 1. Then the article along with it's author are returned as objects.
Create Article
const createArticle = async(body, userId) => {
const article = new ArticleModel(body);
article.author = userId;
const readingTime = article.calculateReadingTime(body.body); //calculateReadingTime is a helper method in the articleModel
article.readingTime = readingTime;
article.save();
return article;
}
This function uses the data from the request body to create a new article. It first initialize an article object, then it calculates the article's reading reading time before saving the new article to the database. Remember that calculateReadtingTime
was defined as an instance method for the articles schema.
Publish an Article
const publishOwnArticle = async(id, user) => {
const article = await ArticleModel.findById(id);
if (article.author == user) { //Ensure that the article belongs to the logged in user
const article = await ArticleModel.findByIdAndUpdate(id, {state: "published"}, {new: true});
if (!article) {
return false;
}
return article;
}
}
This function is responsible for changing the state of an article from draft (default) to published. It finds the article by the given Id, change the state and returns the updated article.
Edit an Article
//Editable properties are title, description, body and tags
const editOwnArticle = async(id, body, user) => {
const article = await ArticleModel.findById(id);
if (article.author == user) { //Ensure that the article belongs to the logged in user
let article = await ArticleModel.findByIdAndUpdate(id, body, {new: true});
const readingTime = await article.calculateReadingTime(article.body); //Update readingTime
article.readingTime = readingTime;
article.save();
if (!article) {
return false;
}
return article;
} else {
return false;
}
}
The function above can update multiple fields in an article, in a single request. The article is gotten by its Id property, its reading time is re-calculated to account for any change in length of the article body. The updated article is then resaved and also returned as an object.
Delete an Article
const deleteOwnArticle = async(id, user) => {
let article = await ArticleModel.findById(id);
if (article.author == user) { //Ensure that the article belongs to the logged in user
await ArticleModel.findByIdAndDelete(id); //Delete the article
//Confirm that it was deleted successfully
article = await ArticleModel.findById(id);
if (!article) {
return true; //Assert that the article has been deleted
}
} else {
return false; //If the user is unauthorized
}
}
Delete an Article
const deleteOwnArticle = async(id, user) => {
let article = await ArticleModel.findById(id);
if (article.author == user) { //Ensure that the article belongs to the logged in user
await ArticleModel.findByIdAndDelete(id); //Delete the article
//Confirm that it was deleted successfully
article = await ArticleModel.findById(id);
if (!article) {
return true; //Assert that the article has been deleted
}
} else {
return false; //If the user is unauthorized
}
}
As the name implies the above function gets an article by its ID and delete it permanently from the database.
Get Articles owned by Logged-in User
const getOwnArticles = async(user, state) => {
if (user) { //Ensure that the user is signed in
const query = {};
query.author = user;
if (state) {
query.state = state;
}
const articles = await ArticleModel.find(query)
.limit(20);
return articles;
} else return false;
}
This function gets only articles created by the user that is currently logged-in. The list of articles can be filtered by state and the final result is limited to 20 articles per page.
At the end of the file we exported all "services" functions in article.service.js
.
Below is what articles.service.js
would look like:
/* src/services/articles.service.js */
const ArticleModel = require('../models/article.model');
const UserModel = require('../models/user.model');
const getPublishedArticles = async(query) => {
let articles;
if (query) {
//
const searchBy = {};
const orderBy = {};
searchBy.state = "published" //Ensure that only published articles are returned
//Filter parameters (state, title and tags)
if (query.author) {
searchBy.author = query.author;
}
if (query.title) {
searchBy.title = query.title;
}
if (query.tags) {
let tags = query.tags.split(',');
//Ensure that all the tags are in lowercase to match with structure of saved documents
tags = tags.map((tag) => {
return tag.toLowerCase();
});
searchBy.tags = {$in: tags};
}
//Order parameters (readCount, readingTime and timestamp)
if (query.readCount) {
if (query.readCount == 'asc') {
orderBy.readCount = 1;
} else if (query.readCount == 'desc') {
orderBy.readCount = -1;
}
}
if (query.readingTime) {
if (query.readingTime == 'asc') {
orderBy.readingTime = 1;
} else if (query.readingTime == 'desc') {
orderBy.readingTime = -1;
}
}
if (query.timestamp) {
if (query.timestamp == 'asc') {
orderBy.timestamp = 1;
} else if (query.timestamp == 'desc') {
orderBy.timestamp = -1;
}
}
articles = await ArticleModel.find(searchBy)
.where("state").equals("published")
.limit(20)
.sort(orderBy);
} else {
articles = await ArticleModel.find({ state: "published" })
.limit(20);
}
return articles;
};
const getPublishedArticleById = async(id) => {
let article = await ArticleModel.findById(id);
if (article && article.state == 'published') { //Confirm that the article is published
const readCount = article.readCount + 1; //increment article read count by 1
article = await ArticleModel.findByIdAndUpdate(id, {readCount}, ({new: true})); //Update article readCount and return the updated article
//Return author information with the article
const authorEmail = article.author;
let author = await UserModel.findOne({ email: authorEmail });
author = author.toObject();
delete author.password; //Delete password from author information
return {author, article};
}
}
const createArticle = async(body, userId) => {
const article = new ArticleModel(body);
article.author = userId;
const readingTime = article.calculateReadingTime(body.body); //calculateReadingTime is a helper method in the articleModel
article.readingTime = readingTime;
article.save();
return article;
}
const publishOwnArticle = async(id, user) => {
const article = await ArticleModel.findById(id);
if (article.author == user) { //Ensure that the article belongs to the logged in user
const article = await ArticleModel.findByIdAndUpdate(id, {state: "published"}, {new: true});
if (!article) {
return false;
}
return article;
}
}
//Editable properties are title, description, body and tags
const editOwnArticle = async(id, body, user) => {
const article = await ArticleModel.findById(id);
if (article.author == user) { //Ensure that the article belongs to the logged in user
let article = await ArticleModel.findByIdAndUpdate(id, body, {new: true});
const readingTime = await article.calculateReadingTime(article.body); //Update readingTime
article.readingTime = readingTime;
article.save();
if (!article) {
return false;
}
return article;
} else {
return false;
}
}
const deleteOwnArticle = async(id, user) => {
let article = await ArticleModel.findById(id);
if (article.author == user) { //Ensure that the article belongs to the logged in user
await ArticleModel.findByIdAndDelete(id); //Delete the article
//Confirm that it was deleted successfully
article = await ArticleModel.findById(id);
if (!article) {
return true; //Assert that the article has been deleted
}
} else {
return false; //If the user is unauthorized
}
}
const getOwnArticles = async(user, state) => {
if (user) { //Ensure that the user is signed in
const query = {};
query.author = user;
if (state) {
query.state = state;
}
const articles = await ArticleModel.find(query)
.limit(20);
return articles;
} else return false;
}
module.exports = {
getPublishedArticles,
getPublishedArticleById,
createArticle,
publishOwnArticle,
editOwnArticle,
deleteOwnArticle,
getOwnArticles,
}
Bringing Everything together
So far, we have created all the various parts of our API, like server, database connection, models and routes. What we need to do next is to connect all the various parts together to work as one program.
Completing the code for app.js
/* app.js */
const express = require('express');
const authRoute = require('./src/routes/auth.route');
const articlesRoute = require('./src/routes/articles.route');
require('./src/middlewares/auth.middleware');
//Create server
const app = express();
app.use(express.json()); //Parse payload
//Register routes middlewares to the app
app.use('/api', authRoute);
app.use('/api/articles', articlesRoute); //Home route redirects here
//The home route
app.use('/', (req, res) => {
return res.status(200).send("Hello, welcome to Blog API. This API allow users to create or read articles created by others. Please Go to /api/articles to get a list of published articles or checkout the `README.md` file in the GitHub Repository to learn more about how it works and how to run or test it. Thank you!")
})
//Error Middleware
app.use((error, req, res, next) => {
if (error) {
return res.status(500).json({ message: "Internal Server Error." });
}
next();
});
//Catch-all route
app.get('*', (req, res) => {
return res.status(404).json({ message: "Not Found"});
});
module.exports = app;
We've made some changes to app.js
. At the top, we imported auth.route.js
, article.route.js
and auth.middleware.js
.
We used auth.route.js and article.route.js to create two middleware, app.use('/api', authRoute) and app.user('/api/articles', articleRoute). These middleware are responsible for handling requests pertaining to user authentication and article management respectively.
The program entry-point
Create a new file index.js
in the root directory. The is the file that gets executed when the program start.
const app = require('./app');
require('dotenv').config();
require('./src/db'); //Connect to production database
const PORT = process.env.PORT;
//Start the server
app.listen(PORT, ()=> {console.log(`Server is running on port: ${PORT}`)});
We imported app.js
, dotenv
and db.js, which runs the database connection codes. Then we started the server by calling app.listen
, which takes two arguments, PORT
and a callback.
With these final steps, we are ready to run our program and start testing the different routes/endpoints in the Blog API.
Configure package.json
{
"name": "Blog_Api",
"version": "1.0.0",
"description": "This API has a general endpoint that shows a list of articles that have been created by different people, and anybody that calls this endpoint, should be able to read a blog created by them or other users.",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
},
"repository": {
"type": "git",
"url": ""
},
"author": "Sheriff Adekunle Tajudeen",
"license": "ISC",
"bugs": {
"url": ""
},
"homepage": "",
"dependencies": {
"bcrypt": "^5.1.0",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"jsonwebtoken": "^8.5.1",
"mongoose": "^6.7.0",
"passport": "^0.6.0",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0"
},
"devDependencies": {
"nodemon": "^2.0.20"
}
}
Environment Variable
Define the following variables in .env
:
PORT=8000
DATABASE_URI=Your database URL
JWT_SECRET=String for signing JWT Tokens
Remember that it is absolutely important that you keep your environment variables secret and never share them on any public platform.
Running the Program
Enter any of these commands in the terminal:
npm run start
npm run dev
If everything is fine up to this point your server should start running. On the console, copy the server URL. Paste and the URL in
a browser and press enter. That should send a request to the home or /
route.
If you want to test more endpoints, here is the documentation for the project that inspired this tutorial. It was built as exam project for the second semester at AltSchool Africa.
Conclusion
We have reached the end of this tutorial. I hope you were able to follow along up to this point. If you enjoyed the tutorial and found it useful, please like and share or leave a comment. I would also love to read any feedback you would like to share to help improve the content.
Thank you for reading.
Top comments (0)