2
0
Web/ErrorHandler.php

447 lines
9.7 KiB
PHP

<?php
namespace Web;
/**
* ErrorHandler manages error pages and exception handling
*/
class ErrorHandler
{
private array $handlers = [];
private $defaultHandler = null;
private bool $debug = false;
/**
* __construct creates a new ErrorHandler
*/
public function __construct(bool $debug = false)
{
$this->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 <<<HTML
<!DOCTYPE html>
<html>
<head>
<title>404 - Not Found</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5;
color: #333;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.error-container {
text-align: center;
padding: 2rem;
}
h1 {
font-size: 6rem;
margin: 0;
color: #e74c3c;
}
h2 {
font-size: 1.5rem;
margin: 1rem 0;
font-weight: normal;
}
p {
color: #666;
margin: 1rem 0;
}
a {
color: #3498db;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="error-container">
<h1>404</h1>
<h2>{$message}</h2>
<p>The page you are looking for could not be found.</p>
<a href="/">Go to Homepage</a>
</div>
</body>
</html>
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 = <<<HTML
<div class="debug-info">
<h3>Debug Information</h3>
<p><strong>Exception:</strong> {$exceptionClass}</p>
<p><strong>File:</strong> {$file}:{$line}</p>
<pre>{$trace}</pre>
</div>
HTML;
}
return <<<HTML
<!DOCTYPE html>
<html>
<head>
<title>500 - Internal Server Error</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5;
color: #333;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.error-container {
text-align: center;
padding: 2rem;
max-width: 800px;
}
h1 {
font-size: 6rem;
margin: 0;
color: #e74c3c;
}
h2 {
font-size: 1.5rem;
margin: 1rem 0;
font-weight: normal;
}
p {
color: #666;
margin: 1rem 0;
}
.debug-info {
margin-top: 2rem;
padding: 1rem;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
text-align: left;
}
.debug-info h3 {
margin-top: 0;
color: #e74c3c;
}
.debug-info pre {
background: #f8f8f8;
padding: 1rem;
overflow-x: auto;
font-size: 0.875rem;
}
</style>
</head>
<body>
<div class="error-container">
<h1>500</h1>
<h2>{$message}</h2>
<p>Something went wrong on our end. Please try again later.</p>
{$debugInfo}
</div>
</body>
</html>
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 = <<<HTML
<div class="debug-info">
<h3>Debug Information</h3>
<p><strong>Exception:</strong> {$exceptionClass}</p>
<p><strong>File:</strong> {$file}:{$line}</p>
<pre>{$trace}</pre>
</div>
HTML;
}
return <<<HTML
<!DOCTYPE html>
<html>
<head>
<title>{$status} - {$message}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f5;
color: #333;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.error-container {
text-align: center;
padding: 2rem;
max-width: 800px;
}
h1 {
font-size: 6rem;
margin: 0;
color: #e74c3c;
}
h2 {
font-size: 1.5rem;
margin: 1rem 0;
font-weight: normal;
}
.debug-info {
margin-top: 2rem;
padding: 1rem;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
text-align: left;
}
.debug-info h3 {
margin-top: 0;
color: #e74c3c;
}
.debug-info pre {
background: #f8f8f8;
padding: 1rem;
overflow-x: auto;
font-size: 0.875rem;
}
</style>
</head>
<body>
<div class="error-container">
<h1>{$status}</h1>
<h2>{$message}</h2>
{$debugInfo}
</div>
</body>
</html>
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;
}
}