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