Compare commits

..

No commits in common. "main" and "feature/repo-testing-crud" have entirely different histories.

43 changed files with 624 additions and 2051 deletions

View File

@ -1,2 +0,0 @@
POSTGRESQL_CONNECTION_STRING=postgres://todo:password@localhost:5432/todo
JWT_SECRET_KEY="supersecretjwtkey"

View File

@ -1,105 +1,53 @@
package main
import (
"context"
"database/sql"
"log"
"log/slog"
"net/http"
"os"
"gitea.michaelthomson.dev/mthomson/habits/internal/auth"
authhandler "gitea.michaelthomson.dev/mthomson/habits/internal/auth/handler"
authservice "gitea.michaelthomson.dev/mthomson/habits/internal/auth/service"
"gitea.michaelthomson.dev/mthomson/habits/internal/logging"
"gitea.michaelthomson.dev/mthomson/habits/internal/middleware"
"gitea.michaelthomson.dev/mthomson/habits/internal/migrate"
todohandler "gitea.michaelthomson.dev/mthomson/habits/internal/todo/handler"
todorepository "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
todorepository "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository/postgres"
todoservice "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
userhandler "gitea.michaelthomson.dev/mthomson/habits/internal/user/handler"
userrepository "gitea.michaelthomson.dev/mthomson/habits/internal/user/repository"
userservice "gitea.michaelthomson.dev/mthomson/habits/internal/user/service"
pgxuuid "github.com/jackc/pgx-gofrs-uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
_ "github.com/jackc/pgx/v5/stdlib"
_ "github.com/mattn/go-sqlite3"
)
func main() {
// create logger
logger := logging.NewLogger()
// load env
err := godotenv.Load()
if err != nil {
logger.Error(err.Error())
os.Exit(1)
}
jwtSecretKey := os.Getenv("JWT_SECRET_KEY")
postgresqlConnectionString := os.Getenv("POSTGRESQL_CONNECTION_STRING")
// create hasher instance
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
httpLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
// create middlewares
traceMiddleware := middleware.TraceMiddleware(logger)
loggingMiddleware := middleware.LoggingMiddleware(logger)
authMiddleware := middleware.AuthMiddleware(logger, []byte(jwtSecretKey))
unauthenticatedStack := []middleware.Middleware{
traceMiddleware,
loggingMiddleware,
}
authenticatedStack := []middleware.Middleware{
traceMiddleware,
loggingMiddleware,
authMiddleware,
}
loggingMiddleware := middleware.LoggingMiddleware(httpLogger)
// create db pool
dbconfig, err := pgxpool.ParseConfig(postgresqlConnectionString)
postgresUrl := "postgres://todo:password@localhost:5432/todo"
db, err := sql.Open("pgx", postgresUrl)
if err != nil {
logger.Error(err.Error())
os.Exit(1)
}
dbconfig.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
pgxuuid.Register(conn.TypeMap())
return nil
}
db, err := pgxpool.NewWithConfig(context.Background(), dbconfig)
if err != nil {
logger.Error(err.Error())
os.Exit(1)
log.Fatalf("Failed to open db pool: %v", err)
}
defer db.Close()
// run migrations
migrate.Migrate(logger, db)
migrate.Migrate(db)
// create repos
todoRepository := todorepository.NewTodoRepository(logger, db)
userRepository := userrepository.NewUserRepository(logger, db)
todoRepository := todorepository.NewPostgresTodoRepository(db)
// create services
todoService := todoservice.NewTodoService(logger, todoRepository)
userService := userservice.NewUserService(logger, userRepository, argon2IdHash)
authService := authservice.NewAuthService(logger, []byte(jwtSecretKey), userRepository, argon2IdHash)
todoService := todoservice.NewTodoService(todoRepository)
// create mux
mux := http.NewServeMux()
// register handlers
// auth
mux.Handle("POST /login", middleware.CompileMiddleware(authhandler.HandleLogin(logger, authService), unauthenticatedStack))
// users
mux.Handle("POST /register", middleware.CompileMiddleware(userhandler.HandleRegisterUser(logger, userService), unauthenticatedStack))
// todos
mux.Handle("GET /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoGet(logger, todoService), authenticatedStack))
mux.Handle("POST /todo", middleware.CompileMiddleware(todohandler.HandleTodoCreate(logger, todoService), authenticatedStack))
mux.Handle("DELETE /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoDelete(logger, todoService), authenticatedStack))
mux.Handle("PUT /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoUpdate(logger, todoService), authenticatedStack))
mux.Handle("GET /todo/{id}", loggingMiddleware(todohandler.HandleTodoGet(todoService)))
mux.Handle("POST /todo", loggingMiddleware(todohandler.HandleTodoCreate(todoService)))
mux.Handle("DELETE /todo/{id}", loggingMiddleware(todohandler.HandleTodoDelete(todoService)))
mux.Handle("PUT /todo/{id}", loggingMiddleware(todohandler.HandleTodoUpdate(todoService)))
// create server
server := &http.Server{

10
flake.lock generated
View File

@ -2,12 +2,12 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1747179050,
"narHash": "sha256-qhFMmDkeJX9KJwr5H32f1r7Prs7XbQWtO0h3V0a0rFY=",
"rev": "adaa24fbf46737f3f1b5497bf64bae750f82942e",
"revCount": 799423,
"lastModified": 1731676054,
"narHash": "sha256-OZiZ3m8SCMfh3B6bfGC/Bm4x3qc1m2SVEAlkV6iY7Yg=",
"rev": "5e4fbfb6b3de1aa2872b76d49fafc942626e2add",
"revCount": 708622,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.799423%2Brev-adaa24fbf46737f3f1b5497bf64bae750f82942e/0196d1c3-1974-7bf1-bcf6-06620ac40c8c/source.tar.gz"
"url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.708622%2Brev-5e4fbfb6b3de1aa2872b76d49fafc942626e2add/0193363c-ab27-7bbd-af1d-3e6093ed5e2d/source.tar.gz"
},
"original": {
"type": "tarball",

View File

@ -5,7 +5,7 @@
outputs = { self, nixpkgs }:
let
goVersion = 23; # Change this to update the whole stack
goVersion = 22; # Change this to update the whole stack
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f {
@ -36,7 +36,6 @@
docker
docker-compose
gopls
];
};
});

79
go.mod
View File

@ -1,76 +1,65 @@
module gitea.michaelthomson.dev/mthomson/habits
go 1.23.0
toolchain go1.23.9
go 1.22.9
require (
github.com/gofrs/uuid/v5 v5.3.2
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2
github.com/jackc/pgx/v5 v5.7.4
github.com/joho/godotenv v1.5.1
github.com/testcontainers/testcontainers-go v0.37.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0
golang.org/x/crypto v0.38.0
github.com/jackc/pgx/v5 v5.7.2
github.com/mattn/go-sqlite3 v1.14.24
github.com/testcontainers/testcontainers-go v0.35.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
dario.cat/mergo v1.0.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/containerd/containerd v1.7.18 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.1.1+incompatible // indirect
github.com/docker/docker v27.1.1+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.8.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/moby/sys/sequential v0.5.0 // indirect
github.com/moby/sys/user v0.1.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/shirou/gopsutil/v4 v4.25.4 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shirou/gopsutil/v3 v3.23.12 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
google.golang.org/protobuf v1.35.2 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.3 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

204
go.sum
View File

@ -1,13 +1,15 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao=
github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
@ -21,32 +23,27 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I=
github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc=
github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0=
github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
@ -55,121 +52,118 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 h1:QWdhlQz98hUe1xmjADOl2mr8ERLrOqj0KWLdkrnNsRQ=
github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2/go.mod h1:Ti7pyNDU/UpXKmBTeFgxTvzYDM9xHLiYKMsLdt4b9cg=
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdelapenya/tlscert v0.1.0 h1:YTpF579PYUX475eOL+6zyEO3ngLTOUWck78NBuJVXaM=
github.com/mdelapenya/tlscert v0.1.0/go.mod h1:wrbyM/DwbFCeCeqdPX/8c6hNOqQgbf0rUDErE1uD+64=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo=
github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg=
github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw=
github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=
github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM=
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc=
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo=
github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4=
github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0 h1:eEGx9kYzZb2cNhRbBrNOCL/YPOM7+RMJiy3bB+ie0/I=
github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0/go.mod h1:hfH71Mia/WWLBgMD2YctYcMlfsbnT0hflweL1dy8Q4s=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o=
go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -177,15 +171,17 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -196,20 +192,20 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20230526203410-71b5a4ffd15e h1:Ao9GzfUMPH3zjVfzXG5rlWlk+Q8MXWKwWpwVQE1MXfw=
google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13 h1:vlzZttNJGVqTsRFU9AmdnrcO1Znh8Ew9kCD//yjigk0=
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4=
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=

View File

@ -1,66 +0,0 @@
package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"gitea.michaelthomson.dev/mthomson/habits/internal/auth/service"
)
type Loginer interface {
Login(ctx context.Context, email string, password string) (string, error)
}
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func HandleLogin(logger *slog.Logger, authService Loginer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
loginRequest := LoginRequest{}
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&loginRequest)
if err != nil {
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusBadRequest)
return
}
token, err := authService.Login(ctx, loginRequest.Email, loginRequest.Password)
if err == service.ErrUnauthorized {
http.Error(w, "", http.StatusUnauthorized)
return
}
if err == service.ErrNotFound {
http.Error(w, "", http.StatusUnauthorized)
return
}
if err != nil {
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusInternalServerError)
return
}
cookie := http.Cookie{
Name: "token",
Value: token,
Path: "/",
MaxAge: 3600,
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(w, &cookie)
w.WriteHeader(http.StatusOK)
}
}

View File

@ -1,168 +0,0 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"errors"
"net/http"
"net/http/httptest"
"testing"
"gitea.michaelthomson.dev/mthomson/habits/internal/auth/service"
)
type MockLoginer struct {
LoginFunc func(ctx context.Context, email string, password string) (string, error)
}
func (m *MockLoginer) Login(ctx context.Context, email string, password string) (string, error) {
return m.LoginFunc(ctx, email, password)
}
func TestLogin(t *testing.T) {
logger := slog.Default()
t.Run("returns 200 for existing user with correct credentials", func(t *testing.T) {
loginRequest := LoginRequest{Email: "test@test.com", Password: "password"}
token := "examplejwt"
service := MockLoginer{
LoginFunc: func(ctx context.Context, email string, password string) (string, error) {
return token, nil
},
}
handler := HandleLogin(logger, &service)
requestBody, err := json.Marshal(loginRequest)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", loginRequest, err)
}
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
handler(res, req)
AssertStatusCodes(t, res.Code, http.StatusOK)
AssertToken(t, res, token)
})
t.Run("returns 401 for existing user with incorrect credentials", func(t *testing.T) {
loginRequest := LoginRequest{Email: "test@test.com", Password: "password"}
service := MockLoginer{
LoginFunc: func(ctx context.Context, email string, password string) (string, error) {
return "", service.ErrUnauthorized
},
}
handler := HandleLogin(logger, &service)
requestBody, err := json.Marshal(loginRequest)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", loginRequest, err)
}
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
handler(res, req)
AssertStatusCodes(t, res.Code, http.StatusUnauthorized)
})
t.Run("returns 401 for non-existing user", func(t *testing.T) {
loginRequest := LoginRequest{Email: "test@test.com", Password: "password"}
service := MockLoginer{
LoginFunc: func(ctx context.Context, email string, password string) (string, error) {
return "", service.ErrNotFound
},
}
handler := HandleLogin(logger, &service)
requestBody, err := json.Marshal(loginRequest)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", loginRequest, err)
}
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
handler(res, req)
AssertStatusCodes(t, res.Code, http.StatusUnauthorized)
})
t.Run("returns 400 with bad json", func(t *testing.T) {
handler := HandleLogin(logger, nil)
badStruct := struct {
Foo string
}{
Foo: "bar",
}
requestBody, err := json.Marshal(badStruct)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", badStruct, err)
}
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
handler(res, req)
AssertStatusCodes(t, res.Code, http.StatusBadRequest)
})
t.Run("returns 500 arbitrary errors", func(t *testing.T) {
loginRequest := LoginRequest{Email: "test@test.com", Password: "password"}
service := MockLoginer{
LoginFunc: func(ctx context.Context, email string, password string) (string, error) {
return "", errors.New("foo bar")
},
}
handler := HandleLogin(logger, &service)
requestBody, err := json.Marshal(loginRequest)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", loginRequest, err)
}
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
handler(res, req)
AssertStatusCodes(t, res.Code, http.StatusInternalServerError)
})
}
func AssertStatusCodes(t testing.TB, got, want int) {
t.Helper()
if got != want {
t.Errorf("got status code: %v, want status code: %v", want, got)
}
}
func AssertToken(t testing.TB, res *httptest.ResponseRecorder, want string) {
t.Helper()
got := res.Result().Cookies()[0].Value
if got != want {
t.Errorf("got cookie: %q, want cookie: %q", got, want)
}
}

View File

@ -1,70 +0,0 @@
package auth
import (
"bytes"
"crypto/rand"
"errors"
"golang.org/x/crypto/argon2"
)
type HashSalt struct {
Hash, Salt []byte
}
type Argon2IdHash struct {
time uint32
memory uint32
threads uint8
keyLen uint32
saltLen uint32
}
var (
ErrNoMatch error = errors.New("hash doesn't match")
)
func NewArgon2IdHash(time, saltLen uint32, memory uint32, threads uint8, keyLen uint32) *Argon2IdHash {
return &Argon2IdHash{
time: time,
saltLen: saltLen,
memory: memory,
threads: threads,
keyLen: keyLen,
}
}
func (a *Argon2IdHash) GenerateHash(password, salt []byte) (*HashSalt, error) {
var err error
if len(salt) == 0 {
salt, err = randomSecret(a.saltLen)
}
if err != nil {
return nil, err
}
hash := argon2.IDKey(password, salt, a.time, a.memory, a.threads, a.keyLen)
return &HashSalt{Hash: hash, Salt: salt}, nil
}
func (a *Argon2IdHash) Compare(hash, salt, password []byte) error {
hashSalt, err := a.GenerateHash(password, salt)
if err != nil {
return err
}
if !bytes.Equal(hash, hashSalt.Hash) {
return ErrNoMatch
}
return nil
}
func randomSecret(length uint32) ([]byte, error) {
secret := make([]byte, length)
_, err := rand.Read(secret)
if err != nil {
return nil, err
}
return secret, nil
}

View File

@ -1,90 +0,0 @@
package service
import (
"context"
"errors"
"log/slog"
"time"
"gitea.michaelthomson.dev/mthomson/habits/internal/auth"
userrepository "gitea.michaelthomson.dev/mthomson/habits/internal/user/repository"
"github.com/golang-jwt/jwt/v5"
)
var (
ErrNotFound error = errors.New("user cannot be found")
ErrUnauthorized error = errors.New("user password incorrect")
)
type UserRepository interface {
Create(ctx context.Context, user userrepository.UserRow) (userrepository.UserRow, error)
GetByEmail(ctx context.Context, email string) (userrepository.UserRow, error)
}
type AuthService struct {
logger *slog.Logger
jwtKey []byte
userRepository UserRepository
argon2IdHash *auth.Argon2IdHash
}
func NewAuthService(logger *slog.Logger, jwtKey []byte, userRepository UserRepository, argon2IdHash *auth.Argon2IdHash) *AuthService {
return &AuthService{
logger: logger,
jwtKey: jwtKey,
userRepository: userRepository,
argon2IdHash: argon2IdHash,
}
}
func (a AuthService) Login(ctx context.Context, email string, password string) (string, error) {
// get user if exists
userRow, err := a.userRepository.GetByEmail(ctx, email)
if err != nil {
if err == userrepository.ErrNotFound {
return "", ErrNotFound
}
a.logger.ErrorContext(ctx, err.Error())
return "", err
}
// compare hashed passswords
err = a.argon2IdHash.Compare(userRow.HashedPassword, userRow.Salt, []byte(password))
if err == auth.ErrNoMatch {
return "", ErrUnauthorized
}
if err != nil {
a.logger.ErrorContext(ctx, err.Error())
return "", err
}
// create token and return it
token, err := a.CreateToken(ctx, email)
if err != nil {
a.logger.ErrorContext(ctx, err.Error())
return "", err
}
return token, nil
}
func (a AuthService) CreateToken(ctx context.Context, email string) (string, error) {
// Create a new JWT token with claims
claims := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": email,
"iss": "todo-app",
"aud": "user",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
})
tokenString, err := claims.SignedString(a.jwtKey)
if err != nil {
a.logger.ErrorContext(ctx, err.Error())
}
return tokenString, nil
}

View File

@ -1,81 +0,0 @@
package service_test
import (
"context"
"log/slog"
"testing"
"gitea.michaelthomson.dev/mthomson/habits/internal/auth"
authservice "gitea.michaelthomson.dev/mthomson/habits/internal/auth/service"
"gitea.michaelthomson.dev/mthomson/habits/internal/test"
"gitea.michaelthomson.dev/mthomson/habits/internal/user/repository"
userservice "gitea.michaelthomson.dev/mthomson/habits/internal/user/service"
"github.com/gofrs/uuid/v5"
)
func TestLogin(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slog.Default()
tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
userRepository := repository.NewUserRepository(logger, tdb.Db)
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
userService := userservice.NewUserService(logger, userRepository, argon2IdHash)
authService := authservice.NewAuthService(logger, []byte("secretkey"), userRepository, argon2IdHash)
_, err := userService.Register(ctx, "test@test.com", "supersecurepassword")
AssertNoError(t, err)
t.Run("login existing user with correct credentials", func(t *testing.T) {
want, err := authService.CreateToken(ctx, "test@test.com")
AssertNoError(t, err)
got, err := authService.Login(ctx, "test@test.com", "supersecurepassword")
AssertNoError(t, err)
AssertTokens(t, got, want)
})
t.Run("login existing user with incorrect credentials", func(t *testing.T) {
_, err := authService.Login(ctx, "test@test.com", "superwrongpassword")
AssertErrors(t, err, authservice.ErrUnauthorized)
})
t.Run("login nonexistant user", func(t *testing.T) {
_, err := authService.Login(ctx, "foo@test.com", "supersecurepassword")
AssertErrors(t, err, authservice.ErrNotFound)
})
}
func AssertErrors(t testing.TB, got, want error) {
t.Helper()
if got != want {
t.Errorf("got error: %v, want error: %v", want, got)
}
}
func AssertNoError(t testing.TB, err error) {
t.Helper()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func AssertTokens(t testing.TB, got, want string) {
t.Helper()
if got != want {
t.Errorf("expected matching tokens, got %q, want %q", got, want)
}
}
func NewUUID(t testing.TB) uuid.UUID {
t.Helper()
uuid, err := uuid.NewV4()
if err != nil {
t.Errorf("error generation uuid: %v", err)
}
return uuid
}

View File

@ -1,28 +0,0 @@
package logging
import (
"context"
"log/slog"
"os"
"gitea.michaelthomson.dev/mthomson/habits/internal/middleware"
)
type ContextHandler struct {
slog.Handler
}
func (h *ContextHandler) Handle(ctx context.Context, r slog.Record) error {
if requestID, ok := ctx.Value(middleware.TraceIdKey).(string); ok {
r.AddAttrs(slog.String(string(middleware.TraceIdKey), requestID))
}
return h.Handler.Handle(ctx, r)
}
func NewLogger() *slog.Logger {
baseHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: true})
customHandler := &ContextHandler{Handler: baseHandler}
logger := slog.New(customHandler)
return logger
}

View File

@ -1,60 +0,0 @@
package middleware
import (
"context"
"log/slog"
"net/http"
"github.com/golang-jwt/jwt/v5"
)
const EmailKey contextKey = "email"
func AuthMiddleware(logger *slog.Logger, jwtKey []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("token")
if err == http.ErrNoCookie {
logger.WarnContext(r.Context(), "token not provided")
w.WriteHeader(http.StatusUnauthorized)
return
}
if err != nil {
logger.ErrorContext(r.Context(), err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
tokenString := cookie.Value
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
return jwtKey, nil
})
if !token.Valid {
logger.WarnContext(r.Context(), err.Error())
w.WriteHeader(http.StatusUnauthorized)
return
}
if err != nil {
logger.ErrorContext(r.Context(), err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
email, err := token.Claims.GetSubject()
if err != nil {
logger.ErrorContext(r.Context(), err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
ctx := context.WithValue(r.Context(), EmailKey, email)
newReq := r.WithContext(ctx)
next.ServeHTTP(w, newReq)
})
}
}

View File

@ -1,39 +1,23 @@
package middleware
import (
"context"
"log/slog"
"net/http"
)
type LoggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func NewLoggingResponseWriter(w http.ResponseWriter) *LoggingResponseWriter {
return &LoggingResponseWriter{w, http.StatusOK}
}
func (lrw *LoggingResponseWriter) WriteHeader(code int) {
lrw.statusCode = code
lrw.ResponseWriter.WriteHeader(code)
}
func LoggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.InfoContext(r.Context(), "Incoming request",
logger.LogAttrs(
context.Background(),
slog.LevelInfo,
"Incoming request",
slog.String("method", r.Method),
slog.String("path", r.URL.String()),
)
lrw := NewLoggingResponseWriter(w)
next.ServeHTTP(lrw, r)
logger.InfoContext(r.Context(), "Sent response",
slog.Int("code", lrw.statusCode),
slog.String("message", http.StatusText(lrw.statusCode)),
)
next.ServeHTTP(w, r)
})
}
}

View File

@ -1,26 +0,0 @@
package middleware
import (
"context"
"log/slog"
"net/http"
"github.com/gofrs/uuid/v5"
)
const TraceIdKey contextKey = "trace_id"
func TraceMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
traceid, err := uuid.NewV4()
if err != nil {
logger.ErrorContext(r.Context(), err.Error())
}
ctx := context.WithValue(r.Context(), TraceIdKey, traceid.String())
newReq := r.WithContext(ctx)
next.ServeHTTP(w, newReq)
})
}
}

View File

@ -1,21 +0,0 @@
package middleware
import "net/http"
type Middleware func(http.Handler) http.Handler
type contextKey string
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

@ -1,14 +1,13 @@
package migrate
import (
"context"
"database/sql"
"embed"
"fmt"
"log"
"log/slog"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/mattn/go-sqlite3"
)
//go:embed migrations/*.sql
@ -19,15 +18,15 @@ type Migration struct {
Name string
}
func Migrate(logger *slog.Logger, db *pgxpool.Pool) {
logger.Info("Running migrations...")
func Migrate(db *sql.DB) {
slog.Info("Running migrations...")
migrationTableSql := `
CREATE TABLE IF NOT EXISTS migrations(
version SERIAL PRIMARY KEY,
name VARCHAR(50)
);`
_, err := db.Exec(context.Background(), migrationTableSql)
_, err := db.Exec(migrationTableSql)
if err != nil {
log.Fatal(err)
}
@ -39,25 +38,25 @@ func Migrate(logger *slog.Logger, db *pgxpool.Pool) {
for _, file := range files {
var migration Migration
row := db.QueryRow(context.Background(), "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)
if err == pgx.ErrNoRows {
logger.Info(fmt.Sprintf("Running migration: %s", file.Name()))
if err == sql.ErrNoRows {
slog.Info(fmt.Sprintf("Running migration: %s", file.Name()))
migrationSql, err := migrations.ReadFile(fmt.Sprintf("migrations/%s", file.Name()))
if err != nil {
log.Fatal(err)
}
_, err = db.Exec(context.Background(), string(migrationSql))
_, err = db.Exec(string(migrationSql))
if err != nil {
log.Fatal(err)
}
_, err = db.Exec(context.Background(), "INSERT INTO migrations(name) VALUES($1);", file.Name())
_, err = db.Exec("INSERT INTO migrations(name) VALUES($1);", file.Name())
if err != nil {
log.Fatal(err)
}
}
}
logger.Info("Migrations completed")
slog.Info("Migrations completed")
}

View File

@ -3,10 +3,3 @@ CREATE TABLE todo(
name VARCHAR(50),
done BOOLEAN
);
CREATE TABLE users(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR NOT NULL UNIQUE,
hashed_password bytea NOT NULL,
salt bytea NOT NULL
);

View File

@ -1,73 +0,0 @@
package test
import (
"context"
"log/slog"
"testing"
"time"
"gitea.michaelthomson.dev/mthomson/habits/internal/migrate"
pgxuuid "github.com/jackc/pgx-gofrs-uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
type TestDatabase struct {
Db *pgxpool.Pool
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)
}
dbconfig, err := pgxpool.ParseConfig(connectionString)
if err != nil {
tb.Fatalf("Failed to create db config: %v", err)
}
dbconfig.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
pgxuuid.Register(conn.TypeMap())
return nil
}
// create db pool
db, err := pgxpool.NewWithConfig(context.Background(), dbconfig)
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.container.Terminate(context.Background())
}

View File

@ -3,7 +3,6 @@ package handler
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
@ -28,21 +27,19 @@ func CreateTodoResponseFromTodo(todo service.Todo) CreateTodoResponse {
return CreateTodoResponse{Id: todo.Id, Name: todo.Name, Done: todo.Done}
}
func HandleTodoCreate(logger *slog.Logger, todoService TodoCreater) http.HandlerFunc {
func HandleTodoCreate(todoService TodoCreater) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
createTodoRequest := CreateTodoRequest{}
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&createTodoRequest)
if err != nil {
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusBadRequest)
return
}
todo, err := todoService.CreateTodo(ctx, TodoFromCreateTodoRequest(createTodoRequest))
todo, err := todoService.CreateTodo(TodoFromCreateTodoRequest(createTodoRequest))
if err != nil {
if err == service.ErrNotFound {
@ -50,7 +47,6 @@ func HandleTodoCreate(logger *slog.Logger, todoService TodoCreater) http.Handler
return
}
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusInternalServerError)
return
}
@ -64,7 +60,6 @@ func HandleTodoCreate(logger *slog.Logger, todoService TodoCreater) http.Handler
err = json.NewEncoder(w).Encode(response)
if err != nil {
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusInternalServerError)
return
}

View File

@ -2,9 +2,7 @@ package handler
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"errors"
"net/http"
@ -16,27 +14,26 @@ import (
)
type MockTodoCreater struct {
CreateTodoFunc func(cxt context.Context, todo service.Todo) (service.Todo, error)
CreateTodoFunc func(todo service.Todo) (service.Todo, error)
}
func (tg *MockTodoCreater) CreateTodo(ctx context.Context, todo service.Todo) (service.Todo, error) {
return tg.CreateTodoFunc(ctx, todo)
func (tg *MockTodoCreater) CreateTodo(todo service.Todo) (service.Todo, error) {
return tg.CreateTodoFunc(todo)
}
func TestCreateTodo(t *testing.T) {
logger := slog.Default()
t.Run("create todo", func(t *testing.T) {
createTodoRequest := CreateTodoRequest{Name: "clean dishes", Done: false}
createdTodo := service.Todo{Id: 1, Name: "clean dishes", Done: false}
want := CreateTodoResponse{Id: 1, Name: "clean dishes", Done: false}
service := MockTodoCreater{
CreateTodoFunc: func(ctx context.Context, todo service.Todo) (service.Todo, error) {
CreateTodoFunc: func(todo service.Todo) (service.Todo, error) {
return createdTodo, nil
},
}
handler := HandleTodoCreate(logger, &service)
handler := HandleTodoCreate(&service)
requestBody, err := json.Marshal(createTodoRequest)
@ -70,7 +67,7 @@ func TestCreateTodo(t *testing.T) {
})
t.Run("returns 400 with bad json", func(t *testing.T) {
handler := HandleTodoCreate(logger, nil)
handler := HandleTodoCreate(nil)
badStruct := struct {
Foo string
@ -98,12 +95,12 @@ func TestCreateTodo(t *testing.T) {
createTodoRequest := CreateTodoRequest{Name: "clean dishes", Done: false}
service := MockTodoCreater{
CreateTodoFunc: func(ctx context.Context, todo service.Todo) (service.Todo, error) {
CreateTodoFunc: func(todo service.Todo) (service.Todo, error) {
return service.Todo{}, errors.New("foo bar")
},
}
handler := HandleTodoCreate(logger, &service)
handler := HandleTodoCreate(&service)
requestBody, err := json.Marshal(createTodoRequest)

View File

@ -1,27 +1,24 @@
package handler
import (
"log/slog"
"net/http"
"strconv"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
)
func HandleTodoDelete(logger *slog.Logger, todoService TodoDeleter) http.HandlerFunc {
func HandleTodoDelete(todoService TodoDeleter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
idString := r.PathValue("id")
id, err := strconv.ParseInt(idString, 10, 64)
if err != nil {
slog.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusBadRequest)
return
}
err = todoService.DeleteTodo(ctx, id)
err = todoService.DeleteTodo(id)
if err != nil {
if err == service.ErrNotFound {
@ -29,7 +26,6 @@ func HandleTodoDelete(logger *slog.Logger, todoService TodoDeleter) http.Handler
return
}
slog.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusInternalServerError)
return
}

View File

@ -1,9 +1,7 @@
package handler
import (
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
@ -12,23 +10,22 @@ import (
)
type MockTodoDeleter struct {
DeleteTodoFunc func(ctx context.Context, id int64) error
DeleteTodoFunc func(id int64) error
}
func (tg *MockTodoDeleter) DeleteTodo(ctx context.Context, id int64) error {
return tg.DeleteTodoFunc(ctx, id)
func (tg *MockTodoDeleter) DeleteTodo(id int64) error {
return tg.DeleteTodoFunc(id)
}
func TestDeleteTodo(t *testing.T) {
logger := slog.Default()
t.Run("deletes existing todo", func(t *testing.T) {
service := MockTodoDeleter{
DeleteTodoFunc: func(ctx context.Context, id int64) error {
DeleteTodoFunc: func(id int64) error {
return nil
},
}
handler := HandleTodoDelete(logger, &service)
handler := HandleTodoDelete(&service)
req := httptest.NewRequest(http.MethodDelete, "/todo/1", nil)
res := httptest.NewRecorder()
@ -42,7 +39,7 @@ func TestDeleteTodo(t *testing.T) {
})
t.Run("returns 400 with bad id", func(t *testing.T) {
handler := HandleTodoDelete(logger, nil)
handler := HandleTodoDelete(nil)
req := httptest.NewRequest(http.MethodDelete, "/todo/hello", nil)
res := httptest.NewRecorder()
@ -57,12 +54,12 @@ func TestDeleteTodo(t *testing.T) {
t.Run("returns 404 for not found todo", func(t *testing.T) {
service := MockTodoDeleter{
DeleteTodoFunc: func(ctx context.Context, id int64) error {
DeleteTodoFunc: func(id int64) error {
return service.ErrNotFound
},
}
handler := HandleTodoDelete(logger, &service)
handler := HandleTodoDelete(&service)
req := httptest.NewRequest(http.MethodDelete, "/todo/1", nil)
res := httptest.NewRecorder()
@ -77,12 +74,12 @@ func TestDeleteTodo(t *testing.T) {
t.Run("returns 500 for arbitrary errors", func(t *testing.T) {
service := MockTodoDeleter{
DeleteTodoFunc: func(ctx context.Context, id int64) error {
DeleteTodoFunc: func(id int64) error {
return errors.New("foo bar")
},
}
handler := HandleTodoDelete(logger, &service)
handler := HandleTodoDelete(&service)
req := httptest.NewRequest(http.MethodDelete, "/todo/1", nil)
res := httptest.NewRecorder()

View File

@ -2,7 +2,6 @@ package handler
import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
@ -19,20 +18,18 @@ func GetTodoResponseFromTodo(todo service.Todo) GetTodoResponse {
return GetTodoResponse{Id: todo.Id, Name: todo.Name, Done: todo.Done}
}
func HandleTodoGet(logger *slog.Logger, todoService TodoGetter) http.HandlerFunc {
func HandleTodoGet(todoService TodoGetter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
idString := r.PathValue("id")
id, err := strconv.ParseInt(idString, 10, 64)
if err != nil {
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusBadRequest)
return
}
todo, err := todoService.GetTodo(ctx, id)
todo, err := todoService.GetTodo(id)
if err != nil {
if err == service.ErrNotFound {
@ -40,7 +37,6 @@ func HandleTodoGet(logger *slog.Logger, todoService TodoGetter) http.HandlerFunc
return
}
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusInternalServerError)
return
}
@ -53,7 +49,6 @@ func HandleTodoGet(logger *slog.Logger, todoService TodoGetter) http.HandlerFunc
err = json.NewEncoder(w).Encode(response)
if err != nil {
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusInternalServerError)
return
}

View File

@ -1,11 +1,9 @@
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"reflect"
@ -15,26 +13,25 @@ import (
)
type MockTodoGetter struct {
GetTodoFunc func(ctx context.Context, id int64) (service.Todo, error)
GetTodoFunc func(id int64) (service.Todo, error)
}
func (tg *MockTodoGetter) GetTodo(ctx context.Context, id int64) (service.Todo, error) {
return tg.GetTodoFunc(ctx, id)
func (tg *MockTodoGetter) GetTodo(id int64) (service.Todo, error) {
return tg.GetTodoFunc(id)
}
func TestGetTodo(t *testing.T) {
logger := slog.Default()
t.Run("gets existing todo", func(t *testing.T) {
todo := service.Todo{Id: 1, Name: "clean dishes", Done: false}
wantedTodo := GetTodoResponse{Id: 1, Name: "clean dishes", Done: false}
service := MockTodoGetter{
GetTodoFunc: func(ctx context.Context, id int64) (service.Todo, error) {
GetTodoFunc: func(id int64) (service.Todo, error) {
return todo, nil
},
}
handler := HandleTodoGet(logger, &service)
handler := HandleTodoGet(&service)
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/todo/%d", todo.Id), nil)
res := httptest.NewRecorder()
@ -59,7 +56,7 @@ func TestGetTodo(t *testing.T) {
})
t.Run("returns 400 with bad id", func(t *testing.T) {
handler := HandleTodoGet(logger, nil)
handler := HandleTodoGet(nil)
req := httptest.NewRequest(http.MethodGet, "/todo/hello", nil)
res := httptest.NewRecorder()
@ -74,12 +71,12 @@ func TestGetTodo(t *testing.T) {
t.Run("returns 404 for not found todo", func(t *testing.T) {
service := MockTodoGetter{
GetTodoFunc: func(ctx context.Context, id int64) (service.Todo, error) {
GetTodoFunc: func(id int64) (service.Todo, error) {
return service.Todo{}, service.ErrNotFound
},
}
handler := HandleTodoGet(logger, &service)
handler := HandleTodoGet(&service)
req := httptest.NewRequest(http.MethodGet, "/todo/1", nil)
res := httptest.NewRecorder()
@ -94,12 +91,12 @@ func TestGetTodo(t *testing.T) {
t.Run("returns 500 for arbitrary errors", func(t *testing.T) {
service := MockTodoGetter{
GetTodoFunc: func(ctx context.Context, id int64) (service.Todo, error) {
GetTodoFunc: func(id int64) (service.Todo, error) {
return service.Todo{}, errors.New("foo bar")
},
}
handler := HandleTodoGet(logger, &service)
handler := HandleTodoGet(&service)
req := httptest.NewRequest(http.MethodGet, "/todo/1", nil)
res := httptest.NewRecorder()

View File

@ -1,23 +1,21 @@
package handler
import (
"context"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
)
type TodoGetter interface {
GetTodo(ctx context.Context, id int64) (service.Todo, error)
GetTodo(id int64) (service.Todo, error)
}
type TodoCreater interface {
CreateTodo(ctx context.Context, todo service.Todo) (service.Todo, error)
CreateTodo(todo service.Todo) (service.Todo, error)
}
type TodoDeleter interface {
DeleteTodo(ctx context.Context, id int64) error
DeleteTodo(id int64) error
}
type TodoUpdater interface {
UpdateTodo(ctx context.Context, todo service.Todo) error
UpdateTodo(todo service.Todo) error
}

View File

@ -2,7 +2,6 @@ package handler
import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
@ -18,16 +17,14 @@ func TodoFromUpdateTodoRequest(todo UpdateTodoRequest, id int64) service.Todo {
return service.Todo{Id: id, Name: todo.Name, Done: todo.Done}
}
func HandleTodoUpdate(logger *slog.Logger, todoService TodoUpdater) http.HandlerFunc {
func HandleTodoUpdate(todoService TodoUpdater) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
updateTodoRequest := UpdateTodoRequest{}
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&updateTodoRequest)
if err != nil {
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusBadRequest)
return
}
@ -37,12 +34,11 @@ func HandleTodoUpdate(logger *slog.Logger, todoService TodoUpdater) http.Handler
id, err := strconv.ParseInt(idString, 10, 64)
if err != nil {
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusBadRequest)
return
}
err = todoService.UpdateTodo(ctx, TodoFromUpdateTodoRequest(updateTodoRequest, id))
err = todoService.UpdateTodo(TodoFromUpdateTodoRequest(updateTodoRequest, id))
if err != nil {
if err == service.ErrNotFound {
@ -50,7 +46,6 @@ func HandleTodoUpdate(logger *slog.Logger, todoService TodoUpdater) http.Handler
return
}
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusInternalServerError)
return
}

View File

@ -2,9 +2,7 @@ package handler
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"errors"
"net/http"
@ -15,25 +13,24 @@ import (
)
type MockTodoUpdater struct {
UpdateTodoFunc func(ctx context.Context, todo service.Todo) error
UpdateTodoFunc func(todo service.Todo) error
}
func (tg *MockTodoUpdater) UpdateTodo(ctx context.Context, todo service.Todo) error {
return tg.UpdateTodoFunc(ctx, todo)
func (tg *MockTodoUpdater) UpdateTodo(todo service.Todo) error {
return tg.UpdateTodoFunc(todo)
}
func TestUpdateTodo(t *testing.T) {
logger := slog.Default()
t.Run("update todo", func(t *testing.T) {
updateTodoRequest := UpdateTodoRequest{Name: "clean dishes", Done: false}
service := MockTodoUpdater{
UpdateTodoFunc: func(ctx context.Context, todo service.Todo) error {
UpdateTodoFunc: func(todo service.Todo) error {
return nil
},
}
handler := HandleTodoUpdate(logger, &service)
handler := HandleTodoUpdate(&service)
requestBody, err := json.Marshal(updateTodoRequest)
@ -53,7 +50,7 @@ func TestUpdateTodo(t *testing.T) {
})
t.Run("returns 400 with bad json", func(t *testing.T) {
handler := HandleTodoUpdate(logger, nil)
handler := HandleTodoUpdate(nil)
badStruct := struct {
Foo string
@ -78,7 +75,7 @@ func TestUpdateTodo(t *testing.T) {
})
t.Run("returns 400 with bad id", func(t *testing.T) {
handler := HandleTodoUpdate(logger, nil)
handler := HandleTodoUpdate(nil)
req := httptest.NewRequest(http.MethodPut, "/todo/hello", nil)
res := httptest.NewRecorder()
@ -95,12 +92,12 @@ func TestUpdateTodo(t *testing.T) {
updateTodoRequest := UpdateTodoRequest{Name: "clean dishes", Done: false}
service := MockTodoUpdater{
UpdateTodoFunc: func(ctx context.Context, todo service.Todo) error {
UpdateTodoFunc: func(todo service.Todo) error {
return errors.New("foo bar")
},
}
handler := HandleTodoUpdate(logger, &service)
handler := HandleTodoUpdate(&service)
requestBody, err := json.Marshal(updateTodoRequest)

View File

@ -0,0 +1,59 @@
package inmemory
import (
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
)
type InMemoryTodoRepository struct {
Db map[int64]repository.TodoRow
id int64
}
func NewInMemoryTodoRepository() InMemoryTodoRepository {
return InMemoryTodoRepository{
Db: make(map[int64]repository.TodoRow),
id: 1,
}
}
func (r *InMemoryTodoRepository) GetById(id int64) (repository.TodoRow, error) {
todo, found := r.Db[id]
if !found {
return todo, repository.ErrNotFound
}
return todo, nil
}
func (r *InMemoryTodoRepository) Create(todo repository.TodoRow) (repository.TodoRow, error) {
todo.Id = r.id
r.Db[r.id] = todo
r.id++
return todo, nil
}
func (r *InMemoryTodoRepository) Update(todo repository.TodoRow) error {
_, found := r.Db[todo.Id]
if !found {
return repository.ErrNotFound
}
r.Db[todo.Id] = todo
return nil
}
func (r *InMemoryTodoRepository) Delete(id int64) error {
_, found := r.Db[id]
if !found {
return repository.ErrNotFound
}
delete(r.Db, id)
return nil
}

View File

@ -0,0 +1,84 @@
package postgres
import (
"database/sql"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
)
type PostgresTodoRepository struct {
db *sql.DB
}
func NewPostgresTodoRepository(db *sql.DB) *PostgresTodoRepository {
return &PostgresTodoRepository{
db: db,
}
}
func (r *PostgresTodoRepository) GetById(id int64) (repository.TodoRow, error) {
todo := repository.TodoRow{}
err := r.db.QueryRow("SELECT * FROM todo WHERE id = $1;", id).Scan(&todo.Id, &todo.Name, &todo.Done)
if err != nil {
if err == sql.ErrNoRows {
return todo, repository.ErrNotFound
}
return todo, err
}
return todo, nil
}
func (r *PostgresTodoRepository) Create(todo repository.TodoRow) (repository.TodoRow, error) {
result := r.db.QueryRow("INSERT INTO todo (name, done) VALUES ($1, $2) RETURNING id;", todo.Name, todo.Done)
err := result.Scan(&todo.Id)
if err != nil {
return repository.TodoRow{}, err
}
return todo, nil
}
func (r *PostgresTodoRepository) Update(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)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return repository.ErrNotFound
}
return nil
}
func (r *PostgresTodoRepository) Delete(id int64) error {
result, err := r.db.Exec("DELETE FROM todo WHERE id = $1;", id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return repository.ErrNotFound
}
return nil
}

View File

@ -0,0 +1,126 @@
package postgres
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"gitea.michaelthomson.dev/mthomson/habits/internal/migrate"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
_ "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) {
tdb := NewTestDatabase(t)
defer tdb.TearDown()
r := NewPostgresTodoRepository(tdb.Db)
t.Run("creates new todo", func(t *testing.T) {
want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
newTodo := repository.TodoRow{Name: "clean dishes", Done: false}
got, err := r.Create(newTodo)
AssertNoError(t, err)
AssertTodoRows(t, got, want)
})
t.Run("gets todo", func(t *testing.T) {
want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
got, err := r.GetById(1)
AssertNoError(t, err)
AssertTodoRows(t, got, want)
})
t.Run("updates todo", func(t *testing.T) {
want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: true}
err := r.Update(want)
AssertNoError(t, err)
got, err := r.GetById(1)
AssertNoError(t, err)
AssertTodoRows(t, got, want)
})
t.Run("deletes todo", func(t *testing.T) {
err := r.Delete(1)
AssertNoError(t, err)
want := repository.ErrNotFound
_, got := r.GetById(1)
AssertErrors(t, got, want)
})
}
func AssertErrors(t testing.TB, got, want error) {
t.Helper()
if !errors.Is(got, want) {
t.Errorf("got error: %v, want error: %v", want, got)
}
}
func AssertNoError(t testing.TB, err error) {
t.Helper()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func AssertTodoRows(t testing.TB, got, want repository.TodoRow) {
t.Helper()
if !got.Equal(want) {
t.Errorf("got %+v want %+v", got, want)
}
}

View File

@ -1,16 +1,11 @@
package repository
import (
"context"
"errors"
"log/slog"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrNotFound error = errors.New("todo cannot be found")
ErrNotFound error = errors.New("Todo cannot be found")
)
type TodoRow struct {
@ -26,78 +21,3 @@ func NewTodoRow(name string, done bool) TodoRow {
func (t TodoRow) Equal(todo TodoRow) bool {
return t.Id == todo.Id && t.Name == todo.Name && t.Done == todo.Done
}
type TodoRepository struct {
logger *slog.Logger
db *pgxpool.Pool
}
func NewTodoRepository(logger *slog.Logger, db *pgxpool.Pool) *TodoRepository {
return &TodoRepository{
logger: logger,
db: db,
}
}
func (r *TodoRepository) GetById(ctx context.Context, id int64) (TodoRow, error) {
todo := TodoRow{}
err := r.db.QueryRow(ctx, "SELECT * FROM todo WHERE id = $1;", id).Scan(&todo.Id, &todo.Name, &todo.Done)
if err != nil {
if err == pgx.ErrNoRows {
return todo, ErrNotFound
}
r.logger.ErrorContext(ctx, err.Error())
return todo, err
}
return todo, nil
}
func (r *TodoRepository) Create(ctx context.Context, todo TodoRow) (TodoRow, error) {
result := r.db.QueryRow(ctx, "INSERT INTO todo (name, done) VALUES ($1, $2) RETURNING id;", todo.Name, todo.Done)
err := result.Scan(&todo.Id)
if err != nil {
r.logger.ErrorContext(ctx, err.Error())
return TodoRow{}, err
}
return todo, nil
}
func (r *TodoRepository) Update(ctx context.Context, todo TodoRow) error {
result, err := r.db.Exec(ctx, "UPDATE todo SET name = $1, done = $2 WHERE id = $3;", todo.Name, todo.Done, todo.Id)
if err != nil {
r.logger.ErrorContext(ctx, err.Error())
return err
}
rowsAffected := result.RowsAffected()
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}
func (r *TodoRepository) Delete(ctx context.Context, id int64) error {
result, err := r.db.Exec(ctx, "DELETE FROM todo WHERE id = $1;", id)
if err != nil {
r.logger.ErrorContext(ctx, err.Error())
return err
}
rowsAffected := result.RowsAffected()
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}

View File

@ -1,75 +0,0 @@
package repository_test
import (
"context"
"errors"
"log/slog"
"testing"
"gitea.michaelthomson.dev/mthomson/habits/internal/test"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
)
func TestCRUD(t *testing.T) {
ctx := context.Background()
logger := slog.Default()
tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := repository.NewTodoRepository(logger, tdb.Db)
t.Run("creates new todo", func(t *testing.T) {
want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
newTodo := repository.TodoRow{Name: "clean dishes", Done: false}
got, err := r.Create(ctx, newTodo)
AssertNoError(t, err)
AssertTodoRows(t, got, want)
})
t.Run("gets todo", func(t *testing.T) {
want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
got, err := r.GetById(ctx, 1)
AssertNoError(t, err)
AssertTodoRows(t, got, want)
})
t.Run("updates todo", func(t *testing.T) {
want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: true}
err := r.Update(ctx, want)
AssertNoError(t, err)
got, err := r.GetById(ctx, 1)
AssertNoError(t, err)
AssertTodoRows(t, got, want)
})
t.Run("deletes todo", func(t *testing.T) {
err := r.Delete(ctx, 1)
AssertNoError(t, err)
want := repository.ErrNotFound
_, got := r.GetById(ctx, 1)
AssertErrors(t, got, want)
})
}
func AssertErrors(t testing.TB, got, want error) {
t.Helper()
if !errors.Is(got, want) {
t.Errorf("got error: %v, want error: %v", want, got)
}
}
func AssertNoError(t testing.TB, err error) {
t.Helper()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func AssertTodoRows(t testing.TB, got, want repository.TodoRow) {
t.Helper()
if !got.Equal(want) {
t.Errorf("got %+v want %+v", got, want)
}
}

View File

@ -0,0 +1,92 @@
package sqlite
import (
"database/sql"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
)
type SqliteTodoRepository struct {
db *sql.DB
}
func NewSqliteTodoRepository(db *sql.DB) *SqliteTodoRepository {
return &SqliteTodoRepository{
db: db,
}
}
func (r *SqliteTodoRepository) GetById(id int64) (repository.TodoRow, error) {
todo := repository.TodoRow{}
row := r.db.QueryRow("SELECT * FROM todo WHERE id = ?;", id)
err := row.Scan(&todo.Id, &todo.Name, &todo.Done)
if err != nil {
if err == sql.ErrNoRows {
return todo, repository.ErrNotFound
}
return todo, err
}
return todo, nil
}
func (r *SqliteTodoRepository) Create(todo repository.TodoRow) (repository.TodoRow, error) {
result, err := r.db.Exec("INSERT INTO todo (name, done) VALUES (?, ?)", todo.Name, todo.Done)
if err != nil {
return repository.TodoRow{}, err
}
id, err := result.LastInsertId()
if err != nil {
return repository.TodoRow{}, err
}
todo.Id = id
return todo, nil
}
func (r *SqliteTodoRepository) Update(todo repository.TodoRow) error {
result, err := r.db.Exec("UPDATE todo SET name = ?, done = ? WHERE id = ?", todo.Name, todo.Done, todo.Id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return repository.ErrNotFound
}
return nil
}
func (r *SqliteTodoRepository) Delete(id int64) error {
result, err := r.db.Exec("DELETE FROM todo WHERE id = ?", id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return repository.ErrNotFound
}
return nil
}

View File

@ -1,15 +1,14 @@
package service
import (
"context"
"errors"
"log/slog"
"log"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
)
var (
ErrNotFound error = errors.New("todo cannot be found")
ErrNotFound error = errors.New("Todo cannot be found")
)
type Todo struct {
@ -35,78 +34,65 @@ func (t Todo) Equal(todo Todo) bool {
}
type TodoRepository interface {
Create(ctx context.Context, todo repository.TodoRow) (repository.TodoRow, error)
GetById(ctx context.Context, id int64) (repository.TodoRow, error)
Update(ctx context.Context, todo repository.TodoRow) error
Delete(ctx context.Context, id int64) error
Create(todo repository.TodoRow) (repository.TodoRow, error)
GetById(id int64) (repository.TodoRow, error)
Update(todo repository.TodoRow) error
Delete(id int64) error
}
type TodoService struct {
logger *slog.Logger
repo TodoRepository
}
func NewTodoService(logger *slog.Logger, todoRepo TodoRepository) *TodoService {
return &TodoService{
logger: logger,
repo: todoRepo,
}
func NewTodoService(todoRepo TodoRepository) *TodoService {
return &TodoService{todoRepo}
}
func (s *TodoService) GetTodo(ctx context.Context, id int64) (Todo, error) {
todo, err := s.repo.GetById(ctx, id)
func (s *TodoService) GetTodo(id int64) (Todo, error) {
todo, err := s.repo.GetById(id)
if err != nil {
if err == repository.ErrNotFound {
return Todo{}, ErrNotFound
}
s.logger.ErrorContext(ctx, err.Error())
return Todo{}, err
}
return TodoFromTodoRow(todo), err
}
func (s *TodoService) CreateTodo(ctx context.Context, todo Todo) (Todo, error) {
func (s *TodoService) CreateTodo(todo Todo) (Todo, error) {
todoRow := TodoRowFromTodo(todo)
newTodoRow, err := s.repo.Create(ctx, todoRow)
newTodoRow, err := s.repo.Create(todoRow)
if err != nil {
s.logger.ErrorContext(ctx, err.Error())
log.Print(err)
return Todo{}, err
}
return TodoFromTodoRow(newTodoRow), err
}
func (s *TodoService) DeleteTodo(ctx context.Context, id int64) error {
err := s.repo.Delete(ctx, id)
func (s *TodoService) DeleteTodo(id int64) error {
err := s.repo.Delete(id)
if err == repository.ErrNotFound {
return ErrNotFound
}
if err != nil {
s.logger.ErrorContext(ctx, err.Error())
}
return err
}
func (s *TodoService) UpdateTodo(ctx context.Context, todo Todo) error {
func (s *TodoService) UpdateTodo(todo Todo) error {
todoRow := TodoRowFromTodo(todo)
err := s.repo.Update(ctx, todoRow)
err := s.repo.Update(todoRow)
if err == repository.ErrNotFound {
return ErrNotFound
}
if err != nil {
s.logger.ErrorContext(ctx, err.Error())
}
return err
}

View File

@ -1,110 +1,82 @@
package service_test
import (
"context"
"log/slog"
"testing"
"gitea.michaelthomson.dev/mthomson/habits/internal/test"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository/inmemory"
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
)
func TestCreateTodo(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slog.Default()
tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := repository.NewTodoRepository(logger, tdb.Db)
todoRepository := inmemory.NewInMemoryTodoRepository()
todoService := service.NewTodoService(logger, r)
todoService := service.NewTodoService(&todoRepository)
t.Run("Create todo", func(t *testing.T) {
todo := service.NewTodo("clean dishes", false)
_, err := todoService.CreateTodo(ctx, todo)
_, err := todoService.CreateTodo(todo)
AssertNoError(t, err)
})
}
func TestGetTodo(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slog.Default()
tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := repository.NewTodoRepository(logger, tdb.Db)
todoRepository := inmemory.NewInMemoryTodoRepository()
row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
_, err := r.Create(ctx, row)
AssertNoError(t, err)
todoRepository.Db[1] = repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
todoService := service.NewTodoService(logger, r)
todoService := service.NewTodoService(&todoRepository)
t.Run("Get exisiting todo", func(t *testing.T) {
_, err := todoService.GetTodo(ctx, 1)
_, err := todoService.GetTodo(1)
AssertNoError(t, err)
})
t.Run("Get non-existant todo", func(t *testing.T) {
_, err := todoService.GetTodo(ctx, 2)
_, err := todoService.GetTodo(2)
AssertErrors(t, err, service.ErrNotFound)
})
}
func TestDeleteTodo(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slog.Default()
tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := repository.NewTodoRepository(logger, tdb.Db)
todoRepository := inmemory.NewInMemoryTodoRepository()
row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
_, err := r.Create(ctx, row)
AssertNoError(t, err)
todoRepository.Db[1] = repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
todoService := service.NewTodoService(logger, r)
todoService := service.NewTodoService(&todoRepository)
t.Run("Delete exisiting todo", func(t *testing.T) {
err := todoService.DeleteTodo(ctx, 1)
err := todoService.DeleteTodo(1)
AssertNoError(t, err)
})
t.Run("Delete non-existant todo", func(t *testing.T) {
err := todoService.DeleteTodo(ctx, 1)
err := todoService.DeleteTodo(1)
AssertErrors(t, err, service.ErrNotFound)
})
}
func TestUpdateTodo(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slog.Default()
tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := repository.NewTodoRepository(logger, tdb.Db)
todoRepository := inmemory.NewInMemoryTodoRepository()
row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
_, err := r.Create(ctx, row)
AssertNoError(t, err)
todoRepository.Db[1] = repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
todoService := service.NewTodoService(logger, r)
todoService := service.NewTodoService(&todoRepository)
t.Run("Update exisiting todo", func(t *testing.T) {
todo := service.Todo{1, "clean dishes", true}
err := todoService.UpdateTodo(ctx, todo)
err := todoService.UpdateTodo(todo)
AssertNoError(t, err)
newTodo, err := todoService.GetTodo(ctx, 1)
newTodo, err := todoService.GetTodo(1)
AssertNoError(t, err)
@ -114,7 +86,7 @@ func TestUpdateTodo(t *testing.T) {
t.Run("Update non-existant todo", func(t *testing.T) {
todo := service.Todo{2, "clean dishes", true}
err := todoService.UpdateTodo(ctx, todo)
err := todoService.UpdateTodo(todo)
AssertErrors(t, err, service.ErrNotFound)
})

View File

@ -1,67 +0,0 @@
package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"gitea.michaelthomson.dev/mthomson/habits/internal/user/service"
"github.com/gofrs/uuid/v5"
)
type UserRegisterer interface {
Register(ctx context.Context, email string, password string) (uuid.UUID, error)
}
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type RegisterResponse struct {
Id string `json:"id"`
}
func HandleRegisterUser(logger *slog.Logger, userService UserRegisterer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
registerRequest := RegisterRequest{}
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&registerRequest)
if err != nil {
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusBadRequest)
return
}
uuid, err := userService.Register(ctx, registerRequest.Email, registerRequest.Password)
if err != nil {
if err == service.ErrUserExists {
http.Error(w, "", http.StatusConflict)
return
}
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusInternalServerError)
return
}
response := RegisterResponse{uuid.String()}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(response)
if err != nil {
logger.ErrorContext(ctx, err.Error())
http.Error(w, "", http.StatusInternalServerError)
return
}
}
}

View File

@ -1,148 +0,0 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"errors"
"net/http"
"net/http/httptest"
"testing"
"gitea.michaelthomson.dev/mthomson/habits/internal/user/service"
"github.com/gofrs/uuid/v5"
)
type MockUserRegisterer struct {
RegisterUserFunc func(ctx context.Context, email string, password string) (uuid.UUID, error)
}
func (m *MockUserRegisterer) Register(ctx context.Context, email string, password string) (uuid.UUID, error) {
return m.RegisterUserFunc(ctx, email, password)
}
func TestCreateUser(t *testing.T) {
logger := slog.Default()
t.Run("create user", func(t *testing.T) {
createUserRequest := RegisterRequest{Email: "test@test.com", Password: "password"}
newUUID := NewUUID(t)
service := MockUserRegisterer{
RegisterUserFunc: func(ctx context.Context, email string, password string) (uuid.UUID, error) {
return newUUID, nil
},
}
handler := HandleRegisterUser(logger, &service)
requestBody, err := json.Marshal(createUserRequest)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", createUserRequest, err)
}
req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
handler(res, req)
AssertStatusCodes(t, res.Code, http.StatusOK)
})
t.Run("returns 409 when user exists", func(t *testing.T) {
createUserRequest := RegisterRequest{Email: "test@test.com", Password: "password"}
newUUID := NewUUID(t)
service := MockUserRegisterer{
RegisterUserFunc: func(ctx context.Context, email string, password string) (uuid.UUID, error) {
return newUUID, service.ErrUserExists
},
}
handler := HandleRegisterUser(logger, &service)
requestBody, err := json.Marshal(createUserRequest)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", createUserRequest, err)
}
req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
handler(res, req)
AssertStatusCodes(t, res.Code, http.StatusConflict)
})
t.Run("returns 400 with bad json", func(t *testing.T) {
handler := HandleRegisterUser(logger, nil)
badStruct := struct {
Foo string
}{
Foo: "bar",
}
requestBody, err := json.Marshal(badStruct)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", badStruct, err)
}
req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
handler(res, req)
AssertStatusCodes(t, res.Code, http.StatusBadRequest)
})
t.Run("returns 500 arbitrary errors", func(t *testing.T) {
createUserRequest := RegisterRequest{Email: "test@test.com", Password: "password"}
newUUID := NewUUID(t)
service := MockUserRegisterer{
RegisterUserFunc: func(ctx context.Context, email string, password string) (uuid.UUID, error) {
return newUUID, errors.New("foo bar")
},
}
handler := HandleRegisterUser(logger, &service)
requestBody, err := json.Marshal(createUserRequest)
if err != nil {
t.Fatalf("Failed to marshal request %+v: %v", createUserRequest, err)
}
req := httptest.NewRequest(http.MethodPost, "/user", bytes.NewBuffer(requestBody))
res := httptest.NewRecorder()
handler(res, req)
AssertStatusCodes(t, res.Code, http.StatusInternalServerError)
})
}
func AssertStatusCodes(t testing.TB, got, want int) {
t.Helper()
if got != want {
t.Errorf("got status code: %v, want status code: %v", want, got)
}
}
func NewUUID(t testing.TB) uuid.UUID {
t.Helper()
uuid, err := uuid.NewV4()
if err != nil {
t.Errorf("error generation uuid: %v", err)
}
return uuid
}

View File

@ -1,130 +0,0 @@
package repository
import (
"bytes"
"context"
"errors"
"log/slog"
"github.com/gofrs/uuid/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrNotFound error = errors.New("user cannot be found")
)
type UserRow struct {
Id uuid.UUID
Email string
HashedPassword []byte
Salt []byte
}
func (u UserRow) Equal(user UserRow) bool {
return u.Id == user.Id && u.Email == user.Email && bytes.Equal(u.HashedPassword, user.HashedPassword) && bytes.Equal(u.Salt, user.Salt)
}
type UserRepository struct {
logger *slog.Logger
db *pgxpool.Pool
}
func NewUserRepository(logger *slog.Logger, db *pgxpool.Pool) *UserRepository {
return &UserRepository{
logger: logger,
db: db,
}
}
func (r *UserRepository) GetById(ctx context.Context, id uuid.UUID) (UserRow, error) {
user := UserRow{}
err := r.db.QueryRow(ctx, "SELECT * FROM users WHERE id = $1;", id).Scan(&user.Id, &user.Email, &user.HashedPassword, &user.Salt)
if err != nil {
if err == pgx.ErrNoRows {
return user, ErrNotFound
}
r.logger.ErrorContext(ctx, err.Error())
return user, err
}
return user, nil
}
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (UserRow, error) {
user := UserRow{}
err := r.db.QueryRow(ctx, "SELECT * FROM users WHERE email = $1;", email).Scan(&user.Id, &user.Email, &user.HashedPassword, &user.Salt)
if err != nil {
if err == pgx.ErrNoRows {
return user, ErrNotFound
}
r.logger.ErrorContext(ctx, err.Error())
return user, err
}
return user, nil
}
func (r *UserRepository) Create(ctx context.Context, user UserRow) (UserRow, error) {
var result pgx.Row
if user.Id.IsNil() {
result = r.db.QueryRow(ctx, "INSERT INTO users (email, hashed_password, salt) VALUES ($1, $2, $3) RETURNING id;", user.Email, user.HashedPassword, user.Salt)
err := result.Scan(&user.Id)
if err != nil {
r.logger.ErrorContext(ctx, err.Error())
return UserRow{}, err
}
} else {
_, err := r.db.Exec(ctx, "INSERT INTO users (id, email, hashed_password, salt) VALUES ($1, $2, $3, $4);", user.Id, user.Email, user.HashedPassword, user.Salt)
if err != nil {
r.logger.ErrorContext(ctx, err.Error())
return UserRow{}, err
}
}
return user, nil
}
func (r *UserRepository) Update(ctx context.Context, user UserRow) error {
result, err := r.db.Exec(ctx, "UPDATE users SET email = $1, hashed_password = $2, salt = $3 WHERE id = $4;", user.Email, user.HashedPassword, user.Salt, user.Id)
if err != nil {
r.logger.ErrorContext(ctx, err.Error())
return err
}
rowsAffected := result.RowsAffected()
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}
func (r *UserRepository) Delete(ctx context.Context, id uuid.UUID) error {
result, err := r.db.Exec(ctx, "DELETE FROM users WHERE id = $1;", id)
if err != nil {
r.logger.ErrorContext(ctx, err.Error())
return err
}
rowsAffected := result.RowsAffected()
if rowsAffected == 0 {
return ErrNotFound
}
return nil
}

View File

@ -1,99 +0,0 @@
package repository_test
import (
"context"
"errors"
"log/slog"
"testing"
"gitea.michaelthomson.dev/mthomson/habits/internal/auth"
"gitea.michaelthomson.dev/mthomson/habits/internal/test"
"gitea.michaelthomson.dev/mthomson/habits/internal/user/repository"
"github.com/gofrs/uuid/v5"
)
func TestCRUD(t *testing.T) {
ctx := context.Background()
logger := slog.Default()
tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := repository.NewUserRepository(logger, tdb.Db)
uuid := NewUUID(t)
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
hashSalt, err := argon2IdHash.GenerateHash([]byte("supersecurepassword"), []byte("supersecuresalt"))
if err != nil {
t.Errorf("could not generate hash: %v", err)
}
t.Run("creates new user", func(t *testing.T) {
newUser := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt}
_, err := r.Create(ctx, newUser)
AssertNoError(t, err)
})
t.Run("gets user by id", func(t *testing.T) {
want := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt}
got, err := r.GetById(ctx, uuid)
AssertNoError(t, err)
AssertUserRows(t, got, want)
})
t.Run("gets user by email", func(t *testing.T) {
want := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt}
got, err := r.GetByEmail(ctx, "test@test.com")
AssertNoError(t, err)
AssertUserRows(t, got, want)
})
t.Run("updates user", func(t *testing.T) {
want := repository.UserRow{Id: uuid, Email: "new@test.com", HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt}
err := r.Update(ctx, want)
AssertNoError(t, err)
got, err := r.GetById(ctx, uuid)
AssertNoError(t, err)
AssertUserRows(t, got, want)
})
t.Run("deletes user", func(t *testing.T) {
err := r.Delete(ctx, uuid)
AssertNoError(t, err)
want := repository.ErrNotFound
_, got := r.GetById(ctx, uuid)
AssertErrors(t, got, want)
})
}
func AssertErrors(t testing.TB, got, want error) {
t.Helper()
if !errors.Is(got, want) {
t.Errorf("got error: %v, want error: %v", want, got)
}
}
func AssertNoError(t testing.TB, err error) {
t.Helper()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func AssertUserRows(t testing.TB, got, want repository.UserRow) {
t.Helper()
if !got.Equal(want) {
t.Errorf("got %+v want %+v", got, want)
}
}
func NewUUID(t testing.TB) uuid.UUID {
t.Helper()
uuid, err := uuid.NewV4()
if err != nil {
t.Errorf("error generation uuid: %v", err)
}
return uuid
}

View File

@ -1,185 +0,0 @@
package service
import (
"context"
"errors"
"log/slog"
"gitea.michaelthomson.dev/mthomson/habits/internal/auth"
"gitea.michaelthomson.dev/mthomson/habits/internal/user/repository"
"github.com/gofrs/uuid/v5"
)
var (
ErrNotFound error = errors.New("user cannot be found")
ErrUserExists error = errors.New("user already exists")
)
type User struct {
Id uuid.UUID
Email string
}
func UserFromUserRow(userRow repository.UserRow) User {
return User{Id: userRow.Id, Email: userRow.Email}
}
func (t User) Equal(user User) bool {
return t.Id == user.Id && t.Email == user.Email
}
type UserRepository interface {
Create(ctx context.Context, user repository.UserRow) (repository.UserRow, error)
GetById(ctx context.Context, id uuid.UUID) (repository.UserRow, error)
GetByEmail(ctx context.Context, email string) (repository.UserRow, error)
Update(ctx context.Context, user repository.UserRow) error
Delete(ctx context.Context, id uuid.UUID) error
}
type UserService struct {
logger *slog.Logger
repo UserRepository
argon2IdHash *auth.Argon2IdHash
}
func NewUserService(logger *slog.Logger, userRepo UserRepository, argon2IdHash *auth.Argon2IdHash) *UserService {
return &UserService{
logger: logger,
repo: userRepo,
argon2IdHash: argon2IdHash,
}
}
func (s *UserService) GetUser(ctx context.Context, id uuid.UUID) (User, error) {
user, err := s.repo.GetById(ctx, id)
if err != nil {
if err == repository.ErrNotFound {
return User{}, ErrNotFound
}
s.logger.ErrorContext(ctx, err.Error())
return User{}, err
}
return UserFromUserRow(user), err
}
func (s *UserService) Register(ctx context.Context, email string, password string) (uuid.UUID, error) {
uuid, err := uuid.NewV4()
if err != nil {
s.logger.ErrorContext(ctx, err.Error())
return uuid, err
}
_, err = s.repo.GetByEmail(ctx, email)
if err != repository.ErrNotFound {
return uuid, ErrUserExists
}
hashSalt, err := s.argon2IdHash.GenerateHash([]byte(password), nil)
if err != nil {
s.logger.ErrorContext(ctx, err.Error())
return uuid, err
}
userRow := repository.UserRow{Id: uuid, Email: email, HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt}
_, err = s.repo.Create(ctx, userRow)
if err != nil {
s.logger.ErrorContext(ctx, err.Error())
return uuid, err
}
return uuid, err
}
func (s *UserService) DeleteUser(ctx context.Context, id uuid.UUID) error {
err := s.repo.Delete(ctx, id)
if err == repository.ErrNotFound {
return ErrNotFound
}
if err != nil {
s.logger.ErrorContext(ctx, err.Error())
}
return err
}
func (s *UserService) UpdateUserEmail(ctx context.Context, id uuid.UUID, email string) error {
user, err := s.repo.GetById(ctx, id)
if err == repository.ErrNotFound {
return ErrNotFound
}
if err != nil {
s.logger.ErrorContext(ctx, err.Error())
return err
}
_, err = s.repo.GetByEmail(ctx, email)
switch err {
case repository.ErrNotFound:
user.Email = email
err = s.repo.Update(ctx, user)
if err == repository.ErrNotFound {
return ErrNotFound
}
if err != nil {
s.logger.ErrorContext(ctx, err.Error())
}
return err
case nil:
return ErrUserExists
default:
s.logger.ErrorContext(ctx, err.Error())
return err
}
}
func (s *UserService) UpdateUserPassword(ctx context.Context, id uuid.UUID, password string) error {
user, err := s.repo.GetById(ctx, id)
if err == repository.ErrNotFound {
return ErrNotFound
}
if err != nil {
s.logger.ErrorContext(ctx, err.Error())
return err
}
hashSalt, err := s.argon2IdHash.GenerateHash([]byte(password), nil)
if err != nil {
s.logger.ErrorContext(ctx, err.Error())
return err
}
user.HashedPassword = hashSalt.Hash
user.Salt = hashSalt.Salt
err = s.repo.Update(ctx, user)
if err == repository.ErrNotFound {
return ErrNotFound
}
if err != nil {
s.logger.ErrorContext(ctx, err.Error())
}
return err
}

View File

@ -1,149 +0,0 @@
package service_test
import (
"context"
"log/slog"
"testing"
"gitea.michaelthomson.dev/mthomson/habits/internal/auth"
"gitea.michaelthomson.dev/mthomson/habits/internal/test"
"gitea.michaelthomson.dev/mthomson/habits/internal/user/repository"
"gitea.michaelthomson.dev/mthomson/habits/internal/user/service"
"github.com/gofrs/uuid/v5"
)
func TestRegisterUser(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slog.Default()
tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := repository.NewUserRepository(logger, tdb.Db)
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
userService := service.NewUserService(logger, r, argon2IdHash)
t.Run("Create user", func(t *testing.T) {
_, err := userService.Register(ctx, "test@test.com", "supersecurepassword")
AssertNoError(t, err)
})
}
func TestGetUser(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slog.Default()
tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := repository.NewUserRepository(logger, tdb.Db)
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
userService := service.NewUserService(logger, r, argon2IdHash)
uuid, err := userService.Register(ctx, "test@test.com", "supersecurepassword")
AssertNoError(t, err)
t.Run("Get exisiting user", func(t *testing.T) {
_, err := userService.GetUser(ctx, uuid)
AssertNoError(t, err)
})
t.Run("Get non-existant user", func(t *testing.T) {
_, err := userService.GetUser(ctx, NewUUID(t))
AssertErrors(t, err, service.ErrNotFound)
})
}
func TestDeleteUser(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slog.Default()
tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := repository.NewUserRepository(logger, tdb.Db)
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
userService := service.NewUserService(logger, r, argon2IdHash)
uuid, err := userService.Register(ctx, "test@test.com", "supersecurepassword")
AssertNoError(t, err)
t.Run("Delete exisiting user", func(t *testing.T) {
err := userService.DeleteUser(ctx, uuid)
AssertNoError(t, err)
})
t.Run("Delete non-existant user", func(t *testing.T) {
err := userService.DeleteUser(ctx, uuid)
AssertErrors(t, err, service.ErrNotFound)
})
}
func TestUpdateUserEmail(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := slog.Default()
tdb := test.NewTestDatabase(t)
defer tdb.TearDown()
r := repository.NewUserRepository(logger, tdb.Db)
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
userService := service.NewUserService(logger, r, argon2IdHash)
uuid, err := userService.Register(ctx, "test@test.com", "supersecurepassword")
AssertNoError(t, err)
t.Run("Update existing user email", func(t *testing.T) {
err := userService.UpdateUserEmail(ctx, uuid, "newemail@test.com")
AssertNoError(t, err)
newUser, err := userService.GetUser(ctx, uuid)
AssertNoError(t, err)
if newUser.Email != "newemail@test.com" {
t.Errorf("Emails do not match wanted %q, got %q", "newemail@test.com", newUser.Email)
}
})
t.Run("Update non-existant user", func(t *testing.T) {
err := userService.UpdateUserEmail(ctx, NewUUID(t), "newemail@test.com")
AssertErrors(t, err, service.ErrNotFound)
})
}
func AssertErrors(t testing.TB, got, want error) {
t.Helper()
if got != want {
t.Errorf("got error: %v, want error: %v", want, got)
}
}
func AssertNoError(t testing.TB, err error) {
t.Helper()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func AssertUsers(t testing.TB, got, want service.User) {
if !got.Equal(want) {
t.Errorf("got %+v want %+v", got, want)
}
}
func NewUUID(t testing.TB) uuid.UUID {
t.Helper()
uuid, err := uuid.NewV4()
if err != nil {
t.Errorf("error generation uuid: %v", err)
}
return uuid
}

View File

@ -7,8 +7,5 @@ build:
test:
go test ./...
format:
go fmt ./...
clean:
rm tmp/main habits.db