You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
256 lines
8.6 KiB
256 lines
8.6 KiB
<?php
|
|
/**
|
|
* @license MIT
|
|
* Copyright 2022 Dustin Wilson, et al.
|
|
* See LICENSE and AUTHORS files for details
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
namespace MensBeam\Catcher;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
|
|
abstract class Handler {
|
|
public const CONTENT_TYPE = null;
|
|
|
|
// Control constants
|
|
public const BUBBLES = 1;
|
|
public const EXIT = 2;
|
|
public const LOG = 4;
|
|
public const NOW = 8;
|
|
public const OUTPUT = 16;
|
|
|
|
/**
|
|
* Array of HandlerOutputs the handler creates
|
|
*
|
|
* @var array[]
|
|
*/
|
|
protected array $outputBuffer = [];
|
|
|
|
/** The number of backtrace frames in which to print arguments; defaults to 5 */
|
|
protected int $_backtraceArgFrameLimit = 5;
|
|
/** If true the handler will move onto the next item in the stack of handlers */
|
|
protected bool $_bubbles = true;
|
|
/**
|
|
* The character encoding used for errors; only used if headers weren't sent before
|
|
* an error occurred
|
|
*/
|
|
protected string $_charset = 'UTF-8';
|
|
/** If true the handler will force an exit */
|
|
protected bool $_forceExit = false;
|
|
/** If true the handler will output as soon as possible, unless silenced */
|
|
protected bool $_forceOutputNow = false;
|
|
/** The HTTP code to be sent; possible values: 200, 400-599 */
|
|
protected int $_httpCode = 500;
|
|
/** The PSR-3 compatible logger in which to log to; defaults to null (no logging) */
|
|
protected ?LoggerInterface $_logger = null;
|
|
/** Still send logs when silent */
|
|
protected bool $_logWhenSilent = true;
|
|
/** If true the handler will output backtraces; defaults to false */
|
|
protected bool $_outputBacktrace = false;
|
|
/** If true the handler will output previous throwables; defaults to true */
|
|
protected bool $_outputPrevious = true;
|
|
/** If true the handler will output times to the output; defaults to true */
|
|
protected bool $_outputTime = true;
|
|
/** When the SAPI is cli output errors to stderr; defaults to true */
|
|
protected bool $_outputToStderr = true;
|
|
/** If true the handler will be silent and won't output */
|
|
protected bool $_silent = false;
|
|
/** The PHP-standard date format which to use for timestamps in output */
|
|
protected string $_timeFormat = 'Y-m-d\TH:i:s.vO';
|
|
/**
|
|
* A user-defined closure to use when printing arguments in backtraces
|
|
*
|
|
* @var ?(mixed): string|bool
|
|
*/
|
|
protected ?\Closure $_varExporter = null;
|
|
|
|
|
|
|
|
|
|
public function __construct(array $options = []) {
|
|
foreach ($options as $key => $value) {
|
|
$key = "_$key";
|
|
if ($key === '_httpCode' && is_int($value) && $value !== 200 && max(400, min($value, 600)) !== $value) {
|
|
throw new \RangeException('Option "httpCode" can only be an integer of 200 or 400-599');
|
|
}
|
|
|
|
$this->$key = $value;
|
|
}
|
|
|
|
if ($this->_varExporter === null) {
|
|
$this->_varExporter = fn(mixed $value): string|bool => print_r($value, true);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
public function __invoke(): void {
|
|
if (count($this->outputBuffer) === 0) {
|
|
return;
|
|
}
|
|
|
|
// Send the headers if possible and necessary
|
|
if (isset($_SERVER['REQUEST_URI'])) {
|
|
// Can't figure out a way to test coverage here, but the logic is tested thoroughly
|
|
// when running tests in HTTP
|
|
// @codeCoverageIgnoreStart
|
|
if (!headers_sent()) {
|
|
header_remove('location');
|
|
header(sprintf('Content-type: %s; charset=%s', static::CONTENT_TYPE, $this->_charset));
|
|
}
|
|
http_response_code($this->_httpCode);
|
|
// @codeCoverageIgnoreEnd
|
|
}
|
|
|
|
$this->invokeCallback();
|
|
$this->outputBuffer = [];
|
|
}
|
|
|
|
public function getOption(string $name): mixed {
|
|
$class = get_class($this);
|
|
if (!property_exists($class, "_$name")) {
|
|
trigger_error(sprintf('Undefined option in %s: %s', $class, $name), \E_USER_WARNING);
|
|
return null;
|
|
}
|
|
|
|
$name = "_$name";
|
|
return $this->$name;
|
|
}
|
|
|
|
public function handle(ThrowableController $controller): array {
|
|
$output = $this->buildOutputArray($controller);
|
|
|
|
if ($this->_outputBacktrace) {
|
|
$output['frames'] = $controller->getFrames(argFrameLimit: $this->_backtraceArgFrameLimit);
|
|
}
|
|
if ($this->_outputTime && $this->_timeFormat !== '') {
|
|
$output['time'] = new \DateTimeImmutable();
|
|
}
|
|
|
|
$code = 0;
|
|
if ($this->_bubbles) {
|
|
$code = self::BUBBLES;
|
|
}
|
|
if ($this->_forceExit) {
|
|
$code |= self::EXIT;
|
|
}
|
|
if ($this->_logger !== null && (!$this->_silent || ($this->_silent && $this->_logWhenSilent))) {
|
|
$code |= self::LOG;
|
|
}
|
|
if ($this->_forceOutputNow) {
|
|
$code |= self::NOW;
|
|
}
|
|
if (!$this->_silent) {
|
|
$code |= self::OUTPUT;
|
|
}
|
|
$output['code'] = $code;
|
|
|
|
$output = $this->handleCallback($output);
|
|
$this->outputBuffer[] = $output;
|
|
return $output;
|
|
}
|
|
|
|
public function setOption(string $name, mixed $value): void {
|
|
$class = get_class($this);
|
|
if (!property_exists($class, "_$name")) {
|
|
trigger_error(sprintf('Undefined option in %s: %s', $class, $name), \E_USER_WARNING);
|
|
return;
|
|
}
|
|
|
|
$name = "_$name";
|
|
$this->$name = $value;
|
|
}
|
|
|
|
|
|
protected function buildOutputArray(ThrowableController $controller): array {
|
|
$throwable = $controller->getThrowable();
|
|
|
|
$output = [
|
|
'controller' => $controller,
|
|
'class' => $throwable::class,
|
|
'code' => $throwable->getCode(),
|
|
'file' => $throwable->getFile() ?: '[UNKNOWN]',
|
|
'line' => $throwable->getLine(),
|
|
'message' => $throwable->getMessage()
|
|
];
|
|
|
|
if ($throwable instanceof Error) {
|
|
$output['errorType'] = $controller->getErrorType();
|
|
}
|
|
|
|
if ($this->_outputPrevious) {
|
|
$prevController = $controller->getPrevious();
|
|
if ($prevController) {
|
|
$output['previous'] = $this->buildOutputArray($prevController);
|
|
}
|
|
}
|
|
|
|
return $output;
|
|
}
|
|
|
|
protected function cleanOutputThrowable(array $outputThrowable): array {
|
|
unset($outputThrowable['controller']);
|
|
unset($outputThrowable['controlCode']);
|
|
unset($outputThrowable['outputCode']);
|
|
|
|
if (isset($outputThrowable['previous'])) {
|
|
$outputThrowable['previous'] = $this->cleanOutputThrowable($outputThrowable['previous']);
|
|
}
|
|
if (isset($outputThrowable['time'])) {
|
|
$outputThrowable['time'] = $outputThrowable['time']->format($this->_timeFormat);
|
|
}
|
|
|
|
return $outputThrowable;
|
|
}
|
|
|
|
protected function handleCallback(array $output): array {
|
|
return $output;
|
|
}
|
|
|
|
abstract protected function invokeCallback(): void;
|
|
|
|
protected function log(\Throwable $throwable, string $message): void {
|
|
$context = [ 'exception' => $throwable ];
|
|
if ($throwable instanceof \Error) {
|
|
switch ($throwable->getCode()) {
|
|
case \E_NOTICE:
|
|
case \E_USER_NOTICE:
|
|
case \E_STRICT:
|
|
$this->_logger->notice($message, $context);
|
|
break;
|
|
case \E_WARNING:
|
|
case \E_COMPILE_WARNING:
|
|
case \E_USER_WARNING:
|
|
case \E_DEPRECATED:
|
|
case \E_USER_DEPRECATED:
|
|
$this->_logger->warning($message, $context);
|
|
break;
|
|
case \E_RECOVERABLE_ERROR:
|
|
$this->_logger->error($message, $context);
|
|
break;
|
|
case \E_PARSE:
|
|
case \E_CORE_ERROR:
|
|
case \E_COMPILE_ERROR:
|
|
$this->_logger->alert($message, $context);
|
|
break;
|
|
default: $this->_logger->critical($message, $context);
|
|
}
|
|
} elseif ($throwable instanceof \Exception && ($throwable instanceof \PharException || $throwable instanceof \RuntimeException)) {
|
|
$this->_logger->alert($message, $context);
|
|
} else {
|
|
$this->_logger->critical($message, $context);
|
|
}
|
|
}
|
|
|
|
protected function print(string $string): void {
|
|
$string = "$string\n";
|
|
if (strtolower(\PHP_SAPI) === 'cli' && $this->_outputToStderr) {
|
|
// Can't test this in code coverage without printing errors to STDERR
|
|
fwrite(\STDERR, $string); // @codeCoverageIgnore
|
|
} else {
|
|
echo $string;
|
|
}
|
|
}
|
|
}
|