logging and middleware chaining
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline was successful

This commit is contained in:
Michael Thomson 2025-05-15 12:34:32 -04:00
parent e76386d10a
commit f9a8e9666e
11 changed files with 168 additions and 100 deletions

View File

@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"log" "log"
"net/http" "net/http"
"os"
"gitea.michaelthomson.dev/mthomson/habits/internal/logging" "gitea.michaelthomson.dev/mthomson/habits/internal/logging"
"gitea.michaelthomson.dev/mthomson/habits/internal/middleware" "gitea.michaelthomson.dev/mthomson/habits/internal/middleware"
@ -22,16 +23,22 @@ func main() {
contextMiddleware := middleware.ContextMiddleware(logger) contextMiddleware := middleware.ContextMiddleware(logger)
loggingMiddleware := middleware.LoggingMiddleware(logger) loggingMiddleware := middleware.LoggingMiddleware(logger)
stack := []middleware.Middleware{
contextMiddleware,
loggingMiddleware,
}
// create db pool // create db pool
postgresUrl := "postgres://todo:password@localhost:5432/todo" postgresUrl := "postgres://todo:password@localhost:5432/todo"
db, err := sql.Open("pgx", postgresUrl) db, err := sql.Open("pgx", postgresUrl)
if err != nil { if err != nil {
log.Fatalf("Failed to open db pool: %v", err) logger.Error(err.Error())
os.Exit(1);
} }
defer db.Close() defer db.Close()
// run migrations // run migrations
migrate.Migrate(db) migrate.Migrate(logger, db)
// create repos // create repos
todoRepository := todorepository.NewPostgresTodoRepository(logger, db) todoRepository := todorepository.NewPostgresTodoRepository(logger, db)
@ -43,10 +50,10 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
// register handlers // register handlers
mux.Handle("GET /todo/{id}", contextMiddleware(loggingMiddleware(todohandler.HandleTodoGet(logger, todoService)))) mux.Handle("GET /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoGet(logger, todoService), stack))
mux.Handle("POST /todo", contextMiddleware(loggingMiddleware(todohandler.HandleTodoCreate(logger, todoService)))) mux.Handle("POST /todo", middleware.CompileMiddleware(todohandler.HandleTodoCreate(logger, todoService), stack))
mux.Handle("DELETE /todo/{id}", contextMiddleware(loggingMiddleware(todohandler.HandleTodoDelete(logger, todoService)))) mux.Handle("DELETE /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoDelete(logger, todoService), stack))
mux.Handle("PUT /todo/{id}", contextMiddleware(loggingMiddleware(todohandler.HandleTodoUpdate(logger, todoService)))) mux.Handle("PUT /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoUpdate(logger, todoService), stack))
// create server // create server
server := &http.Server{ server := &http.Server{

View File

@ -36,6 +36,7 @@
docker docker
docker-compose docker-compose
gopls
]; ];
}; };
}); });

View File

@ -4,6 +4,8 @@ import (
"context" "context"
"log/slog" "log/slog"
"os" "os"
"gitea.michaelthomson.dev/mthomson/habits/internal/middleware"
) )
type ContextHandler struct { type ContextHandler struct {
@ -11,8 +13,8 @@ type ContextHandler struct {
} }
func (h *ContextHandler) Handle(ctx context.Context, r slog.Record) error { func (h *ContextHandler) Handle(ctx context.Context, r slog.Record) error {
if requestID, ok := ctx.Value("trace_id").(string); ok { if requestID, ok := ctx.Value(middleware.TraceIdKey).(string); ok {
r.AddAttrs(slog.String("trace_id", requestID)) r.AddAttrs(slog.String(string(middleware.TraceIdKey), requestID))
} }
return h.Handler.Handle(ctx, r) return h.Handler.Handle(ctx, r)
} }

View File

@ -8,11 +8,14 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type contextKey string
const TraceIdKey contextKey = "trace_id"
func ContextMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { func ContextMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
traceid := uuid.NewString() traceid := uuid.NewString()
ctx := context.WithValue(r.Context(), "trace_id", traceid) ctx := context.WithValue(r.Context(), TraceIdKey, traceid)
newReq := r.WithContext(ctx) newReq := r.WithContext(ctx)
next.ServeHTTP(w, newReq) next.ServeHTTP(w, newReq)

View File

@ -0,0 +1,19 @@
package middleware
import "net/http"
type Middleware func(http.Handler) http.Handler
func CompileMiddleware(h http.Handler, m []Middleware) http.Handler {
if len(m) < 1 {
return h
}
wrapped := h
for i := len(m) - 1; i >= 0; i-- {
wrapped = m[i](wrapped)
}
return wrapped
}

View File

@ -7,7 +7,7 @@ import (
"log" "log"
"log/slog" "log/slog"
_ "github.com/mattn/go-sqlite3" _ "github.com/jackc/pgx/v5/stdlib"
) )
//go:embed migrations/*.sql //go:embed migrations/*.sql
@ -18,8 +18,8 @@ type Migration struct {
Name string Name string
} }
func Migrate(db *sql.DB) { func Migrate(logger *slog.Logger, db *sql.DB) {
slog.Info("Running migrations...") logger.Info("Running migrations...")
migrationTableSql := ` migrationTableSql := `
CREATE TABLE IF NOT EXISTS migrations( CREATE TABLE IF NOT EXISTS migrations(
version SERIAL PRIMARY KEY, version SERIAL PRIMARY KEY,
@ -41,7 +41,7 @@ func Migrate(db *sql.DB) {
row := db.QueryRow("SELECT * FROM migrations WHERE name = $1;", file.Name()) row := db.QueryRow("SELECT * FROM migrations WHERE name = $1;", file.Name())
err = row.Scan(&migration.Version, &migration.Name) err = row.Scan(&migration.Version, &migration.Name)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
slog.Info(fmt.Sprintf("Running migration: %s", file.Name())) logger.Info(fmt.Sprintf("Running migration: %s", file.Name()))
migrationSql, err := migrations.ReadFile(fmt.Sprintf("migrations/%s", file.Name())) migrationSql, err := migrations.ReadFile(fmt.Sprintf("migrations/%s", file.Name()))
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -58,5 +58,5 @@ func Migrate(db *sql.DB) {
} }
} }
} }
slog.Info("Migrations completed") logger.Info("Migrations completed")
} }

View File

@ -0,0 +1,62 @@
package test
import (
"context"
"database/sql"
"log/slog"
"testing"
"time"
"gitea.michaelthomson.dev/mthomson/habits/internal/migrate"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
type TestDatabase struct {
Db *sql.DB
container testcontainers.Container
}
func NewTestDatabase(tb testing.TB) *TestDatabase {
tb.Helper()
ctx := context.Background()
// create container
postgresContainer, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("todo"),
postgres.WithUsername("todo"),
postgres.WithPassword("password"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(5*time.Second)),
)
if err != nil {
tb.Fatalf("Failed to create postgres container, %v", err)
}
connectionString, err := postgresContainer.ConnectionString(ctx)
if err != nil {
tb.Fatalf("Failed to get connection string: %v", err)
}
// create db pool
db, err := sql.Open("pgx", connectionString)
if err != nil {
tb.Fatalf("Failed to open db pool: %v", err)
}
migrate.Migrate(slog.Default(), db)
return &TestDatabase{
Db: db,
container: postgresContainer,
}
}
func (tdb *TestDatabase) TearDown() {
tdb.Db.Close()
_ = tdb.container.Terminate(context.Background())
}

View File

@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"context"
"database/sql" "database/sql"
"log/slog" "log/slog"
@ -19,7 +20,7 @@ func NewPostgresTodoRepository(logger *slog.Logger, db *sql.DB) *PostgresTodoRep
} }
} }
func (r *PostgresTodoRepository) GetById(id int64) (repository.TodoRow, error) { func (r *PostgresTodoRepository) GetById(ctx context.Context, id int64) (repository.TodoRow, error) {
todo := repository.TodoRow{} todo := repository.TodoRow{}
err := r.db.QueryRow("SELECT * FROM todo WHERE id = $1;", id).Scan(&todo.Id, &todo.Name, &todo.Done) err := r.db.QueryRow("SELECT * FROM todo WHERE id = $1;", id).Scan(&todo.Id, &todo.Name, &todo.Done)
@ -29,33 +30,37 @@ func (r *PostgresTodoRepository) GetById(id int64) (repository.TodoRow, error) {
return todo, repository.ErrNotFound return todo, repository.ErrNotFound
} }
r.logger.ErrorContext(ctx, err.Error())
return todo, err return todo, err
} }
return todo, nil return todo, nil
} }
func (r *PostgresTodoRepository) Create(todo repository.TodoRow) (repository.TodoRow, error) { func (r *PostgresTodoRepository) Create(ctx context.Context, todo repository.TodoRow) (repository.TodoRow, error) {
result := r.db.QueryRow("INSERT INTO todo (name, done) VALUES ($1, $2) RETURNING id;", todo.Name, todo.Done) result := r.db.QueryRow("INSERT INTO todo (name, done) VALUES ($1, $2) RETURNING id;", todo.Name, todo.Done)
err := result.Scan(&todo.Id) err := result.Scan(&todo.Id)
if err != nil { if err != nil {
r.logger.ErrorContext(ctx, err.Error())
return repository.TodoRow{}, err return repository.TodoRow{}, err
} }
return todo, nil return todo, nil
} }
func (r *PostgresTodoRepository) Update(todo repository.TodoRow) error { func (r *PostgresTodoRepository) Update(ctx context.Context, todo repository.TodoRow) error {
result, err := r.db.Exec("UPDATE todo SET name = $1, done = $2 WHERE id = $3;", todo.Name, todo.Done, todo.Id) result, err := r.db.Exec("UPDATE todo SET name = $1, done = $2 WHERE id = $3;", todo.Name, todo.Done, todo.Id)
if err != nil { if err != nil {
r.logger.ErrorContext(ctx, err.Error())
return err return err
} }
rowsAffected, err := result.RowsAffected() rowsAffected, err := result.RowsAffected()
if err != nil { if err != nil {
r.logger.ErrorContext(ctx, err.Error())
return err return err
} }
@ -66,16 +71,18 @@ func (r *PostgresTodoRepository) Update(todo repository.TodoRow) error {
return nil return nil
} }
func (r *PostgresTodoRepository) Delete(id int64) error { func (r *PostgresTodoRepository) Delete(ctx context.Context, id int64) error {
result, err := r.db.Exec("DELETE FROM todo WHERE id = $1;", id) result, err := r.db.Exec("DELETE FROM todo WHERE id = $1;", id)
if err != nil { if err != nil {
r.logger.ErrorContext(ctx, err.Error())
return err return err
} }
rowsAffected, err := result.RowsAffected() rowsAffected, err := result.RowsAffected()
if err != nil { if err != nil {
r.logger.ErrorContext(ctx, err.Error())
return err return err
} }

View File

@ -2,105 +2,53 @@ package postgres
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"log/slog" "log/slog"
"testing" "testing"
"time"
"gitea.michaelthomson.dev/mthomson/habits/internal/migrate" "gitea.michaelthomson.dev/mthomson/habits/internal/test"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository" "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
) )
type TestDatabase struct {
Db *sql.DB
container testcontainers.Container
}
func NewTestDatabase(tb testing.TB) *TestDatabase {
tb.Helper()
ctx := context.Background()
// create container
postgresContainer, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("todo"),
postgres.WithUsername("todo"),
postgres.WithPassword("password"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(5*time.Second)),
)
if err != nil {
tb.Fatalf("Failed to create postgres container, %v", err)
}
connectionString, err := postgresContainer.ConnectionString(ctx)
if err != nil {
tb.Fatalf("Failed to get connection string: %v", err)
}
// create db pool
db, err := sql.Open("pgx", connectionString)
if err != nil {
tb.Fatalf("Failed to open db pool: %v", err)
}
migrate.Migrate(db)
return &TestDatabase{
Db: db,
container: postgresContainer,
}
}
func (tdb *TestDatabase) TearDown() {
tdb.Db.Close()
_ = tdb.container.Terminate(context.Background())
}
func TestCRUD(t *testing.T) { func TestCRUD(t *testing.T) {
ctx := context.Background()
logger := slog.Default() logger := slog.Default()
tdb := NewTestDatabase(t) tdb := test.NewTestDatabase(t)
defer tdb.TearDown() defer tdb.TearDown()
r := NewPostgresTodoRepository(logger, tdb.Db) r := NewPostgresTodoRepository(logger, tdb.Db)
t.Run("creates new todo", func(t *testing.T) { t.Run("creates new todo", func(t *testing.T) {
want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false} want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
newTodo := repository.TodoRow{Name: "clean dishes", Done: false} newTodo := repository.TodoRow{Name: "clean dishes", Done: false}
got, err := r.Create(newTodo) got, err := r.Create(ctx, newTodo)
AssertNoError(t, err) AssertNoError(t, err)
AssertTodoRows(t, got, want) AssertTodoRows(t, got, want)
}) })
t.Run("gets todo", func(t *testing.T) { t.Run("gets todo", func(t *testing.T) {
want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false} want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
got, err := r.GetById(1) got, err := r.GetById(ctx, 1)
AssertNoError(t, err) AssertNoError(t, err)
AssertTodoRows(t, got, want) AssertTodoRows(t, got, want)
}) })
t.Run("updates todo", func(t *testing.T) { t.Run("updates todo", func(t *testing.T) {
want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: true} want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: true}
err := r.Update(want) err := r.Update(ctx, want)
AssertNoError(t, err) AssertNoError(t, err)
got, err := r.GetById(1) got, err := r.GetById(ctx, 1)
AssertNoError(t, err) AssertNoError(t, err)
AssertTodoRows(t, got, want) AssertTodoRows(t, got, want)
}) })
t.Run("deletes todo", func(t *testing.T) { t.Run("deletes todo", func(t *testing.T) {
err := r.Delete(1) err := r.Delete(ctx, 1)
AssertNoError(t, err) AssertNoError(t, err)
want := repository.ErrNotFound want := repository.ErrNotFound
_, got := r.GetById(1) _, got := r.GetById(ctx, 1)
AssertErrors(t, got, want) AssertErrors(t, got, want)
}) })

View File

@ -35,10 +35,10 @@ func (t Todo) Equal(todo Todo) bool {
} }
type TodoRepository interface { type TodoRepository interface {
Create(todo repository.TodoRow) (repository.TodoRow, error) Create(ctx context.Context, todo repository.TodoRow) (repository.TodoRow, error)
GetById(id int64) (repository.TodoRow, error) GetById(ctx context.Context, id int64) (repository.TodoRow, error)
Update(todo repository.TodoRow) error Update(ctx context.Context, todo repository.TodoRow) error
Delete(id int64) error Delete(ctx context.Context, id int64) error
} }
type TodoService struct { type TodoService struct {
@ -54,7 +54,7 @@ func NewTodoService(logger *slog.Logger, todoRepo TodoRepository) *TodoService {
} }
func (s *TodoService) GetTodo(ctx context.Context, id int64) (Todo, error) { func (s *TodoService) GetTodo(ctx context.Context, id int64) (Todo, error) {
todo, err := s.repo.GetById(id) todo, err := s.repo.GetById(ctx, id)
if err != nil { if err != nil {
if err == repository.ErrNotFound { if err == repository.ErrNotFound {
@ -71,7 +71,7 @@ func (s *TodoService) GetTodo(ctx context.Context, id int64) (Todo, error) {
func (s *TodoService) CreateTodo(ctx context.Context, todo Todo) (Todo, error) { func (s *TodoService) CreateTodo(ctx context.Context, todo Todo) (Todo, error) {
todoRow := TodoRowFromTodo(todo) todoRow := TodoRowFromTodo(todo)
newTodoRow, err := s.repo.Create(todoRow) newTodoRow, err := s.repo.Create(ctx, todoRow)
if err != nil { if err != nil {
s.logger.ErrorContext(ctx, err.Error()) s.logger.ErrorContext(ctx, err.Error())
@ -82,7 +82,7 @@ func (s *TodoService) CreateTodo(ctx context.Context, todo Todo) (Todo, error) {
} }
func (s *TodoService) DeleteTodo(ctx context.Context, id int64) error { func (s *TodoService) DeleteTodo(ctx context.Context, id int64) error {
err := s.repo.Delete(id) err := s.repo.Delete(ctx, id)
if err == repository.ErrNotFound { if err == repository.ErrNotFound {
return ErrNotFound return ErrNotFound
@ -98,7 +98,7 @@ func (s *TodoService) DeleteTodo(ctx context.Context, id int64) error {
func (s *TodoService) UpdateTodo(ctx context.Context, todo Todo) error { func (s *TodoService) UpdateTodo(ctx context.Context, todo Todo) error {
todoRow := TodoRowFromTodo(todo) todoRow := TodoRowFromTodo(todo)
err := s.repo.Update(todoRow) err := s.repo.Update(ctx, todoRow)
if err == repository.ErrNotFound { if err == repository.ErrNotFound {
return ErrNotFound return ErrNotFound

View File

@ -5,18 +5,22 @@ import (
"log/slog" "log/slog"
"testing" "testing"
"gitea.michaelthomson.dev/mthomson/habits/internal/test"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository" "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository/inmemory" "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository/postgres"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
_ "github.com/jackc/pgx/v5/stdlib"
) )
func TestCreateTodo(t *testing.T) { func TestCreateTodo(t *testing.T) {
t.Parallel()
ctx := context.Background() ctx := context.Background()
logger := slog.Default() logger := slog.Default()
tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := postgres.NewPostgresTodoRepository(logger, tdb.Db)
todoRepository := inmemory.NewInMemoryTodoRepository() todoService := service.NewTodoService(logger, r)
todoService := service.NewTodoService(logger, &todoRepository)
t.Run("Create todo", func(t *testing.T) { t.Run("Create todo", func(t *testing.T) {
todo := service.NewTodo("clean dishes", false) todo := service.NewTodo("clean dishes", false)
@ -28,13 +32,18 @@ func TestCreateTodo(t *testing.T) {
} }
func TestGetTodo(t *testing.T) { func TestGetTodo(t *testing.T) {
t.Parallel()
ctx := context.Background() ctx := context.Background()
logger := slog.Default() logger := slog.Default()
todoRepository := inmemory.NewInMemoryTodoRepository() tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := postgres.NewPostgresTodoRepository(logger, tdb.Db)
todoRepository.Db[1] = repository.TodoRow{Id: 1, Name: "clean dishes", Done: false} row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
_, err := r.Create(ctx, row)
AssertNoError(t, err);
todoService := service.NewTodoService(logger, &todoRepository) todoService := service.NewTodoService(logger, r)
t.Run("Get exisiting todo", func(t *testing.T) { t.Run("Get exisiting todo", func(t *testing.T) {
_, err := todoService.GetTodo(ctx, 1) _, err := todoService.GetTodo(ctx, 1)
@ -50,13 +59,18 @@ func TestGetTodo(t *testing.T) {
} }
func TestDeleteTodo(t *testing.T) { func TestDeleteTodo(t *testing.T) {
t.Parallel()
ctx := context.Background() ctx := context.Background()
logger := slog.Default() logger := slog.Default()
todoRepository := inmemory.NewInMemoryTodoRepository() tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := postgres.NewPostgresTodoRepository(logger, tdb.Db)
todoRepository.Db[1] = repository.TodoRow{Id: 1, Name: "clean dishes", Done: false} row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
_, err := r.Create(ctx, row)
AssertNoError(t, err);
todoService := service.NewTodoService(logger, &todoRepository) todoService := service.NewTodoService(logger, r)
t.Run("Delete exisiting todo", func(t *testing.T) { t.Run("Delete exisiting todo", func(t *testing.T) {
err := todoService.DeleteTodo(ctx, 1) err := todoService.DeleteTodo(ctx, 1)
@ -72,13 +86,18 @@ func TestDeleteTodo(t *testing.T) {
} }
func TestUpdateTodo(t *testing.T) { func TestUpdateTodo(t *testing.T) {
t.Parallel()
ctx := context.Background() ctx := context.Background()
logger := slog.Default() logger := slog.Default()
todoRepository := inmemory.NewInMemoryTodoRepository() tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := postgres.NewPostgresTodoRepository(logger, tdb.Db)
todoRepository.Db[1] = repository.TodoRow{Id: 1, Name: "clean dishes", Done: false} row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
_, err := r.Create(ctx, row)
AssertNoError(t, err);
todoService := service.NewTodoService(logger, &todoRepository) todoService := service.NewTodoService(logger, r)
t.Run("Update exisiting todo", func(t *testing.T) { t.Run("Update exisiting todo", func(t *testing.T) {
todo := service.Todo{1, "clean dishes", true} todo := service.Todo{1, "clean dishes", true}