diff --git a/backend/cmd/main.go b/backend/cmd/main.go index f910425..57612c0 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -1,25 +1,33 @@ - // @title Easywish client API - // @version 1.0 - // @description Easy and feature-rich wishlist. - // @license.name GPL 3.0 +// @title Easywish client API +// @version 1.0 +// @description Easy and feature-rich wishlist. +// @license.name GPL 3.0 - // @BasePath /api/ - // @Schemes http +// @BasePath /api/ +// @Schemes http - // @securityDefinitions.apikey JWT - // @in header - // @name Authorization +// @securityDefinitions.apikey JWT +// @in header +// @name Authorization package main import ( + "context" + "fmt" + "net/http" + "time" + "github.com/gin-gonic/gin" + "go.uber.org/fx" "easywish/config" + docs "easywish/docs" + "easywish/internal/controllers" "easywish/internal/logger" "easywish/internal/routes" + "easywish/internal/services" - docs "easywish/docs" swaggerfiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" ) @@ -30,14 +38,43 @@ func main() { panic(err) } - defer logger.Sync() + fx.New( + fx.Provide( + logger.NewLogger, + gin.Default, + ), + services.Module, + controllers.Module, + routes.Module, - r := gin.Default() - r = routes.SetupRoutes(r) + fx.Invoke(func(lc fx.Lifecycle, router *gin.Engine) { - docs.SwaggerInfo.Schemes = []string{"http"} - r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) + // Swagger + docs.SwaggerInfo.Schemes = []string{"http"} + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) - r.Run(":8080") + // Gin + server := &http.Server{ + Addr: fmt.Sprintf(":%s", config.GetConfig().Port), + Handler: router, + } + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + } + }() + return nil + }, + OnStop: func(ctx context.Context) error { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return server.Shutdown(shutdownCtx) + }, + }) + }), + + ).Run() } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 42b259c..044b645 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -146,6 +146,26 @@ const docTemplate = `{ "responses": {} } }, + "/profile": { + "patch": { + "security": [ + { + "JWT": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Update profile", + "responses": {} + } + }, "/profile/me": { "get": { "security": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 3679bc5..2133584 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -142,6 +142,26 @@ "responses": {} } }, + "/profile": { + "patch": { + "security": [ + { + "JWT": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Profile" + ], + "summary": "Update profile", + "responses": {} + } + }, "/profile/me": { "get": { "security": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index e66c72b..507b388 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -113,6 +113,18 @@ paths: summary: Confirm with code, finish creating the account tags: - Auth + /profile: + patch: + consumes: + - application/json + produces: + - application/json + responses: {} + security: + - JWT: [] + summary: Update profile + tags: + - Profile /profile/{username}: get: consumes: diff --git a/backend/go.mod b/backend/go.mod index 7f2369a..3bc4aa7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -49,6 +49,8 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/dig v1.19.0 // indirect + go.uber.org/fx v1.24.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.18.0 // indirect golang.org/x/crypto v0.39.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 1223490..ae394c4 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -120,6 +120,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2 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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= +go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= diff --git a/backend/internal/controllers/auth.go b/backend/internal/controllers/auth.go index fb11f20..23edbf7 100644 --- a/backend/internal/controllers/auth.go +++ b/backend/internal/controllers/auth.go @@ -1,31 +1,31 @@ package controllers import ( - "easywish/internal/models" "easywish/internal/services" "net/http" "github.com/gin-gonic/gin" ) -// @Summary Register an account -// @Tags Auth -// @Accept json -// @Produce json -// @Router /auth/registrationBegin [post] -func RegistrationBegin(c *gin.Context) { - c.Status(http.StatusNotImplemented) +type AuthController interface { + RegistrationBegin(c *gin.Context) + RegistrationComplete(c *gin.Context) + Login(c *gin.Context) + Refresh(c *gin.Context) + PasswordResetBegin(c *gin.Context) + PasswordResetComplete(c *gin.Context) + Router } -// @Summary Confirm with code, finish creating the account -// @Tags Auth -// @Accept json -// @Produce json -// @Router /auth/registrationComplete [post] -func RegistrationComplete(c *gin.Context) { - c.Status(http.StatusNotImplemented) +type authControllerImpl struct { + authService services.AuthService } +func NewAuthController(as services.AuthService) AuthController { + return &authControllerImpl{authService: as} +} + +// Login implements AuthController. // @Summary Acquire tokens via login credentials (and 2FA code if needed) // @Tags Auth // @Accept json @@ -33,50 +33,65 @@ func RegistrationComplete(c *gin.Context) { // @Param request body models.LoginRequest true "desc" // @Success 200 {object} models.LoginResponse "desc" // @Router /auth/login [post] -func Login(c *gin.Context) { - - var request models.LoginRequest - - if err := c.ShouldBindJSON(&request); err != nil { - c.Status(http.StatusBadRequest) - return - } - - auths := services.NewAuthService() - - result, err := auths.Login(request) - - if err != nil { - c.Status(http.StatusTeapot) - return - } - - c.JSON(http.StatusFound, result) -} - -// @Summary Receive new tokens via refresh token -// @Tags Auth -// @Accept json -// @Produce json -// @Router /auth/refresh [post] -func Refresh(c *gin.Context) { +func (a *authControllerImpl) Login(c *gin.Context) { c.Status(http.StatusNotImplemented) } +// PasswordResetBegin implements AuthController. // @Summary Request password reset email // @Tags Auth // @Accept json // @Produce json // @Router /auth/passwordResetBegin [post] -func PasswordResetBegin(c *gin.Context) { +func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) { c.Status(http.StatusNotImplemented) } +// PasswordResetComplete implements AuthController. // @Summary Complete password reset with email code and provide 2FA code or backup code if needed // @Tags Auth // @Accept json // @Produce json // @Router /auth/passwordResetComplete [post] -func PasswordResetComplete(c *gin.Context) { +func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) { c.Status(http.StatusNotImplemented) } + +// Refresh implements AuthController. +// @Summary Receive new tokens via refresh token +// @Tags Auth +// @Accept json +// @Produce json +// @Router /auth/refresh [post] +func (a *authControllerImpl) Refresh(c *gin.Context) { + c.Status(http.StatusNotImplemented) +} + +// RegistrationComplete implements AuthController. +// @Summary Register an account +// @Tags Auth +// @Accept json +// @Produce json +// @Router /auth/registrationBegin [post] +func (a *authControllerImpl) RegistrationBegin(c *gin.Context) { + c.Status(http.StatusNotImplemented) +} + +// RegistrationBegin implements AuthController. +// @Summary Confirm with code, finish creating the account +// @Tags Auth +// @Accept json +// @Produce json +// @Router /auth/registrationComplete [post] +func (a *authControllerImpl) RegistrationComplete(c *gin.Context) { + c.Status(http.StatusNotImplemented) +} + +func (a *authControllerImpl) RegisterRoutes(group *gin.RouterGroup) { + group.POST("/registrationBegin", a.RegistrationBegin) + group.POST("/registrationComplete", a.RegistrationComplete) + group.POST("/login", a.Login) + group.POST("/refresh", a.Refresh) + group.POST("/passwordResetBegin", a.PasswordResetBegin) + group.POST("/passwordResetComplete", a.PasswordResetComplete) +} diff --git a/backend/internal/controllers/profile.go b/backend/internal/controllers/profile.go index 5cd8321..08aeeaf 100644 --- a/backend/internal/controllers/profile.go +++ b/backend/internal/controllers/profile.go @@ -1,11 +1,28 @@ package controllers import ( + "easywish/internal/middleware" "net/http" "github.com/gin-gonic/gin" ) +type ProfileController interface { + GetProfile(c *gin.Context) + GetOwnProfile(c *gin.Context) + UpdateProfile(c *gin.Context) + GetPrivacySettings(c *gin.Context) + UpdatePrivacySettings(c *gin.Context) + Router +} + +type profileControllerImpl struct { +} + +func NewProfileController() ProfileController { + return &profileControllerImpl{} +} + // @Summary Get someone's profile details // @Tags Profile // @Accept json @@ -13,20 +30,8 @@ import ( // @Param username path string true "Username" // @Security JWT // @Router /profile/{username} [get] -func GetProfile(c *gin.Context) { - - username := c.Param("username") - - if username == "" { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Username cannot be empty", - }) - return - } - - c.JSON(http.StatusNotImplemented, gin.H{ - "username": username, - }) +func (p *profileControllerImpl) GetProfile(c *gin.Context) { + c.Status(http.StatusNotImplemented) } // @Summary Get own profile when authorized @@ -35,13 +40,18 @@ func GetProfile(c *gin.Context) { // @Produce json // @Security JWT // @Router /profile/me [get] -func GetOwnProfile(c *gin.Context) { +func (p *profileControllerImpl) GetOwnProfile(c *gin.Context) { + c.Status(http.StatusNotImplemented) +} - username := "Gregory House" - - c.JSON(http.StatusNotImplemented, gin.H{ - "username": username, - }) +// @Summary Update profile +// @Tags Profile +// @Accept json +// @Produce json +// @Security JWT +// @Router /profile [patch] +func (p *profileControllerImpl) UpdateProfile(c *gin.Context) { + c.Status(http.StatusNotImplemented) } // @Summary Get profile privacy settings @@ -50,7 +60,7 @@ func GetOwnProfile(c *gin.Context) { // @Produce json // @Security JWT // @Router /profile/privacy [get] -func GetPrivacySettings(c *gin.Context) { +func (p *profileControllerImpl) GetPrivacySettings(c *gin.Context) { c.Status(http.StatusNotImplemented) } @@ -60,6 +70,16 @@ func GetPrivacySettings(c *gin.Context) { // @Produce json // @Security JWT // @Router /profile/privacy [patch] -func UpdatePrivacySettings(c *gin.Context) { +func (p *profileControllerImpl) UpdatePrivacySettings(c *gin.Context) { c.Status(http.StatusNotImplemented) } + +func (p *profileControllerImpl) RegisterRoutes(group *gin.RouterGroup) { + protected := group.Group("") + protected.Use(middleware.JWTAuthMiddleware()) + { + protected.GET("/me", p.GetOwnProfile) + protected.GET("/:username", p.GetProfile) + protected.GET("/privacy", p.GetPrivacySettings) + } +} diff --git a/backend/internal/controllers/router.go b/backend/internal/controllers/router.go new file mode 100644 index 0000000..1256a3b --- /dev/null +++ b/backend/internal/controllers/router.go @@ -0,0 +1,9 @@ +package controllers + +import ( + "github.com/gin-gonic/gin" +) + +type Router interface { + RegisterRoutes(group *gin.RouterGroup) +} diff --git a/backend/internal/controllers/service.go b/backend/internal/controllers/service.go index 698a3c5..923856a 100644 --- a/backend/internal/controllers/service.go +++ b/backend/internal/controllers/service.go @@ -6,10 +6,18 @@ import ( "github.com/gin-gonic/gin" ) -type HealthStatus struct { - Healthy bool `json:"healthy"` +type ServiceController interface { + HealthCheck(c *gin.Context) + Router } +type serviceControllerImpl struct{} + +func NewServiceController() ServiceController { + return &serviceControllerImpl{} +} + +// HealthCheck implements ServiceController. // @Summary Get health status // @Description Used internally for checking service health // @Tags Service @@ -17,6 +25,15 @@ type HealthStatus struct { // @Produce json // @Success 200 {object} HealthStatus "Says whether it's healthy or not" // @Router /service/health [get] -func HealthCheck(c *gin.Context) { +func (s *serviceControllerImpl) HealthCheck(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"healthy": true}) } + +// RegisterRoutes implements ServiceController. +func (s *serviceControllerImpl) RegisterRoutes(group *gin.RouterGroup) { + group.GET("/health", s.HealthCheck) +} + +type HealthStatus struct { + Healthy bool `json:"healthy"` +} diff --git a/backend/internal/controllers/setup.go b/backend/internal/controllers/setup.go new file mode 100644 index 0000000..5b64227 --- /dev/null +++ b/backend/internal/controllers/setup.go @@ -0,0 +1,13 @@ +package controllers + +import ( + "go.uber.org/fx" +) + +var Module = fx.Module("controllers", + fx.Provide( + NewServiceController, + NewAuthController, + NewProfileController, + ), +) diff --git a/backend/internal/errors/auth.go b/backend/internal/errors/auth.go index d7f6c90..a3d0959 100644 --- a/backend/internal/errors/auth.go +++ b/backend/internal/errors/auth.go @@ -6,4 +6,7 @@ import ( var ( ErrUnauthorized = errors.New("User is not authorized") + ErrUsernameTaken = errors.New("Provided username is already in use") + ErrInvalidCredentials = errors.New("Invalid username, password or TOTP code") + ErrInvalidToken = errors.New("Token is invalid or expired") ) diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go index 051297e..d2586bd 100644 --- a/backend/internal/logger/logger.go +++ b/backend/internal/logger/logger.go @@ -1,17 +1,31 @@ package logger + import ( "sync" - "go.uber.org/zap" "easywish/config" + + "go.uber.org/zap" ) +type Logger interface { + Get() *zap.Logger + Sync() error +} + +type loggerImpl struct { +} + +func NewLogger() Logger { + return &loggerImpl{} +} + var ( logger *zap.Logger once sync.Once ) -func GetLogger() *zap.Logger { +func (l *loggerImpl) Get() *zap.Logger { once.Do(func() { var err error cfg := config.GetConfig() @@ -28,6 +42,6 @@ func GetLogger() *zap.Logger { return logger } -func Sync() error { +func (l *loggerImpl) Sync() error { return logger.Sync() } diff --git a/backend/internal/models/auth.go b/backend/internal/models/auth.go index 97fb8df..e9bdecc 100644 --- a/backend/internal/models/auth.go +++ b/backend/internal/models/auth.go @@ -5,6 +5,24 @@ type Tokens struct { RefreshToken string `json:"refresh_token"` } +type RegistrationBeginRequest struct { + Username string `json:"username"` + Email *string `json:"email"` + Password string `json:"password"` // TODO: password checking +} + +type RegistrationCompleteRequest struct { + Username string `json:"username"` + VerificationCode string `json:"verification_code"` + Name string `json:"name"` + Birthday *string `json:"birthday"` + AvatarUrl *string `json:"avatar_url"` +} + +type RegistrationCompleteResponse struct { + Tokens +} + type LoginRequest struct { Username string `json:"username"` Password string `json:"password"` diff --git a/backend/internal/routes/router.go b/backend/internal/routes/router.go new file mode 100644 index 0000000..abda7f2 --- /dev/null +++ b/backend/internal/routes/router.go @@ -0,0 +1,44 @@ +package routes + +import ( + "easywish/internal/controllers" + + "github.com/gin-gonic/gin" +) + +func NewRouter(engine *gin.Engine, groups []RouteGroup) *gin.Engine { + apiGroup := engine.Group("/api") + for _, group := range groups { + subgroup := apiGroup.Group(group.BasePath) + subgroup.Use(group.Middleware...) + group.Router.RegisterRoutes(subgroup) + } + return engine +} + +type RouteGroup struct { + BasePath string + Middleware []gin.HandlerFunc + Router controllers.Router +} + +func NewRouteGroups( + authController controllers.AuthController, + serviceController controllers.ServiceController, + profileController controllers.ProfileController, +) []RouteGroup { + return []RouteGroup{ + { + BasePath: "/auth", + Router: authController, + }, + { + BasePath: "/service", + Router: serviceController, + }, + { + BasePath: "/profile", + Router: profileController, + }, + } +} diff --git a/backend/internal/routes/setup.go b/backend/internal/routes/setup.go index 8fe0445..3bfc0c0 100644 --- a/backend/internal/routes/setup.go +++ b/backend/internal/routes/setup.go @@ -1,52 +1,12 @@ package routes import ( - "easywish/internal/controllers" - "easywish/internal/middleware" - - "github.com/gin-gonic/gin" + "go.uber.org/fx" ) -func SetupRoutes(r *gin.Engine) *gin.Engine { - apiGroup := r.Group("/api") - { - serviceGroup := apiGroup.Group("/service") - { - serviceGroup.GET("/health", controllers.HealthCheck) - } - - authGroup := apiGroup.Group("/auth") - { - authGroup.POST("/registrationBegin", controllers.RegistrationBegin) - authGroup.POST("/registrationComplete", controllers.RegistrationComplete) - authGroup.POST("/login", controllers.Login) - authGroup.POST("/refresh", controllers.Refresh) - authGroup.POST("/passwordResetBegin", controllers.PasswordResetBegin) - authGroup.POST("/passwordResetComplete", controllers.PasswordResetComplete) - } - - profileGroup := apiGroup.Group("/profile") - { - profileGroup.GET("/:username", controllers.GetProfile) - } - - protected := apiGroup.Group("") - protected.Use(middleware.JWTAuthMiddleware()) - { - accountGroup := protected.Group("/account") - { - accountGroup.PUT("/changePassword", controllers.ChangePassword) - } - - profileGroup := protected.Group("/profile") - { - profileGroup.GET("/me", controllers.GetOwnProfile) - profileGroup.GET("/privacy", controllers.GetPrivacySettings) - profileGroup.PATCH("/privacy", controllers.UpdatePrivacySettings) - } - } - - } - - return r -} +var Module = fx.Module("routes", + fx.Provide( + NewRouteGroups, + ), + fx.Invoke(NewRouter), +) diff --git a/backend/internal/services/auth.go b/backend/internal/services/auth.go index 1b9636c..55dafef 100644 --- a/backend/internal/services/auth.go +++ b/backend/internal/services/auth.go @@ -2,25 +2,33 @@ package services import ( "easywish/internal/database" - "easywish/internal/errors" errs "easywish/internal/errors" "easywish/internal/models" "easywish/internal/utils" ) type AuthService interface { + RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) + RegistrationComplete(request models.RegistrationBeginRequest) (*models.RegistrationCompleteResponse, error) Login(request models.LoginRequest) (*models.LoginResponse, error) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) } type authServiceImpl struct { - } func NewAuthService() AuthService { return &authServiceImpl{} } +func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) { + return false, errs.ErrNotImplemented +} + +func (a *authServiceImpl) RegistrationComplete(request models.RegistrationBeginRequest) (*models.RegistrationCompleteResponse, error) { + return nil, errs.ErrNotImplemented +} + func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginResponse, error) { conn, ctx, err := utils.GetDbConn() if err != nil { @@ -44,7 +52,6 @@ func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginRespo return &models.LoginResponse{Tokens: models.Tokens{AccessToken: accessToken, RefreshToken: refreshToken}}, nil } - func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) { - return nil, errors.ErrNotImplemented + return nil, errs.ErrNotImplemented } diff --git a/backend/internal/services/setup.go b/backend/internal/services/setup.go new file mode 100644 index 0000000..d93e5b0 --- /dev/null +++ b/backend/internal/services/setup.go @@ -0,0 +1,12 @@ +package services + +import ( + "go.uber.org/fx" +) + +var Module = fx.Module("services", + fx.Provide( + NewAuthService, + ), +) +