diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php index a3c3f0b..91b80b0 100644 --- a/src/ErrorHandler.php +++ b/src/ErrorHandler.php @@ -7,6 +7,13 @@ use Yii; /** + * ErrorHandler is an enhanced version of standard Yii error handler. + * + * Its main feature is conversion of the PHP errors into exceptions, so they may be processed via `try..catch` blocks. + * + * > Note: in order for error to exception conversion to work, the error handler component should be added to the + * application "preload" section. + * * Application configuration example: * * ``` @@ -25,6 +32,9 @@ * ] * ``` * + * In addition, this class provides support for error/exception rendering as JSON output, which is useful for modern + * XHR and API implementation. + * * @author Paul Klimov * @since 1.0 */ @@ -32,10 +42,25 @@ class ErrorHandler extends CErrorHandler { /** * @var bool whether to convert PHP Errors into Exceptions. + * * @see \ErrorException */ public $convertErrorToException = true; + /** + * @var callable|null a PHP callback, which result should determine whether the error/exception should be displayed as JSON. + * The callback signature: + * + * ``` + * function(): bool + * ``` + * + * If not set default condition of matching 'Accept' HTTP request header will be used. + * + * @see shouldRenderErrorAsJson() + */ + public $shouldRenderErrorAsJsonCallback; + /** * {@inheritDoc} */ @@ -109,8 +134,21 @@ protected function setExceptionTrace(\Exception $exception, array $trace): void $traceReflection->setValue($exception, $trace); } + /** + * Checks if error/exception should be rendered as JSON. + * + * @see $shouldRenderErrorAsJsonCallback + * + * > Tip: you can invoke this method inside your custom handler specified via {@see $errorAction}. + * + * @return bool whether the error/exception should be rendered as JSON. + */ public function shouldRenderErrorAsJson(): bool { + if ($this->shouldRenderErrorAsJsonCallback !== null) { + return call_user_func($this->shouldRenderErrorAsJsonCallback); + } + return !empty($_SERVER['HTTP_ACCEPT']) && strcasecmp($_SERVER['HTTP_ACCEPT'], 'application/json') === 0; } @@ -119,6 +157,8 @@ public function shouldRenderErrorAsJson(): bool * This method will display information from current {@see getError()} value. * * > Note: this method does NOT terminate the script. + * + * > Tip: you can invoke this method inside your custom handler specified via {@see $errorAction}. */ public function renderErrorAsJson(): void { @@ -154,10 +194,8 @@ public function renderErrorAsJson(): void */ private function filterErrorTrace(array $trace): array { - /** @var ErrorTraceFilter $traceFilter */ - $traceFilter = Yii::createComponent([ - 'class' => ErrorTraceFilter::class, - ]); + $traceFilter = new ErrorTraceFilter(); + $traceFilter->maxTraceSize = $this->maxTraceSourceLines; return $traceFilter->filter($trace); } @@ -169,12 +207,6 @@ protected function renderException(): void { $exception = $this->getException(); - if ($this->errorAction !== null) { - Yii::app()->runController($this->errorAction); - - return; - } - if ($exception instanceof \CHttpException || !YII_DEBUG) { $this->renderError(); diff --git a/src/ErrorTraceFilter.php b/src/ErrorTraceFilter.php index b1db0aa..5ecda84 100644 --- a/src/ErrorTraceFilter.php +++ b/src/ErrorTraceFilter.php @@ -5,6 +5,10 @@ use CComponent; /** + * ErrorTraceFilter creates simplified representation of the stack trace. + * + * @see \yii1tech\error\handler\ErrorHandler::filterErrorTrace() + * * @author Paul Klimov * @since 1.0 */ @@ -13,11 +17,17 @@ class ErrorTraceFilter extends CComponent /** * @var int maximum number of trace source code lines to be displayed. Defaults to 10. */ - public $maxTraceSourceLines = 10; + public $maxTraceSize = 10; + /** + * Creates simplified representation of the given stack trace. + * + * @param array $trace raw trace. + * @return array simplified trace. + */ public function filter(array $trace): array { - $trace = array_slice($trace, 0, $this->maxTraceSourceLines); + $trace = array_slice($trace, 0, $this->maxTraceSize); $result = []; foreach ($trace as $entry) { @@ -81,9 +91,9 @@ private function simplifyArgument($value) } elseif (is_string($value)) { if (strlen($value) > 64) { return "'" . substr($value, 0, 64) . "...'"; - } else { - return "'" . $value . "'"; } + + return "'" . $value . "'"; } elseif (is_array($value)) { return '[' . $this->simplifyArguments($value) . ']'; } elseif ($value === null) { diff --git a/tests/ErrorHandlerTest.php b/tests/ErrorHandlerTest.php index 27f6e68..c82754f 100644 --- a/tests/ErrorHandlerTest.php +++ b/tests/ErrorHandlerTest.php @@ -2,8 +2,17 @@ namespace yii1tech\error\handler\test; +use Yii; + class ErrorHandlerTest extends TestCase { + protected function tearDown(): void + { + unset($_SERVER['HTTP_ACCEPT']); + + parent::tearDown(); + } + public function testCovertErrorToException(): void { try { @@ -21,4 +30,28 @@ public function testCovertErrorToException(): void $this->assertSame('trigger_error', $trace[0]['function']); $this->assertFalse(empty($trace[0]['args'])); } + + public function testShouldRenderErrorAsJson(): void + { + $errorHandler = Yii::app()->getErrorHandler(); + + $_SERVER['HTTP_ACCEPT'] = 'application/json'; + $this->assertTrue($errorHandler->shouldRenderErrorAsJson()); + + $_SERVER['HTTP_ACCEPT'] = 'text/html'; + $this->assertFalse($errorHandler->shouldRenderErrorAsJson()); + + unset($_SERVER['HTTP_ACCEPT']); + $this->assertFalse($errorHandler->shouldRenderErrorAsJson()); + + $errorHandler->shouldRenderErrorAsJsonCallback = function() { + return true; + }; + $this->assertTrue($errorHandler->shouldRenderErrorAsJson()); + + $errorHandler->shouldRenderErrorAsJsonCallback = function() { + return false; + }; + $this->assertFalse($errorHandler->shouldRenderErrorAsJson()); + } } \ No newline at end of file diff --git a/tests/ErrorTraceFilterTest.php b/tests/ErrorTraceFilterTest.php new file mode 100644 index 0000000..3941e7e --- /dev/null +++ b/tests/ErrorTraceFilterTest.php @@ -0,0 +1,95 @@ + new \stdClass(), + 'bool' => true, + 'string' => 'a string', + // Managed argument count being at 5, create a sub bucket + 'sub' => [ + 'a_too_long_string' => str_repeat('a', 100), + 'null' => null, + 'resource' => tmpfile(), + 'sub' => [ + 'list' => ['foo', 'bar'], + 'list_with_holes' => [9 => 'nine', 5 => 'five'], + 'mixed_array' => ['foo', 'name' => 'bar'] + ] + ], + 'extra_param' => 'will not be normalized!' + ]; + + try { + // create a long stack trace + $closure = function ($exceptionMessage, $exceptionCode, array $allTypesOfArgs) { + throw new \RuntimeException($exceptionMessage, $exceptionCode); + }; + + call_user_func($closure, $exceptionMessage, $exceptionCode, $args); + } catch (\Throwable $exception) { + // shutdown test exception as prepared + } + + return $exception; + } + + public function testFilter(): void + { + $traceFilter = new ErrorTraceFilter(); + + $exceptionMessage = 'Test exception'; + $exceptionCode = 12345; + + $exception = $this->prepareException($exceptionMessage, $exceptionCode); + + $trace = $traceFilter->filter($exception->getTrace()); + + $this->assertFalse(empty($trace[0])); + } + + /** + * @depends testFilter + */ + public function testShouldRestrictTraceSize(): void + { + $exception = $this->prepareException(); + + $traceFilter = new ErrorTraceFilter(); + $traceFilter->maxTraceSize = 1; + + $trace = $traceFilter->filter($exception->getTrace()); + + $this->assertCount($traceFilter->maxTraceSize, $trace); + } + + /** + * @depends testShouldRestrictTraceSize + */ + public function testShouldShowTraceArguments(): void + { + ini_set('zend.exception_ignore_args', 0); // Be sure arguments will be available on the stack trace + $exception = $this->prepareException(); + + $traceFilter = new ErrorTraceFilter(); + $traceFilter->maxTraceSize = 1; + + $trace = $traceFilter->filter($exception->getTrace()); + + $argsFound = false; + foreach ($trace as $entry) { + if (isset($entry['args'])) { + $argsFound = true; + break; + } + } + + $this->assertTrue($argsFound); + } +} \ No newline at end of file