Graduate Program KB

Build your own RESTful Bookshop API (with HATEOS)

In this tutorial, we will build a RESTful API for a bookshop using HATEOAS (Hypermedia as the Engine of Application State) and the Halson library, and test it using Jest.

Prerequisites

Before we get started, make sure you have the following installed:

  • Node.js (version 14 or higher)
  • npm or yarn (I'll be using yarn in this tutorial)
  • MongoDB (you can use a local instance or a cloud service like MongoDB Atlas)

We'll also be using Express.js as our web framework and Mongoose as our MongoDB object modeling tool.

Step 1: Set up the project

Create a new directory for your project and navigate into it:

mkdir bookshop-api
cd bookshop-api

Initialize a new Node.js project using npm:

yarn init

Add version control to your project:

git init

Install the following dependencies:

yarn add express mongoose body-parser halson

We'll also need to install some development dependencies:

yarn add -D jest supertest 

Add a test target to the scripts section of your package.json file:

"scripts":{
    "test": "jest",
    "test-watch": "jest --watchAll"
} 

Create a new file called bookshop-api.test.js for setup and teardown of server and database connection

const request = require('supertest');
const App = require('./bookshop-api.js');

describe('bookshop-api', () => {
  
  let server;

  beforeEach(async () => {
    server = new App();
    await server.start();
  })

  afterEach(async () => {
    await server.stop()
  })


  describe('GET /books', () => {
    it('responds with JSON', async () => {
      const response = await request(server.app).get('/books');

      expect(response.status).toBe(200);
      expect(response.type).toBe('application/json');
    });  
  })  

});

This creates a test suite with lifecycle hooks to setup and teardown the server. We also have a test for the GET /books route because jest will not succeed without a test. We will add more tests for this route later.

Run the tests using the following command:

yarn run test-watch

Notice that the tests fail since there is no implementation.

Create a new file called bookshop-api.js and add the following code:

const express = require('express');
const bodyParser = require('body-parser');
const mongoose = require('mongoose');
const halson = require('halson');

class ServerApp {
  constructor(){
    this.app = express();
    this.app.use(bodyParser.json());

    this.app.get('/books', async (req, res) => {
      res.status(200).json({});
    });
  }

  start() {
    const serverPromise = new Promise((resolve, reject) => {
      this.server = this.app.listen(3000, async () => {
          await mongoose.connect('mongodb://localhost/bookshop', {
          useNewUrlParser: true,
          useUnifiedTopology: true,
        });
        this.BookSchema = new mongoose.Schema({
          title: String,
          author: String,
          price: Number,
        });
        this.Book = mongoose.model('Book', this.BookSchema);
        resolve();
      });
    });
    return serverPromise;
  }

  stop() {
      const serverPromise = new Promise((resolve, reject) => {
      this.server.close(async () => {
        mongoose.deleteModel(/.+/)
        await mongoose.connection.close()
        resolve();
      });
    });
    return serverPromise;
  }
}

module.exports = ServerApp

After implementing the server skeleton you should see that the test passes

Step 2: Write tests for the GET /books endpoint

Create a new file called bookshop-api.test.js in the root of your project and add the following code:

describe('GET /books', () => {
  it('responds with JSON', async () => {
    const response = await request(server.app).get('/books');

    expect(response.status).toBe(200);
    expect(response.type).toBe('application/json');
  });

  it('includes all books in the resource as items', async () => {
    const book = new server.Book({ title: 'Test book', author: 'Test author', price: 9.99 });
    await book.save();

    const response = await request(server.app).get('/books');
    expect(response.body.items.length).toBeGreaterThan(0);
  });
});

This creates a test suite for the GET /books endpoint with two tests:

  • Checks that the response has a status code of 200 and a content type of application/json.
  • Checks that we get a book

You should see that the first test passes, but the second test fails since we have not written code to get the books.

Step 3: Implement the GET /books endpoint

We need to implement retrieving the books to make the test pass. Update the /books endpoint in bookshop-api.js with the following code:

app.get('/books', async (req, res) => {
  const books = await this.Book.find();
  const resource = {
    items: books,
  }
  res.json(resource);
});

Step 4: Update the tests to reflect HAL resource requirements

Update the GET /books tests in bookshop-api.test.js with the following code:

describe('GET /books', () => {

  let booksResponse;
  beforeEach(async () => {
    const book = new server.Book({ title: 'Test book', author: 'Test author', price: 9.99 });
    await book.save();
    booksResponse = await request(server.app).get('/books');
  })

  it('responds with JSON', () => {
    expect(booksResponse.status).toBe(200);
    expect(booksResponse.type).toBe('application/json');
  });

  it('returns a HAL resource with a self-link', () => {
    expect(booksResponse.body._links.self.href).toBe('/books');
  });

  it('includes all books in the resource as items with self-links', () => {
    expect(booksResponse.body.items.length).toBeGreaterThan(0);
    expect(booksResponse.body.items[0]._links.self.href).toBe(`/books/${booksResponse.body.items[0]._id}`);
  });

  it('includes a create link', () => {
    expect(booksResponse.body._links.create.href).toBe('/books');
    expect(booksResponse.body._links.create.method).toBe('POST');
  });
});

These updated tests check that:

  • The response includes a HAL resource with a self-link.
  • The resource includes all books in the database as items, each with a self-link.
  • The resource includes a link to create a new book.

We have also moved the request to a beforeEach hook instead of repeating the code for each test.

Let's modify the /books endpoint to include links to individual books as well as a link to create a new book. We'll also add a title property to the HAL resource.

Update the /books endpoint in bookshop-api.js with the following code:

this.app.get("/books", async (req, res) => {
  const books = await this.Book.find();
  const resource = {
    title: "Bookshop API",
    items: books.map((book) => {
      return {
        ...book.toJSON(),
        _links: {
          self: { href: `/books/${book.id}` },
        },
      };
    }),
    _links: {
      self: { href: "/books" },
      create: { href: "/books", method: "POST" },
    },
  };

  res.json(resource);
});

This creates a HAL resource with the following properties:

  • title: A string representing the title of the resource.
  • items: An array of books, where each book has a self-link with the URL /books/:id.
  • _links: An object containing links to the resource itself and a link to create a new book.

Step 6: Refactor the endpoint to use the halson library

We can use the halson library to add links to the resources rather than managing the format directly. Since we have tests we can change the code without worrying about it breaking. Replace the get handler in bookshop-api.js with the following

this.app.get("/books", async (req, res) => {
  const books = await this.Book.find();
  const resource = halson({
    title: "Bookshop API",
    items: books.map((book) => {
      const bookResource = halson(book.toJSON());
      bookResource.addLink("self", `/books/${book.id}`);
      return bookResource;
    }),
  });

  resource.addLink("self", "/books");
  resource.addLink("create", { href: "/books", method: "POST" });
  res.json(resource);
});

Notice that the tests should still be green

Step 7: Update the tests for individual book retrieval and creation

Add the following code to bookshop-api.test.js:

const mongoose = require("mongoose");

describe('GET /books/:id', () => {
  let book;
  beforeEach(async ()=>{
    book = new server.Book({ title: 'Test book', author: 'Test author', price: 9.99 });
    await book.save();
  })

  it('responds with JSON', async () => {
    const response = await request(server.app).get(`/books/${book.id}`);

    expect(response.status).toBe(200);
    expect(response.type).toBe('application/json');
  });

  it('returns a HAL resource with a self-link and a collection link', async () => {
    const response = await request(server.app).get(`/books/${book.id}`);

    expect(response.body._links.self.href).toBe(`/books/${book.id}`);
    expect(response.body._links.collection.href).toBe('/books');
  });

  it('returns a 404 if the book is not found', async () => {
    const response = await request(server.app).get(`/books/${new mongoose.Types.ObjectId()}`);

    expect(response.status).toBe(404);
  });
});

describe('POST /books', () => {
  it('creates a new book', async () => {
    const book = { title: 'Test book', author: 'Test author', price: 9.99 };
    const response = await request(server.app)
      .post('/books')
      .send(book);

    expect(response.status).toBe(201);
    expect(response.type).toBe('application/json');
    expect(response.body.title).toBe(book.title);
    expect(response.body.author).toBe(book.author);
    expect(response.body.price).toBe(book.price);
  });
});

These tests check that:

  • The GET /books/:id route returns a HAL resource with a self-link and a collection link for a valid book ID.
  • The GET /books/:id route returns a 404 if the book is not found.
  • The POST /books route creates a new book with the correct properties.

These tests should fail since we don't have a route for individual books or a POST handler for /books.

Step 8: Add routes for creating and retrieving individual books

Let's add some more routes to our API to create and retrieve individual books.

Add the following code to the constructor in bookshop-api.js:

// Get a single book
this.app.get('/books/:id', async (req, res) => {
  const book = await this.Book.findById(req.params.id);

  if (!book) {
    res.sendStatus(404);
    return;
  }

  const resource = halson(book.toJSON())
    .addLink('self', `/books/${book.id}`)
    .addLink('collection', '/books');

  res.json(resource);
});

// Create a new book
this.app.post('/books', async (req, res) => {
  const book = new this.Book(req.body);
  await book.save();

  const resource = halson(book.toJSON())
    .addLink('self', `/books/${book.id}`)
    .addLink('collection', '/books');

  res.status(201).json(resource);
});

Now we have a route to retrieve a single book by its ID and a route to create a new book.

Step 9: Refactor the server to use the repository pattern

Instead of using a mongo database for the tests it would be better to use an in memory key value store. We can use the repository pattern to decouple the server logic from how the books get stored. Let's move the database presistance logic out of the server

class MongoBookRepository {
  constructor() {
    super();
  }

  async setup() {
    await mongoose.connect("mongodb://localhost/bookshop", {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    });
    this.BookSchema = new mongoose.Schema({
      _id: String,
      title: String,
      author: String,
      price: Number,
    });
    this.Book = mongoose.model("Book", this.BookSchema);
  }

  convertFromMongo(book) {
    if (book) {
      return {
        id: book._id,
        title: book.title,
        author: book.author,
        price: book.price,
      };
    }
    return null;
  }

  async getById(id) {
    const book = await this.Book.findById(id);
    return this.convertFromMongo(book);
  }

  convertToMongo(book) {
    if (book) {
      return {
        _id: book.id,
        title: book.title,
        author: book.author,
        price: book.price,
      };
    }
    return null;
  }

  async save(mongoBook) {
    const book = new this.Book(this.convertToMongo(mongoBook));
    await book.save();
  }

  async listAll() {
    const books = await this.Book.find();
    return books.map(this.convertFromMongo);
  }

  async cleanUp() {
    await this.Book.deleteMany({});
    mongoose.deleteModel(/.+/);
    await mongoose.connection.close();
  }
}

This was incrementally refactored under test. We made a new MongoBookRepository which the server instantiated. First the mongoose connection logic was moved from the server into the repository. Then the server was updated to call the repository. After this, the tests were run to make sure it all works. Next, the database queries for the routes were moved to the repository one at a time, running tests after each. Note that the book representation also needs to be converted to and from mongo; mongo prefixes the id property name with an underscore and adds additional values to the books such as _v. We also still wipe the database when closing the connection to reset it for the next tests. We can remove this later after changing the tests to use an in memory repository.

Next we can add a Book entity domain class to encapsulate the id generation.

const { v4: uuidv4 } = require("uuid");

class Book {
  constructor(title, author, price) {
    this.id = uuidv4();
    this.title = title;
    this.author = author;
    this.price = price;
  }
}

Now that all the database logic has been moved to the repository we can create a base class and derive another repository to store the books in memory.

class BookRepository {
  async getById(id) {}
  async save(book) {}
  async listAll() {}
  async cleanUp() {}
  async setup() {}
}

Update the MongoBookRepository to extend the base class

class MongoBookRepository extends BookRepository {
...
}

And implement an in memory repository

class InMemoryBookRepository extends BookRepository {
  constructor() {
    super();
    this.books = new Map();
  }

  async getById(id) {
    return this.books.get(id);
  }
  async save(book) {
    this.books.set(book.id, book);
  }
  async listAll() {
    const iterator = this.books.values();
    return Array.from(iterator);
  }
}

We also need to update the server to dependency inject the repository

class ServerApp {
  constructor(bookRepository) {
    this.bookRepository = bookRepository;
    ...
  }
  ...
}

And now the tests can be updated to use the Book domain class and the InMemoryBookRepository The final result can be found in graduate-programme-resources

Conclusion

In this tutorial, we've built a RESTful API for a bookshop using HATEOAS and the Halson library. We've created routes to retrieve a list of books, retrieve individual books, create new books, and we've used Halson to create HAL resources with HATEOAS links.

By following this tutorial, you should now have a good understanding of how to build a RESTful API with HATEOAS and Halson, and how to test your API using Jest.