package handlers import ( "bytes" "context" "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" "github.com/google/uuid" "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 standardisées // INT-007: Standardize pagination format with snake_case for consistency 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"` HasPrev bool `json:"has_prev"` // INT-007: Changed from HasPrevious to HasPrev for consistency NextCursor string `json:"next_cursor,omitempty"` PrevCursor string `json:"prev_cursor,omitempty"` // INT-007: Changed from PreviousCursor to PrevCursor for consistency } // 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 // INT-007: Standardize pagination response format 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) } // BuildPaginationData construit une PaginationData standardisée à partir des paramètres // INT-007: Helper pour standardiser la création de PaginationData func BuildPaginationData(page, limit int, total int64) PaginationData { totalPages := int((total + int64(limit) - 1) / int64(limit)) if totalPages == 0 { totalPages = 1 } return PaginationData{ Page: page, Limit: limit, Total: total, TotalPages: totalPages, HasNext: page < totalPages, HasPrev: page > 1, } } // BuildPaginationDataWithCursor construit une PaginationData avec curseurs // INT-007: Helper pour pagination cursor-based func BuildPaginationDataWithCursor(limit int, total int64, nextCursor, prevCursor string) PaginationData { // Pour cursor-based, on ne peut pas calculer totalPages exactement // mais on peut indiquer s'il y a une page suivante/précédente hasNext := nextCursor != "" hasPrev := prevCursor != "" return PaginationData{ Limit: limit, Total: total, HasNext: hasNext, HasPrev: hasPrev, NextCursor: nextCursor, PrevCursor: prevCursor, } } // 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 DisallowUnknownFields (rejette les champs inconnus) body, err := io.ReadAll(c.Request.Body) if err != nil { h.logger.Warn("Failed to read request body", zap.Error(err), zap.String("request_id", requestID), zap.String("endpoint", c.Request.URL.Path), ) return apperrors.New(apperrors.ErrCodeValidation, "Failed to read request body") } c.Request.Body = io.NopCloser(bytes.NewReader(body)) // Restore for middleware decoder := json.NewDecoder(bytes.NewReader(body)) decoder.DisallowUnknownFields() if err := decoder.Decode(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 // Les erreurs de validation de binding Gin (comme "required", "url", "min") doivent être retournées 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), ) // Améliorer le message d'erreur pour les validations courantes errMsg := err.Error() if strings.Contains(errMsg, "Password") && strings.Contains(errMsg, "min") { errMsg = "Le mot de passe doit contenir au moins 12 caractères" } else if strings.Contains(errMsg, "PasswordConfirm") && strings.Contains(errMsg, "eqfield") { errMsg = "Les mots de passe ne correspondent pas" } else if strings.Contains(errMsg, "Email") && strings.Contains(errMsg, "email") { errMsg = "Format d'email invalide" } else if strings.Contains(errMsg, "Username") && strings.Contains(errMsg, "min") { errMsg = "Le nom d'utilisateur doit contenir au moins 3 caractères" } else if strings.Contains(errMsg, "required") { // Extraire le nom du champ if strings.Contains(errMsg, "Password") { errMsg = "Le mot de passe est requis" } else if strings.Contains(errMsg, "Email") { errMsg = "L'email est requis" } else if strings.Contains(errMsg, "PasswordConfirm") { errMsg = "La confirmation du mot de passe est requise" } else if strings.Contains(errMsg, "Username") { errMsg = "Le nom d'utilisateur est requis" } else { errMsg = fmt.Sprintf("Validation failed: %s", errMsg) } } else { errMsg = fmt.Sprintf("Validation failed: %s", errMsg) } // Retourner l'erreur de validation de binding return apperrors.New( apperrors.ErrCodeValidation, errMsg, ) } } // 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 } // GetUserIDUUID extrait l'ID utilisateur du contexte comme uuid.UUID (MOD-P1-001) // Retourne false si user_id est absent ou invalide (répond déjà avec 401) func GetUserIDUUID(c *gin.Context) (uuid.UUID, bool) { userIDInterface, exists := c.Get("user_id") if !exists { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return uuid.Nil, false } userID, ok := userIDInterface.(uuid.UUID) if !ok { RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return uuid.Nil, false } return userID, true } // WithTimeout crée un context avec timeout pour les opérations I/O critiques (MOD-P1-004) // Utilise le timeout par défaut de 5s pour DB/Redis, ou le timeout fourni func WithTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { if timeout == 0 { timeout = 5 * time.Second // Default timeout pour DB/Redis } return context.WithTimeout(ctx, timeout) } // 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) }