496 lines
15 KiB
Go
496 lines
15 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"veza-backend-api/internal/dto"
|
|
apperrors "veza-backend-api/internal/errors"
|
|
"veza-backend-api/internal/validators"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// ResponseData représente la structure standardisée des réponses API
|
|
type ResponseData struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message,omitempty"`
|
|
Data interface{} `json:"data,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
RequestID string `json:"request_id,omitempty"`
|
|
}
|
|
|
|
// PaginationData représente les données de pagination
|
|
type PaginationData struct {
|
|
Page int `json:"page"`
|
|
Limit int `json:"limit"`
|
|
Total int64 `json:"total"`
|
|
TotalPages int `json:"total_pages"`
|
|
HasNext bool `json:"has_next"`
|
|
HasPrevious bool `json:"has_previous"`
|
|
NextCursor string `json:"next_cursor,omitempty"`
|
|
PreviousCursor string `json:"previous_cursor,omitempty"`
|
|
}
|
|
|
|
// PaginatedResponse représente une réponse paginée
|
|
type PaginatedResponse struct {
|
|
ResponseData
|
|
Pagination PaginationData `json:"pagination"`
|
|
}
|
|
|
|
// ValidationError et ValidationErrors sont maintenant dans internal/dto/validation.go
|
|
// pour éviter les cycles d'import. Utiliser dto.ValidationError et dto.ValidationErrors
|
|
|
|
// CommonHandler contient les dépendances communes aux handlers
|
|
type CommonHandler struct {
|
|
logger *zap.Logger
|
|
validator *validators.Validator // GO-013: Validator centralisé
|
|
}
|
|
|
|
// NewCommonHandler crée une nouvelle instance de CommonHandler
|
|
// GO-013: Initialise le validator centralisé
|
|
func NewCommonHandler(logger *zap.Logger) *CommonHandler {
|
|
return &CommonHandler{
|
|
logger: logger,
|
|
validator: validators.NewValidator(),
|
|
}
|
|
}
|
|
|
|
// ValidateRequest valide une requête avec le validator centralisé
|
|
// GO-013: Helper pour valider les requêtes et retourner des erreurs formatées
|
|
func (h *CommonHandler) ValidateRequest(c *gin.Context, req interface{}) bool {
|
|
validationErrors := h.validator.Validate(req)
|
|
if len(validationErrors) > 0 {
|
|
h.RespondWithValidationError(c, validationErrors)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// RespondWithSuccess répond avec une réponse de succès
|
|
func (h *CommonHandler) RespondWithSuccess(c *gin.Context, data interface{}, message string) {
|
|
// Utiliser la structure unifiée APIResponse via RespondSuccess
|
|
// Si message est présent, on l'encapsule avec les données
|
|
if message != "" {
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"message": message,
|
|
"data": data,
|
|
})
|
|
} else {
|
|
RespondSuccess(c, http.StatusOK, data)
|
|
}
|
|
}
|
|
|
|
// RespondWithError répond avec une erreur
|
|
func (h *CommonHandler) RespondWithError(c *gin.Context, statusCode int, message string, err error) {
|
|
// Utiliser la structure unifiée APIResponse
|
|
// On crée une structure d'erreur ad-hoc pour correspondre à l'interface attendue par APIResponse.Error (qui est interface{})
|
|
// Ou mieux, on utilise RespondWithError qui attend un code, message et détails
|
|
|
|
// Note: RespondWithError est defined in error_response.go et attend (c, code, message, details...)
|
|
// Ici on a statusCode HTTP. RespondWithError attend un ErrorCode interne.
|
|
// C'est un conflit de signature.
|
|
// On va donc construire manuellement la réponse d'erreur unifiée.
|
|
|
|
errResponse := gin.H{
|
|
"code": statusCode,
|
|
"message": message,
|
|
"details": nil,
|
|
}
|
|
if err != nil {
|
|
h.logger.Error("Handler error",
|
|
zap.String("error", err.Error()),
|
|
zap.String("request_id", c.GetString("request_id")),
|
|
zap.String("endpoint", c.Request.URL.Path),
|
|
)
|
|
// On pourrait ajouter err.Error() dans details, mais pour sécurité on évite d'exposer l'erreur brute sauf si nécessaire
|
|
}
|
|
|
|
c.JSON(statusCode, APIResponse{
|
|
Success: false,
|
|
Data: nil,
|
|
Error: errResponse,
|
|
})
|
|
}
|
|
|
|
// RespondWithValidationError répond avec des erreurs de validation
|
|
// GO-013: Utilise dto.ValidationError pour éviter les cycles d'import
|
|
func (h *CommonHandler) RespondWithValidationError(c *gin.Context, errors []dto.ValidationError) {
|
|
// Adapter pour l'enveloppe unifiée
|
|
// Code 400 ou 422
|
|
|
|
c.JSON(http.StatusBadRequest, APIResponse{
|
|
Success: false,
|
|
Data: nil,
|
|
Error: gin.H{
|
|
"code": http.StatusBadRequest,
|
|
"message": "Validation failed",
|
|
"details": errors,
|
|
},
|
|
})
|
|
}
|
|
|
|
// RespondWithPaginatedData répond avec des données paginées
|
|
func (h *CommonHandler) RespondWithPaginatedData(c *gin.Context, data interface{}, pagination PaginationData, message string) {
|
|
// Pour la pagination, on met tout dans Data
|
|
responseData := gin.H{
|
|
"list": data,
|
|
"pagination": pagination,
|
|
}
|
|
if message != "" {
|
|
responseData["message"] = message
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, responseData)
|
|
}
|
|
|
|
// BindJSON lie les données JSON de la requête à une structure
|
|
// DEPRECATED: Utiliser BindAndValidateJSON à la place pour une gestion d'erreurs robuste
|
|
func (h *CommonHandler) BindJSON(c *gin.Context, obj interface{}) error {
|
|
if err := c.ShouldBindJSON(obj); err != nil {
|
|
h.logger.Warn("Failed to bind JSON",
|
|
zap.Error(err),
|
|
zap.String("request_id", c.GetString("request_id")),
|
|
)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MaxJSONBodySize définit la taille maximale du body JSON (10MB par défaut)
|
|
const MaxJSONBodySize = 10 * 1024 * 1024 // 10MB
|
|
|
|
// BindAndValidateJSON lie et valide les données JSON de la requête de manière robuste
|
|
// P0: JSON Hardening - Garantit qu'aucune erreur de parsing/validation ne passe silencieusement
|
|
//
|
|
// Comportement:
|
|
// - Vérifie la taille du body (max 10MB par défaut)
|
|
// - Parse le JSON avec ShouldBindJSON (Gin)
|
|
// - Valide avec le validator centralisé
|
|
// - Retourne une AppError avec code approprié (400 pour JSON malformé, 422 pour validation)
|
|
//
|
|
// Usage:
|
|
//
|
|
// var req MyRequest
|
|
// if appErr := h.BindAndValidateJSON(c, &req); appErr != nil {
|
|
// RespondWithAppError(c, appErr)
|
|
// return
|
|
// }
|
|
func (h *CommonHandler) BindAndValidateJSON(c *gin.Context, obj interface{}) *apperrors.AppError {
|
|
requestID := c.GetString("request_id")
|
|
|
|
// 1. Vérifier la taille du body
|
|
if c.Request.ContentLength > MaxJSONBodySize {
|
|
h.logger.Warn("Request body too large",
|
|
zap.Int64("content_length", c.Request.ContentLength),
|
|
zap.Int64("max_size", MaxJSONBodySize),
|
|
zap.String("request_id", requestID),
|
|
zap.String("endpoint", c.Request.URL.Path),
|
|
)
|
|
return apperrors.New(
|
|
apperrors.ErrCodeValidation,
|
|
fmt.Sprintf("Request body too large: maximum size is %d bytes", MaxJSONBodySize),
|
|
)
|
|
}
|
|
|
|
// 2. Limiter la lecture du body pour éviter les attaques par body trop gros
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, MaxJSONBodySize)
|
|
|
|
// 3. Parser le JSON avec ShouldBindJSON
|
|
if err := c.ShouldBindJSON(obj); err != nil {
|
|
// Analyser le type d'erreur pour retourner le bon code
|
|
var jsonSyntaxError *json.SyntaxError
|
|
var jsonUnmarshalTypeError *json.UnmarshalTypeError
|
|
var maxBytesError *http.MaxBytesError
|
|
|
|
switch {
|
|
case errors.As(err, &maxBytesError):
|
|
// Body trop gros (dépassement de la limite)
|
|
h.logger.Warn("Request body exceeds maximum size",
|
|
zap.Error(err),
|
|
zap.String("request_id", requestID),
|
|
zap.String("endpoint", c.Request.URL.Path),
|
|
)
|
|
return apperrors.New(
|
|
apperrors.ErrCodeValidation,
|
|
fmt.Sprintf("Request body too large: maximum size is %d bytes", MaxJSONBodySize),
|
|
)
|
|
|
|
case errors.As(err, &jsonSyntaxError):
|
|
// JSON syntaxiquement invalide
|
|
h.logger.Warn("Invalid JSON syntax",
|
|
zap.Error(err),
|
|
zap.Int64("offset", jsonSyntaxError.Offset),
|
|
zap.String("request_id", requestID),
|
|
zap.String("endpoint", c.Request.URL.Path),
|
|
)
|
|
return apperrors.New(
|
|
apperrors.ErrCodeValidation,
|
|
fmt.Sprintf("Invalid JSON syntax at offset %d: %s", jsonSyntaxError.Offset, jsonSyntaxError.Error()),
|
|
)
|
|
|
|
case errors.As(err, &jsonUnmarshalTypeError):
|
|
// Type incorrect pour un champ
|
|
h.logger.Warn("Invalid JSON type",
|
|
zap.Error(err),
|
|
zap.String("field", jsonUnmarshalTypeError.Field),
|
|
zap.String("type", jsonUnmarshalTypeError.Type.String()),
|
|
zap.String("request_id", requestID),
|
|
zap.String("endpoint", c.Request.URL.Path),
|
|
)
|
|
return apperrors.New(
|
|
apperrors.ErrCodeInvalidFormat,
|
|
fmt.Sprintf("Invalid type for field '%s': expected %s", jsonUnmarshalTypeError.Field, jsonUnmarshalTypeError.Type.String()),
|
|
)
|
|
|
|
case errors.Is(err, io.EOF):
|
|
// Body vide
|
|
h.logger.Warn("Empty request body",
|
|
zap.String("request_id", requestID),
|
|
zap.String("endpoint", c.Request.URL.Path),
|
|
)
|
|
return apperrors.New(
|
|
apperrors.ErrCodeValidation,
|
|
"Request body is empty or invalid JSON",
|
|
)
|
|
|
|
case errors.Is(err, io.ErrUnexpectedEOF):
|
|
// JSON incomplet
|
|
h.logger.Warn("Incomplete JSON",
|
|
zap.Error(err),
|
|
zap.String("request_id", requestID),
|
|
zap.String("endpoint", c.Request.URL.Path),
|
|
)
|
|
return apperrors.New(
|
|
apperrors.ErrCodeValidation,
|
|
"Incomplete or malformed JSON",
|
|
)
|
|
|
|
default:
|
|
// Erreur générique de binding (peut inclure des erreurs de validation Gin)
|
|
// On va laisser le validator gérer les erreurs de validation
|
|
// Si c'est une erreur de binding Gin (ex: unknown field), on la traite ici
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "unknown field") || strings.Contains(errStr, "unknown") {
|
|
h.logger.Warn("Unknown fields in JSON",
|
|
zap.Error(err),
|
|
zap.String("request_id", requestID),
|
|
zap.String("endpoint", c.Request.URL.Path),
|
|
)
|
|
return apperrors.New(
|
|
apperrors.ErrCodeValidation,
|
|
"Unknown fields in JSON payload",
|
|
)
|
|
}
|
|
|
|
// Pour les autres erreurs de binding, on considère que c'est une erreur de validation
|
|
// et on va laisser le validator s'en occuper
|
|
h.logger.Debug("JSON binding error (will be handled by validator)",
|
|
zap.Error(err),
|
|
zap.String("request_id", requestID),
|
|
zap.String("endpoint", c.Request.URL.Path),
|
|
)
|
|
}
|
|
}
|
|
|
|
// 4. Valider avec le validator centralisé
|
|
validationErrors := h.validator.Validate(obj)
|
|
if len(validationErrors) > 0 {
|
|
// Convertir dto.ValidationError en errors.ErrorDetail
|
|
details := make([]apperrors.ErrorDetail, 0, len(validationErrors))
|
|
for _, ve := range validationErrors {
|
|
details = append(details, apperrors.ErrorDetail{
|
|
Field: ve.Field,
|
|
Message: ve.Message,
|
|
})
|
|
}
|
|
|
|
h.logger.Warn("Validation failed",
|
|
zap.Int("error_count", len(validationErrors)),
|
|
zap.String("request_id", requestID),
|
|
zap.String("endpoint", c.Request.URL.Path),
|
|
)
|
|
|
|
return apperrors.NewValidationError("Validation failed", details...)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetUserIDFromContext extrait l'ID utilisateur du contexte
|
|
func (h *CommonHandler) GetUserIDFromContext(c *gin.Context) (string, error) {
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
return "", apperrors.NewUnauthorizedError("User not authenticated")
|
|
}
|
|
|
|
userIDStr, ok := userID.(string)
|
|
if !ok {
|
|
return "", apperrors.New(apperrors.ErrCodeValidation, "Invalid user ID type")
|
|
}
|
|
|
|
return userIDStr, nil
|
|
}
|
|
|
|
// GetPaginationParams extrait les paramètres de pagination de la requête
|
|
func (h *CommonHandler) GetPaginationParams(c *gin.Context) (page, limit int, cursor string) {
|
|
page = 1
|
|
limit = 20
|
|
|
|
if pageStr := c.Query("page"); pageStr != "" {
|
|
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
|
page = p
|
|
}
|
|
}
|
|
|
|
if limitStr := c.Query("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
|
limit = l
|
|
}
|
|
}
|
|
|
|
cursor = c.Query("cursor")
|
|
return page, limit, cursor
|
|
}
|
|
|
|
// ValidatePagination valide les paramètres de pagination
|
|
// GO-013: Utilise dto.ValidationError
|
|
func (h *CommonHandler) ValidatePagination(page, limit int) []dto.ValidationError {
|
|
var validationErrors []dto.ValidationError
|
|
|
|
if page < 1 {
|
|
validationErrors = append(validationErrors, dto.ValidationError{
|
|
Field: "page",
|
|
Message: "Page must be greater than 0",
|
|
Value: strconv.Itoa(page),
|
|
})
|
|
}
|
|
|
|
if limit < 1 || limit > 100 {
|
|
validationErrors = append(validationErrors, dto.ValidationError{
|
|
Field: "limit",
|
|
Message: "Limit must be between 1 and 100",
|
|
Value: strconv.Itoa(limit),
|
|
})
|
|
}
|
|
|
|
return validationErrors
|
|
}
|
|
|
|
// LogRequest log une requête entrante
|
|
func (h *CommonHandler) LogRequest(c *gin.Context, operation string) {
|
|
h.logger.Info("Request received",
|
|
zap.String("method", c.Request.Method),
|
|
zap.String("path", c.Request.URL.Path),
|
|
zap.String("operation", operation),
|
|
zap.String("user_id", c.GetString("user_id")),
|
|
zap.String("request_id", c.GetString("request_id")),
|
|
zap.String("ip", c.ClientIP()),
|
|
zap.String("user_agent", c.Request.UserAgent()),
|
|
)
|
|
}
|
|
|
|
// LogResponse log une réponse sortante
|
|
func (h *CommonHandler) LogResponse(c *gin.Context, statusCode int, duration time.Duration) {
|
|
h.logger.Info("Response sent",
|
|
zap.Int("status_code", statusCode),
|
|
zap.Duration("duration", duration),
|
|
zap.String("request_id", c.GetString("request_id")),
|
|
)
|
|
}
|
|
|
|
// SetRequestID middleware pour ajouter un ID de requête
|
|
func (h *CommonHandler) SetRequestID() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
requestID := c.GetHeader("X-Request-ID")
|
|
if requestID == "" {
|
|
requestID = generateRequestID()
|
|
}
|
|
c.Set("request_id", requestID)
|
|
c.Header("X-Request-ID", requestID)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// generateRequestID génère un ID de requête unique
|
|
func generateRequestID() string {
|
|
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
|
}
|
|
|
|
// ValidateRequiredFields valide que les champs requis sont présents
|
|
// GO-013: Utilise dto.ValidationError
|
|
func (h *CommonHandler) ValidateRequiredFields(fields map[string]interface{}) []dto.ValidationError {
|
|
var validationErrors []dto.ValidationError
|
|
|
|
for field, value := range fields {
|
|
if value == nil || value == "" {
|
|
validationErrors = append(validationErrors, dto.ValidationError{
|
|
Field: field,
|
|
Message: "This field is required",
|
|
})
|
|
}
|
|
}
|
|
|
|
return validationErrors
|
|
}
|
|
|
|
// SanitizeString nettoie une chaîne de caractères
|
|
func (h *CommonHandler) SanitizeString(input string) string {
|
|
// Supprimer les caractères de contrôle et les espaces en début/fin
|
|
cleaned := strings.TrimSpace(input)
|
|
|
|
// Limiter la longueur
|
|
if len(cleaned) > 1000 {
|
|
cleaned = cleaned[:1000]
|
|
}
|
|
|
|
return cleaned
|
|
}
|
|
|
|
// ParseJSON parse du JSON de manière sécurisée
|
|
func (h *CommonHandler) ParseJSON(data []byte, v interface{}) error {
|
|
if err := json.Unmarshal(data, v); err != nil {
|
|
h.logger.Error("Failed to parse JSON", zap.Error(err))
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SafeMarshalJSON sérialise en JSON de manière sécurisée
|
|
func (h *CommonHandler) SafeMarshalJSON(v interface{}) ([]byte, error) {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
h.logger.Error("Failed to marshal JSON", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// GetClientIP obtient l'IP réelle du client
|
|
func (h *CommonHandler) GetClientIP(c *gin.Context) string {
|
|
// Vérifier les headers de proxy
|
|
if ip := c.GetHeader("X-Forwarded-For"); ip != "" {
|
|
return strings.Split(ip, ",")[0]
|
|
}
|
|
if ip := c.GetHeader("X-Real-IP"); ip != "" {
|
|
return ip
|
|
}
|
|
return c.ClientIP()
|
|
}
|
|
|
|
// RateLimitKey génère une clé pour le rate limiting
|
|
func (h *CommonHandler) RateLimitKey(c *gin.Context, prefix string) string {
|
|
userID := c.GetString("user_id")
|
|
if userID != "" {
|
|
return prefix + ":user:" + userID
|
|
}
|
|
return prefix + ":ip:" + h.GetClientIP(c)
|
|
}
|