Altschool Africa is an institution that takes a non-traditional approach to learning by teaching courses directly connected with a selected track.
I am a student of Altschool Africa enrolled in their school of engineering with the main focus of Backend engineering using javascript. We were given an assignment to practice the skills we had learned so far, the task was to build a blogging API with the following functionalities:
- Users should have their first name, last name, email, and password.
- The user should be able to sign up and sign in to the blog app
- Use JWT as an authentication strategy and expire the token after 1 hour
- A blog can be in two states: draft and published
- Logged-in and not logged in users should be able to get a list of published blogs created
- Logged-in and not logged in users should be able to get a published blog
- Logged-in users should be able to create a blog.
- When a blog is created, it is in a draft state
- The owner of the blog should be able to update the state of the blog to publish
- The owner of the blog should be able to edit the blog in a draft or published state
- The owner of the blog should be able to delete the blog in a draft or published state
- The owner of the blog should be able to get a list of their blogs.
- The endpoint should be paginated and filterable by state
- When a single blog is requested, the API should return the user information(the author) with the blog. The read_count of the blog too should be updated by 1.
After successfully building the API we were told to document the process, which is what I will be doing in this article.
Requirements
To understand or follow along with this article, you will need:
- Knowledge of JavaScript
- Familiarity with nodejs
- An IDE such as VScode
- MongoDB either local or on the cloud
Setting up a basic server
I started by creating a basic server using express and nodemon with the following steps:
Navigate to the folder where you want to build the app and run npm init -y
in the terminal to create the package.json file, the -y
flag is to accept all default options.
Install the required packages for the basic server by running npm install express nodemon
in the terminal, express is for creating the server and nodemon will be used to monitor the codebase for any changes.
Create an index.js
file and input the following code:
// Require express module
const express = require('express');
// Create an app using the express module
const app = express();
// Add a simple endpoint
app.get('/', (req, res) => {
res.send("Hello World")
})
// Listen for requests made to the server
app.listen(4000, (req, res) => {
console.log(`Server is running on port: 4000`)
})
- Run
nodemon index.js
in the terminal, using nodemon automatically restarts the server whenever any changes are made to it. Open your browser and go tolocalhost:4000
, you should see the text "Hello World", this means the server is functioning properly.
MVC Pattern
For this project I made use of the MVC architecture, this is a system design practice that divides the codebase into three parts which contain code for distinct activities. MVC stands for Models, Views, and Controllers, which contain code for the Database, User Interface, and Business Logic respectively.
Models
Models are used to interact with the database, they help to design the structure and format of database objects by creating schemas. I created one for both the users and the articles.
User Model
The user model contains all the required fields:
- first name
- last name
- password
- articles
Create a Models
folder and create a file called userModel.js
inside it, then input the following code:
// Require the mongoose package
const mongoose = require('mongoose');
// Instantiate the schema class from the mongoose package
const Schema = mongoose.Schema;
// Create a user schema with the mongoose schema
const UserSchema = new Schema({
firstName: {
type: String,
required: [true, "Please enter your first name"]
},
lastName: {
type: String,
required: [true, "Please enter your last name"]
},
email: {
type: String,
required: [true, "Please enter an email"],
unique: [true, "This email is already registered, sign in!"],
lowercase: true,
},
password: {
type: String,
required: true,
minLength: [5, "Password must be at least 5 characters"]
},
articles: {
type: Array,
}
}, { timestamps: true });
// This is a function for logging a user in
// more details in the userController section
UserSchema.statics.login = async function(email, password) {
const user = await this.findOne({ email });
if(!user){
throw Error('Incorect email, try again or sign up!');
}
const auth = await bcrypt.compare(password, user.password);
if(!auth){
throw Error('Incorrect password');
}
return user
}
// Create a user model with the user schema
const User = mongoose.model('users', UserSchema);
// Export the user Schema
module.exports = User;
The texts in the square brackets are error messages which are thrown in case certain validation is not met when filling in the fields. The timestamps: true
is used to automatically generate timestamps at the points where the object is created or updated. The login function is a static function created in the user model this makes it more secure and less prone to tampering.
Article Model
For the article model, I created the following fields
- title
- description
- author
- state
- read_count
- read_time
- tags
- body
Create a file called articleModels
in the Models folder and input the following code:
// Require the mongoose package
const mongoose = require('mongoose');
// Instantiate the schema class from the mongoose package
const Schema = mongoose.Schema;
// Create an article schema with the mongoose schema
const articleSchema = new Schema({
title: {
type: String,
required: [true, "Please provide the title"],
unique: [true, "The title name already exists"],
},
description: {
type: String,
},
author: {
type: String,
required: [true, "Please provide the author"],
},
state: {
type: String,
enum: ["draft", "published"],
default: "draft",
},
read_count: {
type: Number,
default: 0,
},
reading_time: {
type: String,
required: [true, "Please provide the reading time"],
},
tags: {
type: String,
required: [true, "Please provide the tags"],
},
body: {
type: String,
required: [true, "Please provide the body"],
}
}, {timestamps: true});
// Create an article model with the user schema
const Article = mongoose.model('articles', articleSchema);
// Export the article model
module.exports = Article;
When an article is created its state is set to "draft" by default, this can later be updated to "published" by the user. The read_count
property of the blog is also initially set to zero, other features like timestamps and id are auto-generated.
Routes
Routes are also another essential part of the application, as they connect the endpoints to the controllers that carry out the blog functions. Adding all the routes to the main page can make things a little overcrowded, hence I created a "routes" folder that would contain the necessary routes.
User routes
The userRoutes
file will contain all the routes for all user-related functions such as:
- /signup => For registering a user into the database
- /login => For logging in an existing user
- /logout => For logging out a user
When a certain endpoint is called it redirects the request to a controller which handles the request. The "userRoutes" file will contain the following code:
const express = require('express')
const userRouter = express.Router();
userRouter.post('/signup', () => {
// Function for signing a new user in goes here
res.send('Signs in a new user')
});
userRouter.post('/login', () => {
// Function for logging a user in goes here
res.send('Logs in an existing user')
});
userRouter.post('/logout', () => {
// Function for logging out a user goes here
res.send('Signs out a user')
});
module.exports = userRouter;
The above code creates a router, userRouter
using the express.router()
method the userRouter
will be connected to all the required endpoints and functions which will then be exported to the app.js
file where it will be used by the server.
The routes above were tested using postman, the messages in the res.send
help to identify that the routes are working properly.
Notice how all the endpoints are POST requests, this is because all user functions send data to the server.
Article routes
The articleRoutes
file contains endpoints for all the article-related requests, it contains the following code:
const express = require('express')
const blogRouter = express.Router();
blogRouter.get('/', () => {
// Function for getting all available blogs
res.send('Gets all available articles')
});
blogRouter.get('/user', () => {
// Function for getting all the articles by a user
res.send('Gets all articles by a user')
});
blogRouter.get('/:id', () => {
// Function for getting an article by id
res.send('Gets an article by id')
});
blogRouter.post('/', () => {
// Function for creating a new article
res.send('Creates a new article')
});
blogRouter.delete('/:id', () => {
// Function for updating an article
res.send('Updates an article')
});
blogRouter.patch('/:id', () => {
// Function for deleting an article
res.send('Deletes an article')
});
module.exports = blogRouter;
In this file, each endpoint signifies a single request which will be handled in the controller part. The texts in the brackets are simple placeholder messages to ensure that the endpoints are working properly when tested with postman.
The next step is to connect these routes to the server by updating the code in the index.js file to look like this:
...
// Require the routers
const userRouter = require('./routes/userRoutes')
const articleRouter = require('./routes/articleRoutes')
...
// Using the routers
app.use('/api/v1/user', userRouter)
app.use('/api/v1/blog', articleRouter)
...
The routers must come after the instantiation of the app, and they will be mapped using specific endpoints, for instance, a request to the api/v1/user/<something>
would first be transferred to the userRouter
in the userRoutes
file.
Controllers
The controllers contain the code for processing and handling requests, interacting with the database with the models, and sending responses back to the user.
Create a controllers
folder that will contain the two controllers, the controllers will be connected to the server by linking them with their respective route files.
User Controller
The userController
will contain functions that the sign-up, sign-in, and sign-out requests. The file contains the following code:
const User = require('../models/userModel');
const bcrypt = require('bcrypt')
The User
object is required from the userModel
and it will be used to interact with the database. The bcrypt
library is used for hashing and decrypting passwords, it will be used in the login and logout sections.
// Signup function
const signup = async (req, res) => {
// Checks if user already exists
const user = await User.findOne({ email: req.body.email })
if(user){
console.log("This user already exists, you have been logged in!")
// Redirect them to log in page
return res.redirect('/api/v1/user/login')
}
try{
// Creates new user and hashes the password
const user = new User(req.body);
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
await user.save();
// Returns the user data
return res.status(201).json({
status: "success",
message: "Sign up successful!, you have been automatically logged in",
data: {
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
articles: user.articles,
id: user._id
}
})
}
// Throws error if any
catch(err){
res.status(400).send(err.message)
}
}
When a new user is created via sign up, information like the name, email, and password, is taken from the request body and used to create a new user in the database. Once the user has been created successfully, the details are sent back as a response to the user.
// Login function
const login = async (req, res) => {
// Obtains email and password from request body
const { email, password } = req.body;
try{
// Uses login static function
const user = await User.login(email, password);
// Returns user data
res.status(201).json({
status: "success",
message: "You logged in successfully",
data: {
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
articles: user.articles,
id: user._id
}
});
}
// Throws error if any
catch(err){
res.status(400).send(err.message)
}
}
The login function takes two parameters from the request body: "email" and "password". These details are used to log the data into the database using the static login function in the userModel
file, but this only works if the user is already registered in the database.
Note that the user password is not sent as part of the response, this is a basic security measure.
const logout = (req, res) => {
// Implement log-out function later
res.send('Logged out successfully')
}
// Exports all the functions
module.exports = {
signup,
login,
logout
}
The logout
function will be implemented later as it has to do with clearing the JWT tokens, this will be discussed in the JWT section.
All three functions for the user controller are then exported so they can be required in the routes folder where they will be connected to their respective endpoints.
Blog controller
The blog controller file will contain all the code for handling all requests made to the blog object. The following code will go into this file:
// Require the article model
const Article = require('../models/articleModel');
// External functions required
const { checkUser } = require('../middleware/jwt');
const { readingTime } = require('../middleware/utils')
The readingTime
function is used to calculate the time it will take to read an article using the average reading speed of 180 words/minute. The checkUser
function is used to check the current logged-in user by decoding the JWT token and using the data to search for the user in the database.
JWT tokens will be discussed in the JWT authentication section.
const getAllArticles = async (req, res) => {
// Get pagination details from the query
const limit = parseInt(req.query.limit)
const offset = parseInt(req.query.skip)
// Get all published articles from database
const articles = await Article.find()
.where({ state: "published"})
.skip(offset)
.limit(limit)
// Error message, if there are no published blogs
if(!articles.length){
return res.json({
status: "failed",
message: "There are no published articles, check Drafts!"
})
}
// Apply pagination
const articleCount = articles.length
const currentPage = Math.ceil(articleCount % offset)
const totalPages = Math.ceil(articleCount / limit)
// Return published articles
res.status(200).json({
status: "success",
message: "All published articles",
total: articleCount,
page: currentPage,
pages: totalPages,
data: articles
})
}
The getAllArticles
function returns all the existing articles in the database. Pagination is implemented here as all the articles can be very large and it will be bad practice to return everything at once. All the articles will be returned page by page and the parameters for the pagination are passed in the URL, e.g https://localhost/api/v1/blog/?limit=2&offset=5
. The limit and offset properties are query parameters that will be parsed into the URL.
const getMyArticles = async (req, res) => {
// Get pagination details
const limit = parseInt(req.query.limit)
const offset = (req.query.limit)
// Check for the current user
const user = await checkUser(req, res)
// Get data from database
const userArticles = Article.find({ author: user.firstName})
.skip(offset)
.limit(limit)
// Throw error message if there are no blogs
if(!userArticles.length){
return res.json({
status: "failed",
message: "This user does not have any published articles"
})
}
// Apply pagination
const articleCount = userArticles.length
const totalPages = Math.ceil(articleCount / limit)
const currentPage = Math.ceil(articleCount % offset)
// Return article data
res.status(200).json({
status: "success",
message: `All articles, published by ${user.firstName}`,
total: articleCount,
page: currentPage,
pages: totalPages,
data: userArticles
})
}
The getMyArticles
function returns all the articles by a particular user, by checking through the database for articles where the author corresponds with the name of the logged-in user, the data is then paginated and sent to the user.
const getArticle = async (req, res) => {
// Get article from database with Id
const article = await Article.findById(req.params.id)
.where({ state: "published" })
// Throw error message if article is not found
if(!article){
return res.status(404).send("The Article you requested was not found")
}
// Increase read count
article.read_count++
article.save()
// Return data
res.status(200).json({
status: "success",
message: `Single article post: "${article.title}"`,
data: {
article
}
})
}
The getArticle
function gets a single article by searching through the database with the id
that is passed in the request parameter. It also increases the read_count
property every time a blog is requested.
const createArticle = async (req, res) => {
try{
// Get details from the request body
const { title, description, state, tags, body } = req.body;
// Check if article with that title exists
const exists = await Article.findOne({ title })
if(exists){
return res.json({
status: "failed",
message: "Article with that title already exists"
})
}
// Check for the current user
const user = await checkUser(req, res)
if(!user){
return res.json({
status: "failed",
message: "You need to be logged in to create a articlepost"
})
}
// Name of user is set to author of article
const article = new Article({
title,
description,
author: user.firstName,
state,
reading_time: readingTime(body),
tags: tags,
body,
})
// Save article to list of user articles
user.articles.push(article.title)
await user.save()
await article.save()
// Return article data
return res.json({
status: "success",
message: `${user.firstName} ${user.lastName} created "${article.title}"`,
data: article
});
}
catch(err){
res.status(500).send(err.message)
}
}
To create a new article details like the title, description, body, and tags are obtained from the request body and used to create an article with the respective fields. Other fields like the reading_time
, author
, and id
are auto-generated with the readingTime
function, checkUser
function, and the mongoose library respectively.
const deleteArticle = async (req, res) => {
// Get article from database
const article = await Article.findOne({ __id: req.params.id })
const user = await checkUser(req, res)
// Check if current user is the author of the article
if(user.firstName !== article.author){
return res.status(401).send({
message: "You are not authorized to delete this article"
})
}
// Remove article from list of user articles
const userArticles = user.articles
for(let i = 0; i < userArticles.length; i++){
if(userArticles[i] == article.title){
userArticles.splice(i, 1)
}
}
await user.save()
await Article.findByIdAndDelete(req.params.id)
// Return success message
res.json({
status: "success",
message: `${article.title} was deleted`,
})
}
The deleteArticle
function deletes a single article by searching through the database with the given id and removing it. It also removes the article from the array of the user's articles, it returns a success message when all this is completed.
const updateArticle = async (req, res) => {
// Get details from request body
const { title, description, state, tags, body } = req.body;
const user = await checkUser(req, res)
const article = await Article.findById(req.params.id)
// check if current user is the author of the article
if(user.firstName !== article.author){
return res.send("You are not authorised to update this article")
}
// Update article
const updatedArticle = await Article.findByIdAndUpdate({ _id: req.params.id }, {
$set: {
title,
description,
state,
tags,
body
}
}, { new: true })
// Return updated article
res.status(200).json({
status: "success",
message: `${updatedArticle.title} was updated`,
data: {
updatedArticle
}
})
}
The updateArticle
updates a single article in the database with the information passed into the request body. This action is only allowed by the owner of the article, so before carrying out the procedure we first check if the name of the current user is the same as the author of the article.
// Export all the functions
module.exports = {
getAllArticles,
getMyArticles,
getArticle,
createArticle,
deleteArticle,
updateArticle,
}
After all the functions are completed, they are exported so they can be connected to their respective endpoints in the routes folder.
After checking that these functions are working properly by connecting them to a database and testing them with postman, we will proceed to implement JWT authentication.
JWT authentication
As the API is now, users will need to log in every time they want to use the application, but with JSON Web Tokens(JWT) we can save the user information and allow users to access the website by simply checking if they have the token.
For this API I used cookies to store the JWT in the browser, to do this we will use the jsonwebtoken
package. A file named jwt
will house the code for the jwt implementation:
// Required packages
const jwt = require('jsonwebtoken');
const User = require('../models/userModel')
require('dotenv').config();
We start by requiring the userModel
object as this is what the JWT interacts with for signing in and logging in, lastly, we will require the dotenv
package, which allows us to work with environmental variables
Notice how the
dotenv
package is not assigned to a variable, it is just declared with the.config
attachment. This means the file is configured to accept environmental variables, which are called with theprocess.env
method. E.gprocess.env.JWT_SECRET
calls theJWT_SECRET
variable from the.env
file.
const maxAge = 60 * 60;
// Function for creating a token
const createToken = (id) => {
return jwt.sign({ id }, process.env.JWT_SECRET, {
expiresIn: maxAge
});
}
The createToken
function returns a jwt token which is set to expire in the time set by the maxage
variable, the jwt.sign()
function takes in two parameters: the information to be signed id
, and a secret which we will need when decoding the token.
// Function for authenticating a route
const requireAuth = (req, res, next) => {
const token = req.cookies.jwt;
if(!token){
console.log("No token")
return res.json({
status: "failed",
message: "You need to be logged in to continue this action"
})
}
jwt.verify(token, process.env.JWT_SECRET, (err, decodedToken) => {
if(err){
console.log(err.message)
return res.redirect('/api/v1/login');
} else {
next();
}
})
}
Some endpoints will require authentication, hence I created a requireAuth
function as a middleware which makes an endpoint only accessible to logged-in users. The requireAuth
function decodes the token which is stored in a cookie in the request object, and then verifies it, if successful the request proceeds to the endpoint, else the user is redirected to the login route.
// Function for checking the user
const checkUser = async (req, res) => {
const token = req.cookies.jwt;
if(!token){
console.log('No token, please login');
return null
}
const decodedToken = jwt.verify(token, process.env.JWT_SECRET)
const user = await User.findById(decodedToken.id)
return user
}
The checkUser
function decodes the jwt token obtains the decoded id and uses it to check through the database and obtain the current user. This information can then be used to carry out many things, for example, it can be used to automatically get the name of the current user and assign it to the author property whenever the user creates a blog. It can also be used to display the current author.
module.exports = {
createToken,
requireAuth,
checkUser
};
The jwt functions are then exported out of the file so they can be used wherever they are needed.
Implementing the jwt functions in the userControllers
file:
const User = require('../models/userModel');
const { createToken } = require('../middleware/jwt');
const bcrypt = require('bcrypt')
const signup = async (req, res) => {
/**
* Function to create a new user
*
* If the user already exists, log them in
*
* Creates a token with a maxAge of 1 hour
*
* Saves the token into a cookie called "jwt"
*
*
* If any error occurs send the error message to the user
*/
const user = await User.findOne({ email: req.body.email })
if(user){
console.log("This user already exists, you have been logged in!")
return res.redirect('/api/v1/user/login')
}
try{
const user = new User(req.body);
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
await user.save();
const token = createToken(user._id);
res.cookie('jwt', token, { maxAge: 60 * 60 * 1000 });
return res.status(201).json({
status: "success",
message: "Sign up successful!, you have been automatically logged in",
data: {
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
articles: user.articles,
id: user._id
}
})
}
catch(err){
res.status(400).send(err.message)
}
}
const login = async (req, res) => {
/**
* Function to log in a registered user
*
* Creates a token with a maxAge of 1 hour
*
* Saves the token into a cookie called "jwt"
*
*
* If any error occurs send them to the user because they are the user's problems now
*/
const { email, password } = req.body;
try{
const user = await User.login(email, password);
const token = createToken(user._id);
res.cookie('jwt', token, { maxAge: 60 * 60 * 1000 });
res.status(201).json({
status: "success",
message: "You logged in successfully",
data: {
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
articles: user.articles,
id: user._id
}
});
}
catch(err){
res.status(400).send(err.message)
}
}
/**
* Function to log out a user
*
* Sets the jwt cookie to an empty string that expires in 1 millisecond
*/
const logout = (req, res) => {
res.cookie('jwt', "", { maxAge: 1 });
res.send('Logged out successfully')
}
module.exports = {
signup,
login,
logout
}
The signup
function now adds a user to the database and redirects them to the login
route, which has a function that creates a jwt and also saves it to a cookie. For the logout
function, the jwt is removed by setting the value of the cookie to an empty string that expires instantly, this is because cookies cannot be deleted directly.
Implementing the authentication in the userRoutes
file:
// USER ROUTES
const express = require('express')
const userController = require('../controllers/userController');
const { requireAuth } = require('../middleware/jwt')
const userRouter = express.Router();
userRouter.post('/signup', userController.signup);
userRouter.post('/login', userController.login);
userRouter.post('/logout', requireAuth, userController.logout); // Secured
module.exports = userRouter;
Here only the logout function is authenticated, as a user will need to be logged in, to be logged out, ironic right?
In the article routes file the following routes will be authenticated:
const express = require('express')
const blogController = require('../controllers/blogController');
const { requireAuth } = require('../middleware/jwt');
const blogRouter = express.Router();
blogRouter.get('/', blogController.getAllArticles); // Not secured
blogRouter.get('/user', requireAuth, blogController.getMyArticles); // Secured
blogRouter.get('/:id', blogController.getArticle); // Not secured
blogRouter.post('/', requireAuth, blogController.createArticle); // Secured
blogRouter.delete('/:id', requireAuth, blogController.deleteArticle); // Secured
blogRouter.patch('/:id', requireAuth, blogController.updateArticle); // Secured
module.exports = blogRouter;
These routes need to be secured as requested by the project instructions, the only exception is the /user
route which obtains all the articles by the logged-in user. I protected this route because it uses the jwt token to obtain the results, and it would throw an error if the user was not logged in.
Problems I faced
Jwt auth
I faced a lot of problems with the jwt authentication as this was my first time using it, I tried following a lot of articles but I had trouble because my project structure was different from the ones in the tutorial, and navigating through the documentation was a tough thing for a beginner.
Code architecture
I constantly had to change my codebase to match the new features I was trying to implement. For instance, for me to write tests I would need to export the entire server to be able to properly test all the endpoints. So I had to create a new file, server.js
which would import the app connect to the database, and then listen on the port.
const app = require ("./src/app");
const mongoose = require('mongoose');
require("dotenv").config()
const PORT = process.env.PORT || 4000
const URL = process.env.DB_URL
//Connect to database
mongoose.connect(URL, { useNewUrlParser: true })
.then(() => console.log('Connected to database.....'))
.catch((err) => console.log('An error occured:', err.message))
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`)
})
Conclusion
The major takeaway I had is how there are multiple ways to do the same thing, from analyzing the projects of my peers to the codebase I saw in the various tutorials I watched, even though they were doing the same thing (building a simple blog API) they used varying methods to achieve different things.
The project was an experience to remember, and I am looking forward to building more projects in the future and sharing my journey.
Check out the GitHub repo to view the entire source code for the project
The code in the GitHub repo may differ from the ones in the article, as I am constantly improving the API and working toward applying views in the project.
Top comments (0)