Overview of an API for a Blogging Platform

Overview of an API for a Blogging Platform

How to build a Blogging API using NodeJS, Express and MongoDB

Table of Content

Introduction

Prerequisite

Project Structure

Basic Functionality Implementation

The Middleware

The Model

The Service Layer

The controller

The Route

Validators

Conclusion

Introduction

Blogging has become an increasingly popular way for individuals and businesses to share information and connect with an audience. A blog, short for "weblog," is a website or section of a website where a person or organization writes about topics of interest. These posts, or "blog entries," are usually displayed in reverse chronological order, with the most recent post appearing first.

One of the key features of a blog is the ability for readers to leave comments on posts. This allows for a two-way conversation between the blogger and the readers and can lead to a sense of community and engagement.

In this article, we are going to explore a simple CRUD API with three core functionalities; the user, posts and comments. Now, let us get to work.

Prerequisite

  • Good Knowledge of Nodejs and Express framework

  • Good knowledge of MongoDB

  • Basic knowledge of how API works

Project Structure

Below image is the image of how we are going to set up our project folders. Create the necessary folder and let's get down to work.

Basic Functionality Implementation

So let us start by installing all the dependencies needed for the project. Below code block is of all dependencies used in this project.

 {
  "name": "memoir",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": 
    "start": "node index.js",
    "test": "jest supertest --runInBand --detectOpenHandles"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "bcrypt": "^5.1.0",
    "bcryptjs": "^2.4.3",
    "bluebird": "^3.7.2",
    "body-parser": "^1.20.1",
    "dayjs": "^1.11.6",
    "dotenv": "^16.0.3",
    "ejs": "^3.1.8",
    "express": "^4.18.2",
    "express-rate-limit": "^6.7.0",
    "helmet": "^6.0.0",
    "joi": "^17.7.0",
    "jsonwebtoken": "^8.5.1",
    "mongodb-memory-server": "^8.9.5",
    "mongoose": "^6.7.0",
    "mongoose-slug-generator": "^1.0.4",
    "mongoose-slug-updater": "^3.3.0",
    "morgan": "^1.10.0",
    "passport": "^0.6.0",
    "passport-jwt": "^4.0.0",
    "passport-local": "^1.0.0",
    "passport-local-mongoose": "^7.1.2"
  },
  "devDependencies": {
    "eslint": "^8.30.0",
    "eslint-config-standard": "^17.0.0",
    "eslint-plugin-import": "^2.26.0",
    "eslint-plugin-n": "^15.6.0",
    "eslint-plugin-promise": "^6.1.1",
    "husky": "^8.0.2",
    "jest": "^29.2.2",
    "prettier": "^2.8.1",
    "pretty-quick": "^3.1.3",
    "supertest": "^6.3.1"
  },
  "husky": {
    "hooks": {
      "pre-commit": "pretty-quick --staged"
    }
  }
}

Then, the files in the project folders and the code it contains are listed below;

  • create a dbConfig.js file in the config folder

      const moogoose = require('mongoose');
      require('dotenv').config();
    
      const MONGODB_URI = process.env.MONGODB_URI;
    
      // connect to mongodb
      function connectToMongoDB () {
        moogoose.connect(MONGODB_URI);
    
        moogoose.connection.on('connected', () => {
          console.log('Connected to MongoDB successfully');
        });
    
        moogoose.connection.on('error', (err) => {
          console.log('Error connecting to MongoDB', err);
        });
      }
    
      module.exports = { connectToMongoDB };
    
  • create an app.js file in the root folder

      const express = require('express');
      const logger = require('morgan');
      const path = require('path');
      const bodyParser = require('body-parser');
      const helmet = require('helmet');
      const authRouter = require('./src/routes/auth.routes');
      const userRouter = require('./src/routes/user.routes');
      const postRouter = require('./src/routes/post.routes');
      const commentRouter = require('./src/routes/comment.routes');
    
      const app = express();
    
      //  use passport middleware
      require('./src/middlewares/auth');
    
      // security middleware
      app.use(helmet());
    
      //  use logger middleware
      app.use(logger('dev'));
    
      //  middleware to serve public files
      app.use(express.static(path.join(__dirname, './src/public')));
    
      //  use body parsr middleware
      app.use(bodyParser.json());
      app.use(bodyParser.urlencoded({ extended: false }));
    
      //  set view engine
      app.set('view engine', 'ejs');
      app.set('views', 'views');
    
      //  add routes
      app.use('/accounts', authRouter);
      app.use('/posts', postRouter);
      app.use('/posts', commentRouter);
      app.use('/', userRouter);
    
      //  homepage route
      app.get('/', (req, res) => {
        res.status(200).json({
          message: 'welcome home'
        });
      });
    
      //  unavailable resources route
      app.get('*', (req, res, next) => {
        try {
          res.status(404).json({
            message: 'No page found, check url!!!'
          });
        } catch (error) {
          next(error);
        }
      });
    
      module.exports = app;
    
  • create an index.js file in the root folder

      const { connectToMongoDB } = require('./src/config/dbConfig');
      const app = require('./app');
      const errorHandler = require('./src/middlewares/errorHandler');
      require('dotenv').config();
    
      const PORT = process.env.PORT || 8000;
    
      //  database connection
      connectToMongoDB();
    
      //  add errorHandler
      errorHandler();
    
      app.listen(PORT, () => {
        console.log(`server is listening at http://localhost:${PORT}`);
      });
    
  • create an index.js file in the lib>error folder, this file holds all customized error messages

      class GenericError extends Error {
        statusCode;
    
        constructor (message, statusCode = 400) {
          super(message);
          this.name = this.constructor.name;
          this.statusCode = statusCode;
          Error.captureStackTrace(this, this.constructor);
        }
      }
    
      class ServiceError extends GenericError {
        static statusCode = 400;
      }
    
      class NotFoundError extends GenericError {
        static statusCode = 404;
    
        constructor (message) {
          super(message, NotFoundError.statusCode);
        }
      }
    
      class ValidationError extends GenericError {
        errors;
    
        static statusCode = 422;
    
        constructor (errors = []) {
          const message = `${errors[0]}`;
          super(message, ValidationError.statusCode);
          this.errors = errors;
        }
      }
    
      class AuthenticationError extends GenericError {
        static statusCode = 401;
    
        constructor (message) {
          super(message, AuthenticationError.statusCode);
        }
      }
    
      class AuthorizationError extends GenericError {
        static statusCode = 403;
    
        constructor (message = 'you are not authorized to perform this action') {
          super(message, AuthorizationError.statusCode);
        }
      };
    
      module.exports = {
        GenericError,
        ServiceError,
        NotFoundError,
        ValidationError,
        AuthenticationError,
        AuthorizationError
      };
    

The Middleware

  • create an errorHandler.js file in the middleware folder.

      const errorHandler = () => (error, req, res, next) => {
        console.log('path: ', req.path);
        console.log('error: ', error);
        if (error.type === 'Redirect') {
          res.redirect('error.html');
        } else if (error.type === 'Not found') {
          res.status(404).send(error);
        } else {
          res.status(500).send(error);
        }
        next();
      };
    
      module.exports = errorHandler;
    
  • create an auth.js file in the middleware folder. This holds the logic for the authentication and authorization of users.

      const passport = require('passport');
      const localStrategy = require('passport-local').Strategy;
      const User = require('../models/user.models');
      require('dotenv').config();
      const JWTstrategy = require('passport-jwt').Strategy;
      const ExtractJWT = require('passport-jwt').ExtractJwt;
      const { signupSchema, loginSchema } = require('../validation/auth.validation');
    
      passport.use(
        new JWTstrategy(
          {
            secretOrKey: process.env.JWT_SECRET,
            // jwtFromRequest: ExtractJWT.fromUrlQueryParameter('secret_token')
            jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken() // Use this if you are using Bearer token
          },
          async (token, done) => {
            try {
              return done(null, token.user);
            } catch (error) {
              done(error);
            }
          }
        )
      );
    
      // This middleware saves the information provided by the user to the database,
      // and then sends the user information to the next middleware if successful.
      // Otherwise, it reports an error.
      passport.use(
        'signup',
        new localStrategy(
          {
            usernameField: 'email',
            passwordField: 'password',
            passReqToCallback: true
          },
          async (req, email, password, done) => {
            try {
              const newUser = {};
              newUser.email = email.toLowerCase();
              newUser.password = password;
              newUser.username = req.body.username;
              newUser.firstname = req.body.firstname;
              newUser.lastname = req.body.lastname;
    
              try {
                await signupSchema.validateAsync(newUser);
              } catch (err) {
                done(err);
              }
    
              const user = await User.create(newUser);
              return done(null, user);
            } catch (error) {
              return done(error);
            }
          }
        )
      );
    
      // This middleware authenticates the user based on the email and password provided.
      // If the user is found, it sends the user information to the next middleware.
      // Otherwise, it reports an error.
      passport.use(
        'login',
        new localStrategy(
          {
            usernameField: 'email',
            passwordField: 'password'
          },
          async (email, password, done) => {
            email = email.toLowerCase();
    
            try {
              await loginSchema.validateAsync({ email, password });
            } catch (err) {
              done(err);
            }
    
            try {
              const user = await User.findOne({ email });
              if (!user) {
                return done(null, false, { message: 'User not found' });
              }
    
              const validate = await user.isValidPassword(password);
    
              if (!validate) {
                return done(null, false, { message: 'username or password is incorrect' });
              }
    
              return done(null, user, { message: 'Logged in Successfully' });
            } catch (error) {
              return done(error);
            }
          }
        )
      );
    

The Model

  • Let us create the blog models. Firstly, we start with the user model. Create a user.models.js file in the models' folder

      const mongoose = require('mongoose');
      const bcrypt = require('bcrypt');
      const Schema = mongoose.Schema;
    
      //  Define user Schema
      const UserSchema = new Schema(
        {
          firstname: {
            type: String,
            required: true
          },
          lastname: {
            type: String,
            required: true
          },
          username: {
            type: String,
            unique: [true, 'username already exist!!!'],
            min: [3, 'username cannot be lesser than 3characters, got {value}'],
            max: [15, 'username cannot be more than 15characters, got {value}'],
            required: true
          },
          email: {
            type: String,
            required: [true, 'email is required'],
            unique: [true, 'user already exists!!!'],
            match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/]
          },
          password: {
            type: String,
            required: true,
            match: [
              /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9]).{8,1024}$/,
              'password must contain, at least a capital letter, at least a small letter, at must be at least 8 characters long'
            ]
          },
          intro: {
            //  brief introduction of the Author to be displayed on each post
            type: String,
            max: 255
          },
          urlToImage: {
            type: String
          },
          posts: [
            {
              type: Schema.Types.ObjectId,
              ref: 'Posts'
            }
          ],
          comments: [
            {
              type: Schema.Types.ObjectId,
              ref: 'Posts'
            }
          ],
          createdAt: {
            type: Date,
            default: Date.now
          },
          updatedAt: {
            type: Date,
            default: Date.now
          }
        },
        {
          toJSON: {
            virtuals: true,
            transform: function (doc, ret) {
              ret._id = ret.id;
              delete ret.password;
              delete ret.__v;
              return ret;
            }
          }
        }
      );
    
      UserSchema.index({
        posts: 1
      });
    
      //  method to hash password beefore saving to database
      UserSchema.pre('save', async function (next) {
        const user = this;
    
        //  if password is modified, do nothing
        if (!user.isModified('password')) return next();
    
        this.email = this.email.toLowerCase();
        //  hash password
        const hash = await bcrypt.hash(this.password, 10);
    
        this.password = hash;
        next();
      });
    
      // user trying to log in has the correct credentials.
      UserSchema.methods.isValidPassword = async function (password) {
        const user = this;
        const compare = await bcrypt.compare(password, user.password);
    
        return compare;
      };
    
      const User = mongoose.model('users', UserSchema);
    
      module.exports = User;
    
  • Create a post.models.js file in the models' folder

      const mongoose = require('mongoose');
      const Schema = mongoose.Schema;
      require('../models/user.models');
      const slug = require('mongoose-slug-generator');
      const options = {
        separator: '-',
        truncate: 120
      };
      mongoose.plugin(slug, options);
    
      const PostSchema = new Schema({
        author: {},
        title: {
          type: String,
          max: 75,
          required: true
        },
        description: {
          type: String,
          max: 255
        },
        tags: [String],
        body: {
          type: String,
          required: true
        },
        readCount: {
          type: Number,
          default: 0
        },
        readingTime: {
          type: Number,
          required: true
        },
        state: {
          type: String,
          enum: ['draft', 'published'],
          default: 'draft'
        },
        publishedAt: {
          type: Date
        },
        slug: {
          type: String,
          slug: ['title', '_id'],
          unique: true,
          lowercase: true
        },
        comment: [
          {
            type: Schema.Types.ObjectId,
            ref: 'Comments'
          }
        ],
        createdAt: {
          type: Date,
          default: Date.now
        },
        updatedAt: {
          type: Date,
          default: Date.now
        }
      });
    
      //  index to fields that are not unique but will be used for ordering  for querying
      PostSchema.index({
        author: 1,
        title: 1,
        tags: 1,
        readCount: 1,
        readingTime: 1,
        state: 1,
        publishedAt: 1
      });
    
      //  presave hook to set description to title if description is not provided
      PostSchema.pre('save', function (next) {
        if (!this.description) {
          this.description = this.get('title');
        }
        next();
      });
    
      module.exports = mongoose.model('Posts', PostSchema);
    
  • Create a post.models.js file in the models' folder

      const mongoose = require('mongoose');
      const Schema = mongoose.Schema;
      require('../models/user.models');
    
      const CommentSchema = new Schema({
        author: {},
        post: {},
        body: {
          type: String,
          required: true
        },
        state: {
          type: String,
          enum: ['draft', 'published'],
          default: 'draft'
        },
        publishedAt: {
          type: Date
        },
        createdAt: {
          type: Date,
          default: Date.now
        },
        updatedAt: {
          type: Date,
          default: Date.now
        }
      });
    
      module.exports = mongoose.model('Comments', CommentSchema);
    

The Service Layer

  • Let us create the service layers. Firstly, we start with the user logic. Create a user folder in the models' folder. Then create a get.js file in the services>user folder.

    This logic enables users to make API calls to get the details of a particular user.

      const User = require('../../models/user.models');
      const { NotFoundError } = require('../../lib/errors');
    
      async function getUser (req, params, query) {
        const { username } = params;
    
        const user = await User.findOne({ username }).select({
          password: false,
          __v: false,
          posts: false,
          comments: false,
          id: false
        });
    
        if (!user) {
          throw new NotFoundError('user not Found');
        };
        return user;
      };
    
      module.exports = getUser;
    
  • In the services>user folder, create a getPosts.js file

    This logic enables users to make API calls to get published posts of other users. Users can also get both published and draft posts made by them.

      const User = require('../../models/user.models');
      const Post = require('../../models/post.models');
      const { NotFoundError } = require('../../lib/errors');
    
      async function getUserAllPosts (req, params, query) {
        const { state } = query;
        const { username } = params;
    
        const user = await User.findOne({ username }).select({
          password: false,
          __v: false,
          posts: false,
          id: false
        });
    
        if (!user) {
          throw new NotFoundError('user not Found');
        };
    
        let posts;
    
        if (user.email === req.user.email) {
          const filter = { 'author.username': username };
          if (state) {
            filter.state = state;
          }
          posts = await Post.find(filter);
        } else {
          posts = await Post.find({
            'author.username': username,
            state: 'published'
          });
        }
        if (!posts || posts.length === 0) {
          posts = `${username} has no post yet`;
        }
        return { posts, username };
      };
    
      module.exports = getUserAllPosts;
    
  • In the services>user folder, create a getComments.js file

    This logic enables users to make API calls to get published comments made on posts by other users. Users can also view both published and draft comments made by them.

      const User = require('../../models/user.models');
      const Comment = require('../../models/comment.models');
      const { NotFoundError } = require('../../lib/errors');
    
      async function getUserAllComments (req, params, query) {
        const { state } = query;
        const { username } = params;
    
        const user = await User.findOne({ username }).select({
          password: false,
          __v: false,
          posts: false,
          _id: false,
          id: false
        });
    
        if (!user) {
          throw new NotFoundError('user not Found');
        };
    
        let comments;
    
        if (user.email === req.user.email) {
          const filter = { 'author.username': username };
          if (state) {
            filter.state = state;
          }
          comments = await Comment.find(filter);
        } else {
          comments = await Comment.find({
            'author.username': username,
            state: 'published'
          });
        }
        if (!comments || comments.length === 0) {
          comments = `${username} has no comment yet`;
        }
    
        return { comments, username };
      };
    
      module.exports = getUserAllComments;
    
  • In the services>user folder, create a update.js file

    This logic enables users to make API calls to update their details.

      const User = require('../../models/user.models');
      const Post = require('../../models/post.models');
      const Comment = require('../../models/comment.models');
      const { NotFoundError, AuthorizationError } = require('../../lib/errors');
    
      async function updateUser (req, params, body) {
        const userDetails = body;
        const { username } = params;
    
        //  take in user's new update and add it to a new object
        //  exclude password and email in case the user added it as input
        //  password and email update require special methods
        const newDetails = {};
    
        for (const detail in userDetails) {
          if (detail !== 'email' && detail !== 'password' && detail !== 'posts') {
            newDetails[detail] = userDetails[detail];
          }
        }
    
        newDetails.updatedAt = new Date();
    
        const user = await User.findOne({ username });
    
        if (!user) {
          throw new NotFoundError('user not found');
        }
    
        if (user.email !== req.user.email) {
          throw new AuthorizationError('unauthorized');
        }
    
        await User.updateOne(
          { email: req.user.email },
          { $set: newDetails }
        );
    
        const post = await Post.find({ 'author.email': req.user.email });
    
        post.forEach(async element => {
          const newPost = { ...element.author, ...newDetails };
          element.author = newPost;
          await element.save();
        });
    
        const comment = await Comment.find({ 'author.email': req.user.email });
    
        comment.forEach(async element => {
          const newComment = { ...element.author, ...newDetails };
          element.author = newComment;
          await element.save();
        });
      }
    
      module.exports = updateUser;
    
  • In the services>user folder, create a delete.js file

    This logic enables users to make API calls to delete their accounts.

      const User = require('../../models/user.models');
      const { NotFoundError, AuthorizationError } = require('../../lib/errors');
    
      async function deleteUser (req, params) {
        const { username } = params;
    
        const user = await User.findOne({ username });
    
        if (!user) {
          throw new NotFoundError('user not found');
        }
    
        if (user.email !== req.user.email) {
          throw new AuthorizationError('unauthorized, user cannot delete other user account');
        }
    
        await User.deleteOne(
          { email: req.user.email }
        );
      };
    
      module.exports = deleteUser;
    
  • After implementing the user-service layer, we then proceed to implement the post-service layer.

    Create a post folder in the service folder. Then create a getMany.js file in the services>post folder. This logic enables users to make API calls to get all published posts. Posts can then be filtered and ordered by date and time of publication, tags and so on.

      const Post = require('../../models/post.models');
      const dayjs = require('dayjs');
      const { NotFoundError } = require('../../lib/errors/index');
    
      async function getManyPosts (query) {
        //  destructure query parameters
        const { page, limit, id, orderBy, tags, title, author, start, end } = query;
    
        //  deduct 1 from the page no before multiplying with the limit value
        //  if page is not provided in query, set to 0
        const pageNO = parseInt(page) || 1;
        const postLimit = parseInt(limit) || 20;
    
        const findQuery = [];
        //  check if author or title or tag is not provided in query params
        //  if none is provided pass in an empty string
        if (!author && !title && !tags && !id) {
          findQuery.push({});
        }
        //  if author is provided as a query params
        //  set the query in an object and push into the findquery array
        if (author) {
          findQuery.push({ 'author.username': author });
        }
    
        //  check for id
        if (id) {
          findQuery.push({ id: { $regex: `${id}`, $options: 'i' } });
        }
    
        //  if title is provided as a query params
        //  set the query to match the title as a regex in an object
        //  and push into the findquery array
        if (title) {
          findQuery.push({ title: { $regex: `${title}`, $options: 'i' } });
        }
    
        //  if tags is/are provided as a query params
        //  set the query to match document that match one/more of the tag(s) in an object
        //  and push into the findquery array
        //  tags should be separated by space or mysql standard '+' in the url
        if (tags) {
          const tagQuery = tags.split(' ');
          findQuery.push({ tags: { $in: tagQuery } });
        }
    
        //  query where published date is in the range start to end date
        //  where start and end is in the format dd-mm-yyyy
        //  and push to the findQuery array
        if (start && end) {
          const startDate = dayjs(start).format('YYYY-MM-DD');
          const endDate = dayjs(end).format('YYYY-MM-DD');
    
          findQuery.push({
            publishedAt: {
              $gt: dayjs(startDate).startOf('day'),
              $lt: dayjs(endDate).endOf('day')
            }
          });
        }
    
        //  sorting posts according to sort query given in query parameter
        //  by default, each query is sorted by publishedDate in descending order
        //  A sample url pattern for the sort query is:
        //  http://myapp.com/books?author+asc&datePublished+desc&title=loremipsum
        //  where "," separates the sort attributes
        //  while "+" separtes the field used for the sort and the sorting value
        const sortQuery = {};
    
        if (orderBy) {
          const sortAttributes = orderBy.split(',');
    
          for (const attribute of sortAttributes) {
            const sortField = attribute.split(' ')[0];
            const sortValue = attribute.split(' ')[1];
    
            if (sortValue === 'asc') {
              sortQuery[sortField] = 1;
              if (sortField === 'publishedAt') {
                sortQuery[sortField] = -1;
              }
            }
    
            if (sortValue === 'desc') {
              sortQuery[sortField] = -1;
              if (sortField === 'publishedAt') {
                sortQuery[sortField] = 1;
              }
            }
          }
        }
    
        if (!orderBy) {
          sortQuery.publishedAt = -1;
        }
    
        const post = await Post.find({ $or: findQuery, state: 'published' })
          .skip((pageNO - 1) * postLimit)
          .limit(postLimit)
          .sort(sortQuery)
          .select({ __v: false });
    
        if (!post) {
          throw new NotFoundError('post(s) not found');
        };
    
        if (post.length === 0) {
          const postProperties = { post: '0 post(s) found' };
          return postProperties;
        };
    
        // const totalPosts = post.length;
        // const totalPages = postLimit === 0 ? 1 : (totalPosts) / limit;
        const postProperties = { post, postLimit, pageNO };
        return postProperties;
      }
    
      module.exports = getManyPosts;
    
  • In the services>post folder, create a create.js file. This logic enables users to make API calls to create a post. Posts created are stored in drafts until published.

      const Post = require('../../models/post.models');
      const User = require('../../models/user.models');
      const postReadingTime = require('../../utils/reading_time');
      const { ServiceError } = require('../../lib/errors/index');
    
      async function createPost (req, body) {
        const newPost = body;
        //  `seperate tags(coming as strings) into array
        if (newPost.tags) {
          const tags = newPost.tags.replace(/\s/g, '').split(',');
          newPost.tags = tags;
        }
    
        //  user should not be able to set readCount and readingTime of post
        if (newPost.readCount) {
          delete newPost.readCount;
        }
    
        //  readingTime is calculated automatically from body
        if (newPost.readingTime) {
          delete newPost.readingTime;
        }
    
        if (newPost.state) {
          delete newPost.state;
        }
    
        //  calculate reading time
        const readingTime = await postReadingTime(newPost.body);
        newPost.readingTime = readingTime;
    
        //  add author
        const user = await User.findOne({ email: req.user.email }).select({
          password: false,
          __v: false,
          posts: false,
          id: false,
          updatedAt: false
        });
    
        newPost.author = user;
        const post = await Post.create(newPost);
    
        if (!post) {
          throw new ServiceError('An error while creating post!!!');
        }
    
        await User.updateOne({ _id: req.user._id }, { $push: { posts: post } });
        return post;
      };
    
      module.exports = createPost;
    
  • In the services>post folder, create a getOne.js file. This logic enables users to make API calls to get a published post made by them or other users. Users can also get both published and draft posts made by them.

      const Post = require('../../models/post.models');
      const { NotFoundError } = require('../../lib/errors/index');
    
      async function getOnePost (params) {
        const { slug } = params;
        const post = await Post.findOne({ slug: slug.toLowerCase() });
    
        if (!post) {
          throw new NotFoundError('post not found');
        };
    
        post.readCount = post.readCount + 1;
        await post.save();
    
        return post;
      }
    
      module.exports = getOnePost;
    
  • In the services>post folder, create a update.js file. This logic enables users to make API calls to update a published or draft post made by them.

      const Post = require('../../models/post.models');
      const Comment = require('../../models/comment.models');
      const postReadingTime = require('../../utils/reading_time');
      const { NotFoundError, AuthorizationError } = require('../../lib/errors/index');
    
      async function updateOnePost (req, params, body) {
        const { slug } = params;
        const postUpdate = body;
    
        //  seperate tags(coming as strings) into array
        if (postUpdate.tags) {
          const tags = postUpdate.tags.replace(/\s/g, '').split(',');
          postUpdate.tags = tags;
        }
    
        //  readCount and radingTime should can not be updated by the user
        if (postUpdate.readCount) {
          delete postUpdate.readCount;
        }
        if (postUpdate.readingTime) {
          delete postUpdate.readingTime;
        }
        //  check if update consists of changing state to pushided.
        if (postUpdate.state) {
          delete postUpdate.state;
        }
    
        //  calculate reading time
        if (postUpdate.body) {
          const readingTime = await postReadingTime(postUpdate.body);
          postUpdate.readingTime = readingTime;
        }
        postUpdate.updatedAt = new Date();
    
        const post = await Post.findOne({ slug: slug.toLowerCase() });
    
        if (!post) {
          throw new NotFoundError('post not found');
        };
    
        //  create new slug from title
        if (postUpdate.title) {
          const titleSlug = postUpdate.title.replaceAll(' ', '-').toLowerCase();
          const newSlug = titleSlug + '-' + post._id.toString();
          postUpdate.slug = newSlug;
        }
        //  create new description, if no description was added on while updating title
        if (!postUpdate.description && postUpdate.title) {
          postUpdate.description = postUpdate.title;
        }
        if (post.author.email !== req.user.email) {
          throw new AuthorizationError('user is not the owner of post, user cannot update post');
        } else {
          await Post.updateOne({ slug: slug.toLowerCase() }, { $set: postUpdate });
        }
    
        const comment = await Comment.find({ 'author.email': req.user.email });
    
        comment.forEach(async element => {
          const newPost = { ...element.post, ...postUpdate };
          element.author = newPost;
          await element.save();
        });
      };
    
      module.exports = updateOnePost;
    
  • In the services>post folder, create a delete.js file. This logic enables users to make API calls to delete a published or draft post made by them.

      const Post = require('../../models/post.models');
      const User = require('../../models/user.models');
      const { NotFoundError, AuthorizationError } = require('../../lib/errors/index');
    
      async function deletePost (req, params) {
        const { slug } = params;
    
        const user = await User.findOne({ _id: req.user._id });
    
        const post = await Post.findOne({ slug: slug.toLowerCase() });
    
        if (!post) {
          throw new NotFoundError('post not found');
        };
    
        if (post.author.email === req.user.email) {
          await post.deleteOne({ slug: slug.toLowerCase() });
    
          //  remove deleted post id from user.posts
          if (user) {
            const index = user.posts.indexOf(post._id);
            user.posts.splice(index, 1);
            await user.save();
          }
        } else {
          throw new AuthorizationError('user is not the owner of post, user cannot delete post');
        }
      };
    
      module.exports = deletePost;
    
  • In the services>post folder, create a publish.js file. This logic enables users to make API calls to publish a post.

      const Post = require('../../models/post.models');
      const { NotFoundError } = require('../../lib/errors/index');
    
      async function publishOnePost (params) {
        const { slug } = params;
    
        const post = await Post.findOne({ slug: slug.toLowerCase() });
    
        if (!post) {
          throw new NotFoundError('post not found');
        };
    
        await Post.updateOne({ slug: slug.toLowerCase() }, { $set: { state: 'published', publishedAt: Date.now() } });
    
        return post;
      }
    
      module.exports = publishOnePost;
    
  • After implementing the post-service layer, we then proceed to implement the comment-service layer.

    Create a comment folder in the service folder. Then create a create.js file in the services>comment folder. This logic enables users to make API calls to

    create a comment on a particular post. comments created are stored in drafts until published.

      const Post = require('../../models/post.models');
      const User = require('../../models/user.models');
      const Comment = require('../../models/comment.models');
      const { ServiceError, NotFoundError } = require('../../lib/errors/index');
    
      async function createNewPost (req, params, body) {
        const newComment = body;
        const { postId } = params;
    
        if (newComment.state) {
          delete newComment.state;
        }
    
        //  add author
        const user = await User.findOne({ email: req.user.email }).select({
          password: false,
          __v: false,
          posts: false,
          comments: false,
          id: false,
          updatedAt: false
        });
    
        newComment.author = user;
    
        const post = await Post.findOne({ _id: postId, state: 'published' }).select({
          __v: false,
          author: false,
          id: false,
          updatedAt: false,
          comment: false
        });
    
        if (!post) {
          throw new NotFoundError('You cannot comment on a post that have been deleted');
        }
    
        newComment.post = post;
    
        const comment = await Comment.create(newComment);
    
        if (!comment) {
          throw new ServiceError('An error occur while creating comment!!!');
        }
    
        await User.updateOne({ _id: req.user._id }, { $push: { comments: comment._id } });
    
        await Post.updateOne({ _id: postId }, { $push: { comment: comment._id } });
    
        return comment;
      };
    
      module.exports = createNewPost;
    
  • In the services>comment folder, create a getOne.js file. This logic enables users to make API calls to get a published comment made by them or other users. Users can also get both published and draft comments made by them.

      const Comment = require('../../models/comment.models');
      const { NotFoundError } = require('../../lib/errors/index');
    
      async function getComment (params) {
        const { commentId } = params;
        const comment = await Comment.findOne({ _id: commentId });
    
        if (!comment) {
          throw new NotFoundError('comment not found');
        };
    
        return comment;
      }
    
      module.exports = getComment;
    
  • In the services>post folder, create a update.js file. This logic enables users to make API calls to update a published comment made by them.

      const Comment = require('../../models/comment.models');
      const { NotFoundError, AuthorizationError } = require('../../lib/errors/index');
    
      async function updateOneComment (req, params, body) {
        const { commentId } = params;
        const commentUpdate = body;
    
        //  check if update consists of changing state to pushided.
        if (commentUpdate.state) {
          delete commentUpdate.state;
        }
    
        commentUpdate.updatedAt = new Date();
    
        const comment = await Comment.findOne({ _id: commentId });
    
        if (!comment) {
          throw new NotFoundError('comment not found');
        };
    
        if (comment.author.email !== req.user.email) {
          throw new AuthorizationError('user is not the owner of comment, user cannot update comment');
        };
    
        await Comment.updateOne({ _id: commentId }, { $set: commentUpdate });
      };
    
      module.exports = updateOneComment;
    
  • In the services>post folder, create a delete.js file. This logic enables users to make API calls to delete a published post made by them.

      const Post = require('../../models/post.models');
      const User = require('../../models/user.models');
      const Comment = require('../../models/comment.models');
      const { NotFoundError, AuthorizationError } = require('../../lib/errors/index');
    
      async function deleteOneComment (req, params) {
        const { postId, commentId } = params;
    
        const user = await User.findOne({ _id: req.user._id });
    
        const post = await Post.findOne({ _id: postId });
    
        const comment = await Comment.findOne({ _id: commentId });
    
        if (!comment) {
          throw new NotFoundError('comment not found');
        };
    
        if (comment.author.email !== req.user.email) {
          throw new AuthorizationError('user is not the owner of comment, user cannot delete comment');
        };
    
        await comment.deleteOne({ _id: commentId });
    
        //  remove deleted comment id from user.comment
        if (user) {
          const userCommentIndex = user.comments.indexOf(comment._id);
          user.comments.splice(userCommentIndex, 1);
          await user.save();
        }
    
        //  remove deleted comment id from post.comment
        if (post) {
          const postCommentIndex = post.comment.indexOf(comment._id);
          post.comment.splice(postCommentIndex, 1);
          await post.save();
        }
      };
    
      module.exports = deleteOneComment;
    
  • In the services>post folder, create a publish.js file. This logic enables users to make API calls to publish a comment on a particular post.

      const Comment = require('../../models/comment.models');
      const { NotFoundError } = require('../../lib/errors/index');
    
      async function publishOneComment (req, params) {
        const { commentId } = params;
        console.log(commentId);
    
        const comment = await Comment.findOne({ _id: commentId });
    
        if (!comment) {
          throw new NotFoundError('comment not found');
        };
    
        await Comment.updateOne({ _id: commentId }, { $set: { state: 'published', publishedAt: Date.now() } });
    
        return comment;
      }
    
      module.exports = publishOneComment;
    

The Controller

  • At this juncture, let us create the controllers. In the controller folder, create an auth.js file. This logic enables users to make API calls to authenticate users through signup and log-in functionalities.

      const jwt = require('jsonwebtoken');
      require('dotenv').config();
      const passport = require('passport');
    
      class AuthenticationController {
      //  create new user
        static userSignUp = async function (req, res, next) {
          res.status(201).json({
            message: 'Signup successful',
            user: req.user
          });
        };
    
        //  login existing user
        static userLogin = async (req, res, next) => {
          passport.authenticate('login', async (err, user, info) => {
            try {
              if (err) {
                return next(err);
              }
              if (!user) {
                res.json({
                  message: 'username or password is incorrect!!!'
                });
                return next();
              }
    
              req.login(user, { session: false }, async (error) => {
                if (error) return next(error);
    
                const body = { _id: user._id, email: user.email };
    
                const token = jwt.sign({ user: body }, process.env.JWT_SECRET, {
                  expiresIn: 3600
                });
                return res.json({ token });
              });
            } catch (error) {
              return next(error);
            }
          })(req, res, next);
        };
      };
    
      module.exports = AuthenticationController;
    
  • Next, in the controller folder, create a user.js file.

      const getUser = require('../services/user/get');
      const updateUser = require('../services/user/update');
      const deletetUser = require('../services/user/delete');
      const getUserAllPosts = require('../services/user/getPosts');
      const getUserAllComments = require('../services/user/getComments');
      // const userSchema = require('../validation/user.validation');
    
      class UserController {
        //  function to retrieve details of a specific user
        static getUserDetails = async (req, res, next) => {
          try {
            const user = await getUser(req, req.params, req.query);
    
            res.status(200).json({
              user
            });
          } catch (err) {
            next(err);
          }
        };
    
        // get user posts
        static getUserPosts = async (req, res, next) => {
          try {
            const post = await getUserAllPosts(req, req.params, req.query);
            res.status(200).json({
              message: `${post.username} has ${post.posts.length || 0} post(s)`,
              posts: post.posts
            });
          } catch (err) {
            next(err);
          }
        };
    
        //  get user comments return commet
        static getUserComments = async (req, res, next) => {
          try {
            const comments = await getUserAllComments(req, req.params, req.query);
    
            res.status(200).json({
              message: `${comments.username} has ${comments.comments.length || 0} comment(s)`,
              comments: comments.comments
            });
          } catch (err) {
            next(err);
          }
        };
    
        static updateUserDetails = async (req, res, next) => {
          try {
            await updateUser(req, req.params, req.body);
            res.status(200).json({
              message: 'user details updated successfully'
            });
          } catch (err) {
            next(err);
          }
        };
    
        //  delte account
        static deleteUserDetails = async (req, res, next) => {
          try {
            await deletetUser(req, req.params);
    
            res.status(200).json({
              message: 'user deleted successfuly'
            });
          } catch (err) {
            next(err);
          }
        };
      };
    
      module.exports = UserController;
    
  • Next, in the controller folder, create a post.js file.

      const getManyPosts = require('../services/post/getMany');
      const getOnePost = require('../services/post/getOne');
      const createnewPost = require('../services/post/create');
      const updateOnePost = require('../services/post/update');
      const publishOnePost = require('../services/post/publish');
      const deleteOnePost = require('../services/post/delete');
      const { NotFoundError, AuthorizationError } = require('../lib/errors/index');
      const postSchema = require('../validation/post.validation');
    
      class PostController {
        //  function to get posts
        static getPosts = async (req, res, next) => {
          try {
          //  check if only one else set aggregation pipeline to achieve and filtering
            const post = await getManyPosts(req.query);
    
            res.status(200).json({
              page: post?.pageNO,
              limit: post?.postLimit,
              message: post?.post
            });
          } catch (err) {
            next(err);
          }
        };
    
        //  function to get a specific post
        static getPost = async (req, res, next) => {
          try {
            const post = await getOnePost(req.params);
    
            if (post.state === 'published') {
              res.status(200).json({
                message: post
              });
            } else {
              throw new NotFoundError('you can only view published posts or draft post created by you!!!');
            };
          } catch (err) {
            next(err);
          }
        };
    
        //  function to create new post
        static createPost = async (req, res, next) => {
          try {
            try {
              const value = await postSchema.validateAsync(req.body);
              console.log(value);
            } catch (err) {
              next(err);
            }
    
            const post = await createnewPost(req, req.body);
            res.status(201).json({
              message: 'post created successfully',
              title: post.title,
              description: post.description,
              readCount: post.readCount,
              readingTime: post.readingTime,
              state: post.state,
              tags: post.tags,
              slug: post.slug
            });
          } catch (err) {
            next(err);
          }
        };
    
        //  function to update post
        static updatePost = async (req, res, next) => {
          try {
          //   try {
          //     const value = await postSchema.validateAsync(req.body);
          //     console.log(value);
          //   } catch (err) {
          //     next(err);
          //   }
    
            await updateOnePost(req, req.params, req.body);
    
            res.status(200).json({
              message: 'post updated successfully'
            });
          } catch (err) {
            next(err);
          }
        };
    
        // function to publish a post
        static publishPost = async (req, res, next) => {
          try {
            const post = await publishOnePost(req.params);
    
            if (post.author.email === req.user.email) {
              res.status(200).json({
                message: 'Post published successfully'
              });
            } else if (post.author.email !== req.user.email) {
              throw new AuthorizationError('user is not the owner of post, user cannot publish post');
            }
          } catch (err) {
            next(err);
          }
        };
    
        // function to delete a post
        static deletePost = async (req, res, next) => {
          try {
            await deleteOnePost(req, req.params);
    
            res.status(200).json({
              message: 'Post deleted successfully'
            });
          } catch (err) {
            next(err);
          }
        };
      };
    
      module.exports = PostController;
    
  • Next, in the controller folder, create a comment.js file.

      const getManyComments = require('../services/comment/getMany');
      const getOneComment = require('../services/comment/getOne');
      const createNewComment = require('../services/comment/create');
      const updateOneComment = require('../services/comment/update');
      const publishOneComment = require('../services/comment/publish');
      const deleteOneComment = require('../services/comment/delete');
      const { NotFoundError, AuthorizationError } = require('../lib/errors/index');
      const commentSchema = require('../validation/comment.validation');
    
      class CommentController {
        //  function to get all comments of a particular post
        static getComments = async (req, res, next) => {
          try {
          //  check if only one else set agrregation pipeline to achieve and filtering
            const comment = await getManyComments(req.query);
    
            res.status(200).json({
              result: `${comment?.totalComment} result(s) found`,
              totalPages: comment?.totalPages,
              currentPage: comment?.pageNO,
              limit: comment?.commentLimit,
              message: comment?.post
            });
          } catch (err) {
            next(err);
          }
        };
    
        //  function to get a specific comment
        static getComment = async (req, res, next) => {
          try {
            const comment = await getOneComment(req.params);
    
            if (comment.state === 'published') {
              res.status(200).json({
                message: comment
              });
            } else {
              throw new NotFoundError('you can only view published comment or draft comment created by you!!!');
            };
          } catch (err) {
            next(err);
          }
        };
    
        //  function to create new comment
        static createComment = async (req, res, next) => {
          try {
            try {
              await commentSchema.validateAsync(req.body);
            } catch (err) {
              next(err);
            }
    
            const comment = await createNewComment(req, req.params, req.body);
    
            res.status(201).json({
              message: 'comment created successfully',
              body: comment.body,
              state: comment.state
            });
          } catch (err) {
            next(err);
          }
        };
    
        //  function to update a comment
        static updateComment = async (req, res, next) => {
          try {
            try {
              await commentSchema.validateAsync(req.body);
            } catch (err) {
              next(err);
            }
    
            await updateOneComment(req, req.params, req.body);
    
            res.status(200).json({
              message: 'comment updated successfully'
            });
          } catch (err) {
            next(err);
          }
        };
    
        // function to publish a comment
        static publishComment = async (req, res, next) => {
          try {
            const comment = await publishOneComment(req, req.params);
    
            if (comment.author.email === req.user.email) {
              res.status(200).json({
                message: 'comment published successfully'
              });
            } else {
              throw new AuthorizationError('user is not the owner of comment, user cannot publish comment');
            }
          } catch (err) {
            next(err);
          }
        };
    
        // function to delete a comment
        static deleteComment = async (req, res, next) => {
          try {
            await deleteOneComment(req, req.params);
    
            res.status(200).json({
              message: 'comment deleted successfully'
            });
          } catch (err) {
            next(err);
          }
        };
      }
    
      module.exports = CommentController;
    

The Route

  • Next, let us create the routes. In the route folder, create an auth.js file.

      const AuthenticationController = require('../controllers/auth.controllers');
      const express = require('express');
      const passport = require('passport');
    
      const authRouter = express.Router();
    
      //  user signup route
      authRouter.post(
        '/signup',
        passport.authenticate('signup', {
          session: false
        }),
        AuthenticationController.userSignUp
      );
    
      //  user Signin route
      authRouter.post('/login', AuthenticationController.userLogin);
    
      module.exports = authRouter;
    
  • Next, In the route folder, create a user.js file.

      const express = require('express');
      const passport = require('passport');
      const UserController = require('../controllers/user.controllers');
    
      const userRouter = express.Router();
    
      //  route to get details/profile for a user
      userRouter.get(
        '/:username',
        passport.authenticate('jwt', { session: false }),
        UserController.getUserDetails
      );
    
      //  route to get a user post
      userRouter.get(
        '/:username/posts',
        passport.authenticate('jwt', { session: false }),
        UserController.getUserPosts
      );
    
      //  route to get details/profile fof a user
      userRouter.get(
        '/:username/comments',
        passport.authenticate('jwt', { session: false }),
        UserController.getUserComments
      );
    
      //  route to update details of a user
      userRouter.put(
        '/:username',
        passport.authenticate('jwt', {
          session: false
        }),
        UserController.updateUserDetails
      );
    
      //  route to delete account
      userRouter.delete(
        '/:username',
        passport.authenticate('jwt', {
          session: false
        }),
        UserController.deleteUserDetails
      );
    
      module.exports = userRouter;
    
  • Next, In the route folder, create a post.js file.

      const express = require('express');
      const passport = require('passport');
      const PostController = require('../controllers/post.controllers');
    
      const postRouter = express.Router();
    
      postRouter.get('/', PostController.getPosts);
    
      //  router to get a specific post by supplying post slug or id
      postRouter.get('/:slug', PostController.getPost);
    
      postRouter.post(
        '/',
        passport.authenticate('jwt', { session: false }),
        PostController.createPost
      );
    
      postRouter.put(
        '/:slug',
        passport.authenticate('jwt', { session: false }),
        PostController.updatePost
      );
    
      postRouter.put(
        '/:slug/publish',
        passport.authenticate('jwt', { session: false }),
        PostController.publishPost
      );
    
      postRouter.delete(
        '/:slug',
        passport.authenticate('jwt', { session: false }),
        PostController.deletePost
      );
    
      module.exports = postRouter;
    
  • Next, In the route folder, create a comment.js file.

      const express = require('express');
      const passport = require('passport');
      const CommentController = require('../controllers/comment.controllers');
    
      const commentRouter = express.Router();
    
      commentRouter.get('/:postId/comments', CommentController.getComments);
    
      //  router to get a specific post by supplying post slug or id
      commentRouter.get('/:postId/comments/:commentId', CommentController.getComment);
    
      commentRouter.post(
        '/:postId/comments',
        passport.authenticate('jwt', { session: false }),
        CommentController.createComment
      );
    
      commentRouter.put(
        '/:postId/comments/:commentId',
        passport.authenticate('jwt', { session: false }),
        CommentController.updateComment
      );
    
      commentRouter.put(
        '/:postId/comments/:commentId/publish',
        passport.authenticate('jwt', { session: false }),
        CommentController.publishComment
      );
    
      commentRouter.delete(
        '/:postId/comments/:commentId',
        passport.authenticate('jwt', { session: false }),
        CommentController.deleteComment
      );
    
      module.exports = commentRouter;
    

Validators

  • Finally, we are going to create the model validators that will protect the models' layer from receiving incorrect inputs

    Firstly, in the validation folder, create an auth.js file.

      const Joi = require('joi');
    
      const signupSchema = Joi.object({
        username: Joi.string()
          .alphanum()
          .min(3)
          .max(15)
          .required(),
    
        firstname: Joi.string()
          .alphanum()
          .required(),
    
        lastname: Joi.string()
          .alphanum()
          .required(),
    
        password: Joi.string()
          .pattern(/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9]).{8,1024}$/)
          .required(),
    
        email: Joi.string()
          .email()
          .required()
      });
    
      const loginSchema = Joi.object({
        password: Joi.string()
          .pattern(/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9]).{8,1024}$/)
          .required(),
    
        email: Joi.string()
          .email()
          .required()
      });
    
      module.exports = {
        signupSchema,
        loginSchema
      };
    
  • Next, in the validation folder, create a user.js file.

      const Joi = require('joi');
    
      const userSchema = Joi.object({
        username: Joi.string()
          .alphanum()
          .min(3)
          .max(15),
    
        firstname: Joi.string()
          .alphanum(),
    
        lastname: Joi.string()
          .alphanum(),
    
        intro: Joi.string(),
        urlToImage: Joi.string()
      });
    
      module.exports = userSchema;
    
  • Next, in the validation folder, create a post.js file.

      const Joi = require('joi');
    
      const postSchema = Joi.object({
        title: Joi.string()
          .required()
          .min(3)
          .max(75),
    
        description: Joi.string()
          .max(225),
    
        tags: Joi.string(),
    
        body: Joi.string()
          .required(),
    
        readCount: Joi.number()
          .default(0),
    
        readingTime: Joi.number(),
    
        state: Joi.string()
          .default('draft'),
    
        publishedAt: Joi.date(),
    
        slug: Joi.string()
          .trim()
          .lowercase()
      });
    
      module.exports = postSchema;
    
  • Next, in the validation folder, create a comment.js file.

      const Joi = require('joi');
    
      const commentSchema = Joi.object({
        body: Joi.string()
          .required(),
    
        state: Joi.number()
          .default('draft'),
    
        publishedAt: Joi.date()
    
      });
    
      module.exports = commentSchema;
    

Conclusion

So far, we have been able to complete the setup and overview of the project and implement the project models, service layers, controllers, routes and models validator. Run the API on your machine and test it out using postman.

Thanks