experiment-service_controller_pattern #1

Merged
weirdcat merged 8 commits from experiment-service_controller_pattern into main 2025-06-20 17:57:12 +03:00
18 changed files with 366 additions and 139 deletions

View File

@@ -1,25 +1,33 @@
// @title Easywish client API // @title Easywish client API
// @version 1.0 // @version 1.0
// @description Easy and feature-rich wishlist. // @description Easy and feature-rich wishlist.
// @license.name GPL 3.0 // @license.name GPL 3.0
// @BasePath /api/ // @BasePath /api/
// @Schemes http // @Schemes http
// @securityDefinitions.apikey JWT // @securityDefinitions.apikey JWT
// @in header // @in header
// @name Authorization // @name Authorization
package main package main
import ( import (
"context"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/fx"
"easywish/config" "easywish/config"
docs "easywish/docs"
"easywish/internal/controllers"
"easywish/internal/logger" "easywish/internal/logger"
"easywish/internal/routes" "easywish/internal/routes"
"easywish/internal/services"
docs "easywish/docs"
swaggerfiles "github.com/swaggo/files" swaggerfiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger" ginSwagger "github.com/swaggo/gin-swagger"
) )
@@ -30,14 +38,43 @@ func main() {
panic(err) panic(err)
} }
defer logger.Sync() fx.New(
fx.Provide(
logger.NewLogger,
gin.Default,
),
services.Module,
controllers.Module,
routes.Module,
r := gin.Default() fx.Invoke(func(lc fx.Lifecycle, router *gin.Engine) {
r = routes.SetupRoutes(r)
// Swagger
docs.SwaggerInfo.Schemes = []string{"http"} docs.SwaggerInfo.Schemes = []string{"http"}
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) 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()
} }

View File

@@ -146,6 +146,26 @@ const docTemplate = `{
"responses": {} "responses": {}
} }
}, },
"/profile": {
"patch": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update profile",
"responses": {}
}
},
"/profile/me": { "/profile/me": {
"get": { "get": {
"security": [ "security": [

View File

@@ -142,6 +142,26 @@
"responses": {} "responses": {}
} }
}, },
"/profile": {
"patch": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update profile",
"responses": {}
}
},
"/profile/me": { "/profile/me": {
"get": { "get": {
"security": [ "security": [

View File

@@ -113,6 +113,18 @@ paths:
summary: Confirm with code, finish creating the account summary: Confirm with code, finish creating the account
tags: tags:
- Auth - Auth
/profile:
patch:
consumes:
- application/json
produces:
- application/json
responses: {}
security:
- JWT: []
summary: Update profile
tags:
- Profile
/profile/{username}: /profile/{username}:
get: get:
consumes: consumes:

View File

@@ -49,6 +49,8 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // 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 go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.18.0 // indirect golang.org/x/arch v0.18.0 // indirect
golang.org/x/crypto v0.39.0 // indirect golang.org/x/crypto v0.39.0 // indirect

View File

@@ -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 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= 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= 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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=

View File

@@ -1,31 +1,31 @@
package controllers package controllers
import ( import (
"easywish/internal/models"
"easywish/internal/services" "easywish/internal/services"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// @Summary Register an account type AuthController interface {
// @Tags Auth RegistrationBegin(c *gin.Context)
// @Accept json RegistrationComplete(c *gin.Context)
// @Produce json Login(c *gin.Context)
// @Router /auth/registrationBegin [post] Refresh(c *gin.Context)
func RegistrationBegin(c *gin.Context) { PasswordResetBegin(c *gin.Context)
c.Status(http.StatusNotImplemented) PasswordResetComplete(c *gin.Context)
Router
} }
// @Summary Confirm with code, finish creating the account type authControllerImpl struct {
// @Tags Auth authService services.AuthService
// @Accept json
// @Produce json
// @Router /auth/registrationComplete [post]
func RegistrationComplete(c *gin.Context) {
c.Status(http.StatusNotImplemented)
} }
func NewAuthController(as services.AuthService) AuthController {
return &authControllerImpl{authService: as}
}
// Login implements AuthController.
// @Summary Acquire tokens via login credentials (and 2FA code if needed) // @Summary Acquire tokens via login credentials (and 2FA code if needed)
// @Tags Auth // @Tags Auth
// @Accept json // @Accept json
@@ -33,50 +33,65 @@ func RegistrationComplete(c *gin.Context) {
// @Param request body models.LoginRequest true "desc" // @Param request body models.LoginRequest true "desc"
// @Success 200 {object} models.LoginResponse "desc" // @Success 200 {object} models.LoginResponse "desc"
// @Router /auth/login [post] // @Router /auth/login [post]
func Login(c *gin.Context) { func (a *authControllerImpl) 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) {
c.Status(http.StatusNotImplemented) c.Status(http.StatusNotImplemented)
} }
// PasswordResetBegin implements AuthController.
// @Summary Request password reset email // @Summary Request password reset email
// @Tags Auth // @Tags Auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Router /auth/passwordResetBegin [post] // @Router /auth/passwordResetBegin [post]
func PasswordResetBegin(c *gin.Context) { func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) {
c.Status(http.StatusNotImplemented) c.Status(http.StatusNotImplemented)
} }
// PasswordResetComplete implements AuthController.
// @Summary Complete password reset with email code and provide 2FA code or backup code if needed // @Summary Complete password reset with email code and provide 2FA code or backup code if needed
// @Tags Auth // @Tags Auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Router /auth/passwordResetComplete [post] // @Router /auth/passwordResetComplete [post]
func PasswordResetComplete(c *gin.Context) { func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) {
c.Status(http.StatusNotImplemented) 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)
}

View File

@@ -1,11 +1,28 @@
package controllers package controllers
import ( import (
"easywish/internal/middleware"
"net/http" "net/http"
"github.com/gin-gonic/gin" "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 // @Summary Get someone's profile details
// @Tags Profile // @Tags Profile
// @Accept json // @Accept json
@@ -13,20 +30,8 @@ import (
// @Param username path string true "Username" // @Param username path string true "Username"
// @Security JWT // @Security JWT
// @Router /profile/{username} [get] // @Router /profile/{username} [get]
func GetProfile(c *gin.Context) { func (p *profileControllerImpl) GetProfile(c *gin.Context) {
c.Status(http.StatusNotImplemented)
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,
})
} }
// @Summary Get own profile when authorized // @Summary Get own profile when authorized
@@ -35,13 +40,18 @@ func GetProfile(c *gin.Context) {
// @Produce json // @Produce json
// @Security JWT // @Security JWT
// @Router /profile/me [get] // @Router /profile/me [get]
func GetOwnProfile(c *gin.Context) { func (p *profileControllerImpl) GetOwnProfile(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
username := "Gregory House" // @Summary Update profile
// @Tags Profile
c.JSON(http.StatusNotImplemented, gin.H{ // @Accept json
"username": username, // @Produce json
}) // @Security JWT
// @Router /profile [patch]
func (p *profileControllerImpl) UpdateProfile(c *gin.Context) {
c.Status(http.StatusNotImplemented)
} }
// @Summary Get profile privacy settings // @Summary Get profile privacy settings
@@ -50,7 +60,7 @@ func GetOwnProfile(c *gin.Context) {
// @Produce json // @Produce json
// @Security JWT // @Security JWT
// @Router /profile/privacy [get] // @Router /profile/privacy [get]
func GetPrivacySettings(c *gin.Context) { func (p *profileControllerImpl) GetPrivacySettings(c *gin.Context) {
c.Status(http.StatusNotImplemented) c.Status(http.StatusNotImplemented)
} }
@@ -60,6 +70,16 @@ func GetPrivacySettings(c *gin.Context) {
// @Produce json // @Produce json
// @Security JWT // @Security JWT
// @Router /profile/privacy [patch] // @Router /profile/privacy [patch]
func UpdatePrivacySettings(c *gin.Context) { func (p *profileControllerImpl) UpdatePrivacySettings(c *gin.Context) {
c.Status(http.StatusNotImplemented) 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)
}
}

View File

@@ -0,0 +1,9 @@
package controllers
import (
"github.com/gin-gonic/gin"
)
type Router interface {
RegisterRoutes(group *gin.RouterGroup)
}

View File

@@ -6,10 +6,18 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type HealthStatus struct { type ServiceController interface {
Healthy bool `json:"healthy"` HealthCheck(c *gin.Context)
Router
} }
type serviceControllerImpl struct{}
func NewServiceController() ServiceController {
return &serviceControllerImpl{}
}
// HealthCheck implements ServiceController.
// @Summary Get health status // @Summary Get health status
// @Description Used internally for checking service health // @Description Used internally for checking service health
// @Tags Service // @Tags Service
@@ -17,6 +25,15 @@ type HealthStatus struct {
// @Produce json // @Produce json
// @Success 200 {object} HealthStatus "Says whether it's healthy or not" // @Success 200 {object} HealthStatus "Says whether it's healthy or not"
// @Router /service/health [get] // @Router /service/health [get]
func HealthCheck(c *gin.Context) { func (s *serviceControllerImpl) HealthCheck(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"healthy": true}) 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"`
}

View File

@@ -0,0 +1,13 @@
package controllers
import (
"go.uber.org/fx"
)
var Module = fx.Module("controllers",
fx.Provide(
NewServiceController,
NewAuthController,
NewProfileController,
),
)

View File

@@ -6,4 +6,7 @@ import (
var ( var (
ErrUnauthorized = errors.New("User is not authorized") 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")
) )

View File

@@ -1,17 +1,31 @@
package logger package logger
import ( import (
"sync" "sync"
"go.uber.org/zap"
"easywish/config" "easywish/config"
"go.uber.org/zap"
) )
type Logger interface {
Get() *zap.Logger
Sync() error
}
type loggerImpl struct {
}
func NewLogger() Logger {
return &loggerImpl{}
}
var ( var (
logger *zap.Logger logger *zap.Logger
once sync.Once once sync.Once
) )
func GetLogger() *zap.Logger { func (l *loggerImpl) Get() *zap.Logger {
once.Do(func() { once.Do(func() {
var err error var err error
cfg := config.GetConfig() cfg := config.GetConfig()
@@ -28,6 +42,6 @@ func GetLogger() *zap.Logger {
return logger return logger
} }
func Sync() error { func (l *loggerImpl) Sync() error {
return logger.Sync() return logger.Sync()
} }

View File

@@ -5,6 +5,24 @@ type Tokens struct {
RefreshToken string `json:"refresh_token"` 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 { type LoginRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`

View File

@@ -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,
},
}
}

View File

@@ -1,52 +1,12 @@
package routes package routes
import ( import (
"easywish/internal/controllers" "go.uber.org/fx"
"easywish/internal/middleware"
"github.com/gin-gonic/gin"
) )
func SetupRoutes(r *gin.Engine) *gin.Engine { var Module = fx.Module("routes",
apiGroup := r.Group("/api") fx.Provide(
{ NewRouteGroups,
serviceGroup := apiGroup.Group("/service") ),
{ fx.Invoke(NewRouter),
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
}

View File

@@ -2,25 +2,33 @@ package services
import ( import (
"easywish/internal/database" "easywish/internal/database"
"easywish/internal/errors"
errs "easywish/internal/errors" errs "easywish/internal/errors"
"easywish/internal/models" "easywish/internal/models"
"easywish/internal/utils" "easywish/internal/utils"
) )
type AuthService interface { type AuthService interface {
RegistrationBegin(request models.RegistrationBeginRequest) (bool, error)
RegistrationComplete(request models.RegistrationBeginRequest) (*models.RegistrationCompleteResponse, error)
Login(request models.LoginRequest) (*models.LoginResponse, error) Login(request models.LoginRequest) (*models.LoginResponse, error)
Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error)
} }
type authServiceImpl struct { type authServiceImpl struct {
} }
func NewAuthService() AuthService { func NewAuthService() AuthService {
return &authServiceImpl{} 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) { func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginResponse, error) {
conn, ctx, err := utils.GetDbConn() conn, ctx, err := utils.GetDbConn()
if err != nil { 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 return &models.LoginResponse{Tokens: models.Tokens{AccessToken: accessToken, RefreshToken: refreshToken}}, nil
} }
func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) { func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) {
return nil, errors.ErrNotImplemented return nil, errs.ErrNotImplemented
} }

View File

@@ -0,0 +1,12 @@
package services
import (
"go.uber.org/fx"
)
var Module = fx.Module("services",
fx.Provide(
NewAuthService,
),
)