designcise/bitframe-whoops

View on GitHub
src/ErrorHandler.php

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
<?php

/**
 * BitFrame Framework (https://www.bitframephp.com)
 *
 * @author    Daniyal Hamid
 * @copyright Copyright (c) 2017-2023 Daniyal Hamid (https://designcise.com)
 * @license   https://bitframephp.com/about/license MIT License
 */

declare(strict_types=1);

namespace BitFrame\Whoops;

use Psr\Http\Message\{ResponseFactoryInterface, ServerRequestInterface, ResponseInterface};
use Psr\Http\Server\{RequestHandlerInterface, MiddlewareInterface};
use Whoops\{Exception\ErrorException, Exception\Inspector, Handler\Handler, Run, RunInterface};
use Whoops\Util\{SystemFacade, Misc};
use BitFrame\Whoops\Provider\{HandlerProviderNegotiator, ProviderInterface};
use Throwable;
use InvalidArgumentException;

use function is_a;
use function array_reverse;
use function in_array;
use function method_exists;
use function http_response_code;

class ErrorHandler implements MiddlewareInterface
{
    use HandlerOptionsAwareTrait;

    /** @var int */
    private const STATUS_INTERNAL_SERVER_ERROR = 500;

    private SystemFacade $system;

    private RunInterface $whoops;

    private bool $catchGlobalErrors;

    private bool $canThrowExceptions = true;

    public static function fromNegotiator(
        ResponseFactoryInterface $responseFactory,
        array $options = [],
    ): self {
        return new self(
            $responseFactory,
            HandlerProviderNegotiator::class,
            $options
        );
    }

    public function __construct(
        private readonly ResponseFactoryInterface $responseFactory,
        private readonly ProviderInterface|string $handlerProvider = HandlerProviderNegotiator::class,
        private readonly array $options = [],
    ) {
        if (! is_a($this->handlerProvider, ProviderInterface::class, true)) {
            throw new InvalidArgumentException(
                'Handler provider must be instance of ' . ProviderInterface::class
            );
        }

        $this->catchGlobalErrors = $options['catchGlobalErrors'] ?? false;
        unset($options['catchGlobalErrors']);

        $this->system = new SystemFacade();
        $this->whoops = new Run($this->system);
    }

    /**
     * {@inheritdoc}
     */
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $this->whoops->allowQuit(false);
        $this->whoops->writeToOutput($this->catchGlobalErrors);

        $this->system->setErrorHandler([$this, 'handleError']);
        $this->system->setExceptionHandler([$this, 'handleException']);
        $this->system->registerShutdownFunction([$this, 'handleShutdown']);

        $handlerProvider = ($this->handlerProvider instanceof ProviderInterface)
            ? $this->handlerProvider
            : new $this->handlerProvider();
        $errorHandler = $handlerProvider->getHandler($request);

        $this->applyOptions($errorHandler);
        $this->whoops->pushHandler($errorHandler);

        try {
            $response = $handler->handle($request);
        } catch (Throwable $e) {
            $output = $this->handleException($e);

            $response = $this->responseFactory->createResponse($this->getStatusCode());
            $response->getBody()->write($output);
        }

        if (! $this->catchGlobalErrors) {
            $this->system->restoreErrorHandler();
            $this->system->restoreExceptionHandler();
        }

        return $response;
    }

    public function handleException(Throwable $exception): string
    {
        $inspector = new Inspector($exception);

        $this->system->startOutputBuffering();

        $handlerContentType = null;
        $handlerStack = array_reverse($this->whoops->getHandlers());

        try {
            foreach ($handlerStack as $handler) {
                $handler->setRun($this->whoops);
                $handler->setInspector($inspector);
                $handler->setException($exception);

                $handlerResponse = $handler->handle();

                $handlerContentType = method_exists($handler, 'contentType')
                    ? $handler->contentType()
                    : null;

                if (in_array($handlerResponse, [Handler::LAST_HANDLER, Handler::QUIT], true)) {
                    break;
                }
            }
        } finally {
            $output = $this->system->cleanOutputBuffer();
        }

        if ($this->whoops->writeToOutput()) {
            if ($handlerContentType && Misc::canSendHeaders()) {
                header("Content-Type: {$handlerContentType}", true, $this->getStatusCode());
            }

            $this->writeToOutputNow($output);
        }

        return $output;
    }

    /**
     * @param int $level
     * @param string $message
     * @param null|string $file
     * @param null|int $line
     *
     * @return bool
     * @throws ErrorException
     */
    public function handleError(
        int $level,
        string $message,
        ?string $file = null,
        ?int $line = null
    ): bool {
        if ($level & $this->system->getErrorReportingLevel()) {
            // XXX we pass `$level` for the "code" param only for BC reasons.
            // @see https://github.com/filp/whoops/issues/267
            $exception = new ErrorException($message, /*code*/ $level, /*severity*/ $level, $file, $line);
            if ($this->canThrowExceptions) {
                throw $exception;
            }

            $this->handleException($exception);
            return true;
        }

        return false;
    }

    /**
     * @throws ErrorException
     */
    public function handleShutdown()
    {
        $this->canThrowExceptions = false;

        $error = $this->system->getLastError();
        if ($error && Misc::isLevelFatal($error['type'])) {
            $this->handleError($error['type'], $error['message'], $error['file'], $error['line']);
        }
    }

    public function getOptions(): array
    {
        return $this->options;
    }

    private function writeToOutputNow(string $output): self
    {
        $statusCode = $this->getStatusCode();

        if ($this->whoops->sendHttpCode($statusCode) && Misc::canSendHeaders()) {
            $this->system->setHttpResponseCode(
                $this->whoops->sendHttpCode($statusCode)
            );
        }

        echo $output;

        return $this;
    }

    private function getStatusCode(): int
    {
        $statusCode = http_response_code();

        if ($statusCode < 400 || $statusCode > 600) {
            $statusCode = self::STATUS_INTERNAL_SERVER_ERROR;
        }
        return $statusCode;
    }
}