diff --git a/cmd/service/main.go b/cmd/service/main.go index 75cde92..2d330c3 100644 --- a/cmd/service/main.go +++ b/cmd/service/main.go @@ -13,15 +13,14 @@ import ( "github.com/nix-united/golang-gin-boilerplate/docs" "github.com/nix-united/golang-gin-boilerplate/internal/config" "github.com/nix-united/golang-gin-boilerplate/internal/db" - "github.com/nix-united/golang-gin-boilerplate/internal/provider" "github.com/nix-united/golang-gin-boilerplate/internal/repository" "github.com/nix-united/golang-gin-boilerplate/internal/server" "github.com/nix-united/golang-gin-boilerplate/internal/server/handler" "github.com/nix-united/golang-gin-boilerplate/internal/server/middleware" + "github.com/nix-united/golang-gin-boilerplate/internal/service/password" "github.com/nix-united/golang-gin-boilerplate/internal/service/post" "github.com/nix-united/golang-gin-boilerplate/internal/service/user" "github.com/nix-united/golang-gin-boilerplate/internal/slogx" - "github.com/nix-united/golang-gin-boilerplate/internal/utils" "github.com/caarlos0/env/v11" "github.com/google/uuid" @@ -77,15 +76,23 @@ func run() error { postRepository := repository.NewPostRepository(gormDB) // Services initialization - bcryptEncoder := utils.NewBcryptEncoder(bcrypt.DefaultCost) - userService := user.NewService(userRepository, bcryptEncoder) + passwordService := password.NewService(bcrypt.DefaultCost) + userService := user.NewService(userRepository, passwordService) postService := post.NewService(postRepository) // Handlers initialization - homeHandler := handler.NewHomeHandler() postHandler := handler.NewPostHandler(postService) - authHandler := handler.NewAuthHandler(userService) - jwtAuth := provider.NewJwtAuth(gormDB) + authHandler, err := handler.NewAuthHandler(handler.AuthHandlerConfig{ + ApplicationName: cfg.ApplicationName, + JWTSecret: cfg.AuthConfig.Secret, + JWTTokenDuration: cfg.AuthConfig.TokenDuration, + JWTTokenRefreshDuration: cfg.AuthConfig.RefreshTokenDuration, + UserService: userService, + PasswordService: passwordService, + }) + if err != nil { + return fmt.Errorf("new auth handler: %w", err) + } // Middlewares initialization requestLoggerMiddleware := middleware.NewRequestLoggerMiddleware(traceStarter) @@ -93,10 +100,8 @@ func run() error { // HTTP Server initialization routes := server.ConfigureRoutes(server.Handlers{ - HomeHandler: homeHandler, AuthHandler: authHandler, PostHandler: postHandler, - JwtAuthMiddleware: jwtAuth, RequestLoggingMiddleware: requestLoggerMiddleware.Handle, RequestDebuggingMiddleware: requestDebuggerMiddleware.Handle, }) diff --git a/go.mod b/go.mod index 66cbf9e..3f5d51a 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,17 @@ module github.com/nix-united/golang-gin-boilerplate go 1.24.6 require ( - github.com/appleboy/gin-jwt/v2 v2.9.1 - github.com/gin-gonic/gin v1.9.1 + github.com/gin-gonic/gin v1.11.0 github.com/joho/godotenv v1.5.1 github.com/mailru/easyjson v0.7.7 // indirect - github.com/stretchr/testify v1.11.0 + github.com/stretchr/testify v1.11.1 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/swag v1.16.2 - golang.org/x/crypto v0.41.0 - golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/crypto v0.43.0 + golang.org/x/net v0.46.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/tools v0.38.0 // indirect gorm.io/driver/mysql v1.5.2 gorm.io/gorm v1.25.5 ) @@ -22,10 +21,9 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/bytedance/sonic v1.10.2 // indirect - github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect + github.com/bytedance/sonic v1.14.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-openapi/jsonpointer v0.20.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/spec v0.20.9 // indirect @@ -33,45 +31,49 @@ require ( github.com/go-ozzo/ozzo-validation v3.6.0+incompatible github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.16.0 // indirect + github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.6 // indirect - github.com/leodido/go-urn v1.2.4 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.11 // indirect - golang.org/x/arch v0.6.0 // indirect - golang.org/x/text v0.28.0 // indirect - google.golang.org/protobuf v1.36.7 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( + github.com/appleboy/gin-jwt/v3 v3.2.0 github.com/caarlos0/env/v11 v11.3.1 github.com/ccoveille/go-safecast v1.6.1 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/pressly/goose/v3 v3.25.0 - github.com/testcontainers/testcontainers-go v0.38.0 + github.com/testcontainers/testcontainers-go v0.39.0 github.com/testcontainers/testcontainers-go/modules/mysql v0.38.0 go.uber.org/mock v0.6.0 ) require ( - dario.cat/mergo v1.0.1 // indirect + dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect - github.com/chenzhuoyu/iasm v0.9.1 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -79,17 +81,16 @@ require ( github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.3.3+incompatible // indirect - github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.8.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect @@ -106,22 +107,26 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.55.0 // indirect + github.com/redis/rueidis v1.0.66 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect - github.com/shirou/gopsutil/v4 v4.25.5 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/sdk v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/sync v0.16.0 // indirect - google.golang.org/grpc v1.75.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/sync v0.17.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect ) tool go.uber.org/mock/mockgen diff --git a/go.sum b/go.sum index ba1a6f2..784c6bc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= @@ -10,29 +10,26 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/appleboy/gin-jwt/v2 v2.9.1 h1:l29et8iLW6omcHltsOP6LLk4s3v4g2FbFs0koxGWVZs= -github.com/appleboy/gin-jwt/v2 v2.9.1/go.mod h1:jwcPZJ92uoC9nOUTOKWoN/f6JZOgMSKlFSHw5/FrRUk= +github.com/appleboy/gin-jwt/v3 v3.2.0 h1:Ifa8Zsm2cZ93u/HIAhcTmftTK8rIaaoAoH77lcajWD0= +github.com/appleboy/gin-jwt/v3 v3.2.0/go.mod h1:ANNEPdDkdOp6jXAbicMFX7N4mIIx70m9A3asDmXdbYo= github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4= github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= -github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= -github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE= -github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/ccoveille/go-safecast v1.6.1 h1:Nb9WMDR8PqhnKCVs2sCB+OqhohwO5qaXtCviZkIff5Q= github.com/ccoveille/go-safecast v1.6.1/go.mod h1:QqwNjxQ7DAqY0C721OIO9InMk9zCwcsO7tnRuHytad8= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= -github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= -github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= -github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= -github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0= -github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -53,8 +50,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -63,15 +60,14 @@ github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0o github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk= -github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= -github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -96,41 +92,33 @@ github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogB github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-ozzo/ozzo-validation v3.6.0+incompatible h1:msy24VGS42fKO9K1vLz82/GeYW1cILu7Nuuj1N3BBkE= github.com/go-ozzo/ozzo-validation v3.6.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU= -github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= -github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= -github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= -github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= -github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -145,22 +133,18 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= -github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= -github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= @@ -170,10 +154,10 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -202,15 +186,14 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= -github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -219,16 +202,20 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng= github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= +github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= +github.com/redis/rueidis v1.0.66 h1:7rvyrl0vL/cAEkE97+L5v3MJ3Vg8IKz+KIxUTfT+yJk= +github.com/redis/rueidis v1.0.66/go.mod h1:Lkhr2QTgcoYBhxARU7kJRO8SyVlgUuEkcJO1Y8MCluA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= -github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc= -github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -242,22 +229,22 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= -github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= github.com/swaggo/swag v1.16.2 h1:28Pp+8DkQoV+HLzLx8RGJZXNGKbFqnuvSbAAtoxiY04= github.com/swaggo/swag v1.16.2/go.mod h1:6YzXnDcpr0767iOejs318CwYkCQqyGer6BizOg03f+E= -github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw= -github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w= +github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts= +github.com/testcontainers/testcontainers-go v0.39.0/go.mod h1:qmHpkG7H5uPf/EvOORKvS6EuDkBUPE3zpVGaH9NL7f8= github.com/testcontainers/testcontainers-go/modules/mysql v0.38.0 h1:msUPAl0LVBalG3m2KhmbFHeRrxCw36xmQFCEhzqsvqo= github.com/testcontainers/testcontainers-go/modules/mysql v0.38.0/go.mod h1:PFyaiqBahyh1BMz23ij99z4LJGsDpkpuZKz6rchlUWc= -github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw= -github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/testcontainers/testcontainers-go/modules/redis v0.39.0 h1:p54qELdCx4Gftkxzf44k9RJRRhaO/S5ehP9zo8SUTLM= +github.com/testcontainers/testcontainers-go/modules/redis v0.39.0/go.mod h1:P1mTbHruHqAU2I26y0RADz1BitF59FLbQr7ceqN9bt4= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= @@ -268,10 +255,10 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= -github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -299,79 +286,64 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.6.0 h1:S0JTfE48HbRj80+4tbvZDYsJ3tGv6BUU3XxyZ7CirAc= -golang.org/x/arch v0.6.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= -golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -379,35 +351,30 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= -google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= -google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= +google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs= @@ -425,5 +392,3 @@ modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/config/configure.go b/internal/config/config.go similarity index 79% rename from internal/config/configure.go rename to internal/config/config.go index 752dfdb..adb1986 100644 --- a/internal/config/configure.go +++ b/internal/config/config.go @@ -3,13 +3,21 @@ package config import "time" type ApplicationConfig struct { + ApplicationName string `env:"APPLICATION_NAME" envDefault:"golang_gin_boilerplate"` ApplicationShutdownTimeout time.Duration `env:"APPLICATION_SHUTDOWN_TIMEOUT" envDefault:"5m"` + AuthConfig AuthConfig DB DBConfig HTTPServer HTTPServerConfig Logger LoggerConfig } +type AuthConfig struct { + Secret string `env:"AUTH_JWT_SECRET"` + TokenDuration time.Duration `env:"AUTH_JWT_TOKEN_DURATION" envDefault:"1h"` + RefreshTokenDuration time.Duration `env:"AUTH_JWT_REFRESH_TOKEN_DURATION" envDefault:"1h"` +} + type DBConfig struct { User string `env:"DB_USER"` Password string `env:"DB_PASSWORD"` diff --git a/internal/db/migrations/20200616011238_init.sql b/internal/db/migrations/20200616011238_init.sql index 52f4dda..911a733 100644 --- a/internal/db/migrations/20200616011238_init.sql +++ b/internal/db/migrations/20200616011238_init.sql @@ -1,30 +1,24 @@ -- +goose Up -CREATE TABLE `users` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `created_at` datetime DEFAULT NULL, - `updated_at` datetime DEFAULT NULL, - `deleted_at` datetime DEFAULT NULL, - `email` varchar(200) DEFAULT NULL, - `password` varchar(200) DEFAULT NULL, - `full_name` varchar(200) DEFAULT NULL, - PRIMARY KEY (`id`), - UNIQUE KEY `email` (`email`), - KEY `idx_users_deleted_at` (`deleted_at`) -) ENGINE=InnoDB DEFAULT CHARSET=latin1; +CREATE TABLE users ( + id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + full_name VARCHAR(255) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, + deleted_at DATETIME DEFAULT NULL +) ENGINE=InnoDB; -CREATE TABLE `posts` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `created_at` datetime DEFAULT NULL, - `updated_at` datetime DEFAULT NULL, - `deleted_at` datetime DEFAULT NULL, - `title` varchar(255) DEFAULT NULL, - `content` varchar(255) DEFAULT NULL, - `user_id` int(10) unsigned DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `idx_posts_deleted_at` (`deleted_at`), - KEY `posts_user_id_users_id_foreign` (`user_id`), - CONSTRAINT `posts_user_id_users_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=latin1; +CREATE TABLE posts ( + id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + user_id INT UNSIGNED NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, + deleted_at DATETIME DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB; -- +goose Down DROP TABLE `posts`; diff --git a/internal/domain/post.go b/internal/domain/post.go new file mode 100644 index 0000000..37081d3 --- /dev/null +++ b/internal/domain/post.go @@ -0,0 +1,45 @@ +package domain + +import ( + "errors" + "fmt" +) + +const maxLimit = 100 + +type CreatePostRequest struct { + UserID uint + Title string + Content string +} + +type UpdatePostRequest struct { + PostID uint + UserID uint + Title string + Content string +} + +type PostFilters struct { + UserID uint + Title string + Offset int64 + Limit int64 +} + +func (f PostFilters) Validate() error { + var err error + if f.Limit < 1 { + err = errors.Join(err, errors.New("limit should be at least 1")) + } + + if f.Limit > maxLimit { + err = errors.Join(err, fmt.Errorf("limit should be not more than %d", maxLimit)) + } + + if f.Offset < 0 { + err = errors.Join(err, errors.New("offset should be positive number")) + } + + return err +} diff --git a/internal/model/post.go b/internal/model/post.go index 462ac79..9a99051 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -4,8 +4,8 @@ import "gorm.io/gorm" type Post struct { gorm.Model - Title string `json:"title"` - Content string `json:"content"` + UserID uint - User User `gorm:"foreignkey:UserID"` + Title string + Content string } diff --git a/internal/model/user.go b/internal/model/user.go index c59de3b..1ad8ab3 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -4,7 +4,8 @@ import "gorm.io/gorm" type User struct { gorm.Model - Email string `gorm:"type:varchar(200);UNIQUE"` - Password string `gorm:"type:varchar(200);"` - FullName string `gorm:"type:varchar(200);"` + + Email string + Password string + FullName string } diff --git a/internal/provider/jwt_auth.go b/internal/provider/jwt_auth.go deleted file mode 100644 index eb940b9..0000000 --- a/internal/provider/jwt_auth.go +++ /dev/null @@ -1,180 +0,0 @@ -package provider - -import ( - "log" - "log/slog" - "sync" - "time" - - "github.com/nix-united/golang-gin-boilerplate/internal/model" - "github.com/nix-united/golang-gin-boilerplate/internal/repository" - "github.com/nix-united/golang-gin-boilerplate/internal/request" - "github.com/nix-united/golang-gin-boilerplate/internal/utils" - - jwt "github.com/appleboy/gin-jwt/v2" - "github.com/gin-gonic/gin" - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" -) - -const identityKey = "id" - -type Success struct { - Code int `json:"code" example:"200"` - Expire string `json:"expire"` - Token string `json:"token"` -} - -var once sync.Once - -var mw *jwtAuthMiddleware - -func NewJwtAuth(db *gorm.DB) JwtAuthMiddleware { - once.Do(func() { - var err error - - mw = &jwtAuthMiddleware{ - databaseDriver: db, - } - - mw.authMiddleware, err = jwt.New(mw.prepareMiddleware()) - - if err != nil { - log.Fatal("JWT error") - } - }) - - return mw -} - -type JwtAuthMiddleware interface { - Middleware() *jwt.GinJWTMiddleware - Refresh(c *gin.Context) -} - -type jwtAuthMiddleware struct { - databaseDriver *gorm.DB - authMiddleware *jwt.GinJWTMiddleware -} - -func (mw *jwtAuthMiddleware) Middleware() *jwt.GinJWTMiddleware { - return mw.authMiddleware -} - -func (mw *jwtAuthMiddleware) prepareMiddleware() *jwt.GinJWTMiddleware { - jwtSettings, err := utils.NewJwtEnvVars() - - if err != nil { - log.Fatal(err) - } - - middleware := &jwt.GinJWTMiddleware{ - Realm: jwtSettings.Realm(), - Key: []byte(jwtSettings.Secret()), - Timeout: jwtSettings.Expiration(), - MaxRefresh: jwtSettings.RefreshTime(), - IdentityKey: identityKey, - PayloadFunc: addUserIDToClaims, - IdentityHandler: extractIdentityKeyFromClaims, - Authorizator: mw.isUserValid, - Authenticator: mw.authenticate, - HTTPStatusMessageFunc: takeAppropriateErrorMessage, - TimeFunc: time.Now, - } - - return middleware -} - -// authenticate godoc -// @Summary Authenticate a user -// @Description Perform user login -// @ID user-login -// @Tags User Actions -// @Accept json -// @Produce json -// @Param params body request.BasicAuthRequest true "User's credentials" -// @Success 200 {object} Success -// @Failure 401 {object} response.Error -// @Router /login [post] -func (mw jwtAuthMiddleware) authenticate(c *gin.Context) (interface{}, error) { - var authRequest request.BasicAuthRequest - - if err := c.ShouldBindJSON(&authRequest); err != nil { - return model.User{}, jwt.ErrMissingLoginValues - } - - userRepository := repository.NewUserRepository(mw.databaseDriver) - - user, err := userRepository.GetByEmail(c.Request.Context(), authRequest.Email) - if err != nil { - return nil, jwt.ErrFailedAuthentication - } - - if user.ID == 0 || (bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(authRequest.Password)) != nil) { - return user, jwt.ErrFailedAuthentication - } - - return user, nil -} - -// refresh godoc -// @Summary Refresh token -// @Description Refresh user's token -// @ID refresh-token -// @Tags User Actions -// @Produce json -// @Success 200 {object} Success -// @Failure 401 {object} response.Error -// @Security ApiKeyAuth -// @Router /refresh [get] -func (mw jwtAuthMiddleware) Refresh(c *gin.Context) { - mw.Middleware().RefreshHandler(c) -} - -func (mw jwtAuthMiddleware) isUserValid(data interface{}, c *gin.Context) bool { - userID, ok := data.(float64) - if !ok { - return false - } - - userRepository := repository.NewUserRepository(mw.databaseDriver) - - _, err := userRepository.GetByID(c.Request.Context(), uint(userID)) - if err != nil { - slog.WarnContext(c.Request.Context(), "Failed to get user by ID to authorize", "err", err) - - return false - } - - return true -} - -func extractIdentityKeyFromClaims(c *gin.Context) interface{} { - identity, ok := jwt.ExtractClaims(c)[identityKey].(float64) - if !ok { - return 0 - } - - return identity -} - -func addUserIDToClaims(data interface{}) jwt.MapClaims { - if user, ok := data.(*model.User); ok { - return jwt.MapClaims{ - identityKey: user.ID, - } - } - - return jwt.MapClaims{} -} - -func takeAppropriateErrorMessage(err error, _ *gin.Context) string { - switch err { - case jwt.ErrMissingLoginValues: - return "Email and password are required" - case jwt.ErrFailedAuthentication: - return "Invalid email or password" - } - - return err.Error() -} diff --git a/internal/repository/post_repository.go b/internal/repository/post_repository.go index fe5663d..0f9f67f 100644 --- a/internal/repository/post_repository.go +++ b/internal/repository/post_repository.go @@ -19,12 +19,46 @@ func NewPostRepository(db *gorm.DB) *PostRepository { return &PostRepository{db: db} } -func (r *PostRepository) Create(ctx context.Context, post *model.Post) error { +func (r *PostRepository) Create(ctx context.Context, post *model.Post) (*model.Post, error) { if err := r.db.WithContext(ctx).Create(post).Error; err != nil { - return fmt.Errorf("execute insert post query: %w", err) + return nil, fmt.Errorf("execute insert post query: %w", err) } - return nil + return post, nil +} + +func (r *PostRepository) Count(ctx context.Context, filters domain.PostFilters) (int64, error) { + tx := r.db.WithContext(ctx) + if filters.UserID != 0 { + tx = tx.Where("user_id = ?", filters.UserID) + } + if filters.Title != "" { + tx = tx.Where("title LIKE CONCAT('%', ?, '%')", filters.Title) + } + + var count int64 + if err := tx.Model(&model.Post{}).Count(&count).Error; err != nil { + return 0, fmt.Errorf("execute count number of posts query: %w", err) + } + + return count, nil +} + +func (r *PostRepository) List(ctx context.Context, filters domain.PostFilters) ([]model.Post, error) { + tx := r.db.WithContext(ctx).Offset(int(filters.Offset)).Limit(int(filters.Limit)) + if filters.UserID != 0 { + tx = tx.Where("user_id = ?", filters.UserID) + } + if filters.Title != "" { + tx = tx.Where("title LIKE CONCAT('%', ?, '%')", filters.Title) + } + + var posts []model.Post + if err := tx.Find(&posts).Error; err != nil { + return nil, fmt.Errorf("execute select posts query: %w", err) + } + + return posts, nil } func (r *PostRepository) GetByID(ctx context.Context, id uint) (*model.Post, error) { @@ -40,15 +74,6 @@ func (r *PostRepository) GetByID(ctx context.Context, id uint) (*model.Post, err return post, nil } -func (r *PostRepository) List(ctx context.Context) ([]model.Post, error) { - var posts []model.Post - if err := r.db.WithContext(ctx).Find(&posts).Error; err != nil { - return nil, fmt.Errorf("execute select posts query: %w", err) - } - - return posts, nil -} - func (r *PostRepository) Update(ctx context.Context, post *model.Post) error { if err := r.db.WithContext(ctx).Save(post).Error; err != nil { return fmt.Errorf("execute update post query: %w", err) diff --git a/internal/request/auth_requests.go b/internal/request/auth_requests.go index 0865716..17b2e5b 100644 --- a/internal/request/auth_requests.go +++ b/internal/request/auth_requests.go @@ -5,9 +5,7 @@ import ( "github.com/go-ozzo/ozzo-validation/is" ) -const ( - minPathLength = 8 -) +const minPasswordLength = 8 type RefreshRequest struct { Token string `json:"token" validate:"required" example:"refresh_token"` @@ -18,9 +16,10 @@ type BasicAuthRequest struct { Password string `json:"password" binding:"required" example:"11111111"` } -func (ar BasicAuthRequest) Validate() error { - return validation.ValidateStruct(&ar, - validation.Field(&ar.Email, is.Email), - validation.Field(&ar.Password, validation.Length(minPathLength, 0)), +func (r BasicAuthRequest) Validate() error { + return validation.ValidateStruct( + &r, + validation.Field(&r.Email, is.Email), + validation.Field(&r.Password, validation.Length(minPasswordLength, 0)), ) } diff --git a/internal/request/post_requests.go b/internal/request/post_requests.go index 92aecb2..e428748 100644 --- a/internal/request/post_requests.go +++ b/internal/request/post_requests.go @@ -2,22 +2,25 @@ package request import validation "github.com/go-ozzo/ozzo-validation" +const minPostTitleLength = 5 + type BasicPost struct { - Title string `json:"title" binding:"required" example:"New Post"` - Content string `json:"content" binding:"required" example:"Lorem Ipsum"` + Title string `json:"title" binding:"required" example:"New Post"` + Content string `json:"content" binding:"required" example:"Lorem Ipsum"` } -type CreatePostRequest struct { - *BasicPost +func (p BasicPost) Validate() error { + return validation.ValidateStruct( + &p, + validation.Field(&p.Title, validation.Length(minPostTitleLength, 255)), + validation.Field(&p.Content, validation.Required), + ) } -type UpdatePostRequest struct { - *BasicPost +type CreatePostRequest struct { + BasicPost } -func (bp BasicPost) Validate() error { - return validation.ValidateStruct(&bp, - validation.Field(&bp.Title, validation.Required), - validation.Field(&bp.Content, validation.Required), - ) +type UpdatePostRequest struct { + BasicPost } diff --git a/internal/request/register_requests.go b/internal/request/register_requests.go index 96f7e87..908b54c 100644 --- a/internal/request/register_requests.go +++ b/internal/request/register_requests.go @@ -7,13 +7,14 @@ import ( ) type RegisterRequest struct { - *BasicAuthRequest + BasicAuthRequest + FullName string `json:"full_name" validate:"required" example:"John Doe"` } -func (rr *RegisterRequest) Validate() error { +func (r RegisterRequest) Validate() error { return errors.Join( - rr.BasicAuthRequest.Validate(), - validation.ValidateStruct(rr, validation.Field(&rr.FullName, validation.Required)), + r.BasicAuthRequest.Validate(), + validation.ValidateStruct(&r, validation.Field(&r.FullName, validation.Required)), ) } diff --git a/internal/response/auth_responses.go b/internal/response/auth_responses.go new file mode 100644 index 0000000..c2d147b --- /dev/null +++ b/internal/response/auth_responses.go @@ -0,0 +1,12 @@ +package response + +// AuthTokenResponse represents a RFC 6749 compliant token response with refresh token. +type AuthTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + + // ExpiresIn represents the lifetime in seconds of the access token. + ExpiresIn int64 `json:"expires_in"` + + RefreshToken string `json:"refresh_token"` +} diff --git a/internal/response/common_responses.go b/internal/response/common_responses.go new file mode 100644 index 0000000..9387fc8 --- /dev/null +++ b/internal/response/common_responses.go @@ -0,0 +1,55 @@ +package response + +type MessageResponse struct { + Message string `json:"message"` +} + +func NewMessageResponse(message string) MessageResponse { + return MessageResponse{Message: message} +} + +type CollectionResponse[T any] struct { + Data []T `json:"data"` + Meta Meta `json:"meta"` +} + +func NewCollectionResponse[T any](items []T, total, offset, limit int64) CollectionResponse[T] { + return CollectionResponse[T]{ + Data: items, + Meta: Meta{ + Count: int64(len(items)), + Total: total, + Offset: offset, + Limit: limit, + }, + } +} + +type Meta struct { + Count int64 `json:"count"` + Total int64 `json:"total"` + Offset int64 `json:"offset"` + Limit int64 `json:"limit"` +} + +type ErrorResponseCode string + +const ( + CodeBadRequest ErrorResponseCode = "bad_request" + CodeNotFound ErrorResponseCode = "not_found" + CodeAlreadyExists ErrorResponseCode = "already_exists" + CodeAccessDenied ErrorResponseCode = "access_denied" + CodeInternalServerError ErrorResponseCode = "internal_server_error" +) + +type ErrorResponse struct { + Code ErrorResponseCode `json:"code"` + Message string `json:"message"` +} + +func NewErrorResponse(code ErrorResponseCode, message string) ErrorResponse { + return ErrorResponse{ + Code: code, + Message: message, + } +} diff --git a/internal/response/post_responses.go b/internal/response/post_responses.go index 9aa0d28..b9f567d 100644 --- a/internal/response/post_responses.go +++ b/internal/response/post_responses.go @@ -1,37 +1,36 @@ package response -import "github.com/nix-united/golang-gin-boilerplate/internal/model" +import ( + "time" -type CreatePostResponse struct { - ID uint `json:"id"` - Title string `json:"title"` - Content string `json:"content"` -} - -type GetPostResponse struct { - ID uint `json:"id"` - Title string `json:"title"` - Content string `json:"content"` -} + "github.com/nix-united/golang-gin-boilerplate/internal/model" +) -type CollectionResponse struct { - Collection interface{} `json:"collection"` - Meta Meta `json:"meta"` +type PostResponse struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + Title string `json:"title"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } -type Meta struct { - Amount int `json:"amount"` +func NewPostResponse(post *model.Post) PostResponse { + return PostResponse{ + ID: post.ID, + UserID: post.UserID, + Title: post.Title, + Content: post.Content, + CreatedAt: post.CreatedAt.Format(time.RFC3339), + UpdatedAt: post.UpdatedAt.Format(time.RFC3339), + } } -func CreatePostsCollectionResponse(posts []model.Post) CollectionResponse { - collection := make([]GetPostResponse, 0) - - for index := range posts { - collection = append(collection, GetPostResponse{ - ID: posts[index].ID, - Title: posts[index].Title, - Content: posts[index].Content, - }) +func NewPostCollectionResponse(posts []model.Post, total, offset, limit int64) CollectionResponse[PostResponse] { + responses := make([]PostResponse, len(posts)) + for i, post := range posts { + responses[i] = NewPostResponse(&post) } - return CollectionResponse{Collection: collection, Meta: Meta{Amount: len(collection)}} + + return NewCollectionResponse(responses, total, offset, limit) } diff --git a/internal/response/response_wrapper.go b/internal/response/response_wrapper.go deleted file mode 100644 index 3bb579e..0000000 --- a/internal/response/response_wrapper.go +++ /dev/null @@ -1,24 +0,0 @@ -package response - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -type Error struct { - Code int `json:"code"` - Message string `json:"message"` -} - -func Response(context *gin.Context, statusCode int, data interface{}) { - context.JSON(statusCode, data) -} - -func SuccessResponse(context *gin.Context, data interface{}) { - Response(context, http.StatusOK, data) -} - -func ErrorResponse(context *gin.Context, statusCode int, message string) { - Response(context, statusCode, Error{Code: statusCode, Message: message}) -} diff --git a/internal/server/handler/auth_handler.go b/internal/server/handler/auth_handler.go index ae2e0b9..e1fd222 100644 --- a/internal/server/handler/auth_handler.go +++ b/internal/server/handler/auth_handler.go @@ -4,71 +4,236 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" + "time" "github.com/nix-united/golang-gin-boilerplate/internal/domain" + "github.com/nix-united/golang-gin-boilerplate/internal/model" "github.com/nix-united/golang-gin-boilerplate/internal/request" "github.com/nix-united/golang-gin-boilerplate/internal/response" + "github.com/nix-united/golang-gin-boilerplate/internal/slogx" + ginjwt "github.com/appleboy/gin-jwt/v3" + "github.com/appleboy/gin-jwt/v3/core" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" ) //go:generate mockgen -source=$GOFILE -destination=auth_handler_mock_test.go -package=${GOPACKAGE}_test -typed=true +const identityKey = "id" + type userService interface { - CreateUser(ctx context.Context, req request.RegisterRequest) error + CreateUser(ctx context.Context, registerRequest request.RegisterRequest) error + GetUserByEmail(ctx context.Context, email string) (*model.User, error) +} + +type passwordService interface { + VerifyPassword(actual, received string) error +} + +type AuthHandlerConfig struct { + ApplicationName string + JWTSecret string + JWTTokenDuration time.Duration + JWTTokenRefreshDuration time.Duration + UserService userService + PasswordService passwordService } type AuthHandler struct { - userService userService + ginJWT ginjwt.GinJWTMiddleware + userService userService + passwordService passwordService } -func NewAuthHandler(userService userService) *AuthHandler { - return &AuthHandler{userService: userService} +func NewAuthHandler(config AuthHandlerConfig) (*AuthHandler, error) { + authHandler := &AuthHandler{ + userService: config.UserService, + passwordService: config.PasswordService, + } + + authHandler.ginJWT = ginjwt.GinJWTMiddleware{ + Realm: config.ApplicationName, + Key: []byte(config.JWTSecret), + Timeout: config.JWTTokenDuration, + MaxRefresh: config.JWTTokenRefreshDuration, + Authenticator: authHandler.authenticate, + PayloadFunc: authHandler.payload, + HTTPStatusMessageFunc: authHandler.mapHTTPStatusMessage, + Unauthorized: authHandler.respondWithUnauthorized, + LoginResponse: authHandler.respondWithAuthToken, + RefreshResponse: authHandler.respondWithAuthToken, + IdentityHandler: authHandler.identityHandler, + } + + if err := authHandler.ginJWT.MiddlewareInit(); err != nil { + return nil, fmt.Errorf("init gin jwt middleware: %w", err) + } + + return authHandler, nil } // RegisterUser godoc -// @Summary Register -// @Description New user registration -// @ID user-register +// @Summary Register user +// @ID register // @Tags User Actions // @Accept json // @Produce json -// @Param params body request.RegisterRequest true "User's email, password, full name" -// @Success 200 {string} string "Successfully registered" -// @Failure 422 {object} response.Error -// @Router /users [post] +// @Param params body request.RegisterRequest true "User's email, password and full name" +// @Success 200 {object} response.MessageResponse +// @Failure 400 {object} response.ErrorResponse +// @Failure 409 {object} response.ErrorResponse +// @Failure 500 {object} response.ErrorResponse +// @Router /register [post] func (h *AuthHandler) RegisterUser(c *gin.Context) { var registerRequest request.RegisterRequest if err := c.ShouldBindJSON(®isterRequest); err != nil { c.Error(fmt.Errorf("bind: %w", err)) - - response.ErrorResponse( - c, - http.StatusUnprocessableEntity, - "Required fields are empty or email is not valid", - ) + c.JSON(http.StatusBadRequest, response.NewErrorResponse( + response.CodeBadRequest, + "Invalid request", + )) return } if err := registerRequest.Validate(); err != nil { c.Error(fmt.Errorf("validate: %w", err)) - - response.ErrorResponse(c, http.StatusBadRequest, "Invalid Request") + c.JSON(http.StatusBadRequest, response.NewErrorResponse( + response.CodeBadRequest, + "Invalid request", + )) return } if err := h.userService.CreateUser(c.Request.Context(), registerRequest); err != nil { c.Error(fmt.Errorf("create user: %w", err)) - if errors.Is(err, domain.ErrAlreadyExists) { - response.ErrorResponse(c, http.StatusUnprocessableEntity, "Such user already exists") + c.JSON(http.StatusConflict, response.NewErrorResponse( + response.CodeAlreadyExists, + "Such user already exists", + )) return } - - response.ErrorResponse(c, http.StatusInternalServerError, "Oops, something went wrong...") + c.JSON(http.StatusInternalServerError, response.NewErrorResponse( + response.CodeBadRequest, + "Oops, something went wrong...", + )) return } - response.SuccessResponse(c, "Successfully registered") + c.JSON(http.StatusOK, response.NewMessageResponse("Successfully registered")) +} + +// LoginUser godoc +// @Summary Login user +// @ID login +// @Tags User Actions +// @Accept json +// @Produce json +// @Param params body request.BasicAuthRequest true "User's credentials" +// @Failure 200 {object} response.AuthTokenResponse +// @Failure 401 {object} response.ErrorResponse +// @Failure 500 {object} response.ErrorResponse +// @Router /login [post] +func (h *AuthHandler) LoginUser(c *gin.Context) { + h.ginJWT.LoginHandler(c) +} + +// RefreshUserToken godoc +// @Summary Refresh user token +// @ID refreshUserToken +// @Tags User Actions +// @Produce json +// @Failure 200 {object} response.AuthTokenResponse +// @Failure 401 {object} response.ErrorResponse +// @Failure 500 {object} response.ErrorResponse +// @Security ApiKeyAuth +// @Router /refresh [post] +func (h *AuthHandler) RefreshUserToken(c *gin.Context) { + h.ginJWT.RefreshHandler(c) +} + +func (h *AuthHandler) Middleware(c *gin.Context) { + h.ginJWT.MiddlewareFunc()(c) +} + +// Returned data will be propagated to [AuthHandler.payload] func by lib. +func (h *AuthHandler) authenticate(c *gin.Context) (any, error) { + var authRequest request.BasicAuthRequest + if err := c.ShouldBindJSON(&authRequest); err != nil { + err = errors.Join(fmt.Errorf("bind: %w", err), ginjwt.ErrMissingLoginValues) + c.Error(err) + return nil, err + } + + storedUser, err := h.userService.GetUserByEmail(c.Request.Context(), authRequest.Email) + if err != nil { + err = fmt.Errorf("get user by email: %w", err) + if errors.Is(err, domain.ErrNotFound) { + err = errors.Join(err, ginjwt.ErrFailedAuthentication) + } + c.Error(err) + return nil, err + } + + if err := h.passwordService.VerifyPassword(storedUser.Password, authRequest.Password); err != nil { + err = errors.Join(fmt.Errorf("verify password: %w", err), ginjwt.ErrFailedAuthentication) + c.Error(err) + return nil, err + } + + return storedUser, nil +} + +// payload receives data from [AuthHandler.authenticate]. +func (h *AuthHandler) payload(data any) jwt.MapClaims { + user, ok := data.(*model.User) + if !ok { + return jwt.MapClaims{} + } + return jwt.MapClaims{identityKey: user.ID} +} + +// mapHTTPStatusMessage propagates message to [AuthHandler.respondWithUnauthorized] by lib. +func (h *AuthHandler) mapHTTPStatusMessage(_ *gin.Context, err error) string { + switch { + case errors.Is(err, ginjwt.ErrMissingLoginValues): + return "Email and password are required" + case errors.Is(err, ginjwt.ErrFailedAuthentication): + return "Invalid email or password" + default: + return "Internal server error" + } +} + +// respondWithUnauthorized receives message from [AuthHandler.mapHTTPStatusMessage]. +func (h *AuthHandler) respondWithUnauthorized(c *gin.Context, code int, message string) { + c.JSON(code, response.NewErrorResponse(response.CodeAccessDenied, message)) +} + +// respondWithAuthToken maps generated token to ersponse. +func (h *AuthHandler) respondWithAuthToken(c *gin.Context, token *core.Token) { + c.JSON(http.StatusOK, response.AuthTokenResponse{ + AccessToken: token.AccessToken, + TokenType: token.TokenType, + ExpiresIn: token.ExpiresIn(), + RefreshToken: token.RefreshToken, + }) +} + +// identityHandler is called by [AuthHandler.Middleware] to determine user identity. +func (h *AuthHandler) identityHandler(c *gin.Context) any { + ctx := c.Request.Context() + userID, err := getUserIDFromContext(c) + if err != nil { + slog.ErrorContext(ctx, "Failed to get user ID in identity handler", "err", err) + return 0 + } + + // Set user ID to propagate between log messages. + c.Request = c.Request.WithContext(slogx.ContextWithUserID(ctx, userID)) + + return userID } diff --git a/internal/server/handler/auth_handler_mock_test.go b/internal/server/handler/auth_handler_mock_test.go index a34f859..d16ef81 100644 --- a/internal/server/handler/auth_handler_mock_test.go +++ b/internal/server/handler/auth_handler_mock_test.go @@ -13,6 +13,7 @@ import ( context "context" reflect "reflect" + model "github.com/nix-united/golang-gin-boilerplate/internal/model" request "github.com/nix-united/golang-gin-boilerplate/internal/request" gomock "go.uber.org/mock/gomock" ) @@ -41,17 +42,17 @@ func (m *MockuserService) EXPECT() *MockuserServiceMockRecorder { } // CreateUser mocks base method. -func (m *MockuserService) CreateUser(ctx context.Context, req request.RegisterRequest) error { +func (m *MockuserService) CreateUser(ctx context.Context, registerRequest request.RegisterRequest) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateUser", ctx, req) + ret := m.ctrl.Call(m, "CreateUser", ctx, registerRequest) ret0, _ := ret[0].(error) return ret0 } // CreateUser indicates an expected call of CreateUser. -func (mr *MockuserServiceMockRecorder) CreateUser(ctx, req any) *MockuserServiceCreateUserCall { +func (mr *MockuserServiceMockRecorder) CreateUser(ctx, registerRequest any) *MockuserServiceCreateUserCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockuserService)(nil).CreateUser), ctx, req) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockuserService)(nil).CreateUser), ctx, registerRequest) return &MockuserServiceCreateUserCall{Call: call} } @@ -77,3 +78,103 @@ func (c *MockuserServiceCreateUserCall) DoAndReturn(f func(context.Context, requ c.Call = c.Call.DoAndReturn(f) return c } + +// GetUserByEmail mocks base method. +func (m *MockuserService) GetUserByEmail(ctx context.Context, email string) (*model.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserByEmail", ctx, email) + ret0, _ := ret[0].(*model.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserByEmail indicates an expected call of GetUserByEmail. +func (mr *MockuserServiceMockRecorder) GetUserByEmail(ctx, email any) *MockuserServiceGetUserByEmailCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByEmail", reflect.TypeOf((*MockuserService)(nil).GetUserByEmail), ctx, email) + return &MockuserServiceGetUserByEmailCall{Call: call} +} + +// MockuserServiceGetUserByEmailCall wrap *gomock.Call +type MockuserServiceGetUserByEmailCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockuserServiceGetUserByEmailCall) Return(arg0 *model.User, arg1 error) *MockuserServiceGetUserByEmailCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockuserServiceGetUserByEmailCall) Do(f func(context.Context, string) (*model.User, error)) *MockuserServiceGetUserByEmailCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockuserServiceGetUserByEmailCall) DoAndReturn(f func(context.Context, string) (*model.User, error)) *MockuserServiceGetUserByEmailCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockpasswordService is a mock of passwordService interface. +type MockpasswordService struct { + ctrl *gomock.Controller + recorder *MockpasswordServiceMockRecorder +} + +// MockpasswordServiceMockRecorder is the mock recorder for MockpasswordService. +type MockpasswordServiceMockRecorder struct { + mock *MockpasswordService +} + +// NewMockpasswordService creates a new mock instance. +func NewMockpasswordService(ctrl *gomock.Controller) *MockpasswordService { + mock := &MockpasswordService{ctrl: ctrl} + mock.recorder = &MockpasswordServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockpasswordService) EXPECT() *MockpasswordServiceMockRecorder { + return m.recorder +} + +// VerifyPassword mocks base method. +func (m *MockpasswordService) VerifyPassword(actual, received string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VerifyPassword", actual, received) + ret0, _ := ret[0].(error) + return ret0 +} + +// VerifyPassword indicates an expected call of VerifyPassword. +func (mr *MockpasswordServiceMockRecorder) VerifyPassword(actual, received any) *MockpasswordServiceVerifyPasswordCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyPassword", reflect.TypeOf((*MockpasswordService)(nil).VerifyPassword), actual, received) + return &MockpasswordServiceVerifyPasswordCall{Call: call} +} + +// MockpasswordServiceVerifyPasswordCall wrap *gomock.Call +type MockpasswordServiceVerifyPasswordCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockpasswordServiceVerifyPasswordCall) Return(arg0 error) *MockpasswordServiceVerifyPasswordCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockpasswordServiceVerifyPasswordCall) Do(f func(string, string) error) *MockpasswordServiceVerifyPasswordCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockpasswordServiceVerifyPasswordCall) DoAndReturn(f func(string, string) error) *MockpasswordServiceVerifyPasswordCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/server/handler/auth_handler_test.go b/internal/server/handler/auth_handler_test.go index a164743..8564b2f 100644 --- a/internal/server/handler/auth_handler_test.go +++ b/internal/server/handler/auth_handler_test.go @@ -3,23 +3,29 @@ package handler_test import ( "bytes" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" "testing" + "time" "github.com/nix-united/golang-gin-boilerplate/internal/domain" + "github.com/nix-united/golang-gin-boilerplate/internal/model" "github.com/nix-united/golang-gin-boilerplate/internal/request" + "github.com/nix-united/golang-gin-boilerplate/internal/response" "github.com/nix-united/golang-gin-boilerplate/internal/server/handler" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" + gomock "go.uber.org/mock/gomock" + "gorm.io/gorm" ) type authHandlerMocks struct { - userService *MockuserService + userService *MockuserService + passwordService *MockpasswordService } func newAuthHandler(t *testing.T) (*gin.Engine, authHandlerMocks) { @@ -27,13 +33,25 @@ func newAuthHandler(t *testing.T) (*gin.Engine, authHandlerMocks) { ctrl := gomock.NewController(t) userService := NewMockuserService(ctrl) - authHandler := handler.NewAuthHandler(userService) + passwordService := NewMockpasswordService(ctrl) + authHandler, err := handler.NewAuthHandler(handler.AuthHandlerConfig{ + ApplicationName: "auth_handler_test", + JWTSecret: "auth_handler_secret", + JWTTokenDuration: time.Minute, + JWTTokenRefreshDuration: time.Minute, + UserService: userService, + PasswordService: passwordService, + }) + require.NoError(t, err) engine := gin.New() - engine.POST("/users", authHandler.RegisterUser) + engine.POST("/register", authHandler.RegisterUser) + engine.POST("/login", authHandler.LoginUser) + engine.POST("/refresh", authHandler.Middleware, authHandler.RefreshUserToken) mocks := authHandlerMocks{ - userService: userService, + userService: userService, + passwordService: passwordService, } return engine, mocks @@ -41,8 +59,8 @@ func newAuthHandler(t *testing.T) (*gin.Engine, authHandlerMocks) { func TestAuthHandler_RegisterUser(t *testing.T) { registerRequest := request.RegisterRequest{ - BasicAuthRequest: &request.BasicAuthRequest{ - Email: "oleksandr.khmil@gmail.com", + BasicAuthRequest: request.BasicAuthRequest{ + Email: "name.surname@gmail.com", Password: "strong-password", }, FullName: "full-name", @@ -55,7 +73,7 @@ func TestAuthHandler_RegisterUser(t *testing.T) { engine, _ := newAuthHandler(t) badRegisterRequest := registerRequest - badRegisterRequest.BasicAuthRequest = &request.BasicAuthRequest{ + badRegisterRequest.BasicAuthRequest = request.BasicAuthRequest{ Email: registerRequest.Email, Password: "weak", } @@ -65,7 +83,7 @@ func TestAuthHandler_RegisterUser(t *testing.T) { httpRequest := httptest.NewRequest( http.MethodPost, - "https://example.com/users", + "https://example.com/register", bytes.NewReader(rawBadRegisterRequest), ) @@ -81,14 +99,14 @@ func TestAuthHandler_RegisterUser(t *testing.T) { assert.Equal(t, http.StatusBadRequest, response.StatusCode) expectedResponse := `{ - "code": 400, - "message": "Invalid Request" + "code": "bad_request", + "message": "Invalid request" }` assert.JSONEq(t, expectedResponse, string(responseBody)) }) - t.Run("It should respond with 422 status if received invalid storage operation error", func(t *testing.T) { + t.Run("It should respond with 409 status if received invalid storage operation error", func(t *testing.T) { engine, mocks := newAuthHandler(t) mocks.userService. @@ -98,7 +116,7 @@ func TestAuthHandler_RegisterUser(t *testing.T) { httpRequest := httptest.NewRequest( http.MethodPost, - "https://example.com/users", + "https://example.com/register", bytes.NewReader(rawRegisterRequest), ) @@ -111,10 +129,10 @@ func TestAuthHandler_RegisterUser(t *testing.T) { responseBody, err := io.ReadAll(response.Body) require.NoError(t, err) - assert.Equal(t, http.StatusUnprocessableEntity, response.StatusCode) + assert.Equal(t, http.StatusConflict, response.StatusCode) expectedResponse := `{ - "code": 422, + "code": "already_exists", "message": "Such user already exists" }` @@ -131,20 +149,138 @@ func TestAuthHandler_RegisterUser(t *testing.T) { httpRequest := httptest.NewRequest( http.MethodPost, - "https://example.com/users", + "https://example.com/register", bytes.NewReader(rawRegisterRequest), ) recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, httpRequest) - response := recorder.Result() - defer response.Body.Close() + httpResponse := recorder.Result() + defer httpResponse.Body.Close() - responseBody, err := io.ReadAll(response.Body) + responseBody, err := io.ReadAll(httpResponse.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, httpResponse.StatusCode) + + wantResponse := response.MessageResponse{ + Message: "Successfully registered", + } + + var gotResponse response.MessageResponse + err = json.Unmarshal(responseBody, &gotResponse) + require.NoError(t, err) + + assert.Equal(t, wantResponse, gotResponse) + }) +} + +func TestAuthHandler_Login(t *testing.T) { + storedUser := &model.User{ + Model: gorm.Model{ + ID: 1, + }, + Email: "name.surname@gmail.com", + Password: "strong-password", + FullName: "name.surname", + } + + invalidLoginRequest := request.BasicAuthRequest{ + Email: storedUser.Email, + Password: "wrong-password", + } + + rawInvalidLoginRequest, err := json.Marshal(invalidLoginRequest) + require.NoError(t, err) + + validLoginRequest := request.BasicAuthRequest{ + Email: storedUser.Email, + Password: storedUser.Password, + } + + rawValidLoginRequest, err := json.Marshal(validLoginRequest) + require.NoError(t, err) + + t.Run("It should return an error when password is wrong", func(t *testing.T) { + engine, mocks := newAuthHandler(t) + + mocks.userService. + EXPECT(). + GetUserByEmail(gomock.Any(), validLoginRequest.Email). + Return(storedUser, nil) + + mocks.passwordService. + EXPECT(). + VerifyPassword(storedUser.Password, invalidLoginRequest.Password). + Return(errors.New("invalid password")) + + httpRequest := httptest.NewRequest( + http.MethodPost, + "https://example.com/login", + bytes.NewReader(rawInvalidLoginRequest), + ) + + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, httpRequest) + + httpResponse := recorder.Result() + defer httpResponse.Body.Close() + + responseBody, err := io.ReadAll(httpResponse.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusUnauthorized, httpResponse.StatusCode) + + var gotResponse response.ErrorResponse + err = json.Unmarshal(responseBody, &gotResponse) + require.NoError(t, err) + + wantResponse := response.ErrorResponse{ + Code: response.CodeAccessDenied, + Message: "Invalid email or password", + } + + assert.Equal(t, wantResponse, gotResponse) + }) + + t.Run("It should login an user when password is correct", func(t *testing.T) { + engine, mocks := newAuthHandler(t) + + mocks.userService. + EXPECT(). + GetUserByEmail(gomock.Any(), validLoginRequest.Email). + Return(storedUser, nil) + + mocks.passwordService. + EXPECT(). + VerifyPassword(storedUser.Password, validLoginRequest.Password). + Return(nil) + + httpRequest := httptest.NewRequest( + http.MethodPost, + "https://example.com/login", + bytes.NewReader(rawValidLoginRequest), + ) + + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, httpRequest) + + httpResponse := recorder.Result() + defer httpResponse.Body.Close() + + responseBody, err := io.ReadAll(httpResponse.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, httpResponse.StatusCode) + + var gotResponse response.AuthTokenResponse + err = json.Unmarshal(responseBody, &gotResponse) require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.StatusCode) - assert.Equal(t, `"Successfully registered"`, string(responseBody)) + assert.NotEmpty(t, gotResponse.AccessToken) + assert.NotEmpty(t, gotResponse.RefreshToken) + assert.NotEmpty(t, gotResponse.ExpiresIn) + assert.Equal(t, "Bearer", gotResponse.TokenType) }) } diff --git a/internal/server/handler/get_user_id.go b/internal/server/handler/get_user_id.go new file mode 100644 index 0000000..4d21f7f --- /dev/null +++ b/internal/server/handler/get_user_id.go @@ -0,0 +1,28 @@ +package handler + +import ( + "errors" + "fmt" + + jwt "github.com/appleboy/gin-jwt/v3" + "github.com/ccoveille/go-safecast" + "github.com/gin-gonic/gin" +) + +// getUserIDFromContext extracts the user ID from JWT claims set by +// the authorization middleware. +func getUserIDFromContext(c *gin.Context) (uint, error) { + claims := jwt.ExtractClaims(c) + + id, ok := claims[identityKey].(float64) + if !ok { + return 0, errors.New("missing user id in claims") + } + + parsed, err := safecast.ToUint(id) + if err != nil { + return 0, fmt.Errorf("convert user id to uint: %w", err) + } + + return parsed, nil +} diff --git a/internal/server/handler/home_handler.go b/internal/server/handler/home_handler.go deleted file mode 100644 index eb1dc79..0000000 --- a/internal/server/handler/home_handler.go +++ /dev/null @@ -1,17 +0,0 @@ -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -type HomeHandler struct{} - -func NewHomeHandler() *HomeHandler { - return &HomeHandler{} -} - -func (h HomeHandler) Index(c *gin.Context) { - c.String(http.StatusOK, "Hello") -} diff --git a/internal/server/handler/post_handler.go b/internal/server/handler/post_handler.go index e5de256..47f8e3a 100644 --- a/internal/server/handler/post_handler.go +++ b/internal/server/handler/post_handler.go @@ -3,6 +3,7 @@ package handler import ( "context" "errors" + "fmt" "net/http" "strconv" @@ -11,18 +12,20 @@ import ( "github.com/nix-united/golang-gin-boilerplate/internal/request" "github.com/nix-united/golang-gin-boilerplate/internal/response" - jwt "github.com/appleboy/gin-jwt/v2" safecast "github.com/ccoveille/go-safecast" "github.com/gin-gonic/gin" ) +const defaultPostLimit = 10 + //go:generate go tool mockgen -source=$GOFILE -destination=post_handler_mock_test.go -package=${GOPACKAGE}_test -typed=true type postService interface { - Create(ctx context.Context, userID uint, title, content string) (*model.Post, error) - GetByID(ctx context.Context, id uint) (*model.Post, error) - List(ctx context.Context) ([]model.Post, error) - UpdateByUser(ctx context.Context, userID, postID uint, title, content string) (*model.Post, error) + Create(ctx context.Context, createPostRequest domain.CreatePostRequest) (*model.Post, error) + Count(ctx context.Context, filers domain.PostFilters) (int64, error) + List(ctx context.Context, filers domain.PostFilters) ([]model.Post, error) + GetByID(ctx context.Context, postID uint) (*model.Post, error) + UpdateByUser(ctx context.Context, updatePostRequest domain.UpdatePostRequest) (*model.Post, error) DeleteByUser(ctx context.Context, userID, postID uint) error } @@ -34,234 +37,367 @@ func NewPostHandler(postService postService) *PostHandler { return &PostHandler{postService: postService} } -// SavePost godoc +// CreatePost godoc // @Summary Create post -// @Description Create post -// @ID posts-create +// @ID createPost // @Tags Posts Actions // @Accept json // @Produce json // @Param params body request.CreatePostRequest true "Post title and content" -// @Success 200 {string} response.CreatePostResponse -// @Failure 400 {string} string "Bad request" +// @Success 201 {object} response.PostResponse +// @Failure 400 {object} response.ErrorResponse +// @Failure 401 {object} response.ErrorResponse +// @Failure 500 {object} response.ErrorResponse // @Security ApiKeyAuth // @Router /posts [post] -func (h *PostHandler) SavePost(c *gin.Context) { - parsedUserID, ok := jwt.ExtractClaims(c)["id"].(float64) - if !ok { - response.ErrorResponse(c, http.StatusBadRequest, "Bad request") +func (h *PostHandler) CreatePost(c *gin.Context) { + userID, err := getUserIDFromContext(c) + if err != nil { + c.Error(fmt.Errorf("get user id from context: %w", err)) + c.JSON(http.StatusInternalServerError, response.NewErrorResponse( + response.CodeInternalServerError, + "Oops, something went wrong...", + )) return } var createPostRequest request.CreatePostRequest if err := c.ShouldBindJSON(&createPostRequest); err != nil { - response.ErrorResponse(c, http.StatusBadRequest, "Required fields are empty") + c.Error(fmt.Errorf("bind: %w", err)) + c.JSON(http.StatusBadRequest, response.NewErrorResponse( + response.CodeBadRequest, + "Invalid request", + )) + return + } + + if err := createPostRequest.Validate(); err != nil { + c.Error(fmt.Errorf("validate: %w", err)) + c.JSON(http.StatusBadRequest, response.NewErrorResponse( + response.CodeBadRequest, + "Invalid request", + )) return } - userID, err := safecast.ToUint(parsedUserID) + post, err := h.postService.Create(c.Request.Context(), domain.CreatePostRequest{ + UserID: userID, + Title: createPostRequest.Title, + Content: createPostRequest.Content, + }) if err != nil { - response.ErrorResponse(c, http.StatusBadRequest, "Invalid User ID") + c.Error(fmt.Errorf("create post: %w", err)) + c.JSON(http.StatusInternalServerError, response.NewErrorResponse( + response.CodeInternalServerError, + "Oops, something went wrong...", + )) return } - post, err := h.postService.Create( - c.Request.Context(), - userID, - createPostRequest.Title, - createPostRequest.Content, - ) + c.JSON(http.StatusCreated, response.NewPostResponse(post)) +} + +// GetPosts godoc +// @Summary Get all posts +// @ID getPosts +// @Tags Posts Actions +// @Produce json +// @Param limit query int false "Limit" minimum(1) maximum(100) +// @Param offset query int false "Offset" minimum(0) +// @Param user_id query string false "User ID" +// @Param title query string false "Title" +// @Success 200 {object} response.PostResponse +// @Failure 400 {object} response.ErrorResponse +// @Failure 401 {object} response.ErrorResponse +// @Failure 500 {object} response.ErrorResponse +// @Security ApiKeyAuth +// @Router /posts [get] +func (h *PostHandler) GetPosts(c *gin.Context) { + filters, err := h.parseFilters(c) if err != nil { - response.ErrorResponse(c, http.StatusInternalServerError, "Post can't be created") + c.Error(fmt.Errorf("parse post filters: %w", err)) + c.JSON(http.StatusBadRequest, response.NewErrorResponse( + response.CodeBadRequest, + "Invalid request", + )) return } - response.SuccessResponse(c, response.CreatePostResponse{ - ID: post.ID, - Title: post.Title, - Content: post.Content, - }) + total, err := h.postService.Count(c.Request.Context(), filters) + if err != nil { + c.Error(fmt.Errorf("count posts: %w", err)) + c.JSON(http.StatusInternalServerError, response.NewErrorResponse( + response.CodeInternalServerError, + "Oops, something went wrong...", + )) + return + } + if total == 0 { + c.JSON(http.StatusOK, response.NewPostCollectionResponse(nil, 0, filters.Offset, filters.Limit)) + return + } + + posts, err := h.postService.List(c.Request.Context(), filters) + if err != nil { + c.Error(fmt.Errorf("list posts: %w", err)) + c.JSON(http.StatusInternalServerError, response.NewErrorResponse( + response.CodeInternalServerError, + "Oops, something went wrong...", + )) + return + } + c.JSON(http.StatusOK, response.NewPostCollectionResponse(posts, total, filters.Offset, filters.Limit)) } // GetPostByID godoc -// @Summary Get post by id -// @Description Get post by id -// @ID get-post +// @Summary Get post by ID +// @ID getPostById // @Tags Posts Actions // @Produce json // @Param id path int true "Post ID" -// @Success 200 {object} response.GetPostResponse -// @Failure 401 {object} response.Error +// @Success 200 {object} response.PostResponse +// @Failure 400 {object} response.ErrorResponse +// @Failure 401 {object} response.ErrorResponse +// @Failure 404 {object} response.ErrorResponse +// @Failure 500 {object} response.ErrorResponse // @Security ApiKeyAuth -// @Router /post/{id} [get] +// @Router /posts/{id} [get] func (h *PostHandler) GetPostByID(c *gin.Context) { parsedPostID, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - response.ErrorResponse(c, http.StatusBadRequest, "Bad request") + c.Error(fmt.Errorf("parse post id: %w", err)) + c.JSON(http.StatusBadRequest, response.NewErrorResponse( + response.CodeBadRequest, + "Invalid request", + )) return } postID, err := safecast.ToUint(parsedPostID) if err != nil { - response.ErrorResponse(c, http.StatusBadRequest, "Invalid Post ID") + c.Error(fmt.Errorf("convert post id to uint: %w", err)) + c.JSON(http.StatusBadRequest, response.NewErrorResponse( + response.CodeBadRequest, + "Invalid request", + )) return } post, err := h.postService.GetByID(c.Request.Context(), postID) if err != nil { + c.Error(fmt.Errorf("get post by id: %w", err)) if errors.Is(err, domain.ErrNotFound) { - response.ErrorResponse(c, http.StatusNotFound, "Post not found") + c.JSON(http.StatusNotFound, response.NewErrorResponse( + response.CodeBadRequest, + "Post not found", + )) return } - - response.ErrorResponse(c, http.StatusInternalServerError, "Server error") + c.JSON(http.StatusInternalServerError, response.NewErrorResponse( + response.CodeInternalServerError, + "Oops, something went wrong...", + )) return } - response.SuccessResponse(c, response.GetPostResponse{ - ID: post.ID, - Title: post.Title, - Content: post.Content, - }) -} - -// GetPosts godoc -// @Summary Get all posts -// @Description Get all posts of all users -// @ID get-posts -// @Tags Posts Actions -// @Produce json -// @Success 200 {object} response.CollectionResponse -// @Failure 401 {object} response.Error -// @Security ApiKeyAuth -// @Router /posts [get] -func (h *PostHandler) GetPosts(c *gin.Context) { - posts, err := h.postService.List(c.Request.Context()) - if err != nil { - response.ErrorResponse(c, http.StatusInternalServerError, "Server error") - return - } - - response.SuccessResponse(c, response.CreatePostsCollectionResponse(posts)) + c.JSON(http.StatusOK, response.NewPostResponse(post)) } // UpdatePost godoc // @Summary Update post -// @Description Update post -// @ID posts-update +// @ID updatePost // @Tags Posts Actions // @Accept json // @Produce json // @Param id path int true "Post ID" // @Param params body request.UpdatePostRequest true "Post title and content" -// @Success 200 {string} response.GetPostResponse -// @Failure 400 {string} string "Bad request" -// @Failure 404 {object} response.Error +// @Success 200 {string} response.PostResponse +// @Failure 400 {string} response.ErrorResponse +// @Failure 401 {object} response.ErrorResponse +// @Failure 403 {object} response.ErrorResponse +// @Failure 404 {object} response.ErrorResponse +// @Failure 500 {object} response.ErrorResponse // @Security ApiKeyAuth -// @Router /post/{id} [put] +// @Router /posts/{id} [put] func (h *PostHandler) UpdatePost(c *gin.Context) { - parsedUserID, ok := jwt.ExtractClaims(c)["id"].(float64) - if !ok { - response.ErrorResponse(c, http.StatusBadRequest, "Bad request") - return - } - - userID, err := safecast.ToUint(parsedUserID) + userID, err := getUserIDFromContext(c) if err != nil { - response.ErrorResponse(c, http.StatusBadRequest, "Invalid User ID") - return - } - - var updatePostRequest request.UpdatePostRequest - if err := c.ShouldBindJSON(&updatePostRequest); err != nil { - response.ErrorResponse(c, http.StatusBadRequest, "Required fields are empty") + c.Error(fmt.Errorf("get user id from context: %w", err)) + c.JSON(http.StatusInternalServerError, response.NewErrorResponse( + response.CodeInternalServerError, + "Oops, something went wrong...", + )) return } parsedPostID, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - response.ErrorResponse(c, http.StatusBadRequest, "Bad request") + c.Error(fmt.Errorf("parse post id: %w", err)) + c.JSON(http.StatusBadRequest, response.NewErrorResponse( + response.CodeBadRequest, + "Invalid request", + )) return } postID, err := safecast.ToUint(parsedPostID) if err != nil { - response.ErrorResponse(c, http.StatusBadRequest, "Invalid Post ID") + c.Error(fmt.Errorf("convert post id to uint: %w", err)) + c.JSON(http.StatusBadRequest, response.NewErrorResponse( + response.CodeBadRequest, + "Invalid request", + )) return } - post, err := h.postService.UpdateByUser( - c.Request.Context(), - userID, - postID, - updatePostRequest.Title, - updatePostRequest.Content, - ) + var updatePostRequest request.UpdatePostRequest + if err := c.ShouldBindJSON(&updatePostRequest); err != nil { + c.Error(fmt.Errorf("bind: %w", err)) + c.JSON(http.StatusBadRequest, response.NewErrorResponse( + response.CodeBadRequest, + "Invalid request", + )) + return + } + + post, err := h.postService.UpdateByUser(c.Request.Context(), domain.UpdatePostRequest{ + PostID: postID, + UserID: userID, + Title: updatePostRequest.Title, + Content: updatePostRequest.Content, + }) if err != nil { + c.Error(fmt.Errorf("update post by user: %w", err)) switch { case errors.Is(err, domain.ErrNotFound): - response.ErrorResponse(c, http.StatusNotFound, "Post not found") + c.JSON(http.StatusNotFound, response.NewErrorResponse( + response.CodeNotFound, + "Post not found", + )) case errors.Is(err, domain.ErrForbidden): - response.ErrorResponse(c, http.StatusForbidden, "Forbidden") + c.JSON(http.StatusForbidden, response.NewErrorResponse( + response.CodeAccessDenied, + "Access denied", + )) default: - response.ErrorResponse(c, http.StatusInternalServerError, "Server error") + c.JSON(http.StatusInternalServerError, response.NewErrorResponse( + response.CodeInternalServerError, + "Oops, something went wrong...", + )) } return } - response.SuccessResponse(c, response.GetPostResponse{ - ID: post.ID, - Title: post.Title, - Content: post.Content, - }) + c.JSON(http.StatusOK, response.NewPostResponse(post)) } // DeletePost godoc // @Summary Delete post -// @Description Delete post -// @ID posts-delete +// @ID detelePost // @Tags Posts Actions // @Param id path int true "Post ID" -// @Success 200 {string} string "Post deleted successfully" -// @Failure 404 {object} response.Error +// @Success 200 {string} response.MessageResponse +// @Failure 400 {string} response.ErrorResponse +// @Failure 401 {object} response.ErrorResponse +// @Failure 403 {object} response.ErrorResponse +// @Failure 404 {object} response.ErrorResponse +// @Failure 500 {object} response.ErrorResponse // @Security ApiKeyAuth -// @Router /post/{id} [delete] +// @Router /posts/{id} [delete] func (h *PostHandler) DeletePost(c *gin.Context) { - parsedUserID, ok := jwt.ExtractClaims(c)["id"].(float64) - if !ok { - response.ErrorResponse(c, http.StatusBadRequest, "Bad request") - return - } - - userID, err := safecast.ToUint(parsedUserID) + userID, err := getUserIDFromContext(c) if err != nil { - response.ErrorResponse(c, http.StatusBadRequest, "Invalid User ID") + c.Error(fmt.Errorf("get user id from context: %w", err)) + c.JSON(http.StatusInternalServerError, response.NewErrorResponse( + response.CodeInternalServerError, + "Oops, something went wrong...", + )) return } parsedPostID, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - response.ErrorResponse(c, http.StatusBadRequest, "Bad request") + c.Error(fmt.Errorf("parse post id: %w", err)) + c.JSON(http.StatusBadRequest, response.NewErrorResponse( + response.CodeBadRequest, + "Invalid request", + )) return } postID, err := safecast.ToUint(parsedPostID) if err != nil { - response.ErrorResponse(c, http.StatusBadRequest, "Invalid Post ID") + c.Error(fmt.Errorf("convert post id to uint: %w", err)) + c.JSON(http.StatusBadRequest, response.NewErrorResponse( + response.CodeBadRequest, + "Invalid request", + )) return } if err := h.postService.DeleteByUser(c.Request.Context(), userID, postID); err != nil { + c.Error(fmt.Errorf("delete post by user: %w", err)) switch { case errors.Is(err, domain.ErrNotFound): - response.ErrorResponse(c, http.StatusNotFound, "Post not found") + c.JSON(http.StatusNotFound, response.NewErrorResponse( + response.CodeNotFound, + "Post not found", + )) case errors.Is(err, domain.ErrForbidden): - response.ErrorResponse(c, http.StatusForbidden, "Forbidden") + c.JSON(http.StatusForbidden, response.NewErrorResponse( + response.CodeAccessDenied, + "Access denied", + )) default: - response.ErrorResponse(c, http.StatusInternalServerError, "Server error") + c.JSON(http.StatusInternalServerError, response.NewErrorResponse( + response.CodeInternalServerError, + "Oops, something went wrong...", + )) } return } - response.SuccessResponse(c, "Post delete successfully") + c.JSON(http.StatusOK, response.NewMessageResponse("Post was deleted successfully")) +} + +func (h *PostHandler) parseFilters(c *gin.Context) (domain.PostFilters, error) { + filters := domain.PostFilters{Limit: defaultPostLimit, Title: c.Query("title")} + + if limitParam := c.Query("limit"); limitParam != "" { + limit, err := strconv.ParseInt(limitParam, 10, 64) + if err != nil { + return domain.PostFilters{}, fmt.Errorf("prase limit query param: %w", err) + } + + filters.Limit = limit + } + + if offsetParam := c.Query("offset"); offsetParam != "" { + offset, err := strconv.ParseInt(offsetParam, 10, 64) + if err != nil { + return domain.PostFilters{}, fmt.Errorf("prase offset query param: %w", err) + } + + filters.Offset = offset + } + + if userIDQuery := c.Query("user_id"); userIDQuery != "" { + userID, err := strconv.ParseUint(userIDQuery, 10, 64) + if err != nil { + return domain.PostFilters{}, fmt.Errorf("prase user_id query param: %w", err) + } + + parsedUserID, err := safecast.ToUint(userID) + if err != nil { + return domain.PostFilters{}, fmt.Errorf("convert user id to uint: %w", err) + } + + filters.UserID = parsedUserID + } + + if err := filters.Validate(); err != nil { + return domain.PostFilters{}, fmt.Errorf("validate filters: %w", err) + } + + return filters, nil } diff --git a/internal/server/handler/post_handler_mock_test.go b/internal/server/handler/post_handler_mock_test.go index 271366b..300600a 100644 --- a/internal/server/handler/post_handler_mock_test.go +++ b/internal/server/handler/post_handler_mock_test.go @@ -13,6 +13,7 @@ import ( context "context" reflect "reflect" + domain "github.com/nix-united/golang-gin-boilerplate/internal/domain" model "github.com/nix-united/golang-gin-boilerplate/internal/model" gomock "go.uber.org/mock/gomock" ) @@ -41,19 +42,58 @@ func (m *MockpostService) EXPECT() *MockpostServiceMockRecorder { return m.recorder } +// Count mocks base method. +func (m *MockpostService) Count(ctx context.Context, filers domain.PostFilters) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Count", ctx, filers) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Count indicates an expected call of Count. +func (mr *MockpostServiceMockRecorder) Count(ctx, filers any) *MockpostServiceCountCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Count", reflect.TypeOf((*MockpostService)(nil).Count), ctx, filers) + return &MockpostServiceCountCall{Call: call} +} + +// MockpostServiceCountCall wrap *gomock.Call +type MockpostServiceCountCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockpostServiceCountCall) Return(arg0 int64, arg1 error) *MockpostServiceCountCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockpostServiceCountCall) Do(f func(context.Context, domain.PostFilters) (int64, error)) *MockpostServiceCountCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockpostServiceCountCall) DoAndReturn(f func(context.Context, domain.PostFilters) (int64, error)) *MockpostServiceCountCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // Create mocks base method. -func (m *MockpostService) Create(ctx context.Context, userID uint, title, content string) (*model.Post, error) { +func (m *MockpostService) Create(ctx context.Context, createPostRequest domain.CreatePostRequest) (*model.Post, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", ctx, userID, title, content) + ret := m.ctrl.Call(m, "Create", ctx, createPostRequest) ret0, _ := ret[0].(*model.Post) ret1, _ := ret[1].(error) return ret0, ret1 } // Create indicates an expected call of Create. -func (mr *MockpostServiceMockRecorder) Create(ctx, userID, title, content any) *MockpostServiceCreateCall { +func (mr *MockpostServiceMockRecorder) Create(ctx, createPostRequest any) *MockpostServiceCreateCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockpostService)(nil).Create), ctx, userID, title, content) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockpostService)(nil).Create), ctx, createPostRequest) return &MockpostServiceCreateCall{Call: call} } @@ -69,13 +109,13 @@ func (c *MockpostServiceCreateCall) Return(arg0 *model.Post, arg1 error) *Mockpo } // Do rewrite *gomock.Call.Do -func (c *MockpostServiceCreateCall) Do(f func(context.Context, uint, string, string) (*model.Post, error)) *MockpostServiceCreateCall { +func (c *MockpostServiceCreateCall) Do(f func(context.Context, domain.CreatePostRequest) (*model.Post, error)) *MockpostServiceCreateCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockpostServiceCreateCall) DoAndReturn(f func(context.Context, uint, string, string) (*model.Post, error)) *MockpostServiceCreateCall { +func (c *MockpostServiceCreateCall) DoAndReturn(f func(context.Context, domain.CreatePostRequest) (*model.Post, error)) *MockpostServiceCreateCall { c.Call = c.Call.DoAndReturn(f) return c } @@ -119,18 +159,18 @@ func (c *MockpostServiceDeleteByUserCall) DoAndReturn(f func(context.Context, ui } // GetByID mocks base method. -func (m *MockpostService) GetByID(ctx context.Context, id uint) (*model.Post, error) { +func (m *MockpostService) GetByID(ctx context.Context, postID uint) (*model.Post, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetByID", ctx, id) + ret := m.ctrl.Call(m, "GetByID", ctx, postID) ret0, _ := ret[0].(*model.Post) ret1, _ := ret[1].(error) return ret0, ret1 } // GetByID indicates an expected call of GetByID. -func (mr *MockpostServiceMockRecorder) GetByID(ctx, id any) *MockpostServiceGetByIDCall { +func (mr *MockpostServiceMockRecorder) GetByID(ctx, postID any) *MockpostServiceGetByIDCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockpostService)(nil).GetByID), ctx, id) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockpostService)(nil).GetByID), ctx, postID) return &MockpostServiceGetByIDCall{Call: call} } @@ -158,18 +198,18 @@ func (c *MockpostServiceGetByIDCall) DoAndReturn(f func(context.Context, uint) ( } // List mocks base method. -func (m *MockpostService) List(ctx context.Context) ([]model.Post, error) { +func (m *MockpostService) List(ctx context.Context, filers domain.PostFilters) ([]model.Post, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "List", ctx) + ret := m.ctrl.Call(m, "List", ctx, filers) ret0, _ := ret[0].([]model.Post) ret1, _ := ret[1].(error) return ret0, ret1 } // List indicates an expected call of List. -func (mr *MockpostServiceMockRecorder) List(ctx any) *MockpostServiceListCall { +func (mr *MockpostServiceMockRecorder) List(ctx, filers any) *MockpostServiceListCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockpostService)(nil).List), ctx) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockpostService)(nil).List), ctx, filers) return &MockpostServiceListCall{Call: call} } @@ -185,30 +225,30 @@ func (c *MockpostServiceListCall) Return(arg0 []model.Post, arg1 error) *Mockpos } // Do rewrite *gomock.Call.Do -func (c *MockpostServiceListCall) Do(f func(context.Context) ([]model.Post, error)) *MockpostServiceListCall { +func (c *MockpostServiceListCall) Do(f func(context.Context, domain.PostFilters) ([]model.Post, error)) *MockpostServiceListCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockpostServiceListCall) DoAndReturn(f func(context.Context) ([]model.Post, error)) *MockpostServiceListCall { +func (c *MockpostServiceListCall) DoAndReturn(f func(context.Context, domain.PostFilters) ([]model.Post, error)) *MockpostServiceListCall { c.Call = c.Call.DoAndReturn(f) return c } // UpdateByUser mocks base method. -func (m *MockpostService) UpdateByUser(ctx context.Context, userID, postID uint, title, content string) (*model.Post, error) { +func (m *MockpostService) UpdateByUser(ctx context.Context, updatePostRequest domain.UpdatePostRequest) (*model.Post, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateByUser", ctx, userID, postID, title, content) + ret := m.ctrl.Call(m, "UpdateByUser", ctx, updatePostRequest) ret0, _ := ret[0].(*model.Post) ret1, _ := ret[1].(error) return ret0, ret1 } // UpdateByUser indicates an expected call of UpdateByUser. -func (mr *MockpostServiceMockRecorder) UpdateByUser(ctx, userID, postID, title, content any) *MockpostServiceUpdateByUserCall { +func (mr *MockpostServiceMockRecorder) UpdateByUser(ctx, updatePostRequest any) *MockpostServiceUpdateByUserCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateByUser", reflect.TypeOf((*MockpostService)(nil).UpdateByUser), ctx, userID, postID, title, content) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateByUser", reflect.TypeOf((*MockpostService)(nil).UpdateByUser), ctx, updatePostRequest) return &MockpostServiceUpdateByUserCall{Call: call} } @@ -224,13 +264,13 @@ func (c *MockpostServiceUpdateByUserCall) Return(arg0 *model.Post, arg1 error) * } // Do rewrite *gomock.Call.Do -func (c *MockpostServiceUpdateByUserCall) Do(f func(context.Context, uint, uint, string, string) (*model.Post, error)) *MockpostServiceUpdateByUserCall { +func (c *MockpostServiceUpdateByUserCall) Do(f func(context.Context, domain.UpdatePostRequest) (*model.Post, error)) *MockpostServiceUpdateByUserCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockpostServiceUpdateByUserCall) DoAndReturn(f func(context.Context, uint, uint, string, string) (*model.Post, error)) *MockpostServiceUpdateByUserCall { +func (c *MockpostServiceUpdateByUserCall) DoAndReturn(f func(context.Context, domain.UpdatePostRequest) (*model.Post, error)) *MockpostServiceUpdateByUserCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/internal/server/handler/post_handler_test.go b/internal/server/handler/post_handler_test.go index d9dc663..7a9ea0d 100644 --- a/internal/server/handler/post_handler_test.go +++ b/internal/server/handler/post_handler_test.go @@ -3,24 +3,28 @@ package handler_test import ( "bytes" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" "testing" + "time" + "github.com/nix-united/golang-gin-boilerplate/internal/domain" "github.com/nix-united/golang-gin-boilerplate/internal/model" "github.com/nix-united/golang-gin-boilerplate/internal/request" + "github.com/nix-united/golang-gin-boilerplate/internal/response" "github.com/nix-united/golang-gin-boilerplate/internal/server/handler" - jwt "github.com/appleboy/gin-jwt/v2" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "gorm.io/gorm" ) -func newPostHandler(t *testing.T) (*gin.Engine, *MockpostService) { +func newPostHandler(t *testing.T, userID uint) (*gin.Engine, *MockpostService) { t.Helper() ctrl := gomock.NewController(t) @@ -31,226 +35,326 @@ func newPostHandler(t *testing.T) (*gin.Engine, *MockpostService) { gin.SetMode(gin.TestMode) engine.Use(func(c *gin.Context) { - c.Set("JWT_PAYLOAD", jwt.MapClaims{"id": float64(101)}) + c.Set("JWT_PAYLOAD", jwt.MapClaims{"id": float64(userID)}) }) - engine.POST("/posts", postHandler.SavePost) + engine.POST("/posts", postHandler.CreatePost) engine.GET("/posts", postHandler.GetPosts) - engine.GET("/post/:id", postHandler.GetPostByID) - engine.PUT("/post/:id", postHandler.UpdatePost) - engine.DELETE("/post/:id", postHandler.DeletePost) + engine.GET("/posts/:id", postHandler.GetPostByID) + engine.PUT("/posts/:id", postHandler.UpdatePost) + engine.DELETE("/posts/:id", postHandler.DeletePost) return engine, postService } -func TestPostHandler_GetPostByID(t *testing.T) { - engine, postService := newPostHandler(t) +func TestPostHandler_CreatePost(t *testing.T) { + const postID, userID = 100, 200 + now, err := time.Parse(time.DateOnly, "2020-01-02") + require.NoError(t, err) - post := &model.Post{ + createdPost := model.Post{ Model: gorm.Model{ - ID: 100, + ID: postID, + CreatedAt: now, + UpdatedAt: now, }, + UserID: userID, Title: "Title", Content: "Content", } + createPostRequest := request.CreatePostRequest{ + BasicPost: request.BasicPost{ + Title: createdPost.Title, + Content: createdPost.Content, + }, + } + + rawCreatePostRequest, err := json.Marshal(createPostRequest) + require.NoError(t, err) + + engine, postService := newPostHandler(t, userID) + postService. EXPECT(). - GetByID(gomock.Any(), post.ID). - Return(post, nil) + Create(gomock.Any(), domain.CreatePostRequest{ + UserID: userID, + Title: createPostRequest.Title, + Content: createPostRequest.Content, + }). + Return(&createdPost, nil) - httpRequest := httptest.NewRequest(http.MethodGet, "/post/100", http.NoBody) + httpRequest := httptest.NewRequest(http.MethodPost, "/posts", bytes.NewReader(rawCreatePostRequest)) recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, httpRequest) - response := recorder.Result() - defer response.Body.Close() + httpResponse := recorder.Result() + defer httpResponse.Body.Close() + + assert.Equal(t, http.StatusCreated, httpResponse.StatusCode) - responseBody, err := io.ReadAll(response.Body) + responseBody, err := io.ReadAll(httpResponse.Body) require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.StatusCode) + var actualResponse response.PostResponse + err = json.Unmarshal(responseBody, &actualResponse) + require.NoError(t, err) - expectedResponse := `{ - "id": 100, - "title": "Title", - "content": "Content" - }` + expectedResponse := response.PostResponse{ + ID: createdPost.ID, + UserID: createdPost.UserID, + Title: createdPost.Title, + Content: createdPost.Content, + CreatedAt: createdPost.CreatedAt.Format(time.RFC3339), + UpdatedAt: createdPost.UpdatedAt.Format(time.RFC3339), + } - assert.JSONEq(t, expectedResponse, string(responseBody)) + assert.Equal(t, expectedResponse, actualResponse) } -func TestPostHandler_SavePost(t *testing.T) { - engine, postService := newPostHandler(t) +func TestPostHandler_GetPosts(t *testing.T) { + const firstPostID, secondPostID, userID, totalPosts = 100, 101, 200, 300 + now, err := time.Parse(time.DateOnly, "2020-01-02") + require.NoError(t, err) - post := model.Post{ - Model: gorm.Model{ - ID: 100, + storedPosts := []model.Post{ + { + Model: gorm.Model{ + ID: firstPostID, + CreatedAt: now, + UpdatedAt: now, + }, + UserID: userID, + Title: "Post 1 Title", + Content: "Post 2 Content", }, - Title: "Title", - Content: "Content", - } - - createPostRequest := request.CreatePostRequest{ - BasicPost: &request.BasicPost{ - Title: "Title", - Content: "Content", + { + Model: gorm.Model{ + ID: secondPostID, + CreatedAt: now, + UpdatedAt: now, + }, + UserID: userID, + Title: "Post 2 Title", + Content: "Post 2 Content", }, } - rawCreatePostRequest, err := json.Marshal(createPostRequest) - require.NoError(t, err) + filters := domain.PostFilters{Limit: 10} + + engine, postService := newPostHandler(t, userID) postService. EXPECT(). - Create(gomock.Any(), uint(101), "Title", "Content"). - Return(&post, nil) + Count(gomock.Any(), filters). + Return(totalPosts, nil) - httpRequest := httptest.NewRequest(http.MethodPost, "/posts", bytes.NewReader(rawCreatePostRequest)) + postService. + EXPECT(). + List(gomock.Any(), filters). + Return(storedPosts, nil) + + httpRequest := httptest.NewRequest(http.MethodGet, "/posts", http.NoBody) recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, httpRequest) - response := recorder.Result() - defer response.Body.Close() + httpResponse := recorder.Result() + defer httpResponse.Body.Close() + + assert.Equal(t, http.StatusOK, httpResponse.StatusCode) - responseBody, err := io.ReadAll(response.Body) + responseBody, err := io.ReadAll(httpResponse.Body) require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.StatusCode) + var actualResponse response.CollectionResponse[response.PostResponse] + err = json.Unmarshal(responseBody, &actualResponse) + require.NoError(t, err) - expectedResponse := `{ - "id": 100, - "title": "Title", - "content": "Content" - }` + expectedResponse := response.CollectionResponse[response.PostResponse]{ + Data: []response.PostResponse{ + { + ID: storedPosts[0].ID, + UserID: storedPosts[0].UserID, + Title: storedPosts[0].Title, + Content: storedPosts[0].Content, + CreatedAt: storedPosts[0].CreatedAt.Format(time.RFC3339), + UpdatedAt: storedPosts[0].UpdatedAt.Format(time.RFC3339), + }, + { + ID: storedPosts[1].ID, + UserID: storedPosts[1].UserID, + Title: storedPosts[1].Title, + Content: storedPosts[1].Content, + CreatedAt: storedPosts[1].CreatedAt.Format(time.RFC3339), + UpdatedAt: storedPosts[1].UpdatedAt.Format(time.RFC3339), + }, + }, + Meta: response.Meta{ + Count: 2, + Total: totalPosts, + Offset: 0, + Limit: 10, + }, + } - assert.JSONEq(t, expectedResponse, string(responseBody)) + assert.Equal(t, expectedResponse, actualResponse) } -func TestPostHandler_UpdatePost(t *testing.T) { - engine, postService := newPostHandler(t) +func TestPostHandler_GetPostByID(t *testing.T) { + const postID, userID = 100, 200 + now, err := time.Parse(time.DateOnly, "2020-01-02") + require.NoError(t, err) - post := &model.Post{ + storedPost := &model.Post{ Model: gorm.Model{ - ID: 100, + ID: postID, + CreatedAt: now, + UpdatedAt: now, }, + UserID: userID, Title: "Title", Content: "Content", - UserID: 101, } - newPost := &model.Post{ - Model: gorm.Model{ - ID: 100, - }, - Title: "New Title", - Content: "New Content", - } - - updatePostRequest := request.UpdatePostRequest{ - BasicPost: &request.BasicPost{ - Title: "New Title", - Content: "New Content", - }, - } - - rawUpdatePostRequest, err := json.Marshal(updatePostRequest) - require.NoError(t, err) + engine, postService := newPostHandler(t, userID) postService. EXPECT(). - UpdateByUser(gomock.Any(), post.UserID, post.ID, "New Title", "New Content"). - Return(newPost, nil) + GetByID(gomock.Any(), storedPost.ID). + Return(storedPost, nil) - httpRequest := httptest.NewRequest(http.MethodPut, "/post/100", bytes.NewReader(rawUpdatePostRequest)) + httpRequest := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/posts/%d", postID), http.NoBody) recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, httpRequest) - response := recorder.Result() - defer response.Body.Close() + httpResponse := recorder.Result() + defer httpResponse.Body.Close() - responseBody, err := io.ReadAll(response.Body) + assert.Equal(t, http.StatusOK, httpResponse.StatusCode) + + responseBody, err := io.ReadAll(httpResponse.Body) require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.StatusCode) + var actualResponse response.PostResponse + err = json.Unmarshal(responseBody, &actualResponse) + require.NoError(t, err) - expectedResponse := `{ - "id": 100, - "title": "New Title", - "content": "New Content" - }` + expectedResponse := response.PostResponse{ + ID: storedPost.ID, + UserID: storedPost.UserID, + Title: storedPost.Title, + Content: storedPost.Content, + CreatedAt: storedPost.CreatedAt.Format(time.RFC3339), + UpdatedAt: storedPost.UpdatedAt.Format(time.RFC3339), + } - assert.JSONEq(t, expectedResponse, string(responseBody)) + assert.Equal(t, expectedResponse, actualResponse) } -func TestPostHandler_GetPosts(t *testing.T) { - engine, postService := newPostHandler(t) +func TestPostHandler_UpdatePost(t *testing.T) { + const userID = 101 + now, err := time.Parse(time.DateOnly, "2020-01-02") + require.NoError(t, err) - post := model.Post{ + newPost := &model.Post{ Model: gorm.Model{ - ID: 100, + ID: 100, + CreatedAt: now, + UpdatedAt: now, }, - Title: "Title", - Content: "Content", + UserID: userID, + Title: "New Title", + Content: "New Content", } + updatePostRequest := request.UpdatePostRequest{ + BasicPost: request.BasicPost{ + Title: "New Title", + Content: "New Content", + }, + } + + rawUpdatePostRequest, err := json.Marshal(updatePostRequest) + require.NoError(t, err) + + engine, postService := newPostHandler(t, userID) + postService. EXPECT(). - List(gomock.Any()). - Return([]model.Post{post}, nil) + UpdateByUser(gomock.Any(), domain.UpdatePostRequest{ + PostID: newPost.ID, + UserID: newPost.UserID, + Title: newPost.Title, + Content: newPost.Content, + }). + Return(newPost, nil) - httpRequest := httptest.NewRequest(http.MethodGet, "/posts", http.NoBody) + httpRequest := httptest.NewRequest( + http.MethodPut, + fmt.Sprintf("/posts/%d", newPost.ID), + bytes.NewReader(rawUpdatePostRequest), + ) recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, httpRequest) - response := recorder.Result() - defer response.Body.Close() + httpResponse := recorder.Result() + defer httpResponse.Body.Close() + + assert.Equal(t, http.StatusOK, httpResponse.StatusCode) + + responseBody, err := io.ReadAll(httpResponse.Body) + require.NoError(t, err) - responseBody, err := io.ReadAll(response.Body) + var actualResponse response.PostResponse + err = json.Unmarshal(responseBody, &actualResponse) require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.StatusCode) + expectedResponse := response.PostResponse{ + ID: newPost.ID, + UserID: newPost.UserID, + Title: newPost.Title, + Content: newPost.Content, + CreatedAt: newPost.CreatedAt.Format(time.RFC3339), + UpdatedAt: newPost.UpdatedAt.Format(time.RFC3339), + } - expectedResponse := `{ - "collection": [ - { - "id": 100, - "title": "Title", - "content": "Content" - } - ], - "meta": { - "amount": 1 - } - }` - - assert.JSONEq(t, expectedResponse, string(responseBody)) + assert.Equal(t, expectedResponse, actualResponse) } func TestPostHandler_DeletePost(t *testing.T) { - engine, postService := newPostHandler(t) + const userID = uint(101) + + engine, postService := newPostHandler(t, userID) postService. EXPECT(). - DeleteByUser(gomock.Any(), uint(101), uint(100)). + DeleteByUser(gomock.Any(), userID, uint(100)). Return(nil) - httpRequest := httptest.NewRequest(http.MethodDelete, "/post/100", http.NoBody) + httpRequest := httptest.NewRequest(http.MethodDelete, "/posts/100", http.NoBody) recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, httpRequest) - response := recorder.Result() - defer response.Body.Close() + httpResponse := recorder.Result() + defer httpResponse.Body.Close() + + assert.Equal(t, http.StatusOK, httpResponse.StatusCode) - responseBody, err := io.ReadAll(response.Body) + responseBody, err := io.ReadAll(httpResponse.Body) require.NoError(t, err) - assert.Equal(t, http.StatusOK, response.StatusCode) + var gotMessageResponse response.MessageResponse + err = json.Unmarshal(responseBody, &gotMessageResponse) + require.NoError(t, err) + + wantMessageRespone := response.MessageResponse{ + Message: "Post was deleted successfully", + } - assert.Equal(t, `"Post delete successfully"`, string(responseBody)) + assert.Equal(t, wantMessageRespone, gotMessageResponse) } diff --git a/internal/server/routes.go b/internal/server/routes.go index 5032e8e..66e58ca 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -3,7 +3,6 @@ package server import ( "net/http" - "github.com/nix-united/golang-gin-boilerplate/internal/provider" "github.com/nix-united/golang-gin-boilerplate/internal/server/handler" "github.com/gin-gonic/gin" @@ -12,11 +11,9 @@ import ( ) type Handlers struct { - HomeHandler *handler.HomeHandler AuthHandler *handler.AuthHandler PostHandler *handler.PostHandler - JwtAuthMiddleware provider.JwtAuthMiddleware RequestLoggingMiddleware gin.HandlerFunc RequestDebuggingMiddleware gin.HandlerFunc } @@ -41,12 +38,12 @@ func ConfigureRoutes(handlers Handlers) *gin.Engine { // Do NOT log request or response bodies; doing so could expose client information. privateAPI := api.Group("/") - privateAPI.POST("/users", handlers.AuthHandler.RegisterUser) - privateAPI.POST("/login", handlers.JwtAuthMiddleware.Middleware().LoginHandler) - privateAPI.GET( + privateAPI.POST("/register", handlers.AuthHandler.RegisterUser) + privateAPI.POST("/login", handlers.AuthHandler.LoginUser) + privateAPI.POST( "/refresh", - handlers.JwtAuthMiddleware.Middleware().MiddlewareFunc(), - handlers.JwtAuthMiddleware.Middleware().RefreshHandler, + handlers.AuthHandler.Middleware, + handlers.AuthHandler.RefreshUserToken, ) // Authorized API route initialization @@ -55,16 +52,15 @@ func ConfigureRoutes(handlers Handlers) *gin.Engine { // before they can be accessed. authorizedAPI := api.Group( "/", - handlers.JwtAuthMiddleware.Middleware().MiddlewareFunc(), + handlers.AuthHandler.Middleware, handlers.RequestDebuggingMiddleware, ) - authorizedAPI.GET("/", handlers.HomeHandler.Index) - authorizedAPI.POST("/posts", handlers.PostHandler.SavePost) + authorizedAPI.POST("/posts", handlers.PostHandler.CreatePost) authorizedAPI.GET("/posts", handlers.PostHandler.GetPosts) - authorizedAPI.GET("/post/:id", handlers.PostHandler.GetPostByID) - authorizedAPI.PUT("/post/:id", handlers.PostHandler.UpdatePost) - authorizedAPI.DELETE("/post/:id", handlers.PostHandler.DeletePost) + authorizedAPI.GET("/posts/:id", handlers.PostHandler.GetPostByID) + authorizedAPI.PUT("/posts/:id", handlers.PostHandler.UpdatePost) + authorizedAPI.DELETE("/posts/:id", handlers.PostHandler.DeletePost) return engine } diff --git a/internal/service/password/service.go b/internal/service/password/service.go new file mode 100644 index 0000000..b614e6b --- /dev/null +++ b/internal/service/password/service.go @@ -0,0 +1,30 @@ +package password + +import ( + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +type Service struct { + cost int +} + +func NewService(cost int) *Service { + return &Service{cost: cost} +} + +func (s *Service) VerifyPassword(actual, received string) error { + if err := bcrypt.CompareHashAndPassword([]byte(actual), []byte(received)); err != nil { + return fmt.Errorf("compare hash and password: %w", err) + } + return nil +} + +func (s *Service) EncryptPassword(password string) (string, error) { + encrypted, err := bcrypt.GenerateFromPassword([]byte(password), s.cost) + if err != nil { + return "", fmt.Errorf("generate encrypted password: %w", err) + } + return string(encrypted), nil +} diff --git a/internal/service/password/service_test.go b/internal/service/password/service_test.go new file mode 100644 index 0000000..3102d60 --- /dev/null +++ b/internal/service/password/service_test.go @@ -0,0 +1,22 @@ +package password_test + +import ( + "testing" + + "github.com/nix-united/golang-gin-boilerplate/internal/service/password" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +func TestService(t *testing.T) { + service := password.NewService(bcrypt.DefaultCost) + + const testPassword = "test-password" + + encryptedPassword, err := service.EncryptPassword(testPassword) + require.NoError(t, err) + + err = service.VerifyPassword(encryptedPassword, testPassword) + require.NoError(t, err) +} diff --git a/internal/service/post/service.go b/internal/service/post/service.go index ad7f1cc..c2d5e50 100644 --- a/internal/service/post/service.go +++ b/internal/service/post/service.go @@ -12,9 +12,10 @@ import ( //go:generate mockgen -source=$GOFILE -destination=service_mock_test.go -package=${GOPACKAGE}_test -typed=true type postRepository interface { - Create(ctx context.Context, post *model.Post) error + Create(ctx context.Context, post *model.Post) (*model.Post, error) + Count(ctx context.Context, filters domain.PostFilters) (int64, error) + List(ctx context.Context, filters domain.PostFilters) ([]model.Post, error) GetByID(ctx context.Context, id uint) (*model.Post, error) - List(ctx context.Context) ([]model.Post, error) Update(ctx context.Context, post *model.Post) error Delete(ctx context.Context, post *model.Post) error } @@ -27,31 +28,32 @@ func NewService(postRepository postRepository) *Service { return &Service{postRepository: postRepository} } -func (s *Service) Create(ctx context.Context, userID uint, title, content string) (*model.Post, error) { +func (s *Service) Create(ctx context.Context, createPostRequest domain.CreatePostRequest) (*model.Post, error) { post := &model.Post{ - Title: title, - Content: content, - UserID: userID, + UserID: createPostRequest.UserID, + Title: createPostRequest.Title, + Content: createPostRequest.Content, } - if err := s.postRepository.Create(ctx, post); err != nil { + post, err := s.postRepository.Create(ctx, post) + if err != nil { return nil, fmt.Errorf("create post in repository: %w", err) } return post, nil } -func (s *Service) GetByID(ctx context.Context, id uint) (*model.Post, error) { - post, err := s.postRepository.GetByID(ctx, id) +func (s *Service) Count(ctx context.Context, filters domain.PostFilters) (int64, error) { + count, err := s.postRepository.Count(ctx, filters) if err != nil { - return nil, fmt.Errorf("get post by id from repository: %w", err) + return 0, fmt.Errorf("get posts count from repository: %w", err) } - return post, nil + return count, nil } -func (s *Service) List(ctx context.Context) ([]model.Post, error) { - posts, err := s.postRepository.List(ctx) +func (s *Service) List(ctx context.Context, filters domain.PostFilters) ([]model.Post, error) { + posts, err := s.postRepository.List(ctx, filters) if err != nil { return nil, fmt.Errorf("get all posts from repository: %w", err) } @@ -59,18 +61,27 @@ func (s *Service) List(ctx context.Context) ([]model.Post, error) { return posts, nil } -func (s *Service) UpdateByUser(ctx context.Context, userID, postID uint, title, content string) (*model.Post, error) { - post, err := s.postRepository.GetByID(ctx, postID) +func (s *Service) GetByID(ctx context.Context, id uint) (*model.Post, error) { + post, err := s.postRepository.GetByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("get post by id from repository: %w", err) + } + + return post, nil +} + +func (s *Service) UpdateByUser(ctx context.Context, request domain.UpdatePostRequest) (*model.Post, error) { + post, err := s.postRepository.GetByID(ctx, request.PostID) if err != nil { return nil, fmt.Errorf("get post by id: %w", err) } - if post.UserID != userID { + if post.UserID != request.UserID { return nil, fmt.Errorf("post belongs to a different user: %w", domain.ErrForbidden) } - post.Title = cmp.Or(title, post.Title) - post.Content = cmp.Or(content, post.Content) + post.Title = cmp.Or(request.Title, post.Title) + post.Content = cmp.Or(request.Content, post.Content) if err := s.postRepository.Update(ctx, post); err != nil { return nil, fmt.Errorf("update post in repository: %w", err) diff --git a/internal/service/post/service_mock_test.go b/internal/service/post/service_mock_test.go index 001a089..7930e5f 100644 --- a/internal/service/post/service_mock_test.go +++ b/internal/service/post/service_mock_test.go @@ -13,6 +13,7 @@ import ( context "context" reflect "reflect" + domain "github.com/nix-united/golang-gin-boilerplate/internal/domain" model "github.com/nix-united/golang-gin-boilerplate/internal/model" gomock "go.uber.org/mock/gomock" ) @@ -40,12 +41,52 @@ func (m *MockpostRepository) EXPECT() *MockpostRepositoryMockRecorder { return m.recorder } +// Count mocks base method. +func (m *MockpostRepository) Count(ctx context.Context, filters domain.PostFilters) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Count", ctx, filters) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Count indicates an expected call of Count. +func (mr *MockpostRepositoryMockRecorder) Count(ctx, filters any) *MockpostRepositoryCountCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Count", reflect.TypeOf((*MockpostRepository)(nil).Count), ctx, filters) + return &MockpostRepositoryCountCall{Call: call} +} + +// MockpostRepositoryCountCall wrap *gomock.Call +type MockpostRepositoryCountCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockpostRepositoryCountCall) Return(arg0 int64, arg1 error) *MockpostRepositoryCountCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockpostRepositoryCountCall) Do(f func(context.Context, domain.PostFilters) (int64, error)) *MockpostRepositoryCountCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockpostRepositoryCountCall) DoAndReturn(f func(context.Context, domain.PostFilters) (int64, error)) *MockpostRepositoryCountCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // Create mocks base method. -func (m *MockpostRepository) Create(ctx context.Context, post *model.Post) error { +func (m *MockpostRepository) Create(ctx context.Context, post *model.Post) (*model.Post, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Create", ctx, post) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(*model.Post) + ret1, _ := ret[1].(error) + return ret0, ret1 } // Create indicates an expected call of Create. @@ -61,19 +102,19 @@ type MockpostRepositoryCreateCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockpostRepositoryCreateCall) Return(arg0 error) *MockpostRepositoryCreateCall { - c.Call = c.Call.Return(arg0) +func (c *MockpostRepositoryCreateCall) Return(arg0 *model.Post, arg1 error) *MockpostRepositoryCreateCall { + c.Call = c.Call.Return(arg0, arg1) return c } // Do rewrite *gomock.Call.Do -func (c *MockpostRepositoryCreateCall) Do(f func(context.Context, *model.Post) error) *MockpostRepositoryCreateCall { +func (c *MockpostRepositoryCreateCall) Do(f func(context.Context, *model.Post) (*model.Post, error)) *MockpostRepositoryCreateCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockpostRepositoryCreateCall) DoAndReturn(f func(context.Context, *model.Post) error) *MockpostRepositoryCreateCall { +func (c *MockpostRepositoryCreateCall) DoAndReturn(f func(context.Context, *model.Post) (*model.Post, error)) *MockpostRepositoryCreateCall { c.Call = c.Call.DoAndReturn(f) return c } @@ -156,18 +197,18 @@ func (c *MockpostRepositoryGetByIDCall) DoAndReturn(f func(context.Context, uint } // List mocks base method. -func (m *MockpostRepository) List(ctx context.Context) ([]model.Post, error) { +func (m *MockpostRepository) List(ctx context.Context, filters domain.PostFilters) ([]model.Post, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "List", ctx) + ret := m.ctrl.Call(m, "List", ctx, filters) ret0, _ := ret[0].([]model.Post) ret1, _ := ret[1].(error) return ret0, ret1 } // List indicates an expected call of List. -func (mr *MockpostRepositoryMockRecorder) List(ctx any) *MockpostRepositoryListCall { +func (mr *MockpostRepositoryMockRecorder) List(ctx, filters any) *MockpostRepositoryListCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockpostRepository)(nil).List), ctx) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockpostRepository)(nil).List), ctx, filters) return &MockpostRepositoryListCall{Call: call} } @@ -183,13 +224,13 @@ func (c *MockpostRepositoryListCall) Return(arg0 []model.Post, arg1 error) *Mock } // Do rewrite *gomock.Call.Do -func (c *MockpostRepositoryListCall) Do(f func(context.Context) ([]model.Post, error)) *MockpostRepositoryListCall { +func (c *MockpostRepositoryListCall) Do(f func(context.Context, domain.PostFilters) ([]model.Post, error)) *MockpostRepositoryListCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockpostRepositoryListCall) DoAndReturn(f func(context.Context) ([]model.Post, error)) *MockpostRepositoryListCall { +func (c *MockpostRepositoryListCall) DoAndReturn(f func(context.Context, domain.PostFilters) ([]model.Post, error)) *MockpostRepositoryListCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/internal/service/post/service_test.go b/internal/service/post/service_test.go index 2fc12ea..e902ce9 100644 --- a/internal/service/post/service_test.go +++ b/internal/service/post/service_test.go @@ -1,9 +1,9 @@ package post_test import ( - "context" "testing" + "github.com/nix-united/golang-gin-boilerplate/internal/domain" "github.com/nix-united/golang-gin-boilerplate/internal/model" "github.com/nix-united/golang-gin-boilerplate/internal/service/post" @@ -13,36 +13,60 @@ import ( "gorm.io/gorm" ) -func TestPostService_Create(t *testing.T) { +func TestService_Create(t *testing.T) { + createPostRequest := domain.CreatePostRequest{ + UserID: 100, + Title: "Title", + Content: "Content", + } + + postToCreate := &model.Post{ + UserID: createPostRequest.UserID, + Title: createPostRequest.Title, + Content: createPostRequest.Content, + } + + createdPost := &model.Post{ + Model: gorm.Model{ID: 100}, + UserID: createPostRequest.UserID, + Title: createPostRequest.Title, + Content: createPostRequest.Content, + } + ctrl := gomock.NewController(t) postRepository := NewMockpostRepository(ctrl) postService := post.NewService(postRepository) - expectedPostToCreate := &model.Post{ - Title: "Title", - Content: "Content", - UserID: 100, - } + postRepository. + EXPECT(). + Create(gomock.Any(), postToCreate). + Return(createdPost, nil) + + post, err := postService.Create(t.Context(), createPostRequest) + require.NoError(t, err) - expectedCreatedPost := new(model.Post) - *expectedCreatedPost = *expectedPostToCreate - expectedCreatedPost.ID = 101 + assert.Equal(t, createdPost, post) +} + +func TestService_Count(t *testing.T) { + ctrl := gomock.NewController(t) + postRepository := NewMockpostRepository(ctrl) + postService := post.NewService(postRepository) + + const wantCount = int64(100) postRepository. EXPECT(). - Create(gomock.Any(), expectedPostToCreate). - DoAndReturn(func(_ context.Context, p *model.Post) error { - (*p) = *expectedCreatedPost - return nil - }) + Count(gomock.Any(), domain.PostFilters{}). + Return(wantCount, nil) - post, err := postService.Create(t.Context(), 100, "Title", "Content") + gotCount, err := postService.Count(t.Context(), domain.PostFilters{}) require.NoError(t, err) - assert.Equal(t, expectedCreatedPost, post) + assert.Equal(t, wantCount, gotCount) } -func TestPostService_List(t *testing.T) { +func TestService_List(t *testing.T) { ctrl := gomock.NewController(t) postRepository := NewMockpostRepository(ctrl) postService := post.NewService(postRepository) @@ -53,18 +77,23 @@ func TestPostService_List(t *testing.T) { UserID: 100, }} + filters := domain.PostFilters{ + Offset: 1, + Limit: 10, + } + postRepository. EXPECT(). - List(gomock.Any()). + List(gomock.Any(), filters). Return(storedPosts, nil) - posts, err := postService.List(t.Context()) + posts, err := postService.List(t.Context(), filters) require.NoError(t, err) assert.Equal(t, storedPosts, posts) } -func TestPostService_GetByID(t *testing.T) { +func TestService_GetByID(t *testing.T) { ctrl := gomock.NewController(t) postRepository := NewMockpostRepository(ctrl) postService := post.NewService(postRepository) @@ -89,12 +118,12 @@ func TestPostService_GetByID(t *testing.T) { assert.Equal(t, storedPost, post) } -func TestPostService_UpdateByUser(t *testing.T) { +func TestService_UpdateByUser(t *testing.T) { ctrl := gomock.NewController(t) postRepository := NewMockpostRepository(ctrl) postService := post.NewService(postRepository) - post := &model.Post{ + storedPost := &model.Post{ Model: gorm.Model{ ID: 101, }, @@ -103,49 +132,59 @@ func TestPostService_UpdateByUser(t *testing.T) { UserID: 102, } - newPost := &model.Post{ + updatedPost := &model.Post{ Model: gorm.Model{ - ID: 101, + ID: storedPost.ID, }, Title: "New Title", Content: "New Content", - UserID: 102, + UserID: storedPost.UserID, } postRepository. - EXPECT().GetByID(gomock.Any(), post.ID). - Return(post, nil) + EXPECT().GetByID(gomock.Any(), storedPost.ID). + Return(storedPost, nil) postRepository. EXPECT(). - Update(gomock.Any(), newPost). + Update(gomock.Any(), updatedPost). Return(nil) - gotPost, err := postService.UpdateByUser(t.Context(), post.UserID, post.ID, "New Title", "New Content") + gotPost, err := postService.UpdateByUser(t.Context(), domain.UpdatePostRequest{ + PostID: updatedPost.ID, + UserID: updatedPost.UserID, + Title: updatedPost.Title, + Content: updatedPost.Content, + }) require.NoError(t, err) - assert.Equal(t, newPost, gotPost) + assert.Equal(t, updatedPost, gotPost) } -// func TestPostService_Delete(t *testing.T) { -// ctrl := gomock.NewController(t) -// postRepository := NewMockpostRepository(ctrl) -// postService := post.NewService(postRepository) - -// post := &model.Post{ -// Model: gorm.Model{ -// ID: 101, -// }, -// Title: "Title", -// Content: "Content", -// UserID: 102, -// } - -// postRepository. -// EXPECT(). -// Delete(gomock.Any(), post). -// Return(nil) - -// err := postService.Delete(t.Context(), post) -// assert.Nil(t, err) -// } +func TestService_DeleteByUser(t *testing.T) { + ctrl := gomock.NewController(t) + postRepository := NewMockpostRepository(ctrl) + postService := post.NewService(postRepository) + + post := &model.Post{ + Model: gorm.Model{ + ID: 101, + }, + Title: "Title", + Content: "Content", + UserID: 102, + } + + postRepository. + EXPECT(). + GetByID(gomock.Any(), post.ID). + Return(post, nil) + + postRepository. + EXPECT(). + Delete(gomock.Any(), post). + Return(nil) + + err := postService.DeleteByUser(t.Context(), post.UserID, post.ID) + assert.NoError(t, err) +} diff --git a/internal/service/user/service.go b/internal/service/user/service.go index a03daa8..410088d 100644 --- a/internal/service/user/service.go +++ b/internal/service/user/service.go @@ -17,43 +17,43 @@ type userRepository interface { GetByEmail(ctx context.Context, email string) (*model.User, error) } -type encryptor interface { - Encrypt(str string) (string, error) +type passwordService interface { + EncryptPassword(password string) (string, error) } // Service provides a use case level for the user entity type Service struct { - userRepository userRepository - encryptor encryptor + userRepository userRepository + passwordService passwordService } -func NewService(userRepository userRepository, enencryptor encryptor) *Service { +func NewService(userRepository userRepository, passwordService passwordService) *Service { return &Service{ - userRepository: userRepository, - encryptor: enencryptor, + userRepository: userRepository, + passwordService: passwordService, } } // CreateUser Create takes a request with new user credentials and registers it. // An error will be returned if a user exists in the system, or // if an error occurs during interaction with the database. -func (s *Service) CreateUser(ctx context.Context, req request.RegisterRequest) error { - _, err := s.userRepository.GetByEmail(ctx, req.Email) +func (s *Service) CreateUser(ctx context.Context, registerRequest request.RegisterRequest) error { + _, err := s.userRepository.GetByEmail(ctx, registerRequest.Email) if err != nil && !errors.Is(err, domain.ErrNotFound) { return fmt.Errorf("get user by email: %w", err) } else if err == nil { return domain.ErrAlreadyExists } - encryptedPassword, err := s.encryptor.Encrypt(req.Password) + encryptedPassword, err := s.passwordService.EncryptPassword(registerRequest.Password) if err != nil { return fmt.Errorf("encrypt password: %w", err) } err = s.userRepository.Create(ctx, &model.User{ - Email: req.Email, + Email: registerRequest.Email, Password: encryptedPassword, - FullName: req.FullName, + FullName: registerRequest.FullName, }) if err != nil { return fmt.Errorf("store user: %w", err) @@ -61,3 +61,12 @@ func (s *Service) CreateUser(ctx context.Context, req request.RegisterRequest) e return nil } + +func (s *Service) GetUserByEmail(ctx context.Context, email string) (*model.User, error) { + user, err := s.userRepository.GetByEmail(ctx, email) + if err != nil { + return nil, fmt.Errorf("get user by email from repository: %w", err) + } + + return user, nil +} diff --git a/internal/service/user/service_mock_test.go b/internal/service/user/service_mock_test.go index 6980828..e450989 100644 --- a/internal/service/user/service_mock_test.go +++ b/internal/service/user/service_mock_test.go @@ -117,64 +117,64 @@ func (c *MockuserRepositoryGetByEmailCall) DoAndReturn(f func(context.Context, s return c } -// Mockencryptor is a mock of encryptor interface. -type Mockencryptor struct { +// MockpasswordService is a mock of passwordService interface. +type MockpasswordService struct { ctrl *gomock.Controller - recorder *MockencryptorMockRecorder + recorder *MockpasswordServiceMockRecorder } -// MockencryptorMockRecorder is the mock recorder for Mockencryptor. -type MockencryptorMockRecorder struct { - mock *Mockencryptor +// MockpasswordServiceMockRecorder is the mock recorder for MockpasswordService. +type MockpasswordServiceMockRecorder struct { + mock *MockpasswordService } -// NewMockencryptor creates a new mock instance. -func NewMockencryptor(ctrl *gomock.Controller) *Mockencryptor { - mock := &Mockencryptor{ctrl: ctrl} - mock.recorder = &MockencryptorMockRecorder{mock} +// NewMockpasswordService creates a new mock instance. +func NewMockpasswordService(ctrl *gomock.Controller) *MockpasswordService { + mock := &MockpasswordService{ctrl: ctrl} + mock.recorder = &MockpasswordServiceMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *Mockencryptor) EXPECT() *MockencryptorMockRecorder { +func (m *MockpasswordService) EXPECT() *MockpasswordServiceMockRecorder { return m.recorder } -// Encrypt mocks base method. -func (m *Mockencryptor) Encrypt(str string) (string, error) { +// EncryptPassword mocks base method. +func (m *MockpasswordService) EncryptPassword(password string) (string, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Encrypt", str) + ret := m.ctrl.Call(m, "EncryptPassword", password) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } -// Encrypt indicates an expected call of Encrypt. -func (mr *MockencryptorMockRecorder) Encrypt(str any) *MockencryptorEncryptCall { +// EncryptPassword indicates an expected call of EncryptPassword. +func (mr *MockpasswordServiceMockRecorder) EncryptPassword(password any) *MockpasswordServiceEncryptPasswordCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Encrypt", reflect.TypeOf((*Mockencryptor)(nil).Encrypt), str) - return &MockencryptorEncryptCall{Call: call} + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EncryptPassword", reflect.TypeOf((*MockpasswordService)(nil).EncryptPassword), password) + return &MockpasswordServiceEncryptPasswordCall{Call: call} } -// MockencryptorEncryptCall wrap *gomock.Call -type MockencryptorEncryptCall struct { +// MockpasswordServiceEncryptPasswordCall wrap *gomock.Call +type MockpasswordServiceEncryptPasswordCall struct { *gomock.Call } // Return rewrite *gomock.Call.Return -func (c *MockencryptorEncryptCall) Return(arg0 string, arg1 error) *MockencryptorEncryptCall { +func (c *MockpasswordServiceEncryptPasswordCall) Return(arg0 string, arg1 error) *MockpasswordServiceEncryptPasswordCall { c.Call = c.Call.Return(arg0, arg1) return c } // Do rewrite *gomock.Call.Do -func (c *MockencryptorEncryptCall) Do(f func(string) (string, error)) *MockencryptorEncryptCall { +func (c *MockpasswordServiceEncryptPasswordCall) Do(f func(string) (string, error)) *MockpasswordServiceEncryptPasswordCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockencryptorEncryptCall) DoAndReturn(f func(string) (string, error)) *MockencryptorEncryptCall { +func (c *MockpasswordServiceEncryptPasswordCall) DoAndReturn(f func(string) (string, error)) *MockpasswordServiceEncryptPasswordCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/internal/service/user/service_test.go b/internal/service/user/service_test.go index 794565e..a0ab4f0 100644 --- a/internal/service/user/service_test.go +++ b/internal/service/user/service_test.go @@ -10,34 +10,35 @@ import ( "github.com/nix-united/golang-gin-boilerplate/internal/service/user" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "gorm.io/gorm" ) -type userServiceMocks struct { - userRepository *MockuserRepository - encryptor *Mockencryptor +type serviceMovkcs struct { + userRepository *MockuserRepository + passwordService *MockpasswordService } -func newUserService(t *testing.T) (*user.Service, userServiceMocks) { +func newService(t *testing.T) (*user.Service, serviceMovkcs) { t.Helper() ctrl := gomock.NewController(t) userRepository := NewMockuserRepository(ctrl) - encryptor := NewMockencryptor(ctrl) - userService := user.NewService(userRepository, encryptor) + passwordService := NewMockpasswordService(ctrl) + userService := user.NewService(userRepository, passwordService) - mocks := userServiceMocks{ - userRepository: userRepository, - encryptor: encryptor, + mocks := serviceMovkcs{ + userRepository: userRepository, + passwordService: passwordService, } return userService, mocks } -func TestUserService_CreateUser(t *testing.T) { +func TestService_CreateUser(t *testing.T) { registerRequest := request.RegisterRequest{ - BasicAuthRequest: &request.BasicAuthRequest{ + BasicAuthRequest: request.BasicAuthRequest{ Email: "test@test.com", Password: "password", }, @@ -45,19 +46,22 @@ func TestUserService_CreateUser(t *testing.T) { } storedUser := &model.User{ - Email: "test@test.com", - Password: "encrypted password", - FullName: "test full name", - } - - userInDB := &model.User{ Model: gorm.Model{ ID: 1, }, + Email: "stored_user@test.com", + Password: "stored encrypted password", + FullName: "stored user full name", + } + + expectedUserToCreate := &model.User{ + Email: registerRequest.Email, + Password: "encrypted password", + FullName: registerRequest.FullName, } t.Run("It should propagate an error if failed to find user in database", func(t *testing.T) { - service, mocks := newUserService(t) + service, mocks := newService(t) mocks.userRepository. EXPECT(). @@ -69,16 +73,16 @@ func TestUserService_CreateUser(t *testing.T) { }) t.Run("It should propagate an error if failed to encrypt password", func(t *testing.T) { - service, mocks := newUserService(t) + service, mocks := newService(t) mocks.userRepository. EXPECT(). GetByEmail(gomock.Any(), "test@test.com"). Return(nil, domain.ErrNotFound) - mocks.encryptor. + mocks.passwordService. EXPECT(). - Encrypt("password"). + EncryptPassword("password"). Return("", errors.New("encryption error")) err := service.CreateUser(t.Context(), registerRequest) @@ -86,21 +90,21 @@ func TestUserService_CreateUser(t *testing.T) { }) t.Run("It should propagate an error if failed to store an user", func(t *testing.T) { - service, mocks := newUserService(t) + service, mocks := newService(t) mocks.userRepository. EXPECT(). GetByEmail(gomock.Any(), "test@test.com"). Return(nil, domain.ErrNotFound) - mocks.encryptor. + mocks.passwordService. EXPECT(). - Encrypt("password"). + EncryptPassword("password"). Return("encrypted password", nil) mocks.userRepository. EXPECT(). - Create(gomock.Any(), storedUser). + Create(gomock.Any(), expectedUserToCreate). Return(errors.New("store user error")) err := service.CreateUser(t.Context(), registerRequest) @@ -108,36 +112,72 @@ func TestUserService_CreateUser(t *testing.T) { }) t.Run("It should return an error if user already exists in database", func(t *testing.T) { - service, mocks := newUserService(t) + service, mocks := newService(t) mocks.userRepository. EXPECT(). GetByEmail(gomock.Any(), "test@test.com"). - Return(userInDB, nil) + Return(storedUser, nil) err := service.CreateUser(t.Context(), registerRequest) assert.ErrorIs(t, err, domain.ErrAlreadyExists) }) t.Run("It should create a new user", func(t *testing.T) { - service, mocks := newUserService(t) + service, mocks := newService(t) mocks.userRepository. EXPECT(). GetByEmail(gomock.Any(), "test@test.com"). Return(nil, domain.ErrNotFound) - mocks.encryptor. + mocks.passwordService. EXPECT(). - Encrypt("password"). + EncryptPassword("password"). Return("encrypted password", nil) mocks.userRepository. EXPECT(). - Create(gomock.Any(), storedUser). + Create(gomock.Any(), expectedUserToCreate). Return(nil) err := service.CreateUser(t.Context(), registerRequest) assert.NoError(t, err) }) } + +func TestService_GetUserByEmail(t *testing.T) { + storedUser := &model.User{ + Model: gorm.Model{ + ID: 1, + }, + Email: "stored_user@test.com", + Password: "stored encrypted password", + FullName: "stored user full name", + } + + t.Run("It should fetch user by email from the repository", func(t *testing.T) { + service, mocks := newService(t) + + mocks.userRepository. + EXPECT(). + GetByEmail(gomock.Any(), storedUser.Email). + Return(storedUser, nil) + + actualUser, err := service.GetUserByEmail(t.Context(), storedUser.Email) + require.NoError(t, err) + assert.Equal(t, storedUser, actualUser) + }) + + t.Run("It should propagate error when failed to find such user in repository", func(t *testing.T) { + service, mocks := newService(t) + + mocks.userRepository. + EXPECT(). + GetByEmail(gomock.Any(), storedUser.Email). + Return(nil, domain.ErrNotFound) + + _, err := service.GetUserByEmail(t.Context(), storedUser.Email) + assert.ErrorIs(t, err, domain.ErrNotFound) + }) +} diff --git a/internal/slogx/trace.go b/internal/slogx/trace.go index 100f485..8279a7f 100644 --- a/internal/slogx/trace.go +++ b/internal/slogx/trace.go @@ -10,8 +10,15 @@ import ( ) type trace struct { - trace string - index *atomic.Int64 + // traceID represents a common UUID that share logs during request processing. + traceID string + + // spanID represents a number of a log within a span. + spanID *atomic.Int64 + + // baggage represents an additional information that log messages shares. + // For example, it could be a user ID. + baggage map[string]any } type TraceStarter struct { @@ -28,7 +35,11 @@ func (s *TraceStarter) Start(ctx context.Context) (context.Context, error) { return nil, fmt.Errorf("new trace id: %w", err) } - return withTrace(ctx, trace{trace: traceID.String(), index: &atomic.Int64{}}), nil + return contextWithTrace(ctx, trace{ + traceID: traceID.String(), + spanID: &atomic.Int64{}, + baggage: make(map[string]any), + }), nil } var _ slog.Handler = (*traceHandler)(nil) @@ -73,20 +84,42 @@ func (h *traceHandler) addAttrs(ctx context.Context, record slog.Record) slog.Re return record } - record.AddAttrs(slog.Group("trace", slog.String("trace", t.trace), slog.Int64("index", t.index.Add(1)))) + attrs := []any{ + slog.String("trace_id", t.traceID), + slog.Int64("span_id", t.spanID.Add(1)), + } + + for key, value := range t.baggage { + attrs = append(attrs, slog.Any(key, value)) + } + + record.AddAttrs(slog.Group("trace", attrs...)) return record } -type traceKeyType int8 - -var traceKey traceKeyType = 1 +type traceKey struct{} -func withTrace(ctx context.Context, trace trace) context.Context { - return context.WithValue(ctx, traceKey, trace) +func contextWithTrace(ctx context.Context, trace trace) context.Context { + return context.WithValue(ctx, traceKey{}, trace) } func traceFromContext(ctx context.Context) (trace, bool) { - trace, ok := ctx.Value(traceKey).(trace) + trace, ok := ctx.Value(traceKey{}).(trace) return trace, ok } + +// ContextWithBaggage appends [key] field with [value] to all log messages. +func ContextWithBaggage(ctx context.Context, key string, value any) context.Context { + t, ok := traceFromContext(ctx) + if !ok { + return ctx + } + t.baggage[key] = value + return contextWithTrace(ctx, t) +} + +// ContextWithUserID appends user_id field to all log messages. +func ContextWithUserID(ctx context.Context, userID uint) context.Context { + return ContextWithBaggage(ctx, "user_id", userID) +} diff --git a/internal/slogx/trace_test.go b/internal/slogx/trace_test.go index daa8eb4..6d2d0a5 100644 --- a/internal/slogx/trace_test.go +++ b/internal/slogx/trace_test.go @@ -13,8 +13,8 @@ import ( ) type testTrace struct { - Trace string `json:"trace"` - Index int64 `json:"index"` + TraceID string `json:"trace_id"` + SpanID int64 `json:"span_id"` } type testLog struct { @@ -50,16 +50,16 @@ func TestTrace(t *testing.T) { Level: "INFO", Msg: "First message with context", Trace: testTrace{ - Trace: "11111111-1111-1111-1111-111111111111", - Index: 1, + TraceID: "11111111-1111-1111-1111-111111111111", + SpanID: 1, }, }, { Level: "INFO", Msg: "Second message with context", Trace: testTrace{ - Trace: "11111111-1111-1111-1111-111111111111", - Index: 2, + TraceID: "11111111-1111-1111-1111-111111111111", + SpanID: 2, }, }, { diff --git a/internal/utils/jwt_env_loader.go b/internal/utils/jwt_env_loader.go deleted file mode 100644 index 65fda32..0000000 --- a/internal/utils/jwt_env_loader.go +++ /dev/null @@ -1,80 +0,0 @@ -package utils - -import ( - "errors" - "os" - "strconv" - "time" -) - -var ( - errSecretKeyIsNotSet = errors.New("jwt secret key is not set") - - errRealmIsNotSet = errors.New("jwt realm is not set") - - errExpirationTimeHasNotBeenLoaded = errors.New("an error has occurred during jwt expiration time loading") - - errMaxRefreshTimeHasNotBeenLoadedE = errors.New("an error has occurred during jwt max refresh time loading") -) - -func NewJwtEnvVars() (JwtEnvVars, error) { - var jwtVars *jwtEnvVars - var jwtSecret string - var jwtRealm string - var jwtExpration int - var jwtMaxRefreshTime int - var err error - - if jwtSecret = os.Getenv("JWT_SECRET"); jwtSecret == "" { - return jwtVars, errSecretKeyIsNotSet - } - - if jwtRealm = os.Getenv("JWT_REALM"); jwtRealm == "" { - return jwtVars, errRealmIsNotSet - } - - if jwtExpration, err = strconv.Atoi(os.Getenv("JWT_EXPIRATION_TIME")); err != nil { - return jwtVars, errExpirationTimeHasNotBeenLoaded - } - - if jwtMaxRefreshTime, err = strconv.Atoi(os.Getenv("JWT_REFRESH_TIME")); err != nil { - return jwtVars, errMaxRefreshTimeHasNotBeenLoadedE - } - - return &jwtEnvVars{ - secret: jwtSecret, - realm: jwtRealm, - expirationTime: time.Duration(jwtExpration) * time.Second, - maxRefreshTime: time.Duration(jwtMaxRefreshTime) * time.Second, - }, nil -} - -type JwtEnvVars interface { - Secret() string - Realm() string - Expiration() time.Duration - RefreshTime() time.Duration -} - -type jwtEnvVars struct { - secret string - realm string - expirationTime time.Duration - maxRefreshTime time.Duration -} - -func (jwt *jwtEnvVars) Secret() string { - return jwt.secret -} - -func (jwt *jwtEnvVars) Realm() string { - return jwt.secret -} - -func (jwt *jwtEnvVars) Expiration() time.Duration { - return jwt.expirationTime -} - -func (jwt *jwtEnvVars) RefreshTime() time.Duration { - return jwt.maxRefreshTime -} diff --git a/internal/utils/pass_encryptor.go b/internal/utils/pass_encryptor.go deleted file mode 100644 index 5ef4828..0000000 --- a/internal/utils/pass_encryptor.go +++ /dev/null @@ -1,29 +0,0 @@ -package utils - -import ( - "fmt" - - "golang.org/x/crypto/bcrypt" -) - -type BcryptEncoder struct { - cost int -} - -func NewBcryptEncoder(cost int) BcryptEncoder { - return BcryptEncoder{ - cost: cost, - } -} - -func (en BcryptEncoder) Encrypt(pass string) (string, error) { - enPass, err := bcrypt.GenerateFromPassword( - []byte(pass), - en.cost, - ) - if err != nil { - return "", fmt.Errorf("generate from password: %w", err) - } - - return string(enPass), nil -} diff --git a/internal/utils/pass_encryptor_test.go b/internal/utils/pass_encryptor_test.go deleted file mode 100644 index f8cd41b..0000000 --- a/internal/utils/pass_encryptor_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package utils - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "golang.org/x/crypto/bcrypt" -) - -func TestEncrypt(t *testing.T) { - tests := []struct { - name string - fields struct { - cost int - } - args struct { - pass string - } - wantErr bool - }{ - { - "test successful encrypting a password", - struct{ cost int }{cost: bcrypt.DefaultCost}, - struct{ pass string }{pass: "test pass"}, - false, - }, - { - "test returning an error", - // Below we set a cost that is greater than max available cost value. - // So, it causes the InvalidCostError, which is returned - // by bcrypt.GenerateFromPassword function. - struct{ cost int }{cost: 100}, - struct{ pass string }{pass: "test pass"}, - true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := NewBcryptEncoder(tt.fields.cost).Encrypt(tt.args.pass) - - if tt.wantErr { - assert.Error(t, err) - assert.Empty(t, got) - } else { - assert.NoError(t, err) - assert.NotEmpty(t, got) - } - }) - } -} diff --git a/test/integration/acceptance_test.go b/test/integration/acceptance_test.go index 894159d..f6971e6 100644 --- a/test/integration/acceptance_test.go +++ b/test/integration/acceptance_test.go @@ -8,7 +8,6 @@ import ( "net/http" "testing" - "github.com/nix-united/golang-gin-boilerplate/internal/provider" "github.com/nix-united/golang-gin-boilerplate/internal/request" "github.com/nix-united/golang-gin-boilerplate/internal/response" @@ -18,7 +17,7 @@ import ( func TestAcceptance(t *testing.T) { registerRequest := request.RegisterRequest{ - BasicAuthRequest: &request.BasicAuthRequest{ + BasicAuthRequest: request.BasicAuthRequest{ Email: "example@email.com", Password: "some-password", }, @@ -32,7 +31,7 @@ func TestAcceptance(t *testing.T) { require.NoError(t, err) createPostRequest := request.CreatePostRequest{ - BasicPost: &request.BasicPost{ + BasicPost: request.BasicPost{ Title: "Title", Content: "Content", }, @@ -41,13 +40,13 @@ func TestAcceptance(t *testing.T) { require.NoError(t, err) var ( - createdPost response.CreatePostResponse + createdPost response.PostResponse accessToken string ) t.Run("It should register an user", func(t *testing.T) { httpResponse, err := http.Post( - applicationURL.JoinPath("/users").String(), + applicationURL.JoinPath("/register").String(), "application/json", bytes.NewReader(rawRegisterRequest), ) @@ -75,14 +74,16 @@ func TestAcceptance(t *testing.T) { rawResponse, err := io.ReadAll(httpResponse.Body) require.NoError(t, err) - var loginResponse provider.Success + var loginResponse response.AuthTokenResponse err = json.Unmarshal(rawResponse, &loginResponse) require.NoError(t, err) - require.NotEmpty(t, loginResponse.Token) - require.NotEmpty(t, loginResponse.Expire) + require.NotEmpty(t, loginResponse.AccessToken) + require.NotEmpty(t, loginResponse.ExpiresIn) + require.NotEmpty(t, loginResponse.RefreshToken) + require.Equal(t, "Bearer", loginResponse.TokenType) - accessToken = loginResponse.Token + accessToken = loginResponse.AccessToken }) t.Run("It should create a post", func(t *testing.T) { @@ -102,12 +103,12 @@ func TestAcceptance(t *testing.T) { assert.NoError(t, httpResponse.Body.Close()) }() - require.Equal(t, http.StatusOK, httpResponse.StatusCode) + require.Equal(t, http.StatusCreated, httpResponse.StatusCode) rawResponse, err := io.ReadAll(httpResponse.Body) require.NoError(t, err) - var createPostResponse response.CreatePostResponse + var createPostResponse response.PostResponse err = json.Unmarshal(rawResponse, &createPostResponse) require.NoError(t, err) @@ -121,7 +122,7 @@ func TestAcceptance(t *testing.T) { t.Run("It should fetch a newly created post", func(t *testing.T) { httpRequest, err := http.NewRequest( http.MethodGet, - applicationURL.JoinPath(fmt.Sprintf("/post/%d", createdPost.ID)).String(), + applicationURL.JoinPath(fmt.Sprintf("/posts/%d", createdPost.ID)).String(), http.NoBody, ) require.NoError(t, err) @@ -140,7 +141,7 @@ func TestAcceptance(t *testing.T) { rawResponse, err := io.ReadAll(httpResponse.Body) require.NoError(t, err) - var getPostResponse response.GetPostResponse + var getPostResponse response.PostResponse err = json.Unmarshal(rawResponse, &getPostResponse) require.NoError(t, err) @@ -148,4 +149,39 @@ func TestAcceptance(t *testing.T) { assert.Equal(t, createdPost.Title, getPostResponse.Title) assert.Equal(t, createdPost.Content, getPostResponse.Content) }) + + t.Run("It should fetch a newly created post by title prefix", func(t *testing.T) { + httpRequest, err := http.NewRequest( + http.MethodGet, + applicationURL.JoinPath("/posts").String(), + http.NoBody, + ) + require.NoError(t, err) + + httpRequest.Header.Set("Content-Type", "application/json") + httpRequest.Header.Set("Authorization", "Bearer "+accessToken) + + query := httpRequest.URL.Query() + query.Set("title", "Tit") + httpRequest.URL.RawQuery = query.Encode() + + httpResponse, err := http.DefaultClient.Do(httpRequest) + require.NoError(t, err) + defer func() { + assert.NoError(t, httpResponse.Body.Close()) + }() + + require.Equal(t, http.StatusOK, httpResponse.StatusCode) + + rawResponse, err := io.ReadAll(httpResponse.Body) + require.NoError(t, err) + + var getPostResponse response.CollectionResponse[response.PostResponse] + err = json.Unmarshal(rawResponse, &getPostResponse) + require.NoError(t, err) + + assert.Equal(t, createdPost.ID, getPostResponse.Data[0].ID) + assert.Equal(t, createdPost.Title, getPostResponse.Data[0].Title) + assert.Equal(t, createdPost.Content, getPostResponse.Data[0].Content) + }) } diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 8add9ef..58ac4b2 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/url" + "os" "slices" "testing" @@ -28,14 +29,14 @@ func TestMain(m *testing.M) { shutdown, err := setupMain(ctx) if err != nil { slog.ErrorContext(ctx, "Failed to setup integration tests", "err", err) - return + os.Exit(1) } m.Run() if err := shutdown(ctx); err != nil { slog.ErrorContext(ctx, "Failed to shutdown integration tests", "err", err) - return + os.Exit(1) } } @@ -100,6 +101,7 @@ func setupMain(ctx context.Context) (_ func(context.Context) error, err error) { gdb, sqlDB, err := db.NewDBConnection(config.DBConfig{ User: mysqlConfig.User, Password: mysqlConfig.Password, + Driver: "mysql", Name: mysqlConfig.Name, Host: mysqlConfig.Host, Port: mysqlConfig.ExposedPort, diff --git a/test/integration/post_repository_test.go b/test/integration/post_repository_test.go index 45d44bc..be58f30 100644 --- a/test/integration/post_repository_test.go +++ b/test/integration/post_repository_test.go @@ -26,22 +26,24 @@ func TestPostRepository(t *testing.T) { require.NoError(t, err) }) - post := &model.Post{ + createdPost := &model.Post{ Title: "test_post_repository_title", Content: "test_post_repository_content", UserID: user.ID, } t.Run("It should create a post", func(t *testing.T) { - err := postRepository.Create(t.Context(), post) + post, err := postRepository.Create(t.Context(), createdPost) require.NoError(t, err) + + createdPost = post }) t.Run("It should get post by ID", func(t *testing.T) { - gotPost, err := postRepository.GetByID(t.Context(), post.ID) + gotPost, err := postRepository.GetByID(t.Context(), createdPost.ID) require.NoError(t, err) - assert.Equal(t, post.Title, gotPost.Title) + assert.Equal(t, createdPost.Title, gotPost.Title) }) t.Run("It should return ErrNotFound error when post with such ID not found", func(t *testing.T) { @@ -50,26 +52,26 @@ func TestPostRepository(t *testing.T) { }) t.Run("It should fetch all posts", func(t *testing.T) { - gotPosts, err := postRepository.List(t.Context()) + gotPosts, err := postRepository.List(t.Context(), domain.PostFilters{Offset: 0, Limit: 100}) require.NoError(t, err) require.GreaterOrEqual(t, len(gotPosts), 1) }) t.Run("It should update existing post", func(t *testing.T) { - post.Title = "test_post_repository_title_updated" + createdPost.Title = "test_post_repository_title_updated" - err := postRepository.Update(t.Context(), post) + err := postRepository.Update(t.Context(), createdPost) require.NoError(t, err) - gotPost, err := postRepository.GetByID(t.Context(), post.ID) + gotPost, err := postRepository.GetByID(t.Context(), createdPost.ID) require.NoError(t, err) - assert.Equal(t, post.Title, gotPost.Title) + assert.Equal(t, createdPost.Title, gotPost.Title) }) t.Run("It should soft delete existing post", func(t *testing.T) { - err := postRepository.Delete(t.Context(), post) + err := postRepository.Delete(t.Context(), createdPost) require.NoError(t, err) }) } diff --git a/test/setup/application.go b/test/setup/application.go index 44fae8c..6cbaf9c 100644 --- a/test/setup/application.go +++ b/test/setup/application.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "net/url" "time" @@ -12,7 +11,10 @@ import ( "github.com/testcontainers/testcontainers-go/wait" ) -const appHTTPPort = "80" +const ( + appHTTPPort = "80" + appContainerName = "golang_gin_boilerplate" +) type AppConfig struct { Port string @@ -25,6 +27,8 @@ func SetupApplication( networks []string, mySQLConfig MySQLConfig, ) (_ AppConfig, _ func(ctx context.Context) error, err error) { + containerLogsConsumer := newContainerLogsConsumer(appContainerName) + container, err := testcontainers.GenericContainer( ctx, testcontainers.GenericContainerRequest{ @@ -34,7 +38,7 @@ func SetupApplication( Dockerfile: "Dockerfile", }, Env: map[string]string{ - "LOG_APPLICATION": "golang-gin-boilerplate-integration-tests", + "LOG_APPLICATION": appContainerName, "PORT": appHTTPPort, "DB_DRIVER": "mysql", "DB_USER": mySQLConfig.User, @@ -50,35 +54,30 @@ func SetupApplication( WaitingFor: wait. ForAll(wait.ForHTTP("/health")). WithDeadline(time.Minute), + Name: appContainerName, Networks: networks, ExposedPorts: []string{appHTTPPort}, + LogConsumerCfg: &testcontainers.LogConsumerConfig{ + Consumers: []testcontainers.LogConsumer{containerLogsConsumer}, + }, }, Started: true, }, ) if err != nil { + // Print logs for container bootstrap failures, such as condition wait timeouts. + containerLogsConsumer.Print() return AppConfig{}, nil, fmt.Errorf("generic container from app: %w", err) } shutdown := func(ctx context.Context) error { - containerLogs, err := container.Logs(ctx) - if err != nil { - return fmt.Errorf("get application container logs: %w", err) - } - - rawContainerLogs, err := io.ReadAll(containerLogs) - if err != nil { - return fmt.Errorf("read container logs: %w", err) - } - - fmt.Print("\n\n\n### Start of application logs\n\n") - fmt.Println(string(rawContainerLogs)) - fmt.Print("### End of application logs\n\n\n\n") - if err := container.Terminate(ctx); err != nil { return fmt.Errorf("terminate app container: %w", err) } + // Print container logs after tests complete during shutdown. + containerLogsConsumer.Print() + return nil } diff --git a/test/setup/container_log_consumer.go b/test/setup/container_log_consumer.go new file mode 100644 index 0000000..6dd7869 --- /dev/null +++ b/test/setup/container_log_consumer.go @@ -0,0 +1,33 @@ +package setup + +import ( + "fmt" + + "github.com/testcontainers/testcontainers-go" +) + +var _ testcontainers.LogConsumer = (*containerLogsConsumer)(nil) + +// containerLogsConsumer collects logs from a container. +type containerLogsConsumer struct { + containerName string + logs []byte +} + +func newContainerLogsConsumer(containerName string) *containerLogsConsumer { + return &containerLogsConsumer{containerName: containerName} +} + +// Accept records a log message from the container. +// It implements [testcontainers.LogConsumer] interface. +func (c *containerLogsConsumer) Accept(log testcontainers.Log) { + c.logs = append(c.logs, log.Content...) +} + +// Print returns prints all logs. +func (c *containerLogsConsumer) Print() { + fmt.Printf(`### Start of %[1]s container logs +%[2]s +### End of %[1]s container logs +`, c.containerName, c.logs) +} diff --git a/test/setup/mysql.go b/test/setup/mysql.go index c470562..5a8f0cb 100644 --- a/test/setup/mysql.go +++ b/test/setup/mysql.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "time" "github.com/testcontainers/testcontainers-go" @@ -32,7 +31,9 @@ type MySQLConfig struct { ContainerName string } -func SetupMySQL(ctx context.Context, networks []string) (_ MySQLConfig, _ func(ctx context.Context) error, err error) { +func SetupMySQL(ctx context.Context, networks []string) (MySQLConfig, func(ctx context.Context) error, error) { + containerLogsConsumer := newContainerLogsConsumer(mysqlContainerName) + container, err := mysql.Run( ctx, mysqlImage, @@ -47,32 +48,26 @@ func SetupMySQL(ctx context.Context, networks []string) (_ MySQLConfig, _ func(c ContainerRequest: testcontainers.ContainerRequest{ Name: mysqlContainerName, Networks: networks, + LogConsumerCfg: &testcontainers.LogConsumerConfig{ + Consumers: []testcontainers.LogConsumer{containerLogsConsumer}, + }, }, }), ) if err != nil { + // Print logs for container bootstrap failures, such as condition wait timeouts. + containerLogsConsumer.Print() return MySQLConfig{}, nil, fmt.Errorf("run mysql container: %w", err) } shutdown := func(ctx context.Context) error { - containerLogs, err := container.Logs(ctx) - if err != nil { - return fmt.Errorf("get application container logs: %w", err) - } - - rawContainerLogs, err := io.ReadAll(containerLogs) - if err != nil { - return fmt.Errorf("read container logs: %w", err) - } - - fmt.Print("\n\n\n### Start of database logs\n\n") - fmt.Println(string(rawContainerLogs)) - fmt.Print("### End of database logs\n\n\n\n") - if err := container.Terminate(ctx); err != nil { return fmt.Errorf("terminate mysql container: %w", err) } + // Print container logs after tests complete during shutdown. + containerLogsConsumer.Print() + return nil }