diff --git a/errorhandler.php b/errorhandler.php new file mode 100644 index 0000000..29e07d1 --- /dev/null +++ b/errorhandler.php @@ -0,0 +1,444 @@ +debug = $debug; + $this->registerDefaultHandlers(); + } + + /** + * register registers a custom error handler for a specific status code + */ + public function register(int $status, callable $handler): void + { + $this->handlers[$status] = $handler; + } + + /** + * setDefaultHandler sets the default error handler for unregistered status codes + */ + public function setDefaultHandler(callable $handler): void + { + $this->defaultHandler = $handler; + } + + /** + * handle handles an error with the appropriate handler + */ + public function handle(Context $context, int $status, string $message = '', ?Exception $exception = null): void + { + if (isset($this->handlers[$status])) { + $handler = $this->handlers[$status]; + $handler($context, $status, $message, $exception); + return; + } + + if ($this->defaultHandler) { + ($this->defaultHandler)($context, $status, $message, $exception); + return; + } + + $this->renderDefaultError($context, $status, $message, $exception); + } + + /** + * handleException handles uncaught exceptions + */ + public function handleException(Context $context, Exception $exception): void + { + $status = $this->getStatusFromException($exception); + $message = $this->debug ? $exception->getMessage() : $this->getDefaultMessage($status); + + if ($this->debug) { + error_log($exception->getMessage() . "\n" . $exception->getTraceAsString()); + } + + $this->handle($context, $status, $message, $exception); + } + + /** + * getStatusFromException determines HTTP status from exception type + */ + private function getStatusFromException(Exception $exception): int + { + if ($exception instanceof HttpException) { + return $exception->getStatusCode(); + } + + return match(get_class($exception)) { + 'InvalidArgumentException' => 400, + 'UnauthorizedException' => 401, + 'ForbiddenException' => 403, + 'NotFoundException' => 404, + 'MethodNotAllowedException' => 405, + 'ValidationException' => 422, + default => 500 + }; + } + + /** + * registerDefaultHandlers registers built-in error handlers + */ + private function registerDefaultHandlers(): void + { + // 404 Not Found + $this->register(404, function(Context $context, int $status, string $message) { + $accept = $context->request->header('accept') ?? ''; + + if (str_contains($accept, 'application/json')) { + $context->json(['error' => $message ?: 'Not Found'], 404); + } else { + $html = $this->render404Page($message); + $context->html($html, 404); + } + }); + + // 500 Internal Server Error + $this->register(500, function(Context $context, int $status, string $message, ?Exception $exception) { + $accept = $context->request->header('accept') ?? ''; + + if (str_contains($accept, 'application/json')) { + $response = ['error' => $message ?: 'Internal Server Error']; + if ($this->debug && $exception) { + $response['trace'] = $exception->getTrace(); + } + $context->json($response, 500); + } else { + $html = $this->render500Page($message, $exception); + $context->html($html, 500); + } + }); + } + + /** + * renderDefaultError renders a generic error response + */ + private function renderDefaultError(Context $context, int $status, string $message, ?Exception $exception): void + { + $message = $message ?: $this->getDefaultMessage($status); + $accept = $context->request->header('accept') ?? ''; + + if (str_contains($accept, 'application/json')) { + $response = ['error' => $message]; + if ($this->debug && $exception) { + $response['trace'] = $exception->getTrace(); + } + $context->json($response, $status); + } else { + $html = $this->renderErrorPage($status, $message, $exception); + $context->html($html, $status); + } + } + + /** + * getDefaultMessage gets default message for status code + */ + private function getDefaultMessage(int $status): string + { + return match($status) { + 400 => 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 422 => 'Unprocessable Entity', + 429 => 'Too Many Requests', + 500 => 'Internal Server Error', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + default => 'Error' + }; + } + + /** + * render404Page renders a 404 error page + */ + private function render404Page(string $message): string + { + $message = htmlspecialchars($message ?: 'Not Found'); + + return << + + + 404 - Not Found + + + +
+

404

+

{$message}

+

The page you are looking for could not be found.

+ Go to Homepage +
+ + +HTML; + } + + /** + * render500Page renders a 500 error page + */ + private function render500Page(string $message, ?Exception $exception): string + { + $message = htmlspecialchars($message ?: 'Internal Server Error'); + $debugInfo = ''; + + if ($this->debug && $exception) { + $exceptionClass = get_class($exception); + $file = htmlspecialchars($exception->getFile()); + $line = $exception->getLine(); + $trace = htmlspecialchars($exception->getTraceAsString()); + + $debugInfo = << +

Debug Information

+

Exception: {$exceptionClass}

+

File: {$file}:{$line}

+
{$trace}
+ +HTML; + } + + return << + + + 500 - Internal Server Error + + + +
+

500

+

{$message}

+

Something went wrong on our end. Please try again later.

+ {$debugInfo} +
+ + +HTML; + } + + /** + * renderErrorPage renders a generic error page + */ + private function renderErrorPage(int $status, string $message, ?Exception $exception): string + { + $message = htmlspecialchars($message); + $debugInfo = ''; + + if ($this->debug && $exception) { + $exceptionClass = get_class($exception); + $file = htmlspecialchars($exception->getFile()); + $line = $exception->getLine(); + $trace = htmlspecialchars($exception->getTraceAsString()); + + $debugInfo = << +

Debug Information

+

Exception: {$exceptionClass}

+

File: {$file}:{$line}

+
{$trace}
+ +HTML; + } + + return << + + + {$status} - {$message} + + + +
+

{$status}

+

{$message}

+ {$debugInfo} +
+ + +HTML; + } +} + +/** + * HttpException base class for HTTP exceptions + */ +class HttpException extends Exception +{ + protected int $statusCode; + + public function __construct(int $statusCode, string $message = '', int $code = 0, Exception $previous = null) + { + $this->statusCode = $statusCode; + parent::__construct($message, $code, $previous); + } + + public function getStatusCode(): int + { + return $this->statusCode; + } +} + +/** + * ValidationException for validation errors + */ +class ValidationException extends HttpException +{ + private array $errors; + + public function __construct(array $errors, string $message = 'Validation failed') + { + $this->errors = $errors; + parent::__construct(422, $message); + } + + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/validator.php b/validator.php new file mode 100644 index 0000000..7175556 --- /dev/null +++ b/validator.php @@ -0,0 +1,355 @@ +data = $data; + $this->rules = $rules; + $this->messages = $messages; + $this->errors = []; + + foreach ($rules as $field => $fieldRules) { + $this->validateField($field, $fieldRules); + } + + return empty($this->errors); + } + + /** + * validateField validates a single field against its rules + */ + private function validateField(string $field, string|array $rules): void + { + $rules = is_string($rules) ? explode('|', $rules) : $rules; + $value = $this->getValue($field); + + foreach ($rules as $rule) { + $this->applyRule($field, $value, $rule); + } + } + + /** + * getValue gets a value from data using dot notation + */ + private function getValue(string $field): mixed + { + $keys = explode('.', $field); + $value = $this->data; + + foreach ($keys as $key) { + if (!is_array($value) || !array_key_exists($key, $value)) { + return null; + } + $value = $value[$key]; + } + + return $value; + } + + /** + * applyRule applies a single validation rule + */ + private function applyRule(string $field, mixed $value, string $rule): void + { + $parts = explode(':', $rule, 2); + $ruleName = $parts[0]; + $parameters = isset($parts[1]) ? explode(',', $parts[1]) : []; + + $passes = match($ruleName) { + 'required' => $this->validateRequired($value), + 'email' => $this->validateEmail($value), + 'url' => $this->validateUrl($value), + 'alpha' => $this->validateAlpha($value), + 'alphaNum' => $this->validateAlphaNum($value), + 'numeric' => $this->validateNumeric($value), + 'integer' => $this->validateInteger($value), + 'float' => $this->validateFloat($value), + 'boolean' => $this->validateBoolean($value), + 'array' => $this->validateArray($value), + 'json' => $this->validateJson($value), + 'date' => $this->validateDate($value), + 'min' => $this->validateMin($value, $parameters[0] ?? 0), + 'max' => $this->validateMax($value, $parameters[0] ?? 0), + 'between' => $this->validateBetween($value, $parameters[0] ?? 0, $parameters[1] ?? 0), + 'length' => $this->validateLength($value, $parameters[0] ?? 0), + 'in' => $this->validateIn($value, $parameters), + 'notIn' => $this->validateNotIn($value, $parameters), + 'regex' => $this->validateRegex($value, $parameters[0] ?? ''), + 'confirmed' => $this->validateConfirmed($field, $value), + 'unique' => $this->validateUnique($value, $parameters), + 'exists' => $this->validateExists($value, $parameters), + default => $this->applyCustomRule($ruleName, $value, $parameters) + }; + + if (!$passes) { + $this->addError($field, $ruleName, $parameters); + } + } + + /** + * Validation rule methods + */ + private function validateRequired(mixed $value): bool + { + if ($value === null) return false; + if (is_string($value) && trim($value) === '') return false; + if (is_array($value) && empty($value)) return false; + return true; + } + + private function validateEmail(mixed $value): bool + { + if (!is_string($value)) return false; + return filter_var($value, FILTER_VALIDATE_EMAIL) !== false; + } + + private function validateUrl(mixed $value): bool + { + if (!is_string($value)) return false; + return filter_var($value, FILTER_VALIDATE_URL) !== false; + } + + private function validateAlpha(mixed $value): bool + { + if (!is_string($value)) return false; + return ctype_alpha($value); + } + + private function validateAlphaNum(mixed $value): bool + { + if (!is_string($value)) return false; + return ctype_alnum($value); + } + + private function validateNumeric(mixed $value): bool + { + return is_numeric($value); + } + + private function validateInteger(mixed $value): bool + { + return filter_var($value, FILTER_VALIDATE_INT) !== false; + } + + private function validateFloat(mixed $value): bool + { + return filter_var($value, FILTER_VALIDATE_FLOAT) !== false; + } + + private function validateBoolean(mixed $value): bool + { + return in_array($value, [true, false, 0, 1, '0', '1', 'true', 'false'], true); + } + + private function validateArray(mixed $value): bool + { + return is_array($value); + } + + private function validateJson(mixed $value): bool + { + if (!is_string($value)) return false; + json_decode($value); + return json_last_error() === JSON_ERROR_NONE; + } + + private function validateDate(mixed $value): bool + { + if (!is_string($value)) return false; + $date = date_parse($value); + return $date['error_count'] === 0 && $date['warning_count'] === 0; + } + + private function validateMin(mixed $value, mixed $min): bool + { + if (is_numeric($value)) return $value >= $min; + if (is_string($value)) return strlen($value) >= $min; + if (is_array($value)) return count($value) >= $min; + return false; + } + + private function validateMax(mixed $value, mixed $max): bool + { + if (is_numeric($value)) return $value <= $max; + if (is_string($value)) return strlen($value) <= $max; + if (is_array($value)) return count($value) <= $max; + return false; + } + + private function validateBetween(mixed $value, mixed $min, mixed $max): bool + { + return $this->validateMin($value, $min) && $this->validateMax($value, $max); + } + + private function validateLength(mixed $value, mixed $length): bool + { + if (is_string($value)) return strlen($value) == $length; + if (is_array($value)) return count($value) == $length; + return false; + } + + private function validateIn(mixed $value, array $values): bool + { + return in_array($value, $values, true); + } + + private function validateNotIn(mixed $value, array $values): bool + { + return !in_array($value, $values, true); + } + + private function validateRegex(mixed $value, string $pattern): bool + { + if (!is_string($value)) return false; + return preg_match($pattern, $value) === 1; + } + + private function validateConfirmed(string $field, mixed $value): bool + { + $confirmField = $field . '_confirmation'; + return $value === $this->getValue($confirmField); + } + + private function validateUnique(mixed $value, array $parameters): bool + { + // Placeholder for database uniqueness check + // Would require database connection in real implementation + return true; + } + + private function validateExists(mixed $value, array $parameters): bool + { + // Placeholder for database existence check + // Would require database connection in real implementation + return true; + } + + /** + * applyCustomRule applies a custom validation rule + */ + private function applyCustomRule(string $ruleName, mixed $value, array $parameters): bool + { + if (!isset(self::$customRules[$ruleName])) { + return true; // Unknown rules pass by default + } + + return call_user_func(self::$customRules[$ruleName], $value, $parameters, $this->data); + } + + /** + * addError adds a validation error + */ + private function addError(string $field, string $rule, array $parameters = []): void + { + $message = $this->messages["$field.$rule"] + ?? $this->messages[$rule] + ?? $this->getDefaultMessage($field, $rule, $parameters); + + if (!isset($this->errors[$field])) { + $this->errors[$field] = []; + } + + $this->errors[$field][] = $message; + } + + /** + * getDefaultMessage gets a default error message + */ + private function getDefaultMessage(string $field, string $rule, array $parameters): string + { + $fieldName = str_replace('_', ' ', $field); + + return match($rule) { + 'required' => "The {$fieldName} field is required.", + 'email' => "The {$fieldName} must be a valid email address.", + 'url' => "The {$fieldName} must be a valid URL.", + 'alpha' => "The {$fieldName} may only contain letters.", + 'alphaNum' => "The {$fieldName} may only contain letters and numbers.", + 'numeric' => "The {$fieldName} must be a number.", + 'integer' => "The {$fieldName} must be an integer.", + 'float' => "The {$fieldName} must be a float.", + 'boolean' => "The {$fieldName} must be a boolean.", + 'array' => "The {$fieldName} must be an array.", + 'json' => "The {$fieldName} must be valid JSON.", + 'date' => "The {$fieldName} must be a valid date.", + 'min' => "The {$fieldName} must be at least {$parameters[0]}.", + 'max' => "The {$fieldName} must not be greater than {$parameters[0]}.", + 'between' => "The {$fieldName} must be between {$parameters[0]} and {$parameters[1]}.", + 'length' => "The {$fieldName} must be exactly {$parameters[0]} characters.", + 'in' => "The selected {$fieldName} is invalid.", + 'notIn' => "The selected {$fieldName} is invalid.", + 'regex' => "The {$fieldName} format is invalid.", + 'confirmed' => "The {$fieldName} confirmation does not match.", + 'unique' => "The {$fieldName} has already been taken.", + 'exists' => "The selected {$fieldName} is invalid.", + default => "The {$fieldName} is invalid." + }; + } + + /** + * errors returns all validation errors + */ + public function errors(): array + { + return $this->errors; + } + + /** + * failed checks if validation failed + */ + public function failed(): bool + { + return !empty($this->errors); + } + + /** + * passed checks if validation passed + */ + public function passed(): bool + { + return empty($this->errors); + } + + /** + * firstError gets the first error message + */ + public function firstError(string $field = null): ?string + { + if ($field) { + return $this->errors[$field][0] ?? null; + } + + foreach ($this->errors as $errors) { + if (!empty($errors)) { + return $errors[0]; + } + } + + return null; + } + + /** + * extend adds a custom validation rule + */ + public static function extend(string $name, callable $callback): void + { + self::$customRules[$name] = $callback; + } +}