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 domain/gateway/book_manager.go:
/*
Package gateway contains all domain gateways.
*/
package gateway
import (
"context"
"literank.com/event-books/domain/model"
)
// BookManager manages all books
type BookManager interface {
CreateBook(ctx context.Context, b *model.Book) (uint, error)
}
Again, we‘re using 4 Layers Architecture Pattern. It will pay off when your project grows bigger and bigger. Read more here.
Install gorm
dependencies:
go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql
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 infrastructure/database/mysql.go:
/*
Package database does all db persistence implementations.
*/
package database
import (
"context"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"literank.com/event-books/domain/model"
)
// MySQLPersistence runs all MySQL operations
type MySQLPersistence struct {
db *gorm.DB
pageSize int
}
// NewMySQLPersistence constructs a new MySQLPersistence
func NewMySQLPersistence(dsn string, pageSize int) (*MySQLPersistence, error) {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
// Auto Migrate the data structs
if err := db.AutoMigrate(&model.Book{}); err != nil {
return nil, err
}
return &MySQLPersistence{db, pageSize}, nil
}
// CreateBook creates a new book
func (s *MySQLPersistence) CreateBook(ctx context.Context, b *model.Book) (uint, error) {
if err := s.db.WithContext(ctx).Create(b).Error; err != nil {
return 0, err
}
return b.ID, nil
}
Install yaml
dependencies:
go get -u gopkg.in/yaml.v3
Create infrastructure/config/config.go:
/*
Package config provides config structures and parse funcs.
*/
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Config is the global configuration.
type Config struct {
App ApplicationConfig `json:"app" yaml:"app"`
DB DBConfig `json:"db" yaml:"db"`
}
// DBConfig is the configuration of databases.
type DBConfig struct {
DSN string `json:"dsn" yaml:"dsn"`
}
// ApplicationConfig is the configuration of main app.
type ApplicationConfig struct {
Port int `json:"port" yaml:"port"`
PageSize int `json:"page_size" yaml:"page_size"`
TemplatesPattern string `json:"templates_pattern" yaml:"templates_pattern"`
}
// Parse parses config file and returns a Config.
func Parse(filename string) (*Config, error) {
buf, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
c := &Config{}
err = yaml.Unmarshal(buf, c)
if err != nil {
return nil, fmt.Errorf("failed to parse file %s: %v", filename, err)
}
return c, nil
}
Create config.yml:
app:
port: 8080
page_size: 5
templates_pattern: "adapter/templates/*.html"
db:
dsn: "test_user:test_pass@tcp(127.0.0.1:3306)/lr_event_book?charset=utf8mb4&parseTime=True&loc=Local"
Caution: Do not directly track your config.yml file with Git, as this could potentially leak sensitive data. If necessary, only track the template of the configuration file.
e.g.app: port: 8080 db: dsn: ""
Create application/executor/book_operator.go:
/*
Package executor handles request-response style business logic.
*/
package executor
import (
"context"
"literank.com/event-books/domain/gateway"
"literank.com/event-books/domain/model"
)
// BookOperator handles book input/output and proxies operations to the book manager.
type BookOperator struct {
bookManager gateway.BookManager
}
// NewBookOperator constructs a new BookOperator
func NewBookOperator(b gateway.BookManager) *BookOperator {
return &BookOperator{bookManager: b}
}
// CreateBook creates a new book
func (o *BookOperator) CreateBook(ctx context.Context, b *model.Book) (*model.Book, error) {
id, err := o.bookManager.CreateBook(ctx, b)
if err != nil {
return nil, err
}
b.ID = id
return b, nil
}
Create application/wire_helper.go:
/*
Package application provides all common structures and functions of the application layer.
*/
package application
import (
"literank.com/event-books/domain/gateway"
"literank.com/event-books/infrastructure/config"
"literank.com/event-books/infrastructure/database"
)
// WireHelper is the helper for dependency injection
type WireHelper struct {
sqlPersistence *database.MySQLPersistence
}
// NewWireHelper constructs a new WireHelper
func NewWireHelper(c *config.Config) (*WireHelper, error) {
db, err := database.NewMySQLPersistence(c.DB.DSN, c.App.PageSize)
if err != nil {
return nil, err
}
return &WireHelper{
sqlPersistence: db}, nil
}
// BookManager returns an instance of BookManager
func (w *WireHelper) BookManager() gateway.BookManager {
return w.sqlPersistence
}
Create adapter/router.go:
/*
Package adapter adapts to all kinds of framework or protocols.
*/
package adapter
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"literank.com/event-books/application"
"literank.com/event-books/application/executor"
"literank.com/event-books/domain/model"
)
// RestHandler handles all restful requests
type RestHandler struct {
bookOperator *executor.BookOperator
}
func newRestHandler(wireHelper *application.WireHelper) *RestHandler {
return &RestHandler{
bookOperator: executor.NewBookOperator(wireHelper.BookManager()),
}
}
// MakeRouter makes the main router
func MakeRouter(templates_pattern string, wireHelper *application.WireHelper) (*gin.Engine, error) {
rest := newRestHandler(wireHelper)
// Create a new Gin router
r := gin.Default()
// Load HTML templates from the templates directory
r.LoadHTMLGlob(templates_pattern)
// Define a health endpoint handler
r.GET("/", rest.indexPage)
apiGroup := r.Group("/api")
apiGroup.POST("/books", rest.createBook)
return r, nil
}
// Render and show the index page
func (r *RestHandler) indexPage(c *gin.Context) {
// Render the HTML template named "index.html"
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "LiteRank Book Store",
})
}
// Create a new book
func (r *RestHandler) createBook(c *gin.Context) {
var reqBody model.Book
if err := c.ShouldBindJSON(&reqBody); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
book, err := r.bookOperator.CreateBook(c, &reqBody)
if err != nil {
fmt.Printf("Failed to create: %v\n", err)
c.JSON(http.StatusNotFound, gin.H{"error": "failed to create"})
return
}
c.JSON(http.StatusCreated, book)
}
Move templates to adapter/templates.
Replace main.go with the following code:
package main
import (
"fmt"
"literank.com/event-books/adapter"
"literank.com/event-books/application"
"literank.com/event-books/infrastructure/config"
)
const configFileName = "config.yml"
func main() {
// Read the config
c, err := config.Parse(configFileName)
if err != nil {
panic(err)
}
// Prepare dependencies
wireHelper, err := application.NewWireHelper(c)
if err != nil {
panic(err)
}
// Build main router
r, err := adapter.MakeRouter(c.App.TemplatesPattern, wireHelper)
if err != nil {
panic(err)
}
// Run the server on the specified port
if err := r.Run(fmt.Sprintf(":%d", c.App.Port)); err != nil {
panic(err)
}
}
Run go mod tidy
to tidy the go.mod
file:
go mod tidy
Run the server again and try it with curl
:
go run main.go
curl --request POST \
--url http://localhost:8080/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:
{
"id": 1,
"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.",
"created_at": "2024-04-02T20:36:01.015+08:00"
}
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:8080/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:8080/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:8080/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:8080/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:8080/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:8080/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:8080/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:8080/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:8080/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:8080/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:8080/api/books
Search API
Update domain/gateway/book_manager.go:
@@ -12,4 +12,5 @@ import (
// BookManager manages all books
type BookManager interface {
CreateBook(ctx context.Context, b *model.Book) (uint, error)
+ GetBooks(ctx context.Context, offset int, keyword string) ([]*model.Book, error)
}
Update infrastructure/database/mysql.go:
@@ -39,3 +39,17 @@ func (s *MySQLPersistence) CreateBook(ctx context.Context, b *model.Book) (uint,
}
return b.ID, nil
}
+
+// GetBooks gets a list of books by offset and keyword
+func (s *MySQLPersistence) GetBooks(ctx context.Context, offset int, keyword string) ([]*model.Book, error) {
+ books := make([]*model.Book, 0)
+ tx := s.db.WithContext(ctx)
+ if keyword != "" {
+ term := "%" + keyword + "%"
+ tx = tx.Where("title LIKE ?", term).Or("author LIKE ?", term).Or("description LIKE ?", term)
+ }
+ if err := tx.Offset(offset).Limit(s.pageSize).Find(&books).Error; err != nil {
+ return nil, err
+ }
+ return books, nil
+}
Update application/executor/book_operator.go:
@@ -29,3 +29,8 @@ func (o *BookOperator) CreateBook(ctx context.Context, b *model.Book) (*model.Bo
b.ID = id
return b, nil
}
+
+// GetBooks gets a list of books by offset and keyword, and caches its result if needed
+func (o *BookOperator) GetBooks(ctx context.Context, offset int, query string) ([]*model.Book, error) {
+ return o.bookManager.GetBooks(ctx, offset, query)
+}
Update adapter/router.go:
@@ -6,6 +6,7 @@ package adapter
import (
"fmt"
"net/http"
+ "strconv"
"github.com/gin-gonic/gin"
@@ -14,6 +15,11 @@ import (
"literank.com/event-books/domain/model"
)
+const (
+ fieldOffset = "o"
+ fieldQuery = "q"
+)
+
// RestHandler handles all restful requests
type RestHandler struct {
bookOperator *executor.BookOperator
@@ -37,6 +43,7 @@ func MakeRouter(templates_pattern string, wireHelper *application.WireHelper) (*
r.GET("/", rest.indexPage)
apiGroup := r.Group("/api")
+ apiGroup.GET("/books", rest.getBooks)
apiGroup.POST("/books", rest.createBook)
return r, nil
}
@@ -49,6 +56,27 @@ func (r *RestHandler) indexPage(c *gin.Context) {
})
}
+// Get all books
+func (r *RestHandler) getBooks(c *gin.Context) {
+ offset := 0
+ offsetParam := c.Query(fieldOffset)
+ if offsetParam != "" {
+ value, err := strconv.Atoi(offsetParam)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid offset"})
+ return
+ }
+ offset = value
+ }
+ books, err := r.bookOperator.GetBooks(c, offset, c.Query(fieldQuery))
+ if err != nil {
+ fmt.Printf("Failed to get books: %v\n", err)
+ c.JSON(http.StatusNotFound, gin.H{"error": "failed to get books"})
+ return
+ }
+ c.JSON(http.StatusOK, books)
+}
+
// Create a new book
func (r *RestHandler) createBook(c *gin.Context) {
var reqBody model.Book
Run the server again and try it with curl
:
curl --request GET --url 'http://localhost:8080/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-02T21:02:59.314+08:00"
},
{
"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-02T21:02:59.42+08:00"
}
]
Show books on index page
Update adapter/router.go:
@@ -50,9 +50,15 @@ func MakeRouter(templates_pattern string, wireHelper *application.WireHelper) (*
// Render and show the index page
func (r *RestHandler) indexPage(c *gin.Context) {
+ books, err := r.bookOperator.GetBooks(c, 0, "")
+ if err != nil {
+ c.String(http.StatusNotFound, "failed to get books")
+ return
+ }
// Render the HTML template named "index.html"
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "LiteRank Book Store",
+ "books": books,
})
}
Update adapter/templates/index.html:
@@ -10,7 +10,7 @@
</head>
<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">
@@ -18,6 +18,20 @@
<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">
</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">
+ {{range .books}}
+ <div class="bg-white p-4 rounded-md border-gray-300 shadow mt-2">
+ <div><b>{{.Title}}</b></div>
+ <div class="text-gray-500 text-sm">{{.PublishedAt}}</div>
+ <div class="italic text-sm">{{.Author}}</div>
+ </div>
+ {{end}}
+ </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 adapter/router.go:
@@ -50,7 +50,8 @@ func MakeRouter(templates_pattern string, wireHelper *application.WireHelper) (*
// Render and show the index page
func (r *RestHandler) indexPage(c *gin.Context) {
- books, err := r.bookOperator.GetBooks(c, 0, "")
+ q := c.Query(fieldQuery)
+ books, err := r.bookOperator.GetBooks(c, 0, q)
if err != nil {
c.String(http.StatusNotFound, "failed to get books")
return
@@ -59,6 +60,7 @@ func (r *RestHandler) indexPage(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "LiteRank Book Store",
"books": books,
+ "q": q,
})
}
Update adapter/templates/index.html:
@@ -15,12 +15,15 @@
<!-- 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{{end}}</h2>
<div class="grid grid-cols-4 gap-2">
{{range .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: