Skip to content

Commit 09ca920

Browse files
Fix chained exceptions serializing as empty JSON objects (#164)
* Fix chained exceptions serializing as empty JSON objects The exception chain collector stored raw Throwable objects in the viewVars. Since Exception properties are protected and the class does not implement JsonSerializable, json_encode produced {} for each entry. Extract class, message, code (and file/line in debug mode) into plain arrays so the chain is visible in JSON responses. * Use SerializableException wrapper for backwards compatibility Replace plain array serialization with a SerializableException class that extends Exception and implements JsonSerializable. This preserves the Throwable contract for event listeners on beforeRender while producing structured JSON output. Also caps exception chain traversal at 10 iterations. * Fix PHPCS violations - Avoid count() in loop condition (use depth counter) - Add doc comments for __construct and jsonSerialize - Remove space after (int) cast
1 parent 1bc5a9c commit 09ca920

4 files changed

Lines changed: 219 additions & 6 deletions

File tree

plugins/exception-render/src/MixerApiExceptionRenderer.php

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,14 @@ public function render(): ResponseInterface
7373
}
7474
$response = $response->withStatus($code);
7575

76-
$exceptions = [$exception];
77-
$previous = $exception->getPrevious();
78-
while ($previous != null) {
79-
$exceptions[] = $previous;
80-
$previous = $previous->getPrevious();
76+
$maxExceptions = 10;
77+
$depth = 0;
78+
$exceptions = [];
79+
$current = $exception;
80+
while ($current !== null && $depth < $maxExceptions) {
81+
$depth++;
82+
$exceptions[] = new SerializableException($current);
83+
$current = $current->getPrevious();
8184
}
8285

8386
$viewVars = [
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace MixerApi\ExceptionRender;
5+
6+
use Cake\Core\Configure;
7+
use JsonSerializable;
8+
use ReflectionClass;
9+
use Throwable;
10+
11+
class SerializableException extends \Exception implements JsonSerializable
12+
{
13+
private Throwable $wrapped;
14+
15+
/**
16+
* @param \Throwable $exception The exception to wrap
17+
*/
18+
public function __construct(Throwable $exception)
19+
{
20+
$this->wrapped = $exception;
21+
parent::__construct($exception->getMessage(), (int)$exception->getCode());
22+
$this->file = $exception->getFile();
23+
$this->line = $exception->getLine();
24+
}
25+
26+
/**
27+
* @return array
28+
*/
29+
public function jsonSerialize(): array
30+
{
31+
$data = [
32+
'class' => (new ReflectionClass($this->wrapped))->getShortName(),
33+
'message' => $this->getMessage(),
34+
'code' => $this->getCode(),
35+
];
36+
37+
if (Configure::read('debug')) {
38+
$data['file'] = $this->getFile();
39+
$data['line'] = $this->getLine();
40+
}
41+
42+
return $data;
43+
}
44+
}

plugins/exception-render/tests/TestCase/MixerApiExceptionRenderTest.php

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
namespace MixerApi\ExceptionRender\Test\TestCase;
44

5-
use Cake\Core\Exception\CakeException;
5+
use Cake\Event\EventManager;
66
use Cake\Http\Exception\HttpException;
77
use Cake\Http\ServerRequest;
88
use Cake\TestSuite\TestCase;
@@ -11,6 +11,80 @@
1111

1212
class MixerApiExceptionRenderTest extends TestCase
1313
{
14+
public function test_chained_exceptions_serialize_as_structured_arrays(): void
15+
{
16+
$previous = new \RuntimeException('Connection refused', 111);
17+
$exception = new HttpException('Service unavailable', 503, $previous);
18+
19+
$request = new ServerRequest();
20+
$request = $request->withHeader('Accept', 'application/json');
21+
$request = $request->withHeader('Content-Type', 'application/json');
22+
23+
$response = (new MixerApiExceptionRenderer($exception, $request))->render();
24+
25+
$body = json_decode((string)$response->getBody(), true);
26+
$exceptions = $body['exceptions'];
27+
28+
$this->assertCount(2, $exceptions);
29+
30+
$this->assertEquals('HttpException', $exceptions[0]['class']);
31+
$this->assertEquals('Service unavailable', $exceptions[0]['message']);
32+
$this->assertEquals(503, $exceptions[0]['code']);
33+
$this->assertArrayHasKey('file', $exceptions[0]);
34+
$this->assertArrayHasKey('line', $exceptions[0]);
35+
36+
$this->assertEquals('RuntimeException', $exceptions[1]['class']);
37+
$this->assertEquals('Connection refused', $exceptions[1]['message']);
38+
$this->assertEquals(111, $exceptions[1]['code']);
39+
$this->assertArrayHasKey('file', $exceptions[1]);
40+
$this->assertArrayHasKey('line', $exceptions[1]);
41+
}
42+
43+
public function test_exception_chain_is_capped_at_max_depth(): void
44+
{
45+
$exception = new \RuntimeException('root');
46+
for ($i = 0; $i < 11; $i++) {
47+
$exception = new \RuntimeException("level $i", 0, $exception);
48+
}
49+
50+
$request = new ServerRequest();
51+
$request = $request->withHeader('Accept', 'application/json');
52+
$request = $request->withHeader('Content-Type', 'application/json');
53+
54+
$response = (new MixerApiExceptionRenderer($exception, $request))->render();
55+
56+
$body = json_decode((string)$response->getBody(), true);
57+
$this->assertCount(10, $body['exceptions']);
58+
}
59+
60+
public function test_chained_exceptions_are_throwable_for_event_listeners(): void
61+
{
62+
$previous = new \RuntimeException('Connection refused', 111);
63+
$exception = new HttpException('Service unavailable', 503, $previous);
64+
65+
$request = new ServerRequest();
66+
$request = $request->withHeader('Accept', 'application/json');
67+
$request = $request->withHeader('Content-Type', 'application/json');
68+
69+
$captured = null;
70+
EventManager::instance()->on(
71+
'MixerApi.ExceptionRender.beforeRender',
72+
function ($event) use (&$captured) {
73+
$captured = $event->getSubject()->getViewVars()['exceptions'];
74+
}
75+
);
76+
77+
(new MixerApiExceptionRenderer($exception, $request))->render();
78+
79+
$this->assertCount(2, $captured);
80+
$this->assertInstanceOf(\Throwable::class, $captured[0]);
81+
$this->assertInstanceOf(\Throwable::class, $captured[1]);
82+
$this->assertEquals('Service unavailable', $captured[0]->getMessage());
83+
$this->assertEquals('Connection refused', $captured[1]->getMessage());
84+
85+
EventManager::instance()->off('MixerApi.ExceptionRender.beforeRender');
86+
}
87+
1488
public function test_get_error(): void
1589
{
1690
$this->assertInstanceOf(
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
namespace MixerApi\ExceptionRender\Test\TestCase;
4+
5+
use Cake\Core\Configure;
6+
use Cake\TestSuite\TestCase;
7+
use MixerApi\ExceptionRender\SerializableException;
8+
9+
class SerializableExceptionTest extends TestCase
10+
{
11+
public function test_is_throwable(): void
12+
{
13+
$wrapped = new \RuntimeException('test');
14+
$exception = new SerializableException($wrapped);
15+
16+
$this->assertInstanceOf(\Throwable::class, $exception);
17+
}
18+
19+
public function test_is_json_serializable(): void
20+
{
21+
$wrapped = new \RuntimeException('test');
22+
$exception = new SerializableException($wrapped);
23+
24+
$this->assertInstanceOf(\JsonSerializable::class, $exception);
25+
}
26+
27+
public function test_proxies_message_and_code(): void
28+
{
29+
$wrapped = new \RuntimeException('Connection refused', 111);
30+
$exception = new SerializableException($wrapped);
31+
32+
$this->assertEquals('Connection refused', $exception->getMessage());
33+
$this->assertEquals(111, $exception->getCode());
34+
}
35+
36+
public function test_proxies_file_and_line(): void
37+
{
38+
$wrapped = new \RuntimeException('test');
39+
$exception = new SerializableException($wrapped);
40+
41+
$this->assertEquals($wrapped->getFile(), $exception->getFile());
42+
$this->assertEquals($wrapped->getLine(), $exception->getLine());
43+
}
44+
45+
public function test_json_serialize_returns_structured_data(): void
46+
{
47+
$wrapped = new \RuntimeException('Connection refused', 111);
48+
$exception = new SerializableException($wrapped);
49+
50+
Configure::write('debug', true);
51+
$data = $exception->jsonSerialize();
52+
53+
$this->assertEquals('RuntimeException', $data['class']);
54+
$this->assertEquals('Connection refused', $data['message']);
55+
$this->assertEquals(111, $data['code']);
56+
$this->assertArrayHasKey('file', $data);
57+
$this->assertArrayHasKey('line', $data);
58+
}
59+
60+
public function test_json_serialize_excludes_file_and_line_without_debug(): void
61+
{
62+
$wrapped = new \RuntimeException('Connection refused', 111);
63+
$exception = new SerializableException($wrapped);
64+
65+
Configure::write('debug', false);
66+
$data = $exception->jsonSerialize();
67+
68+
$this->assertEquals('RuntimeException', $data['class']);
69+
$this->assertEquals('Connection refused', $data['message']);
70+
$this->assertEquals(111, $data['code']);
71+
$this->assertArrayNotHasKey('file', $data);
72+
$this->assertArrayNotHasKey('line', $data);
73+
74+
Configure::write('debug', true);
75+
}
76+
77+
public function test_json_encode_produces_correct_output(): void
78+
{
79+
$wrapped = new \RuntimeException('test', 42);
80+
$exception = new SerializableException($wrapped);
81+
82+
Configure::write('debug', false);
83+
$json = json_encode($exception);
84+
$decoded = json_decode($json, true);
85+
86+
$this->assertEquals('RuntimeException', $decoded['class']);
87+
$this->assertEquals('test', $decoded['message']);
88+
$this->assertEquals(42, $decoded['code']);
89+
90+
Configure::write('debug', true);
91+
}
92+
}

0 commit comments

Comments
 (0)