» Go: 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 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:

search results