Building a REST Blog API with Node, Express and MongoDB

In this tutorial, we will build a Blogging API that allows users to create, read, update, and delete blog posts, as well as add comments and organize their posts with categories and tags. We will use Express, a popular web framework for Node.js, and MongoDB as our database. We will also include features such as authentication and authorization using JSON Web Tokens (JWTs) and search capabilities.

Prerequisites

Before starting this tutorial, you should have the following

  • Node.js installed on your computer

  • A basic understanding of JavaScript and Node.js

  • A MongoDB database set up and running on MongoDB Atlas

Setting up the project

First, create a new directory for your project and navigate into it:

mkdir blogging-api
cd blogging-api

Next, initialize a new Node.js project by running the following command:

npm init -y

This will create a package.json file in your project directory.

Next, install the necessary dependencies by running the following commands:

npm install express body-parser mongoose dotenv

Now we can start coding our API logic.

Initializing the app

In the project directory, create an index.js file. First, import the Express and Mongoose packages. Next, use Express to create a port variable that will run on port 9000.

const express = require('express');
const bodyParser = require('body-parser')

const app = express();
app.use(express.json())
app.use(bodyParser.urlencoded({extended:false}))

const PORT = 9000;

app.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`)
});

When you run node index.js in the terminal, "Server is running on port 9000" will be printed out and this means our basic app works.

Connecting to the database

We will be connecting our API to our MongoDB database and we need to store our in a secure place and we can do this is in a .env file.
Create a new file called .env and add your MongoDB connection URI from MongoDB Atlas. Make sure to replace <username> and <password> with your own MongoDB Atlas credentials and it should look like this:

MONGO_DB_CONNECTION_URI=mongodb+srv://<username>:<password>restofurl.mongodb.net/Blog?

Note: If you're uploading your code to GitHub, don't forget to add your .env file to your gitignore file.

Next, we need to connect to our MongoDB database. We would create a function that connects to the database url stored in the .env file. Create a new directory called config in your main project directory. Then, create a new file called db.js in the newly created directory and add the following code:

const mongoose = require('mongoose');
require('dotenv').config()

const MONGO_DB_CONNECTION_URI = process.env.MONGO_DB

function connectToMongoDB(){
    mongoose.connect(MONGO_DB_CONNECTION_URI);

    mongoose.connection.on("connected",()=>{
        console.log("Connection to MongoDB is successful")
    })

    mongoose.connection.on("error",(err)=>{
        console.log(err)
        console.log("An error occured.")
    })
}

module.exports={connectToMongoDB}

First, we import mongoose which allows us to easily create and manage data in MongoDB. Then we connect to the DB, with mongoose.connect. If the connection is successful, it prints "Connection to MongoDB is successful." else, we log the error and print "An error occurred."
Next, we export the function so we can call it in our main file. Then we edit our index.js file to add the connecting function.

const express = require('express');
const app = express();
const PORT = 9000;
//import the connectToMongoDB function
const {connectToMongoDB} = require('./config/db')
//call the function to connect to the DB
connectToMongoDB()

app.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`)
});

Now, if you run node index.js in the terminal, "Connection to MongoDB is successful" would be logged to the console.

Creating the User Endpoints

Setting up the User Schema

Now that we are connected to the database, we can define the schema for our users. In MongoDB, a schema defines the structure of the documents in a collection.

Create a new directory called models where we would store all the schemas. Then create a new file and name it user.model.js and add the following code:

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const UserSchema = new Schema({
    first_name:{
        type: String,
        required:true
    },
    last_name:{
        type: String,
        required:true
    },
    email: {
        type: String,
        required: true,
        unique: true
    },
    password: {
        type: String,
        required: true
    }
},
{collection:'User'}
);

const UserModel = mongoose.model('User', UserSchema);

module.exports = UserModel;

This schema defines a User model with first_name, last_name, username and password as required fields. The data type is also specified. {collection:'User'} shows the data created with this schema should be saved to the User collection.

Hashing the User's Password

Hashing and encryption both provide ways to keep sensitive data such as user details safe and we can do this using the bcrypt package. First, install the package using:

npm install bcrypt

Then, import it into your schema file with:

const bcrypt = require('bcrypt');

Next, we want the password to be hashed before saving a new user's details to the database so we add the logic to the User Schema right after defining the schema.

UserSchema.pre(
    'save',
    async function (next) {
        const user = this;

        //to stop unnecessary password rehashing
        if (!user.isModified('password')){return next()}

        const hash = await bcrypt.hash(this.password, 10);

        this.password = hash;
        next();
    }
);

In the above code, we create a function that is called before the data is saved. It hashes the password with bcrypt and replaced the password with the hash then calls the save function with next().

Comparing user's password during login

Because, we store the hashed password, we need a way to check if the password entered by the user during login is correct. We can do this with bcrypt by adding a method to the User Schema.

UserSchema.methods.isValidPassword = async function(password) {
    const user = this;
    const compare = await bcrypt.compare(password, user.password);
    return compare;
}

We pass in the entered password as a parameter, compare it to stored hash using bcrypt and return a compare variable which is a boolean, that is true if the password is correct and false, if it isn't.

Lastly, we export our schema in order to import it into the controller where we will add the login and signup logic.

const UserModel = mongoose.model('User', UserSchema);
module.exports = UserModel;

User SignUp

First, we need a middleware that will handle the authentication, so we will write one. Create a new directory called middlewares and create a new file, auth.js where we will store our authentication logic.

Next, we'll install passport and passport-local which would help us handle the authentication.

npm install passport passport-local

Next, we import passport into our newly created auth.js file. We'll also import localStrategy from passport-local.

const passport = require('passport');
const localStrategy = require('passport-local').Strategy;

Now, we add the logic for the signup functionality:

passport.use(
    'signup',

    new localStrategy(
        {
            usernameField: 'email',
            passwordField: 'password',
            passReqToCallback: true

        },

        async (req,email, password,done) => {
            try {
                const first_name = req.body.first_name
                const last_name = req.body.last_name
                const user = await UserModel.create({ email, password, first_name, last_name });

                return done(null, user);
            } catch (error) {
                done(error)
            }
        }
    )
);

Passport then creates a new user with the data from the request and returns an error, if an error occurs otherwise, it returns null and the newly created user.

Now, we need to create the user controller. Create a new directory called controllers where we would store all the schema controllers. Then create a new file and name it user.controller.js and add the following code:

const passport = require('passport');

async function userSignup(req,res,next){
    try{
        res.status(201).json({
            status: "true",
            message: 'Signup successful'
        })
    }catch(error){
        return res.status(400).json({
            status: "false",
            message: "Signup failed"
        })
    }

} 
module.exports = {
    userSignup
}

This is a simple function that accepts, request, response and next and returns a json response if there is no error. This is because we will attach our passport signup strategy to our routes.

Create a new directory called routes where we would store all the routes.Then create a new file and name it user.route.js and add the following code:

const passport = require('passport');
const express = require('express');
const userRouter = express.Router();

const userController = require('../controllers/user.controller');

userRouter.post('/signup',passport.authenticate('signup', { session: false }), userController.userSignup);

module.exports = userRouter

Here, we import passport and express and create a new userRouter with express. Next we import the user controller. Then we add a new post route to the router with the path, /signup . First, we pass the passport signup authentication so passport authenticates the request then the regular function in the controller is called.

Now, we add our user router to our index.js file.

//import passport
const passport = require('passport')

//import the authentication middleware
require('./middlewares/auth')

//import the user route
const userRoute = require('./routes/user.route')

//initialize passport when the app starts up
app.use(passport.initialize());

//define a path for the user route
app.use('/user', userRoute)

Now, if we pass the user signup data to localhost://9000/user/signup we'll get a response depending on the outcome of the authentication by passport.

User login

First, we need to define our login strategy with passport in our auth.js file.

passport.use(
    'login',
    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 validate = await user.isValidPassword(password);
                if (!validate) {
                    return done(null, false, { message: 'Username or password is incorrect' });
                }

                return done(null, user, { message: 'Logged in Successfully' });
            } catch (error) {
                return done(error);
            }
        }
    )
);

We receive the user's email and password from the request object. First, we check if the user exists, then we call the .isValidPassword function we defined in the schema to check if the password provided is correct. If the password is correct, we return null which means no error occurred, the user and a message.
If an error occurs, we return the error, false and an appropriate error message.

When our user logs in, we want to give them a jwt token so we install jsonwebtoken to help us handle that.

npm install jsonwebtoken

Next, we create a jwt secret in our .env file that we'll to generate a jwt token for the user. It can be a random string, for example:

JWT_SECRET=kfdjkhsgcfikjyhgfdfghjk/

Finally, we create our login function in our controller. We first call our login authentication, if there is an error we send the error message otherwise, we sign a token with the jwt secret from our .env file.

//import jsonwebtoken
const jwt = require('jsonwebtoken');
//import the .env file
require('dotenv').config();
async function userLogin(req,res, next){
    passport.authenticate('login',async(err,user,message) =>{
        try{
            if (err){
                return next(err);
            }
            if (!user){
                return res.status(401).send(message)
            }
            req.login(user, {session:false}, async (error) =>{
                    if (error) return next(error)

                    const body = {_id: user._id, email: user.email};

                    const token = jwt.sign({ user: body },  process.env.JWT_SECRET, {expiresIn: '1hr'});

                    return res.status(200).json({
                        "message": message.message,
                        "token": token
                    });

                }
            )
        }catch(error){
            return res.status(401).send({
                status: "false",
                message: "Login failed"
            })
        }
    })(req, res, next);

}

//add the login function to the exports
module.exports = {
    userSignup,
    userLogin
}

Lastly, we add the login to our user routes.

userRouter.post('/login', userController.userLogin);

Now, if we pass the user login data to localhost://9000/user/login we'll get a response depending on the outcome of the authentication by passport.

Creating the Blog Endpoints

Creating the blog schema

Create a new file in the models directory and name it blog.model.js and add the following code:

const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const Schema = mongoose.Schema;

const blogSchema = new Schema({
    title:{
        type: String,
        required: true,
        unique: true
    },
    description:{
        type: String,
        required: true,
    },
    body:{
        type: String,
        required: true
    },
    tags:{
        type: [String],
    },
    timestamp:{
        type: Date,
        default: Date.now()
    },
    state:{
        type: String, 
        enum: ['draft', 'published'],
        default: 'draft'
    },
//we link each created blog to it's author using the author's id from the User collection
    author: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User'
    }, 
    read_count:{
        type: Number,
        default: 0
    }
},
{collection:'Blog'}
)

const BlogModel = mongoose.model('Blog', blogSchema);

module.exports = BlogModel;

We added an author field where we add the author's id from the User collection. Now, we will add a blog field to the User schema.

blogs:[
        {
            type: mongoose.Schema.Types.ObjectId,
            ref: 'Blog'
        }
    ]

This adds an array of all the Ids of the blogs created by the user.

Creating a Blog

We want to limit the create blog route to only logged in users so we will create a protect function in the auth.js file to protect the route.

const protect = async (req, res, next) => {
    const authorization = req.get('authorization')
    let token 
    if (authorization && authorization.toLowerCase().startsWith('bearer')){
        token = authorization.substring(7)
    }

    if (!token){
        return res.status(401).json({error: 'Invalid or Missing Token'})
    }
    let decodedToken =  null
    try{
        // fix: expired token error showing for all types of errors
        decodedToken = jwt.verify(token, process.env.JWT_SECRET)
        const user = await UserModel.findById(decodedToken.user._id)
        req.user = user
        next()
    }catch(e){
        if (e instanceof TokenExpiredError){
            return res.status(401).json({
                status: "false",
                error: "Token expired. Please try to Log In again"
            })
        }else if (e instanceof JsonWebTokenError){
            return res.status(401).json({
                status: "false",
                error: "Invalid Token"
            })

        }else{
            console.log(e)
            return res.status(401).json({
                status: "false",
                error: "An error occured, please try again"
            })
        }
    }
}

module.exports={protect}

The function extracts the token from the request, verifies it and passes returns the user. If there is an error, it returns the appropriate error message.

Next, we create the create blog controller. Create a new file in the controllers directory and name it blog.controller.js and add the following code:

const passport = require('passport');
const jwt = require('jsonwebtoken');
require('dotenv').config();

//import the blog model
const Blog = require('../models/blog.model')


async function createBlog(req, res, next) {
    const content = req.body

    const blog = new Blog({
        title: content.title,
        description: content.description,
        body: content.body,
        tags: content.tags,
        author: req.user._id,
    })
    try {
        const savedBlog = await blog.save()
//adding the new blog id to the user object
        req.user.blogs = req.user.blogs.concat(savedBlog._id)
        await req.user.save()
        res.status(201).json({
            message: "Blog saved successfully",
            savedBlog
        })
    } catch {
        res.status(400).json({
            "state": "false",
            "error": "Blog Titles must be unique"
        })
    }


}
//export the function
module.exports = {
    createBlog
}

We get the blog data from the request object and create a new blog. Then, we add the blog id to the blog array in the user object.

Next, we create our blog route.Create a new file in the routess directory and name it blog.route.js and add the following code:

const express = require('express');
const blogRouter = express.Router()

const auth =  require('../middlewares/auth')
const blogController = require('../controllers/blog.controller')

blogRouter.post('/', auth.protect,blogController.createBlog)


module.exports = blogRouter

We create the post route and add the protect function before the controller to ensure the jwt token is checked and verified before a blog can be created.

Then, we add our blog route to the app:

//import the route
const blogRoute = require('./routes/blog.route')
//set our app to use the route
app.use('/blog', blogRoute)

Getting all blogs

We want our users to only be able to get published blogs and we want them to be able to add filters, so we add the following code to our blog controller:

async function getBlogs(req, res, next) {
    try {
        const { page, sortBy, search, orderBy = 'asc' } = req.query
        orderBy === "desc" ? orderIndex = -1 : orderIndex = 1
        const limit = 20
        const skip = (page - 1) * limit

        if (sortBy == 'timestamp') {
            const blog = await Blog
            .find({ state: 'published' }).limit(limit).skip(skip).sort({ createdAt: orderIndex })
            return res.status(200).send(blog)
        }
        if (sortBy == 'reading_time') {
            const blog = await Blog
            .find({ state: 'published' }).limit(limit).skip(skip).sort({ reading_time: orderIndex })
            return res.status(200).send(blog)
        }
        if (sortBy == 'read_count') {
            const blog = await Blog
            .find({ state: 'published' }).limit(limit).skip(skip).sort({ read_count: orderIndex })
            return res.status(200).send(blog)
        }
        if (search) {

            const blog = await Blog
            .find({ state: 'published' }).and({
                $or: [
                    {
                        title: { $regex: search, $options: "i" },
                    },
                    {
                        author: { $regex: search, $options: "i" },
                    },
                    {
                        tags: { $regex: search, $options: "i" },
                    }
                ],
            }).limit(limit).skip(skip)
            return res.status(200).json({
                status: "true",
                blog
            })
        }
        const blog = await Blog
        .find({ state: 'published' }).limit(limit).skip(skip)
        return res.status(200).json({ status: "true", blog })



    } catch (error) {
        console.log(error)
        return res.status(404).json({
            status: "false",
            message: "Blog not Found"
        })
    }

}

Next, we add the function to the exprots and we add the function to the routes.

blogRouter.get('/', blogController.getBlogs)

Now, when we get the localhost://9000/blog/ route, we'll see a list of published blogs.

Updating a blog

We would protect the route as we only want logged in users to be able to update blogs. We would also check if the logged in user is the blog's author as we want only authors to be able to update their blogs. We add the following function to our blog controller.

async function updateBlog(req, res, next) {
    const user = req.user
    const id = req.params.id
    const newBlog = req.body
    try {
        const blog = await Blog.findById(id)
        if (user.id == blog.author) {
            await Blog.findByIdAndUpdate(id, newBlog, { new: true })
            return res.status(201).json({
                state: "true",
                message: "Blog updated successfully"
            })
        } else {
            return res.status(403).json({
                state: "false",
                message: "You're not authorized to perform this action"
            })
        }

    } catch (err) {
        return res.status(403).json({
            state: "false",
            message: "Blog not found"
        })
    }
}

Next, we add the update route to our blog router with:

blogRouter.put('/:id', auth.protect, blogController.updateBlog
)

The user passes in the id of the blog to be updated and the body that will form the new blog.

Deleting a blog

Like, the update endpoint, we will protect the route and get the user from the request. If the user is the author of the blog, the blog will be deleted otherwise, a message, "You're not authorized to perform this action" would be returned. We add the following to our blog controller:

async function deleteBlog(req, res, next) {
    const user = req.user
    const id = req.params.id
    try {
        const blog = await Blog.findById(id)
        if (user.id == blog.author) {
            await Blog.deleteOne({ _id: id })
            return res.status(200).json({
                state: "true",
                message: "Blog deleted successfully"
            })
        } else {
            return res.status(403).json({
                state: "false",
                message: "You're not authorized to perform this action"
            })
        }

    } catch (err) {
        console.log(err)
        return res.status(404).json({
            state: "false",
            message: "Blog not found"
        })
    }
}

Next, we export the function and create a delete route in our blog router.

blogRouter.delete('/:id',auth.protect, blogController.deleteBlog)

Comment Endpoints

Creating a comment schema

Create a new file in the models directory and name it comment.model.js and add the following code:

const mongoose = require('mongoose');

const Schema = mongoose.Schema;

const commentSchema = new Schema({
    postId:{
        type: mongoose.Schema.Types.ObjectId,
        ref: 'Blog'
    },
    body:{
        type: String,
        required: true
    },
    timestamp:{
        type: Date,
        default: Date.now()
    },
    author: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User'
    }
},
{collection:'Comment'}
)

const CommentModel = mongoose.model('Comment', commentSchema);

module.exports = CommentModel;

We add the comment author from the User collection. In addition, we will edit the blog schema to have an comments field which would be an array of created comments.

comments:[
        {
            type: String,
            ref: 'Comment'
        }
    ],

Next, create a new file in the controllers directory and name it comments.controller.js and add the following code:

const Comment = require('../models/comment.model')
const Blog = require('../models/blog.model')

async function createComment(req,res,next){
    try {
        const newComment = new Comment( {
            postId: req.params.id,
            author: req.user._id,
            body: req.body.body
        });

        const savedComment = await newComment.save();

        //adding comment to blog object
        const blogPost = await Blog.findById(newComment.postId)


        blogPost.comments = blogPost.comments.concat(savedComment.body)
        blogPost.save()

        res.status(201).json(savedComment);
    } catch (error) {
        res.status(500).json({ message: error.message });
    }
}

We import the Blog and Comment model, create a new Comemnt with the Comment model and add the created comment to the Blog's comment field.

Next, we create the comment route. Create a new file in the routes directory and name it comments.route.js and add the following code:

const express = require('express');
const commentRouter = express.Router()

const auth =  require('../middlewares/auth')
const commentController = require('../controllers/comment.controller')

commentRouter.post('/:id', auth.protect, commentController.createComment)
module.exports = commentRouter

We protect the create comment route as we only want logged in users to be able to leave comments. Next, we add our comments router to our index.js file with:

const commentRoute = require('./routes/comment.route')
app.use('/comments', commentRoute)

Now, if we pass the comment data to localhost://9000/comments/<post id> we'll get a response showing the comment has been created.

Getting comments on a blog

We can get all the comments on a particular blog post by adding a getComments function to our comments controller:

async function getComments(req,res,next){
    try{
        const blogPost = await Blog.findById(req.params.id)

        res.status(200).json(blogPost.comments)
    }catch(error){
        res.status(500).json({ message: error.message });
    }
}

Next, we export the function and create a route in our comment route file wuth:

commentRouter.get('/:id', commentController.getComments)

Now, if we pass the blog id to localhost://9000/comments/<post id> we'll get a list of the created comemnts.

Conclusion

Thanks for reading and I hope you've now learnt how to create a REST Blog API with Node, Express and MongoDB! If you have any questions or improvements, please let me know in the comments below.