DEV Community

Sheriff Adekunle Tajudeen
Sheriff Adekunle Tajudeen

Posted on • Updated on

How to create a Blog API using Node.js and Express.js Framework

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

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;
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 Databaseand 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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
            }
        }
    )
);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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)
});
Enter fullscreen mode Exit fullscreen mode

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);
})
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
    }
});
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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};
    } 
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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}`)});
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Environment Variable
Define the following variables in .env:

PORT=8000
DATABASE_URI=Your database URL
JWT_SECRET=String for signing JWT Tokens
Enter fullscreen mode Exit fullscreen mode

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)