| title | Repository |
|---|---|
| slug | repository |
π¨βπ« Before we start...
- The repository pattern is a design pattern in software development used to isolate/ abstract the data layer.
- Go standard library provides the
database/sqlpackage to interaction with SQL databases. But there are Go libraries such assqlx,sqlc, GORM, Ent which can be a good fit for your requirements. We choose GORM because it's good for rapid development and to handle complex database transactions comfortably.
GORM is a comprehensive ORM for Go. Its features include CRUD operations, querying, association handling, auto migrations, preloading, hooks, and much more. It is also compatible with various SQL databases including MySQL, PostgreSQL, SQLite, and SQL Server. Please refer to the GORM documentation for more information.
We use a Postgres database and support database logs. So, we need to install following packages.
go get gorm.io/gorm
go get gorm.io/driver/postgres
go get gorm.io/gorm/loggerWe need to define a Go struct that corresponds to the books table of our database. Since the id column utilizes the UUID data type, we have to use a Go implementation of uuid like gofrs/uuid, google/uuid and here we use google/uuid.
go get github.com/google/uuidimport (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Book struct {
ID uuid.UUID `gorm:"primarykey"`
Title string
Author string
PublishedDate time.Time
ImageURL string
Description string
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt
}
type Books []*BookLet's add repository functions under api/resource/book/repository.go.
package book
import (
"github.com/google/uuid"
"gorm.io/gorm"
)
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{
db: db,
}
}
func (r *Repository) List() (Books, error) {
books := make([]*Book, 0)
if err := r.db.Find(&books).Error; err != nil {
return nil, err
}
return books, nil
}
func (r *Repository) Create(book *Book) (*Book, error) {
if err := r.db.Create(book).Error; err != nil {
return nil, err
}
return book, nil
}
func (r *Repository) Read(id uuid.UUID) (*Book, error) {
book := &Book{}
if err := r.db.Where("id = ?", id).First(&book).Error; err != nil {
return nil, err
}
return book, nil
}
func (r *Repository) Update(book *Book) (int64, error) {
result := r.db.Model(&Book{}).
Select("Title", "Author", "PublishedDate", "ImageURL", "Description", "UpdatedAt").
Where("id = ?", book.ID).
Updates(book)
return result.RowsAffected, result.Error
}
func (r *Repository) Delete(id uuid.UUID) (int64, error) {
result := r.db.Where("id = ?", id).Delete(&Book{})
return result.RowsAffected, result.Error
}π‘ Refer GORM documentation for more information.
First, we will create a helper package for tests, to test code concise and focused by reducing the amount of repeated assert-like code. We use DATA-DOG/go-sqlmock to create unit tests without relying on an actual database connection. As each repository test function requires a mock database connection, we will add a mock DB helper under mock/db package. Then, we can write our tests more concisely.
- Add
util/test/test.go
package test
import (
"testing"
)
func NoError(t *testing.T, err error) {
if err != nil {
t.Fatalf("err: %e", err)
}
}
func Equal[T comparable](t *testing.T, x, y T) {
if x != y {
t.Fatalf("not equal: %v, %v", x, y)
}
}go get github.com/DATA-DOG/go-sqlmock Let's add the mock database factory function under mock/db/db.go
package db
import (
"database/sql/driver"
"time"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func NewMockDB() (*gorm.DB, sqlmock.Sqlmock, error) {
db, mock, err := sqlmock.New()
if err != nil {
return nil, nil, err
}
gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: db}), &gorm.Config{})
if err != nil {
return nil, nil, err
}
return gdb, mock, nil
}
type AnyTime struct{}
func (a AnyTime) Match(v driver.Value) bool {
_, ok := v.(time.Time)
return ok
}π‘AnyTime will be used to check if a parameter is of the time.Time type. Refer, "Matching arguments like time.Time"
Let's write repository tests under api/resource/book/repository_test.go
package book_test
import (
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/google/uuid"
"myapp/api/resource/book"
mockDB "myapp/mock/db"
testUtil "myapp/util/test"
)
func TestRepository_List(t *testing.T) {
t.Parallel()
db, mock, err := mockDB.NewMockDB()
testUtil.NoError(t, err)
repo := book.NewRepository(db)
mockRows := sqlmock.NewRows([]string{"id", "title", "author"}).
AddRow(uuid.New(), "Book1", "Author1").
AddRow(uuid.New(), "Book2", "Author2")
mock.ExpectQuery("^SELECT (.+) FROM \"books\"").WillReturnRows(mockRows)
books, err := repo.List()
testUtil.NoError(t, err)
testUtil.Equal(t, len(books), 2)
}
func TestRepository_Create(t *testing.T) {
t.Parallel()
db, mock, err := mockDB.NewMockDB()
testUtil.NoError(t, err)
repo := book.NewRepository(db)
id := uuid.New()
mock.ExpectBegin()
mock.ExpectExec("^INSERT INTO \"books\" ").
WithArgs(id, "Title", "Author", mockDB.AnyTime{}, "", "", mockDB.AnyTime{}, mockDB.AnyTime{}, nil).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
book := &book.Book{ID: id, Title: "Title", Author: "Author", PublishedDate: time.Now()}
_, err = repo.Create(book)
testUtil.NoError(t, err)
}
func TestRepository_Read(t *testing.T) {
t.Parallel()
db, mock, err := mockDB.NewMockDB()
testUtil.NoError(t, err)
repo := book.NewRepository(db)
id := uuid.New()
mockRows := sqlmock.NewRows([]string{"id", "title", "author"}).
AddRow(id, "Book1", "Author1")
mock.ExpectQuery("^SELECT (.+) FROM \"books\" WHERE (.+)").
WithArgs(id).
WillReturnRows(mockRows)
book, err := repo.Read(id)
testUtil.NoError(t, err)
testUtil.Equal(t, "Book1", book.Title)
}
func TestRepository_Update(t *testing.T) {
t.Parallel()
db, mock, err := mockDB.NewMockDB()
testUtil.NoError(t, err)
repo := book.NewRepository(db)
id := uuid.New()
_ = sqlmock.NewRows([]string{"id", "title", "author"}).
AddRow(id, "Book1", "Author1")
mock.ExpectBegin()
mock.ExpectExec("^UPDATE \"books\" SET").
WithArgs("Title", "Author", mockDB.AnyTime{}, "", "", mockDB.AnyTime{}, id).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
book := &book.Book{ID: id, Title: "Title", Author: "Author"}
rows, err := repo.Update(book)
testUtil.NoError(t, err)
testUtil.Equal(t, 1, rows)
}
func TestRepository_Delete(t *testing.T) {
t.Parallel()
db, mock, err := mockDB.NewMockDB()
testUtil.NoError(t, err)
repo := book.NewRepository(db)
id := uuid.New()
_ = sqlmock.NewRows([]string{"id", "title", "author"}).
AddRow(id, "Book1", "Author1")
mock.ExpectBegin()
mock.ExpectExec("^UPDATE \"books\" SET \"deleted_at\"").
WithArgs(mockDB.AnyTime{}, id).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
rows, err := repo.Delete(id)
testUtil.NoError(t, err)
testUtil.Equal(t, 1, rows)
}Let's add the repository as a dependency for API struct in api/resource/book/handler.go. In addition to that we create the factory func New(db *gorm.DB) *API to create the API from router, and then we need to update cmd/api/main.go to open a database connection from the main func and pass it to router.
import "gorm.io/gorm"
type API struct {
repository *Repository
}
func New(db *gorm.DB) *API {
return &API{
repository: NewRepository(db),
}
}Let's update api/router/router.go to call the API factory function with a db *gorm.DB
package router
import (
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
"myapp/api/resource/book"
"myapp/api/resource/health"
)
func New(db *gorm.DB) *chi.Mux {
r := chi.NewRouter()
r.Get("/livez", health.Read)
r.Route("/v1", func(r chi.Router) {
bookAPI := book.New(db)
r.Get("/books", bookAPI.List)
r.Post("/books", bookAPI.Create)
r.Get("/books/{id}", bookAPI.Read)
r.Put("/books/{id}", bookAPI.Update)
r.Delete("/books/{id}", bookAPI.Delete)
})
return r
}package main
import (
"fmt"
"log"
"net/http"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
"myapp/api/router"
"myapp/config"
)
const fmtDBString = "host=%s user=%s password=%s dbname=%s port=%d sslmode=disable"
func main() {
c := config.New()
var logLevel gormlogger.LogLevel
if c.DB.Debug {
logLevel = gormlogger.Info
} else {
logLevel = gormlogger.Error
}
dbString := fmt.Sprintf(fmtDBString, c.DB.Host, c.DB.Username, c.DB.Password, c.DB.DBName, c.DB.Port)
db, err := gorm.Open(postgres.Open(dbString), &gorm.Config{Logger: gormlogger.Default.LogMode(logLevel)})
if err != nil {
log.Fatal("DB connection start failure")
return
}
r := router.New(db)
s := &http.Server{
Addr: fmt.Sprintf(":%d", c.Server.Port),
Handler: r,
ReadTimeout: c.Server.TimeoutRead,
WriteTimeout: c.Server.TimeoutWrite,
IdleTimeout: c.Server.TimeoutIdle,
}
log.Println("Starting server " + s.Addr)
if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("Server startup failed")
}
}When we add a new package and use it, we have to run go mod tidy to reorganize the dependencies in the go.mod file.
Let's start coding handlers in api/resource/book/handler.go.
Currently, in api/resource/book/model.go, we have the Form, Book and DTO structs. But the Form needs to be converted to Book model to save in the database. Also, the Book needs to be converted to DTO to show to the end user.
func (f *Form) ToModel() *Book {
pubDate, _ := time.Parse("2006-01-02", f.PublishedDate)
return &Book{
Title: f.Title,
Author: f.Author,
PublishedDate: pubDate,
ImageURL: f.ImageURL,
Description: f.Description,
}
}
func (b *Book) ToDto() *DTO {
return &DTO{
ID: b.ID.String(),
Title: b.Title,
Author: b.Author,
PublishedDate: b.PublishedDate.Format("2006-01-02"),
ImageURL: b.ImageURL,
Description: b.Description,
}
}
func (bs Books) ToDto() []*DTO {
dtos := make([]*DTO, len(bs))
for i, v := range bs {
dtos[i] = v.ToDto()
}
return dtos
}func (a *API) List(w http.ResponseWriter, r *http.Request) {
books, err := a.repository.List()
if err != nil {
// handle later
return
}
if len(books) == 0 {
fmt.Fprint(w, "[]")
return
}
if err := json.NewEncoder(w).Encode(books.ToDto()); err != nil {
// handle later
return
}
}func (a *API) Create(w http.ResponseWriter, r *http.Request) {
form := &Form{}
if err := json.NewDecoder(r.Body).Decode(form); err != nil {
// handle later
return
}
newBook := form.ToModel()
newBook.ID = uuid.New()
_, err := a.repository.Create(newBook)
if err != nil {
// handle later
return
}
w.WriteHeader(http.StatusCreated)
}func (a *API) Read(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
// handle later
return
}
book, err := a.repository.Read(id)
if err != nil {
if err == gorm.ErrRecordNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
// handle later
return
}
dto := book.ToDto()
if err := json.NewEncoder(w).Encode(dto); err != nil {
// handle later
return
}
}func (a *API) Update(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
// handle later
return
}
form := &Form{}
if err := json.NewDecoder(r.Body).Decode(form); err != nil {
// handle later
return
}
book := form.ToModel()
book.ID = id
rows, err := a.repository.Update(book)
if err != nil {
// handle later
return
}
if rows == 0 {
w.WriteHeader(http.StatusNotFound)
return
}
}func (a *API) Delete(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
// handle later
return
}
rows, err := a.repository.Delete(id)
if err != nil {
// handle later
return
}
if rows == 0 {
w.WriteHeader(http.StatusNotFound)
return
}
}π‘Now, you can test the APIs via the Open-API specification we generated in the previous section.
myapp
βββ cmd
β βββ api
β β βββ main.go
β βββ migrate
β βββ main.go
β
βββ api
β βββ router
β β βββ router.go
β β
β βββ resource
β βββ health
β β βββ handler.go
β βββ book
β β βββ handler.go
β β βββ model.go
β β βββ repository.go
β β βββ repository_test.go
β βββ common
β βββ err
β βββ err.go
β
βββ migrations
β βββ 00001_create_books_table.sql
β
βββ config
β βββ config.go
β
βββ .env
β
βββ go.mod
βββ go.sum
β
βββ mock
β βββ db
β βββ db.go
βββ util
β βββ test
β βββ test.go
β
βββ compose.yml
βββ DockerfileIn the next article, weβll add the error handing and the validator to our application.