Build A Bookstore With Node.js, Express.js, Mongodb And Jwt

Table of contents

No heading

No headings in the article.

The bookstore API is a RESTful API that allows you to manage a collection of books and their authors in a MongoDB database using the Mongoose ODM. The API follows the MVC pattern, with separate controllers for books and authors, and corresponding models and routes.

To build the blogging API described in the requirements, we follow these steps:

  1. Set up the project: we create a new Node.js project using Express for the web framework and Mongoose for the MongoDB integration.

  2. Define data models: Create Mongoose models for the User and Blog objects, and define the required and optional fields for each model. We use the unique and required options in the schema definitions to enforce these constraints.

  3. Implementing the authentication and authorization: Using JSON Web Tokens (JWTs) to implement the sign-up, sign-in, and token refresh flows for the users. We use a library like jsonwebtoken to generate and verify the JWTs, and set the expiration time to 1 hour.

  4. Create endpoints for the blogs: we define the routes and controller functions for the different actions that a user can perform on the blogs, such as creating, updating, deleting, and reading. We check the authentication and authorization status of the user for each endpoint and return appropriate HTTP status codes and error messages if the user is not authorized to perform the action.

  5. Add pagination and filtering to the list of blogs: Using query parameters and options like skip and limit in the MongoDB queries to implement pagination and filtering for the list of blogs endpoint. We also use the sort option to allow users to order blogs by different fields.

  6. Calculating the reading time: we come up with an algorithm to calculate the reading time of the blog based on the length of the body and the average reading speed of an adult. To do this, divide the length of the body by the average reading speed (e.g. 250 words per minute) to get the reading time in minutes.

The API has the following endpoints:

Books

  • GET /books: This endpoint returns a list of all books in the bookstore. The list can be filtered by title, author, or genre using query parameters, and it can be sorted by title, publication date, or page count.

  • GET /books/:id: This endpoint returns a single book by its ID.

  • POST /books: This endpoint creates a new book with the information provided in the request body. The request body must include the title, author, publication date, and page count of the book. The author can be specified by the ID of an existing author or by creating a new author with the name and birthDate fields.

  • PUT /books/:id: This endpoint updates an existing book by its ID with the information provided in the request body. The request body can include any of the fields of the book, including the author field, which can be updated with the ID of an existing author or with a new author object with the name and birthDate fields.

  • DELETE /books/:id: This endpoint deletes a book by its ID.

Authors

  • GET /authors: This endpoint returns a list of all authors in the bookstore. The list can be filtered by name or nationality using query parameters, and it can be sorted by name or birth date.

  • GET /authors/:id: This endpoint returns a single author by its ID.

  • POST /authors: This endpoint creates a new author with the information provided in the request body. The request body must include the name and birth date of the author.

  • PUT /authors/:id: This endpoint updates an existing author by its ID with the information provided in the request body. The request body can include any of the fields of the author.

  • DELETE /authors/:id: This endpoint deletes an author by its ID.

To use the API, the user needs to first sign up and login to obtain a JWT token. The token must be included in the `Authorization header of the requests to the protected routes. The token expires after 1 hour. You can refresh the token by sending a request to the POST /refresh-token endpoint with the expired token in the Authorization header.

We then test the API using a tool like Postman or by writing tests with a library like Jest and a library for testing HTTP requests like Supertest.

Here are the requests to the bookstore API:

SIGN UP

// SIGN UP
POST /signup
Content-Type: application/json

{
  "email": "user@example.com",
  "password": "password",
  "firstName": "John",
  "lastName": "Doe"
}

// SIGN IN
POST /signin
Content-Type: application/json

{
  "email": "user@example.com",
  "password": "password"
}

// CREATE A BOOK
POST /books
Content-Type: application/json
Authorization: Bearer <JWT_TOKEN>

{
  "title": "The Great Gatsby",
  "author": {
    "name": "F. Scott Fitzgerald",
    "birthDate": "1896-09-24"
  },
  "publicationDate": "1925-04-10",
  "pageCount": 180
}

// UPDATE A BOOK
PUT /books/<BOOK_ID>
Content-Type: application/json
Authorization: Bearer <JWT_TOKEN>

{
  "title": "The New Great Gatsby",
  "author": {
    "name": "F. Scott Fitzgerald",
    "birthDate": "1896-09-24"
  },
  "publicationDate": "1925-04-10",
  "pageCount": 190
}

// DELETE A BOOK
DELETE /books/<BOOK_ID>
Authorization: Bearer <JWT_TOKEN>

To begin the project, we initialize our project using npm or yarn, so:

npm init -y
OR
yarn init -y

Then we install all dependencies for the project:

yarn add cookie-parser dotenv express express-session jsonwebtoken mongoose morgan passport passport-local passport-jwt passport-local-mongoose

Next, we create the file app.js in the root of the application where we will have most of our configuration code for express and mongodb

// import the dependencies
const express = require('express')
const cookieParser = require('cookie-parser')
const logger = require('morgan')
require('dotenv').config()
const mongoose = require('mongoose')
const passport = require('passport')

// import the blog routes
const blogRoutes = require('./routes/blog.routes')

// database connection
MONGO_URI = process.env.NODE_ENV === 'test'
        ? process.env.MONGO_URI_TEST
        : process.env.MONGO_URI
mongoose
        .connect(process.env.MONGO_URI)
        .then(() => {
            console.log("MongoDB connected...")
        })
        .catch((err) => {
            console.log("MongoDB connection error", err)
            process.exit(1)
        })
// initializing express application into app
const app = express()
// initializing the middlewares
app.use(logger('dev'))
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use(cookieParser())

//passport initialization
app.use(passport.initialize())

app.use('/api/v2/blogs', blogRoutes)
// configuring the port to run on a port defined in the environment variables or an alternative port 5000
const port = process.env.PORT || 5000
app.listen(port, () => console.log(`Server listening on port: ${port}`))
// then we can export the app instance to be used later
module.exports = app

Now we create our user and blog models. Create a folder called models and add a blog.models.js file in it.

// import the needed dependencies
const mongoose = require('mongoose')
const jwt = require('jsonwebtoken')
// we create a schema that defines what properties a user will have
const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true
  },
  firstName: {
    type: String,
    required: true
  },
  lastName: {
    type: String,
    required: true
  },
  password: {
    type: String,
    required: true
  }
})
// we generate a jwt token which expires in 1 hour
userSchema.methods.generateAuthToken = function() {
  const token = jwt.sign({ _id: this._id }, process.env.JWT_SECRET, {
    expiresIn: '1h'
  });
  return token;
}

// we create a schema that defines what properties a blog will have
const blogSchema = new mongoose.Schema({
  title: {
    type: String,
    required: true,
    unique: true
  },
  description: String,
  author: {
    type: mongoose.Types.ObjectId,
    ref: 'User',
    required: true
  },
  state: {
    type: String,
    enum: ['draft', 'published'],
    default: 'draft'
  },
  readCount: {
    type: Number,
    default: 0
  },
  readingTime: Number,
  tags: [String],
  body: {
    type: String,
    required: true
  },
  timestamp: {
    type: Date,
    default: Date.now
  }
});
// we then create models for our user and blog. This method generates a new JWT for the user and returns it. 
const User = mongoose.model('User', userSchema);
const Blog = mongoose.model('Blog', blogSchema);
// now we export the models to be used in the controllers
module.exports = {
  User,
  Blog
}

Next, we define the controller functions for the different actions that a user can perform on the blogs. These functions will handle the logic for interacting with the database, and call the appropriate routes in the routes file. Now, create a folder named controllers and add the blog.controllers.js file.

const jwt = require("jsonwebtoken")
const { Blog } = require("../models/blog.model")
// this function will calculate the time taken for a user to read a blog
const calculateReadingTime = body => {
    const wordsPerMinute = 250;
    const wordCount = body.split(' ').length;
    return Math.ceil(wordCount / wordsPerMinute);
  };
  // to create a blog
  const createBlog = async (req, res) => {
    try {
      const { title, description, tags, body } = req.body;
      const author = req.user._id;
      const readingTime = calculateReadingTime(body);
      const blog = new Blog({
        title,
        description,
        author,
        tags,
        body,
        readingTime
      });
      await blog.save();
      res.status(201).json(blog);
    } catch (error) {
      console.error(error);
      res.status(500).send(error);
    }
  };
  // to update a blog
const updateBlog = async (req, res) => {
  try {
    const { title, description, state, tags, body } = req.body
    const readingTime = calculateReadingTime(body)
    const updatedBlog = await Blog.findOneAndUpdate(
      {
        _id: req.params.id,
        author: req.user._id,
      },
      {
        title,
        description,
        state,
        tags,
        body,
        readingTime,
      },
      { new: true }
    )
    if (!updatedBlog) {
      return res.status(404).json({ error: "Blog not found" })
    }
    res.json(updatedBlog)
  } catch (error) {
    console.error(error)
    res.status(500).send(error)
  }
}
// to delete a blog
const deleteBlog = async (req, res) => {
  try {
    const deletedBlog = await Blog.findOneAndDelete({
      _id: req.params.id,
      author: req.user._id,
    })
    if (!deletedBlog) {
      return res.status(404).json({ error: "Blog not found" })
    }
    res.json(deletedBlog)
  } catch (error) {
    console.error(error)
    res.status(500).send(error)
  }
}
// to get all blogs and paginate it
const getBlogs = async (req, res) => {
  try {
    const page = parseInt(req.query.page) || 1
    const limit = parseInt(req.query.limit) || 20
    const state = req.query.state || "published"
    const author = req.query.author
    const title = req.query.title
    const tags = req.query.tags
    const sort = req.query.sort || "-timestamp"
    const search = {
      state,
    }
    if (author) {
      search.author = author
    }
    if (title) {
      search.title = {
        $regex: title,
        $options: "i",
      }
    }
    if (tags) {
      search.tags = {
        $in: tags.split(","),
      }
    }
    const blogs = await Blog.find(search)
      .populate("author", "firstName lastName")
      .sort(sort)
      .skip((page - 1) * limit)
      .limit(limit)
    const count = await Blog.countDocuments(search)
    res.json({
      blogs,
      count,
      page,
      limit,
    })
  } catch (error) {
    console.error(error)
    res.status(500).send(error)
  }
}
// to get a specific blog by it's id
const getBlogById = async (req, res) => {
  try {
    const blog = await Blog.findOneAndUpdate(
      {
        _id: req.params.id,
        state: "published",
      },
      {
        $inc: {
          readCount: 1,
        },
      },
      {
        new: true,
      }
    ).populate("author", "firstName lastName")
    if (!blog) {
      return res.status(404).json({ error: "Blog not found" })
    }
    res.json(blog)
  } catch (error) {
    console.error(error)
    res.status(500).send(error)
  }
}
// to get the blogs by a specific user
const getUserBlogs = async (req, res) => {
  try {
    const page = parseInt(req.query.page) || 1
    const limit = parseInt(req.query.limit) || 20
    const state = req.query.state || "all"
    const sort = req.query.sort || "-timestamp"
    const search = {
      author: req.user._id,
    }
    if (state !== "all") {
      search.state = state
    }
    const blogs = await Blog.find(search)
      .sort(sort)
      .skip((page - 1) * limit)
      .limit(limit)
    const count = await Blog.countDocuments(search)
    res.json({
      blogs,
      count,
      page,
      limit,
    })
  } catch (error) {
    console.error(error)
    res.status(500).send(error)
  }
}
// we can now export our functions to be used in a different module
module.exports = {
  createBlog,
  updateBlog,
  deleteBlog,
  getBlogs,
  getBlogById,
  getUserBlogs,
}

Next, we define the routes for the different endpoints in the API. These routes will call the appropriate controller functions based on the HTTP method and the authentication status of the user.. So create a routes folder and add the blog.routes.js file there

const express = require('express');
const router = express.Router();
const auth = require('../authenticate')
const {
  createBlog,
  updateBlog,
  deleteBlog,
  getBlogs,
  getBlogById,
  getUserBlogs
} = require('../controllers/blog.controllers')

router.post('/', auth, createBlog);
router.put('/:id', auth, updateBlog);
router.delete('/:id', auth, deleteBlog);
router.get('/', getBlogs);
router.get('/:id', getBlogById);
router.get('/me', auth, getUserBlogs);

module.exports = router;

We will create the auth function for authenticating users soon.

First, in the controllers folder, we create a user.controller.js file:

// import the required dependencies
const passport = require('passport');
const JWTStrategy = require('passport-jwt').Strategy;
const { ExtractJwt } = require('passport-jwt');
const User = require('../models/user');
// Set up the Passport strategy for JWT authentication
passport.use(
  new JWTStrategy(
    {
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.JWT_SECRET
    },
    async (payload, done) => {
      try {
        const user = await User.findById(payload._id);
        if (!user) {
          return done(null, false);
        }
        return done(null, user);
      } catch (error) {
        console.error(error);
        done(error);
      }
    }
  )
)
// create the signup route
router.post('/signup', async (req, res) => {
    try {
      const { email, password, firstName, lastName } = req.body;
      const user = new User({
        email,
        password,
        firstName,
        lastName
      });
      await user.save();
      res.status(201).send(user);
    } catch (error) {
      console.error(error);
      res.status(500).send(error);
    }
  })
// create the signin route
  router.post('/signin', async (req, res) => {
    try {
      const { email, password } = req.body;
      const user = await User.findOne({ email: req.body.email });
      if (!user) {
        return res.status(401).send({ error: 'Invalid login credentials' });
      }
      const token = user.generateAuthToken();
      res.send({ token });
    } catch (error) {
      console.error(error);
      res.status(500).send(error);
    }
  })

Now we create the authentication middleware.


const jwt = require('jsonwebtoken');
const { User } = require('./models/blog.model');

const auth = async (req, res, next) => {
  try {
    const token = req.header('Authorization').replace('Bearer ', '');
    const data = jwt.verify(token, process.env.JWT_SECRET);
    const user = await User.findOne({
      _id: data._id,
      'tokens.token': token
    });
    if (!user) {
      throw new Error();
    }
    req.user = user;
    req.token = token;
    next();
  } catch (error) {
    console.error(error);
    res.status(401).send({ error: 'Not authorized to access this resource' });
  }
};

module.exports = auth

Now we can use the auth in out routes file as shown above.