Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions plugins/exception-render/src/MixerApiExceptionRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,14 @@ public function render(): ResponseInterface
}
$response = $response->withStatus($code);

$exceptions = [$exception];
$previous = $exception->getPrevious();
while ($previous != null) {
$exceptions[] = $previous;
$previous = $previous->getPrevious();
$maxExceptions = 10;
$depth = 0;
$exceptions = [];
$current = $exception;
while ($current !== null && $depth < $maxExceptions) {
$depth++;
$exceptions[] = new SerializableException($current);
$current = $current->getPrevious();
}

$viewVars = [
Expand Down
44 changes: 44 additions & 0 deletions plugins/exception-render/src/SerializableException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);

namespace MixerApi\ExceptionRender;

use Cake\Core\Configure;
use JsonSerializable;
use ReflectionClass;
use Throwable;

class SerializableException extends \Exception implements JsonSerializable
{
private Throwable $wrapped;

/**
* @param \Throwable $exception The exception to wrap
*/
public function __construct(Throwable $exception)
{
$this->wrapped = $exception;
parent::__construct($exception->getMessage(), (int)$exception->getCode());
$this->file = $exception->getFile();
$this->line = $exception->getLine();
}

/**
* @return array
*/
public function jsonSerialize(): array
{
$data = [
'class' => (new ReflectionClass($this->wrapped))->getShortName(),
'message' => $this->getMessage(),
'code' => $this->getCode(),
];

if (Configure::read('debug')) {
$data['file'] = $this->getFile();
$data['line'] = $this->getLine();
}

return $data;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace MixerApi\ExceptionRender\Test\TestCase;

use Cake\Core\Exception\CakeException;
use Cake\Event\EventManager;
use Cake\Http\Exception\HttpException;
use Cake\Http\ServerRequest;
use Cake\TestSuite\TestCase;
Expand All @@ -11,6 +11,80 @@

class MixerApiExceptionRenderTest extends TestCase
{
public function test_chained_exceptions_serialize_as_structured_arrays(): void
{
$previous = new \RuntimeException('Connection refused', 111);
$exception = new HttpException('Service unavailable', 503, $previous);

$request = new ServerRequest();
$request = $request->withHeader('Accept', 'application/json');
$request = $request->withHeader('Content-Type', 'application/json');

$response = (new MixerApiExceptionRenderer($exception, $request))->render();

$body = json_decode((string)$response->getBody(), true);
$exceptions = $body['exceptions'];

$this->assertCount(2, $exceptions);

$this->assertEquals('HttpException', $exceptions[0]['class']);
$this->assertEquals('Service unavailable', $exceptions[0]['message']);
$this->assertEquals(503, $exceptions[0]['code']);
$this->assertArrayHasKey('file', $exceptions[0]);
$this->assertArrayHasKey('line', $exceptions[0]);

$this->assertEquals('RuntimeException', $exceptions[1]['class']);
$this->assertEquals('Connection refused', $exceptions[1]['message']);
$this->assertEquals(111, $exceptions[1]['code']);
$this->assertArrayHasKey('file', $exceptions[1]);
$this->assertArrayHasKey('line', $exceptions[1]);
}

public function test_exception_chain_is_capped_at_max_depth(): void
{
$exception = new \RuntimeException('root');
for ($i = 0; $i < 11; $i++) {
$exception = new \RuntimeException("level $i", 0, $exception);
}

$request = new ServerRequest();
$request = $request->withHeader('Accept', 'application/json');
$request = $request->withHeader('Content-Type', 'application/json');

$response = (new MixerApiExceptionRenderer($exception, $request))->render();

$body = json_decode((string)$response->getBody(), true);
$this->assertCount(10, $body['exceptions']);
}

public function test_chained_exceptions_are_throwable_for_event_listeners(): void
{
$previous = new \RuntimeException('Connection refused', 111);
$exception = new HttpException('Service unavailable', 503, $previous);

$request = new ServerRequest();
$request = $request->withHeader('Accept', 'application/json');
$request = $request->withHeader('Content-Type', 'application/json');

$captured = null;
EventManager::instance()->on(
'MixerApi.ExceptionRender.beforeRender',
function ($event) use (&$captured) {
$captured = $event->getSubject()->getViewVars()['exceptions'];
}
);

(new MixerApiExceptionRenderer($exception, $request))->render();

$this->assertCount(2, $captured);
$this->assertInstanceOf(\Throwable::class, $captured[0]);
$this->assertInstanceOf(\Throwable::class, $captured[1]);
$this->assertEquals('Service unavailable', $captured[0]->getMessage());
$this->assertEquals('Connection refused', $captured[1]->getMessage());

EventManager::instance()->off('MixerApi.ExceptionRender.beforeRender');
}

public function test_get_error(): void
{
$this->assertInstanceOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

namespace MixerApi\ExceptionRender\Test\TestCase;

use Cake\Core\Configure;
use Cake\TestSuite\TestCase;
use MixerApi\ExceptionRender\SerializableException;

class SerializableExceptionTest extends TestCase
{
public function test_is_throwable(): void
{
$wrapped = new \RuntimeException('test');
$exception = new SerializableException($wrapped);

$this->assertInstanceOf(\Throwable::class, $exception);
}

public function test_is_json_serializable(): void
{
$wrapped = new \RuntimeException('test');
$exception = new SerializableException($wrapped);

$this->assertInstanceOf(\JsonSerializable::class, $exception);
}

public function test_proxies_message_and_code(): void
{
$wrapped = new \RuntimeException('Connection refused', 111);
$exception = new SerializableException($wrapped);

$this->assertEquals('Connection refused', $exception->getMessage());
$this->assertEquals(111, $exception->getCode());
}

public function test_proxies_file_and_line(): void
{
$wrapped = new \RuntimeException('test');
$exception = new SerializableException($wrapped);

$this->assertEquals($wrapped->getFile(), $exception->getFile());
$this->assertEquals($wrapped->getLine(), $exception->getLine());
}

public function test_json_serialize_returns_structured_data(): void
{
$wrapped = new \RuntimeException('Connection refused', 111);
$exception = new SerializableException($wrapped);

Configure::write('debug', true);
$data = $exception->jsonSerialize();

$this->assertEquals('RuntimeException', $data['class']);
$this->assertEquals('Connection refused', $data['message']);
$this->assertEquals(111, $data['code']);
$this->assertArrayHasKey('file', $data);
$this->assertArrayHasKey('line', $data);
}

public function test_json_serialize_excludes_file_and_line_without_debug(): void
{
$wrapped = new \RuntimeException('Connection refused', 111);
$exception = new SerializableException($wrapped);

Configure::write('debug', false);
$data = $exception->jsonSerialize();

$this->assertEquals('RuntimeException', $data['class']);
$this->assertEquals('Connection refused', $data['message']);
$this->assertEquals(111, $data['code']);
$this->assertArrayNotHasKey('file', $data);
$this->assertArrayNotHasKey('line', $data);

Configure::write('debug', true);
}

public function test_json_encode_produces_correct_output(): void
{
$wrapped = new \RuntimeException('test', 42);
$exception = new SerializableException($wrapped);

Configure::write('debug', false);
$json = json_encode($exception);
$decoded = json_decode($json, true);

$this->assertEquals('RuntimeException', $decoded['class']);
$this->assertEquals('test', $decoded['message']);
$this->assertEquals(42, $decoded['code']);

Configure::write('debug', true);
}
}
Loading