REST API with Echo, Sqlc, Migrate and Postgres
1. Project Overview
In this project we are gonna built a simple rest api with following structure
POST /authors
GET /authors
GET /authors/len
GET /authors/:id
PATCH /authors/:id
DELETE /authors/:id
POST /authors/:id/books
GET /authors/:id/books
GET /authors-and-books
Database Table
CREATE TABLE author (
id UUID PRIMARY KEY,
name VARCHAR NOT NULL,
bio TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE book (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
author_id UUID NOT NULL REFERENCES author(id) ON DELETE CASCADE
);
2. Tool Installation
Install Docker
Mac Installation of other tools
brew install make
brew install migrate
brew install sqlc
3. Repo and Environment Setup
a. Repo Structure
author-api/
├── cmd/
│ └── server/
│ └── main.go
├── database/
│ ├── migrations/
│ │ ├── 00001_initial.up.sql
│ │ └── 00001_initial.down.sql
│ └── queries/
│ └── author.sql
├── internal/
│ ├── handler/
│ │ ├── handler.go
│ │ └── author.go
│ ├── service/
│ │ └── author.go
│ └── repository/
│ ├── db.go
│ ├── models.go
│ └── author.sql.go
├── .env
├── sqlc.yaml
├── Makefile
├── go.mod
├── go.sum
└── README.md
b. Makefile
Create a Makefile to easily spin up the development environment
DATABASE_URL = postgres://postgres:111111@localhost:5432/book?sslmode=disable
.PHONY: postgres migrate sqlc run
postgres:
@echo "==> 1/4 starting postgres"
@docker start book-postgres 2>/dev/null || docker run -d --name book-postgres \
-p 5432:5432 -e POSTGRES_PASSWORD=111111 -e POSTGRES_DB=book postgres
@sleep 3
migrate:
@echo "==> 2/4 running migrations"
migrate -path ./database/migrations -database "$(DATABASE_URL)" up
sqlc:
@echo "==> 3/4 generating sqlc code"
sqlc generate
run: postgres migrate sqlc
@echo "==> 4/4 starting server on :1323"
go run ./cmd/server
Docker with Postgres
- Run the postgres inside docker container with name=book=postgres, with expose port 5432, with postgres password 111111 and postgres db book
docker run -d --name book-postgres \
-p 5432:5432 -e POSTGRES_PASSWORD=111111 -e POSTGRES_DB=book postgres
Migrate
- Migrate the migrations file in
database/migrationsto the docker postgres db
migrate -path ./database/migrations -database "$(DATABASE_URL)" up
Sqlc
- Generate the go repository code based on the config in
sqlc.yaml
sqlc generate
Run the server
go run ./cmd/server
4. Development
a. initialize project and download dependencies
go mod init github.com/<github-name>/author-api
go get github.com/labstack/echo/v5
go get github.com/jackc/pgx/v5
go get github.com/joho/godotenv
go get github.com/google/uuid
a. repository
Create .env file
touch .env
.env
DATABASE_URL="postgresql://postgres:111111@localhost:5432/author?sslmode=disable"
Create migration file
migrate create -ext sql -seq -digits 5 -dir database/migrations initial
00001_initial.up.sql
CREATE TABLE author (
id UUID PRIMARY KEY,
name VARCHAR NOT NULL,
bio TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE book (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
author_id UUID NOT NULL REFERENCES author(id) ON DELETE CASCADE
);
00001_initial.down.sql
DROP TABLE IF EXISTS book;
DROP TABLE IF EXISTS author;
Create sqlc.yaml
version: "2"
sql:
- engine: "postgresql"
queries: "database/queries"
schema: "database/migrations"
gen:
go:
package: "repository"
out: "internal/repository"
sql_package: "pgx/v5"
emit_json_tags: true # to add json:"id" to the model
emit_pointers_for_null_types: true # change to *string type where the zero value is nil to only partial update provided column
overrides:
- db_type: "uuid"
go_type:
import: "github.com/google/uuid"
type: "UUID"
- db_type: "timestamptz"
go_type: "time.Time"
For more information about sqlc, can take a look on https://docs.sqlc.dev/en/latest/tutorials/getting-started-postgresql.html
b. service
internal/service/author.go
package service
import (
"context"
"errors"
"github.com/Fulim13/author-api/internal/repository"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
var (
ErrAuthorNotFound = errors.New("author not found")
)
type DB interface {
repository.DBTX
Begin(ctx context.Context) (pgx.Tx, error)
}
type AuthorService struct {
db DB
repo *repository.Queries
}
func NewAuthorService(db DB) *AuthorService {
return &AuthorService{
db: db,
repo: repository.New(db),
}
}
func (s *AuthorService) withTx(ctx context.Context, fn func(q *repository.Queries) error) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if err := fn(s.repo.WithTx(tx)); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *AuthorService) CreateAuthor(ctx context.Context, name string, bio string) (repository.Author, error) {
return s.repo.CreateAuthor(ctx, repository.CreateAuthorParams{
ID: uuid.New(),
Name: name,
Bio: bio,
})
}
func (s *AuthorService) ListAuthors(ctx context.Context) ([]repository.Author, error) {
authors, err := s.repo.ListAuthors(ctx)
if err != nil {
return nil, err
}
if authors == nil {
authors = []repository.Author{}
}
return authors, nil
}
func (s *AuthorService) CountAuthors(ctx context.Context) (int64, error) {
return s.repo.CountAuthors(ctx)
}
func (s *AuthorService) GetAuthor(ctx context.Context, id uuid.UUID) (repository.Author, error) {
author, err := s.repo.GetAuthor(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return repository.Author{}, ErrAuthorNotFound
}
return author, err
}
func (s *AuthorService) UpdateAuthor(ctx context.Context, id uuid.UUID, name, bio *string) error {
rows, err := s.repo.UpdateAuthor(ctx, repository.UpdateAuthorParams{
ID: id,
Name: name,
Bio: bio,
})
if err != nil {
return err
}
if rows == 0 {
return ErrAuthorNotFound
}
return nil
}
func (s *AuthorService) DeleteAuthor(ctx context.Context, id uuid.UUID) error {
rows, err := s.repo.DeleteAuthor(ctx, id)
if err != nil {
return err
}
if rows == 0 {
return ErrAuthorNotFound
}
return nil
}
func (s *AuthorService) CreateBook(ctx context.Context, authorId uuid.UUID, name string) (repository.Book, error) {
var book repository.Book
err := s.withTx(ctx, func(q *repository.Queries) error {
if _, err := q.GetAuthorForShare(ctx, authorId); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrAuthorNotFound
}
return err
}
var err error
book, err = q.CreateBook(ctx, repository.CreateBookParams{
Name: name,
AuthorID: authorId,
})
return err
})
if err != nil {
return repository.Book{}, err
}
return book, nil
}
func (s *AuthorService) ListBooks(ctx context.Context, authorId uuid.UUID) ([]repository.Book, error) {
books, err := s.repo.ListBooks(ctx, authorId)
if err != nil {
return nil, err
}
if books == nil {
books = []repository.Book{}
}
return books, nil
}
func (s *AuthorService) ListAuthorsAndBooks(ctx context.Context) ([]repository.ListAuthorsAndBooksRow, error) {
booksAndAuthors, err := s.repo.ListAuthorsAndBooks(ctx)
if err != nil {
return nil, err
}
if booksAndAuthors == nil {
booksAndAuthors = []repository.ListAuthorsAndBooksRow{}
}
return booksAndAuthors, nil
}
c. handler
internal/handler/author.go
package handler
import (
"errors"
"net/http"
"github.com/Fulim13/author-api/internal/service"
"github.com/google/uuid"
"github.com/labstack/echo/v5"
)
type AuthorHandler struct {
service *service.AuthorService
}
func NewAuthorHandler(service *service.AuthorService) *AuthorHandler {
return &AuthorHandler{
service: service,
}
}
type CreateAuthorRequest struct {
Name string `json:"name"`
Bio string `json:"bio"`
}
type UpdateAuthorRequest struct {
Name *string `json:"name"`
Bio *string `json:"bio"`
}
type CreateBookRequest struct {
Name string `json:"name"`
}
func authorID(c *echo.Context) (uuid.UUID, error) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
return uuid.Nil, echo.NewHTTPError(http.StatusBadRequest, "invalid author id")
}
return id, nil
}
func (h *AuthorHandler) createAuthor(c *echo.Context) error {
var reqBody CreateAuthorRequest
if err := c.Bind(&reqBody); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if reqBody.Name == "" {
return echo.NewHTTPError(http.StatusBadRequest, "name is required")
}
if reqBody.Bio == "" {
return echo.NewHTTPError(http.StatusBadRequest, "bio is required")
}
author, err := h.service.CreateAuthor(c.Request().Context(), reqBody.Name, reqBody.Bio)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
}
return c.JSON(http.StatusCreated, author)
}
func (h *AuthorHandler) listAuthors(c *echo.Context) error {
authors, err := h.service.ListAuthors(c.Request().Context())
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
}
return c.JSON(http.StatusOK, authors)
}
func (h *AuthorHandler) countAuthors(c *echo.Context) error {
count, err := h.service.CountAuthors(c.Request().Context())
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
}
return c.JSON(http.StatusOK, map[string]int64{"count": count})
}
func (h *AuthorHandler) getAuthor(c *echo.Context) error {
id, err := authorID(c)
if err != nil {
return err
}
author, err := h.service.GetAuthor(c.Request().Context(), id)
if err != nil {
if errors.Is(err, service.ErrAuthorNotFound) {
return echo.NewHTTPError(http.StatusNotFound, "author not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
}
return c.JSON(http.StatusOK, author)
}
func (h *AuthorHandler) updateAuthor(c *echo.Context) error {
id, err := authorID(c)
if err != nil {
return err
}
var reqBody UpdateAuthorRequest
if err := c.Bind(&reqBody); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if reqBody.Name == nil && reqBody.Bio == nil {
return echo.NewHTTPError(http.StatusBadRequest, "name or bio is required")
}
if reqBody.Name != nil && *reqBody.Name == "" {
return echo.NewHTTPError(http.StatusBadRequest, "name cannot be empty")
}
err = h.service.UpdateAuthor(c.Request().Context(), id, reqBody.Name, reqBody.Bio)
if err != nil {
if errors.Is(err, service.ErrAuthorNotFound) {
return echo.NewHTTPError(http.StatusNotFound, "author not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
}
return c.NoContent(http.StatusNoContent)
}
func (h *AuthorHandler) deleteAuthor(c *echo.Context) error {
id, err := authorID(c)
if err != nil {
return err
}
err = h.service.DeleteAuthor(c.Request().Context(), id)
if err != nil {
if errors.Is(err, service.ErrAuthorNotFound) {
return echo.NewHTTPError(http.StatusNotFound, "author not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
}
return c.NoContent(http.StatusNoContent)
}
func (h *AuthorHandler) createBook(c *echo.Context) error {
authorId, err := authorID(c)
if err != nil {
return err
}
var reqBody CreateBookRequest
if err := c.Bind(&reqBody); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if reqBody.Name == "" {
return echo.NewHTTPError(http.StatusBadRequest, "name is required")
}
book, err := h.service.CreateBook(c.Request().Context(), authorId, reqBody.Name)
if err != nil {
if errors.Is(err, service.ErrAuthorNotFound) {
return echo.NewHTTPError(http.StatusNotFound, "author not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
}
return c.JSON(http.StatusCreated, book)
}
func (h *AuthorHandler) listBooks(c *echo.Context) error {
authorId, err := authorID(c)
if err != nil {
return err
}
books, err := h.service.ListBooks(c.Request().Context(), authorId)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
}
return c.JSON(http.StatusOK, books)
}
func (h *AuthorHandler) listAuthorsAndBooks(c *echo.Context) error {
authorsAndBooks, err := h.service.ListAuthorsAndBooks(c.Request().Context())
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error")
}
return c.JSON(http.StatusOK, authorsAndBooks)
}
internal/handler/handler.go
package handler
import "github.com/labstack/echo/v5"
func Routes(e *echo.Echo, authorHandler *AuthorHandler) {
e.POST("/authors", authorHandler.createAuthor)
e.GET("/authors", authorHandler.listAuthors)
e.GET("/authors/len", authorHandler.countAuthors)
e.GET("/authors/:id", authorHandler.getAuthor)
e.PATCH("/authors/:id", authorHandler.updateAuthor)
e.DELETE("/authors/:id", authorHandler.deleteAuthor)
e.POST("/authors/:id/books", authorHandler.createBook)
e.GET("/authors/:id/books", authorHandler.listBooks)
e.GET("/authors-and-books", authorHandler.listAuthorsAndBooks)
}
d. main.go
cmd/server/main.go
package main
import (
"context"
"log"
"os"
"github.com/Fulim13/author-api/internal/handler"
"github.com/Fulim13/author-api/internal/service"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
)
func main() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("Failed to connect db: %v", err)
}
defer pool.Close()
authorService := service.NewAuthorService(pool)
authorHandler := handler.NewAuthorHandler(authorService)
e := echo.New()
e.Use(middleware.RequestLogger())
e.Use(middleware.Recover())
handler.Routes(e, authorHandler)
if err := e.Start(":1323"); err != nil {
e.Logger.Error("failed to start server", "error", err)
}
}
5. Test the Endpoint
Run make run then can start test the endpoints
# Create Author
curl -s localhost:1323/authors \
-H 'Content-Type: application/json' \
-d '{"name": "fulim", "bio": "An Author of this tutorial"}' | jq
# List Authors
curl -s localhost:1323/authors | jq
# Count Authors
curl -s localhost:1323/authors/len | jq
# Get Author
curl -s localhost:1323/authors/author-id | jq
# Update Author
curl -s -i -X PATCH localhost:1323/authors/author-id \
-H 'Content-Type: application/json' \
-d '{"name": "Fu Lim Wong", "bio": "New Author"}'
# Delete Author
curl -s -i -X DELETE localhost:1323/authors/author-id
# Create Book
curl -s localhost:1323/authors/author-id/books \
-H 'Content-Type: application/json' \
-d '{"name": "Go Web Tutorial"}' | jq
# List Book
curl -s localhost:1323/authors/author-id/books | jq
# List Authors and Books
curl -s localhost:1323/authors-and-books | jq
# Create/Update Author / Create Book - invalid request body
curl -s -i localhost:1323/authors \
-H 'Content-Type: application/json' \
-d '{"na}'
curl -s -i -X PATCH localhost:1323/authors/98843538-6cd4-4811-b16e-cb58dbb30218 \
-H 'Content-Type: application/json' \
-d '{"na}'
curl -s -i localhost:1323/authors/98843538-6cd4-4811-b16e-cb58dbb30218/books \
-H 'Content-Type: application/json' \
-d '{"na}'
# Get/Update/Delete Author / Create/List Book - invalid author id
curl -s -i localhost:1323/authors/author-id
curl -s -i -X PATCH localhost:1323/authors/author-id \
-H 'Content-Type: application/json' \
-d '{"name": "Fu Lim Wong", "bio": "New Author"}'
curl -s -i -X DELETE localhost:1323/authors/author-id
curl -s -i localhost:1323/authors/author-id/books \
-H 'Content-Type: application/json' \
-d '{"name": "Go Web Tutorial"}'
curl -s -i localhost:1323/authors/author-id/books
# Get/Update/Delete Author / Create Book - author not found
curl -s -i localhost:1323/authors/98843538-6cd4-4811-b16e-cb58dbb30218
curl -s -i localhost:1323/authors/98843538-6cd4-4811-b16e-cb58dbb30218/books \
-H 'Content-Type: application/json' \
-d '{"name": "Go Web Tutorial"}'
curl -s -i localhost:1323/authors/98843538-6cd4-4811-b16e-cb58dbb30218/books \
-H 'Content-Type: application/json' \
-d '{"name": "Go Web Tutorial"}'
curl -s -i localhost:1323/authors/98843538-6cd4-4811-b16e-cb58dbb30218/books \
-H 'Content-Type: application/json' \
-d '{"name": "Go Web Tutorial"}'