Add comprehensive i18n localization support

- Add go-i18n package for internationalization
- Create i18n middleware to extract Accept-Language header
- Add translation files for en, es, fr, de, pt languages
- Localize all handler error messages and responses
- Add language context to all API handlers

Supported languages: English, Spanish, French, German, Portuguese

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-02 02:01:47 -06:00
parent c72741fd5f
commit c17e85c14e
22 changed files with 1771 additions and 193 deletions

View File

@@ -9,6 +9,7 @@ import (
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/dto/responses"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/services"
)
@@ -40,7 +41,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
var req requests.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: "Invalid request body",
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
@@ -51,10 +52,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
response, err := h.authService.Login(&req)
if err != nil {
status := http.StatusUnauthorized
message := "Invalid credentials"
message := i18n.LocalizedMessage(c, "error.invalid_credentials")
if errors.Is(err, services.ErrUserInactive) {
message = "Account is inactive"
message = i18n.LocalizedMessage(c, "error.account_inactive")
}
log.Debug().Err(err).Str("identifier", req.Username).Msg("Login failed")
@@ -70,7 +71,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
var req requests.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: "Invalid request body",
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
@@ -84,12 +85,12 @@ func (h *AuthHandler) Register(c *gin.Context) {
message := err.Error()
if errors.Is(err, services.ErrUsernameTaken) {
message = "Username already taken"
message = i18n.LocalizedMessage(c, "error.username_taken")
} else if errors.Is(err, services.ErrEmailTaken) {
message = "Email already registered"
message = i18n.LocalizedMessage(c, "error.email_taken")
} else {
status = http.StatusInternalServerError
message = "Registration failed"
message = i18n.LocalizedMessage(c, "error.registration_failed")
log.Error().Err(err).Msg("Registration failed")
}
@@ -113,7 +114,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
func (h *AuthHandler) Logout(c *gin.Context) {
token := middleware.GetAuthToken(c)
if token == "" {
c.JSON(http.StatusUnauthorized, responses.ErrorResponse{Error: "Not authenticated"})
c.JSON(http.StatusUnauthorized, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.not_authenticated")})
return
}
@@ -129,7 +130,7 @@ func (h *AuthHandler) Logout(c *gin.Context) {
}
}
c.JSON(http.StatusOK, responses.MessageResponse{Message: "Logged out successfully"})
c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.logged_out")})
}
// CurrentUser handles GET /api/auth/me/
@@ -142,7 +143,7 @@ func (h *AuthHandler) CurrentUser(c *gin.Context) {
response, err := h.authService.GetCurrentUser(user.ID)
if err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to get current user")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: "Failed to get user"})
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_get_user")})
return
}
@@ -159,7 +160,7 @@ func (h *AuthHandler) UpdateProfile(c *gin.Context) {
var req requests.UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: "Invalid request body",
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
@@ -170,12 +171,12 @@ func (h *AuthHandler) UpdateProfile(c *gin.Context) {
response, err := h.authService.UpdateProfile(user.ID, &req)
if err != nil {
if errors.Is(err, services.ErrEmailTaken) {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: "Email already taken"})
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.email_already_taken")})
return
}
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to update profile")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: "Failed to update profile"})
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_update_profile")})
return
}
@@ -192,7 +193,7 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
var req requests.VerifyEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: "Invalid request body",
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
@@ -206,14 +207,14 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
message := err.Error()
if errors.Is(err, services.ErrInvalidCode) {
message = "Invalid verification code"
message = i18n.LocalizedMessage(c, "error.invalid_verification_code")
} else if errors.Is(err, services.ErrCodeExpired) {
message = "Verification code has expired"
message = i18n.LocalizedMessage(c, "error.verification_code_expired")
} else if errors.Is(err, services.ErrAlreadyVerified) {
message = "Email already verified"
message = i18n.LocalizedMessage(c, "error.email_already_verified")
} else {
status = http.StatusInternalServerError
message = "Verification failed"
message = i18n.LocalizedMessage(c, "error.verification_failed")
log.Error().Err(err).Uint("user_id", user.ID).Msg("Email verification failed")
}
@@ -222,7 +223,7 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
}
c.JSON(http.StatusOK, responses.VerifyEmailResponse{
Message: "Email verified successfully",
Message: i18n.LocalizedMessage(c, "message.email_verified"),
Verified: true,
})
}
@@ -237,12 +238,12 @@ func (h *AuthHandler) ResendVerification(c *gin.Context) {
code, err := h.authService.ResendVerificationCode(user.ID)
if err != nil {
if errors.Is(err, services.ErrAlreadyVerified) {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: "Email already verified"})
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.email_already_verified")})
return
}
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to resend verification")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: "Failed to resend verification"})
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_resend_verification")})
return
}
@@ -255,7 +256,7 @@ func (h *AuthHandler) ResendVerification(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, responses.MessageResponse{Message: "Verification email sent"})
c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.verification_email_sent")})
}
// ForgotPassword handles POST /api/auth/forgot-password/
@@ -263,7 +264,7 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
var req requests.ForgotPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: "Invalid request body",
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
@@ -275,7 +276,7 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
if err != nil {
if errors.Is(err, services.ErrRateLimitExceeded) {
c.JSON(http.StatusTooManyRequests, responses.ErrorResponse{
Error: "Too many password reset requests. Please try again later.",
Error: i18n.LocalizedMessage(c, "error.rate_limit_exceeded"),
})
return
}
@@ -295,7 +296,7 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
// Always return success to prevent email enumeration
c.JSON(http.StatusOK, responses.ForgotPasswordResponse{
Message: "If an account with that email exists, a password reset code has been sent.",
Message: i18n.LocalizedMessage(c, "message.password_reset_email_sent"),
})
}
@@ -304,7 +305,7 @@ func (h *AuthHandler) VerifyResetCode(c *gin.Context) {
var req requests.VerifyResetCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: "Invalid request body",
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
@@ -315,13 +316,13 @@ func (h *AuthHandler) VerifyResetCode(c *gin.Context) {
resetToken, err := h.authService.VerifyResetCode(req.Email, req.Code)
if err != nil {
status := http.StatusBadRequest
message := "Invalid verification code"
message := i18n.LocalizedMessage(c, "error.invalid_verification_code")
if errors.Is(err, services.ErrCodeExpired) {
message = "Verification code has expired"
message = i18n.LocalizedMessage(c, "error.verification_code_expired")
} else if errors.Is(err, services.ErrRateLimitExceeded) {
status = http.StatusTooManyRequests
message = "Too many attempts. Please request a new code."
message = i18n.LocalizedMessage(c, "error.too_many_attempts")
}
c.JSON(status, responses.ErrorResponse{Error: message})
@@ -329,7 +330,7 @@ func (h *AuthHandler) VerifyResetCode(c *gin.Context) {
}
c.JSON(http.StatusOK, responses.VerifyResetCodeResponse{
Message: "Code verified successfully",
Message: i18n.LocalizedMessage(c, "message.reset_code_verified"),
ResetToken: resetToken,
})
}
@@ -339,7 +340,7 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
var req requests.ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: "Invalid request body",
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
@@ -350,13 +351,13 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
err := h.authService.ResetPassword(req.ResetToken, req.NewPassword)
if err != nil {
status := http.StatusBadRequest
message := "Invalid or expired reset token"
message := i18n.LocalizedMessage(c, "error.invalid_reset_token")
if errors.Is(err, services.ErrInvalidResetToken) {
message = "Invalid or expired reset token"
message = i18n.LocalizedMessage(c, "error.invalid_reset_token")
} else {
status = http.StatusInternalServerError
message = "Password reset failed"
message = i18n.LocalizedMessage(c, "error.password_reset_failed")
log.Error().Err(err).Msg("Password reset failed")
}
@@ -365,7 +366,7 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
}
c.JSON(http.StatusOK, responses.ResetPasswordResponse{
Message: "Password reset successfully. Please log in with your new password.",
Message: i18n.LocalizedMessage(c, "message.password_reset_success"),
})
}
@@ -374,7 +375,7 @@ func (h *AuthHandler) AppleSignIn(c *gin.Context) {
var req requests.AppleSignInRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: "Invalid request body",
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
@@ -385,7 +386,7 @@ func (h *AuthHandler) AppleSignIn(c *gin.Context) {
if h.appleAuthService == nil {
log.Error().Msg("Apple auth service not configured")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{
Error: "Apple Sign In is not configured",
Error: i18n.LocalizedMessage(c, "error.apple_signin_not_configured"),
})
return
}
@@ -393,12 +394,12 @@ func (h *AuthHandler) AppleSignIn(c *gin.Context) {
response, err := h.authService.AppleSignIn(c.Request.Context(), h.appleAuthService, &req)
if err != nil {
status := http.StatusUnauthorized
message := "Apple Sign In failed"
message := i18n.LocalizedMessage(c, "error.apple_signin_failed")
if errors.Is(err, services.ErrUserInactive) {
message = "Account is inactive"
message = i18n.LocalizedMessage(c, "error.account_inactive")
} else if errors.Is(err, services.ErrAppleSignInFailed) {
message = "Invalid Apple identity token"
message = i18n.LocalizedMessage(c, "error.invalid_apple_token")
}
log.Debug().Err(err).Msg("Apple Sign In failed")