ErrorHandler.php

16.74 KB
30/07/2025 01:25
PHP
ErrorHandler.php
<?php

/**
 * ErrorHandler class for comprehensive error handling and logging
 * Provides structured error responses, logging, and validation
 */
class ErrorHandler {
    private $logFile;
    private $logLevel;
    private $enableLogging;

    // Error codes for different types of errors
    const ERROR_CODES = [
        // Input validation errors (400-499)
        'INVALID_INPUT' => 'INVALID_INPUT',
        'MISSING_REQUIRED_FIELD' => 'MISSING_REQUIRED_FIELD',
        'INVALID_JSON' => 'INVALID_JSON',
        'INVALID_PARAMETER' => 'INVALID_PARAMETER',
        'VALIDATION_FAILED' => 'VALIDATION_FAILED',
        'INVALID_FILE_TYPE' => 'INVALID_FILE_TYPE',
        'FILE_TOO_LARGE' => 'FILE_TOO_LARGE',
        'INVALID_IMAGE' => 'INVALID_IMAGE',

        // Resource not found errors (404)
        'ALBUM_NOT_FOUND' => 'ALBUM_NOT_FOUND',
        'PHOTO_NOT_FOUND' => 'PHOTO_NOT_FOUND',
        'TAG_NOT_FOUND' => 'TAG_NOT_FOUND',
        'RESOURCE_NOT_FOUND' => 'RESOURCE_NOT_FOUND',

        // Authentication/Authorization errors (401-403)
        'UNAUTHORIZED' => 'UNAUTHORIZED',
        'FORBIDDEN' => 'FORBIDDEN',
        'INVALID_TOKEN' => 'INVALID_TOKEN',

        // Method not allowed (405)
        'METHOD_NOT_ALLOWED' => 'METHOD_NOT_ALLOWED',

        // Conflict errors (409)
        'RESOURCE_EXISTS' => 'RESOURCE_EXISTS',
        'DUPLICATE_ENTRY' => 'DUPLICATE_ENTRY',

        // Server errors (500-599)
        'INTERNAL_ERROR' => 'INTERNAL_ERROR',
        'DATABASE_ERROR' => 'DATABASE_ERROR',
        'FILE_SYSTEM_ERROR' => 'FILE_SYSTEM_ERROR',
        'IMAGE_PROCESSING_ERROR' => 'IMAGE_PROCESSING_ERROR',
        'STORAGE_ERROR' => 'STORAGE_ERROR',
        'CONFIGURATION_ERROR' => 'CONFIGURATION_ERROR',

        // Service unavailable (503)
        'SERVICE_UNAVAILABLE' => 'SERVICE_UNAVAILABLE',
        'STORAGE_FULL' => 'STORAGE_FULL',
    ];

    // HTTP status codes mapping
    const HTTP_STATUS_CODES = [
        'INVALID_INPUT' => 400,
        'MISSING_REQUIRED_FIELD' => 400,
        'INVALID_JSON' => 400,
        'INVALID_PARAMETER' => 400,
        'VALIDATION_FAILED' => 400,
        'INVALID_FILE_TYPE' => 400,
        'FILE_TOO_LARGE' => 413,
        'INVALID_IMAGE' => 400,

        'ALBUM_NOT_FOUND' => 404,
        'PHOTO_NOT_FOUND' => 404,
        'TAG_NOT_FOUND' => 404,
        'RESOURCE_NOT_FOUND' => 404,

        'UNAUTHORIZED' => 401,
        'FORBIDDEN' => 403,
        'INVALID_TOKEN' => 401,

        'METHOD_NOT_ALLOWED' => 405,

        'RESOURCE_EXISTS' => 409,
        'DUPLICATE_ENTRY' => 409,

        'INTERNAL_ERROR' => 500,
        'DATABASE_ERROR' => 500,
        'FILE_SYSTEM_ERROR' => 500,
        'IMAGE_PROCESSING_ERROR' => 500,
        'STORAGE_ERROR' => 500,
        'CONFIGURATION_ERROR' => 500,

        'SERVICE_UNAVAILABLE' => 503,
        'STORAGE_FULL' => 507,
    ];

    // Log levels
    const LOG_LEVELS = [
        'DEBUG' => 0,
        'INFO' => 1,
        'WARNING' => 2,
        'ERROR' => 3,
        'CRITICAL' => 4
    ];

    /**
     * Constructor
     * @param string $logFile Path to log file
     * @param string $logLevel Minimum log level to record
     * @param bool $enableLogging Whether to enable logging
     */
    public function __construct($logFile = 'logs/error.log', $logLevel = 'ERROR', $enableLogging = true) {
        $this->logFile = $logFile;
        $this->logLevel = $logLevel;
        $this->enableLogging = $enableLogging;

        // Ensure log directory exists
        $logDir = dirname($this->logFile);
        if (!is_dir($logDir)) {
            mkdir($logDir, 0755, true);
        }
    }

    /**
     * Create structured error response
     * @param string $message Error message
     * @param string $errorCode Error code constant
     * @param array $details Additional error details
     * @param array $context Additional context information
     * @return array Error response structure
     */
    public function createErrorResponse($message, $errorCode = 'INTERNAL_ERROR', $details = [], $context = []) {
        // Get HTTP status code
        $httpCode = self::HTTP_STATUS_CODES[$errorCode] ?? 500;

        // Log the error
        $this->logError($message, $errorCode, $details, $context, $httpCode);

        // Create response structure
        $response = [
            'success' => false,
            'error' => [
                'message' => $message,
                'code' => $errorCode,
                'http_status' => $httpCode,
                'timestamp' => date('c')
            ]
        ];

        // Add details if provided
        if (!empty($details)) {
            $response['error']['details'] = $details;
        }

        // Add request ID for tracking
        $response['error']['request_id'] = $this->generateRequestId();

        return $response;
    }

    /**
     * Create success response
     * @param array $data Response data
     * @param int $httpCode HTTP status code
     * @param string $message Optional success message
     * @return array Success response structure
     */
    public function createSuccessResponse($data, $httpCode = 200, $message = null) {
        $response = [
            'success' => true,
            'data' => $data,
            'meta' => [
                'http_status' => $httpCode,
                'timestamp' => date('c'),
                'request_id' => $this->generateRequestId()
            ]
        ];

        if ($message) {
            $response['message'] = $message;
        }

        // Log successful operations for audit trail
        $this->logInfo('API Success', [
            'http_status' => $httpCode,
            'data_keys' => array_keys($data),
            'message' => $message
        ]);

        return $response;
    }

    /**
     * Log error with context
     * @param string $message Error message
     * @param string $errorCode Error code
     * @param array $details Error details
     * @param array $context Context information
     * @param int $httpCode HTTP status code
     */
    public function logError($message, $errorCode = 'INTERNAL_ERROR', $details = [], $context = [], $httpCode = 500) {
        if (!$this->enableLogging) {
            return;
        }

        $logLevel = $httpCode >= 500 ? 'ERROR' : 'WARNING';

        $logData = [
            'level' => $logLevel,
            'message' => $message,
            'error_code' => $errorCode,
            'http_status' => $httpCode,
            'timestamp' => date('c'),
            'request_id' => $this->generateRequestId(),
            'request_uri' => $_SERVER['REQUEST_URI'] ?? '',
            'request_method' => $_SERVER['REQUEST_METHOD'] ?? '',
            'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
            'ip_address' => $this->getClientIpAddress(),
        ];

        if (!empty($details)) {
            $logData['details'] = $details;
        }

        if (!empty($context)) {
            $logData['context'] = $context;
        }

        $this->writeLog($logData, $logLevel);
    }

    /**
     * Log informational message
     * @param string $message Log message
     * @param array $context Context data
     */
    public function logInfo($message, $context = []) {
        if (!$this->enableLogging) {
            return;
        }

        $logData = [
            'level' => 'INFO',
            'message' => $message,
            'timestamp' => date('c'),
            'request_id' => $this->generateRequestId(),
            'request_uri' => $_SERVER['REQUEST_URI'] ?? '',
            'request_method' => $_SERVER['REQUEST_METHOD'] ?? '',
        ];

        if (!empty($context)) {
            $logData['context'] = $context;
        }

        $this->writeLog($logData, 'INFO');
    }

    /**
     * Log warning message
     * @param string $message Warning message
     * @param array $context Context data
     */
    public function logWarning($message, $context = []) {
        if (!$this->enableLogging) {
            return;
        }

        $logData = [
            'level' => 'WARNING',
            'message' => $message,
            'timestamp' => date('c'),
            'request_id' => $this->generateRequestId(),
            'request_uri' => $_SERVER['REQUEST_URI'] ?? '',
            'request_method' => $_SERVER['REQUEST_METHOD'] ?? '',
        ];

        if (!empty($context)) {
            $logData['context'] = $context;
        }

        $this->writeLog($logData, 'WARNING');
    }

    /**
     * Write log entry to file
     * @param array $logData Log data
     * @param string $level Log level
     */
    private function writeLog($logData, $level) {
        // Check if we should log this level
        if (self::LOG_LEVELS[$level] < self::LOG_LEVELS[$this->logLevel]) {
            return;
        }

        $logEntry = json_encode($logData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL;

        // Write to log file with error handling
        if (file_put_contents($this->logFile, $logEntry, FILE_APPEND | LOCK_EX) === false) {
            // Fallback to error_log if file writing fails
            error_log('ErrorHandler: Failed to write to log file. Original message: ' . $logData['message']);
        }
    }

    /**
     * Generate unique request ID for tracking
     * @return string Request ID
     */
    private function generateRequestId() {
        static $requestId = null;

        if ($requestId === null) {
            $requestId = uniqid('req_', true);
        }

        return $requestId;
    }

    /**
     * Get client IP address
     * @return string IP address
     */
    private function getClientIpAddress() {
        $ipKeys = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'REMOTE_ADDR'];

        foreach ($ipKeys as $key) {
            if (!empty($_SERVER[$key])) {
                $ip = $_SERVER[$key];
                // Handle comma-separated IPs (X-Forwarded-For)
                if (strpos($ip, ',') !== false) {
                    $ip = trim(explode(',', $ip)[0]);
                }
                return $ip;
            }
        }

        return 'unknown';
    }

    /**
     * Validate required fields in input data
     * @param array $data Input data
     * @param array $requiredFields Required field names
     * @return array|null Returns error response if validation fails, null if valid
     */
    public function validateRequiredFields($data, $requiredFields) {
        $missingFields = [];

        foreach ($requiredFields as $field) {
            if (!isset($data[$field]) || (is_string($data[$field]) && trim($data[$field]) === '')) {
                $missingFields[] = $field;
            }
        }

        if (!empty($missingFields)) {
            return $this->createErrorResponse(
                'Missing required fields: ' . implode(', ', $missingFields),
                'MISSING_REQUIRED_FIELD',
                ['missing_fields' => $missingFields]
            );
        }

        return null;
    }

    /**
     * Validate JSON input
     * @param string $jsonString JSON string to validate
     * @return array|null Returns error response if validation fails, null if valid
     */
    public function validateJsonInput($jsonString) {
        if (empty($jsonString)) {
            return $this->createErrorResponse(
                'Request body is empty',
                'INVALID_INPUT'
            );
        }

        $data = json_decode($jsonString, true);

        if (json_last_error() !== JSON_ERROR_NONE) {
            return $this->createErrorResponse(
                'Invalid JSON input: ' . json_last_error_msg(),
                'INVALID_JSON',
                ['json_error' => json_last_error_msg()]
            );
        }

        if (!is_array($data)) {
            return $this->createErrorResponse(
                'Request body must be a JSON object',
                'INVALID_INPUT'
            );
        }

        return null;
    }

    /**
     * Validate string length
     * @param string $value String value
     * @param string $fieldName Field name for error message
     * @param int $minLength Minimum length
     * @param int $maxLength Maximum length
     * @return array|null Returns error response if validation fails, null if valid
     */
    public function validateStringLength($value, $fieldName, $minLength = 0, $maxLength = null) {
        $length = strlen($value);

        if ($length < $minLength) {
            return $this->createErrorResponse(
                "{$fieldName} must be at least {$minLength} characters long",
                'VALIDATION_FAILED',
                ['field' => $fieldName, 'min_length' => $minLength, 'actual_length' => $length]
            );
        }

        if ($maxLength !== null && $length > $maxLength) {
            return $this->createErrorResponse(
                "{$fieldName} cannot exceed {$maxLength} characters",
                'VALIDATION_FAILED',
                ['field' => $fieldName, 'max_length' => $maxLength, 'actual_length' => $length]
            );
        }

        return null;
    }

    /**
     * Validate numeric value
     * @param mixed $value Value to validate
     * @param string $fieldName Field name for error message
     * @param int|float $min Minimum value
     * @param int|float $max Maximum value
     * @return array|null Returns error response if validation fails, null if valid
     */
    public function validateNumeric($value, $fieldName, $min = null, $max = null) {
        if (!is_numeric($value)) {
            return $this->createErrorResponse(
                "{$fieldName} must be a numeric value",
                'VALIDATION_FAILED',
                ['field' => $fieldName, 'value' => $value]
            );
        }

        $numValue = (float)$value;

        if ($min !== null && $numValue < $min) {
            return $this->createErrorResponse(
                "{$fieldName} must be at least {$min}",
                'VALIDATION_FAILED',
                ['field' => $fieldName, 'min' => $min, 'value' => $numValue]
            );
        }

        if ($max !== null && $numValue > $max) {
            return $this->createErrorResponse(
                "{$fieldName} cannot exceed {$max}",
                'VALIDATION_FAILED',
                ['field' => $fieldName, 'max' => $max, 'value' => $numValue]
            );
        }

        return null;
    }

    /**
     * Validate array values
     * @param mixed $value Value to validate
     * @param string $fieldName Field name for error message
     * @param array $allowedValues Allowed values
     * @return array|null Returns error response if validation fails, null if valid
     */
    public function validateAllowedValues($value, $fieldName, $allowedValues) {
        if (!in_array($value, $allowedValues, true)) {
            return $this->createErrorResponse(
                "{$fieldName} must be one of: " . implode(', ', $allowedValues),
                'VALIDATION_FAILED',
                ['field' => $fieldName, 'value' => $value, 'allowed_values' => $allowedValues]
            );
        }

        return null;
    }

    /**
     * Handle exceptions and convert to error response
     * @param Exception $exception Exception to handle
     * @param string $context Context where exception occurred
     * @return array Error response
     */
    public function handleException($exception, $context = '') {
        $message = $exception->getMessage();
        $errorCode = 'INTERNAL_ERROR';

        // Map specific exception types to error codes
        if (strpos($message, 'not found') !== false || strpos($message, 'does not exist') !== false) {
            $errorCode = 'RESOURCE_NOT_FOUND';
        } elseif (strpos($message, 'already exists') !== false || strpos($message, 'duplicate') !== false) {
            $errorCode = 'DUPLICATE_ENTRY';
        } elseif (strpos($message, 'invalid') !== false || strpos($message, 'validation') !== false) {
            $errorCode = 'VALIDATION_FAILED';
        } elseif (strpos($message, 'permission') !== false || strpos($message, 'access') !== false) {
            $errorCode = 'FORBIDDEN';
        } elseif (strpos($message, 'storage') !== false || strpos($message, 'disk') !== false) {
            $errorCode = 'STORAGE_ERROR';
        } elseif (strpos($message, 'image') !== false || strpos($message, 'conversion') !== false) {
            $errorCode = 'IMAGE_PROCESSING_ERROR';
        } elseif (strpos($message, 'database') !== false || strpos($message, 'data') !== false) {
            $errorCode = 'DATABASE_ERROR';
        } elseif (strpos($message, 'file') !== false || strpos($message, 'directory') !== false) {
            $errorCode = 'FILE_SYSTEM_ERROR';
        }

        $details = [
            'exception_type' => get_class($exception),
            'file' => $exception->getFile(),
            'line' => $exception->getLine(),
            'trace' => $exception->getTraceAsString()
        ];

        if (!empty($context)) {
            $details['context'] = $context;
        }

        return $this->createErrorResponse($message, $errorCode, $details);
    }
}