Skip to content

Commit

Permalink
enhance error rendering as JSON
Browse files Browse the repository at this point in the history
  • Loading branch information
klimov-paul committed Apr 24, 2024
1 parent 0ec7667 commit ef3aee2
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 14 deletions.
52 changes: 42 additions & 10 deletions src/ErrorHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
* ```
Expand All @@ -25,17 +32,35 @@
* ]
* ```
*
* 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 <[email protected]>
* @since 1.0
*/
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}
*/
Expand Down Expand Up @@ -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;
}

Expand All @@ -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
{
Expand Down Expand Up @@ -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);
}
Expand All @@ -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();

Expand Down
18 changes: 14 additions & 4 deletions src/ErrorTraceFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
use CComponent;

/**
* ErrorTraceFilter creates simplified representation of the stack trace.
*
* @see \yii1tech\error\handler\ErrorHandler::filterErrorTrace()
*
* @author Paul Klimov <[email protected]>
* @since 1.0
*/
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions tests/ErrorHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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());
}
}
95 changes: 95 additions & 0 deletions tests/ErrorTraceFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace yii1tech\error\handler\test;

use yii1tech\error\handler\ErrorTraceFilter;

class ErrorTraceFilterTest extends TestCase
{
protected function prepareException($exceptionMessage = 'Test exception', $exceptionCode = 12345) : \Throwable
{
$args = [
'object' => 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);
}
}

0 comments on commit ef3aee2

Please sign in to comment.