Build An Express API For Books: CRUD, Zod, And Jest
Hey guys! Ever wondered how to build a robust and scalable API for managing books? This guide will walk you through creating an Express API with CRUD operations, Zod validation, an in-memory repository, and Jest tests. We'll cover everything from setting up the project to writing tests, ensuring you have a solid foundation for your next project.
Introduction to Building an Express API for Books
In this comprehensive guide, we'll dive deep into building an Express API specifically designed for managing books. This API will support Create, Read, Update, and Delete (CRUD) operations, which are the fundamental actions for interacting with data. To ensure our API is robust and reliable, we'll incorporate Zod validation to handle request data, an in-memory repository for data persistence, and Jest tests for quality assurance. Whether you're a beginner or an experienced developer, this guide provides a step-by-step approach to building a scalable and maintainable API.
Key Concepts and Technologies
Before we get started, let's briefly touch on the key concepts and technologies we'll be using:
- Express: A minimal and flexible Node.js web application framework that provides a robust set of features for web and mobile applications.
- CRUD Operations: The four basic functions of persistent storage: Create, Read, Update, and Delete.
- Zod: A TypeScript-first schema declaration and validation library that makes it easy to validate data.
- In-Memory Repository: A simple data storage solution that keeps data in memory, useful for development and testing.
- Jest: A delightful JavaScript Testing Framework with a focus on simplicity.
By understanding these concepts, you'll be well-equipped to follow along and build your own book API.
Why This Guide?
This guide is designed to be practical and hands-on. We'll not only explain the concepts but also provide code examples and step-by-step instructions. By the end of this guide, you'll have a fully functional Express API for books, complete with validation and tests. This will serve as a great starting point for your future projects, whether you're building a personal library app or a full-fledged e-commerce platform.
Setting Up the Project
Alright, let's get our hands dirty and start setting up the project! First things first, you'll need to make sure you have Node.js and npm (or yarn) installed on your machine. If you don't, head over to the Node.js website and download the latest version. Once you've got that sorted, we can dive into creating our project.
Creating a New Project
To kick things off, we'll create a new directory for our project and initialize a new Node.js project using npm or yarn. Open up your terminal and follow these steps:
-
Create a new directory:
mkdir express-book-api cd express-book-api
-
Initialize a new Node.js project using npm:
npm init -y
Or, if you prefer using yarn:
yarn init -y
This will create a package.json
file in your project directory, which will keep track of our project's dependencies and scripts.
Installing Dependencies
Next up, we need to install the dependencies we'll be using for our project. We'll be using Express for our API framework, Zod for validation, and Jest for testing. Let's install these dependencies using npm or yarn:
Using npm:
npm install express zod
npm install --save-dev @types/express jest ts-jest @types/jest nodemon typescript ts-node
Using yarn:
yarn add express zod
yarn add -D @types/express jest ts-jest @types/jest nodemon typescript ts-node
Here's a quick rundown of what each dependency does:
express
: Our web application framework.zod
: For schema declaration and validation.@types/express
: TypeScript definitions for Express.jest
: Our testing framework.ts-jest
: A Jest transformer for TypeScript.@types/jest
: TypeScript definitions for Jest.nodemon
: Automatically restarts the server when file changes are detected.typescript
: Our primary language.ts-node
: TypeScript execution and REPL for Node.js.
Configuring TypeScript
Since we're using TypeScript, we need to configure it for our project. Create a tsconfig.json
file in the root of your project with the following configuration:
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"sourceMap": true
},
"include": ["src/**/*"]
}
This configuration tells the TypeScript compiler how to compile our code. We've set the target to es2016
, the module system to commonjs
, and enabled strict mode for better type checking. The outDir
specifies where the compiled JavaScript files should be placed, and include
tells the compiler to include all files in the src
directory.
Setting Up Nodemon
To make development easier, we'll set up Nodemon to automatically restart our server whenever we make changes to our code. Open your package.json
file and add a dev
script to the scripts
section:
"scripts": {
"start": "node dist/index.js",
"dev": "nodemon src/index.ts",
"test": "jest",
"build": "tsc",
"format": "prettier --write \"src/**/*.ts\"",
"lint": "eslint \"src/**/*.ts\" --fix"
},
We've added a dev
script that uses Nodemon to run our src/index.ts
file. We've also added test
, build
, format
, and lint
scripts for testing, building, formatting, and linting our code.
Creating Project Structure
Finally, let's create the basic project structure. Create a src
directory in the root of your project, and inside src
, create the following files:
src/index.ts
: This will be our main application file.src/bookRepository.ts
: This will handle our in-memory data storage.src/validation.ts
: This will contain our Zod schemas for validation.src/routes/bookRoutes.ts
: This will define our book routes.src/types.ts
: This will define our TypeScript types.src/tests/book.test.ts
: This will contain our Jest tests.
With this setup, we're ready to start building our API! Let’s move on to defining our types and setting up the in-memory repository.
Defining Types and Setting Up the In-Memory Repository
Now that we have our project structure in place, let's define the types we'll be using and set up our in-memory repository. This will involve creating TypeScript interfaces for our Book
object and implementing a BookRepository
class to handle data storage.
Defining TypeScript Types
First, we'll define the Book
interface in src/types.ts
. This interface will represent the structure of our book objects. Open src/types.ts
and add the following code:
export interface Book {
id: string;
title: string;
author: string;
isbn: string;
}
export interface CreateBook {
title: string;
author: string;
isbn: string;
}
export interface UpdateBook {
title?: string;
author?: string;
isbn?: string;
}
Here, we've defined three interfaces:
Book
: Represents a book object with anid
,title
,author
, andisbn
.CreateBook
: Represents the data required to create a new book, without theid
.UpdateBook
: Represents the data that can be updated for a book, with all fields being optional.
These types will help us ensure type safety throughout our application.
Implementing the In-Memory Repository
Next, we'll implement the BookRepository
class in src/bookRepository.ts
. This class will handle our in-memory data storage and provide methods for CRUD operations. Open src/bookRepository.ts
and add the following code:
import { Book, CreateBook, UpdateBook } from './types';
import { v4 as uuidv4 } from 'uuid';
class BookRepository {
private books: Book[] = [];
async create(bookData: CreateBook): Promise<Book> {
const newBook: Book = { id: uuidv4(), ...bookData };
this.books.push(newBook);
return newBook;
}
async read(id: string): Promise<Book | undefined> {
return this.books.find((book) => book.id === id);
}
async readAll(): Promise<Book[]> {
return this.books;
}
async update(id: string, updateData: UpdateBook): Promise<Book | undefined> {
const bookIndex = this.books.findIndex((book) => book.id === id);
if (bookIndex === -1) {
return undefined;
}
this.books[bookIndex] = { ...this.books[bookIndex], ...updateData };
return this.books[bookIndex];
}
async delete(id: string): Promise<boolean> {
const initialLength = this.books.length;
this.books = this.books.filter((book) => book.id !== id);
return this.books.length < initialLength;
}
}
export const bookRepository = new BookRepository();
In this code, we've defined a BookRepository
class with the following methods:
create(bookData: CreateBook)
: Creates a new book with a unique ID and adds it to the in-memory store.read(id: string)
: Reads a book by its ID.readAll()
: Reads all books.update(id: string, updateData: UpdateBook)
: Updates a book by its ID.delete(id: string)
: Deletes a book by its ID.
We're using an array this.books
to store our book objects. The uuidv4
function from the uuid
library is used to generate unique IDs for new books. We also export a singleton instance of BookRepository
called bookRepository
for use throughout the application.
With our types defined and the in-memory repository set up, we can now move on to defining our Zod schemas for validation. This will ensure that the data we receive from requests is valid and safe to use.
Implementing Zod Validation
Data validation is super important for any API, guys. It ensures that the data we receive is in the correct format and meets our requirements. This is where Zod comes in! Zod is a fantastic TypeScript-first schema declaration and validation library that makes it easy to validate data. Let's dive into how we can use Zod to validate our book data.
Defining Zod Schemas
We'll define our Zod schemas in src/validation.ts
. These schemas will specify the structure and requirements for our book data. Open src/validation.ts
and add the following code:
import { z } from 'zod';
export const createBookSchema = z.object({
title: z.string().min(1, { message: 'Title is required' }),
author: z.string().min(1, { message: 'Author is required' }),
isbn: z.string().min(1, { message: 'ISBN is required' }),
});
export const updateBookSchema = z.object({
title: z.string().min(1, { message: 'Title is required' }).optional(),
author: z.string().min(1, { message: 'Author is required' }).optional(),
isbn: z.string().min(1, { message: 'ISBN is required' }).optional(),
});
export type CreateBookSchema = z.infer<typeof createBookSchema>;
export type UpdateBookSchema = z.infer<typeof updateBookSchema>;
Here, we've defined two Zod schemas:
createBookSchema
: This schema validates the data for creating a new book. It requirestitle
,author
, andisbn
to be non-empty strings.updateBookSchema
: This schema validates the data for updating an existing book. It allowstitle
,author
, andisbn
to be optionally updated, but if they are provided, they must be non-empty strings.
We're using the z.string().min(1, { message: '...' })
method to ensure that the strings are not empty. The { message: '...' }
option allows us to provide custom error messages.
We've also defined two TypeScript types, CreateBookSchema
and UpdateBookSchema
, using z.infer
. These types are inferred from our Zod schemas, providing us with type safety when working with validated data.
Using Zod Schemas in Our API
Now that we have our Zod schemas, we can use them in our API to validate request data. We'll use the safeParse
method provided by Zod to validate the data. This method returns an object with either a success
property set to true
and a data
property containing the validated data, or a success
property set to false
and an error
property containing the validation errors.
We'll see how to use these schemas in our route handlers in the next section when we implement our book routes. By using Zod, we can ensure that our API only processes valid data, reducing the risk of errors and improving the overall reliability of our application.
Implementing CRUD Endpoints with Express
Alright, let's get to the heart of our API – implementing the CRUD endpoints! We'll be using Express to create these endpoints, and we'll integrate our Zod validation and in-memory repository to handle data. This is where our API really starts to take shape.
Setting Up the Express Router
First, we'll set up an Express router in src/routes/bookRoutes.ts
. This router will handle all requests to the /books
endpoint. Open src/routes/bookRoutes.ts
and add the following code:
import express, { Request, Response } from 'express';
import { bookRepository } from '../bookRepository';
import { CreateBookSchema, createBookSchema, UpdateBookSchema, updateBookSchema } from '../validation';
const router = express.Router();
// GET /books
router.get('/', async (req: Request, res: Response) => {
try {
const books = await bookRepository.readAll();
res.status(200).json(books);
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Failed to retrieve books' });
}
});
// GET /books/:id
router.get('/:id', async (req: Request, res: Response) => {
try {
const book = await bookRepository.read(req.params.id);
if (!book) {
return res.status(404).json({ message: 'Book not found' });
}
res.status(200).json(book);
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Failed to retrieve book' });
}
});
// POST /books
router.post('/', async (req: Request, res: Response) => {
const result = createBookSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ message: 'Invalid book data', errors: result.error.errors });
}
try {
const newBook = await bookRepository.create(result.data);
res.status(201).json(newBook);
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Failed to create book' });
}
});
// PUT /books/:id
router.put('/:id', async (req: Request, res: Response) => {
const result = updateBookSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ message: 'Invalid book data', errors: result.error.errors });
}
try {
const updatedBook = await bookRepository.update(req.params.id, result.data);
if (!updatedBook) {
return res.status(404).json({ message: 'Book not found' });
}
res.status(200).json(updatedBook);
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Failed to update book' });
}
});
// DELETE /books/:id
router.delete('/:id', async (req: Request, res: Response) => {
try {
const deleted = await bookRepository.delete(req.params.id);
if (!deleted) {
return res.status(404).json({ message: 'Book not found' });
}
res.status(204).send();
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Failed to delete book' });
}
});
export default router;
Let's break down what's happening here:
- We import the necessary modules, including Express, our
bookRepository
, and our Zod schemas. - We create an Express router instance.
- We define the following CRUD endpoints:
GET /books
: Retrieves all books.GET /books/:id
: Retrieves a book by ID.POST /books
: Creates a new book.PUT /books/:id
: Updates a book by ID.DELETE /books/:id
: Deletes a book by ID.
- For the
POST
andPUT
endpoints, we use our Zod schemas to validate the request body. If the validation fails, we return a 400 status code with the validation errors. - We use the
bookRepository
to interact with our in-memory data store. - We handle errors and return appropriate HTTP status codes.
Integrating the Router in the Main Application
Now that we have our router, we need to integrate it into our main Express application. Open src/index.ts
and add the following code:
import express from 'express';
import bookRoutes from './routes/bookRoutes';
const app = express();
const port = process.env.PORT || 3000;
app.use(express.json());
app.use('/books', bookRoutes);
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
Here, we:
- Import Express and our
bookRoutes
. - Create an Express application instance.
- Define the port our server will run on.
- Use the
express.json()
middleware to parse JSON request bodies. - Mount our
bookRoutes
router at the/books
path. - Start the server and log a message to the console.
With these changes, our API is now set up to handle CRUD operations for books. We've integrated Zod validation to ensure data integrity and used an in-memory repository for data storage. Next, we'll add Jest tests to ensure our API is working correctly.
Writing Jest Tests
Testing, testing, 1, 2, 3! Writing tests is a crucial part of building a robust API. It helps us ensure that our code is working as expected and that we don't introduce bugs when making changes. We'll be using Jest, a delightful JavaScript Testing Framework, to write tests for our API.
Setting Up Jest
Before we start writing tests, we need to configure Jest for our project. We've already installed the necessary dependencies (jest
, ts-jest
, @types/jest
) in the setup phase. Now, we need to create a Jest configuration file. Create a jest.config.js
file in the root of your project with the following configuration:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['<rootDir>/src/tests/**/*.test.ts'],
moduleNameMapper: {
'^@/(.*){{content}}#39;: '<rootDir>/src/$1',
},
};
This configuration tells Jest to use the ts-jest
preset for TypeScript support, set the test environment to Node.js, and look for test files in the src/tests
directory with the .test.ts
extension. We've also added a moduleNameMapper
to handle absolute imports.
Writing Tests for CRUD Operations
Now, let's write some tests for our CRUD operations. Open src/tests/book.test.ts
and add the following code:
import request from 'supertest';
import express from 'express';
import bookRoutes from '../routes/bookRoutes';
import { bookRepository } from '../bookRepository';
import { Book } from '../types';
const app = express();
app.use(express.json());
app.use('/books', bookRoutes);
const clearRepository = () => {
(bookRepository as any).books = [];
};
beforeEach(() => {
clearRepository();
});
describe('Book API', () => {
it('should create a new book', async () => {
const newBookData = { title: 'Test Book', author: 'Test Author', isbn: '1234567890' };
const res = await request(app).post('/books').send(newBookData);
expect(res.statusCode).toEqual(201);
expect(res.body).toHaveProperty('id');
expect(res.body.title).toEqual(newBookData.title);
});
it('should return an error if ISBN is missing', async () => {
const newBookData = { title: 'Test Book', author: 'Test Author' };
const res = await request(app).post('/books').send(newBookData);
expect(res.statusCode).toEqual(400);
expect(res.body.message).toEqual('Invalid book data');
});
it('should get all books', async () => {
await request(app).post('/books').send({ title: 'Test Book 1', author: 'Test Author 1', isbn: '1234567890' });
await request(app).post('/books').send({ title: 'Test Book 2', author: 'Test Author 2', isbn: '0987654321' });
const res = await request(app).get('/books');
expect(res.statusCode).toEqual(200);
expect(res.body.length).toEqual(2);
});
it('should get a book by id', async () => {
const createRes = await request(app).post('/books').send({ title: 'Test Book', author: 'Test Author', isbn: '1234567890' });
const bookId = createRes.body.id;
const res = await request(app).get(`/books/${bookId}`);
expect(res.statusCode).toEqual(200);
expect(res.body.title).toEqual('Test Book');
});
it('should update a book', async () => {
const createRes = await request(app).post('/books').send({ title: 'Test Book', author: 'Test Author', isbn: '1234567890' });
const bookId = createRes.body.id;
const updateData = { title: 'Updated Book' };
const res = await request(app).put(`/books/${bookId}`).send(updateData);
expect(res.statusCode).toEqual(200);
expect(res.body.title).toEqual('Updated Book');
});
it('should delete a book', async () => {
const createRes = await request(app).post('/books').send({ title: 'Test Book', author: 'Test Author', isbn: '1234567890' });
const bookId = createRes.body.id;
const res = await request(app).delete(`/books/${bookId}`);
expect(res.statusCode).toEqual(204);
const getRes = await request(app).get(`/books/${bookId}`);
expect(getRes.statusCode).toEqual(404);
});
});
Here's what's going on in our test file:
- We import the necessary modules, including
request
for making HTTP requests, Express, ourbookRoutes
, and ourbookRepository
. - We create an Express application instance and mount our
bookRoutes
. - We define a
clearRepository
function to clear the in-memory repository before each test. - We use the
beforeEach
hook to callclearRepository
before each test, ensuring a clean state. - We use the
describe
block to group our tests for the Book API. - We write tests for each CRUD operation:
should create a new book
: Tests thePOST /books
endpoint.should return an error if ISBN is missing
: Tests the validation for thePOST /books
endpoint.should get all books
: Tests theGET /books
endpoint.should get a book by id
: Tests theGET /books/:id
endpoint.should update a book
: Tests thePUT /books/:id
endpoint.should delete a book
: Tests theDELETE /books/:id
endpoint.
- We use
supertest
to make HTTP requests to our API andexpect
to make assertions about the responses.
Running Tests
To run our tests, we can use the test
script we added to our package.json
file. Open your terminal and run the following command:
npm test
Or, if you're using yarn:
yarn test
This will run Jest and execute our tests. If all tests pass, you'll see a success message in the console. If any tests fail, you'll see an error message with details about the failed tests.
By writing tests, we can ensure that our API is working correctly and that we don't introduce bugs when making changes. This gives us confidence in our code and helps us build a reliable application.
Conclusion
Wrapping things up, guys, we've built a solid Express API for managing books! We've covered everything from setting up the project to writing tests. You've learned how to implement CRUD operations, use Zod for validation, work with an in-memory repository, and write Jest tests. This is a fantastic foundation for any API project.
Key Takeaways
Let's recap some of the key things we've learned in this guide:
- Express: We used Express to create our API, providing a robust and flexible framework for handling requests and responses.
- CRUD Operations: We implemented the four basic functions of persistent storage: Create, Read, Update, and Delete. These operations are essential for any API that interacts with data.
- Zod Validation: We used Zod to validate our request data, ensuring that it meets our requirements and is safe to use. This helps prevent errors and improves the reliability of our API.
- In-Memory Repository: We implemented an in-memory repository for data storage, which is great for development and testing. In a real-world application, you'd likely use a database like MongoDB or PostgreSQL.
- Jest Testing: We wrote Jest tests to ensure that our API is working correctly. Testing is crucial for building a robust and reliable application.
Further Exploration
Now that you have a basic API set up, there are several ways you can extend it:
- Database Integration: Replace the in-memory repository with a database like MongoDB or PostgreSQL. This will allow you to persist data between server restarts.
- Authentication and Authorization: Add authentication and authorization to your API to protect your data. You could use libraries like Passport or JSON Web Tokens (JWT).
- Pagination: Implement pagination for the
GET /books
endpoint to handle large datasets more efficiently. - Error Handling: Implement more robust error handling to provide better feedback to clients.
- API Documentation: Generate API documentation using tools like Swagger or OpenAPI.
By exploring these topics, you can continue to improve your API and build more complex applications.
Final Thoughts
Building APIs is a fundamental skill for any web developer, and this guide has provided you with a solid foundation for building your own APIs. Remember to practice and experiment with different technologies and techniques to continue learning and growing as a developer. Keep coding, and have fun!