diff --git a/README.md b/README.md index 9b8590a..de63bcb 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Go's best practices and standards are followed most of the time, using technique - [Prerequisites](#prerequisites) - [Installation and Usage](#installation-and-usage) - [Project Architecture](#project-architecture) +- [Request Flow Overview](#request-flow-overview) - [Contributing and License](#contributing-and-license) ## Prerequisites @@ -65,6 +66,56 @@ This will start the server and you'll hopefully be able to hit the endpoints. | **pkg > transport** | Sets up routes and manages the transport layer. | | **pkg > utils** | Houses utility functions used across the project. | +## Request Flow Overview + +1. **Entry Point**: + + All requests start at `router.go`, where the URL is matched. + +2. **Token Validation**: + + For private endpoints, the request is routed through `token_validation.go` for authentication. + +3. **Endpoint Matching**: + + The corresponding function in `transport_endpoints.go` is invoked based on the matched URL. + +4. **Request Handling**: + + This function then calls `HandleRequest` from `transport.go`. + +5. **Request Typification & Validation**: + + Inside `transport.go`, the appropriate function from `transport_requests.go` is called. This step involves typifying and validating the incoming request. + +6. **Service Layer Invocation**: + + Upon successful validation, `HandleRequest` invokes the corresponding method in `service.go`. + +7. **Service Implementation**: + + The actual implementation of these methods resides in `service_xxx.go`, organized by domain. + +8. **Entity to Model Conversion**: + + The service layer operates using **entities**. If needed, the `codec` is used to convert these entities into **models** before interacting with the `repository` layer. + +9. **Repository Layer**: + + Here, the matching `repository_xxx.go` file is invoked. This layer handles database operations and returns data or errors as required. + +10. **Backtracking & Response Handling**: + + The system then retraces its steps. If the repository layer returned a model, it's converted back to an entity in `service_xxx.go`. This file also handles any errors or continues execution if there are none. + +11. **Finalizing Response**: + + The process returns to `HandleRequest` in `transport.go`, where the service's response is processed. If there's an error, it's mapped using `errors_mapper.go`. + +12. **Sending HTTP Response**: + + Finally, the HTTP response is sent back to the client. + ## Contributing and License ### Contributing diff --git a/cmd/main.go b/cmd/main.go index 641ea08..b489fcc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -9,6 +9,8 @@ import ( "github.com/gilperopiola/go-rest-example/pkg/repository" "github.com/gilperopiola/go-rest-example/pkg/service" "github.com/gilperopiola/go-rest-example/pkg/transport" + + "github.com/sirupsen/logrus" ) func main() { @@ -22,6 +24,9 @@ func main() { // Load configuration settings config = config.NewConfig() + // Initialize logger + logger = logrus.New() + // Initialize authentication module auth = auth.NewAuth(config.JWT.SECRET, config.JWT.SESSION_DURATION_DAYS) @@ -29,24 +34,28 @@ func main() { codec = codec.NewCodec() // Establish database connection - database = repository.NewDatabase(config.DATABASE) + database = repository.NewDatabase(config.DATABASE, logger) // Initialize repository with the database connection repository = repository.NewRepository(database) // Setup the main service with dependencies - service = service.NewService(repository, auth, codec, config, service.ErrorsMapper{}) + service = service.NewService(repository, auth, codec, config, service.NewErrorsMapper()) // Setup endpoints & transport layer with dependencies - endpoints = transport.NewTransport(service, codec, transport.ErrorsMapper{}) + endpoints = transport.NewTransport(service, codec, transport.NewErrorsMapper(logger)) // Initialize the router with the endpoints - router = transport.NewRouter(endpoints, config, auth) + router = transport.NewRouter(endpoints, config, auth, logger) ) // Defer closing open connections defer database.Close() + // Set log format and level + logger.SetFormatter(&logrus.JSONFormatter{}) + logger.SetLevel(logrus.InfoLevel) + // Start server log.Println("About to run server on port " + config.PORT) diff --git a/go.mod b/go.mod index 9f5fdc9..5c9a42e 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/afero v1.9.5 // indirect github.com/spf13/cast v1.5.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect diff --git a/go.sum b/go.sum index 7cb74c7..c766031 100644 --- a/go.sum +++ b/go.sum @@ -223,6 +223,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR 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.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= @@ -407,6 +409,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc 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-20220704084225-05e143d24a9e/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-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= diff --git a/pkg/config/config.go b/pkg/config/config.go index e919f65..e8894fc 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -55,7 +55,7 @@ var ( defaultDatabasePort = "3306" defaultDatabaseSchema = "go-rest-example-db" defaultDatabasePurge = false - defaultDatabaseDebug = true + defaultDatabaseDebug = false defaultJWTSecret = "a0#3ndl2" defaultJWTSessionDurationDays = 14 diff --git a/pkg/entities/errors.go b/pkg/entities/errors.go index 7c185c5..668e3c8 100644 --- a/pkg/entities/errors.go +++ b/pkg/entities/errors.go @@ -5,6 +5,10 @@ import ( "fmt" ) +// IMPORTANT! +// +// If you add a new error, make sure to add it to the errorsMapToHTTPCode map in pkg/transport/errors_mapper.go + var ( // Generic @@ -12,6 +16,8 @@ var ( ErrUnauthorized = errors.New("unauthorized") ErrBindingRequest = errors.New("error binding request") ErrAllFieldsRequired = errors.New("all fields required") + ErrNilError = errors.New("unexpected behavior, nil error") + ErrUnknown = errors.New("unknown") // Signup - Login - Users in general diff --git a/pkg/repository/database.go b/pkg/repository/database.go index 405eac1..1b820e5 100644 --- a/pkg/repository/database.go +++ b/pkg/repository/database.go @@ -1,13 +1,15 @@ package repository import ( - "log" + "os" + "time" "github.com/gilperopiola/go-rest-example/pkg/config" "github.com/gilperopiola/go-rest-example/pkg/models" _ "github.com/go-sql-driver/mysql" "github.com/jinzhu/gorm" + "github.com/sirupsen/logrus" ) type Database struct { @@ -15,26 +17,35 @@ type Database struct { } type DatabaseProvider interface { - Setup(config config.DatabaseConfig) + Setup(config config.DatabaseConfig, logger *logrus.Logger) Purge() Migrate() Close() } -func NewDatabase(config config.DatabaseConfig) Database { +func NewDatabase(config config.DatabaseConfig, logger *logrus.Logger) Database { var database Database - database.Setup(config) + database.Setup(config, logger) return database } /* ------------------- */ -func (database *Database) Setup(config config.DatabaseConfig) { +func (database *Database) Setup(config config.DatabaseConfig, logger *logrus.Logger) { + + // Create connection var err error if database.DB, err = gorm.Open(config.TYPE, config.GetConnectionString()); err != nil { - log.Fatalf("error connecting to database: %v", err) + logger.Fatalf("error connecting to database: %v", err) + os.Exit(1) } + // Set connection pool limits + database.DB.DB().SetMaxIdleConns(10) + database.DB.DB().SetMaxOpenConns(100) + database.DB.DB().SetConnMaxLifetime(time.Hour) + + // Flags if config.DEBUG { database.DB.LogMode(true) } diff --git a/pkg/repository/repository_users.go b/pkg/repository/repository_users.go index 1d66dce..27d227d 100644 --- a/pkg/repository/repository_users.go +++ b/pkg/repository/repository_users.go @@ -21,7 +21,7 @@ func buildNonDeletedQuery(query string, onlyNonDeleted bool) string { // CreateUser creates a user on the database. Id, username and email are unique func (r *Repository) CreateUser(user models.User) (models.User, error) { if err := r.Database.DB.Create(&user).Error; err != nil { - return models.User{}, utils.JoinErrors(ErrCreatingUser, err) + return models.User{}, utils.JoinErrors(err, ErrCreatingUser) } return user, nil @@ -30,7 +30,7 @@ func (r *Repository) CreateUser(user models.User) (models.User, error) { // UpdateUser updates the user on the database, skipping fields that are empty func (r *Repository) UpdateUser(user models.User) (models.User, error) { if err := r.Database.DB.Model(&user).Update(&user).Error; err != nil { - return models.User{}, utils.JoinErrors(ErrUpdatingUser, err) + return models.User{}, utils.JoinErrors(err, ErrUpdatingUser) } return user, nil @@ -58,9 +58,9 @@ func (r *Repository) GetUser(user models.User, onlyNonDeleted bool) (models.User err := r.Database.DB.Where(query, user.ID, user.Username, user.Email).First(&user).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return models.User{}, utils.JoinErrors(ErrGettingUser, err) + return models.User{}, utils.JoinErrors(err, ErrGettingUser) } - return models.User{}, utils.JoinErrors(ErrUnknown, err) + return models.User{}, utils.JoinErrors(err, ErrUnknown) } return user, nil @@ -83,7 +83,7 @@ func (r *Repository) DeleteUser(id int) (user models.User, err error) { // Then, mark the user as deleted and save it user.Deleted = true if _, err := r.UpdateUser(user); err != nil { - return models.User{}, utils.JoinErrors(ErrUpdatingUser, err) + return models.User{}, utils.JoinErrors(err, ErrUpdatingUser) } return user, nil diff --git a/pkg/service/errors_mapper.go b/pkg/service/errors_mapper.go index 7ba6f24..a4e49b8 100644 --- a/pkg/service/errors_mapper.go +++ b/pkg/service/errors_mapper.go @@ -14,8 +14,17 @@ type errorsMapperInterface interface { type ErrorsMapper struct{} +func NewErrorsMapper() ErrorsMapper { + return ErrorsMapper{} +} + func (e ErrorsMapper) Map(err error) error { + // If we're here we shouldn't have a nil error + if err == nil { + return entities.ErrNilError + } + // Signup, Login & Users if errors.Is(err, repository.ErrCreatingUser) { return utils.JoinErrors(entities.ErrCreatingUser, err) @@ -25,6 +34,10 @@ func (e ErrorsMapper) Map(err error) error { return utils.JoinErrors(entities.ErrUserNotFound, err) } + if errors.Is(err, repository.ErrUserAlreadyDeleted) { + return utils.JoinErrors(entities.ErrUserNotFound, err) + } + if errors.Is(err, repository.ErrUnknown) { return utils.JoinErrors(entities.ErrUserNotFound, err) } diff --git a/pkg/service/service_users.go b/pkg/service/service_users.go index 7b2bac7..dfaa8e8 100644 --- a/pkg/service/service_users.go +++ b/pkg/service/service_users.go @@ -58,6 +58,7 @@ func (s *Service) UpdateUser(updateUserRequest entities.UpdateUserRequest) (enti func (s *Service) DeleteUser(deleteUserRequest entities.DeleteUserRequest) (entities.DeleteUserResponse, error) { // Set the user's Deleted field to true + // This returns an error if the user is already deleted userModel, err := s.Repository.DeleteUser(deleteUserRequest.ID) if err != nil { return entities.DeleteUserResponse{}, s.ErrorsMapper.Map(err) diff --git a/pkg/transport/errors_mapper.go b/pkg/transport/errors_mapper.go index de20f95..b8d238e 100644 --- a/pkg/transport/errors_mapper.go +++ b/pkg/transport/errors_mapper.go @@ -1,14 +1,22 @@ package transport import ( - "errors" "net/http" + "strings" "github.com/gilperopiola/go-rest-example/pkg/entities" "github.com/gilperopiola/go-rest-example/pkg/utils" + + "github.com/sirupsen/logrus" ) -type ErrorsMapper struct{} +type ErrorsMapper struct { + logger *logrus.Logger +} + +func NewErrorsMapper(logger *logrus.Logger) ErrorsMapper { + return ErrorsMapper{logger: logger} +} type ErrorsMapperInterface interface { Map(err error) (status int, response HTTPResponse) @@ -19,32 +27,61 @@ func (e ErrorsMapper) MapWithType(errType, err error) (status int, response HTTP return e.Map(utils.JoinErrors(errType, err)) } +// This method will define the response of the transport layer func (e ErrorsMapper) Map(err error) (status int, response HTTPResponse) { - // Generic errors - - if errors.Is(err, entities.ErrUnauthorized) { - return returnErrorResponse(http.StatusUnauthorized, err) + // If we're here we shouldn't have a nil error + if err == nil { + err = entities.ErrNilError } - if errors.Is(err, entities.ErrBindingRequest) { - return returnErrorResponse(http.StatusBadRequest, err) - } + // We check if the specific error msg is in the error chain + // Assigning then the HTTP code and defaulting to 500 + responseStatusCode := http.StatusInternalServerError - if errors.Is(err, entities.ErrAllFieldsRequired) { - return returnErrorResponse(http.StatusBadRequest, err) + for key, value := range errorsMapToHTTPCode { + if strings.Contains(err.Error(), key.Error()) { + responseStatusCode = value + break + } } - // Signup + // We log 500's as errors, and 400's as warnings + logWarningOrError(e.logger, err, responseStatusCode) - if errors.Is(err, entities.ErrPasswordsDontMatch) { - return returnErrorResponse(http.StatusBadRequest, err) - } + return returnErrorResponse(responseStatusCode, err) +} - if errors.Is(err, entities.ErrUsernameOrEmailAlreadyInUse) { - return returnErrorResponse(http.StatusBadRequest, err) - } +var errorsMapToHTTPCode = map[error]int{ + + // 400 - Bad Request + entities.ErrBindingRequest: 400, + entities.ErrAllFieldsRequired: 400, + entities.ErrPasswordsDontMatch: 400, + entities.ErrInvalidEmailFormat: 400, + entities.ErrInvalidUsernameLength: 400, + entities.ErrInvalidPasswordLength: 400, + + // 401 - Unauthorized + entities.ErrUnauthorized: 401, + entities.ErrWrongPassword: 401, + + // 404 - Not Found + entities.ErrUserNotFound: 404, - // Default to internal server error - return returnErrorResponse(http.StatusInternalServerError, err) + // 409 - Conflict + entities.ErrUsernameOrEmailAlreadyInUse: 409, + + // 500 - Internal Server Error + entities.ErrCreatingUser: 500, + entities.ErrNilError: 500, + entities.ErrUnknown: 500, +} + +func logWarningOrError(logger *logrus.Logger, err error, responseStatusCode int) { + if responseStatusCode >= 500 { + logger.Error(err.Error()) + } else { + logger.Warn(err.Error()) + } } diff --git a/pkg/transport/http_response.go b/pkg/transport/http_response.go index 3de4960..8be4b41 100644 --- a/pkg/transport/http_response.go +++ b/pkg/transport/http_response.go @@ -1,6 +1,8 @@ package transport -import "net/http" +import ( + "net/http" +) type HTTPResponse struct { Success bool `json:"success"` diff --git a/pkg/transport/router.go b/pkg/transport/router.go index c8ceea2..6d308dc 100644 --- a/pkg/transport/router.go +++ b/pkg/transport/router.go @@ -6,29 +6,41 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" ) type Router struct { *gin.Engine } -func NewRouter(transport TransportProvider, config config.ConfigInterface, auth auth.AuthInterface) Router { +func NewRouter(transport TransportProvider, config config.ConfigInterface, auth auth.AuthInterface, + logger *logrus.Logger) Router { var router Router - router.Setup(transport, config, auth) + router.Setup(transport, config, auth, logger) return router } /* ------------------- */ -func (router *Router) Setup(transport TransportProvider, config config.ConfigInterface, auth auth.AuthInterface) { +func LoggerToContext(logger *logrus.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("logger", logger) + c.Next() + } +} + +func (router *Router) Setup(transport TransportProvider, config config.ConfigInterface, auth auth.AuthInterface, + logger *logrus.Logger) { // Prepare router if !config.GetDebugMode() { gin.SetMode(gin.ReleaseMode) } + // Add middleware router.Engine = gin.New() router.Use(getCORSConfig()) + router.Use(LoggerToContext(logger)) // Set endpoints router.SetPublicEndpoints(transport) diff --git a/pkg/transport/transport_requests.go b/pkg/transport/transport_requests.go index afeb6ff..15484ea 100644 --- a/pkg/transport/transport_requests.go +++ b/pkg/transport/transport_requests.go @@ -118,7 +118,7 @@ func getUserIDFromContext(c *gin.Context) (int, error) { } // Get URL user ID - userToGetID, err := utils.GetIntFromURLParams(c, "user_id") + userToGetID, err := utils.GetIntFromContextParams(c.Params, "user_id") if err != nil { return 0, err } diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 0793d93..3036d24 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -47,10 +47,10 @@ func GetIntFromContext(c *gin.Context, key string) (int, error) { return valueInt, nil } -func GetIntFromURLParams(c *gin.Context, key string) (int, error) { +func GetIntFromContextParams(params gin.Params, key string) (int, error) { // Get from params - value, ok := c.Params.Get(key) + value, ok := params.Get(key) if !ok { return 0, fmt.Errorf("error getting %s from URL params", key) }