Project Golang Postgres Stack
This app looks up locations from a static table.
For simplicity, it's all treated as text.
Code available on Github branch read-postgres
Bash |
---|
| # Open terminal to desired project directory
cd ~/projects
# Make directory for the project
mkdir places
# Enter the directory
cd places
# Make docker-compose config file
touch docker-compose.yml
# Make directory for the go backend server
mkdir backend
# Enter the go
cd backend
# Initialize go project
go mod init github.com/gerardrbentley/places
# Install postgres driver and mock interface
go get -u github.com/jackc/pgx/v5/pgxpool
go get -u github.com/spacetab-io/pgxpoolmock
# Make file for entrypoint and basic integration testing
touch main.go main_test.go
# Make package for API handlers
mkdir handler
# Make file for web handler code
touch handler/place.go handler/place_test.go
# Make package for data services
mkdir service
# Make files for service code
touch service/place.go service/place_test.go
# Make dockerfile for building
touch Dockerfile
# Run tests
go test ./...
# Run stack. (cd from `backend` to `places`)
cd ..
docker-compose up --build
# Tear down stack and database
docker-compose down --volumes --remove-orphans
# Enter psql (with running stack)
docker-compose exec database psql -U places_user -d places
# Run database management script
docker-compose exec database psql -U places_user -d places -f /tmp/sample_data/load_places_data.sql
# With database in background
docker-compose up -d database
# Run Just Go server
DB_CONNECTION=postgres://places_user:places_password@localhost:5432/places go run main.go
# Manually request endpoint (from another terminal)
curl -v "http://localhost:5000/place?name=First"
# Build and run executable
go build main.go
DB_CONNECTION=postgres://places_user:places_password@localhost:5432/places ./main
|
Main Entrypoint
Adds setup for services and database connection over basic web service.
main.go |
---|
| package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
. "github.com/gerardrbentley/places/handler"
. "github.com/gerardrbentley/places/service"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupHandlers(placeService PlaceService) *http.ServeMux {
h := http.NewServeMux()
h.Handle("/place", PlaceHandler(placeService))
return h
}
func main() {
log.Println("Starting Up....")
log.Println("Connecting to postgres...")
dbpool, err := pgxpool.New(context.Background(), os.Getenv("DB_CONNECTION"))
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to create connection pool: %v\n", err)
os.Exit(1)
}
defer dbpool.Close()
placeService := NewPgPlaceService(dbpool)
h := setupHandlers(placeService)
log.Fatal(http.ListenAndServe(":5000", h))
}
|
Handler
Handler can accept a service on initialization for use in serving requests.
This layer can be responsible for serialization, but we'll re-use the struct from the service.
handler/place.go |
---|
| package handler
import (
"encoding/json"
"log"
"net/http"
. "github.com/gerardrbentley/places/service"
)
type PlaceError struct {
Error string `json:"error"`
}
func PlaceHandler(service PlaceService) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
searchName := r.URL.Query().Get("name")
if searchName == "" {
w.WriteHeader(http.StatusBadRequest)
p := PlaceError{Error: "no name query"}
if err := json.NewEncoder(w).Encode(p); err != nil {
log.Println(err.Error())
w.WriteHeader(http.StatusInternalServerError)
}
return
}
records, err := service.LookupByName(searchName)
if err != nil {
log.Println(err.Error())
w.WriteHeader(http.StatusNotFound)
return
}
if err := json.NewEncoder(w).Encode(records); err != nil {
log.Println(err.Error())
w.WriteHeader(http.StatusInternalServerError)
}
})
}
|
Service
By accepting an interface for our database Query tool we can freely mock out the database returns.
The database access could be abstracted to a "repository" layer or a "db" package.
service/place.go |
---|
| package service
import (
"context"
"errors"
"log"
"github.com/jackc/pgx/v5"
)
type PlaceRecord struct {
FoodResourceType string `db:"food_resource_type" json:"-"`
Agency string `db:"agency" json:"name"`
Location string `db:"location" json:"location"`
OperationalStatus string `db:"operational_status" json:"-"`
OperationalNotes string `db:"operational_notes" json:"notes"`
WhoTheyServe string `db:"who_they_serve" json:"-"`
Address string `db:"address" json:"address"`
Latitude string `db:"latitude" json:"latitude"`
Longitude string `db:"longitude" json:"longitude"`
PhoneNumber string `db:"phone_number" json:"phone_number"`
Website string `db:"website" json:"website"`
DaysOrHours string `db:"days_or_hours" json:"days_or_hours"`
DateUpdated string `db:"date_updated" json:"-"`
}
type PlaceService interface {
LookupByName(searchName string) ([]PlaceRecord, error)
}
type DbPool interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
}
type PgPlaceService struct {
dbpool DbPool
}
func NewPgPlaceService(dbpool DbPool) *PgPlaceService {
return &PgPlaceService{dbpool: dbpool}
}
func (s *PgPlaceService) LookupByName(searchName string) ([]PlaceRecord, error) {
rows, err := s.dbpool.Query(context.Background(),
`select "food_resource_type",
"agency",
"location",
"operational_status",
"operational_notes",
"who_they_serve",
"address",
"latitude",
"longitude",
"phone_number",
"website",
"days_or_hours",
"date_updated"
from place where tsv @@ plainto_tsquery($1);`,
searchName)
if err != nil {
log.Printf("Query failed: %v\n", err)
return []PlaceRecord{}, errors.New("Database Error")
}
records, err := pgx.CollectRows(rows, pgx.RowToStructByName[PlaceRecord])
if err == pgx.ErrNoRows || len(records) == 0 {
return []PlaceRecord{}, errors.New("Not Found")
} else if err != nil {
log.Printf("Parsing Record failed: %v\n", err)
return []PlaceRecord{}, errors.New("Database Record Corrupted")
}
return records, nil
}
type InMemoryPlaceService struct {
LookupByNameFunc func(searchName string) ([]PlaceRecord, error)
}
func (s *InMemoryPlaceService) LookupByName(searchName string) ([]PlaceRecord, error) {
return s.LookupByNameFunc(searchName)
}
type InMemoryDbPool struct {
QueryFunc func(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
}
func (p InMemoryDbPool) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
return p.QueryFunc(ctx, sql, args)
}
|
Happy Path Integration Test
This utilizes an in-memory service to mock the responses.
A full e2e test would ideally use the running postgres container.
main_test.go |
---|
| package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"testing"
. "github.com/gerardrbentley/places/service"
)
func TestLookupPlaceRoute(t *testing.T) {
s := InMemoryPlaceService{
LookupByNameFunc: func(searchName string) ([]PlaceRecord, error) {
return []PlaceRecord{{Agency: searchName}}, nil
},
}
h := setupHandlers(&s)
w := httptest.NewRecorder()
mockName := "seattle"
req, _ := http.NewRequest("GET", fmt.Sprintf("/place?name=%s", mockName), nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Not OK %v", w.Code)
}
results := []PlaceRecord{}
if err := json.NewDecoder(w.Body).Decode(&results); err != nil {
log.Fatalln(err)
}
result := results[0]
if result.Agency != mockName {
t.Errorf("Not same Name: %v", result.Agency)
}
}
|
Happy Path Handler Test
handler/place_test.go |
---|
| package handler
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"testing"
. "github.com/gerardrbentley/places/service"
)
func TestPlaceHandler(t *testing.T) {
ctx := context.Background()
mockName := "seattle"
req, _ := http.NewRequestWithContext(
ctx,
http.MethodPatch,
fmt.Sprintf("/place?name=%s", mockName),
nil,
)
w := httptest.NewRecorder()
s := InMemoryPlaceService{
LookupByNameFunc: func(searchName string) ([]PlaceRecord, error) {
return []PlaceRecord{{Agency: searchName}}, nil
},
}
r := http.NewServeMux()
r.Handle("/place", PlaceHandler(&s))
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Not OK %v", w.Code)
}
var results []PlaceRecord
if err := json.NewDecoder(w.Body).Decode(&results); err != nil {
log.Fatalln(err)
}
result := results[0]
if result.Agency != mockName {
t.Errorf("Not same name: %v", result.Agency)
}
}
|
Happy Path Service Test
Since the postgres rows is an interface, we can hack in a mock.
service/place_test.go |
---|
| package service
import (
"context"
"reflect"
"testing"
"unsafe"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type MockRows struct {
values [][]any
visitIdx int
}
func (r *MockRows) Close() {}
func (r *MockRows) Err() error {
return nil
}
func (r *MockRows) CommandTag() pgconn.CommandTag {
return pgconn.NewCommandTag("mock")
}
func (r *MockRows) FieldDescriptions() []pgconn.FieldDescription {
return []pgconn.FieldDescription{}
}
func (r *MockRows) Next() bool {
return r.visitIdx < len(r.values)
}
type mockScanner struct {
ptrToStruct any
}
func (r *MockRows) Scan(dest ...any) error {
v := reflect.ValueOf(dest[0]).Elem().FieldByName("ptrToStruct")
v = reflect.NewAt(v.Type(), unsafe.Pointer(v.UnsafeAddr())).Elem()
for i, value := range r.values[r.visitIdx] {
n := v.Elem().Elem().Field(i)
n.Set(reflect.ValueOf(value))
}
r.visitIdx = r.visitIdx + 1
return nil
}
func (r *MockRows) Values() ([]any, error) {
return r.values[r.visitIdx], nil
}
func (r *MockRows) RawValues() [][]byte {
return nil
}
func (r *MockRows) Conn() *pgx.Conn {
return nil
}
func TestLookupByName(t *testing.T) {
mockName := "seattle"
mockPool := InMemoryDbPool{
QueryFunc: func(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
pgxrows := MockRows{
values: [][]any{
{
"meal",
mockName,
},
{
"food bank",
"Fremont " + mockName,
},
},
}
return &pgxrows, nil
},
}
s := PgPlaceService{dbpool: mockPool}
records, _ := s.LookupByName(mockName)
record := records[0]
if record.Agency != mockName {
t.Errorf("Not expected: %v", record.Agency)
}
}
|
Dockerfile
Running a database container with your webserver means you need networking.
Docker-compose is a straightforward way to handle this networking.
A go application can be built and ran in a Docker container, optionally distroless (comment last FROM
and COPY
steps to maintain shell access).
backend/Dockerfile |
---|
| FROM golang:1.19.3-buster AS build
WORKDIR /src/github.com/gerardrbentley/places/
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY . /src/github.com/gerardrbentley/places/
RUN CGO_ENABLED=0 GO111MODULE=on GOOS=linux go build -o /bin/app && \
chmod 111 /bin/app
FROM gcr.io/distroless/base as final
COPY --from=build /bin/app /bin/app
CMD ["/bin/app"]
|
Docker Compose File
This allows one command to spin up and down all or individual database and webserver.
docker-compose.yml |
---|
| services:
backend:
build: ./backend
environment:
- DB_CONNECTION=postgres://places_user:places_password@database:5432/places
ports:
- "5000:5000"
restart: always
database:
image: postgres:15.1
command: ["postgres", "-c", "log_statement=all", "-c", "log_destination=stderr"]
environment:
PGDATA: /var/lib/postgresql/data/pgdata/
POSTGRES_HOST: database
POSTGRES_PORT: 5432
POSTGRES_DB: places
POSTGRES_USER: places_user
POSTGRES_PASSWORD: places_password
ports:
- "5432:5432"
restart: always
volumes:
- ./sample_data:/tmp/sample_data
- postgres_data:/var/lib/postgresql/data/pgdata
volumes:
postgres_data:
|
Database Management Script
Data source from http://www.seattle.gov/humanservices/: sample_data/Emergency_Food_and_Meals_Seattle_and_King_County.csv
To load the table into postgres and add a text search index we can use a sql script such as the following:
sample_data/load_places_data.sql |
---|
| drop table if exists place;
create table place (
"food_resource_type" text,
"agency" text,
"location" text,
"operational_status" text,
"operational_notes" text,
"who_they_serve" text,
"address" text,
"latitude" text,
"longitude" text,
"phone_number" text,
"website" text,
"days_or_hours" text,
"date_updated" text,
"tsv" tsvector
);
create trigger tsvectorupdate before insert or update
on place for each row execute procedure
tsvector_update_trigger(
tsv, 'pg_catalog.english', "agency", "location", "operational_notes"
);
create index index_pages_on_tsv on place using gin (tsv);
\copy place("food_resource_type", "agency", "location", "operational_status", "operational_notes", "who_they_serve", "address", "latitude", "longitude", "phone_number", "website", "days_or_hours", "date_updated") from '/tmp/sample_data/Emergency_Food_and_Meals_Seattle_and_King_County.csv' with null as E'\'\'' delimiter ',' CSV HEADER
|
And that's a wrap.
Last update:
June 7, 2023
Created:
June 7, 2023