Creating a Medium-like API with Node.js and Mongodb: A step-by-step guide
Table of contents
Prerequisites
A quality knowledge of ExpressJS for creating web servers and MongoDB for storing users informations, also familiarity with the MVC architectural design would be nice.
Features
Signup, login and logout system
Authenticated users can perform the CRUD operations
Authenticated users can follow one another
Authenticated users can clap/like blog posts
Authenticated users can comment on blog post.
Packages required:
ExpressJS: is a framework based on Node. js which is used for building web-applications using approaches and principles of Node.
Mongoose: Mongoose is an Object Data Modeling (ODM) library for MongoDB and Node.js.
Jsonwebtoken: JWT is a technology for authentication and Authorization of users.
Cookie-parser: Cookie-parser is a middleware which parses cookies attached to the client request object.
Express-Rate-Limit: Express rate limit is a rate-limiting middleware for ExpressJS.
Bcryptjs: This package is used for encrypting data before they get stored in the database.
dotenv: The dotenv package is a great way to keep passwords, API keys, and other sensitive data out of our code.
Nodemon: The nodemon package is used to refresh our code anytime a change is been made. You can install the package using
npm i nodemon--save-dev
To begin initialize the project by running
npm init
This will walk you through a process of creating a package.json file. You can fill in an answer for each prompt, or you can just press enter to use the default shown in parentheses.
When the package.json has been created successfully you can proceed by installing the required packages by running npm i 'package-name
.
In our package.json file we need to configure the nodemon package to be able to use it run our project.
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon index.js"
}
Creating the web server:
Create a new file named index.js. The index.js file would contain our web server and our middlewares. Importing the required packages into our file
const express = require('express');
const cookie = require('cookie-parser');
const mongoose = require('mongoose');
const rateLimiter = require('express-rate-limit')
const app = express();
Connecting to our Mongodb database,
mongoose.connect('mongodb://127.0.0.1:27017/medium', {UseNewUrlParser: true}).then(()=>{
app.use(express.urlencoded({extended: true}))
app.use(express.json())
app.use(cookie())
Configuring our rate limiter used for limiting repeated request to our endpoints
app.use(rateLimiter({
windowMs: 0.25 * 60 * 1000,
max: 5,
message: "To many request from this IP address, try again under 15secs" ,
standardHeaders: true,
legacyHeaders: false
}))
Where windowMs describes the time frame for which request are checked.
max is the amount of request that a user can send within that time frame.
message is the response a user gets when the user has expired the maximum request he/she can send.
Creating middleware for handling crashes on the server.
app.use((err, req, res, next)=>{
console.log(err.message)
return res.status(500).send('Server down...')
next()
})
app.listen(process.env.PORT, ()=>{
console.log("Server running")
})
})
Creating our users and blogs schema:
We include both schemas in a model folder.
In the users.js file create the schema for a user define the datas that would be needed from each users
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
fullname:{
type:String,
required:true
},
email:{
type:String,
required:true
},
password:{
type:String,
required:true
},
followers:[{
author:{
type:mongoose.Schema.Types.ObjectId,
ref:"user"
}
}],
following:[{
author:{
type:mongoose.Schema.Types.ObjectId,
ref:"user"
}
}]
})
userSchema.methods.follower = function(d){
this.followers.push(d)
return this.save()
}
Once the schema is ready we export it by doing
module.exports = mongoose.model("user", userSchema)
In blogs.js file define the blogs schema
const mongoose = require('mongoose');
const articleSchema = new mongoose.Schema({
title: String,
description: String,
text: String,
tags:[{type:String}],
author: {
type: String,
ref:"user"
},
claps:{
type:Number,
default: 0
},
read_count:{
type: Number,
default: 0
},
read_time:{
type:Number,
default: 0
},
comments:[
{
author:{
type: mongoose.Schema.Types.ObjectId
,
ref:"user"
},
text: String
}
]
});
articleSchema.methods.clap = function(){
this.claps++
return this.save()
}
articleSchema.methods.count = function(){
this.read_count++
return this.save()
}
articleSchema.methods.comment = function(c){
this.comments.push(c)
return this.save()
}
module.exports = mongoose.model('article', articleSchema)
Creating our routes
Create a new folder called "controller"
Working on the users route
Import the required packages into the file
const express = require('express')
const jwt = require('jsonwebtoken')
const bcrypt = require("bcryptjs")
Importing the users schema,
const user = require('../Model/user')
const article = require('../Controllers/article')
const route = express.Router()
Creating the users sign up route,
route.post('/signup', async(req, res)=>{
const {fullname, email, password}= req.body
Hashing the users password before storing in the database
const hashed = await bcrypt.hash(password, 12)
req.body.password = hashed
const check = await user.findOne({email: email})
if(check)
return res.status(403).send('Email already assigned to a user')
}
const author = new user({
fullname:req.body.fullname,
email: req.body.email,
password: req.body.password
})
await author.save();
res.status(201).send("Author Created")
})
Working on the user login route
route.post('/login', async(req, res)=>{
const {email, password} = req.body
Check if the email been sent by the client exist in the database
const userExist = await user.findOne({email: email})
if(!userExist){
return res.status(403).send('User not found')
}
Comparing the password gotten from the client with that which is been stored in the database
const checkPassword = await bcrypt.compare(password, userExist.password)
if(!checkPassword){
return res.status(403).send("Password incorrect")
}
Assigning each user with a unique token whenever they try to login and storing the token with the cookie-parser package
const token = jwt.sign({id: userExist._id, date: new Date()}, process.env.SECRET, {expiresIn: '2hr'})
res.cookie('access_token', token, {
httpOnly:true,
secure:false
}
).status(200).send('Yay!!! login successful')
})
Middleware responsible for verifying the users token
const auth = async (req, res, next)=>{
const token = req.cookies.access_token
const verification = jwt.verify(token, process.env.SECRET)
if(!verification){
return res.ststus(403).send('Forbidden')
}
const currentUser = await user.findById(verification.id)
req.user = currentUser
next();
}
Follow routes, which would enable authenticated users follow one another.
route.post('/follow/:id', auth, async(req, res)=>{
if(req.user._id === req.params.id){
return res.send('Restricted from following yourself');
}
const author = await user.findById({_id: req.params.id})
const action = author.follower({
author: req.user._id
})
res.status(200).send('You followed')
})
The users profile route gets data of the user whose Id was specified in the endpoint.
route.get('/user/:id', async(req, res)=>{
const person = await user.findById({_id: req.params.id})
if(!person){
return res.status(404).send('User not found')
}
const authIFollow = await user.aggregate([
{
$match:{followers:{$elemMatch:{author: person._id}}}
}
])
person.following = []
authIFollow.forEach((user)=>{
person.following.push({author: user._id})
})
person.save();
res.status(200).send(person)
})
Logout feature
When an authenticated user request hits this endpoint the token assigned to the user is been cleared from the cookies therefore making the user unauthenticated.
route.post('/logout', auth, async(req, res)=>{
return res.clearCookie('access_token').send("You logged out")
})
Exporting our user route.
module.exports = route
With the user routes completed the next stage is to work on the blogs routes. Import the necessary package and modules into the file you can name it blogRoutes.js,
const express = require('express')
const jwt = require('jsonwebtoken')
const article = require('../Model/article')
const userSchema = require('../Model/user')
const user = require('../Controllers/user')
const router = express.Router()
Middleware to verify if a user is authenticated
const authorization = async (req, res, next)=>{
const token = req.cookies.access_token
const verification = jwt.verify(token, process.env.SECRET)
if(!verification){
return res.ststus(403).send('Forbidden')
}
const user = await userSchema.findById(verification.id)
req.user = user
next();
}
The /publish route would enable authenticated users to publish new blogs and save them to the database
router.post("/publish", authorization, async(req, res)=>{
const {read_time, text} = req.body
Solution to calculate the amount of words in a users blog before saving it.
const amountOfWords = text.split(" ").length
const timeTaken = Math.round(amountOfWords / 200)
req.body.read_time = timeTaken
const story = new article({
title: req.body.title,
description: req.body.description,
text: req.body.text,
tags:req.body.tags,
read_time:req.body.read_time,
author: req.user.fullname
})
await story.save();
res.status(201).send("Blog Sucessfully published")
})
Creating our clap feature that would enable users clap for a specific blog post by getting the unique ID of that post.
router.post('/clap/:id', async(req, res)=>{
const story = await article.findById({_id: req.params.id})
The .clap() method call refers to the method defined in our blog schema.
const clap = story.clap()
res.send('You clapped')
})
Commenting on users post is made possible by getting the post by its ID, each blog post has an array of comments field defined in the schema.
router.post('/comment/:id', authorization, async (req, res)=>{
const {comments} = req.body
const story = await article.findById({_id: req.params.id})
The . comment () method takes in an object containing the text and the users id who made the comment and save it to the database.
const addComment = story.comment({
text: req.body.text,
author: req.user._id
})
res.status(201).send('Commented Successfully')
})
The /allStories route is used to fetch all the blogs been stored in the database, any user is allowed to send request to this endpoint.
router.get('/allStories', async(req, res)=>{
const stories = await article.find()
res.send(stories)
})
A particular blog post can be fetched successfully from the database by using its unique ID.
router.get('/story/:id', async(req, res)=>{
const story = await article.findById({_id: req.params.id})
This particular line of code is used to count how many times a post was fetched.
const counts = story.count()
res.status(200).send(story)
})
Authenticated users are also allowed to see their personal blogs
router.get('/personalBlogs', authorization, async(req, res)=>{
const stories = await article.aggregate([
{
$match:{author: req.user.fullname}
}
])
if(stories.length === 0 || !stories){
return res.status(404).send("You've got no story")
}
res.send(stories)
})
Users who are authenticated can filter posts by their tags
router.get('/blog', authorization, async(req, res)=>{
const filter = await article.find({tags: { $in: [req.query.tags] }})
if(filter.length === 0){
return res.status(404).send("Couldn't find any post")
}
return res.status(200).send(filter)
})
For users to be able to update their blogs we create a patch route that gets the currently logged in users blogs by it ID and updates it,
router.patch('/updateBlogs/:id', authorization, async(req, res)=>{
const getBlog = await article.findById(req.params.id)
if(getBlog.author !== req.user.fullname){
return res.status(403).send('You cant update other authors blog')
}
const edit = await article.findByIdAndUpdate({_id: req.params.id}, {
title: req.body.title,
text: req.body.text,
description: req.body.description,
tags: req.body.tags
})
res.status(201).send('updated')
})
The delete operation also enables users to delete their blogs by getting the blog post by it ID and checks if the author of the blog is same as the user trying to perform the operation.
router.delete('/delblogs/:id', authorization, async(req, res)=>{
const getBlog = await article.findById(req.params.id)
if(getBlog.author !== req.user.fullname){
return res.status(403).send('You cant delete other authors blog')
}
await article.findByIdAndDelete({_id: req.params.id})
res.send('Blog deleted successfully')
})
we export the router to the server.
module.exports = router
Finally, don't forget to create the .env file where variables like the port number, database URI string or other secrets are stored.