Server-Side Functionality with a Database

Hierarchical Resources and APIs


Learning Objectives

  • You know how to implement an API for hierarchical resources.

In the previous chapter, we discussed how hierarchical resources could be modeled in RESTful APIs. Here, we implement that design in practice with books and their chapters.

Database migration

First, we need to create a migration file to add chapters to the database. The migration file will create a new table for chapters, which will have a foreign key reference to the books table. This way, each chapter can be associated with a specific book.

Create a new file called V3__book_chapters.sql (note two underscores in the name) in the database-migrations folder and add the following SQL commands to it.

CREATE TABLE book_chapters (
  id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
  chapter_number INTEGER NOT NULL CHECK (chapter_number > 0),
  title TEXT NOT NULL CHECK (length(title) > 0),
  content TEXT
);

Then, run the database migration. With this, we have created a new table called book_chapters that has a foreign key reference to the books table. Each chapter has a book_id that references the book it belongs to, a chapter_number to indicate the order of the chapter, a title, and optional content.

Hierarchical Resources and Server-Side APIs

Next, we will create a server-side API that allows us to interact with the hierarchical resources. The API will allow us to create, read, update, and delete chapters associated with books. The structure of the API will be as follows:

  • GET /api/books/{bookId}/chapters returns a list of chapters for a specific book.
  • GET /api/books/{bookId}/chapters/{chapterId} returns a single chapter for a specific book.
  • POST /api/books/{bookId}/chapters creates a new chapter for a specific book.
  • PUT /api/books/{bookId}/chapters/{chapterId} updates a chapter for a specific book.
  • DELETE /api/books/{bookId}/chapters/{chapterId} deletes a chapter for a specific book.

We could expand the existing bookController.js and bookRepository.js (that were worked on in the chapter CRUD Pattern, Repository Pattern, and Layered Architecture), but to keep the code organized, we will create a new controller and repository for chapters.

Repository

First, create a file called chapterRepository.js in the server folder and add the following code to it.

import postgres from "postgres";

const sql = postgres();

const create = async (bookId, chapter) => {
  const result = await sql`INSERT INTO book_chapters
    (book_id, chapter_number, title, content)
    VALUES (${bookId}, ${chapter.chapter_number}, ${chapter.title}, ${chapter.content})
    RETURNING *;`;

  return result[0];
};

const readAll = async (bookId) => {
  return await sql`SELECT * FROM book_chapters
    WHERE book_id = ${bookId}
    ORDER BY chapter_number;`;
};

const readOne = async (bookId, chapterId) => {
  const result = await sql`SELECT * FROM book_chapters
    WHERE book_id = ${bookId} AND id = ${chapterId};`;
  return result[0];
};

const update = async (bookId, chapterId, chapter) => {
  const result = await sql`UPDATE book_chapters SET
      chapter_number = ${chapter.chapter_number},
      title = ${chapter.title},
      content = ${chapter.content}
    WHERE book_id = ${bookId} AND id = ${chapterId}
    RETURNING *;`;

  return result[0];
};

const deleteOne = async (bookId, chapterId) => {
  const result = await sql`DELETE FROM book_chapters
    WHERE book_id = ${bookId} AND id = ${chapterId}
    RETURNING *;
  `;

  return result[0];
};

export { create, deleteOne, readAll, readOne, update };

As you might notice, it is highly similar to the bookRepository.js file, but it operates on the book_chapters table instead of the books table — it is still just CRUD operations after all. The key change is that we pass both bookId and chapterId to the functions that operate on chapters, as chapters are associated with books. We order the chapters by their chapter_number when reading all chapters for a book to make sure that they’re always returned in the correct order.

Loading Exercise...

Controller

Next, create a file called chapterController.js in the server folder and add the following code to it. In the following code, we assume that the path variables will be called bookId and chapterId.

Note again the similarity to the bookController.js file.

import * as chapterRepository from "./chapterRepository.js";

const create = async (c) => {
  const bookId = Number(c.req.param("bookId"));
  if (!Number.isInteger(bookId)) {
    return c.json({ error: "Invalid book id" }, 400);
  }

  const chapter = await c.req.json();

  if (!chapter.chapter_number ||
    !chapter.title) {
    return c.json({ error: "Missing required fields" }, 400);
  }

  const newChapter = await chapterRepository.create(bookId, chapter);
  return c.json(newChapter, 201);
};

const readAll = async (c) => {
  const bookId = Number(c.req.param("bookId"));
  if (!Number.isInteger(bookId)) {
    return c.json({ error: "Invalid book id" }, 400);
  }

  const chapters = await chapterRepository.readAll(bookId);
  return c.json(chapters);
};

const readOne = async (c) => {
  const bookId = Number(c.req.param("bookId"));
  if (!Number.isInteger(bookId)) {
    return c.json({ error: "Invalid book id" }, 400);
  }

  const chapterId = Number(c.req.param("chapterId"));
  if (!Number.isInteger(chapterId)) {
    return c.json({ error: "Invalid chapter id" }, 400);
  }

  const chapter = await chapterRepository.readOne(bookId, chapterId);

  if (!chapter) {
    return c.json({ error: "Chapter not found" }, 404);
  }

  return c.json(chapter);
};

const update = async (c) => {
  const bookId = Number(c.req.param("bookId"));
  if (!Number.isInteger(bookId)) {
    return c.json({ error: "Invalid book id" }, 400);
  }

  const chapterId = Number(c.req.param("chapterId"));
  if (!Number.isInteger(chapterId)) {
    return c.json({ error: "Invalid chapter id" }, 400);
  }

  const chapter = await c.req.json();

  if (!chapter.chapter_number ||
    !chapter.title) {
    return c.json({ error: "Missing required fields" }, 400);
  }

  const updatedChapter = await chapterRepository.update(
    bookId,
    chapterId,
    chapter,
  );

  if (!updatedChapter) {
    return c.json({ error: "Chapter not found" }, 404);
  }

  return c.json(updatedChapter);
};

const deleteOne = async (c) => {
  const bookId = Number(c.req.param("bookId"));
  if (!Number.isInteger(bookId)) {
    return c.json({ error: "Invalid book id" }, 400);
  }

  const chapterId = Number(c.req.param("chapterId"));
  if (!Number.isInteger(chapterId)) {
    return c.json({ error: "Invalid chapter id" }, 400);
  }

  const deletedChapter = await chapterRepository.deleteOne(bookId, chapterId);

  if (!deletedChapter) {
    return c.json({ error: "Chapter not found" }, 404);
  }

  return c.json(deletedChapter);
};

export { create, deleteOne, readAll, readOne, update };

Wiring routes

Finally, we need to wire the controller to the routes. Open the app.js file, and modify it to match the following.

import { Hono } from "@hono/hono";
import { cors } from "@hono/hono/cors";
import * as bookController from "./bookController.js";
import * as chapterController from "./chapterController.js";

const app = new Hono();

app.use("/*", cors());

app.post("/api/books", bookController.create);
app.get("/api/books", bookController.readAll);
app.get("/api/books/:bookId", bookController.readOne);
app.put("/api/books/:bookId", bookController.update);
app.delete("/api/books/:bookId", bookController.deleteOne);

app.post("/api/books/:bookId/chapters", chapterController.create);
app.get("/api/books/:bookId/chapters", chapterController.readAll);
app.get("/api/books/:bookId/chapters/:chapterId", chapterController.readOne);
app.put("/api/books/:bookId/chapters/:chapterId", chapterController.update);
app.delete("/api/books/:bookId/chapters/:chapterId", chapterController.deleteOne);

export default app;

Now, our application has a server-side API that allows us to interact with hierarchical resources, specifically books and their chapters. The API supports creating, reading, updating, and deleting chapters associated with books.

With the API in place, we can test it with curl. Below, we first check the details of a book, then add two chapters to it, and finally check the chapters of the book.

$ curl localhost:8000/api/books/1
{"id":1,"title":"HTML for Hamsters","description":"Teaching HTML to small rodents.","published_at":"2017-02-01T00:00:00.000Z","page_count":156}
$ curl -X POST -H "Content-Type: application/json" -d '{"chapter_number": 1, "title": "From Wheel to Webpage", "content": "First HTML page."}' http://localhost:8000/api/books/1/chapters
{"id":1,"book_id":1,"chapter_number":1,"title":"From Wheel to Webpage","content":"First HTML page."}
$ curl -X POST -H "Content-Type: application/json" -d '{"chapter_number": 2, "title": "The Great Nesting Instinct", "content": "HTML and nested elements."}' http://localhost:8000/api/books/1/chapters
{"id":2,"book_id":1,"chapter_number":2,"title":"The Great Nesting Instinct","content":"HTML and nested elements."}
$ curl http://localhost:8000/api/books/1/chapters
[{"id":1,"book_id":1,"chapter_number":1,"title":"From Wheel to Webpage","content":"First HTML page."},{"id":2,"book_id":1,"chapter_number":2,"title":"The Great Nesting Instinct","content":"HTML and nested elements."}]
Loading Exercise...

Summary

To summarize:

  • Hierarchical resources allow modeling relationships between entities, such as books and their chapters.
  • A database schema can capture these relationships using foreign keys, e.g. chapters referencing their parent book.
  • Server-side APIs can expose hierarchical resources by nesting endpoints under their parent resource.
Loading Exercise...