├── .dockerignore ├── .env.example ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── http │ └── main.go ├── db └── postgres │ ├── migration │ ├── 000001_init.down.sql │ └── 000001_init.up.sql │ └── query │ ├── session.sql │ └── user.sql ├── docker-compose.yml ├── env.json ├── go.mod ├── go.sum ├── internal ├── adapters │ ├── http │ │ ├── httperr │ │ │ ├── encoding.go │ │ │ └── validation.go │ │ ├── httputils │ │ │ └── encoding.go │ │ ├── middleware │ │ │ ├── authentication.go │ │ │ ├── keys.go │ │ │ ├── logger.go │ │ │ └── request_id.go │ │ ├── token │ │ │ ├── paseto.go │ │ │ └── payload.go │ │ └── v1 │ │ │ ├── error_handler.go │ │ │ ├── http.go │ │ │ ├── main_test.go │ │ │ ├── user.go │ │ │ └── user_test.go │ └── pgsqlc │ │ ├── db.go │ │ ├── models.go │ │ ├── querier.go │ │ ├── session.sql.go │ │ └── user.sql.go ├── domain │ ├── domainerr │ │ ├── bcrypt.go │ │ ├── error.go │ │ └── pgsql.go │ ├── ports │ │ └── user_service.go │ └── services │ │ └── user.go └── utils │ ├── config.go │ ├── logger.go │ ├── password.go │ └── random.go ├── scripts ├── http_test.sh └── json │ ├── create_user.json │ ├── login.json │ └── renew_token.json └── sqlc.yaml /.dockerignore: -------------------------------------------------------------------------------- 1 | # .dockerignore 2 | 3 | .data 4 | bin -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ENVIRONMENT=development 2 | 3 | HTTP_SERVER_ADDRESS=0.0.0.0:8080 4 | 5 | POSTGRES_DB=go_boilerplate 6 | POSTGRES_USER=pguser 7 | POSTGRES_PASSWORD=pgpassword 8 | 9 | DB_SOURCE=postgresql://pguser:pgpassword@localhost:5432/go_boilerplate?sslmode=disable 10 | 11 | TOKEN_SYMMETRIC_KEY= 12 | ACCESS_TOKEN_DURATION=15m 13 | REFRESH_TOKEN_DURATION=24h -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # docker 2 | .data 3 | 4 | # env 5 | .env 6 | app.env 7 | scripts/temp/ 8 | 9 | # output 10 | bin/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build Stage 2 | FROM golang:1.22.4-alpine3.19 AS builder 3 | 4 | WORKDIR /app 5 | COPY . . 6 | RUN go build -o bin/api-boilerplate cmd/http/main.go 7 | 8 | # Run Stage 9 | FROM alpine:3.19 10 | 11 | WORKDIR /app 12 | COPY --from=builder /app/bin/api-boilerplate . 13 | 14 | EXPOSE 8080 15 | 16 | ENTRYPOINT [ "/app/api-boilerplate" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Horion Dreher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | go run cmd/http/main.go 3 | 4 | build: 5 | @go build -o bin/go-boilerplate cmd/http/main.go 6 | 7 | test: 8 | @go test -v ./... 9 | 10 | sqlc: 11 | sqlc generate 12 | 13 | migrateup: 14 | migrate -path db/postgres/migration -database "$(DB_SOURCE)" -verbose up 15 | 16 | migrateup1: 17 | migrate -path db/postgres/migration -database "$(DB_SOURCE)" -verbose up 1 18 | 19 | migratedown: 20 | migrate -path db/postgres/migration -database "$(DB_SOURCE)" -verbose down 21 | 22 | migratedown1: 23 | migrate -path db/postgres/migration -database "$(DB_SOURCE)" -verbose down 1 24 | 25 | new_migration: 26 | migrate create -ext sql -dir db/postgres/migration -seq $(name) 27 | 28 | .PHONY: 29 | run build test sqlc migrateup migrateup1 migratedown migratedown1 new_migration -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Go Web API Boilerplate 3 | 4 | Just writing some golang with things that I like to see in a Web API 5 | 6 | ## Features 7 | 8 | - Hexagonal Architecture (kinda overengineering but ok. Also, just wrote like this to see how it goes) 9 | - Simple routing with chi 10 | - Centralized encoding and decoding 11 | - Centralized error handling 12 | - Versioned HTTP Handler 13 | - SQL type safety with SQLC 14 | - Migrations with golang migrate 15 | - PASETO tokens instead of JWT 16 | - Access and Refresh Tokens 17 | - Tests that uses Testcontainers instead of mocks 18 | - Testing scripts that uses cURL and jq (f* Postman) 19 | 20 | ## Required dependencies 21 | 22 | - jq 23 | - golang-migrate 24 | - docker 25 | - sqlc 26 | -------------------------------------------------------------------------------- /cmd/http/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/golang-migrate/migrate/v4" 11 | _ "github.com/golang-migrate/migrate/v4/database/postgres" 12 | _ "github.com/golang-migrate/migrate/v4/source/file" 13 | httpV1 "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/http/v1" 14 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/pgsqlc" 15 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/services" 16 | "github.com/horiondreher/go-web-api-boilerplate/internal/utils" 17 | 18 | "github.com/jackc/pgx/v5/pgxpool" 19 | "github.com/rs/zerolog/log" 20 | ) 21 | 22 | var interruptSignals = []os.Signal{ 23 | os.Interrupt, 24 | syscall.SIGTERM, 25 | syscall.SIGINT, 26 | } 27 | 28 | func main() { 29 | os.Setenv("TZ", "UTC") 30 | 31 | utils.StartLogger() 32 | 33 | // creates a new context with a cancel function that is called when the interrupt signal is received 34 | ctx, stop := signal.NotifyContext(context.Background(), interruptSignals...) 35 | defer stop() 36 | 37 | config := utils.GetConfig() 38 | 39 | runDBMigration(config.MigrationURL, config.DBSource) 40 | 41 | conn, err := pgxpool.New(ctx, config.DBSource) 42 | if err != nil { 43 | log.Err(err).Msg("error connecting to database") 44 | } 45 | 46 | store := pgsqlc.New(conn) 47 | userService := services.NewUserManager(store) 48 | server, err := httpV1.NewHTTPAdapter(userService) 49 | if err != nil { 50 | log.Err(err).Msg("error creating server") 51 | stop() 52 | } 53 | 54 | // starts the server in a goroutine to let the main goroutine listen for the interrupt signal 55 | go func() { 56 | if err := server.Start(); err != nil && err != http.ErrServerClosed { 57 | log.Err(err).Msg("error starting server") 58 | } 59 | }() 60 | 61 | <-ctx.Done() 62 | 63 | // gracefully shutdown the server 64 | server.Shutdown() 65 | 66 | log.Info().Msg("server stopped") 67 | } 68 | 69 | func runDBMigration(migrationURL string, dbSource string) { 70 | migration, err := migrate.New(migrationURL, dbSource) 71 | if err != nil { 72 | log.Fatal().Err(err).Msg("cannot create new migrate instance") 73 | } 74 | 75 | if err = migration.Up(); err != nil && err != migrate.ErrNoChange { 76 | log.Fatal().Err(err).Msg("failed to run migrate up") 77 | } 78 | 79 | log.Info().Msg("db migrated successfully") 80 | } 81 | -------------------------------------------------------------------------------- /db/postgres/migration/000001_init.down.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | -- Drop constraints 4 | ALTER TABLE "session" DROP CONSTRAINT "fk_user_email"; 5 | 6 | -- Drop indexes 7 | DROP INDEX "session_uid_idx"; 8 | DROP INDEX "user_uid_idx"; 9 | DROP INDEX "user_email_idx"; 10 | 11 | -- Drop tables 12 | DROP TABLE "session"; 13 | DROP TABLE "user"; 14 | 15 | COMMIT; -------------------------------------------------------------------------------- /db/postgres/migration/000001_init.up.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 4 | 5 | -- Create tables 6 | CREATE TABLE "user" ( 7 | "id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, 8 | "uid" UUID DEFAULT uuid_generate_v4(), 9 | "email" VARCHAR(255) NOT NULL, 10 | "password" VARCHAR(255) NOT NULL, 11 | "full_name" VARCHAR(255) NOT NULL, 12 | "is_staff" BOOLEAN NOT NULL, 13 | "is_active" BOOLEAN NOT NULL, 14 | "last_login" TIMESTAMPTZ, 15 | "created_at" TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP), 16 | "modified_at" TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP) 17 | ); 18 | 19 | CREATE TABLE "session" ( 20 | "id" BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, 21 | "uid" UUID NOT NULL, 22 | "user_email" VARCHAR(255) NOT NULL, 23 | "refresh_token" VARCHAR NOT NULL, 24 | "user_agent" VARCHAR(255) NOT NULL, 25 | "client_ip" VARCHAR(255) NOT NULL, 26 | "is_blocked" BOOLEAN NOT NULL DEFAULT false, 27 | "expires_at" TIMESTAMPTZ NOT NULL, 28 | "created_at" TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP) 29 | ); 30 | 31 | -- Add indexes 32 | CREATE UNIQUE INDEX "user_email_idx" ON "user" USING BTREE ("email"); 33 | CREATE UNIQUE INDEX "user_uid_idx" ON "user" USING BTREE ("uid"); 34 | CREATE UNIQUE INDEX "session_uid_idx" ON "session" USING BTREE ("uid"); 35 | 36 | -- Add constraints 37 | ALTER TABLE "session" 38 | ADD CONSTRAINT "fk_user_email" FOREIGN KEY ("user_email") REFERENCES "user" ("email") ON DELETE CASCADE; 39 | 40 | COMMIT; -------------------------------------------------------------------------------- /db/postgres/query/session.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateSession :one 2 | INSERT INTO "session" ( 3 | "uid" 4 | , "user_email" 5 | , "refresh_token" 6 | , "user_agent" 7 | , "client_ip" 8 | , "is_blocked" 9 | , "expires_at" 10 | ) 11 | VALUES ( 12 | $1 13 | , $2 14 | , $3 15 | , $4 16 | , $5 17 | , $6 18 | , $7 19 | ) RETURNING *; 20 | 21 | -- name: GetSession :one 22 | SELECT * 23 | FROM "session" 24 | WHERE "uid" = $1 LIMIT 1; -------------------------------------------------------------------------------- /db/postgres/query/user.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateUser :one 2 | INSERT INTO "user" ( 3 | "email" 4 | , "password" 5 | , "full_name" 6 | , "is_staff" 7 | , "is_active" 8 | , "last_login" 9 | ) 10 | VALUES ( 11 | $1 12 | , $2 13 | , $3 14 | , $4 15 | , $5 16 | , $6 17 | ) RETURNING "uid" 18 | , "email" 19 | , "full_name" 20 | , "created_at" 21 | , "modified_at"; 22 | 23 | -- name: GetUser :one 24 | SELECT * 25 | FROM "user" 26 | WHERE "email" = $1 LIMIT 1; 27 | 28 | -- name: GetUserByUID :one 29 | SELECT * 30 | FROM "user" 31 | WHERE "uid" = $1 LIMIT 1; 32 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | container_name: postgres 4 | image: postgres:16.2 5 | restart: always 6 | environment: 7 | - POSTGRES_DB=${POSTGRES_DB} 8 | - POSTGRES_USER=${POSTGRES_USER} 9 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 10 | volumes: 11 | - data-volume:/var/lib/postgresql/data 12 | ports: 13 | - 5432:5432 14 | healthcheck: 15 | test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] 16 | interval: 5s 17 | timeout: 5s 18 | retries: 5 19 | 20 | api: 21 | build: 22 | context: . 23 | dockerfile: Dockerfile 24 | ports: 25 | - 8080:8080 26 | depends_on: 27 | postgres: 28 | condition: service_healthy 29 | volumes: 30 | - ./db/postgres/migration:/app/migration 31 | environment: 32 | - ENVIRONMENT=${ENVIRONMENT} 33 | - HTTP_SERVER_ADDRESS=${HTTP_SERVER_ADDRESS} 34 | - POSTGRES_DB=${POSTGRES_DB} 35 | - POSTGRES_USER=${POSTGRES_USER} 36 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 37 | - DB_SOURCE=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable 38 | - MIGRATION_URL=file://migration 39 | - TOKEN_SYMMETRIC_KEY=${TOKEN_SYMMETRIC_KEY} 40 | - ACCESS_TOKEN_DURATION=${ACCESS_TOKEN_DURATION} 41 | - REFRESH_TOKEN_DURATION=${REFRESH_TOKEN_DURATION} 42 | 43 | volumes: 44 | data-volume: 45 | 46 | -------------------------------------------------------------------------------- /env.json: -------------------------------------------------------------------------------- 1 | { 2 | "ENVIRONMENT": "development", 3 | "HTTP_SERVER_ADDRESS": "0.0.0.0:8080", 4 | 5 | "POSTGRES_DB": "go_boilerplate", 6 | "POSTGRES_USER": "pguser", 7 | "POSTGRES_PASSWORD": "pgpassword", 8 | 9 | "DB_SOURCE": "postgresql://pguser:pgpassword@localhost:5432/go_boilerplate?sslmode=disable", 10 | 11 | "TOKEN_SYMMETRIC_KEY": "EPnY2WPmyW41mPkWiw7i6tcsRlewpdeC", 12 | "ACCESS_TOKEN_DURATION": "15m", 13 | "REFRESH_TOKEN_DURATION": "24h" 14 | } 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/horiondreher/go-web-api-boilerplate 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/aead/chacha20poly1305 v0.0.0-20201124145622-1a5aba2a8b29 7 | github.com/go-chi/chi/v5 v5.0.12 8 | github.com/go-playground/validator/v10 v10.19.0 9 | github.com/golang-migrate/migrate/v4 v4.17.1 10 | github.com/google/uuid v1.6.0 11 | github.com/jackc/pgx/v5 v5.5.4 12 | github.com/knadh/koanf/parsers/dotenv v1.0.0 13 | github.com/knadh/koanf/providers/env v0.1.0 14 | github.com/knadh/koanf/providers/file v0.1.0 15 | github.com/knadh/koanf/v2 v2.1.1 16 | github.com/o1egl/paseto v1.0.0 17 | github.com/rs/zerolog v1.32.0 18 | github.com/stretchr/testify v1.9.0 19 | github.com/testcontainers/testcontainers-go v0.30.0 20 | github.com/testcontainers/testcontainers-go/modules/postgres v0.30.0 21 | golang.org/x/crypto v0.22.0 22 | 23 | ) 24 | 25 | require ( 26 | dario.cat/mergo v1.0.0 // indirect 27 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect 28 | github.com/Microsoft/go-winio v0.6.1 // indirect 29 | github.com/Microsoft/hcsshim v0.11.4 // indirect 30 | github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect 31 | github.com/aead/poly1305 v0.0.0-20180717145839-3fee0db0b635 // indirect 32 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 33 | github.com/containerd/containerd v1.7.12 // indirect 34 | github.com/containerd/log v0.1.0 // indirect 35 | github.com/cpuguy83/dockercfg v0.3.1 // indirect 36 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 37 | github.com/distribution/reference v0.5.0 // indirect 38 | github.com/docker/docker v25.0.5+incompatible // indirect 39 | github.com/docker/go-connections v0.5.0 // indirect 40 | github.com/docker/go-units v0.5.0 // indirect 41 | github.com/felixge/httpsnoop v1.0.4 // indirect 42 | github.com/fsnotify/fsnotify v1.7.0 // indirect 43 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 44 | github.com/go-logr/logr v1.4.1 // indirect 45 | github.com/go-logr/stdr v1.2.2 // indirect 46 | github.com/go-ole/go-ole v1.2.6 // indirect 47 | github.com/go-playground/locales v0.14.1 // indirect 48 | github.com/go-playground/universal-translator v0.18.1 // indirect 49 | github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect 50 | github.com/gogo/protobuf v1.3.2 // indirect 51 | github.com/golang/protobuf v1.5.3 // indirect 52 | github.com/hashicorp/errwrap v1.1.0 // indirect 53 | github.com/hashicorp/go-multierror v1.1.1 // indirect 54 | github.com/jackc/pgpassfile v1.0.0 // indirect 55 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 56 | github.com/jackc/puddle/v2 v2.2.1 // indirect 57 | github.com/joho/godotenv v1.5.1 // indirect 58 | github.com/klauspost/compress v1.17.0 // indirect 59 | github.com/knadh/koanf/maps v0.1.1 // indirect 60 | github.com/kr/pretty v0.3.1 // indirect 61 | github.com/leodido/go-urn v1.4.0 // indirect 62 | github.com/lib/pq v1.10.9 // indirect 63 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 64 | github.com/magiconair/properties v1.8.7 // indirect 65 | github.com/mattn/go-colorable v0.1.13 // indirect 66 | github.com/mattn/go-isatty v0.0.20 // indirect 67 | github.com/mitchellh/copystructure v1.2.0 // indirect 68 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 69 | github.com/moby/patternmatcher v0.6.0 // indirect 70 | github.com/moby/sys/sequential v0.5.0 // indirect 71 | github.com/moby/sys/user v0.1.0 // indirect 72 | github.com/moby/term v0.5.0 // indirect 73 | github.com/morikuni/aec v1.0.0 // indirect 74 | github.com/opencontainers/go-digest v1.0.0 // indirect 75 | github.com/opencontainers/image-spec v1.1.0 // indirect 76 | github.com/pkg/errors v0.9.1 // indirect 77 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 78 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 79 | github.com/shirou/gopsutil/v3 v3.23.12 // indirect 80 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 81 | github.com/sirupsen/logrus v1.9.3 // indirect 82 | github.com/tklauser/go-sysconf v0.3.12 // indirect 83 | github.com/tklauser/numcpus v0.6.1 // indirect 84 | github.com/yusufpapurcu/wmi v1.2.3 // indirect 85 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 86 | go.opentelemetry.io/otel v1.24.0 // indirect 87 | go.opentelemetry.io/otel/metric v1.24.0 // indirect 88 | go.opentelemetry.io/otel/trace v1.24.0 // indirect 89 | go.uber.org/atomic v1.7.0 // indirect 90 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 91 | golang.org/x/mod v0.16.0 // indirect 92 | golang.org/x/net v0.21.0 // indirect 93 | golang.org/x/sync v0.5.0 // indirect 94 | golang.org/x/sys v0.20.0 // indirect 95 | golang.org/x/text v0.14.0 // indirect 96 | golang.org/x/time v0.5.0 // indirect 97 | golang.org/x/tools v0.13.0 // indirect 98 | google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect 99 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect 100 | google.golang.org/grpc v1.59.0 // indirect 101 | google.golang.org/protobuf v1.33.0 // indirect 102 | gopkg.in/yaml.v3 v3.0.1 // indirect 103 | ) 104 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= 4 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= 5 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= 6 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 7 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 8 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 9 | github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8= 10 | github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w= 11 | github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= 12 | github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA= 13 | github.com/aead/chacha20poly1305 v0.0.0-20170617001512-233f39982aeb/go.mod h1:UzH9IX1MMqOcwhoNOIjmTQeAxrFgzs50j4golQtXXxU= 14 | github.com/aead/chacha20poly1305 v0.0.0-20201124145622-1a5aba2a8b29 h1:1DcvRPZOdbQRg5nAHt2jrc5QbV0AGuhDdfQI6gXjiFE= 15 | github.com/aead/chacha20poly1305 v0.0.0-20201124145622-1a5aba2a8b29/go.mod h1:UzH9IX1MMqOcwhoNOIjmTQeAxrFgzs50j4golQtXXxU= 16 | github.com/aead/poly1305 v0.0.0-20180717145839-3fee0db0b635 h1:52m0LGchQBBVqJRyYYufQuIbVqRawmubW3OFGqK1ekw= 17 | github.com/aead/poly1305 v0.0.0-20180717145839-3fee0db0b635/go.mod h1:lmLxL+FV291OopO93Bwf9fQLQeLyt33VJRUg5VJ30us= 18 | github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 19 | github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 20 | github.com/containerd/containerd v1.7.12 h1:+KQsnv4VnzyxWcfO9mlxxELaoztsDEjOuCMPAuPqgU0= 21 | github.com/containerd/containerd v1.7.12/go.mod h1:/5OMpE1p0ylxtEUGY8kuCYkDRzJm9NO1TFMWjUpdevk= 22 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= 23 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 24 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 25 | github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= 26 | github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 27 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 28 | github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= 29 | github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 30 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 31 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 32 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 33 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 34 | github.com/dhui/dktest v0.4.1 h1:/w+IWuDXVymg3IrRJCHHOkMK10m9aNVMOyD0X12YVTg= 35 | github.com/dhui/dktest v0.4.1/go.mod h1:DdOqcUpL7vgyP4GlF3X3w7HbSlz8cEQzwewPveYEQbA= 36 | github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= 37 | github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 38 | github.com/docker/docker v25.0.5+incompatible h1:UmQydMduGkrD5nQde1mecF/YnSbTOaPeFIeP5C4W+DE= 39 | github.com/docker/docker v25.0.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 40 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= 41 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= 42 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 43 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 44 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 45 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 46 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 47 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 48 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 49 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 50 | github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= 51 | github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 52 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 53 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 54 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 55 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 56 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 57 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 58 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 59 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 60 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 61 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 62 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 63 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 64 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 65 | github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4= 66 | github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 67 | github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c= 68 | github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 69 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 70 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 71 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 72 | github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= 73 | github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= 74 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 75 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 76 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 77 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 78 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 79 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 80 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 81 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 82 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 83 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 84 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= 85 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= 86 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 87 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 88 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 89 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 90 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 91 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 92 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 93 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 94 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 95 | github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= 96 | github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= 97 | github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 98 | github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 99 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 100 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 101 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 102 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 103 | github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= 104 | github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= 105 | github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= 106 | github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= 107 | github.com/knadh/koanf/parsers/dotenv v1.0.0 h1:9CBNMQ0qlvEa5ZMjyc58KKROU1c3vN61/lad0kqKpwM= 108 | github.com/knadh/koanf/parsers/dotenv v1.0.0/go.mod h1:fdAFOI98neG5BlLySDhXPXOlbLBZdBjtr1VcBWfubF4= 109 | github.com/knadh/koanf/providers/env v0.1.0 h1:LqKteXqfOWyx5Ab9VfGHmjY9BvRXi+clwyZozgVRiKg= 110 | github.com/knadh/koanf/providers/env v0.1.0/go.mod h1:RE8K9GbACJkeEnkl8L/Qcj8p4ZyPXZIQ191HJi44ZaQ= 111 | github.com/knadh/koanf/providers/file v0.1.0 h1:fs6U7nrV58d3CFAFh8VTde8TM262ObYf3ODrc//Lp+c= 112 | github.com/knadh/koanf/providers/file v0.1.0/go.mod h1:rjJ/nHQl64iYCtAW2QQnF0eSmDEX/YZ/eNFj5yR6BvA= 113 | github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM= 114 | github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es= 115 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 116 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 117 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 118 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 119 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 120 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 121 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 122 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 123 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= 124 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 125 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 126 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 127 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 128 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 129 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 130 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 131 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 132 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 133 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 134 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 135 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 136 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 137 | github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= 138 | github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= 139 | github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= 140 | github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= 141 | github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= 142 | github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= 143 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 144 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 145 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 146 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 147 | github.com/o1egl/paseto v1.0.0 h1:bwpvPu2au176w4IBlhbyUv/S5VPptERIA99Oap5qUd0= 148 | github.com/o1egl/paseto v1.0.0/go.mod h1:5HxsZPmw/3RI2pAwGo1HhOOwSdvBpcuVzO7uDkm+CLU= 149 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 150 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 151 | github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= 152 | github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= 153 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 154 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 155 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 156 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 157 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 158 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 159 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 160 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= 161 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 162 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 163 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 164 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 165 | github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= 166 | github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 167 | github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= 168 | github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= 169 | github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= 170 | github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= 171 | github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= 172 | github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= 173 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 174 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 175 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 176 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 177 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 178 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 179 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 180 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 181 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 182 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 183 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 184 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 185 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 186 | github.com/testcontainers/testcontainers-go v0.30.0 h1:jmn/XS22q4YRrcMwWg0pAwlClzs/abopbsBzrepyc4E= 187 | github.com/testcontainers/testcontainers-go v0.30.0/go.mod h1:K+kHNGiM5zjklKjgTtcrEetF3uhWbMUyqAQoyoh8Pf0= 188 | github.com/testcontainers/testcontainers-go/modules/postgres v0.30.0 h1:D3HFqpZS90iRGAN7M85DFiuhPfvYvFNnx8urQ6mPAvo= 189 | github.com/testcontainers/testcontainers-go/modules/postgres v0.30.0/go.mod h1:e1sKxwUOkqzvaqdHl/oV9mUtFmkDPTfBGp0po2tnWQU= 190 | github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= 191 | github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= 192 | github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= 193 | github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= 194 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 195 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 196 | github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= 197 | github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 198 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= 199 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= 200 | go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= 201 | go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= 202 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= 203 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= 204 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= 205 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= 206 | go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= 207 | go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= 208 | go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= 209 | go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= 210 | go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= 211 | go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= 212 | go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= 213 | go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= 214 | go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= 215 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 216 | golang.org/x/crypto v0.0.0-20181025213731-e84da0312774/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 217 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 218 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 219 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 220 | golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= 221 | golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 222 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 223 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 224 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 225 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 226 | golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= 227 | golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 228 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 229 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 230 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 231 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 232 | golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= 233 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 234 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 235 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 236 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 237 | golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= 238 | golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 239 | golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 240 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 241 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 242 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 243 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 244 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 245 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 246 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 247 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 248 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 249 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 250 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 251 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 252 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 253 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 254 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 255 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 256 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 257 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 258 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 259 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 260 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 261 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 262 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 263 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 264 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 265 | golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= 266 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 267 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 268 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 269 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 270 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 271 | google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo= 272 | google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4= 273 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I= 274 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= 275 | google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= 276 | google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= 277 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 278 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 279 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 280 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 281 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 282 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 283 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 284 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 285 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 286 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 287 | gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= 288 | gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= 289 | -------------------------------------------------------------------------------- /internal/adapters/http/httperr/encoding.go: -------------------------------------------------------------------------------- 1 | package httperr 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | 10 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/domainerr" 11 | ) 12 | 13 | func MatchEncodingError(err error) *domainerr.DomainError { 14 | if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) { 15 | return &domainerr.DomainError{ 16 | HTTPCode: http.StatusBadRequest, 17 | OriginalError: err.Error(), 18 | HTTPErrorBody: domainerr.HTTPErrorBody{ 19 | Code: domainerr.JsonDecodeError, 20 | Errors: "The request body is invalid", 21 | }, 22 | } 23 | } 24 | 25 | jsonErr, ok := err.(*json.UnmarshalTypeError) 26 | if ok { 27 | return transformUnmarshalError(jsonErr) 28 | } 29 | 30 | return domainerr.NewInternalError(err) 31 | } 32 | 33 | func transformUnmarshalError(err *json.UnmarshalTypeError) *domainerr.DomainError { 34 | errors := make(map[string]string) 35 | errors[err.Field] = fmt.Sprintf("The field is invalid. Expected type %v", err.Type) 36 | 37 | return &domainerr.DomainError{ 38 | HTTPCode: http.StatusUnprocessableEntity, 39 | OriginalError: err.Error(), 40 | HTTPErrorBody: domainerr.HTTPErrorBody{ 41 | Code: domainerr.JsonDecodeError, 42 | Errors: errors, 43 | }, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/adapters/http/httperr/validation.go: -------------------------------------------------------------------------------- 1 | package httperr 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-playground/validator/v10" 7 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/domainerr" 8 | ) 9 | 10 | func MatchValidationError(err error) *domainerr.DomainError { 11 | validationErr, ok := err.(validator.ValidationErrors) 12 | if ok { 13 | return TransformValidatorError(validationErr) 14 | } 15 | 16 | return domainerr.NewInternalError(err) 17 | } 18 | 19 | func TransformValidatorError(err validator.ValidationErrors) *domainerr.DomainError { 20 | errors := make(map[string]string) 21 | 22 | for _, e := range err { 23 | errors[e.Field()] = mapValidationTags(e.Tag()) 24 | } 25 | 26 | return &domainerr.DomainError{ 27 | HTTPCode: http.StatusUnprocessableEntity, 28 | OriginalError: err.Error(), 29 | HTTPErrorBody: domainerr.HTTPErrorBody{ 30 | Code: domainerr.ValidationError, 31 | Errors: errors, 32 | }, 33 | } 34 | } 35 | 36 | func mapValidationTags(tag string) string { 37 | var tagMessage string 38 | 39 | switch tag { 40 | case "required": 41 | tagMessage = "The field is required" 42 | case "email": 43 | tagMessage = "The field must be a valid email address" 44 | default: 45 | tagMessage = "The field is invalid" 46 | } 47 | 48 | return tagMessage 49 | } 50 | -------------------------------------------------------------------------------- /internal/adapters/http/httputils/encoding.go: -------------------------------------------------------------------------------- 1 | package httputils 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/http/httperr" 8 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/domainerr" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | func Encode[T any](w http.ResponseWriter, _ *http.Request, status int, v T) *domainerr.DomainError { 13 | w.Header().Set("Content-Type", "application/json") 14 | w.WriteHeader(status) 15 | 16 | if err := json.NewEncoder(w).Encode(v); err != nil { 17 | log.Err(err).Msg("error encoding json") 18 | return httperr.MatchEncodingError(err) 19 | } 20 | 21 | return nil 22 | } 23 | 24 | func Decode[T any](r *http.Request) (T, *domainerr.DomainError) { 25 | var v T 26 | 27 | if err := json.NewDecoder(r.Body).Decode(&v); err != nil { 28 | log.Err(err).Msg("error decoding JSON") 29 | return v, httperr.MatchEncodingError(err) 30 | } 31 | 32 | return v, nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/adapters/http/middleware/authentication.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/http/httputils" 10 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/http/token" 11 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/domainerr" 12 | "github.com/rs/zerolog/log" 13 | ) 14 | 15 | const ( 16 | bearerAuth = "bearer" 17 | ) 18 | 19 | func Authentication(tokenMaker *token.PasetoMaker) func(next http.Handler) http.Handler { 20 | if tokenMaker == nil { 21 | fmt.Println("PasetoMaker is not initialized") 22 | } 23 | 24 | return func(next http.Handler) http.Handler { 25 | fn := func(w http.ResponseWriter, r *http.Request) { 26 | auth := r.Header.Get("Authorization") 27 | 28 | requestID := GetRequestID(r.Context()) 29 | 30 | if len(auth) == 0 { 31 | log.Info().Str("id", requestID).Str("error message", "empty authorization header").Msg("request error") 32 | _ = httputils.Encode(w, r, http.StatusUnauthorized, domainerr.HTTPErrorBody{ 33 | Code: domainerr.UnauthorizedError, 34 | Errors: "Empty Authorization Header", 35 | }) 36 | return 37 | } 38 | 39 | fields := strings.Fields(auth) 40 | 41 | if len(fields) < 2 { 42 | log.Info().Str("id", requestID).Str("error message", "invalid authorization header").Msg("request error") 43 | _ = httputils.Encode(w, r, http.StatusUnauthorized, domainerr.HTTPErrorBody{ 44 | Code: domainerr.UnauthorizedError, 45 | Errors: "Invalid Authorization Header", 46 | }) 47 | return 48 | } 49 | 50 | authorizationType := strings.ToLower(fields[0]) 51 | if authorizationType != bearerAuth { 52 | log.Info().Str("id", requestID).Str("error message", "invalid authorization type").Msg("request error") 53 | _ = httputils.Encode(w, r, http.StatusUnauthorized, domainerr.HTTPErrorBody{ 54 | Code: domainerr.UnauthorizedError, 55 | Errors: "Invalid Authorization Type", 56 | }) 57 | return 58 | } 59 | 60 | accessToken := fields[1] 61 | fmt.Println(accessToken) 62 | payload, err := tokenMaker.VerifyToken(accessToken) 63 | if err != nil { 64 | log.Info().Str("id", requestID).Str("error message", err.Error()).Msg("request error") 65 | _ = httputils.Encode(w, r, http.StatusUnauthorized, err.HTTPErrorBody) 66 | return 67 | } 68 | 69 | ctx := r.Context() 70 | ctx = context.WithValue(ctx, KeyAuthUser, payload) 71 | 72 | next.ServeHTTP(w, r.WithContext(ctx)) 73 | } 74 | 75 | return http.HandlerFunc(fn) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /internal/adapters/http/middleware/keys.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | type key int 4 | 5 | const ( 6 | KeyRequestID key = iota 7 | KeyAuthUser 8 | ) 9 | -------------------------------------------------------------------------------- /internal/adapters/http/middleware/logger.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | type responseWriter struct { 10 | http.ResponseWriter 11 | statusCode int 12 | } 13 | 14 | func (rw *responseWriter) WriteHeader(statusCode int) { 15 | rw.statusCode = statusCode 16 | rw.ResponseWriter.WriteHeader(statusCode) 17 | } 18 | 19 | func NewResponseWriter(w http.ResponseWriter) *responseWriter { 20 | return &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} 21 | } 22 | 23 | func Logger(next http.Handler) http.Handler { 24 | fn := func(w http.ResponseWriter, r *http.Request) { 25 | requestID := r.Context().Value(KeyRequestID).(string) 26 | 27 | log.Info().Str("id", requestID).Str("method", r.Method).Str("path", r.URL.Path).Msg("request received") 28 | 29 | customWriter := NewResponseWriter(w) 30 | next.ServeHTTP(customWriter, r) 31 | 32 | log.Info().Str("id", requestID).Str("method", r.Method).Str("path", r.URL.Path).Int("response", customWriter.statusCode).Msg("request response") 33 | } 34 | 35 | return http.HandlerFunc(fn) 36 | } 37 | -------------------------------------------------------------------------------- /internal/adapters/http/middleware/request_id.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func RequestID(next http.Handler) http.Handler { 11 | fn := func(w http.ResponseWriter, r *http.Request) { 12 | requestID := uuid.New().String() 13 | 14 | ctx := r.Context() 15 | ctx = context.WithValue(ctx, KeyRequestID, requestID) 16 | 17 | next.ServeHTTP(w, r.WithContext(ctx)) 18 | } 19 | 20 | return http.HandlerFunc(fn) 21 | } 22 | 23 | func GetRequestID(httpCtx context.Context) (requestID string) { 24 | if requestIdVal, ok := httpCtx.Value(KeyRequestID).(string); ok { 25 | requestID = requestIdVal 26 | } 27 | 28 | return 29 | } 30 | -------------------------------------------------------------------------------- /internal/adapters/http/token/paseto.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/aead/chacha20poly1305" 9 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/domainerr" 10 | "github.com/o1egl/paseto" 11 | ) 12 | 13 | type PasetoMaker struct { 14 | paseto *paseto.V2 15 | symmetricKey []byte 16 | } 17 | 18 | func NewPasetoMaker(symmetricKey string) (*PasetoMaker, error) { 19 | if len(symmetricKey) != chacha20poly1305.KeySize { 20 | return nil, fmt.Errorf("invalid key size: must be exactly %d characters", chacha20poly1305.KeySize) 21 | } 22 | 23 | maker := &PasetoMaker{ 24 | paseto: paseto.NewV2(), 25 | symmetricKey: []byte(symmetricKey), 26 | } 27 | 28 | return maker, nil 29 | } 30 | 31 | func (maker *PasetoMaker) CreateToken(email string, role string, duration time.Duration) (string, *Payload, *domainerr.DomainError) { 32 | payload, payloadErr := NewPayload(email, role, duration) 33 | if payloadErr != nil { 34 | return "", payload, payloadErr 35 | } 36 | 37 | token, err := maker.paseto.Encrypt(maker.symmetricKey, payload, nil) 38 | if err != nil { 39 | return token, nil, domainerr.NewInternalError(err) 40 | } 41 | 42 | return token, payload, nil 43 | } 44 | 45 | func (maker *PasetoMaker) VerifyToken(token string) (*Payload, *domainerr.DomainError) { 46 | payload := &Payload{} 47 | 48 | if maker == nil || maker.paseto == nil { 49 | return nil, domainerr.NewDomainError(http.StatusInternalServerError, domainerr.UnexpectedError, "internal server error", ErrInvalidInstance) 50 | } 51 | 52 | err := maker.paseto.Decrypt(token, maker.symmetricKey, payload, nil) 53 | if err != nil { 54 | return nil, domainerr.NewDomainError(http.StatusUnauthorized, domainerr.UnauthorizedError, "invalid token", ErrInvalidToken) 55 | } 56 | 57 | validationErr := payload.Valid() 58 | if validationErr != nil { 59 | return nil, validationErr 60 | } 61 | 62 | return payload, nil 63 | } 64 | -------------------------------------------------------------------------------- /internal/adapters/http/token/payload.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/domainerr" 10 | ) 11 | 12 | var ( 13 | ErrInvalidToken = errors.New("token is invalid") 14 | ErrExpiredToken = errors.New("token has expired") 15 | ErrInvalidInstance = errors.New("paseto maker is not initialized") 16 | ) 17 | 18 | type Payload struct { 19 | ID uuid.UUID `json:"id"` 20 | Email string `json:"email"` 21 | Role string `json:"role"` 22 | IssuedAt time.Time `json:"issued_at"` 23 | ExpiredAt time.Time `json:"expired_at"` 24 | } 25 | 26 | func NewPayload(email string, role string, duration time.Duration) (*Payload, *domainerr.DomainError) { 27 | tokenID, err := uuid.NewRandom() 28 | if err != nil { 29 | return nil, domainerr.NewDomainError(http.StatusInternalServerError, domainerr.UnexpectedError, err.Error(), err) 30 | } 31 | 32 | payload := &Payload{ 33 | ID: tokenID, 34 | Email: email, 35 | Role: role, 36 | IssuedAt: time.Now(), 37 | ExpiredAt: time.Now().Add(duration), 38 | } 39 | 40 | return payload, nil 41 | } 42 | 43 | func (payload *Payload) Valid() *domainerr.DomainError { 44 | if time.Now().After(payload.ExpiredAt) { 45 | return domainerr.NewDomainError(http.StatusUnauthorized, domainerr.ExpiredToken, "Expired Token", ErrExpiredToken) 46 | } 47 | 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/adapters/http/v1/error_handler.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/http/httputils" 7 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/domainerr" 8 | ) 9 | 10 | func notFoundResponse(w http.ResponseWriter, r *http.Request) { 11 | httpError := domainerr.HTTPErrorBody{ 12 | Code: domainerr.NotFoundError, 13 | Errors: "The requested resource was not found", 14 | } 15 | 16 | _ = httputils.Encode(w, r, http.StatusNotFound, httpError) 17 | } 18 | 19 | func methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) { 20 | httpError := domainerr.HTTPErrorBody{ 21 | Code: domainerr.MehodNotAllowedError, 22 | Errors: "The request method is not allowed", 23 | } 24 | 25 | _ = httputils.Encode(w, r, http.StatusMethodNotAllowed, httpError) 26 | } 27 | -------------------------------------------------------------------------------- /internal/adapters/http/v1/http.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "reflect" 8 | "strings" 9 | "time" 10 | 11 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/http/httputils" 12 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/http/middleware" 13 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/http/token" 14 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/domainerr" 15 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/ports" 16 | "github.com/horiondreher/go-web-api-boilerplate/internal/utils" 17 | 18 | "github.com/go-chi/chi/v5" 19 | chiMiddleware "github.com/go-chi/chi/v5/middleware" 20 | "github.com/go-playground/validator/v10" 21 | "github.com/rs/zerolog/log" 22 | ) 23 | 24 | var validate *validator.Validate 25 | 26 | func setupValidator() { 27 | validate = validator.New(validator.WithRequiredStructEnabled()) 28 | validate.RegisterTagNameFunc(func(fld reflect.StructField) string { 29 | name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] 30 | 31 | if name == "-" { 32 | return "" 33 | } 34 | return name 35 | }) 36 | } 37 | 38 | type HTTPAdapter struct { 39 | userService ports.UserService 40 | 41 | config *utils.Config 42 | router *chi.Mux 43 | server *http.Server 44 | 45 | tokenMaker *token.PasetoMaker 46 | } 47 | 48 | func NewHTTPAdapter(userService ports.UserService) (*HTTPAdapter, error) { 49 | httpAdapter := &HTTPAdapter{ 50 | userService: userService, 51 | config: utils.GetConfig(), 52 | } 53 | 54 | setupValidator() 55 | 56 | err := httpAdapter.setupTokenMaker() 57 | if err != nil { 58 | log.Err(err).Msg("error setting up server") 59 | return nil, err 60 | } 61 | 62 | httpAdapter.setupRouter() 63 | httpAdapter.setupServer() 64 | 65 | return httpAdapter, nil 66 | } 67 | 68 | func (adapter *HTTPAdapter) Start() error { 69 | log.Info().Str("address", adapter.server.Addr).Msg("starting server") 70 | 71 | _ = chi.Walk(adapter.router, adapter.printRoutes) 72 | 73 | return adapter.server.ListenAndServe() 74 | } 75 | 76 | func (adapter *HTTPAdapter) Shutdown() { 77 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 78 | defer cancel() 79 | 80 | if err := adapter.server.Shutdown(ctx); err != nil { 81 | log.Err(err).Msg("error shutting down server") 82 | } 83 | } 84 | 85 | func (adapter *HTTPAdapter) setupRouter() { 86 | router := chi.NewRouter() 87 | 88 | router.Use(chiMiddleware.Recoverer) 89 | router.Use(chiMiddleware.RedirectSlashes) 90 | 91 | router.NotFound(notFoundResponse) 92 | router.MethodNotAllowed(methodNotAllowedResponse) 93 | 94 | v1Router := chi.NewRouter() 95 | v1Router.Use(middleware.RequestID) 96 | v1Router.Use(middleware.Logger) 97 | 98 | v1Router.Post("/users", adapter.handlerWrapper(adapter.createUser)) 99 | v1Router.Post("/login", adapter.handlerWrapper(adapter.loginUser)) 100 | v1Router.Post("/renew-token", adapter.handlerWrapper(adapter.renewAccessToken)) 101 | 102 | // private routes 103 | v1Router.Group(func(r chi.Router) { 104 | r.Use(middleware.Authentication(adapter.tokenMaker)) 105 | r.Get("/user/{uid}", adapter.handlerWrapper(adapter.getUserByUID)) 106 | }) 107 | 108 | router.Mount("/api/v1", v1Router) 109 | 110 | adapter.router = router 111 | } 112 | 113 | type HandlerWrapper func(w http.ResponseWriter, r *http.Request) *domainerr.DomainError 114 | 115 | func (adapter *HTTPAdapter) handlerWrapper(handlerFn HandlerWrapper) http.HandlerFunc { 116 | return func(w http.ResponseWriter, r *http.Request) { 117 | if apiErr := handlerFn(w, r); apiErr != nil { 118 | var httpErrIntf *domainerr.DomainError 119 | var err *domainerr.DomainError 120 | 121 | requestID := middleware.GetRequestID(r.Context()) 122 | 123 | if errors.As(apiErr, &httpErrIntf) { 124 | log.Info().Str("id", requestID).Str("error message", httpErrIntf.OriginalError).Msg("request error") 125 | err = httputils.Encode(w, r, httpErrIntf.HTTPCode, httpErrIntf.HTTPErrorBody) 126 | 127 | } else { 128 | http.Error(w, "Internal server error", http.StatusInternalServerError) 129 | } 130 | 131 | if err != nil { 132 | log.Err(err).Msg("error encoding response") 133 | } 134 | } 135 | } 136 | } 137 | 138 | func (adapter *HTTPAdapter) printRoutes(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { 139 | log.Info().Str("method", method).Str("route", route).Msg("route registered") 140 | return nil 141 | } 142 | 143 | func (adapter *HTTPAdapter) setupTokenMaker() error { 144 | tokenMaker, err := token.NewPasetoMaker(adapter.config.TokenSymmetricKey) 145 | if err != nil { 146 | return err 147 | } 148 | 149 | adapter.tokenMaker = tokenMaker 150 | 151 | return nil 152 | } 153 | 154 | func (adapter *HTTPAdapter) setupServer() { 155 | server := &http.Server{ 156 | Addr: adapter.config.HTTPServerAddress, 157 | Handler: adapter.router, 158 | } 159 | 160 | adapter.server = server 161 | } 162 | -------------------------------------------------------------------------------- /internal/adapters/http/v1/main_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | 11 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/pgsqlc" 12 | service "github.com/horiondreher/go-web-api-boilerplate/internal/domain/services" 13 | "github.com/horiondreher/go-web-api-boilerplate/internal/utils" 14 | 15 | "github.com/testcontainers/testcontainers-go" 16 | "github.com/testcontainers/testcontainers-go/modules/postgres" 17 | "github.com/testcontainers/testcontainers-go/wait" 18 | 19 | "github.com/jackc/pgx/v5/pgxpool" 20 | ) 21 | 22 | var testUserService *service.UserManager 23 | 24 | func TestMain(m *testing.M) { 25 | ctx := context.Background() 26 | 27 | utils.SetConfigFile("../../../../.env") 28 | config := utils.GetConfig() 29 | 30 | migrationsPath := filepath.Join("..", "..", "..", "..", "db", "postgres", "migration", "*.up.sql") 31 | upMigrations, err := filepath.Glob(migrationsPath) 32 | if err != nil { 33 | log.Fatalf("cannot find up migrations: %v", err) 34 | } 35 | 36 | pgContainer, err := postgres.RunContainer(ctx, 37 | testcontainers.WithImage("postgres:16.2"), 38 | postgres.WithInitScripts(upMigrations...), 39 | postgres.WithDatabase(config.DBName), 40 | postgres.WithUsername(config.DBUser), 41 | postgres.WithPassword(config.DBPassword), 42 | testcontainers.WithWaitStrategy( 43 | wait.ForLog("database system is ready to accept connections"). 44 | WithOccurrence(2).WithStartupTimeout(5*time.Second), 45 | ), 46 | ) 47 | if err != nil { 48 | log.Fatalf("cannot start postgres container: %v", err) 49 | } 50 | 51 | connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable") 52 | if err != nil { 53 | log.Fatalf("cannot get connection string: %v", err) 54 | } 55 | 56 | conn, err := pgxpool.New(ctx, connStr) 57 | if err != nil { 58 | log.Fatalf("cannot connect to database: %v", err) 59 | } 60 | 61 | testStore := pgsqlc.New(conn) 62 | testUserService = service.NewUserManager(testStore) 63 | 64 | os.Exit(m.Run()) 65 | } 66 | -------------------------------------------------------------------------------- /internal/adapters/http/v1/user.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/go-chi/chi/v5" 10 | "github.com/google/uuid" 11 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/http/httperr" 12 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/http/httputils" 13 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/http/middleware" 14 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/http/token" 15 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/domainerr" 16 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/ports" 17 | "github.com/rs/zerolog/log" 18 | ) 19 | 20 | type CreateUserRequestDto struct { 21 | FullName string `json:"full_name" validate:"required"` 22 | Email string `json:"email" validate:"required,email"` 23 | Password string `json:"password" validate:"required"` 24 | } 25 | 26 | type CreateUserResponseDto struct { 27 | UID uuid.UUID `json:"uid"` 28 | FullName string `json:"full_name"` 29 | Email string `json:"email"` 30 | } 31 | 32 | func (adapter *HTTPAdapter) createUser(w http.ResponseWriter, r *http.Request) *domainerr.DomainError { 33 | reqUser, err := httputils.Decode[CreateUserRequestDto](r) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | validationErr := validate.Struct(reqUser) 39 | if validationErr != nil { 40 | return httperr.MatchValidationError(validationErr) 41 | } 42 | 43 | createdUser, err := adapter.userService.CreateUser(r.Context(), ports.NewUser{ 44 | FullName: reqUser.FullName, 45 | Email: reqUser.Email, 46 | Password: reqUser.Password, 47 | }) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | log.Info().Msg("AAAAAAAAAAAA") 53 | 54 | err = httputils.Encode(w, r, http.StatusCreated, CreateUserResponseDto{ 55 | UID: createdUser.UID, 56 | FullName: createdUser.FullName, 57 | Email: createdUser.Email, 58 | }) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return nil 64 | } 65 | 66 | type LoginUserRequestDto struct { 67 | Email string `json:"email" validate:"required,email"` 68 | Password string `json:"password" validate:"required"` 69 | } 70 | 71 | type LoginUserResponseDto struct { 72 | Email string `json:"email"` 73 | AccessToken string `json:"access_token"` 74 | RefreshToken string `json:"refresh_token"` 75 | AccessTokenExpiresAt time.Time `json:"access_token_expires_at"` 76 | RefreshTokenExpiresAt time.Time `json:"refresh_token_expires_at"` 77 | } 78 | 79 | func (adapter *HTTPAdapter) loginUser(w http.ResponseWriter, r *http.Request) *domainerr.DomainError { 80 | reqUser, err := httputils.Decode[LoginUserRequestDto](r) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | validationErr := validate.Struct(reqUser) 86 | if validationErr != nil { 87 | return httperr.MatchValidationError(validationErr) 88 | } 89 | 90 | user, err := adapter.userService.LoginUser(r.Context(), ports.LoginUser{ 91 | Email: reqUser.Email, 92 | Password: reqUser.Password, 93 | }) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | accessToken, accessPayload, err := adapter.tokenMaker.CreateToken(user.Email, "user", adapter.config.AccessTokenDuration) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | refreshToken, refreshPayload, err := adapter.tokenMaker.CreateToken(user.Email, "user", adapter.config.RefreshTokenDuration) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | loginRes := LoginUserResponseDto{ 109 | Email: user.Email, 110 | AccessToken: accessToken, 111 | AccessTokenExpiresAt: accessPayload.ExpiredAt, 112 | RefreshToken: refreshToken, 113 | RefreshTokenExpiresAt: refreshPayload.ExpiredAt, 114 | } 115 | 116 | _, err = adapter.userService.CreateUserSession(r.Context(), ports.NewUserSession{ 117 | RefreshTokenID: refreshPayload.ID, 118 | Email: loginRes.Email, 119 | RefreshToken: loginRes.RefreshToken, 120 | RefreshTokenExpiresAt: loginRes.RefreshTokenExpiresAt, 121 | UserAgent: r.UserAgent(), 122 | ClientIP: r.RemoteAddr, 123 | }) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | err = httputils.Encode(w, r, http.StatusOK, loginRes) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | return nil 134 | } 135 | 136 | type RenewAccessTokenRequestDto struct { 137 | RefreshToken string `json:"refresh_token" validate:"required"` 138 | } 139 | 140 | type RenewAccessTokenResponseDto struct { 141 | AccessToken string `json:"access_token"` 142 | AccessTokenExpiresAt time.Time `json:"access_token_expires_at"` 143 | } 144 | 145 | func (adapter *HTTPAdapter) renewAccessToken(w http.ResponseWriter, r *http.Request) *domainerr.DomainError { 146 | renewAccessDto, err := httputils.Decode[RenewAccessTokenRequestDto](r) 147 | if err != nil { 148 | return err 149 | } 150 | 151 | validationErr := validate.Struct(renewAccessDto) 152 | if validationErr != nil { 153 | return httperr.MatchValidationError(validationErr) 154 | } 155 | 156 | refreshPayload, err := adapter.tokenMaker.VerifyToken(renewAccessDto.RefreshToken) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | session, err := adapter.userService.GetUserSession(r.Context(), refreshPayload.ID) 162 | if err != nil { 163 | return err 164 | } 165 | 166 | if session.IsBlocked { 167 | return domainerr.NewDomainError(http.StatusUnauthorized, domainerr.UnauthorizedError, "session is blocked", errors.New("session is blocked")) 168 | } 169 | 170 | if session.UserEmail != refreshPayload.Email { 171 | return domainerr.NewDomainError(http.StatusUnauthorized, domainerr.UnauthorizedError, "invalid session user", errors.New("invalid session user")) 172 | } 173 | 174 | if session.RefreshToken != renewAccessDto.RefreshToken { 175 | return domainerr.NewDomainError(http.StatusUnauthorized, domainerr.UnauthorizedError, "invalid refresh token", errors.New("invalid refresh token")) 176 | } 177 | 178 | if time.Now().After(session.ExpiresAt) { 179 | return domainerr.NewDomainError(http.StatusUnauthorized, domainerr.UnauthorizedError, "session expired", errors.New("session expired")) 180 | } 181 | 182 | accessToken, accessPayload, err := adapter.tokenMaker.CreateToken(session.UserEmail, "user", adapter.config.AccessTokenDuration) 183 | if err != nil { 184 | return err 185 | } 186 | 187 | renewAccessTokenResponse := RenewAccessTokenResponseDto{ 188 | AccessToken: accessToken, 189 | AccessTokenExpiresAt: accessPayload.ExpiredAt, 190 | } 191 | 192 | err = httputils.Encode(w, r, http.StatusOK, renewAccessTokenResponse) 193 | if err != nil { 194 | return err 195 | } 196 | 197 | return nil 198 | } 199 | 200 | func (adapter *HTTPAdapter) getUserByUID(w http.ResponseWriter, r *http.Request) *domainerr.DomainError { 201 | payload := r.Context().Value(middleware.KeyAuthUser).(*token.Payload) 202 | requestID := middleware.GetRequestID(r.Context()) 203 | 204 | fmt.Println(payload) 205 | fmt.Println(requestID) 206 | 207 | userID := chi.URLParam(r, "uid") 208 | 209 | user, serviceErr := adapter.userService.GetUserByUID(r.Context(), userID) 210 | if serviceErr != nil { 211 | return serviceErr 212 | } 213 | 214 | err := httputils.Encode(w, r, http.StatusOK, CreateUserResponseDto{ 215 | UID: user.UID, 216 | Email: user.Email, 217 | FullName: user.FullName, 218 | }) 219 | if err != nil { 220 | return err 221 | } 222 | 223 | return nil 224 | } 225 | -------------------------------------------------------------------------------- /internal/adapters/http/v1/user_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/google/uuid" 13 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/ports" 14 | "github.com/horiondreher/go-web-api-boilerplate/internal/utils" 15 | 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | type testUser struct { 20 | full_name string 21 | email string 22 | password string 23 | } 24 | 25 | func TestCreateUserV1(t *testing.T) { 26 | user := testUser{ 27 | full_name: utils.RandomString(6), 28 | email: utils.RandomEmail(), 29 | password: utils.RandomString(6), 30 | } 31 | 32 | tt := []struct { 33 | name string 34 | body string 35 | checkResponse func(recorder *httptest.ResponseRecorder) 36 | }{ 37 | { 38 | name: "CreateUser", 39 | body: fmt.Sprintf(`{"full_name": "%s", "email": "%s", "password": "%s"}`, user.full_name, user.email, user.password), 40 | checkResponse: func(recorder *httptest.ResponseRecorder) { 41 | require.Equal(t, http.StatusCreated, recorder.Code) 42 | validateUserResponse(t, user, recorder.Body) 43 | }, 44 | }, 45 | { 46 | name: "CreateUserWithInvalidEmail", 47 | body: fmt.Sprintf(`{"full_name": "%s", "email": "%s", "password": "%s"}`, user.full_name, "invalid_email", user.password), 48 | checkResponse: func(recorder *httptest.ResponseRecorder) { 49 | require.Equal(t, http.StatusUnprocessableEntity, recorder.Code) 50 | }, 51 | }, 52 | { 53 | name: "CreateUserWithoutName", 54 | body: fmt.Sprintf(`{"email": "%s", "password": "%s"}`, user.email, user.password), 55 | checkResponse: func(recorder *httptest.ResponseRecorder) { 56 | require.Equal(t, http.StatusUnprocessableEntity, recorder.Code) 57 | }, 58 | }, 59 | { 60 | name: "CreateUserWithoutEmail", 61 | body: fmt.Sprintf(`{"full_name": "%s", "password": "%s"}`, user.full_name, user.password), 62 | checkResponse: func(recorder *httptest.ResponseRecorder) { 63 | require.Equal(t, http.StatusUnprocessableEntity, recorder.Code) 64 | }, 65 | }, 66 | { 67 | name: "CreateUserWithoutPassword", 68 | body: fmt.Sprintf(`{"full_name": "%s", "email": "%s"}`, user.full_name, user.email), 69 | checkResponse: func(recorder *httptest.ResponseRecorder) { 70 | require.Equal(t, http.StatusUnprocessableEntity, recorder.Code) 71 | }, 72 | }, 73 | { 74 | name: "CreateUserWithEmptyBody", 75 | body: `{}`, 76 | checkResponse: func(recorder *httptest.ResponseRecorder) { 77 | require.Equal(t, http.StatusUnprocessableEntity, recorder.Code) 78 | }, 79 | }, 80 | { 81 | name: "CreateUserWithInvalidJson", 82 | body: `{"full_name": "invalid_json}`, 83 | checkResponse: func(recorder *httptest.ResponseRecorder) { 84 | require.Equal(t, http.StatusBadRequest, recorder.Code) 85 | }, 86 | }, 87 | { 88 | name: "CreateUserWithEmptyJson", 89 | checkResponse: func(recorder *httptest.ResponseRecorder) { 90 | require.Equal(t, http.StatusBadRequest, recorder.Code) 91 | }, 92 | }, 93 | } 94 | 95 | for _, tc := range tt { 96 | t.Run(tc.name, func(t *testing.T) { 97 | req, err := http.NewRequest("POST", "/api/v1/users", bytes.NewBufferString(tc.body)) 98 | require.NoError(t, err) 99 | 100 | recorder := httptest.NewRecorder() 101 | server, err := NewHTTPAdapter(testUserService) 102 | 103 | require.NoError(t, err) 104 | 105 | server.router.ServeHTTP(recorder, req) 106 | tc.checkResponse(recorder) 107 | }) 108 | } 109 | } 110 | 111 | func validateUserResponse(t *testing.T, response testUser, body *bytes.Buffer) { 112 | var responseUser CreateUserResponseDto 113 | err := json.NewDecoder(body).Decode(&responseUser) 114 | require.NoError(t, err) 115 | 116 | require.Equal(t, response.full_name, responseUser.FullName) 117 | require.Equal(t, response.email, responseUser.Email) 118 | 119 | require.NotZero(t, responseUser.UID) 120 | require.IsType(t, uuid.UUID{}, responseUser.UID) 121 | } 122 | 123 | func TestLoginUser(t *testing.T) { 124 | user := testUser{ 125 | full_name: utils.RandomString(6), 126 | email: utils.RandomEmail(), 127 | password: utils.RandomString(6), 128 | } 129 | 130 | _, err := testUserService.CreateUser(context.Background(), ports.NewUser{ 131 | FullName: user.full_name, 132 | Email: user.email, 133 | Password: user.password, 134 | }) 135 | 136 | require.Nil(t, err) 137 | 138 | tt := []struct { 139 | name string 140 | body string 141 | checkResponse func(recorder *httptest.ResponseRecorder) 142 | }{ 143 | { 144 | name: "LoginUser", 145 | body: fmt.Sprintf(`{"email": "%s", "password": "%s"}`, user.email, user.password), 146 | checkResponse: func(recorder *httptest.ResponseRecorder) { 147 | require.Equal(t, http.StatusOK, recorder.Code) 148 | }, 149 | }, 150 | { 151 | name: "LoginUserWithInvalidEmail", 152 | body: fmt.Sprintf(`{"email": "%s", "password": "%s"}`, "invalid_email", user.password), 153 | checkResponse: func(recorder *httptest.ResponseRecorder) { 154 | require.Equal(t, http.StatusUnprocessableEntity, recorder.Code) 155 | }, 156 | }, 157 | { 158 | name: "LoginUserWithoutEmail", 159 | body: fmt.Sprintf(`{"password": "%s"}`, user.password), 160 | checkResponse: func(recorder *httptest.ResponseRecorder) { 161 | require.Equal(t, http.StatusUnprocessableEntity, recorder.Code) 162 | }, 163 | }, 164 | { 165 | name: "LoginUserWithEmptyPassword", 166 | body: fmt.Sprintf(`{"email": "%s"}`, user.email), 167 | checkResponse: func(recorder *httptest.ResponseRecorder) { 168 | require.Equal(t, http.StatusUnprocessableEntity, recorder.Code) 169 | }, 170 | }, 171 | { 172 | name: "LoginUserWithEmptyBody", 173 | body: `{}`, 174 | checkResponse: func(recorder *httptest.ResponseRecorder) { 175 | require.Equal(t, http.StatusUnprocessableEntity, recorder.Code) 176 | }, 177 | }, 178 | { 179 | name: "LoginUserWithInvalidJson", 180 | body: `{"email": "invalid_json}`, 181 | checkResponse: func(recorder *httptest.ResponseRecorder) { 182 | require.Equal(t, http.StatusBadRequest, recorder.Code) 183 | }, 184 | }, 185 | { 186 | name: "LoginUserWithEmptyJson", 187 | checkResponse: func(recorder *httptest.ResponseRecorder) { 188 | require.Equal(t, http.StatusBadRequest, recorder.Code) 189 | }, 190 | }, 191 | } 192 | 193 | for _, tc := range tt { 194 | t.Run(tc.name, func(t *testing.T) { 195 | req, err := http.NewRequest("POST", "/api/v1/login", bytes.NewBufferString(tc.body)) 196 | require.NoError(t, err) 197 | 198 | recorder := httptest.NewRecorder() 199 | server, err := NewHTTPAdapter(testUserService) 200 | 201 | require.NoError(t, err) 202 | 203 | server.router.ServeHTTP(recorder, req) 204 | tc.checkResponse(recorder) 205 | }) 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /internal/adapters/pgsqlc/db.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.25.0 4 | 5 | package pgsqlc 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/jackc/pgx/v5" 11 | "github.com/jackc/pgx/v5/pgconn" 12 | ) 13 | 14 | type DBTX interface { 15 | Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) 16 | Query(context.Context, string, ...interface{}) (pgx.Rows, error) 17 | QueryRow(context.Context, string, ...interface{}) pgx.Row 18 | } 19 | 20 | func New(db DBTX) *Queries { 21 | return &Queries{db: db} 22 | } 23 | 24 | type Queries struct { 25 | db DBTX 26 | } 27 | 28 | func (q *Queries) WithTx(tx pgx.Tx) *Queries { 29 | return &Queries{ 30 | db: tx, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/adapters/pgsqlc/models.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.25.0 4 | 5 | package pgsqlc 6 | 7 | import ( 8 | "time" 9 | 10 | "github.com/google/uuid" 11 | ) 12 | 13 | type Session struct { 14 | ID int64 15 | UID uuid.UUID 16 | UserEmail string 17 | RefreshToken string 18 | UserAgent string 19 | ClientIP string 20 | IsBlocked bool 21 | ExpiresAt time.Time 22 | CreatedAt time.Time 23 | } 24 | 25 | type User struct { 26 | ID int64 27 | UID uuid.UUID 28 | Email string 29 | Password string 30 | FullName string 31 | IsStaff bool 32 | IsActive bool 33 | LastLogin time.Time 34 | CreatedAt time.Time 35 | ModifiedAt time.Time 36 | } 37 | -------------------------------------------------------------------------------- /internal/adapters/pgsqlc/querier.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.25.0 4 | 5 | package pgsqlc 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/google/uuid" 11 | ) 12 | 13 | type Querier interface { 14 | CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) 15 | CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) 16 | GetSession(ctx context.Context, uid uuid.UUID) (Session, error) 17 | GetUser(ctx context.Context, email string) (User, error) 18 | GetUserByUID(ctx context.Context, uid uuid.UUID) (User, error) 19 | } 20 | 21 | var _ Querier = (*Queries)(nil) 22 | -------------------------------------------------------------------------------- /internal/adapters/pgsqlc/session.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.25.0 4 | // source: session.sql 5 | 6 | package pgsqlc 7 | 8 | import ( 9 | "context" 10 | "time" 11 | 12 | "github.com/google/uuid" 13 | ) 14 | 15 | const createSession = `-- name: CreateSession :one 16 | INSERT INTO "session" ( 17 | "uid" 18 | , "user_email" 19 | , "refresh_token" 20 | , "user_agent" 21 | , "client_ip" 22 | , "is_blocked" 23 | , "expires_at" 24 | ) 25 | VALUES ( 26 | $1 27 | , $2 28 | , $3 29 | , $4 30 | , $5 31 | , $6 32 | , $7 33 | ) RETURNING id, uid, user_email, refresh_token, user_agent, client_ip, is_blocked, expires_at, created_at 34 | ` 35 | 36 | type CreateSessionParams struct { 37 | UID uuid.UUID 38 | UserEmail string 39 | RefreshToken string 40 | UserAgent string 41 | ClientIP string 42 | IsBlocked bool 43 | ExpiresAt time.Time 44 | } 45 | 46 | func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { 47 | row := q.db.QueryRow(ctx, createSession, 48 | arg.UID, 49 | arg.UserEmail, 50 | arg.RefreshToken, 51 | arg.UserAgent, 52 | arg.ClientIP, 53 | arg.IsBlocked, 54 | arg.ExpiresAt, 55 | ) 56 | var i Session 57 | err := row.Scan( 58 | &i.ID, 59 | &i.UID, 60 | &i.UserEmail, 61 | &i.RefreshToken, 62 | &i.UserAgent, 63 | &i.ClientIP, 64 | &i.IsBlocked, 65 | &i.ExpiresAt, 66 | &i.CreatedAt, 67 | ) 68 | return i, err 69 | } 70 | 71 | const getSession = `-- name: GetSession :one 72 | SELECT id, uid, user_email, refresh_token, user_agent, client_ip, is_blocked, expires_at, created_at 73 | FROM "session" 74 | WHERE "uid" = $1 LIMIT 1 75 | ` 76 | 77 | func (q *Queries) GetSession(ctx context.Context, uid uuid.UUID) (Session, error) { 78 | row := q.db.QueryRow(ctx, getSession, uid) 79 | var i Session 80 | err := row.Scan( 81 | &i.ID, 82 | &i.UID, 83 | &i.UserEmail, 84 | &i.RefreshToken, 85 | &i.UserAgent, 86 | &i.ClientIP, 87 | &i.IsBlocked, 88 | &i.ExpiresAt, 89 | &i.CreatedAt, 90 | ) 91 | return i, err 92 | } 93 | -------------------------------------------------------------------------------- /internal/adapters/pgsqlc/user.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.25.0 4 | // source: user.sql 5 | 6 | package pgsqlc 7 | 8 | import ( 9 | "context" 10 | "time" 11 | 12 | "github.com/google/uuid" 13 | ) 14 | 15 | const createUser = `-- name: CreateUser :one 16 | INSERT INTO "user" ( 17 | "email" 18 | , "password" 19 | , "full_name" 20 | , "is_staff" 21 | , "is_active" 22 | , "last_login" 23 | ) 24 | VALUES ( 25 | $1 26 | , $2 27 | , $3 28 | , $4 29 | , $5 30 | , $6 31 | ) RETURNING "uid" 32 | , "email" 33 | , "full_name" 34 | , "created_at" 35 | , "modified_at" 36 | ` 37 | 38 | type CreateUserParams struct { 39 | Email string 40 | Password string 41 | FullName string 42 | IsStaff bool 43 | IsActive bool 44 | LastLogin time.Time 45 | } 46 | 47 | type CreateUserRow struct { 48 | UID uuid.UUID 49 | Email string 50 | FullName string 51 | CreatedAt time.Time 52 | ModifiedAt time.Time 53 | } 54 | 55 | func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) { 56 | row := q.db.QueryRow(ctx, createUser, 57 | arg.Email, 58 | arg.Password, 59 | arg.FullName, 60 | arg.IsStaff, 61 | arg.IsActive, 62 | arg.LastLogin, 63 | ) 64 | var i CreateUserRow 65 | err := row.Scan( 66 | &i.UID, 67 | &i.Email, 68 | &i.FullName, 69 | &i.CreatedAt, 70 | &i.ModifiedAt, 71 | ) 72 | return i, err 73 | } 74 | 75 | const getUser = `-- name: GetUser :one 76 | SELECT id, uid, email, password, full_name, is_staff, is_active, last_login, created_at, modified_at 77 | FROM "user" 78 | WHERE "email" = $1 LIMIT 1 79 | ` 80 | 81 | func (q *Queries) GetUser(ctx context.Context, email string) (User, error) { 82 | row := q.db.QueryRow(ctx, getUser, email) 83 | var i User 84 | err := row.Scan( 85 | &i.ID, 86 | &i.UID, 87 | &i.Email, 88 | &i.Password, 89 | &i.FullName, 90 | &i.IsStaff, 91 | &i.IsActive, 92 | &i.LastLogin, 93 | &i.CreatedAt, 94 | &i.ModifiedAt, 95 | ) 96 | return i, err 97 | } 98 | 99 | const getUserByUID = `-- name: GetUserByUID :one 100 | SELECT id, uid, email, password, full_name, is_staff, is_active, last_login, created_at, modified_at 101 | FROM "user" 102 | WHERE "uid" = $1 LIMIT 1 103 | ` 104 | 105 | func (q *Queries) GetUserByUID(ctx context.Context, uid uuid.UUID) (User, error) { 106 | row := q.db.QueryRow(ctx, getUserByUID, uid) 107 | var i User 108 | err := row.Scan( 109 | &i.ID, 110 | &i.UID, 111 | &i.Email, 112 | &i.Password, 113 | &i.FullName, 114 | &i.IsStaff, 115 | &i.IsActive, 116 | &i.LastLogin, 117 | &i.CreatedAt, 118 | &i.ModifiedAt, 119 | ) 120 | return i, err 121 | } 122 | -------------------------------------------------------------------------------- /internal/domain/domainerr/bcrypt.go: -------------------------------------------------------------------------------- 1 | package domainerr 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "golang.org/x/crypto/bcrypt" 8 | ) 9 | 10 | func MatchHashError(err error) *DomainError { 11 | if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { 12 | return &DomainError{ 13 | HTTPCode: http.StatusUnauthorized, 14 | OriginalError: err.Error(), 15 | HTTPErrorBody: HTTPErrorBody{ 16 | Code: InvalidPasswordError, 17 | Errors: "The password is invalid", 18 | }, 19 | } 20 | } 21 | 22 | return NewInternalError(err) 23 | } 24 | -------------------------------------------------------------------------------- /internal/domain/domainerr/error.go: -------------------------------------------------------------------------------- 1 | package domainerr 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | var ( 9 | UnauthorizedError = "auth/unauthorized" 10 | InvalidToken = "auth/invalid-token" 11 | ExpiredToken = "auth/expired-token" 12 | InvalidPasswordError = "auth/invalid-password" 13 | ValidationError = "request/invalid-fields" 14 | JsonDecodeError = "request/invalid-json" 15 | DuplicateError = "data/duplicate" 16 | QueryError = "data/invalid-query" 17 | UnexpectedError = "server/internal-error" 18 | InternalError = "server/internal-error" 19 | NotFoundError = "server/not-found" 20 | MehodNotAllowedError = "server/method-not-allowed" 21 | ) 22 | 23 | type HTTPErrorBody struct { 24 | Code string `json:"code"` 25 | Errors any `json:"errors"` 26 | } 27 | 28 | type DomainError struct { 29 | HTTPCode int 30 | HTTPErrorBody HTTPErrorBody 31 | OriginalError string 32 | } 33 | 34 | func NewDomainError(httpCode int, errorCode string, errorMsg any, err error) *DomainError { 35 | return &DomainError{ 36 | HTTPCode: httpCode, 37 | OriginalError: err.Error(), 38 | HTTPErrorBody: HTTPErrorBody{ 39 | Code: errorCode, 40 | Errors: errorMsg, 41 | }, 42 | } 43 | } 44 | 45 | func NewInternalError(err error) *DomainError { 46 | return &DomainError{ 47 | HTTPCode: http.StatusInternalServerError, 48 | OriginalError: err.Error(), 49 | HTTPErrorBody: HTTPErrorBody{ 50 | Code: UnexpectedError, 51 | Errors: "Internal server error", 52 | }, 53 | } 54 | } 55 | 56 | func (e DomainError) Error() string { 57 | return fmt.Sprintf("api error: %d", e.HTTPCode) 58 | } 59 | -------------------------------------------------------------------------------- /internal/domain/domainerr/pgsql.go: -------------------------------------------------------------------------------- 1 | package domainerr 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/jackc/pgx/v5" 8 | "github.com/jackc/pgx/v5/pgconn" 9 | ) 10 | 11 | func MatchPostgresError(err error) *DomainError { 12 | if errors.Is(err, pgx.ErrNoRows) { 13 | return &DomainError{ 14 | HTTPCode: http.StatusNotFound, 15 | OriginalError: err.Error(), 16 | HTTPErrorBody: HTTPErrorBody{ 17 | Code: NotFoundError, 18 | Errors: "Not found", 19 | }, 20 | } 21 | } 22 | 23 | pgErr, ok := err.(*pgconn.PgError) 24 | if ok { 25 | return TransformPostgresError(pgErr) 26 | } 27 | 28 | return NewInternalError(err) 29 | } 30 | 31 | func TransformPostgresError(err *pgconn.PgError) *DomainError { 32 | httpError := &DomainError{ 33 | HTTPCode: http.StatusBadRequest, 34 | OriginalError: err.Error(), 35 | HTTPErrorBody: HTTPErrorBody{ 36 | Code: QueryError, 37 | Errors: err.ConstraintName, 38 | }, 39 | } 40 | 41 | switch err.Code { 42 | case "23505": 43 | httpError.HTTPCode = http.StatusConflict 44 | httpError.HTTPErrorBody.Code = DuplicateError 45 | httpError.HTTPErrorBody.Errors = MapDuplicateError(err.ConstraintName) 46 | } 47 | 48 | return httpError 49 | } 50 | 51 | func MapDuplicateError(constraintName string) string { 52 | var errorMessage string 53 | 54 | switch constraintName { 55 | case "user_email_idx": 56 | errorMessage = "The email is already in use" 57 | } 58 | 59 | return errorMessage 60 | } 61 | -------------------------------------------------------------------------------- /internal/domain/ports/user_service.go: -------------------------------------------------------------------------------- 1 | package ports 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/pgsqlc" 9 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/domainerr" 10 | ) 11 | 12 | type NewUser struct { 13 | FullName string 14 | Email string 15 | Password string 16 | } 17 | 18 | type LoginUser struct { 19 | Email string 20 | Password string 21 | } 22 | 23 | type NewUserSession struct { 24 | RefreshTokenID uuid.UUID 25 | Email string 26 | RefreshToken string 27 | UserAgent string 28 | ClientIP string 29 | RefreshTokenExpiresAt time.Time 30 | } 31 | 32 | type UserService interface { 33 | CreateUser(ctx context.Context, newUser NewUser) (pgsqlc.CreateUserRow, *domainerr.DomainError) 34 | LoginUser(ctx context.Context, loginUser LoginUser) (pgsqlc.User, *domainerr.DomainError) 35 | CreateUserSession(ctx context.Context, newUserSession NewUserSession) (pgsqlc.Session, *domainerr.DomainError) 36 | GetUserSession(ctx context.Context, refreshTokenID uuid.UUID) (pgsqlc.Session, *domainerr.DomainError) 37 | GetUserByUID(ctx context.Context, userUID string) (pgsqlc.User, *domainerr.DomainError) 38 | } 39 | -------------------------------------------------------------------------------- /internal/domain/services/user.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | "github.com/horiondreher/go-web-api-boilerplate/internal/adapters/pgsqlc" 10 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/domainerr" 11 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/ports" 12 | "github.com/horiondreher/go-web-api-boilerplate/internal/utils" 13 | ) 14 | 15 | type UserManager struct { 16 | store pgsqlc.Querier 17 | } 18 | 19 | func NewUserManager(store pgsqlc.Querier) *UserManager { 20 | return &UserManager{ 21 | store: store, 22 | } 23 | } 24 | 25 | func (service *UserManager) CreateUser(ctx context.Context, newUser ports.NewUser) (pgsqlc.CreateUserRow, *domainerr.DomainError) { 26 | hashedPassword, hashErr := utils.HashPassword(newUser.Password) 27 | if hashErr != nil { 28 | return pgsqlc.CreateUserRow{}, hashErr 29 | } 30 | 31 | args := pgsqlc.CreateUserParams{ 32 | Email: newUser.Email, 33 | Password: hashedPassword, 34 | FullName: newUser.FullName, 35 | IsStaff: false, 36 | IsActive: true, 37 | LastLogin: time.Now(), 38 | } 39 | 40 | user, err := service.store.CreateUser(ctx, args) 41 | if err != nil { 42 | return pgsqlc.CreateUserRow{}, domainerr.MatchPostgresError(err) 43 | } 44 | 45 | return user, nil 46 | } 47 | 48 | func (service *UserManager) LoginUser(ctx context.Context, loginUser ports.LoginUser) (pgsqlc.User, *domainerr.DomainError) { 49 | user, err := service.store.GetUser(ctx, loginUser.Email) 50 | if err != nil { 51 | return pgsqlc.User{}, domainerr.MatchPostgresError(err) 52 | } 53 | 54 | passErr := utils.CheckPassword(loginUser.Password, user.Password) 55 | if passErr != nil { 56 | return pgsqlc.User{}, passErr 57 | } 58 | 59 | return user, nil 60 | } 61 | 62 | func (service *UserManager) CreateUserSession(ctx context.Context, newUserSession ports.NewUserSession) (pgsqlc.Session, *domainerr.DomainError) { 63 | session, err := service.store.CreateSession(ctx, pgsqlc.CreateSessionParams{ 64 | UID: newUserSession.RefreshTokenID, 65 | UserEmail: newUserSession.Email, 66 | RefreshToken: newUserSession.RefreshToken, 67 | ExpiresAt: newUserSession.RefreshTokenExpiresAt, 68 | UserAgent: newUserSession.UserAgent, 69 | ClientIP: newUserSession.ClientIP, 70 | }) 71 | if err != nil { 72 | return pgsqlc.Session{}, domainerr.MatchPostgresError(err) 73 | } 74 | 75 | return session, nil 76 | } 77 | 78 | func (service *UserManager) GetUserSession(ctx context.Context, refreshTokenID uuid.UUID) (pgsqlc.Session, *domainerr.DomainError) { 79 | session, err := service.store.GetSession(ctx, refreshTokenID) 80 | if err != nil { 81 | return pgsqlc.Session{}, domainerr.MatchPostgresError(err) 82 | } 83 | 84 | return session, nil 85 | } 86 | 87 | func (service *UserManager) GetUserByUID(ctx context.Context, userUID string) (pgsqlc.User, *domainerr.DomainError) { 88 | parsedUID, err := uuid.Parse(userUID) 89 | if err != nil { 90 | return pgsqlc.User{}, domainerr.NewDomainError(http.StatusInternalServerError, domainerr.UnexpectedError, err.Error(), err) 91 | } 92 | 93 | user, err := service.store.GetUserByUID(ctx, parsedUID) 94 | if err != nil { 95 | return pgsqlc.User{}, domainerr.MatchPostgresError(err) 96 | } 97 | 98 | return user, nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/utils/config.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/go-playground/validator/v10" 8 | "github.com/knadh/koanf/parsers/dotenv" 9 | "github.com/knadh/koanf/providers/env" 10 | "github.com/knadh/koanf/providers/file" 11 | "github.com/knadh/koanf/v2" 12 | "github.com/rs/zerolog/log" 13 | ) 14 | 15 | type Config struct { 16 | Environment string `validate:"required" koanf:"ENVIRONMENT"` 17 | HTTPServerAddress string `validate:"required" koanf:"HTTP_SERVER_ADDRESS"` 18 | DBName string `validate:"required" koanf:"POSTGRES_DB"` 19 | DBUser string `validate:"required" koanf:"POSTGRES_USER"` 20 | DBPassword string `validate:"required" koanf:"POSTGRES_PASSWORD"` 21 | DBSource string `validate:"required" koanf:"DB_SOURCE"` 22 | MigrationURL string `validate:"required" koanf:"MIGRATION_URL"` 23 | TokenSymmetricKey string `validate:"required" koanf:"TOKEN_SYMMETRIC_KEY"` 24 | AccessTokenDuration time.Duration `validate:"required" koanf:"ACCESS_TOKEN_DURATION"` 25 | RefreshTokenDuration time.Duration `validate:"required" koanf:"REFRESH_TOKEN_DURATION"` 26 | } 27 | 28 | var configFile string = ".env" 29 | 30 | var ( 31 | k *koanf.Koanf 32 | instance *Config 33 | once sync.Once 34 | ) 35 | 36 | // should be used before first call of GetConfig (only for testing) 37 | func SetConfigFile(file string) { 38 | configFile = file 39 | } 40 | 41 | // GetConfig returns the configuration instance using once.Do to ensure that the configuration is loaded only once 42 | func GetConfig() *Config { 43 | 44 | once.Do(func() { 45 | var err error 46 | 47 | k = koanf.New(".") 48 | validate := validator.New(validator.WithRequiredStructEnabled()) 49 | 50 | log.Info().Msg("loading config...") 51 | 52 | fileProvider := file.Provider(configFile) 53 | envProvider := env.Provider("", ".", nil) 54 | 55 | err = k.Load(fileProvider, dotenv.Parser()) 56 | 57 | if err != nil { 58 | log.Info().Msgf("could not load config file: %s", err.Error()) 59 | } 60 | 61 | err = k.Load(envProvider, nil) 62 | 63 | if err != nil { 64 | log.Info().Msgf("could not environment variables: %s", err.Error()) 65 | } 66 | 67 | err = k.Unmarshal("", &instance) 68 | 69 | if err != nil { 70 | log.Panic().Err(err).Msg("error unmarshing config") 71 | } 72 | 73 | err = validate.Struct(instance) 74 | 75 | if err != nil { 76 | log.Panic().Err(err).Msg("correct configs were not loaded") 77 | } 78 | }) 79 | 80 | return instance 81 | } 82 | -------------------------------------------------------------------------------- /internal/utils/logger.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/rs/zerolog" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | func StartLogger() { 12 | log.Logger = log.Output(zerolog.ConsoleWriter{ 13 | Out: os.Stdout, 14 | TimeFormat: time.RFC3339, // Customize the time format or use an empty string to hide the time 15 | NoColor: false, // Set to true if you do not want colored output 16 | }) 17 | 18 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 19 | } 20 | -------------------------------------------------------------------------------- /internal/utils/password.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/horiondreher/go-web-api-boilerplate/internal/domain/domainerr" 7 | "golang.org/x/crypto/bcrypt" 8 | ) 9 | 10 | type HashError struct { 11 | msg string 12 | } 13 | 14 | func (e *HashError) Error() string { 15 | return e.msg 16 | } 17 | 18 | func HashPassword(password string) (string, *domainerr.DomainError) { 19 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 20 | if err != nil { 21 | return "", domainerr.NewDomainError(http.StatusInternalServerError, domainerr.InternalError, err.Error(), err) 22 | } 23 | 24 | return string(hashedPassword), nil 25 | } 26 | 27 | func CheckPassword(password string, hashedPassword string) *domainerr.DomainError { 28 | err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) 29 | if err != nil { 30 | return domainerr.MatchHashError(err) 31 | } 32 | 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/utils/random.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "math/rand" 5 | "strings" 6 | ) 7 | 8 | const alphabet = "abcdefghijklmnopqrstuvwxyz" 9 | 10 | func RandomInt(min, max int64) int64 { 11 | return min + rand.Int63n(max-min+1) 12 | } 13 | 14 | func RandomString(n int) string { 15 | var sb strings.Builder 16 | k := len(alphabet) 17 | 18 | for i := 0; i < n; i++ { 19 | c := alphabet[rand.Intn(k)] 20 | sb.WriteByte(c) 21 | } 22 | 23 | return sb.String() 24 | } 25 | 26 | func RandomEmail() string { 27 | return RandomString(6) + "@" + RandomString(3) + ".com" 28 | } 29 | -------------------------------------------------------------------------------- /scripts/http_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [ -z "$1" ]; then 6 | echo "Please provide an action" 7 | exit 1 8 | fi 9 | 10 | SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) 11 | cd "$SCRIPT_DIR" || exit 12 | 13 | ACTION=$1 14 | 15 | # Curl variables 16 | BASE_URL="http://localhost:8080/api/v1" 17 | JSON_CONTENT_TYPE="Content-Type: application/json" 18 | 19 | # JSON files 20 | LOGIN_JSON_FILE="json/login.json" 21 | USER_JSON_FILE="json/create_user.json" 22 | 23 | # Token file 24 | TOKEN_FILE="temp/tokens.json" 25 | 26 | send_post_request() { 27 | local url=$1 28 | local json_file=$2 29 | local jq_filter=${3:-.} 30 | local json_data 31 | 32 | json_data=$(jq "$jq_filter" "$json_file") || exit 1 33 | 34 | curl -s -X POST "$url" -H "$JSON_CONTENT_TYPE" -d "$json_data" -w "%{http_code}\n" 35 | } 36 | 37 | send_get_request() { 38 | local url=$1 39 | local bearer_token 40 | 41 | if [ -f $TOKEN_FILE ]; then 42 | bearer_token=$(jq -r '.access_token' "$TOKEN_FILE") 43 | fi 44 | 45 | curl -s -X GET "$url" -H "$JSON_CONTENT_TYPE" -H "Authorization: Bearer $bearer_token" -w "%{http_code}\n" 46 | } 47 | 48 | save_tokens() { 49 | echo "$1" | sed '$d' | jq '{access_token: .access_token, refresh_token: .refresh_token}' >"$TOKEN_FILE" 50 | } 51 | 52 | case $ACTION in 53 | login) 54 | response=$(send_post_request "$BASE_URL/login" "$LOGIN_JSON_FILE") 55 | save_tokens "$response" 56 | ;; 57 | create_user) 58 | response=$(send_post_request "$BASE_URL/users" "$USER_JSON_FILE") 59 | ;; 60 | renew_token) 61 | response=$(send_post_request "$BASE_URL/renew-token" "$TOKEN_FILE" '{refresh_token: .refresh_token}') 62 | save_tokens "$response" 63 | ;; 64 | get_user) 65 | response=$(send_get_request "$BASE_URL/user/eb01f6d3-b964-4bdc-be66-a40c95378480") 66 | ;; 67 | *) 68 | echo "Invalid action: $ACTION" 69 | exit 1 70 | ;; 71 | esac 72 | 73 | http_code=$(echo "$response" | tail -n1) 74 | response_body=$(echo "$response" | sed '$d') 75 | 76 | echo "$response_body" | jq 77 | echo "Response Code: $http_code" 78 | 79 | -------------------------------------------------------------------------------- /scripts/json/create_user.json: -------------------------------------------------------------------------------- 1 | { 2 | "full_name": "John Doe", 3 | "email": "john.doe30@example.com", 4 | "password": "yourSecurePassword" 5 | } 6 | -------------------------------------------------------------------------------- /scripts/json/login.json: -------------------------------------------------------------------------------- 1 | { 2 | "email": "john.doe30@example.com", 3 | "password": "yourSecurePassword" 4 | } 5 | -------------------------------------------------------------------------------- /scripts/json/renew_token.json: -------------------------------------------------------------------------------- 1 | { 2 | "refresh_token": "" 3 | } -------------------------------------------------------------------------------- /sqlc.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | sql: 3 | - schema: "db/postgres/migration" 4 | queries: "db/postgres/query" 5 | engine: "postgresql" 6 | gen: 7 | go: 8 | package: "pgsqlc" 9 | out: "internal/adapters/pgsqlc" 10 | sql_package: "pgx/v5" 11 | emit_json_tags: false 12 | emit_interface: true 13 | emit_empty_slices: true 14 | rename: 15 | uid: "UID" 16 | client_ip: "ClientIP" 17 | overrides: 18 | - db_type: "timestamptz" 19 | go_type: "time.Time" 20 | - db_type: "timestamptz" 21 | go_type: "time.Time" 22 | nullable: true 23 | - db_type: "uuid" 24 | go_type: "github.com/google/uuid.UUID" 25 | - db_type: "uuid" 26 | go_type: "github.com/google/uuid.UUID" 27 | nullable: true 28 | --------------------------------------------------------------------------------