» Node.js: Building Event-Driven Microservices with Kafka » 2. Producer: Web Service » 2.4 Create and Search Books

Create and Search Books

Let's add the APIs for creating books and searching for books, then we can input some test data for later use.

Create API

Create src/domain/gateway/book_manager.ts:

import { Book } from "../model";

export interface BookManager {
  createBook(b: Book): Promise<number>;
}

Again, we‘re using 4 Layers Architecture Pattern. It will pay off when your project grows bigger and bigger. Read more here.

Create src/domain/gateway/index.ts:

export { BookManager } from "./book_manager";

Remember to create the index.ts for other sub-folders.

Install mysql dependency:

npm install mysql2

Prepare the MySQL database:

  • Install MySQL on your machine and start it.
  • Create a database named lr_event_book.
CREATE DATABASE lr_event_book CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  • Create the user test_user:
CREATE USER 'test_user'@'localhost' IDENTIFIED BY 'test_pass';
GRANT ALL PRIVILEGES ON lr_event_book.* TO 'test_user'@'localhost';
FLUSH PRIVILEGES;

Create src/infrastructure/database/mysql.ts:

import mysql, { ResultSetHeader } from "mysql2";

import { Book } from "../../domain/model";
import { BookManager } from "../../domain/gateway";

export class MySQLPersistence implements BookManager {
  private db: mysql.Connection;
  private page_size: number;

  constructor(dsn: string, page_size: number) {
    this.page_size = page_size;
    this.db = mysql.createConnection(dsn);
    this.db.addListener("error", (err) => {
      console.error("Error connecting to MySQL:", err.message);
    });

    this.db.execute(
      `CREATE TABLE IF NOT EXISTS books (
        id INT AUTO_INCREMENT PRIMARY KEY,
        title VARCHAR(255) NOT NULL,
        author VARCHAR(255) NOT NULL,
        published_at VARCHAR(15) NOT NULL,
        description TEXT NOT NULL,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )`,
      (err) => {
        if (err) {
          console.error("Error in MySQL:", err.message);
        } else {
          console.log("Successfully initialized tables.");
        }
      }
    );
  }

  async createBook(b: Book): Promise<number> {
    const { title, author, published_at, description } = b;
    const [result] = await this.db
      .promise()
      .query(
        "INSERT INTO books (title, author, published_at, description) VALUES (?, ?, ?, ?)",
        [title, author, published_at, description]
      );
    return (result as ResultSetHeader).insertId;
  }

  close(): void {
    this.db.end();
  }
}

Create src/infrastructure/config/config.ts:

import { readFileSync } from "fs";

interface DBConfig {
  dsn: string;
}

interface ApplicationConfig {
  port: number;
  page_size: number;
  templates_dir: string;
}

export interface Config {
  app: ApplicationConfig;
  db: DBConfig;
}

export function parseConfig(filename: string): Config {
  return JSON.parse(readFileSync(filename, "utf-8"));
}

Create config.json:

{
  "app": {
    "port": 3000,
    "page_size": 5,
    "templates_dir": "src/adapter/templates"
  },
  "db": {
    "dsn": "mysql://test_user:test_pass@127.0.0.1:3306/lr_event_book?charset=utf8mb4"
  }
}

Caution: Do not directly track your config.json file with Git, as this could potentially leak sensitive data. If necessary, only track the template of the configuration file.
e.g.

{
 "app": {
   "port": 3000
 },
 "db": {
   "dsn": ""
 }
}

Create src/application/executor/book_operator.ts:

import { BookManager } from "../../domain/gateway";
import { Book } from "../../domain/model";

export class BookOperator {
  private bookManager: BookManager;

  constructor(b: BookManager) {
    this.bookManager = b;
  }

  async createBook(b: Book): Promise<Book> {
    const id = await this.bookManager.createBook(b);
    b.id = id;
    return b;
  }
}

Create src/application/wire_helper.ts:

import { Config } from "../infrastructure/config";
import { BookManager } from "../domain/gateway";
import { MySQLPersistence } from "../infrastructure/database";

// WireHelper is the helper for dependency injection
export class WireHelper {
  private sql_persistence: MySQLPersistence;

  constructor(c: Config) {
    this.sql_persistence = new MySQLPersistence(c.db.dsn, c.app.page_size);
  }

  bookManager(): BookManager {
    return this.sql_persistence;
  }
}

Create src/adapter/router.ts:

import express, { Request, Response } from "express";
import { engine } from "express-handlebars";

import { Book } from "../domain/model";
import { BookOperator } from "../application/executor";
import { WireHelper } from "../application";

class RestHandler {
  private bookOperator: BookOperator;

  constructor(bookOperator: BookOperator) {
    this.bookOperator = bookOperator;
  }

  // Create a new book
  public async createBook(req: Request, res: Response): Promise<void> {
    try {
      const book = await this.bookOperator.createBook(req.body as Book);
      res.status(201).json(book);
    } catch (err) {
      console.error(`Failed to create: ${err}`);
      res.status(404).json({ error: "Failed to create" });
    }
  }
}

// Create router
function MakeRouter(wireHelper: WireHelper): express.Router {
  const restHandler = new RestHandler(
    new BookOperator(wireHelper.bookManager())
  );

  const router = express.Router();
  router.get("/", (req, res) => {
    // Render the 'index.handlebars' template, passing data to it
    res.render("index", { layout: false, title: "LiteRank Book Store" });
  });
  router.post("/api/books", restHandler.createBook.bind(restHandler));
  return router;
}

export function InitApp(
  templates_dir: string,
  wireHelper: WireHelper
): express.Express {
  const app = express();

  // Middleware to parse JSON bodies
  app.use(express.json());

  // Set Handlebars as the template engine
  app.engine("handlebars", engine());
  app.set("view engine", "handlebars");
  // Set the directory for template files
  app.set("views", templates_dir);

  const r = MakeRouter(wireHelper);
  app.use("", r);
  return app;
}

Move templates to src/adapter/templates.

Replace src/app.ts with the following code:

import { WireHelper } from "./application";
import { InitApp } from "./adapter/router";
import { parseConfig } from "./infrastructure/config";

const config_filename = "config.json";

const c = parseConfig(config_filename);
const wireHelper = new WireHelper(c);
const app = InitApp(c.app.templates_dir, wireHelper);

app.listen(c.app.port, () => {
  console.log(`Running on port ${c.app.port}`);
});

Run the server again and try it with curl:

npm run dev
curl --request POST \
  --url http://localhost:3000/api/books \
  --header 'Content-Type: application/json' \
  --data '{
	"title": "The Great Gatsby",
	"author": "F. Scott Fitzgerald",
	"published_at": "1925-04-10",
	"description": "A novel depicting the opulent lives of wealthy Long Island residents during the Jazz Age."
}'

Example response:

{
  "title": "The Great Gatsby",
  "author": "F. Scott Fitzgerald",
  "published_at": "1925-04-10",
  "description": "A novel depicting the opulent lives of wealthy Long Island residents during the Jazz Age.",
  "id": 13
}

Put in some data for test

curl -X POST -H "Content-Type: application/json" -d '{"title": "To Kill a Mockingbird", "author": "Harper Lee", "published_at": "1960-07-11", "description": "A novel set in the American South during the 1930s, dealing with themes of racial injustice and moral growth."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "1984", "author": "George Orwell", "published_at": "1949-06-08", "description": "A dystopian novel depicting a totalitarian regime, surveillance, and propaganda."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "Pride and Prejudice", "author": "Jane Austen", "published_at": "1813-01-28", "description": "A classic novel exploring the themes of love, reputation, and social class in Georgian England."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Catcher in the Rye", "author": "J.D. Salinger", "published_at": "1951-07-16", "description": "A novel narrated by a disaffected teenager, exploring themes of alienation and identity."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Lord of the Rings", "author": "J.R.R. Tolkien", "published_at": "1954-07-29", "description": "A high fantasy epic following the quest to destroy the One Ring and defeat the Dark Lord Sauron."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "Moby-Dick", "author": "Herman Melville", "published_at": "1851-10-18", "description": "A novel exploring themes of obsession, revenge, and the nature of good and evil."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Hobbit", "author": "J.R.R. Tolkien", "published_at": "1937-09-21", "description": "A fantasy novel set in Middle-earth, following the adventure of Bilbo Baggins and the quest for treasure."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Adventures of Huckleberry Finn", "author": "Mark Twain", "published_at": "1884-12-10", "description": "A novel depicting the journey of a young boy and an escaped slave along the Mississippi River."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "War and Peace", "author": "Leo Tolstoy", "published_at": "1869-01-01", "description": "A novel depicting the Napoleonic era in Russia, exploring themes of love, war, and historical determinism."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "Alice’s Adventures in Wonderland", "author": "Lewis Carroll", "published_at": "1865-11-26", "description": "A children’s novel featuring a young girl named Alice who falls into a fantastical world populated by peculiar creatures."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Odyssey", "author": "Homer", "published_at": "8th Century BC", "description": "An ancient Greek epic poem attributed to Homer, detailing the journey of Odysseus after the Trojan War."}' http://localhost:3000/api/books

Search API

Update src/domain/gateway/book_manager.ts:

@@ -2,4 +2,5 @@ import { Book } from "../model";
 
 export interface BookManager {
   createBook(b: Book): Promise<number>;
+  getBooks(offset: number, keyword: string): Promise<Book[]>;
 }

Update src/infrastructure/database/mysql.ts:

@@ -44,6 +44,21 @@ export class MySQLPersistence implements BookManager {
     return (result as ResultSetHeader).insertId;
   }
 
+  async getBooks(offset: number, keyword: string): Promise<Book[]> {
+    let query = "SELECT * FROM books";
+    let params: (string | number)[] = [];
+
+    if (keyword) {
+      query += " WHERE title LIKE ? OR author LIKE ? OR description LIKE ?";
+      params = [`%${keyword}%`, `%${keyword}%`, `%${keyword}%`];
+    }
+
+    query += " LIMIT ?, ?";
+    params.push(offset, this.page_size);
+    const [rows] = await this.db.promise().query(query, params);
+    return rows as Book[];
+  }
+
   close(): void {
     this.db.end();
   }

Update src/application/executor/book_operator.ts:

@@ -13,4 +13,8 @@ export class BookOperator {
     b.id = id;
     return b;
   }
+
+  async getBooks(offset: number, query: string): Promise<Book[]> {
+    return await this.bookManager.getBooks(offset, query);
+  }
 }

Update src/adapter/router.ts:

@@ -12,6 +12,24 @@ class RestHandler {
     this.bookOperator = bookOperator;
   }
 
+  // Get all books
+  public async getBooks(req: Request, res: Response): Promise<void> {
+    let offset = parseInt(req.query.o as string);
+    if (isNaN(offset)) {
+      offset = 0;
+    }
+    try {
+      const books = await this.bookOperator.getBooks(
+        offset,
+        req.query.q as string
+      );
+      res.status(200).json(books);
+    } catch (err) {
+      console.error(`Failed to get books: ${err}`);
+      res.status(404).json({ error: "Failed to get books" });
+    }
+  }
+
   // Create a new book
   public async createBook(req: Request, res: Response): Promise<void> {
     try {
@@ -35,6 +53,7 @@ function MakeRouter(wireHelper: WireHelper): express.Router {
     // Render the 'index.handlebars' template, passing data to it
     res.render("index", { layout: false, title: "LiteRank Book Store" });
   });
+  router.get("/api/books", restHandler.getBooks.bind(restHandler));
   router.post("/api/books", restHandler.createBook.bind(restHandler));
   return router;
 }

Run the server again and try it with curl:

curl --request GET --url 'http://localhost:3000/api/books?q=love'

Example response:

[
  {
    "id": 4,
    "title": "Pride and Prejudice",
    "author": "Jane Austen",
    "published_at": "1813-01-28",
    "description": "A classic novel exploring the themes of love, reputation, and social class in Georgian England.",
    "created_at": "2024-04-02T13:02:59.314Z"
  },
  {
    "id": 10,
    "title": "War and Peace",
    "author": "Leo Tolstoy",
    "published_at": "1869-01-01",
    "description": "A novel depicting the Napoleonic era in Russia, exploring themes of love, war, and historical determinism.",
    "created_at": "2024-04-02T13:02:59.420Z"
  }
]

Show books on index page

Update src/adapter/router.ts:

@@ -12,6 +12,18 @@ class RestHandler {
     this.bookOperator = bookOperator;
   }
 
+  public async indexPage(req: Request, res: Response): Promise<void> {
+    let books: Book[];
+    try {
+      books = await this.bookOperator.getBooks(0, "");
+    } catch (err) {
+      console.warn(`Failed to get books: ${err}`);
+      books = [];
+    }
+    // Render the 'index.handlebars' template, passing data to it
+    res.render("index", { layout: false, title: "LiteRank Book Store", books });
+  }
+
   // Get all books
   public async getBooks(req: Request, res: Response): Promise<void> {
     let offset = parseInt(req.query.o as string);
@@ -49,10 +61,7 @@ function MakeRouter(wireHelper: WireHelper): express.Router {
   );
 
   const router = express.Router();
-  router.get("/", (req, res) => {
-    // Render the 'index.handlebars' template, passing data to it
-    res.render("index", { layout: false, title: "LiteRank Book Store" });
-  });
+  router.get("/", restHandler.indexPage.bind(restHandler));
   router.get("/api/books", restHandler.getBooks.bind(restHandler));
   router.post("/api/books", restHandler.createBook.bind(restHandler));
   return router;

Update src/adapter/templates/index.handlebars:

@@ -21,6 +21,20 @@
                 class="w-full px-4 py-2 rounded-md border-gray-300 focus:outline-none focus:border-blue-500">
         </div>
 
+        <!-- Books Section -->
+        <div class="mb-8">
+            <h2 class="text-2xl font-bold mb-4">Books</h2>
+            <div class="grid grid-cols-4 gap-2">
+                {{#each books}}
+                <div class="bg-white p-4 rounded-md border-gray-300 shadow mt-2">
+                    <div><b>{{this.title}}</b></div>
+                    <div class="text-gray-500 text-sm">{{this.published_at}}</div>
+                    <div class="italic text-sm">{{this.author}}</div>
+                </div>
+                {{/each}}
+            </div>
+        </div>
+
         <!-- Trends Section -->
         <div class="mb-8">
             <h2 class="text-2xl font-bold mb-4">Trends</h2>

Restart the server and refresh the index page in your browser, you‘ll see the book list.

Search books on index page

Update src/adapter/router.ts:

@@ -14,14 +14,20 @@ class RestHandler {
 
   public async indexPage(req: Request, res: Response): Promise<void> {
     let books: Book[];
+    const q = req.query.q as string;
     try {
-      books = await this.bookOperator.getBooks(0, "");
+      books = await this.bookOperator.getBooks(0, q);
     } catch (err) {
       console.warn(`Failed to get books: ${err}`);
       books = [];
     }
     // Render the 'index.handlebars' template, passing data to it
-    res.render("index", { layout: false, title: "LiteRank Book Store", books });
+    res.render("index", {
+      layout: false,
+      title: "LiteRank Book Store",
+      books,
+      q,
+    });
   }
 
   // Get all books

Update src/adapter/templates/index.handlebars:

@@ -12,18 +12,21 @@
 
 <body class="bg-gray-100 p-2">
     <div class="container mx-auto py-8">
-        <h1 class="text-4xl font-bold">{{ title }}</h1>
+        <h1 class="text-4xl font-bold"><a href="/">{{ title }}</a></h1>
 
         <!-- Search Bar Section -->
         <div class="mb-8">
             <h2 class="text-2xl font-bold mb-4 mt-6">Search</h2>
-            <input type="text" placeholder="Search for books..."
-                class="w-full px-4 py-2 rounded-md border-gray-300 focus:outline-none focus:border-blue-500">
+            <form class="flex">
+                <input type="text" name="q" value="{{q}}" placeholder="Search for books..."
+                    class="flex-grow px-4 py-2 rounded-l-md border-gray-300 focus:outline-none focus:border-blue-500">
+                <button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-r-md">Search</button>
+            </form>
         </div>
 
         <!-- Books Section -->
         <div class="mb-8">
-            <h2 class="text-2xl font-bold mb-4">Books</h2>
+            <h2 class="text-2xl font-bold mb-4">{{#if q}}Keyword: “{{q}}“{{else}}Books{{/if}}</h2>
             <div class="grid grid-cols-4 gap-2">
                 {{#each books}}
                 <div class="bg-white p-4 rounded-md border-gray-300 shadow mt-2">

Restart the server and refresh the index page in your browser.

The search results look like this:

search results