DEV Community

Cover image for Implementing your Blog API Designs
Awosise Oluwaseun
Awosise Oluwaseun

Posted on

Implementing your Blog API Designs

After going through the API design and features we'd be implementing in our previous article, let us delve straight into the full implementation in codes

brace up codes ahead

Setting up our Server

Before we set up our server, let's take a look at connecting to our database. In our config folder, let's create a database.js file and copy and paste the code below:

const mongoose = require("mongoose")
const dotenv = require("dotenv")
dotenv.config({ path: "./config/.env" })

const MONGODB_URI =
  process.env.NODE_ENV === "test"
    ? process.env.TEST_MONGODB_URI
    : process.env.MONGODB_URI

const connectDB = async () => {
  await mongoose
    .connect(MONGODB_URI, {
      useNewUrlParser: true,
      useUnifiedTopology: true
    })
    .then((conn) => {
      console.log(
        `Conected to Mongo! Database name: ${conn.connections[0].name}`
      )
    })
    .catch((err) => console.error("Error connecting to mongo", err))
}

module.exports = connectDB
Enter fullscreen mode Exit fullscreen mode

The dotenv package allows us to read values from the .env file we created earlier using the process.env.VARIABLE_NAME command and mongoose is an ORM that helps relate with the MongoDB database. The MONGODB_URI is gotten from mongodb atlas. You can check out this video for refesher. The database is exported as a module to be used in another file.

Let's link this up with our server code shown below.

const app = require("./app")
const connectDB = require("./config/database")
const http = require("http")
const path = require("path")
const dotenv = require("dotenv")
dotenv.config({ path: path.join(__dirname, "./config/.env") })

const PORT = process.env.PORT || 4040
const server = http.createServer(app)

connectDB().then(() => {
  server.listen(PORT, async () => {
    console.log(`Server running on port ${PORT}`)
  })
})
Enter fullscreen mode Exit fullscreen mode

The connectDB() function is called and on resolving, the server is spawned. This ensures that we're connected to the database first in order to interact with it

This is a server running on our local machine and on the value of PORT from our .env file or 4040. The app in our server is the heart of all of our logic and this is where all the several pieces that makes up our blog API come together.

Having talked about setting up our server and connecting to the database, we also want to know who our database looks like, how the information collected are stored in an organised Schema created with mongoose

Models

Two Schemas(blog.js and users.js) were created for this project in the models folder in our project folder structure. One for the Blog and one for Users.

The Blog Model:

const mongoose = require("mongoose")

const Schema = mongoose.Schema
const objectId = Schema.Types.ObjectId

const BlogSchema = new Schema(
  {
    title: {
      type: String,
      required: [true, "Please enter a title"],
      unique: true
    },
    description: {
      type: String,
      required: [true, "Please enter a description"]
    },
    owner: {
      type: String
    },
    author: {
      type: objectId,
      ref: "User"
    },
    state: {
      type: String,
      default: "draft",
      enum: ["draft", "published"]
    },
    readCount: {
      type: Number
    },
    readingTime: {
      type: Number
    },
    tags: [String],
    body: {
      type: String,
      required: [true, "Please provide the blog content"]
    }
  },
  { timestamps: true }
)

//! Add the blog reading time before saving
BlogSchema.pre("save", function (next) {
  let blog = this
  let titleLength = blog.title.length
  let descriptionLength = blog.description.length
  let bodyLength = blog.body.length
  let totalLength = titleLength + descriptionLength + bodyLength
  let totalTime = Math.round(totalLength / 200)

  blog.readCount = 0
  blog.readingTime = totalTime == 0 ? 1 : totalTime

  blog.tags = blog.tags.map((tag) => tag.toLowerCase())

  next()
})

//! Delete certain fields before returning result to client
BlogSchema.set("toJSON", {
  transform: (document, returnedObject) => {
    delete returnedObject.__v
  }
})

const Blog = mongoose.model("Blog", BlogSchema)

module.exports = Blog
Enter fullscreen mode Exit fullscreen mode

The User Model:

const mongoose = require("mongoose")
const bcrypt = require("bcrypt")

const Schema = mongoose.Schema
const ObjectId = Schema.Types.ObjectId

const UserSchema = new Schema(
  {
    email: {
      type: String,
      required: true,
      unique: true
    },
    firstName: {
      type: String,
      required: true
    },
    lastName: {
      type: String,
      required: true
    },
    password: {
      type: String,
      required: true
    },
    repeatPassword: {
      type: String,
      required: true
    },
    userType: {
      type: String,
      default: "user",
      enum: ["admin", "user"]
    },
    blogs: [
      {
        type: ObjectId,
        ref: "Blog"
      }
    ]
  },
  { timestamps: true }
)

//! Encrypt password before saving it to database
UserSchema.pre("save", function (next) {
  let user = this

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

  bcrypt.hash(user.password, 8, (err, hash) => {
    if (err) return next(err)
    user.password = hash
    user.repeatPassword = hash
    next()
  })
})

//! Compare inputted password with the password in the database
UserSchema.methods.comparePassword = function (pword) {
  const passwordHash = this.password
  return new Promise((resolve, reject) => {
    bcrypt.compare(pword, passwordHash, (err, same) => {
      if (err) {
        return reject(err)
      }
      resolve(same)
    })
  })
}

//! Delete certain fields before returning result to client
UserSchema.set("toJSON", {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
    delete returnedObject.password
    delete returnedObject.repeatPassword
  }
})

const User = mongoose.model("user", UserSchema)
module.exports = User
Enter fullscreen mode Exit fullscreen mode

Both models represent the model of the information we want to recieve from our users and store in our database. The User model is used for authentication. They both also have pre hooks that run just before the data is saved to the database. The user password is hashed or encrypted before it is saved to the database using the bcrypt package. The user model has a method that is also used to compare user's inputted password with that in the database when trying to login.

The next thing we want to talk about are the endpoints but before then, let's look at some of the middleware codes we implemented so we can get the flow of request objects through the middlewares and the eventual request handler or controller.

Let's create a middleware folder as in our file structure and put inside the folder we create the auth.js, filter.js and paginate.js. This leads us to explaining the implementation of the middleware features discussed in the previous article.

Authentication

This middleware code is contained in the auth.js file we just created. It contains the following codes:

const passport = require("passport")
const localStrategy = require("passport-local").Strategy
const UserModel = require("../models/users")

const JWTstrategy = require("passport-jwt").Strategy
const ExtractJWT = require("passport-jwt").ExtractJwt

const path = require("path")
const dotenv = require("dotenv")
dotenv.config({ path: path.join(__dirname, "../config/.env") })

const { BadRequestError, UnauthenticatedError } = require("../errors")

const opts = {}
opts.secretOrKey = process.env.JWT_SECRET
opts.jwtFromRequest = ExtractJWT.fromAuthHeaderAsBearerToken()

module.exports = (passport) => {
  passport.use(
    new JWTstrategy(opts, async (token, done) => {
      try {
        const user = token.user
        return done(null, user)
      } catch (err) {
        done(err)
      }
    })
  )

  passport.use(
    "signup",
    new localStrategy(
      {
        usernameField: "email",
        passwordField: "password",
        passReqToCallback: true
      },
      async (req, email, password, done) => {
        try {
          const { firstName, lastName, repeatPassword } = req.body
          const userObject = {
            firstName,
            lastName,
            email,
            password,
            repeatPassword
          }

          const user = new UserModel(userObject)
          const savedUser = await user.save()

          return done(null, savedUser)
        } catch (err) {
          done(err)
        }
      }
    )
  )

  passport.use(
    "login",
    new localStrategy(
      {
        usernameField: "email",
        passwordField: "password"
      },
      async (email, password, done) => {
        try {
          if (!email || !password) {
            throw new BadRequestError("Please provide email or password")
          }
          const user = await UserModel.findOne({ email })

          if (!user) {
            throw new UnauthenticatedError("Please provide valid credentials")
          }

          const validate = await user.comparePassword(password)

          if (!validate) {
            throw new UnauthenticatedError("Please provide valid credentials")
          }

          return done(null, user, { message: "Logged in Successfully" })
        } catch (err) {
          done(err)
        }
      }
    )
  )
}
Enter fullscreen mode Exit fullscreen mode

Passport handles our authentication using the JWT strategy (a token based authentication). It also allows us to use the local Strategy to run bothe the sign up and login feature of the application. The JWT strategy allows for a token to be generated after it has been verified to release a payload containing information that can be used to access the next request handler.

Filter

This middleware code is contained in the filter.js file we just created. It contains the following codes:

const filterByPublished = (req, res, next) => {
  req.filterObject.state = "published"
  return next()
}

const filterAndSort = (req, res, next) => {
  const { tag, author, title, state, sort } = req.query
  req.filterObject = {}
  req.sortObject = {}


  try {
    if (tag) {
      const searchTag = Array.isArray(tag)
        ? tag.toLowerCase()
        : tag.split(", " || " " || ",").map((t) => t.toLowerCase())
      req.filterObject.tags = { $in: searchTag }
    }
    if (author) {
      req.filterObject.owner = { $regex: author, $options: "i" }
    }
    if (title) {
      req.filterObject.title = { $regex: title, $options: "i" }
    }

    if (sort) {
      const sortList = sort.split(",").join(" ")
      req.sortObject.sort = sortList
    } else {
      req.sortObject.sort = "createdAt"
    }

    if (state) {
      req.filterObject.state = state
    } else {
      req.filterObject.state = { $in: ["draft", "published"] }
    }

    return next()
  } catch (err) {
    next(err)
  }
}

module.exports = {
  filterByPublished,
  filterAndSort
}
Enter fullscreen mode Exit fullscreen mode

When we try to search for published blogs based on certain criteria such as authors, tags, titles, we have to add query to our request. These queries are collected in this middleware and passed on to the next request handler in order to be used.

Paginate

This middleware code is contained in the paginate.js file we just created. It contains the following codes:

const paginate = (req, res, next) => {
  req.paginate = {}
  const blogsPerPage = 20
  try {
    if (req.query.p) {
      const page = req.query.p
      const numOfBlogsToSkip = (page - 1) * blogsPerPage

      req.paginate.blogsPerPage = blogsPerPage
      req.paginate.numOfBlogsToSkip = numOfBlogsToSkip
      return next()
    }
    const page = 1
    let numOfBlogsToSkip = (page - 1) * blogsPerPage

    req.paginate.blogsPerPage = blogsPerPage
    req.paginate.numOfBlogsToSkip = numOfBlogsToSkip
    return next()
  } catch (err) {
    next(err)
  }
}

module.exports = paginate
Enter fullscreen mode Exit fullscreen mode

The paginate middleware sends the number of blogs per page and number of blogs to skip per page to the next request handler.

Now that we are done with the middlewares that preceed our controllers, let us talk about our Routes and Controllers.

Routes

Let's not forget the summary of the endpoints in our previous article in this series. We are going to be linking the routes in our endpoints to a particular controller and/or middleware
Let's take a look

The Blog Routes

const express = require("express")
const passport = require("passport")
const {
  createBlog,
  getAllBlogs,
  updateBlog,
  updateBlogState,
  getAllUsersBlogs,
  getBlogById,
  deleteBlog,
  getBlogByIdAuth
} = require("../controllers/blog")
const { filterAndSort, filterByPublished } = require("../middlewares/filter")
const paginate = require("../middlewares/paginate")
const validateBlog = require("../validators/blog.validator")

const blogRouter = express.Router()

blogRouter
  .route("/home/blog")
  .get(filterAndSort, filterByPublished, paginate, getAllBlogs)
blogRouter
  .route("/home/blog/:id")
  .get(filterAndSort, filterByPublished, getBlogById)

blogRouter.use("/blog", passport.authenticate("jwt", { session: false }))

blogRouter
  .route("/blog")
  .get(filterAndSort, paginate, getAllUsersBlogs)
  .post(validateBlog, createBlog)

blogRouter
  .route("/blog/:id")
  .get(filterAndSort, getBlogByIdAuth)
  .put(validateBlog, updateBlog)
  .patch(updateBlogState)
  .delete(deleteBlog)

module.exports = blogRouter
Enter fullscreen mode Exit fullscreen mode

The blog route makes use of the the express Router() method and we can see how it links our various routes to middlewares and controllers. It is important to note that the order of the middlewares are important and should be followed strictly. The user route follows the same pattern.

The input validator used in thr validateBlog function is a middleware that helps validate the user input withe the help of a package known as joi. You can check the validator folder in the source code link belw for it implementation.

The Controllers

They handle all of our CRUD operations. There are quite a number of controllers in this project and I would be picking three of them to talk about. One that requires authentication, one that does not then one that requires parameter.

The POST or create a new blog controller

const createBlog = async (req, res, next) => {
  const { id } = req.user
  const { title, description, body, tags } = req.body

  const user = await UserModel.findById({ _id: id })

  try {
    const blog = new BlogModel({
      title,
      description,
      owner: `${user.firstName} ${user.lastName}`,
      body,
      tags,
      author: id
    })

    const savedBlog = await blog.save()

    user.blogs = user.blogs.concat(savedBlog.id)
    await user.save()

    return res.status(201).json(savedBlog)
  } catch (err) {
    next(err)
  }
}
Enter fullscreen mode Exit fullscreen mode

The req.user is the payload handed down from the authentication middleware and we can see how we used our BlogModel to create a new blog and at the same time save the reference ID of the blog to the list of blogs created by that user.

The GET all published blog controller

const getAllBlogs = async (req, res, next) => {
  const filters = req.filterObject
  const { sort } = req.sortObject
  const { blogsPerPage, numOfBlogsToSkip } = req.paginate

  try {
    const blog = await BlogModel.find(filters, { title: 1, description: 1 })
      .sort(sort)
      .skip(numOfBlogsToSkip)
      .limit(blogsPerPage)

    return res.status(200).json({ count: blog.length, blogs: blog })
  } catch (err) {
    next(err)
  }
}
Enter fullscreen mode Exit fullscreen mode

This controller does not require authentication and we have the filterObject, sortObject and paginate attached to requests coming their respective middlewares. It returns just the title and description of all the blogs.

The GET blog published blog by ID controller

const getBlogById = async (req, res, next) => {
  try {
    const { state } = req.filterObject
    const { id } = req.params
    const blog = await BlogModel.findOneAndUpdate(
      { _id: id, state: state },
      { $inc: { readCount: 1 } },
      { new: true }
    )

    if (!blog) {
      throw new NotFoundError(`No blog with id ${id} to update`)
    }

    return res.status(200).json(blog)
  } catch (err) {
    next(err)
  }
}
Enter fullscreen mode Exit fullscreen mode

Getting blog by id requires that a parameter is added to the route in order to open that specific blog.

Everything we have discussed individually are brought together in the app.js file which can be checked in the source code provided below.

Wrapping Up

All of the codes above were abstracted from the main source code and should not be run separately on their own. The source code should be checked to further understanding and also to be able to link how all the modules work hand in hand.

As time goes on, specific parts of the code will be spoken upon with reference to this.

Let's talk about testing and deployment in our next article.

Top comments (0)