How I built a blog api using node.js, express.js and mongoDB

How I built a blog api using node.js, express.js and mongoDB

·

22 min read

Introduction

I was assigned a project at AltSchool Africa to work on a blog API. This article will provide a detailed step-by-step guide on how I went about the whole process.

The project is simply a blog API that allows users to read, create, update and delete articles. You can read more about the project requirements and see the code right here. This project was created using node, express and MongoDB database.

Prerequisites

To follow along with this project you need to understand JavaScript and node.js.

To get started with this project you need to have

  • vs code or any IDE of your choice

  • node installed. You can run a check by typing this command on your terminal node --version.

  • Create an account on MongoDB atlas. We will store our data on an online instance

Project setup

To get started, type this command npm init -y on your terminal. This creates a new node project with the default options.

Next, we install our project dependencies.

First, we install express

npm install express

Next, let's install our dev dependencies. I'll explain what each of them does as we progress.

npm install --save-dev nodemon jest supertest mongodb-memory-server cross-env

  • nodemon automatically restarts our node application whenever a change is detected within our project folder. This enables us focus on our actual project functionality.

  • Jest and supertest - is to enable us write test for our node application

  • Mongodb-memory-server - spins up an actual Mongodb server programmatically from within nodejs to enable us test our application during the development phase. It could be beneficial to implement some of the backend test by mocking the database instead of using the real database.

Lastly, let's install other dependencies

npm install joi bcrypt dotenv jsonwebtoken mongoose passport passport-jwt passport-local

  • joi - To validate user's input

  • passport-jwt

    JSON Web Token is an open standard that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. passport-jwt is an npm package which allows to use jwt in our application

  • jsonwebtoken - To encrypt and decrypt tokens

  • bcrypt - To hash passwords.

  • dotenv - To store our applications' environment variables and make them accessible from the process.env

  • mongoose - It is an Object Document Mapper (ODM) for MongoDB. It is used to interact with our database directly from our application.

  • passport - To add authentication to our application

  • passport-local - is a passport strategy for authenticating with a username and password.

The convention of Node is to define the execution mode of the application with the NODE_ENV environment variable. It is a common practice to define separate modes for development and testing.

Within our script in the package.json file, add this

"scripts": {
    "test": "cross-env NODE_ENV=test jest --detect-openHandles",
    "dev": "cross-env NODE_ENV=development nodemon index.js"
  }

With the above, when the tests are run, NODE_ENV gets the value of test. This enables us to tweak how our application works in different modes.

cross-env in our scripts enables us to achieve cross-platform compatibility. Without it, our various modes will not work on windows.

Connecting to MongoDB

To connect our database, ensure you have created an account on MongoDB atlas. If you haven't done so right here. Then create a new database. This is where our data will be stored.

To connect our application to the database we just created we need to copy a URL which will be placed in our .env file. To get the URL click on connect > connect to an application.

In your code, create a file db.js and paste the code below

require('dotenv').config()

const mongoose = require('mongoose');
const MONGODB_URI = process.env.MONGODB_URI

// CONNECT TO MONGOOSE
function connectToDb() {
    mongoose.connect(MONGODB_URI)

    // TESTING CONNECTION
    mongoose.connection.on('connected', () => {
        console.log("Database connected successfully")
    })

    mongoose.connection.on('error', err => {
        console.log("An error has occurred while connecting to Mongodb")
        console.log(err)
    })
}

module.exports = { connectToDb }

create a .env file and paste the URL which you copied

MONGODB_URI = mongodb+srv://username:password@cluster0.ucgcqxq.mongodb.net/?retryWrites=true&w=majority

Replace username and password with your atlas account username and password

Let's set up our index.js file. Paste the code below

const express = require('express')
const app = express()
const db = require('./db')
const os = require('os')
const PORT = process.env.PORT || 3000

//CONNECT TO MONGOOSE
if (process.env.NODE_ENV != "test") {
    db.connectToDb()
}

app.get('/', (req, res) => {
    res.status(200).json({status: true, message: `Welcome to Annies Blog API`})
})


//Error Middleware function
app.use(function(err, req, res, next) {
    res.status(err.status || 500);

    //send the first line of an error message 
    if (err instanceof Error) return res.json({error: err.message.split(os.EOL)[0]})

    res.json({ error: err.message });
})

app.use('*', (req, res) => {
    res.status(404).json({status: false, message: `Route not found`})
})

if (process.env.NODE_ENV != "test") {
    app.listen(PORT, () => {
        console.log(`Server is running at PORT http://localhost:${PORT}`)
    })
}

module.exports = app

We only want to connect to the online database when we are running our application in other modes but not the test mode. For our test, we will use an in-memory database just like we said before.

We added the home endpoint, an error middleware function to handle errors and also * for unknown routes.

To start the application, type in the command below on your terminal

npm run dev

Setup Database models

The next thing we want to do is to set up our database model. The model provides an interface which will be used to interact with the database.

Before proceeding, we need to be clear on what model we need to create.

Remember, our goal is to create a blog API where users can create blog posts. This means that our database needs to keep track of all users and articles.

Create a folder called models and within it create a file called userModel.js. Paste the following code

const mongoose = require('mongoose')
const { Schema } = require('mongoose')
const bcrypt = require('bcrypt')

const userSchema = new Schema({
    first_name: {
        type: String,
        required: [true, "first_name field is required"]
    },
    last_name: {
        type: String,
        required: [true, "last_name field is required"]
    },
    email: {
        type: String,
        required: [true, "email field is required"],
        trim: true,
        lowercase: true,
        unique: true,
        match: [/^.+@(?:[\w-]+\.)+\w+$/, 'Please fill a valid email address']
    }, 
    password: {
        type: String,
        required: true
    },
    articles: [
        {
            type: mongoose.Schema.Types.ObjectId,
            ref: "Article"
        }
    ],
    created_at: {
        type: Date,
        default: Date
    },
    updated_at: {
        type: Date,
        default: Date
    }
})


userSchema.pre(
    'save',
    async function(next) {
        let user = this

        if (!user.isModified('password')) return next()

        try {
            const hash = await bcrypt.hash(user.password, 10)
            user.password = hash
            next()
        } catch(err) {
            next(err)
        }  
    }
)

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

//modifies data sent back to user
userSchema.set('toJSON', {
    transform: (document, returnedObject) => {
      delete returnedObject.__v
      // the passwordHash should not be revealed
      delete returnedObject.password
    }
})

module.exports = mongoose.model('User', userSchema)

The userSchema defines what properties every user should have. We used bcrypt to hash the password before it gets saved to the database.

Next, we create a file called articleModel.js, it should contain the code below

const mongoose = require('mongoose')
const { Schema } = mongoose

const articleSchema = new Schema({
    title: {
        type: String,
        required: true,
        unique: true
    },
    author: {
        type: String
    },
    formattedTitle: {
        type: String,
        unique: true
    },
    description: {
        type: String
    },
    tags: [String],
    timestamp: {
        type: Date
    },
    state: {
        type: String,
        enum: ["draft", "published"],
        default: "draft"
    },
    read_count: {
        type: Number,
        default: 0
    },
    reading_time: {
        type: Number
    },
    body: {
        type: String,
        required: true
    },
    created_at: {
        type: Date,
        default: Date
    },
    updated_at: {
        type: Date,
        default: Date
    },
    authorInfo: {
        type: mongoose.Schema.Types.ObjectId,
        ref: "User"
    }
})

module.exports = mongoose.model('Article', articleSchema)

All articleSchema just like userSchema defines properties every article should have. formattedTitle stores titles of articles but unlike title, it removes all white spaces and special characters. This makes it possible to retrieve a single article by its name.

From the above schema, we can also see all users and articles have a relationship between them. This makes it possible to retrieve articles created by a specific user and also ensures owners of articles have more privileges than regular users. We will get more explanation on this as we proceed.

Adding validation with Joi

Joi is an npm package which is used to validate a user's input. It helps ensure that the data passed by the user is in the right format. The data entered is checked against the validation schema which has been defined with JOI.

To use Joi we need to add it as a middleware to a route which needs data validation before any operation is performed.

The folder structure for our validators will look like this

validators 
|_user.validator.js 

_article.validator.js

Within user.validator.js paste this

const joi = require('joi')

const userValidationMiddleware = async function(req, res, next) {
    try {
        const userPayload = req.body
        await userValidator.validateAsync(userPayload)
        next()
    } catch(err) {
        console.log(err)
        return res.status(406).json({error: err.details[0].message})
    }
}

//Define validation schema
const userValidator = joi.object({
    first_name: joi.string()
                    .required(),
    last_name: joi.string()
                    .required(),
    email: joi.string()
                .pattern(new RegExp(/^.+@(?:[\w-]+\.)+\w+$/))
                .required(),
    password: joi.string()
                    .required()
})

module.exports = userValidationMiddleware

Within article.validator.js paste the code below

const joi = require('joi')

const articleValidationMiddleware = async function(req, res, next) {
    try {
        const articlePayload = req.body
        await articleValidator.validateAsync(articlePayload)
        next()
    } catch(err) {
        console.log(err)
        return res.status(406).json({error: err.details[0].message})
    }
}
const articleValidator = joi.object({
    title: joi.string()
              .required(),
    description: joi.string()
                    .optional(),
    tags: joi.string(),
    body: joi.string()
              .required()

})

module.exports = articleValidationMiddleware

User authentication

Authentication is simply the act of verifying a user's identity.

The authentication process in our application is pretty straightforward.

When a user signs up, their information gets stored in the database. When a user tries to log in a token will be sent to the user as long as the information provided matches the one on our database. Going forward the user will be able to access protected routes as long as such user is authorized to do so and the token provided alongside the request is valid. Note that every token is set to expire after one hour.

In the root of our project, create a folder called authentication and a file called auth.js. Place the code below

const mongoose = require('mongoose')
const passport = require('passport')
const localStrategy = require('passport-local').Strategy
const JWTStrategy = require('passport-jwt').Strategy
const ExtractJwt = require('passport-jwt').ExtractJwt
const userModel = require('../models/userModel')

require('dotenv').config()

passport.use(
    'jwt',
    new JWTStrategy(
        {
            secretOrKey: process.env.JWT_SECRET,
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken()
        },

        async(token, done) => {
            try {
                return done(null, token.user)
            } catch(err) {
                done(err)
            }
        }
    )
)
passport.use(
    'signup',
    new localStrategy(
        {
            usernameField: 'email',
            passwordField: 'password',
            passReqToCallback: true
        },

        async function (req, email, password, done) {
            try {
                const userInfo = req.body

                const {first_name, last_name} = userInfo

                const user = await userModel.create({first_name, last_name, email, password})

                return done(null, user)
            } catch(err) {
                if (err instanceof mongoose.Error.ValidationError) {
                    err.status = 400
                }
                done(err)
            }
        }
    )
)

passport.use(
    'login',
    new localStrategy(
        {
            "usernameField": "email",
            "passwordField": "password",
            "passReqToCallback": true
        },

        async function(req, 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: "Password incorrect"})

                return done(null, user, {message: "Login successful"})
            } catch(err) {
                console.log(err)
                return done(err)
            }
        }
    )
)

We used passport-local which is a passport strategy to authenticate our users using username and password and passport-jwt to authorize a user. The token sent will be extracted and checked against the JWT secret key to ensure it is right.

The JWT secret key is used to encrypt the JSON web token which is sent back to the user when they log in. Ensure that this is only known to you.

Now, let's go ahead and update the .env file with our JWT secret key

JWT_SECRET = mysecretkey

Application Architecture

Our project uses an MVC architecture. MVC stands for Model View Controller.

It is a software design pattern commonly used for developing user interfaces that divide the related program logic into 3 interconnected elements. It tells us how to organise the various pieces of our code based on what they do. It makes our application easier to understand and scale as we write more code

The logic behind it is pretty straightforward. The user uses the controller eg making a request. The controller understands and processes that request and manipulates the model accordingly eg updating some data in the database. The views reacts to the changes made to the model and gets updated. Then the user sees the updated data however it is presented by the view.

How is an MVC pattern implemented in express.js

In express, the controller is the code that gets executed upon getting a request(functions that react to request and set response accordingly), the model is the data we interact with, and it also includes any function we use to interact with the database. The view gets populated by data from the model.

In some cases, when working the APIs, the view might just be the api or json data we send back to the user which is exactly what we will do in this project.

Working on the user route

First of all, let's update the index.js file

const express = require('express')
const app = express()
const db = require('./db')
const passport = require('passport')
const userRoute = require('./routes/user.route')
const blogRoute = require('./routes/blog.route')
const os = require('os')
const PORT = process.env.PORT || 3000

//CONNECT TO MONGOOSE
if (process.env.NODE_ENV != "test") {
    db.connectToDb()
}


//Signup and login authentication middleware
require('./authentication/auth')

//To parse url encoded data
app.use(express.urlencoded( {extended: false} ))

//To parse data passed via body
app.use(express.json())


//USER ROUTE
app.use('/api', userRoute)

//BLOG ROUTE
app.use('/api/blog', blogRoute)

app.get('/', (req, res) => {
    res.status(200).json({status: true, message: `Welcome to Annies Blog API`})
})


//Error Middleware function
app.use(function(err, req, res, next) {
    res.status(err.status || 500);

    //send the first line of an error message 
    if (err instanceof Error) return res.json({error: err.message.split(os.EOL)[0]})

    res.json({ error: err.message });
})

app.use('*', (req, res) => {
    res.status(404).json({status: false, message: `Route not found`})
})

if (process.env.NODE_ENV != "test") {
    app.listen(PORT, () => {
        console.log(`Server is running at PORT http://localhost:${PORT}`)
    })
}

module.exports = app

We will have a router for our user and article. A router is used to break down our application and make it more modular.

The folder structure for our routes and controller will be

routes
|_blog.route.js 

_user.route.js

controllers
|_user.controller.js 

_blog.controller.js

Within the user.route.js, we have

const express = require('express')
const userRoute = express.Router()
const userController = require('../controllers/user.controller')
const userValidator = require('../validators/user.validator')
const passport = require('passport')

userRoute.post('/signup', userValidator, passport.authenticate('signup', {session: false}), userController.signup)
userRoute.post('/login', userController.login)

module.exports = userRoute

From the above, you can see that we added userValidator as a middleware to validate a user's input using joi and also passport for authentication

Then within the user.controller.js we have this

const passport = require('passport')
const jwt = require('jsonwebtoken')

function signup(req, res) {
    return res.status(201).json({
        status: true, 
        message: "Signup successful",
        user: req.user
    })
}

async function login(req, res, next) {
    passport.authenticate('login', async (err, user, info) => {
        try {
            if (err) return next(err)

            if (!user) {
                const err = new Error(info.message)
                err.status = 403
                return next(err)
            }
            req.login(user, {session: false}, async(err) => {
                if (err) return next(err)

                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: info.message,
                    token
                })
            })
        } catch(err) {
            next(err)
        }
    })(req, res, next)

}

module.exports = {
    signup,
    login
}

We used jsonwebtoken to encrypt and decrypt the token and passport to ensure a user provides the right credentials. When a user successfully logs in, we send a token which will be used to access protected routes.

Working on the blog route

Within the blog.route.js add this

const express = require('express')
const blogRoute = express.Router()
const passport = require('passport')
const articleValidator = require('../validators/article.validator')
const blogController = require('../controllers/blog.controller')

blogRoute.get("/", blogController.getAllArticles)

blogRoute.get("/article/:idOrTitle", blogController.getArticleByIdOrTitle)

blogRoute.get('/articles', passport.authenticate('jwt', {session: false}), blogController.getDraftsAndPublished)

blogRoute.post('/create-article', articleValidator, passport.authenticate('jwt', {session: false}), blogController.createArticle)

blogRoute.patch('/article/:id', passport.authenticate('jwt', {session: false}), blogController.updateArticle)

blogRoute.patch('/publish/:id', passport.authenticate('jwt', {session: false}), blogController.updateDraftToPublished)

blogRoute.delete('/article/:id', passport.authenticate('jwt', {session: false}), blogController.deleteArticle)

module.exports = blogRoute

Then within the controllers folder, create a file called blog.controller.js and add this

const articleModel = require('../models/articleModel')
const userModel = require('../models/userModel')
const { calculateReadingTime, errorHandler } = require('../utils')

//Get a list of all articles
async function getAllArticles(req, res, next) {
    try {
        const { query } = req;
        let { 
            author, 
            title, 
            tags,
            order = 'asc', 
            order_by = 'timestamp', 
            skip = 0, 
            per_page = 20 
        } = query;

        const findQuery = {};

        if (author) {
            findQuery.author = author
        } 

        if (title) {
            findQuery.title = title
        }

        if (tags) {
            tags = tags.split(',')
            findQuery.tags = { $in: tags }
        }

        findQuery.state = 'published'

        const sortQuery = {};

        const sortAttributes = order_by.split(',')

        for (const attribute of sortAttributes) {
            if (order === 'asc' && order_by) {
                sortQuery[attribute] = 1
            }

            if (order === 'desc' && order_by) {
                sortQuery[attribute] = -1
            }
        }


        const articles = await articleModel
        .find(findQuery)
        .sort(sortQuery)
        .skip(skip)
        .limit(per_page)

        return res.status(200).json(articles)
    } catch(err) {
        errorHandler(req, res, err)
    }
}

//create a new article
async function createArticle(req, res, next) {
    const alphabets = "abcdefghijklmnopqrstuvwxyz0123456789"
    const { title } = req.body
    const lowerCaseTitle = title.toLowerCase()
    let formattedTitle = ""

    //Formats title to be stored in db. This will be used to get a single article
    for (let i = 0; i < lowerCaseTitle.length; i++) {

        if (alphabets.includes(lowerCaseTitle[i])) formattedTitle += lowerCaseTitle[i]

        if (lowerCaseTitle[i] == " " && formattedTitle.slice(-1) != "-") formattedTitle += "-"
    }
    if (formattedTitle.slice(-1) == "-") formattedTitle = formattedTitle.slice(0, -1)

    if (process.env.NODE_ENV != 'test') req.body.tags = req.body.tags.split(",")
    try {
        const user = await userModel.findById(req.user._id);
        const article = await articleModel.create({
            ...req.body,
            author: `${user.last_name} ${user.first_name}`,
            formattedTitle: formattedTitle,
            authorInfo: req.user._id
        })

        article.reading_time = calculateReadingTime(article.body)
        article.save()

        const response = {article: {...article._doc}, status: true, message: "Article creation successful"}
        return res.status(201).json(response)
    } catch(err) {
        errorHandler(req, res, err)
    }

}

//update an article
async function updateArticle(req, res, next) {
    const id = req.params.id
    const authorInfo = req.user._id
    const infoToUpdate = req.body

    const { body } = infoToUpdate
    if (body) {
        infoToUpdate.reading_time = calculateReadingTime(body)
    }
    try {
        infoToUpdate.updated_at = new Date()
        const update = await articleModel.findByIdAndUpdate(id, {...infoToUpdate, authorInfo}, {new: true})

        if (!update) {
            let err = new Error("An article with such id doesn't exist")
            err.status = 404
            next(err)
        }
        const response = {article: {...update._doc}, status: true, message: "Update successful"}
        return res.status(200).json(response)
    } catch(err) {
        errorHandler(req, res, err)
    }   
}


async function getDraftsAndPublished(req, res, next) {
    const authorInfo = req.user._id;
    let { state, skip = 0, per_page = 10 } = req.query

    try {
        let filter;
        if (state) {
            filter = await articleModel.find({authorInfo, state: state}).skip(skip).limit(per_page)
        } else filter = await articleModel.find({authorInfo}).skip(skip).limit(per_page)

        const response = {articles: filter, status: true}
        return res.status(200).json(response)
    } catch(err) {
        errorHandler(req, res, err)
    }
}


async function updateDraftToPublished(req, res, next) {
    const authorInfo = req.user._id
    const _id = req.params.id

    try {
        const article = await articleModel.findOne({_id, authorInfo})
        if (article.state == 'published') return res.status(200).json({article: article, message: "Article has already been published"})

        if (!article) {
            let err = new Error("An article with such id doesn't exist")
            err.status = 404
            next(err)
        }

        article.state = 'published'
        article.timestamp = new Date()

        article.reading_time = calculateReadingTime(article.body)
        await article.save() 

        const response = {article: {...article._doc}, status: true, message: "Update successful - your article is now live"}
        return res.status(200).json(response)
    } catch(err) {
       errorHandler(req, res, err)
    }
}

async function getArticleByIdOrTitle(req, res, next) {
    const idOrTitle = req.params.idOrTitle;
    try {
        let article = await articleModel.findOne({formattedTitle: idOrTitle}).populate('authorInfo', {first_name: 1, last_name: 1, email: 1})

        //check state of article
        if (article?.state == 'published') {
            article.read_count++
            await article.save()
            return res.status(200).json(article)
        }
        if (article?.state == 'draft') return res.status(404).json({message: "Aritlce hasn't been published", status: false})

        article = await articleModel.findOne({_id: idOrTitle}).populate('authorInfo', {first_name: 1, last_name: 1, email: 1})

        if (article?.state == 'published') {
            article.read_count++
            await article.save()
            return res.status(200).json(article)
        }
        if (article?.state == 'draft') return res.status(404).json({message: "Aritlce hasn't been published", status: false})

        return res.status(404).json({message: "Aritlce doesn't exist", status: false})
    } catch(err) {
        errorHandler(req, res, err)
    }
}

async function deleteArticle(req, res, next) {
    const id = req.params.id
    const authorInfo = req.user._id

    try {
        const deleteArticle = await articleModel.deleteOne({_id: id, authorInfo})
        if (deleteArticle.deletedCount == 0) return res.status(404).json({status: false, message: "Article with such id doesn't exist"})

        const response = {status: true, message: "Article successfully deleted"}

        return res.status(200).json(response)

    } catch(err) {
        errorHandler(req, res, err)
    }  
}
module.exports = {
    getAllArticles,
    createArticle,
    updateArticle,
    getDraftsAndPublished,
    updateDraftToPublished,
    getArticleByIdOrTitle,
    deleteArticle
}

Let's step through the above code

  • getAllArticles - Retrieves all the articles in published state and displays them. It is accessible on this route /api/blog. The list defaults to 20 articles per page. Articles can be filtered by author, title and tags. It can be ordered by read_count, reading_time and timestamp. All users can access this route.

  • createArticle - Creates a new article. When an article is created, it formats the title by removing special characters and white spaces. It ensures that formattedTitle only contains only alphanumeric characters. Only authorized users can create articles.

  • updateArticle - Updates an article. When an article gets updated. The reading_time also gets updated. An article can only be updated by the owner of the article.

    Below is the algorithm used to generate the reading_time.

    create a file called util.js

      //util.js
      const mongoose = require('mongoose')
    
      //calculate reading_time
      function calculateReadingTime(text) {
          let reading_time = Math.round(text.split(" ").length / 200)
          reading_time = reading_time || 1
          return reading_time
      }
    
      //Error handler
      function errorHandler(req, res, err) {
          if (err instanceof mongoose.Error.CastError) {
              return res.status(400).json({status: false, message: "Invalid id"})
          }
          next(err)
      }
    
      module.exports = {
          calculateReadingTime,
          errorHandler
      }
    
  • getDraftsAndPublished - ensures the owner of the article is able to retrieve both articles in draft and published state.

  • updateDraftToPublished - updates the state of an article to published. An article can only be updated by the owner.

  • getArticleByIdOrTitle - gets a single article by id or title

  • deleteArticle - delete an article. An article can only be deleted by the owner.

Testing our application

To test our application we used Jest and supertest to make requests.

We create a fixtures folder to store dummy data which will be used to run our test.

The test and fixtures folder structure looks like this:

fixtures
|_articles.json
_users.json

test
|_auth.spec.js 
_blog.spec.js
_database.js
_home.spec.js

fixtures folder

users.json

[
    {
        "first_name": "Ann",
        "last_name": "Mba",
        "password": "ann123",
        "email": "anny@gmail.com"
    },

    {
        "first_name": "Oscar",
        "last_name": "Okoro",
        "password": "scar789",
        "email": "scar@gmail.com"
    },

    {
        "first_name": "Chris",
        "username": "David",
        "password": "chris456",
        "email": "chris@gmail.com"
    },

    {
        "first_name": "Cynthia",
        "username": "Jerry",
        "password": "cynthia123",
        "email": "cynthia@gmail.com"
    }
]

articles.json

[
    {
        "title": "Understanding CSS Positioning",
        "description": "Simple explanation of CSS positioning",
        "tags": ["newbies", "css"],
        "body": "Positioning can seem daunting at first for everyone new to css. In this article I'll explain ....."
    },

    {
        "title": "Closures in Javascript",
        "description": "Detailed explanation of JavaScript Closures",
        "tags": ["javascript", "programming"],
        "body": "Closure means that a function have access to it's outer functions. In Js, every function is a closure except functions created using the new function keywor."
    }
]

test folder

Within database.js, we have this

const mongoose = require('mongoose')
const { MongoMemoryServer } = require('mongodb-memory-server')

class Connection {
    //create a new server instance and connect to server
    async connect() {
      this.mongoServer = await MongoMemoryServer.create();
      const mongoUri = this.mongoServer.getUri();

      this.connection = await mongoose.connect(mongoUri, {
        useNewUrlParser: true,
        useUnifiedTopology: true,
      });
    }

    //disconnect from server and stop running server
    async disconnect() {
      await mongoose.disconnect();
      await this.mongoServer.stop();
    }

    //delete all documents in inmemory db
    async cleanup() {
      const models = Object.keys(this.connection.models);
      const promises = [];

      models.map((model) => {
        promises.push(this.connection.models[model].deleteMany({}));
      });

      await Promise.all(promises);
    }
}

exports.connect = async () => {
    const conn = new Connection();
    await conn.connect();
    return conn;
};

The above code creates a mongodb server instance and tests our application using an in-memory database.

Tests for home route:

const request = require('supertest')
const app = require('../index')


describe('test root route', () => {
    it('test home route - GET request /', async () => {
        const response = await request(app).get('/')
        .set('content-type', 'application/json')

        expect(response.status).toBe(200)
        expect(response.body).toEqual({status: true, message: `Welcome to Annies Blog API`})
    })

    it('Should return error when routed to an unknown route', async () => {
        const response = await request(app).get('/undefined')
        .set('content-type', 'application/json')

        expect(response.status).toBe(404)
        expect(response.body).toEqual({status: false, message: `Route not found`})
    })
})

Tests for authentication:

const request = require('supertest')
const app = require('../index')
const { connect } = require('./database')
const userModel = require('../models/userModel')
const users = require('../fixtures/users.json')

describe('authenticate a user', () => {
    let conn;

    beforeAll(async () => {
        conn = await connect()
    })

    afterEach(async () => {
        await conn.cleanup()
    })

    afterAll(async () => {
        await conn.disconnect()
    })

    it('should signup a user - POST request /api/signup', async () => {
        const response = await request(app).post('/api/signup')
        .set('content-type', 'application/json')
        .send(users[0])

        expect(response.status).toBe(201)
        expect(response.body).toHaveProperty('message')
        expect(response.body).toHaveProperty('status')
        expect(response.body).toHaveProperty('user')
        expect(response.body.status).toBe(true)
        expect(response.body.message).toBe('Signup successful')
        expect(response.body.user.first_name).toBe(users[0].first_name)
        expect(response.body.user.last_name).toBe(users[0].last_name)
        expect(response.body.user.email).toBe(users[0].email)
    })

    it('should test if a user provides incorrect details during signup - POST request /api/signup', async () => {
        const response = await request(app).post('/api/signup')
        .set('content-type', 'application/json')
        .send(users[2])

        expect(response.status).toBe(400)
        expect(response.body).toHaveProperty('error')
        expect(response.body.error).toBe("User validation failed: last_name: last_name field is required")
    })

    it('should login a user - POST request /api/login', async () => {
        //Add a user to db
        const user = await userModel.create(users[1])

        const response = await request(app).post('/api/login')
        .set('content-type', 'application/json')
        .send({email: users[1].email, password: users[1].password})

        expect(response.status).toBe(200)
        expect(response.body).toHaveProperty('message')
        expect(response.body).toHaveProperty('token')
        expect(response.body.message).toBe('Login successful')

    })

    it('should test if the user doesnt provide the necessary info during login - POST request /api/login', async () => {
        //Add a user to db
        const user = await userModel.create(users[1])

        const response = await request(app).post('/api/login')
        .set('content-type', 'application/json')
        .send({email: users[1].email})

        expect(response.status).toBe(403)
        expect(response.body).toHaveProperty('error')
        expect(response.body.error).toBe('Missing credentials')
        expect(response.body).not.toHaveProperty('token')

    })
})

Test for blog route:

const request = require('supertest')
const app = require('../index')
const { connect } = require('./database')
const userModel = require('../models/userModel')
const articleModel = require('../models/articleModel')
const articles = require('../fixtures/articles.json')
const users = require('../fixtures/users.json')


describe('authenticate a user', () => {
    let conn, token, _id1, id2;

    beforeAll(async () => {
        conn = await connect()

        await userModel.create(users[0])

        const response = await request(app).post('/api/login')
        .set('content-type', 'application/json')
        .send({email: users[0].email, password: users[0].password})

        token = response.body.token
    })

    afterAll(async () => {
        await conn.disconnect()
    })

    it('logged in users(owner) should be able to create articles - POST request /api/blog/create-article', async () => {
        const response = await request(app).post('/api/blog/create-article')
        .set('Authorization', `Bearer ${token}`)
        .send(articles[0])
        expect(response.status).toBe(201)
        expect(response.body).toHaveProperty("status")
        expect(response.body).toHaveProperty("message")
        expect(response.body).toHaveProperty("article")
        expect(response.body.status).toBe(true)
        expect(response.body.message).toBe("Article creation successful")
        _id1 = response.body.article._id
    })

    it('logged in users(owner) should be able to create articles - POST request /api/blog/create-article', async () => {
        const response = await request(app).post('/api/blog/create-article')
        .set('Authorization', `Bearer ${token}`)
        .send(articles[1])
        expect(response.status).toBe(201)
        expect(response.body).toHaveProperty("status")
        expect(response.body).toHaveProperty("message")
        expect(response.body).toHaveProperty("article")
        expect(response.body.status).toBe(true)
        expect(response.body.message).toBe("Article creation successful")
        _id2 = response.body.article._id
    })

    it('logged in users(owner) should be able to edit articles - PATCH request /api/blog/article/id', async () => {
        const response = await request(app).patch(`/api/blog/article/${_id1}`)
        .set('content-type', 'application/json')
        .set('Authorization', `Bearer ${token}`)
        .send({body: "Positioning can seem daunting at first for everyone new to css. In this article I'll explain the concept in the simplest way possible"})

        expect(response.status).toBe(200)
        expect(response.body).toHaveProperty("status")
        expect(response.body).toHaveProperty("article")
        expect(response.body).toHaveProperty("message")
        expect(response.body.status).toBe(true)
        expect(response.body.message).toBe("Update successful")
    })

    it('logged in users(owner) should be able to get all articles in all states - GET request /api/blog/articles', async () => {
        const response = await request(app).get(`/api/blog/articles`)
        .set('content-type', 'application/json')
        .set('Authorization', `Bearer ${token}`)

        expect(response.status).toBe(200)
        expect(response.body).toHaveProperty("status")
        expect(response.body).toHaveProperty("articles")
        expect(response.body.articles.length).toBe(2)
        expect(response.body.status).toBe(true)
    })

    it('logged in users(owner) should be able to filter articles by draft state - GET request /api/blog/articles', async () => {
        const response = await request(app).get(`/api/blog/articles?state=draft`)
        .set('content-type', 'application/json')
        .set('Authorization', `Bearer ${token}`)

        expect(response.status).toBe(200)
        expect(response.body).toHaveProperty("status")
        expect(response.body).toHaveProperty("articles")
        expect(response.body.articles.length).toBe(2)
        expect(response.body.status).toBe(true)
    })

    it('logged in users(owner) should be able to filter articles by published state - GET request /api/blog/articles', async () => {
        const response = await request(app).get(`/api/blog/articles?state=published`)
        .set('content-type', 'application/json')
        .set('Authorization', `Bearer ${token}`)

        expect(response.status).toBe(200)
        expect(response.body).toHaveProperty("status")
        expect(response.body).toHaveProperty("articles")
        expect(response.body.status).toBe(true)
        expect(response.body.articles).toEqual([])
    })

    it(`return an error if article with such title hasn't been published - GET request /api/blog/article/:idOrTitle`, async () => {
        const title = "understanding-css-positioning"
        const response = await request(app).get(`/api/blog/article/${title}`)
        .set('content-type', 'application/json')

        expect(response.status).toBe(404)
        expect(response.body.message).toBe(`Aritlce hasn't been published`)
        expect(response.body.status).toBe(false)
    })


    it('logged in users(owner) should be able to update draft to publish - PATCH request /api/blog/publish/:id', async () => {
        const response = await request(app).patch(`/api/blog/publish/${_id1}`)
        .set('content-type', 'application/json')
        .set('Authorization', `Bearer ${token}`)

        expect(response.status).toBe(200)
        expect(response.body).toHaveProperty("status")
        expect(response.body).toHaveProperty("article")
        expect(response.body).toHaveProperty("message")
        expect(response.body.status).toBe(true)
        expect(response.body.article.state).toBe("published")
        expect(response.body.message).toBe("Update successful - your article is now live")
    })

})

You can check out my github, to see more test cases.

To run the above test, type the command below on your terminal

npm run test

During the course of this project, I followed a Test Driven Development approach.

This enabled me gain clarity on how I wanted the application to work and the features I wanted to implement. You can consider doing that too, if now but on your next project. I'm sure you will also find it fun as I did.

Summary

In this article, we created a RESTFul blog API with CRUD(Create, Read, Update, Delete) functionalities using node.js express.js and MongoDB. We covered authentication, MVC pattern and also wrote tests to ensure our application works right. We also looked at some security practices like ensuring passwords are hashed before storing them in the database to prevent unwanted users from gaining access.

Here is the live link to this project annies-blog-api and my github for more info https://github.com/Ann-tech/Blog-api.

Thanks for reading!