refactor-controllers #5

Merged
weirdcat merged 5 commits from refactor-controllers into main 2025-07-17 22:39:20 +03:00
15 changed files with 437 additions and 1585 deletions
Showing only changes of commit d6e2d02bff - Show all commits

View File

@@ -46,7 +46,6 @@ import (
"easywish/internal/database" "easywish/internal/database"
"easywish/internal/logger" "easywish/internal/logger"
redisclient "easywish/internal/redisClient" redisclient "easywish/internal/redisClient"
"easywish/internal/routes"
"easywish/internal/services" "easywish/internal/services"
"easywish/internal/validation" "easywish/internal/validation"
@@ -74,7 +73,6 @@ func main() {
validation.Module, validation.Module,
controllers.Module, controllers.Module,
routes.Module,
fx.Invoke(func(lc fx.Lifecycle, router *gin.Engine, syncLogger *logger.SyncLogger) { fx.Invoke(func(lc fx.Lifecycle, router *gin.Engine, syncLogger *logger.SyncLogger) {

View File

@@ -38,257 +38,6 @@ const docTemplate = `{
"responses": {} "responses": {}
} }
}, },
"/auth/changePassword": {
"post": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Set new password using the old password",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.ChangePasswordRequest"
}
}
],
"responses": {
"200": {
"description": "Password successfully changed"
},
"403": {
"description": "Invalid old password"
}
}
}
},
"/auth/login": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Acquire tokens via login credentials (and 2FA code if needed)",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.LoginRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.LoginResponse"
}
},
"403": {
"description": "Invalid login credentials"
}
}
}
},
"/auth/passwordResetBegin": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Request password reset email",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetBeginRequest"
}
}
],
"responses": {
"200": {
"description": "Reset code sent to the email if it is attached to an account"
},
"429": {
"description": "Too many recent requests for this email"
}
}
}
},
"/auth/passwordResetComplete": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Complete password reset via email code",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteResponse"
}
},
"403": {
"description": "Wrong verification code or username"
}
}
}
},
"/auth/refresh": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Receive new tokens via refresh token",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RefreshRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.RefreshResponse"
}
},
"401": {
"description": "Invalid refresh token"
}
}
}
},
"/auth/registrationBegin": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Register an account",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RegistrationBeginRequest"
}
}
],
"responses": {
"200": {
"description": "Account is created and awaiting verification"
},
"409": {
"description": "Username or email is already taken"
},
"429": {
"description": "Too many recent registration attempts for this email"
}
}
}
},
"/auth/registrationComplete": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Confirm with code, finish creating the account",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteResponse"
}
},
"403": {
"description": "Invalid email or verification code"
}
}
}
},
"/profile": { "/profile": {
"patch": { "patch": {
"security": [ "security": [
@@ -395,207 +144,6 @@ const docTemplate = `{
], ],
"responses": {} "responses": {}
} }
},
"/service/health": {
"get": {
"description": "Used internally for checking service health",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Service"
],
"summary": "Get health status",
"responses": {
"200": {
"description": "Says whether it's healthy or not",
"schema": {
"$ref": "#/definitions/controllers.HealthStatus"
}
}
}
}
}
},
"definitions": {
"controllers.HealthStatus": {
"type": "object",
"properties": {
"healthy": {
"type": "boolean"
}
}
},
"models.ChangePasswordRequest": {
"type": "object",
"required": [
"old_password",
"password"
],
"properties": {
"old_password": {
"type": "string"
},
"password": {
"type": "string"
},
"totp": {
"type": "string"
}
}
},
"models.LoginRequest": {
"type": "object",
"required": [
"password",
"username"
],
"properties": {
"password": {
"type": "string",
"maxLength": 100
},
"totp": {
"type": "string"
},
"username": {
"type": "string",
"maxLength": 20,
"minLength": 3
}
}
},
"models.LoginResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.PasswordResetBeginRequest": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string"
}
}
},
"models.PasswordResetCompleteRequest": {
"type": "object",
"required": [
"email",
"password",
"verification_code"
],
"properties": {
"email": {
"type": "string"
},
"log_out_sessions": {
"type": "boolean"
},
"password": {
"type": "string"
},
"verification_code": {
"type": "string"
}
}
},
"models.PasswordResetCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.RefreshRequest": {
"type": "object",
"required": [
"refresh_token"
],
"properties": {
"refresh_token": {
"type": "string"
}
}
},
"models.RefreshResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.RegistrationBeginRequest": {
"type": "object",
"required": [
"email",
"password",
"username"
],
"properties": {
"email": {
"type": "string"
},
"password": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"models.RegistrationCompleteRequest": {
"type": "object",
"required": [
"name",
"username",
"verification_code"
],
"properties": {
"birthday": {
"type": "string"
},
"name": {
"type": "string"
},
"username": {
"type": "string"
},
"verification_code": {
"type": "string"
}
}
},
"models.RegistrationCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
} }
}, },
"securityDefinitions": { "securityDefinitions": {

View File

@@ -34,257 +34,6 @@
"responses": {} "responses": {}
} }
}, },
"/auth/changePassword": {
"post": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Set new password using the old password",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.ChangePasswordRequest"
}
}
],
"responses": {
"200": {
"description": "Password successfully changed"
},
"403": {
"description": "Invalid old password"
}
}
}
},
"/auth/login": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Acquire tokens via login credentials (and 2FA code if needed)",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.LoginRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.LoginResponse"
}
},
"403": {
"description": "Invalid login credentials"
}
}
}
},
"/auth/passwordResetBegin": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Request password reset email",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetBeginRequest"
}
}
],
"responses": {
"200": {
"description": "Reset code sent to the email if it is attached to an account"
},
"429": {
"description": "Too many recent requests for this email"
}
}
}
},
"/auth/passwordResetComplete": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Complete password reset via email code",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteResponse"
}
},
"403": {
"description": "Wrong verification code or username"
}
}
}
},
"/auth/refresh": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Receive new tokens via refresh token",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RefreshRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.RefreshResponse"
}
},
"401": {
"description": "Invalid refresh token"
}
}
}
},
"/auth/registrationBegin": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Register an account",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RegistrationBeginRequest"
}
}
],
"responses": {
"200": {
"description": "Account is created and awaiting verification"
},
"409": {
"description": "Username or email is already taken"
},
"429": {
"description": "Too many recent registration attempts for this email"
}
}
}
},
"/auth/registrationComplete": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Confirm with code, finish creating the account",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteResponse"
}
},
"403": {
"description": "Invalid email or verification code"
}
}
}
},
"/profile": { "/profile": {
"patch": { "patch": {
"security": [ "security": [
@@ -391,207 +140,6 @@
], ],
"responses": {} "responses": {}
} }
},
"/service/health": {
"get": {
"description": "Used internally for checking service health",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Service"
],
"summary": "Get health status",
"responses": {
"200": {
"description": "Says whether it's healthy or not",
"schema": {
"$ref": "#/definitions/controllers.HealthStatus"
}
}
}
}
}
},
"definitions": {
"controllers.HealthStatus": {
"type": "object",
"properties": {
"healthy": {
"type": "boolean"
}
}
},
"models.ChangePasswordRequest": {
"type": "object",
"required": [
"old_password",
"password"
],
"properties": {
"old_password": {
"type": "string"
},
"password": {
"type": "string"
},
"totp": {
"type": "string"
}
}
},
"models.LoginRequest": {
"type": "object",
"required": [
"password",
"username"
],
"properties": {
"password": {
"type": "string",
"maxLength": 100
},
"totp": {
"type": "string"
},
"username": {
"type": "string",
"maxLength": 20,
"minLength": 3
}
}
},
"models.LoginResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.PasswordResetBeginRequest": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string"
}
}
},
"models.PasswordResetCompleteRequest": {
"type": "object",
"required": [
"email",
"password",
"verification_code"
],
"properties": {
"email": {
"type": "string"
},
"log_out_sessions": {
"type": "boolean"
},
"password": {
"type": "string"
},
"verification_code": {
"type": "string"
}
}
},
"models.PasswordResetCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.RefreshRequest": {
"type": "object",
"required": [
"refresh_token"
],
"properties": {
"refresh_token": {
"type": "string"
}
}
},
"models.RefreshResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.RegistrationBeginRequest": {
"type": "object",
"required": [
"email",
"password",
"username"
],
"properties": {
"email": {
"type": "string"
},
"password": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"models.RegistrationCompleteRequest": {
"type": "object",
"required": [
"name",
"username",
"verification_code"
],
"properties": {
"birthday": {
"type": "string"
},
"name": {
"type": "string"
},
"username": {
"type": "string"
},
"verification_code": {
"type": "string"
}
}
},
"models.RegistrationCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
} }
}, },
"securityDefinitions": { "securityDefinitions": {

View File

@@ -1,122 +1,4 @@
basePath: /api/ basePath: /api/
definitions:
controllers.HealthStatus:
properties:
healthy:
type: boolean
type: object
models.ChangePasswordRequest:
properties:
old_password:
type: string
password:
type: string
totp:
type: string
required:
- old_password
- password
type: object
models.LoginRequest:
properties:
password:
maxLength: 100
type: string
totp:
type: string
username:
maxLength: 20
minLength: 3
type: string
required:
- password
- username
type: object
models.LoginResponse:
properties:
access_token:
type: string
refresh_token:
type: string
type: object
models.PasswordResetBeginRequest:
properties:
email:
type: string
required:
- email
type: object
models.PasswordResetCompleteRequest:
properties:
email:
type: string
log_out_sessions:
type: boolean
password:
type: string
verification_code:
type: string
required:
- email
- password
- verification_code
type: object
models.PasswordResetCompleteResponse:
properties:
access_token:
type: string
refresh_token:
type: string
type: object
models.RefreshRequest:
properties:
refresh_token:
type: string
required:
- refresh_token
type: object
models.RefreshResponse:
properties:
access_token:
type: string
refresh_token:
type: string
type: object
models.RegistrationBeginRequest:
properties:
email:
type: string
password:
type: string
username:
type: string
required:
- email
- password
- username
type: object
models.RegistrationCompleteRequest:
properties:
birthday:
type: string
name:
type: string
username:
type: string
verification_code:
type: string
required:
- name
- username
- verification_code
type: object
models.RegistrationCompleteResponse:
properties:
access_token:
type: string
refresh_token:
type: string
type: object
info: info:
contact: {} contact: {}
description: Easy and feature-rich wishlist. description: Easy and feature-rich wishlist.
@@ -137,165 +19,6 @@ paths:
summary: Change account password summary: Change account password
tags: tags:
- Account - Account
/auth/changePassword:
post:
consumes:
- application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.ChangePasswordRequest'
produces:
- application/json
responses:
"200":
description: Password successfully changed
"403":
description: Invalid old password
security:
- JWT: []
summary: Set new password using the old password
tags:
- Auth
/auth/login:
post:
consumes:
- application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.LoginRequest'
produces:
- application/json
responses:
"200":
description: ' '
schema:
$ref: '#/definitions/models.LoginResponse'
"403":
description: Invalid login credentials
summary: Acquire tokens via login credentials (and 2FA code if needed)
tags:
- Auth
/auth/passwordResetBegin:
post:
consumes:
- application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.PasswordResetBeginRequest'
produces:
- application/json
responses:
"200":
description: Reset code sent to the email if it is attached to an account
"429":
description: Too many recent requests for this email
summary: Request password reset email
tags:
- Auth
/auth/passwordResetComplete:
post:
consumes:
- application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.PasswordResetCompleteRequest'
produces:
- application/json
responses:
"200":
description: ' '
schema:
$ref: '#/definitions/models.PasswordResetCompleteResponse'
"403":
description: Wrong verification code or username
summary: Complete password reset via email code
tags:
- Auth
/auth/refresh:
post:
consumes:
- application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.RefreshRequest'
produces:
- application/json
responses:
"200":
description: ' '
schema:
$ref: '#/definitions/models.RefreshResponse'
"401":
description: Invalid refresh token
summary: Receive new tokens via refresh token
tags:
- Auth
/auth/registrationBegin:
post:
consumes:
- application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.RegistrationBeginRequest'
produces:
- application/json
responses:
"200":
description: Account is created and awaiting verification
"409":
description: Username or email is already taken
"429":
description: Too many recent registration attempts for this email
summary: Register an account
tags:
- Auth
/auth/registrationComplete:
post:
consumes:
- application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.RegistrationCompleteRequest'
produces:
- application/json
responses:
"200":
description: ' '
schema:
$ref: '#/definitions/models.RegistrationCompleteResponse'
"403":
description: Invalid email or verification code
summary: Confirm with code, finish creating the account
tags:
- Auth
/profile: /profile:
patch: patch:
consumes: consumes:
@@ -361,21 +84,6 @@ paths:
summary: Update profile privacy settings summary: Update profile privacy settings
tags: tags:
- Profile - Profile
/service/health:
get:
consumes:
- application/json
description: Used internally for checking service health
produces:
- application/json
responses:
"200":
description: Says whether it's healthy or not
schema:
$ref: '#/definitions/controllers.HealthStatus'
summary: Get health status
tags:
- Service
schemes: schemes:
- http - http
securityDefinitions: securityDefinitions:

View File

@@ -19,7 +19,6 @@ package controllers
import ( import (
errs "easywish/internal/errors" errs "easywish/internal/errors"
"easywish/internal/middleware"
"easywish/internal/models" "easywish/internal/models"
"easywish/internal/services" "easywish/internal/services"
"easywish/internal/utils" "easywish/internal/utils"
@@ -31,42 +30,101 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
type AuthController interface { func NewAuthController(log *zap.Logger, auth services.AuthService) Controller {
RegistrationBegin(c *gin.Context) return &controllerImpl{
RegistrationComplete(c *gin.Context) Path: "/auth",
Login(c *gin.Context) Middleware: []gin.HandlerFunc{},
Refresh(c *gin.Context) Methods: []ControllerMethod{
PasswordResetBegin(c *gin.Context)
PasswordResetComplete(c *gin.Context)
ChangePassword(c *gin.Context)
Router
}
type authControllerImpl struct { // @Summary Register an account
log *zap.Logger // @Tags Auth
auth services.AuthService // @Accept json
} // @Produce json
// @Param request body models.RegistrationBeginRequest true " "
// @Success 200 "Account is created and awaiting verification"
// @Failure 409 "Username or email is already taken"
// @Failure 429 "Too many recent registration attempts for this email"
// @Router /auth/registrationBegin [post]
{
HttpMethod: POST,
Path: "/registrationBegin",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: func(c *gin.Context) {
func NewAuthController(_log *zap.Logger, _auth services.AuthService) AuthController { request, err := GetRequest[models.RegistrationBeginRequest](c); if err != nil {
return &authControllerImpl{log: _log, auth: _auth}
}
// @Summary Acquire tokens via login credentials (and 2FA code if needed)
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.LoginRequest true " "
// @Success 200 {object} models.LoginResponse " "
// @Failure 403 "Invalid login credentials"
// @Router /auth/login [post]
func (a *authControllerImpl) Login(c *gin.Context) {
request, ok := utils.GetRequest[models.LoginRequest](c)
if !ok {
c.Status(http.StatusBadRequest)
return return
} }
response, err := a.auth.Login(request.User, request.Body) _, err = auth.RegistrationBegin(request.Body); if err != nil {
if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) {
c.Status(http.StatusConflict)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.Status(http.StatusOK)
return
},
},
// @Summary Confirm with code, finish creating the account
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.RegistrationCompleteRequest true " "
// @Success 200 {object} models.RegistrationCompleteResponse " "
// @Failure 403 "Invalid email or verification code"
// @Router /auth/registrationComplete [post]
{
HttpMethod: POST,
Path: "/registrationComplete",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: func(c *gin.Context) {
request, err := GetRequest[models.RegistrationCompleteRequest](c); if err != nil {
return
}
response, err := auth.RegistrationComplete(request.User, request.Body)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else if errors.Is(err, errs.ErrUnauthorized) {
c.Status(http.StatusUnauthorized)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
},
},
// @Summary Acquire tokens via login credentials (and 2FA code if needed)
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.LoginRequest true " "
// @Success 200 {object} models.LoginResponse " "
// @Failure 403 "Invalid login credentials"
// @Router /auth/login [post]
{
HttpMethod: POST,
Path: "/login",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: func(c *gin.Context) {
request, err := GetRequest[models.LoginRequest](c); if err != nil {
return
}
response, err := auth.Login(request.User, request.Body)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrForbidden) { if errors.Is(err, errs.ErrForbidden) {
@@ -78,83 +136,29 @@ func (a *authControllerImpl) Login(c *gin.Context) {
} }
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} },
},
// @Summary Request password reset email // @Summary Receive new tokens via refresh token
// @Tags Auth // @Tags Auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body models.PasswordResetBeginRequest true " " // @Param request body models.RefreshRequest true " "
// @Router /auth/passwordResetBegin [post] // @Router /auth/refresh [post]
// @Success 200 "Reset code sent to the email if it is attached to an account" // @Success 200 {object} models.RefreshResponse " "
// @Failure 429 "Too many recent requests for this email" // @Failure 401 "Invalid refresh token"
func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) { {
request, ok := utils.GetRequest[models.PasswordResetBeginRequest](c) HttpMethod: POST,
if !ok { Path: "/refresh",
c.Status(http.StatusBadRequest) Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: func(c *gin.Context) {
request, err := GetRequest[models.RefreshRequest](c); if err != nil {
return return
} }
response, err := a.auth.PasswordResetBegin(request.Body) response, err := auth.Refresh(request.Body)
if err != nil {
if errors.Is(err, errs.ErrTooManyRequests) {
c.Status(http.StatusTooManyRequests)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Complete password reset via email code
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.PasswordResetCompleteRequest true " "
// @Router /auth/passwordResetComplete [post]
// @Success 200 {object} models.PasswordResetCompleteResponse " "
// @Success 403 "Wrong verification code or username"
func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) {
request, ok := utils.GetRequest[models.PasswordResetCompleteRequest](c)
if !ok {
c.Status(http.StatusBadRequest)
return
}
response, err := a.auth.PasswordResetComplete(request.Body)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Receive new tokens via refresh token
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.RefreshRequest true " "
// @Router /auth/refresh [post]
// @Success 200 {object} models.RefreshResponse " "
// @Failure 401 "Invalid refresh token"
func (a *authControllerImpl) Refresh(c *gin.Context) {
request, ok := utils.GetRequest[models.RefreshRequest](c)
if !ok {
c.Status(http.StatusBadRequest)
return
}
response, err := a.auth.Refresh(request.Body)
if err != nil { if err != nil {
if utils.ErrorIsOneOf( if utils.ErrorIsOneOf(
err, err,
@@ -173,62 +177,32 @@ func (a *authControllerImpl) Refresh(c *gin.Context) {
} }
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} },
},
// @Summary Register an account // @Summary Request password reset email
// @Tags Auth // @Tags Auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body models.RegistrationBeginRequest true " " // @Param request body models.PasswordResetBeginRequest true " "
// @Success 200 "Account is created and awaiting verification" // @Router /auth/passwordResetBegin [post]
// @Failure 409 "Username or email is already taken" // @Success 200 "Reset code sent to the email if it is attached to an account"
// @Failure 429 "Too many recent registration attempts for this email" // @Failure 429 "Too many recent requests for this email"
// @Router /auth/registrationBegin [post] {
func (a *authControllerImpl) RegistrationBegin(c *gin.Context) { HttpMethod: POST,
Path: "/passwordResetBegin",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: func(c *gin.Context) {
request, ok := utils.GetRequest[models.RegistrationBeginRequest](c) request, err := GetRequest[models.PasswordResetBeginRequest](c); if err != nil {
if !ok {
c.Status(http.StatusBadRequest)
return return
} }
_, err := a.auth.RegistrationBegin(request.Body) response, err := auth.PasswordResetBegin(request.Body)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) { if errors.Is(err, errs.ErrTooManyRequests) {
c.Status(http.StatusConflict) c.Status(http.StatusTooManyRequests)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.Status(http.StatusOK)
return
}
// @Summary Confirm with code, finish creating the account
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.RegistrationCompleteRequest true " "
// @Success 200 {object} models.RegistrationCompleteResponse " "
// @Failure 403 "Invalid email or verification code"
// @Router /auth/registrationComplete [post]
func (a *authControllerImpl) RegistrationComplete(c *gin.Context) {
request, ok := utils.GetRequest[models.RegistrationCompleteRequest](c)
if !ok {
c.Status(http.StatusBadRequest)
return
}
response, err := a.auth.RegistrationComplete(request.User, request.Body)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else if errors.Is(err, errs.ErrUnauthorized) {
c.Status(http.StatusUnauthorized)
} else { } else {
c.Status(http.StatusInternalServerError) c.Status(http.StatusInternalServerError)
} }
@@ -236,25 +210,64 @@ func (a *authControllerImpl) RegistrationComplete(c *gin.Context) {
} }
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} },
},
// @Summary Set new password using the old password
// @Tags Auth // @Summary Complete password reset via email code
// @Accept json // @Tags Auth
// @Produce json // @Accept json
// @Security JWT // @Produce json
// @Param request body models.ChangePasswordRequest true " " // @Param request body models.PasswordResetCompleteRequest true " "
// @Success 200 "Password successfully changed" // @Router /auth/passwordResetComplete [post]
// @Failure 403 "Invalid old password" // @Success 200 {object} models.PasswordResetCompleteResponse " "
// @Router /auth/changePassword [post] // @Success 403 "Wrong verification code or username"
func (a *authControllerImpl) ChangePassword(c *gin.Context) { {
request, ok := utils.GetRequest[models.ChangePasswordRequest](c) HttpMethod: POST,
if !ok { Path: "/passwordResetComplete",
c.Status(http.StatusBadRequest) Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: func(c *gin.Context) {
request, err := GetRequest[models.PasswordResetCompleteRequest](c); if err != nil {
return return
} }
response, err := a.auth.ChangePassword(request.Body, request.User) response, err := auth.PasswordResetComplete(request.Body)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
},
},
// @Summary Set new password using the old password
// @Tags Auth
// @Accept json
// @Produce json
// @Security JWT
// @Param request body models.ChangePasswordRequest true " "
// @Success 200 "Password successfully changed"
// @Failure 403 "Invalid old password"
// @Router /auth/changePassword [post]
{
HttpMethod: POST,
Path: "/changePassword",
Authorization: enums.UserRole,
Middleware: []gin.HandlerFunc{},
Function: func(c *gin.Context) {
request, err := GetRequest[models.ChangePasswordRequest](c); if err != nil {
return
}
response, err := auth.ChangePassword(request.Body, request.User)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrForbidden) { if errors.Is(err, errs.ErrForbidden) {
@@ -266,14 +279,9 @@ func (a *authControllerImpl) ChangePassword(c *gin.Context) {
} }
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} },
},
func (a *authControllerImpl) RegisterRoutes(group *gin.RouterGroup) { },
group.POST("/registrationBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.RegistrationBegin) }
group.POST("/registrationComplete", middleware.RequestMiddleware[models.RegistrationCompleteRequest](enums.GuestRole), a.RegistrationComplete)
group.POST("/login", middleware.RequestMiddleware[models.LoginRequest](enums.GuestRole), a.Login)
group.POST("/refresh", middleware.RequestMiddleware[models.RefreshRequest](enums.GuestRole), a.Refresh)
group.POST("/passwordResetBegin", middleware.RequestMiddleware[models.PasswordResetBeginRequest](enums.GuestRole), a.PasswordResetBegin)
group.POST("/passwordResetComplete", middleware.RequestMiddleware[models.PasswordResetCompleteRequest](enums.GuestRole), a.PasswordResetComplete)
group.POST("/changePassword", middleware.RequestMiddleware[models.ChangePasswordRequest](enums.UserRole), a.ChangePassword)
} }

View File

@@ -19,7 +19,6 @@ package controllers
import ( import (
"easywish/internal/dto" "easywish/internal/dto"
"easywish/internal/middleware"
"easywish/internal/services" "easywish/internal/services"
"easywish/internal/utils/enums" "easywish/internal/utils/enums"
"easywish/internal/validation" "easywish/internal/validation"
@@ -48,39 +47,16 @@ type ControllerMethod struct {
type controllerImpl struct { type controllerImpl struct {
Path string Path string
Authorization enums.Role
Middleware []gin.HandlerFunc Middleware []gin.HandlerFunc
Methods []ControllerMethod Methods []ControllerMethod
} }
func (ctrl *controllerImpl) Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService) *gin.RouterGroup { type Controller interface {
Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService)
}
func (ctrl *controllerImpl) Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService) {
ctrlGroup := group.Group(ctrl.Path) ctrlGroup := group.Group(ctrl.Path)
ctrlGroup.Use(middleware.AuthMiddleware(log, auth))
ctrlGroup.Use(gin.HandlerFunc(func(c *gin.Context) {
ip := c.ClientIP()
userAgent := c.Request.UserAgent()
sessionInfoFromCtx, ok := c.Get("session_info"); if !ok {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid or missing session data"})
return
}
sessionInfo := sessionInfoFromCtx.(dto.SessionInfo)
if sessionInfo.Role < ctrl.Authorization {
c.AbortWithStatusJSON(
http.StatusForbidden,
gin.H{"error": "Insufficient authorization for this controller"})
return
}
c.Set("client_info", dto.ClientInfo{
SessionInfo: sessionInfo,
IP: ip,
UserAgent: userAgent,
})
c.Next()
}))
ctrlGroup.Use(ctrl.Middleware...) ctrlGroup.Use(ctrl.Middleware...)
for _, method := range ctrl.Methods { for _, method := range ctrl.Methods {
@@ -100,11 +76,6 @@ func (ctrl *controllerImpl) Setup(group *gin.RouterGroup, log *zap.Logger, auth
}), }),
method.Function)..., method.Function)...,
)} )}
return ctrlGroup
}
type Controller interface {
Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService)
} }
func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) { func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) {

View File

@@ -18,39 +18,39 @@
package controllers package controllers
import ( import (
"easywish/internal/models"
"easywish/internal/utils/enums"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type ServiceController interface { func NewServiceController() Controller {
HealthCheck(c *gin.Context) return &controllerImpl{
Router Path: "/service",
} Middleware: []gin.HandlerFunc{},
Methods: []ControllerMethod{
type serviceControllerImpl struct{} // Health godoc
// @Summary Get health status
// @Description Used internally for checking service health
// @Tags Service
// @Accept json
// @Produce json
// @Success 200 {object} HealthStatus "Says whether it's healthy or not"
// @Router /service/health [get]
{
HttpMethod: GET,
Path: "/health",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: func(c *gin.Context) {
func NewServiceController() ServiceController { c.JSON(http.StatusOK, models.HealthStatusResponse{Healthy: true,})
return &serviceControllerImpl{}
}
// HealthCheck implements ServiceController. },
// @Summary Get health status },
// @Description Used internally for checking service health
// @Tags Service
// @Accept json
// @Produce json
// @Success 200 {object} HealthStatus "Says whether it's healthy or not"
// @Router /service/health [get]
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"`
} }

View File

@@ -18,13 +18,55 @@
package controllers package controllers
import ( import (
"easywish/internal/dto"
"easywish/internal/middleware"
"easywish/internal/services"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/zap"
) )
type SetupControllersParams struct {
fx.In
Controllers []Controller `group:"controllers"`
Log *zap.Logger
Auth services.AuthService
Group *gin.Engine
}
func setupControllers(p SetupControllersParams) {
apiGroup := p.Group.Group("/api")
apiGroup.Use(middleware.AuthMiddleware(p.Log, p.Auth))
apiGroup.Use(gin.HandlerFunc(func(c *gin.Context) {
ip := c.ClientIP()
userAgent := c.Request.UserAgent()
sessionInfoFromCtx, ok := c.Get("session_info"); if !ok {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid or missing session data"})
return
}
sessionInfo := sessionInfoFromCtx.(dto.SessionInfo)
c.Set("client_info", dto.ClientInfo{
SessionInfo: sessionInfo,
IP: ip,
UserAgent: userAgent,
})
c.Next()
}))
for _, ctrl := range p.Controllers {
ctrl.Setup(apiGroup, p.Log, p.Auth)
}
}
var Module = fx.Module("controllers", var Module = fx.Module("controllers",
fx.Provide( fx.Provide(
NewServiceController, fx.Annotate(NewAuthController, fx.ResultTags(`group:"controllers"`)),
NewAuthController, fx.Annotate(NewServiceController, fx.ResultTags(`group:"controllers"`)),
NewProfileController,
), ),
fx.Invoke(setupControllers),
) )

View File

@@ -30,6 +30,7 @@ import (
const requestKey = "request" const requestKey = "request"
// Deprecated: no longer used, embedded into controllers.GetRequest instead
func ClientInfoFromContext(c *gin.Context) (*dto.ClientInfo, bool) { func ClientInfoFromContext(c *gin.Context) (*dto.ClientInfo, bool) {
var ok bool var ok bool
@@ -58,10 +59,12 @@ func ClientInfoFromContext(c *gin.Context) (*dto.ClientInfo, bool) {
}, true }, true
} }
// Deprecated: no longer used, see controllers.GetRequest
func RequestFromContext[T any](c *gin.Context) dto.Request[T] { func RequestFromContext[T any](c *gin.Context) dto.Request[T] {
return c.Value(requestKey).(dto.Request[T]) return c.Value(requestKey).(dto.Request[T])
} }
// Deprecated: no longer used, see controllers.GetRequest
func RequestMiddleware[T any](role enums.Role) gin.HandlerFunc { func RequestMiddleware[T any](role enums.Role) gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) { return gin.HandlerFunc(func(c *gin.Context) {

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
type HealthStatusResponse struct {
Healthy bool `json:"healthy"`
}

View File

@@ -26,6 +26,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// Deprecated: no longer used, see controllers
func NewRouter(engine *gin.Engine, log *zap.Logger, auth services.AuthService, groups []RouteGroup) *gin.Engine { func NewRouter(engine *gin.Engine, log *zap.Logger, auth services.AuthService, groups []RouteGroup) *gin.Engine {
apiGroup := engine.Group("/api") apiGroup := engine.Group("/api")
apiGroup.Use(middleware.AuthMiddleware(log, auth)) apiGroup.Use(middleware.AuthMiddleware(log, auth))
@@ -37,29 +38,30 @@ func NewRouter(engine *gin.Engine, log *zap.Logger, auth services.AuthService, g
return engine return engine
} }
// Deprecated: no longer used, see controllers
type RouteGroup struct { type RouteGroup struct {
BasePath string BasePath string
Middleware []gin.HandlerFunc Middleware []gin.HandlerFunc
Router controllers.Router Router controllers.Router
} }
func NewRouteGroups( // func NewRouteGroups(
authController controllers.AuthController, // authController controllers.AuthController,
serviceController controllers.ServiceController, // serviceController controllers.ServiceController,
profileController controllers.ProfileController, // profileController controllers.ProfileController,
) []RouteGroup { // ) []RouteGroup {
return []RouteGroup{ // return []RouteGroup{
{ // {
BasePath: "/auth", // BasePath: "/auth",
Router: authController, // Router: authController,
}, // },
{ // {
BasePath: "/service", // BasePath: "/service",
Router: serviceController, // Router: serviceController,
}, // },
{ // {
BasePath: "/profile", // BasePath: "/profile",
Router: profileController, // Router: profileController,
}, // },
} // }
} // }

View File

@@ -21,9 +21,10 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
) )
// Deprecated: no longer used, see controllers
var Module = fx.Module("routes", var Module = fx.Module("routes",
fx.Provide( // fx.Provide(
NewRouteGroups, // NewRouteGroups,
), // ),
fx.Invoke(NewRouter), // fx.Invoke(NewRouter),
) )

View File

@@ -22,6 +22,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// Deprecated: use controllers.GetRequest method for the new controllers
func GetRequest[T any](c *gin.Context) (*dto.Request[T], bool) { func GetRequest[T any](c *gin.Context) (*dto.Request[T], bool) {
req, ok := c.Get("request") req, ok := c.Get("request")