Building a REST API Project - Part 1
Building a Complete REST API Project - Part 1
In this lesson, we'll start building a complete Task Management REST API from scratch. This project will incorporate all the concepts we've learned throughout the course, including authentication, database operations, validation, error handling, and security best practices.
Project Overview
We'll build a Task Management API with the following features:
- User registration and authentication (JWT)
- Create, read, update, and delete tasks
- Task categories and tags
- Task filtering and pagination
- File attachments for tasks
- User profile management
- Role-based access control
- API documentation
Step 1: Project Setup
First, let's create and initialize our project:
# Create project directory
mkdir task-management-api
cd task-management-api
# Initialize npm project
npm init -y
# Install core dependencies
npm install express mongoose dotenv bcryptjs jsonwebtoken
npm install express-validator express-async-errors
npm install cors helmet compression morgan
# Install development dependencies
npm install --save-dev nodemon
Step 2: Folder Structure
Create a well-organized folder structure for our project:
task-management-api/
├── src/
│ ├── config/
│ │ └── database.js
│ ├── models/
│ │ ├── User.js
│ │ ├── Task.js
│ │ └── Category.js
│ ├── controllers/
│ │ ├── authController.js
│ │ ├── taskController.js
│ │ ├── categoryController.js
│ │ └── userController.js
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── errorHandler.js
│ │ ├── validation.js
│ │ └── upload.js
│ ├── routes/
│ │ ├── auth.js
│ │ ├── tasks.js
│ │ ├── categories.js
│ │ └── users.js
│ ├── utils/
│ │ ├── ApiResponse.js
│ │ └── ApiError.js
│ └── app.js
├── uploads/
├── .env
├── .gitignore
├── package.json
└── server.js
Create the folder structure:
mkdir -p src/{config,models,controllers,middleware,routes,utils}
mkdir uploads
Step 3: Environment Configuration
Create a .env file for environment variables:
# .env
NODE_ENV=development
PORT=5000
# Database
MONGODB_URI=mongodb://localhost:27017/task-management
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=7d
# Security
BCRYPT_ROUNDS=10
# File Upload
MAX_FILE_SIZE=5242880
ALLOWED_FILE_TYPES=image/jpeg,image/png,application/pdf
.env file to version control. Add it to .gitignore immediately.Create a .gitignore file:
node_modules/
.env
uploads/
*.log
.DS_Store
Step 4: Database Configuration
Create the database configuration file (src/config/database.js):
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
});
console.log(`MongoDB Connected: ${conn.connection.host}`);
// Handle connection events
mongoose.connection.on('error', (err) => {
console.error('MongoDB connection error:', err);
});
mongoose.connection.on('disconnected', () => {
console.log('MongoDB disconnected');
});
// Graceful shutdown
process.on('SIGINT', async () => {
await mongoose.connection.close();
console.log('MongoDB connection closed through app termination');
process.exit(0);
});
} catch (error) {
console.error('Error connecting to MongoDB:', error.message);
process.exit(1);
}
};
module.exports = connectDB;
Step 5: Utility Classes
Create utility classes for API responses and errors (src/utils/ApiResponse.js):
class ApiResponse {
constructor(statusCode, data, message = 'Success') {
this.statusCode = statusCode;
this.success = statusCode < 400;
this.message = message;
this.data = data;
}
static success(data, message = 'Success', statusCode = 200) {
return new ApiResponse(statusCode, data, message);
}
static created(data, message = 'Resource created successfully') {
return new ApiResponse(201, data, message);
}
}
module.exports = ApiResponse;
Create API error class (src/utils/ApiError.js):
class ApiError extends Error {
constructor(statusCode, message, isOperational = true, stack = '') {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
this.success = false;
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
static badRequest(message = 'Bad Request') {
return new ApiError(400, message);
}
static unauthorized(message = 'Unauthorized') {
return new ApiError(401, message);
}
static forbidden(message = 'Forbidden') {
return new ApiError(403, message);
}
static notFound(message = 'Resource not found') {
return new ApiError(404, message);
}
static conflict(message = 'Conflict') {
return new ApiError(409, message);
}
static internal(message = 'Internal Server Error') {
return new ApiError(500, message, false);
}
}
module.exports = ApiError;
Step 6: User Model
Create the User model (src/models/User.js):
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const userSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
minlength: [2, 'Name must be at least 2 characters'],
maxlength: [50, 'Name cannot exceed 50 characters']
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email']
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: [6, 'Password must be at least 6 characters'],
select: false // Don't include password in queries by default
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
},
avatar: {
type: String,
default: null
},
isActive: {
type: Boolean,
default: true
},
refreshTokens: [{
token: String,
createdAt: {
type: Date,
default: Date.now,
expires: 604800 // 7 days in seconds
}
}]
}, {
timestamps: true
});
// Hash password before saving
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
const rounds = parseInt(process.env.BCRYPT_ROUNDS) || 10;
this.password = await bcrypt.hash(this.password, rounds);
next();
});
// Method to compare password
userSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
// Method to generate JWT token
userSchema.methods.generateAuthToken = function() {
const token = jwt.sign(
{
id: this._id,
email: this.email,
role: this.role
},
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN }
);
return token;
};
// Method to get public profile
userSchema.methods.toJSON = function() {
const user = this.toObject();
delete user.password;
delete user.refreshTokens;
delete user.__v;
return user;
};
// Static method to find user by credentials
userSchema.statics.findByCredentials = async function(email, password) {
const user = await this.findOne({ email, isActive: true }).select('+password');
if (!user) {
throw new Error('Invalid email or password');
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
throw new Error('Invalid email or password');
}
return user;
};
const User = mongoose.model('User', userSchema);
module.exports = User;
select: false option on the password field prevents it from being included in query results by default. Use .select('+password') when you need to access it.Step 7: Category Model
Create the Category model (src/models/Category.js):
const mongoose = require('mongoose');
const categorySchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Category name is required'],
trim: true,
unique: true,
maxlength: [30, 'Category name cannot exceed 30 characters']
},
description: {
type: String,
trim: true,
maxlength: [200, 'Description cannot exceed 200 characters']
},
color: {
type: String,
match: [/^#[0-9A-F]{6}$/i, 'Please provide a valid hex color'],
default: '#3498db'
},
icon: {
type: String,
default: 'folder'
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
}
}, {
timestamps: true
});
// Index for faster queries
categorySchema.index({ userId: 1, name: 1 });
const Category = mongoose.model('Category', categorySchema);
module.exports = Category;
Step 8: Task Model
Create the Task model (src/models/Task.js):
const mongoose = require('mongoose');
const taskSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'Task title is required'],
trim: true,
minlength: [3, 'Title must be at least 3 characters'],
maxlength: [100, 'Title cannot exceed 100 characters']
},
description: {
type: String,
trim: true,
maxlength: [1000, 'Description cannot exceed 1000 characters']
},
status: {
type: String,
enum: ['pending', 'in-progress', 'completed', 'cancelled'],
default: 'pending'
},
priority: {
type: String,
enum: ['low', 'medium', 'high', 'urgent'],
default: 'medium'
},
dueDate: {
type: Date,
validate: {
validator: function(value) {
return value >= new Date();
},
message: 'Due date must be in the future'
}
},
categoryId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Category'
},
tags: [{
type: String,
trim: true,
maxlength: [20, 'Tag cannot exceed 20 characters']
}],
attachments: [{
filename: String,
originalName: String,
mimeType: String,
size: Number,
path: String,
uploadedAt: {
type: Date,
default: Date.now
}
}],
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
completedAt: {
type: Date
}
}, {
timestamps: true
});
// Indexes for better query performance
taskSchema.index({ userId: 1, status: 1 });
taskSchema.index({ userId: 1, categoryId: 1 });
taskSchema.index({ userId: 1, dueDate: 1 });
taskSchema.index({ tags: 1 });
// Virtual for checking if task is overdue
taskSchema.virtual('isOverdue').get(function() {
return this.dueDate && this.dueDate < new Date() && this.status !== 'completed';
});
// Automatically set completedAt when status changes to completed
taskSchema.pre('save', function(next) {
if (this.isModified('status')) {
if (this.status === 'completed' && !this.completedAt) {
this.completedAt = new Date();
} else if (this.status !== 'completed') {
this.completedAt = null;
}
}
next();
});
// Enable virtuals in JSON output
taskSchema.set('toJSON', { virtuals: true });
taskSchema.set('toObject', { virtuals: true });
const Task = mongoose.model('Task', taskSchema);
module.exports = Task;
Step 9: Authentication Middleware
Create authentication middleware (src/middleware/auth.js):
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const ApiError = require('../utils/ApiError');
const auth = async (req, res, next) => {
try {
// Get token from header
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
throw ApiError.unauthorized('No authentication token provided');
}
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Find user
const user = await User.findById(decoded.id);
if (!user || !user.isActive) {
throw ApiError.unauthorized('User not found or inactive');
}
// Attach user to request
req.user = user;
req.token = token;
next();
} catch (error) {
if (error.name === 'JsonWebTokenError') {
return next(ApiError.unauthorized('Invalid token'));
}
if (error.name === 'TokenExpiredError') {
return next(ApiError.unauthorized('Token has expired'));
}
next(error);
}
};
// Middleware to check if user is admin
const isAdmin = (req, res, next) => {
if (req.user.role !== 'admin') {
return next(ApiError.forbidden('Access denied. Admin privileges required.'));
}
next();
};
module.exports = { auth, isAdmin };
Step 10: Authentication Controller
Create the authentication controller (src/controllers/authController.js):
const User = require('../models/User');
const ApiResponse = require('../utils/ApiResponse');
const ApiError = require('../utils/ApiError');
// Register new user
exports.register = async (req, res, next) => {
try {
const { name, email, password } = req.body;
// Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
throw ApiError.conflict('User with this email already exists');
}
// Create user
const user = new User({
name,
email,
password
});
await user.save();
// Generate token
const token = user.generateAuthToken();
res.status(201).json(
ApiResponse.created(
{ user, token },
'User registered successfully'
)
);
} catch (error) {
next(error);
}
};
// Login user
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
// Find user by credentials
const user = await User.findByCredentials(email, password);
// Generate token
const token = user.generateAuthToken();
res.json(
ApiResponse.success(
{ user, token },
'Login successful'
)
);
} catch (error) {
next(ApiError.unauthorized(error.message));
}
};
// Get current user profile
exports.getProfile = async (req, res, next) => {
try {
res.json(
ApiResponse.success(
req.user,
'Profile retrieved successfully'
)
);
} catch (error) {
next(error);
}
};
// Logout user
exports.logout = async (req, res, next) => {
try {
// In a real application, you might want to blacklist the token
// or remove it from a refresh token list
res.json(
ApiResponse.success(
null,
'Logout successful'
)
);
} catch (error) {
next(error);
}
};
Step 11: Authentication Routes
Create authentication routes (src/routes/auth.js):
const express = require('express');
const router = express.Router();
const { body } = require('express-validator');
const authController = require('../controllers/authController');
const { auth } = require('../middleware/auth');
const { validate } = require('../middleware/validation');
// Validation rules
const registerValidation = [
body('name')
.trim()
.notEmpty().withMessage('Name is required')
.isLength({ min: 2, max: 50 }).withMessage('Name must be 2-50 characters'),
body('email')
.trim()
.notEmpty().withMessage('Email is required')
.isEmail().withMessage('Please provide a valid email')
.normalizeEmail(),
body('password')
.notEmpty().withMessage('Password is required')
.isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
];
const loginValidation = [
body('email')
.trim()
.notEmpty().withMessage('Email is required')
.isEmail().withMessage('Please provide a valid email')
.normalizeEmail(),
body('password')
.notEmpty().withMessage('Password is required')
];
// Routes
router.post('/register', registerValidation, validate, authController.register);
router.post('/login', loginValidation, validate, authController.login);
router.get('/profile', auth, authController.getProfile);
router.post('/logout', auth, authController.logout);
module.exports = router;
Step 12: Validation Middleware
Create validation middleware (src/middleware/validation.js):
const { validationResult } = require('express-validator');
const ApiError = require('../utils/ApiError');
exports.validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const errorMessages = errors.array().map(err => err.msg);
throw ApiError.badRequest(errorMessages.join(', '));
}
next();
};
Practice Exercise
Complete the following tasks:
- Set up the project structure as described above
- Install all required dependencies
- Create all the models (User, Category, Task)
- Implement the authentication endpoints
- Test the registration and login endpoints using Postman or curl
- Verify that JWT tokens are generated correctly
Summary
In this lesson, we've completed the first part of building our REST API project:
- Set up a well-organized project structure
- Configured environment variables and database connection
- Created utility classes for consistent API responses
- Implemented User, Category, and Task models with validation
- Built authentication middleware with JWT
- Created authentication endpoints (register, login, profile, logout)
- Added input validation using express-validator
In the next lesson, we'll continue by implementing CRUD operations for tasks and categories, adding file upload functionality, and implementing pagination and filtering.