Class 26: Building a RESTful API with Express (CRUD - Delete)
Today, we'll complete our CRUD API by implementing the "Delete"
operation. We'll also learn how to organize our routes using
Express's Router
, which is crucial for larger
applications. Finally, we'll touch upon API versioning and
enhanced error handling.
Implementing DELETE Requests
The DELETE
method is used to remove a specific
resource from the server.
-
Endpoint:
DELETE /api/books/:id
-
Logic: Extract the ID from
req.params.id
. Find the book in our array and remove it. -
Responding: On successful deletion, we
respond with
204 No Content
. This status code indicates that the server successfully processed the request but is not returning any content in the response body. If the resource is not found, we return404 Not Found
.
// server.js
const express = require('express');
const app = express();
const port = 3000;
let books = [
{ id: '1', title: 'The Hitchhiker\'s Guide to the Galaxy', author: 'Douglas Adams', year: 1979 },
{ id: '2', title: '1984', author: 'George Orwell', year: 1949 },
{ id: '3', title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 }
];
app.use(express.json());
// GET all books
app.get('/api/books', (req, res) => {
res.status(200).json(books);
});
// GET single book by ID
app.get('/api/books/:id', (req, res) => {
const book = books.find(b => b.id === req.params.id);
if (book) {
res.status(200).json(book);
} else {
res.status(404).json({ message: `Book with ID ${req.params.id} not found.` });
}
});
// POST create book
app.post('/api/books', (req, res) => {
const { title, author, year } = req.body;
if (!title || !author || !year) {
return res.status(400).json({ message: 'Title, author, and year are required.' });
}
const newId = (books.length > 0 ? Math.max(...books.map(b => parseInt(b.id))) + 1 : 1).toString();
const newBook = { id: newId, title, author, year: parseInt(year) };
books.push(newBook);
res.status(201).json(newBook);
});
// PUT update book
app.put('/api/books/:id', (req, res) => {
const bookId = req.params.id;
const { title, author, year } = req.body;
if (!title || !author || !year) {
return res.status(400).json({ message: 'Title, author, and year are required for a full update.' });
}
const bookIndex = books.findIndex(b => b.id === bookId);
if (bookIndex !== -1) {
books[bookIndex] = { id: bookId, title, author, year: parseInt(year) };
res.status(200).json(books[bookIndex]);
} else {
res.status(404).json({ message: `Book with ID ${bookId} not found for update.` });
}
});
// PATCH update book
app.patch('/api/books/:id', (req, res) => {
const bookId = req.params.id;
const updates = req.body;
const bookIndex = books.findIndex(b => b.id === bookId);
if (bookIndex !== -1) {
books[bookIndex] = { ...books[bookIndex], ...updates };
if (updates.year) {
books[bookIndex].year = parseInt(updates.year);
}
res.status(200).json(books[bookIndex]);
} else {
res.status(404).json({ message: `Book with ID ${bookId} not found for partial update.` });
}
});
// DELETE /api/books/:id - Delete a book
app.delete('/api/books/:id', (req, res) => {
const bookId = req.params.id;
const initialLength = books.length;
books = books.filter(b => b.id !== bookId); // Filter out the book to be deleted
if (books.length < initialLength) {
// If length changed, a book was deleted
res.status(204).send(); // 204 No Content
} else {
// No book found with that ID
res.status(404).json({ message: `Book with ID ${bookId} not found for deletion.` });
}
});
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
Test it with Postman/Insomnia:
-
First, use a GET request to
/api/books
to see existing IDs. - Method:
DELETE
-
URL:
http://localhost:3000/api/books/1
(replace '1' with an existing ID) -
Send the request. You should receive a
204 No Content
response. The response body will be empty. -
Send a GET request to
/api/books
again to confirm the book is gone. -
Try to delete a non-existent ID (e.g.,
99
) to see the404 Not Found
response.
Consolidating API Endpoints with express.Router()
As your application grows, putting all routes in a single
server.js
file becomes unmanageable.
express.Router()
allows you to create modular,
mountable route handlers. This helps organize your code by
feature or resource.
Steps:
-
Create a new file for your resource's routes:
e.g.,
routes/books.js
. -
Define routes using
router
instead ofapp
: Insidebooks.js
, create an instance ofexpress.Router()
and define all your book-related routes on this router. - Export the router: Make the router available for import in your main application file.
-
Mount the router in
server.js
: Useapp.use()
to "mount" the router at a specific base path (e.g.,/api/books
).
// routes/books.js
const express = require('express');
const router = express.Router();
// In-memory array (for now, would be database in real app)
let books = [
{ id: '1', title: 'The Hitchhiker\'s Guide to the Galaxy', author: 'Douglas Adams', year: 1979 },
{ id: '2', title: '1984', author: 'George Orwell', year: 1949 },
{ id: '3', title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 }
];
// GET all books
router.get('/', (req, res) => {
res.status(200).json(books);
});
// GET single book by ID
router.get('/:id', (req, res) => {
const book = books.find(b => b.id === req.params.id);
if (book) {
res.status(200).json(book);
} else {
res.status(404).json({ message: `Book with ID ${req.params.id} not found.` });
}
});
// POST create book
router.post('/', (req, res) => {
const { title, author, year } = req.body;
if (!title || !author || !year) {
return res.status(400).json({ message: 'Title, author, and year are required.' });
}
const newId = (books.length > 0 ? Math.max(...books.map(b => parseInt(b.id))) + 1 : 1).toString();
const newBook = { id: newId, title, author, year: parseInt(year) };
books.push(newBook);
res.status(201).json(newBook);
});
// PUT update book
router.put('/:id', (req, res) => {
const bookId = req.params.id;
const { title, author, year } = req.body;
if (!title || !author || !year) {
return res.status(400).json({ message: 'Title, author, and year are required for a full update.' });
}
const bookIndex = books.findIndex(b => b.id === bookId);
if (bookIndex !== -1) {
books[bookIndex] = { id: bookId, title, author, year: parseInt(year) };
res.status(200).json(books[bookIndex]);
} else {
res.status(404).json({ message: `Book with ID ${bookId} not found for update.` });
}
});
// PATCH update book
router.patch('/:id', (req, res) => {
const bookId = req.params.id;
const updates = req.body;
const bookIndex = books.findIndex(b => b.id === bookId);
if (bookIndex !== -1) {
books[bookIndex] = { ...books[bookIndex], ...updates };
if (updates.year) {
books[bookIndex].year = parseInt(updates.year);
}
res.status(200).json(books[bookIndex]);
} else {
res.status(404).json({ message: `Book with ID ${bookId} not found for partial update.` });
}
});
// DELETE book
router.delete('/:id', (req, res) => {
const bookId = req.params.id;
const initialLength = books.length;
books = books.filter(b => b.id !== bookId);
if (books.length < initialLength) {
res.status(204).send();
} else {
res.status(404).json({ message: `Book with ID ${bookId} not found for deletion.` });
}
});
module.exports = router; // Export the router
// server.js (main application file)
const express = require('express');
const app = express();
const port = 3000;
const booksRouter = require('./routes/books'); // Import the books router
app.use(express.json()); // Global middleware for JSON body parsing
// Mount the books router at the /api/books path
// All routes defined in booksRouter will now be prefixed with /api/books
app.use('/api/books', booksRouter);
// Basic root route (optional, for testing server status)
app.get('/', (req, res) => {
res.send('Welcome to the Book API!');
});
// Start the server
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
This modular approach keeps your main server.js
clean and makes it easy to add more resources (e.g.,
/api/users
, /api/orders
) by creating
new router files.
Basic API Design Best Practices
-
Consistency in URL naming: Use plural nouns
for collections (
/books
) and specific IDs for individual resources (/books/123
). - Meaningful HTTP status codes: Always return the appropriate status code (e.g., 200 OK, 201 Created, 204 No Content, 400 Bad Request, 404 Not Found, 500 Internal Server Error).
- Clear error messages: When an error occurs, provide a descriptive JSON error object with a message and optionally an error code or details.
- Use plural nouns for collections: As discussed, this is a standard REST convention.
API Versioning
As your API evolves, you might need to make breaking changes that are incompatible with older clients. API versioning allows you to introduce new versions of your API while maintaining backward compatibility for existing clients.
Common versioning strategies:
-
URI Versioning (most common):
Include the version number directly in the URL path.
This is explicit and easy to understand. You would typically create separate router files for each version (e.g.,/v1/api/books /v2/api/books
routes/v1/books.js
,routes/v2/books.js
). -
Header Versioning:
Include the version in a custom HTTP header (e.g.,
X-API-Version: 1
). Less visible but cleaner URLs. -
Query Parameter Versioning:
Include the version as a query parameter (e.g.,
/api/books?version=1
). Not truly RESTful as query parameters are for filtering, not identifying resources.
Enhanced Error Handling in API Responses
While we added basic inline validation, a more robust API will have a centralized error handling mechanism. This involves:
-
Custom error objects: Instead of just a
string, return a structured JSON error object.
{ "status": "error", "statusCode": 400, "message": "Validation failed", "errors": ["Title is required.", "Year must be a number."] }
-
Catching errors in route handlers: Use
try...catch
blocks or promise.catch()
to catch errors and pass them to the Express error handling middleware usingnext(err)
. -
Dedicated error middleware: A middleware
function with four arguments (
err, req, res, next
) that catches all errors passed to it and sends a consistent error response.
// server.js (main file, add this at the end, after all routes)
// Handle 404 Not Found errors for unhandled routes
app.use((req, res, next) => {
res.status(404).json({ message: 'Resource not found.' });
});
// Centralized Error Handling Middleware (must be the last middleware)
app.use((err, req, res, next) => {
console.error(err.stack); // Log the error for debugging purposes
const statusCode = err.statusCode || 500;
const message = err.message || 'Something went wrong on the server.';
const errors = err.errors || []; // For validation errors
res.status(statusCode).json({
status: 'error',
statusCode,
message,
errors
});
});
This error handling pattern makes your API more predictable and easier for clients to consume, as they always know what format to expect for error responses.
You've now built a complete CRUD API with Express.js, including basic error handling and modular routing. In the next class, we'll move beyond in-memory data and connect our API to a real database, starting with relational databases and SQL.