Go-chi is a lightweight router library for building HTTP services in Go. It is especially useful for when you want the benefits of modular request handling without the batteries that come with using a full-blown web framework.
In this tutorial, we will be building a containerized bucket list API using go-chi, PostgreSQL, and Docker. In more concrete terms, our API will expose the following endpoints:
POST /items
to add a new item to the listGET /items
to fetch all existing items in the listGET /items/{itemId}
to fetch a single item from the list using its IDPUT /items/{itemId}
to update an existing itemDELETE /items/{itemId}
to delete an item from the listTo continue with this tutorial, you will need:
To get started, create the project folder in your preferred location and initialize the Go module:
$ mkdir bucketeer && cd bucketeer $ go mod init gitlab.com/idoko/bucketeer
You will usually find Go projects using their GitHub or GitLab path as their module name though you can also set it up with a custom domain.
Run the commands below to install our application dependencies which consist of:
$ go get github.com/go-chi/chi github.com/go-chi/render github.com/lib/pq
In the project directory, create the needed folders and files to match the layout below:
├── db │ ├── db.go │ └── item.go ├── handler │ ├── errors.go │ ├── handler.go │ └── items.go ├── models │ └── item.go ├── .env ├── docker-compose.yml ├── Dockerfile ├── go.mod ├── go.sum ├── main.go └── README.md
Let’s go over some of the directories and files from above:
db
: The code here is responsible for interacting directly with our database. This way, the database engine is properly separated from the rest of the applicationhandler
: The handler
package creates and handles our API routes using chi
models
: Contains Go structs that can be bounded to database objects or transformed into their equivalent JSON formatDockerfile
defines the base image and commands required to have our API server up and running. The docker-compose.yml
defines our app dependencies (the server using the Dockerfile
and the database using the official postgres docker image). The Docker website has a detailed reference for both Dockerfiles and docker-compose.env
: This holds our application environment variables (such as database credentials)main.go
is our application entry point. It will be responsible for reading environment variables, setting up the database as well as starting and stopping the API serverLet us set up the Dockerfile
to build the API server into a single binary file, expose the server port, and execute the binary on startup. Open it in your preferred editor and add the code below to it:
FROM golang:1.14.6-alpine3.12 as builder COPY go.mod go.sum /go/src/gitlab.com/idoko/bucketeer/ WORKDIR /go/src/gitlab.com/idoko/bucketeer RUN go mod download COPY . /go/src/gitlab.com/idoko/bucketeer RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o build/bucketeer gitlab.com/idoko/bucketeer FROM alpine RUN apk add --no-cache ca-certificates && update-ca-certificates COPY --from=builder /go/src/gitlab.com/idoko/bucketeer/build/bucketeer /usr/bin/bucketeer EXPOSE 8080 8080 ENTRYPOINT ["/usr/bin/bucketeer"]
Next, open the docker-compose.yml
file and declare the server
and database
services:
version: "3.7" services: database: image: postgres restart: always env_file: - .env ports: - "5432:5432" volumes: - data:/var/lib/postgresql/data server: build: context: . dockerfile: Dockerfile env_file: .env depends_on: - database networks: - default ports: - "8080:8080" volumes: data:
Also, populate the .env
file with your app-specific credentials like this:
POSTGRES_USER=bucketeer POSTGRES_PASSWORD=bucketeer_pass POSTGRES_DB=bucketeer_db
We will be using golang-migrate to manage our database migrations. That way, we can track changes to our database alongside our code and ensure that such changes are reproducible. Install the migrate binary by following the installation guide and generate the database migrations by running:
migrate create -ext sql -dir db/migrations -seq create_items_table
The command creates two SQL files in the db/migrations
folder. The XXXXXX_create_items_table.up.sql
file is executed when we run our migrations. Open it and add the SQL code to create a new table:
CREATE TABLE IF NOT EXISTS items( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, description TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );
Conversely, the XXXXXX_create_items_table.down.sql
file is executed when we roll back the migration. In this case, we simply want to drop the table during rollback, so add this code block to it:
DROP TABLE IF EXISTS items;
We can now apply our migrations with migrate by passing in the database connection and the folder that contains our migration files as command-line arguments. The command below does that by creating a bash environment variable using the same credentials declared in the .env
file:
$ export POSTGRESQL_URL="postgres://bucketeer:bucketeer_pass@localhost:5432/bucketeer_db?sslmode=disable" $ migrate -database ${POSTGRESQL_URL} -path db/migrations up
We need models to ease how we interact with the database from our Go code. For our case, this model is in the item.go
file in the models
folder. With chi, we also get the benefit of rendering them as JSON objects to our API consumer. We do this by making our model implement the chi.Renderer
interface i.e, by implementing a Render
method for it. Open the file (models/item.go
) and add the following code to it:
package models import ( "fmt" "net/http" ) type Item struct { ID int `json:"id"` Name string `json:"name"` Description string `json:"description"` CreatedAt string `json:"created_at"` } type ItemList struct { Items []Item `json:"items"` } func (i *Item) Bind(r *http.Request) error { if i.Name == "" { return fmt.Errorf("name is a required field") } return nil } func (*ItemList) Render(w http.ResponseWriter, r *http.Request) error { return nil } func (*Item) Render(w http.ResponseWriter, r *http.Request) error { return nil }
With our database in place now, we can connect to it from our Go code. Edit the db.go
file in the db
directory and add the code to manage the connection:
package db import ( "database/sql" "fmt" "log" _ "github.com/lib/pq" ) const ( HOST = "database" PORT = 5432 ) // ErrNoMatch is returned when we request a row that doesn't exist var ErrNoMatch = fmt.Errorf("no matching record") type Database struct { Conn *sql.DB } func Initialize(username, password, database string) (Database, error) { db := Database{} dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", HOST, PORT, username, password, database) conn, err := sql.Open("postgres", dsn) if err != nil { return db, err } db.Conn = conn err = db.Conn.Ping() if err != nil { return db, err } log.Println("Database connection established") return db, nil }
Next, edit the item.go
file to make it responsible for interacting with the items table. Such interactions include fetching all list items, creating an item, fetching an item using its ID as well as updating and deleting them:
package db import ( "database/sql" "gitlab.com/idoko/bucketeer/models" ) func (db Database) GetAllItems() (*models.ItemList, error) { list := &models.ItemList{} rows, err := db.Conn.Query("SELECT * FROM items ORDER BY ID DESC") if err != nil { return list, err } for rows.Next() { var item models.Item err := rows.Scan(&item.ID, &item.Name, &item.Description, &item.CreatedAt) if err != nil { return list, err } list.Items = append(list.Items, item) } return list, nil } func (db Database) AddItem(item *models.Item) error { var id int var createdAt string query := `INSERT INTO items (name, description) VALUES ($1, $2) RETURNING id, created_at` err := db.Conn.QueryRow(query, item.Name, item.Description).Scan(&id, &createdAt) if err != nil { return err } item.ID = id item.CreatedAt = createdAt return nil } func (db Database) GetItemById(itemId int) (models.Item, error) { item := models.Item{} query := `SELECT * FROM items WHERE id = $1;` row := db.Conn.QueryRow(query, itemId) switch err := row.Scan(&item.ID, &item.Name, &item.Description, &item.CreatedAt); err { case sql.ErrNoRows: return item, ErrNoMatch default: return item, err } } func (db Database) DeleteItem(itemId int) error { query := `DELETE FROM items WHERE id = $1;` _, err := db.Conn.Exec(query, itemId) switch err { case sql.ErrNoRows: return ErrNoMatch default: return err } } func (db Database) UpdateItem(itemId int, itemData models.Item) (models.Item, error) { item := models.Item{} query := `UPDATE items SET name=$1, description=$2 WHERE id=$3 RETURNING id, name, description, created_at;` err := db.Conn.QueryRow(query, itemData.Name, itemData.Description, itemId).Scan(&item.ID, &item.Name, &item.Description, &item.CreatedAt) if err != nil { if err == sql.ErrNoRows { return item, ErrNoMatch } return item, err } return item, nil }
The code above sets up five methods that match each of our API endpoints. Notice that each of the methods is capable of returning any error they encounter during the database operation. That way, we can bubble the errors all the way up to a place where they are properly handled.
GetAllItems
retrieves all the items in the database and returns them as an ItemList
which holds a slice of items.
AddItem
is responsible for creating a new item in the database. It also updates the ID
of the Item
instance it receives by leveraging PostgreSQL’s RETURNING
keyword.
GetItemById
, UpdateItem
, and DeleteItem
are responsible for fetching, updating, and deleting items from our database. In their cases, we perform an additional check and return a different error if the item does not exist in the database.
We are now ready to leverage chi’s powerful routing features. We will first initialize the route handlers in handler/handler.go
and implement the code to handle HTTP errors such as 404 Not Found and 405 Method Not Allowed. Open the handler.go
file and paste in the code below:
package handler import ( "net/http" "github.com/go-chi/chi" "github.com/go-chi/render" "gitlab.com/idoko/bucketeer/db" ) var dbInstance db.Database func NewHandler(db db.Database) http.Handler { router := chi.NewRouter() dbInstance = db router.MethodNotAllowed(methodNotAllowedHandler) router.NotFound(notFoundHandler) router.Route("/items", items) return router } func methodNotAllowedHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-type", "application/json") w.WriteHeader(405) render.Render(w, r, ErrMethodNotAllowed) } func notFoundHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-type", "application/json") w.WriteHeader(400) render.Render(w, r, ErrNotFound) }
Next, edit the handler/errors.go
file to declare the error responses we referenced above (i.e., ErrNotFound
and ErrMethodNotAllowed
) as well as the ones we will be using later on across the different route handlers:
package handler import ( "github.com/go-chi/render" "net/http" ) type ErrorResponse struct { Err error `json:"-"` StatusCode int `json:"-"` StatusText string `json:"status_text"` Message string `json:"message"` } var ( ErrMethodNotAllowed = &ErrorResponse{StatusCode: 405, Message: "Method not allowed"} ErrNotFound = &ErrorResponse{StatusCode: 404, Message: "Resource not found"} ErrBadRequest = &ErrorResponse{StatusCode: 400, Message: "Bad request"} ) func (e *ErrorResponse) Render(w http.ResponseWriter, r *http.Request) error { render.Status(r, e.StatusCode) return nil } func ErrorRenderer(err error) *ErrorResponse { return &ErrorResponse{ Err: err, StatusCode: 400, StatusText: "Bad request", Message: err.Error(), } } func ServerErrorRenderer(err error) *ErrorResponse { return &ErrorResponse{ Err: err, StatusCode: 500, StatusText: "Internal server error", Message: err.Error(), } }
Next, we will update handler/items.go
which is responsible for all API endpoints having the /items
prefix as we specified in the main handler file. Open it in your editor and add the following:
package handler import ( "context" "fmt" "net/http" "strconv" "github.com/go-chi/chi" "github.com/go-chi/render" "gitlab.com/idoko/bucketeer/db" "gitlab.com/idoko/bucketeer/models" ) var itemIDKey = "itemID" func items(router chi.Router) { router.Get("/", getAllItems) router.Post("/", createItem) router.Route("/{itemId}", func(router chi.Router) { router.Use(ItemContext) router.Get("/", getItem) router.Put("/", updateItem) router.Delete("/", deleteItem) }) } func ItemContext(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { itemId := chi.URLParam(r, "itemId") if itemId == "" { render.Render(w, r, ErrorRenderer(fmt.Errorf("item ID is required"))) return } id, err := strconv.Atoi(itemId) if err != nil { render.Render(w, r, ErrorRenderer(fmt.Errorf("invalid item ID"))) } ctx := context.WithValue(r.Context(), itemIDKey, id) next.ServeHTTP(w, r.WithContext(ctx)) }) }
At the top level, we specified the package name and imported the needed packages. We also declared an itemIDKey
variable. We will later use this variable for passing the itemID URL parameter across middlewares and request handlers using Go’s context.
We have also created a chi middleware method (ItemContext
) to help us extract the itemID
URL parameter from request URLs and use it in our code. The middleware checks if itemID
exists and is valid, and goes on to add it to the request context (using the itemIDKey
variable created earlier).
To create a new bucket list, we will use chi’s render.Bind
to decode the request body into an instance of models.Item
before sending it to be saved in the database. Add the code below to the end of handler/items.go
i.e., after the ItemContext
function:
func createItem(w http.ResponseWriter, r *http.Request) { item := &models.Item{} if err := render.Bind(r, item); err != nil { render.Render(w, r, ErrBadRequest) return } if err := dbInstance.AddItem(item); err != nil { render.Render(w, r, ErrorRenderer(err)) return } if err := render.Render(w, r, item); err != nil { render.Render(w, r, ServerErrorRenderer(err)) return } }
To fetch all existing items in the database, append the code below to handler/items.go
:
func getAllItems(w http.ResponseWriter, r *http.Request) { items, err := dbInstance.GetAllItems() if err != nil { render.Render(w, r, ServerErrorRenderer(err)) return } if err := render.Render(w, r, items); err != nil { render.Render(w, r, ErrorRenderer(err)) } }
Viewing a specific item means we will have to retrieve the item ID added to the request context by the ItemContext
middleware we implemented earlier and retrieve the matching row from the database:
func getItem(w http.ResponseWriter, r *http.Request) { itemID := r.Context().Value(itemIDKey).(int) item, err := dbInstance.GetItemById(itemID) if err != nil { if err == db.ErrNoMatch { render.Render(w, r, ErrNotFound) } else { render.Render(w, r, ErrorRenderer(err)) } return } if err := render.Render(w, r, &item); err != nil { render.Render(w, r, ServerErrorRenderer(err)) return } }
Similarly, we will implement deleting and updating an existing item from the database:
func deleteItem(w http.ResponseWriter, r *http.Request) { itemId := r.Context().Value(itemIDKey).(int) err := dbInstance.DeleteItem(itemId) if err != nil { if err == db.ErrNoMatch { render.Render(w, r, ErrNotFound) } else { render.Render(w, r, ServerErrorRenderer(err)) } return } } func updateItem(w http.ResponseWriter, r *http.Request) { itemId := r.Context().Value(itemIDKey).(int) itemData := models.Item{} if err := render.Bind(r, &itemData); err != nil { render.Render(w, r, ErrBadRequest) return } item, err := dbInstance.UpdateItem(itemId, itemData) if err != nil { if err == db.ErrNoMatch { render.Render(w, r, ErrNotFound) } else { render.Render(w, r, ServerErrorRenderer(err)) } return } if err := render.Render(w, r, &item); err != nil { render.Render(w, r, ServerErrorRenderer(err)) return } }
Having set up the individual components of our API, we will tie them together in the main.go
file. Open the file and add the following code:
package main import ( "context" "fmt" "gitlab.com/idoko/bucketeer/db" "gitlab.com/idoko/bucketeer/handler" "log" "net" "net/http" "os" "os/signal" "syscall" "time" ) func main() { addr := ":8080" listener, err := net.Listen("tcp", addr) if err != nil { log.Fatalf("Error occurred: %s", err.Error()) } dbUser, dbPassword, dbName := os.Getenv("POSTGRES_USER"), os.Getenv("POSTGRES_PASSWORD"), os.Getenv("POSTGRES_DB") database, err := db.Initialize(dbUser, dbPassword, dbName) if err != nil { log.Fatalf("Could not set up database: %v", err) } defer database.Conn.Close() httpHandler := handler.NewHandler(database) server := &http.Server{ Handler: httpHandler, } go func() { server.Serve(listener) }() defer Stop(server) log.Printf("Started server on %s", addr) ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) log.Println(fmt.Sprint(<-ch)) log.Println("Stopping API server.") } func Stop(server *http.Server) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil { log.Printf("Could not shut down server correctly: %v\n", err) os.Exit(1) } }
In the above, we ask the db
package to create a new database connection using the credentials gotten from the environment variables. The connection is then passed to the handler for its use. Using defer database.Conn.Close()
, we ensure that the database connection is kept alive while the application is running.
The API server is started on a separate goroutine and keeps running until it receives a SIGINT or SIGTERM
signal after which it calls the Stop
function to clean up and shut down the server.
We are now ready to test our application using docker-compose. Run the command below in a terminal to build and start up the services.
$ docker-compose up --build
In a separate terminal, you can test out the individual endpoints using Postman or by running the following curl commands.
Add a new item to the bucket list:
$ curl -X POST http://localhost:8080/items -H "Content-type: application/json" -d '{ "name": "swim across the River Benue", "description": "ho ho ho"}'
The command above should give a response similar to the one below:
{"id":8,"name":"swim across the River Benue","description":"ho ho ho","created_at":"2020-07-26T22:31:04.49683Z"}
Fetch all items currently in the list by running:
curl http://localhost:8080/items
Which in turn, gives the following response:
{ "items": [ { "id": 1, "name": "swim across the River Benue", "description": "ho ho ho", "created_at": "2020-07-26T22:31:04.49683Z" } ] }
Fetch a single item using its ID:
$ curl http://localhost:8080/items/8
The command above should return a response like the one below:
{"id":8,"name":"swim across the River Benue","description":"ho ho ho","created_at":"2020-07-26T22:31:04.49683Z"}
In this article, we built a simple REST API using chi, Docker, and PostgreSQL and explored some of chi’s niceties, such as middleware, while we were at it. The complete source code is available on Gitlab. Feel free to create an issue on Gitlab or reach out to me on Twitter with questions or feedback.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowCreate a multi-lingual web application using Nuxt 3 and the Nuxt i18n and Nuxt i18n Micro modules.
Use CSS to style and manage disclosure widgets, which are the HTML `details` and `summary` elements.
React Native’s New Architecture offers significant performance advantages. In this article, you’ll explore synchronous and asynchronous rendering in React Native through practical use cases.
Build scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
4 Replies to "How to build a RESTful API with Docker, PostgreSQL, and go-chi"
Good good good! Though they are strange concepts to me. Could you tell me why I should use it?
In the latest pgx, you need to make `CreatedAt` a `time.Time` in the model, otherwise it will not `Scan`
Superb article. Basically answered my question as regards persisting the db object for the lifetime of the application without any drawbacks.
Nice article! Thanks for helping me to bootstrap this nice little service, I love it. Just a small adjust that I needed to do:
– the service is reporting this log to all the calls: `http: superfluous response.WriteHeader call from github.com/go-chi/render.JSON (responder.go:104)`
I found that it happens because of the line: `w.WriteHeader(405)` and `w.WriteHeader(400)` inside the functions: `methodNotAllowedHandler` and `notFoundHandler`; removing these two lines fixed the issue; and actually we are returning the proper status code because of the errors values inside the `handler/errors.go` file, so no harm done by removing these lines.