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: