Build An Express API For Books: CRUD, Zod, And Jest

by Omar Yusuf 52 views

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:

  1. Create a new directory:

    mkdir express-book-api
    cd express-book-api
    
  2. 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 an id, title, author, and isbn.
  • CreateBook: Represents the data required to create a new book, without the id.
  • 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 requires title, author, and isbn to be non-empty strings.
  • updateBookSchema: This schema validates the data for updating an existing book. It allows title, author, and isbn 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 and PUT 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, our bookRoutes, and our bookRepository.
  • 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 call clearRepository 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 the POST /books endpoint.
    • should return an error if ISBN is missing: Tests the validation for the POST /books endpoint.
    • should get all books: Tests the GET /books endpoint.
    • should get a book by id: Tests the GET /books/:id endpoint.
    • should update a book: Tests the PUT /books/:id endpoint.
    • should delete a book: Tests the DELETE /books/:id endpoint.
  • We use supertest to make HTTP requests to our API and expect 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!