Building a REST API Project - Part 2
Building a Complete REST API Project - Part 2
In this lesson, we'll continue building our Task Management API by implementing CRUD operations for tasks and categories, adding file upload functionality, and implementing pagination and filtering. This builds on the authentication system we created in Part 1.
Step 1: Error Handler Middleware
First, let's create a centralized error handling middleware (src/middleware/errorHandler.js):
const ApiError = require('../utils/ApiError');
const errorHandler = (err, req, res, next) => {
let error = err;
// Mongoose validation error
if (err.name === 'ValidationError') {
const messages = Object.values(err.errors).map(e => e.message);
error = ApiError.badRequest(messages.join(', '));
}
// Mongoose duplicate key error
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
error = ApiError.conflict(`${field} already exists`);
}
// Mongoose cast error (invalid ObjectId)
if (err.name === 'CastError') {
error = ApiError.badRequest('Invalid ID format');
}
// Default to 500 server error
if (!error.statusCode) {
error = ApiError.internal(err.message);
}
// Send error response
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
...(process.env.NODE_ENV === 'development' && { stack: error.stack })
});
};
module.exports = errorHandler;
Step 2: Category Controller
Create the category controller (src/controllers/categoryController.js):
const Category = require('../models/Category');
const Task = require('../models/Task');
const ApiResponse = require('../utils/ApiResponse');
const ApiError = require('../utils/ApiError');
// Get all categories for current user
exports.getCategories = async (req, res, next) => {
try {
const categories = await Category.find({ userId: req.user._id })
.sort({ name: 1 });
// Get task count for each category
const categoriesWithCount = await Promise.all(
categories.map(async (category) => {
const taskCount = await Task.countDocuments({
categoryId: category._id,
userId: req.user._id
});
return {
...category.toObject(),
taskCount
};
})
);
res.json(
ApiResponse.success(
categoriesWithCount,
'Categories retrieved successfully'
)
);
} catch (error) {
next(error);
}
};
// Get single category
exports.getCategory = async (req, res, next) => {
try {
const category = await Category.findOne({
_id: req.params.id,
userId: req.user._id
});
if (!category) {
throw ApiError.notFound('Category not found');
}
res.json(
ApiResponse.success(category, 'Category retrieved successfully')
);
} catch (error) {
next(error);
}
};
// Create new category
exports.createCategory = async (req, res, next) => {
try {
const category = new Category({
...req.body,
userId: req.user._id
});
await category.save();
res.status(201).json(
ApiResponse.created(category, 'Category created successfully')
);
} catch (error) {
next(error);
}
};
// Update category
exports.updateCategory = async (req, res, next) => {
try {
const category = await Category.findOneAndUpdate(
{ _id: req.params.id, userId: req.user._id },
req.body,
{ new: true, runValidators: true }
);
if (!category) {
throw ApiError.notFound('Category not found');
}
res.json(
ApiResponse.success(category, 'Category updated successfully')
);
} catch (error) {
next(error);
}
};
// Delete category
exports.deleteCategory = async (req, res, next) => {
try {
const category = await Category.findOne({
_id: req.params.id,
userId: req.user._id
});
if (!category) {
throw ApiError.notFound('Category not found');
}
// Check if category has tasks
const taskCount = await Task.countDocuments({
categoryId: category._id
});
if (taskCount > 0) {
throw ApiError.badRequest(
`Cannot delete category with ${taskCount} task(s). Please reassign or delete the tasks first.`
);
}
await category.deleteOne();
res.json(
ApiResponse.success(null, 'Category deleted successfully')
);
} catch (error) {
next(error);
}
};
Step 3: Category Routes
Create category routes (src/routes/categories.js):
const express = require('express');
const router = express.Router();
const { body } = require('express-validator');
const categoryController = require('../controllers/categoryController');
const { auth } = require('../middleware/auth');
const { validate } = require('../middleware/validation');
// Validation rules
const categoryValidation = [
body('name')
.trim()
.notEmpty().withMessage('Category name is required')
.isLength({ max: 30 }).withMessage('Name cannot exceed 30 characters'),
body('description')
.optional()
.trim()
.isLength({ max: 200 }).withMessage('Description cannot exceed 200 characters'),
body('color')
.optional()
.matches(/^#[0-9A-F]{6}$/i).withMessage('Please provide a valid hex color'),
body('icon')
.optional()
.trim()
];
// All routes require authentication
router.use(auth);
router.get('/', categoryController.getCategories);
router.get('/:id', categoryController.getCategory);
router.post('/', categoryValidation, validate, categoryController.createCategory);
router.patch('/:id', categoryValidation, validate, categoryController.updateCategory);
router.delete('/:id', categoryController.deleteCategory);
module.exports = router;
Step 4: File Upload Middleware
Create file upload middleware using multer (src/middleware/upload.js):
const multer = require('multer');
const path = require('path');
const ApiError = require('../utils/ApiError');
// Configure storage
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/');
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
}
});
// File filter
const fileFilter = (req, file, cb) => {
const allowedTypes = process.env.ALLOWED_FILE_TYPES.split(',');
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new ApiError(400, `File type ${file.mimetype} is not allowed`), false);
}
};
// Multer configuration
const upload = multer({
storage: storage,
limits: {
fileSize: parseInt(process.env.MAX_FILE_SIZE) || 5242880 // 5MB default
},
fileFilter: fileFilter
});
// Multer error handler
const handleMulterError = (err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return next(ApiError.badRequest('File size exceeds the limit'));
}
return next(ApiError.badRequest(err.message));
}
next(err);
};
module.exports = { upload, handleMulterError };
npm install multerStep 5: Task Controller
Create the task controller with CRUD operations and filtering (src/controllers/taskController.js):
const Task = require('../models/Task');
const Category = require('../models/Category');
const ApiResponse = require('../utils/ApiResponse');
const ApiError = require('../utils/ApiError');
const fs = require('fs').promises;
// Get all tasks with filtering and pagination
exports.getTasks = async (req, res, next) => {
try {
const {
status,
priority,
categoryId,
tags,
search,
sortBy = 'createdAt',
order = 'desc',
page = 1,
limit = 10
} = req.query;
// Build filter
const filter = { userId: req.user._id };
if (status) filter.status = status;
if (priority) filter.priority = priority;
if (categoryId) filter.categoryId = categoryId;
if (tags) filter.tags = { $in: tags.split(',') };
// Search in title and description
if (search) {
filter.$or = [
{ title: { $regex: search, $options: 'i' } },
{ description: { $regex: search, $options: 'i' } }
];
}
// Build sort
const sortOptions = {};
sortOptions[sortBy] = order === 'asc' ? 1 : -1;
// Execute query with pagination
const skip = (parseInt(page) - 1) * parseInt(limit);
const [tasks, total] = await Promise.all([
Task.find(filter)
.populate('categoryId', 'name color icon')
.sort(sortOptions)
.skip(skip)
.limit(parseInt(limit)),
Task.countDocuments(filter)
]);
// Calculate pagination info
const totalPages = Math.ceil(total / parseInt(limit));
const hasNextPage = parseInt(page) < totalPages;
const hasPrevPage = parseInt(page) > 1;
res.json(
ApiResponse.success({
tasks,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages,
hasNextPage,
hasPrevPage
}
}, 'Tasks retrieved successfully')
);
} catch (error) {
next(error);
}
};
// Get single task
exports.getTask = async (req, res, next) => {
try {
const task = await Task.findOne({
_id: req.params.id,
userId: req.user._id
}).populate('categoryId', 'name color icon');
if (!task) {
throw ApiError.notFound('Task not found');
}
res.json(
ApiResponse.success(task, 'Task retrieved successfully')
);
} catch (error) {
next(error);
}
};
// Create new task
exports.createTask = async (req, res, next) => {
try {
// Verify category belongs to user if provided
if (req.body.categoryId) {
const category = await Category.findOne({
_id: req.body.categoryId,
userId: req.user._id
});
if (!category) {
throw ApiError.badRequest('Invalid category');
}
}
const task = new Task({
...req.body,
userId: req.user._id
});
await task.save();
// Populate category
await task.populate('categoryId', 'name color icon');
res.status(201).json(
ApiResponse.created(task, 'Task created successfully')
);
} catch (error) {
next(error);
}
};
// Update task
exports.updateTask = async (req, res, next) => {
try {
// Verify category belongs to user if being updated
if (req.body.categoryId) {
const category = await Category.findOne({
_id: req.body.categoryId,
userId: req.user._id
});
if (!category) {
throw ApiError.badRequest('Invalid category');
}
}
const task = await Task.findOneAndUpdate(
{ _id: req.params.id, userId: req.user._id },
req.body,
{ new: true, runValidators: true }
).populate('categoryId', 'name color icon');
if (!task) {
throw ApiError.notFound('Task not found');
}
res.json(
ApiResponse.success(task, 'Task updated successfully')
);
} catch (error) {
next(error);
}
};
// Delete task
exports.deleteTask = async (req, res, next) => {
try {
const task = await Task.findOne({
_id: req.params.id,
userId: req.user._id
});
if (!task) {
throw ApiError.notFound('Task not found');
}
// Delete associated files
if (task.attachments && task.attachments.length > 0) {
await Promise.all(
task.attachments.map(async (attachment) => {
try {
await fs.unlink(attachment.path);
} catch (err) {
console.error(`Failed to delete file: ${attachment.path}`);
}
})
);
}
await task.deleteOne();
res.json(
ApiResponse.success(null, 'Task deleted successfully')
);
} catch (error) {
next(error);
}
};
// Upload attachment to task
exports.uploadAttachment = async (req, res, next) => {
try {
if (!req.file) {
throw ApiError.badRequest('No file uploaded');
}
const task = await Task.findOne({
_id: req.params.id,
userId: req.user._id
});
if (!task) {
// Delete uploaded file if task not found
await fs.unlink(req.file.path);
throw ApiError.notFound('Task not found');
}
// Add attachment to task
task.attachments.push({
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
path: req.file.path
});
await task.save();
res.json(
ApiResponse.success(
task.attachments[task.attachments.length - 1],
'Attachment uploaded successfully'
)
);
} catch (error) {
// Clean up uploaded file on error
if (req.file) {
try {
await fs.unlink(req.file.path);
} catch (err) {
console.error('Failed to delete uploaded file');
}
}
next(error);
}
};
// Delete attachment from task
exports.deleteAttachment = async (req, res, next) => {
try {
const task = await Task.findOne({
_id: req.params.id,
userId: req.user._id
});
if (!task) {
throw ApiError.notFound('Task not found');
}
const attachmentIndex = task.attachments.findIndex(
a => a._id.toString() === req.params.attachmentId
);
if (attachmentIndex === -1) {
throw ApiError.notFound('Attachment not found');
}
// Delete file from disk
const attachment = task.attachments[attachmentIndex];
try {
await fs.unlink(attachment.path);
} catch (err) {
console.error(`Failed to delete file: ${attachment.path}`);
}
// Remove from array
task.attachments.splice(attachmentIndex, 1);
await task.save();
res.json(
ApiResponse.success(null, 'Attachment deleted successfully')
);
} catch (error) {
next(error);
}
};
// Get task statistics
exports.getStatistics = async (req, res, next) => {
try {
const userId = req.user._id;
const [
total,
pending,
inProgress,
completed,
cancelled,
overdue
] = await Promise.all([
Task.countDocuments({ userId }),
Task.countDocuments({ userId, status: 'pending' }),
Task.countDocuments({ userId, status: 'in-progress' }),
Task.countDocuments({ userId, status: 'completed' }),
Task.countDocuments({ userId, status: 'cancelled' }),
Task.countDocuments({
userId,
status: { $ne: 'completed' },
dueDate: { $lt: new Date() }
})
]);
// Tasks by priority
const byPriority = await Task.aggregate([
{ $match: { userId } },
{ $group: { _id: '$priority', count: { $sum: 1 } } }
]);
const statistics = {
total,
byStatus: {
pending,
inProgress,
completed,
cancelled
},
byPriority: byPriority.reduce((acc, item) => {
acc[item._id] = item.count;
return acc;
}, {}),
overdue
};
res.json(
ApiResponse.success(statistics, 'Statistics retrieved successfully')
);
} catch (error) {
next(error);
}
};
Promise.all() for parallel database queries can significantly improve performance when fetching multiple independent datasets.Step 6: Task Routes
Create task routes (src/routes/tasks.js):
const express = require('express');
const router = express.Router();
const { body, query } = require('express-validator');
const taskController = require('../controllers/taskController');
const { auth } = require('../middleware/auth');
const { validate } = require('../middleware/validation');
const { upload, handleMulterError } = require('../middleware/upload');
// Validation rules
const taskValidation = [
body('title')
.trim()
.notEmpty().withMessage('Task title is required')
.isLength({ min: 3, max: 100 }).withMessage('Title must be 3-100 characters'),
body('description')
.optional()
.trim()
.isLength({ max: 1000 }).withMessage('Description cannot exceed 1000 characters'),
body('status')
.optional()
.isIn(['pending', 'in-progress', 'completed', 'cancelled'])
.withMessage('Invalid status'),
body('priority')
.optional()
.isIn(['low', 'medium', 'high', 'urgent'])
.withMessage('Invalid priority'),
body('dueDate')
.optional()
.isISO8601().withMessage('Invalid date format'),
body('categoryId')
.optional()
.isMongoId().withMessage('Invalid category ID'),
body('tags')
.optional()
.isArray().withMessage('Tags must be an array')
];
const queryValidation = [
query('page')
.optional()
.isInt({ min: 1 }).withMessage('Page must be a positive integer'),
query('limit')
.optional()
.isInt({ min: 1, max: 100 }).withMessage('Limit must be between 1 and 100'),
query('status')
.optional()
.isIn(['pending', 'in-progress', 'completed', 'cancelled']),
query('priority')
.optional()
.isIn(['low', 'medium', 'high', 'urgent'])
];
// All routes require authentication
router.use(auth);
router.get('/', queryValidation, validate, taskController.getTasks);
router.get('/statistics', taskController.getStatistics);
router.get('/:id', taskController.getTask);
router.post('/', taskValidation, validate, taskController.createTask);
router.patch('/:id', taskValidation, validate, taskController.updateTask);
router.delete('/:id', taskController.deleteTask);
// File upload routes
router.post(
'/:id/attachments',
upload.single('file'),
handleMulterError,
taskController.uploadAttachment
);
router.delete('/:id/attachments/:attachmentId', taskController.deleteAttachment);
module.exports = router;
Step 7: Main Application File
Create the main application file (src/app.js):
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const morgan = require('morgan');
require('express-async-errors');
const authRoutes = require('./routes/auth');
const taskRoutes = require('./routes/tasks');
const categoryRoutes = require('./routes/categories');
const errorHandler = require('./middleware/errorHandler');
const ApiError = require('./utils/ApiError');
const app = express();
// Middleware
app.use(helmet());
app.use(cors());
app.use(compression());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Logging
if (process.env.NODE_ENV === 'development') {
app.use(morgan('dev'));
}
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/tasks', taskRoutes);
app.use('/api/categories', categoryRoutes);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'OK', timestamp: new Date().toISOString() });
});
// 404 handler
app.use((req, res, next) => {
next(ApiError.notFound('Route not found'));
});
// Error handler
app.use(errorHandler);
module.exports = app;
Step 8: Server Entry Point
Create the server entry point (server.js):
require('dotenv').config();
const app = require('./src/app');
const connectDB = require('./src/config/database');
const PORT = process.env.PORT || 5000;
// Connect to database
connectDB();
// Start server
const server = app.listen(PORT, () => {
console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
console.error('Unhandled Rejection:', err);
server.close(() => process.exit(1));
});
Step 9: Update package.json Scripts
Add development and production scripts to package.json:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
}
Step 10: Testing the API
Test all endpoints using curl or Postman. Here are some example requests:
# Register user
curl -X POST http://localhost:5000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name":"John Doe","email":"john@example.com","password":"password123"}'
# Login
curl -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"john@example.com","password":"password123"}'
# Create category (with token)
curl -X POST http://localhost:5000/api/categories \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Work","description":"Work tasks","color":"#3498db"}'
# Create task
curl -X POST http://localhost:5000/api/tasks \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Complete project","status":"pending","priority":"high"}'
# Get tasks with filtering and pagination
curl -X GET "http://localhost:5000/api/tasks?status=pending&page=1&limit=10" \
-H "Authorization: Bearer YOUR_TOKEN"
# Upload attachment
curl -X POST http://localhost:5000/api/tasks/TASK_ID/attachments \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@/path/to/file.pdf"
Practice Exercise
Complete the following tasks:
- Implement all CRUD operations for tasks and categories
- Test file upload functionality with different file types
- Test pagination and filtering with various query parameters
- Create a task with a category and verify the relationship
- Try to delete a category that has tasks and verify the error handling
- Test the statistics endpoint and verify the counts
- Test overdue task detection by creating a task with a past due date
Summary
In this lesson, we've completed the second part of our REST API project:
- Implemented centralized error handling middleware
- Created CRUD operations for categories
- Implemented file upload functionality using multer
- Built complete task management with CRUD operations
- Added advanced filtering and search capabilities
- Implemented pagination for efficient data retrieval
- Created task statistics endpoint
- Added file attachment management for tasks
- Tested all endpoints with example requests
In the next lesson (Part 3), we'll add comprehensive testing, API documentation, enhanced error handling, and security hardening to complete our professional REST API.