For my second semester examination at ALT-School, I was tasked with building a blogging API with Node and Express, and the blogging API was to have some functionalities.
This article provides a detailed walkthrough of how the project was implemented.
Creating a Node Server
I have an app.js file where I instantiate my express app and also contains all my middleware,and a server.js file where I start my express server.
App.js:
const express = require("express");
const dotenv = require("dotenv").config();
const morgan = require("morgan");
const authRoutes = require("./routes/auth");
const blogRoutes = require("./routes/blog");
const httpLogger = require("./logger/httpLogger");
const errorHandler = require("./src/middlewares/error-handler");
require("./src/middlewares/auth"); //signup and login middleware
const app = express();
// middlewares
app.use(express.json());
app.use(httpLogger);
app.use(authRoutes);
app.use(blogRoutes);
app.use(errorHandler);
module.exports = app;
Server.js:
const app = require("./app");
const logger = require("./logger/logger");
const connectDb = require("./database/connect");
const PORT = 4000;
app.listen(PORT, () => {
console.log(`listen on port ${PORT}`);
});
connectDb(
"mongodb+srv://tohbaba:Adeku1997@cluster0.htsowjn.mongodb.net/?retryWrites=true&w=majority"
);
Database
Made use of MongoDb as my database and mongoose library as my ODM. Created a database folder in my root directory and in the folder, I created a connect.js file that contained a function that accepts your connection URI as a parameter.
const mongoose = require("mongoose");
const connectDb = (uri) => {
mongoose
.connect(uri)
.then(() => console.log("db connection succesful"))
.catch((err) => console.log(err));
};
module.exports = connectDb;
Models
This project contained two models, a user model and a blog model
UserModel
const mongoose = require("mongoose");
const bcrypt = require("bcryptjs");
const Schema = mongoose.Schema;
const ObjectId = Schema.ObjectId;
const UserSchema = new Schema({
id: ObjectId,
first_name: {
type: String,
required: [true, "firstname must be provided"],
},
last_name: {
type: String,
required: [true, "lastname must be provided"],
},
email: {
type: String,
required: [true, "Please provide your email"],
unique: true,
validate: {
validator: async (value) => {
const userCollection = mongoose.connection.collections["users"];
const user = await userCollection.findOne({ email: value });
if (user) {
return false;
}
},
message: "The email address must be unique",
},
lowercase: true,
},
password: {
type: String,
required: [true, "A password must be provided"],
},
created_at: {
type: Date,
default: Date.now(),
},
});
//pre-hook to hash password and store it
UserSchema.pre("save", async function (next) {
const user = this;
const hash = await bcrypt.hash(this.password, 10);
this.password = hash;
next();
});
//validate password
UserSchema.methods.isValidPassword = async function (password) {
const user = this;
const compare = await bcrypt.compare(password, user.password);
return compare;
};
module.exports = mongoose.model("User", UserSchema);
Blog Model
const mongoose = require("mongoose");
const Schema = mongoose.Schema;
const ObjectId = Schema.ObjectId;
const BlogSchema = new Schema({
id: ObjectId,
title: {
type: String,
required: [true, "title must be provided"],
unique: true,
},
description: {
type: String,
},
body: {
type: String,
required: [true, "body must be provided"],
},
tags: [String],
author: {
type: String,
},
owner_id: {
type: String,
},
state: {
type: String,
enum: ["draft", "published"],
default: "draft",
},
read_count: {
type: Number,
default: 0,
},
reading_time: {
type: Number,
},
created_at: {
type: Date,
default: Date.now(),
},
updated_at: {
type: Date,
default: Date.now(),
},
});
module.exports = mongoose.model("Blog", BlogSchema);
Controllers
Created two blog controllers, one to handle all the requests that can only be accessed by authenticated users and the second blog controller to contain all the blog methods that can be accessed by any user(logged-in and not logged-in users)
Blog Controller:
The first Blog controller handles all the requests that can be performed by only authenticated users,and it contains the following methods.
Create Post:Only authenticated users can create a post,and when posts are created they are in draft state.
exports.create = async (req, res, next) => {
try {
const user = await User.findOne({ _id: req.user._id });
const blog = await Blog.create({
title: req.body.title,
description: req.body.description,
body: req.body.body,
state: blogStates.draft,
owner_id: req.user._id,
author: `${user.first_name} ${user.last_name}`,
reading_time: getTimeToRead(req.body.body),
tags: req.body.tags,
});
return res.status(201).json({
data: blog,
});
} catch (error) {
next(error);
}
};
View Post:Authenticated users can view a post created by them
exports.view = async (req, res, next) => {
try {
const { id } = req.params;
const blog = await Blog.findOne({
_id: id,
owner_id: req.user._id,
});
if (!blog) {
return res.status(404).json({
message: "Blog not found.",
});
}
return res.status(200).json({
data: blog,
});
} catch (error) {
console.log(error)
}
};
List Posts:Authenticated users can get all the posts created by them.The endpoint is also filterable by state and paginated.
exports.list = async (req, res, next) => {
try {
const criteria = {
owner_id: req.user._id,
};
// filter by state if asked to.
if (req.query.state && Object.keys(blogStates).includes(req.query.state)) {
criteria.state = req.query.state;
}
const { skip, size } = getPaginationMetadata(
req.query.page,
req.query.page_size
);
const blogs = await Blog.find(criteria).skip(skip).limit(size);
return res.status(200).json({
data: blogs,
});
} catch (error) {
next(error);
}
};
Update Post:Authenticated users can update posts created by them
exports.partialUpdate = async (req, res, next) => {
const { id } = req.params;
try {
const blog = await Blog.findOne({
_id: id,
owner_id: req.user._id,
});
if (!blog) {
return res.status(404).json({
message: "Blog not found.",
});
}
const blogBody = req.body.body || blog.body;
const updatedBlog = await Blog.updateOne(
{
_id: id,
owner_id: req.user._id,
},
{
$set: {
title: req.body.title || blog.title,
description: req.body.description || blog.description,
body: req.body.body || blog.body,
state: req.body.state || blog.state,
reading_time: getTimeToRead(blogBody),
updated_at: Date.now(),
},
},
{
new: true,
runValidators: true,
}
);
return res.status(204).send();
} catch (error) {
next(error);
}
};
Delete Post:
Authenticated users can delete a post created by them.
exports.delete = async (req, res, next) => {
try {
const { id } = req.params;
const blog = await Blog.findOne({
_id: id,
owner_id: req.user._id,
});
if (!blog) {
return res.status(404).json({
message: "Blog not found",
});
}
await Blog.deleteOne({ _id: id });
return res.status(200).send();
} catch (error) {
next(error);
}
};
Blog Controller:
The second blog controller handles all requests that can be performed by both logged-in and not logged-in users.
List Posts:Logged in and not logged in users can get a list of all the posts in published state.The endpoint is paginated,searchable and also orderable .
exports.list = async (req, res, next) => {
try {
const orderableFields = ["created_at", "read_count", "reading_time"];
const orderBy = orderableFields.includes(req.query.order_by)
? req.query.order_by
: "created_at";
const orderDirection = ["asc", "des"].includes(req.query.direction)
? req.query.direction
: "desc";
const criteria = {
state: blogStates.published,
};
if (req.query.author) {
criteria.author = { $regex: `.*${req.query.author}.*` };
}
if (req.query.title) {
criteria.title = { $regex: `.*${req.query.title}.*` };
}
if (req.query.tag) {
criteria.tags = req.query.tag;
}
const { skip, size } = getPaginationMetadata(req.query.page);
const blogs = await Blog.find(criteria)
.skip(skip)
.limit(size)
.sort({
[orderBy]: orderDirection === "desc" ? -1 : 1,
});
return res.status(200).json({
data: blogs,
});
} catch (error) {
next(error);
}
};
View Post:
Logged and not logged-in users can view a post in published state,and for every time the post is viewed ,the read-count is updated.
exports.view = async (req, res, next) => {
try {
const blog = await Blog.findOne({
_id: req.params.id,
state: blogStates.published,
});
if (!blog) {
return res.status(404).json({
message: "no blog with this id is published",
});
}
blog.user = await User.findOne({ _id: blog.owner_id });
// update read-count.
Blog.updateOne(
{
_id: blog._id,
},
{
$set: {
read_count: (blog.read_count || 0) + 1,
},
}
).catch((error) => {
console.error(error);
});
return res.status(200).json({
data: blog,
});
} catch (err) {
next(err);
}
};
Routes
Created two route files, the Authentication route file and the blog route file
Authentication Routes
This file consists of both the Register and Login routes.
const express = require("express");
const passport = require("passport");
const jwt = require("jsonwebtoken");
const dotenv = require("dotenv").config();
const { extractErrorResponse } = require("../src/utils/helpers");
const authRouter = express.Router();
authRouter.post(
"/signup",
passport.authenticate("signup", { session: false }),
async (req, res, next) => {
delete req.user.password;
delete req.user.password;
res.status(201).json({
message: "Signup Succesful",
user: req.user,
});
},
async (error, req, res, next) => {
if (error.name === "ValidationError") {
return res.status(422).json(extractErrorResponse(error));
}
console.error(error);
res.status(500).json({
message: "An error occurred.",
});
}
);
authRouter.post(
"/login",
passport.authenticate("login", { session: false }),
async (req, res, next) => {
const body = { _id: req.user._id, email: req.user.email };
const token = jwt.sign({ user: body }, process.env.JWT_SECRET, {
expiresIn: "1h",
});
return res.json({ token });
},
async (error, req, res, next) => {
return res.status(error.custom ? error.code : 500).json({
message: error.custom ? error.message : "something went wrong",
});
}
);
module.exports = authRouter;
Blog Routes
const express = require("express");
const passport = require("passport");
const userBlogController = require("../src/controllers/user/blogController");
const blogController = require("../src/controllers/blogController");
const blogRouter = express.Router();
// blog routes with user namespace
blogRouter.get(
"/user/blogs",
passport.authenticate("jwt", { session: false }),
userBlogController.list
);
blogRouter.get(
"/user/blogs/:id",
passport.authenticate("jwt", { session: false }),
userBlogController.view
);
blogRouter.post(
"/user/blogs",
passport.authenticate("jwt", { session: false }),
userBlogController.create
);
blogRouter.patch(
"/user/blogs/:id",
passport.authenticate("jwt", { session: false }),
userBlogController.partialUpdate
);
blogRouter.delete(
"/user/blogs/:id",
passport.authenticate("jwt", { session: false }),
userBlogController.delete
);
// unguarded blogs routes
blogRouter.get("/blogs", blogController.list);
blogRouter.get("/blogs/:id", blogController.view);
module.exports = blogRouter;
Blog routes with the user namespace are the routes that can only be accessed by authenticated users.
Authentication
Made use of passport-jwt to handle authentication.Created an auth middleware that contained both signup and login strategies.
Sign-up middleware:
passport.use(
"signup",
new localStrategy(
{
usernameField: "email",
passwordField: "password",
passReqToCallback: true,
},
async (req, email, password, done) => {
try {
const first_name = req.body.first_name;
const last_name = req.body.last_name;
const user = await User.create({
email,
password,
last_name,
first_name,
});
return done(null, user);
} catch (err) {
return done(err);
}
}
)
);
Login Middleware:
passport.use(
"login",
new localStrategy(
{
usernameField: "email",
passwordField: "password",
},
async (email, password, done) => {
try {
const user = await User.findOne({ email });passport.use(
"login",
new localStrategy(
{
usernameField: "email",
passwordField: "password",
},
async (email, password, done) => {
try {
const user = await User.findOne({ email });
if (!user) {
const error = new Error("User not found");
error.custom = true;
error.code = 400;
return done(error);
}
const validate = await user.isValidPassword(password);
if (!validate) {
const error = new Error("Wrong Password");
error.custom = true;
error.code = 400;
return done(error);
}
return done(null, user);
} catch (error) {
return done(error);
}
}
)
);
if (!user) {
const error = new Error("User not found");
error.custom = true;
error.code = 400;
return done(error);
}
const validate = await user.isValidPassword(password);
if (!validate) {
const error = new Error("Wrong Password");
error.custom = true;
error.code = 400;
return done(error);
}
return done(null, user);
} catch (error) {
return done(error);
}
}
)
);
Conclusion
The following steps outlines how I was able carry out the task.