From 958013291f86c9f97f418477989a95928d0525aa Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Wed, 29 Apr 2026 16:58:15 -0400 Subject: [PATCH 1/3] 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. --- .../src/MixerApiExceptionRenderer.php | 21 +++++++++---- .../TestCase/MixerApiExceptionRenderTest.php | 30 ++++++++++++++++++- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/plugins/exception-render/src/MixerApiExceptionRenderer.php b/plugins/exception-render/src/MixerApiExceptionRenderer.php index 9b5f632..356cd4e 100644 --- a/plugins/exception-render/src/MixerApiExceptionRenderer.php +++ b/plugins/exception-render/src/MixerApiExceptionRenderer.php @@ -73,11 +73,22 @@ public function render(): ResponseInterface } $response = $response->withStatus($code); - $exceptions = [$exception]; - $previous = $exception->getPrevious(); - while ($previous != null) { - $exceptions[] = $previous; - $previous = $previous->getPrevious(); + $exceptions = []; + $current = $exception; + while ($current !== null) { + $exceptionData = [ + 'class' => (new ReflectionClass($current))->getShortName(), + 'message' => $current->getMessage(), + 'code' => $current->getCode(), + ]; + + if (Configure::read('debug')) { + $exceptionData['file'] = $current->getFile(); + $exceptionData['line'] = $current->getLine(); + } + + $exceptions[] = $exceptionData; + $current = $current->getPrevious(); } $viewVars = [ diff --git a/plugins/exception-render/tests/TestCase/MixerApiExceptionRenderTest.php b/plugins/exception-render/tests/TestCase/MixerApiExceptionRenderTest.php index 4a75252..a85716e 100644 --- a/plugins/exception-render/tests/TestCase/MixerApiExceptionRenderTest.php +++ b/plugins/exception-render/tests/TestCase/MixerApiExceptionRenderTest.php @@ -2,7 +2,6 @@ namespace MixerApi\ExceptionRender\Test\TestCase; -use Cake\Core\Exception\CakeException; use Cake\Http\Exception\HttpException; use Cake\Http\ServerRequest; use Cake\TestSuite\TestCase; @@ -11,6 +10,35 @@ 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_get_error(): void { $this->assertInstanceOf( From 3250c20c031a0b35e139c4d0cdcd2f1c0684237b Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Mon, 4 May 2026 08:11:57 -0400 Subject: [PATCH 2/3] 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. --- .../src/MixerApiExceptionRenderer.php | 16 +--- .../src/SerializableException.php | 38 ++++++++ .../TestCase/MixerApiExceptionRenderTest.php | 46 ++++++++++ .../TestCase/SerializableExceptionTest.php | 92 +++++++++++++++++++ 4 files changed, 179 insertions(+), 13 deletions(-) create mode 100644 plugins/exception-render/src/SerializableException.php create mode 100644 plugins/exception-render/tests/TestCase/SerializableExceptionTest.php diff --git a/plugins/exception-render/src/MixerApiExceptionRenderer.php b/plugins/exception-render/src/MixerApiExceptionRenderer.php index 356cd4e..c7de1ed 100644 --- a/plugins/exception-render/src/MixerApiExceptionRenderer.php +++ b/plugins/exception-render/src/MixerApiExceptionRenderer.php @@ -73,21 +73,11 @@ public function render(): ResponseInterface } $response = $response->withStatus($code); + $maxExceptions = 10; $exceptions = []; $current = $exception; - while ($current !== null) { - $exceptionData = [ - 'class' => (new ReflectionClass($current))->getShortName(), - 'message' => $current->getMessage(), - 'code' => $current->getCode(), - ]; - - if (Configure::read('debug')) { - $exceptionData['file'] = $current->getFile(); - $exceptionData['line'] = $current->getLine(); - } - - $exceptions[] = $exceptionData; + while ($current !== null && count($exceptions) < $maxExceptions) { + $exceptions[] = new SerializableException($current); $current = $current->getPrevious(); } diff --git a/plugins/exception-render/src/SerializableException.php b/plugins/exception-render/src/SerializableException.php new file mode 100644 index 0000000..e91e8a5 --- /dev/null +++ b/plugins/exception-render/src/SerializableException.php @@ -0,0 +1,38 @@ +wrapped = $exception; + parent::__construct($exception->getMessage(), (int) $exception->getCode()); + $this->file = $exception->getFile(); + $this->line = $exception->getLine(); + } + + 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; + } +} diff --git a/plugins/exception-render/tests/TestCase/MixerApiExceptionRenderTest.php b/plugins/exception-render/tests/TestCase/MixerApiExceptionRenderTest.php index a85716e..c896abf 100644 --- a/plugins/exception-render/tests/TestCase/MixerApiExceptionRenderTest.php +++ b/plugins/exception-render/tests/TestCase/MixerApiExceptionRenderTest.php @@ -2,6 +2,7 @@ namespace MixerApi\ExceptionRender\Test\TestCase; +use Cake\Event\EventManager; use Cake\Http\Exception\HttpException; use Cake\Http\ServerRequest; use Cake\TestSuite\TestCase; @@ -39,6 +40,51 @@ public function test_chained_exceptions_serialize_as_structured_arrays(): void $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( diff --git a/plugins/exception-render/tests/TestCase/SerializableExceptionTest.php b/plugins/exception-render/tests/TestCase/SerializableExceptionTest.php new file mode 100644 index 0000000..785470b --- /dev/null +++ b/plugins/exception-render/tests/TestCase/SerializableExceptionTest.php @@ -0,0 +1,92 @@ +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); + } +} From f281adc3c727ab10590c164f797f6f1903ed7f8d Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Wed, 6 May 2026 19:26:34 -0400 Subject: [PATCH 3/3] Fix PHPCS violations - Avoid count() in loop condition (use depth counter) - Add doc comments for __construct and jsonSerialize - Remove space after (int) cast --- .../exception-render/src/MixerApiExceptionRenderer.php | 4 +++- plugins/exception-render/src/SerializableException.php | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/exception-render/src/MixerApiExceptionRenderer.php b/plugins/exception-render/src/MixerApiExceptionRenderer.php index c7de1ed..0c50de3 100644 --- a/plugins/exception-render/src/MixerApiExceptionRenderer.php +++ b/plugins/exception-render/src/MixerApiExceptionRenderer.php @@ -74,9 +74,11 @@ public function render(): ResponseInterface $response = $response->withStatus($code); $maxExceptions = 10; + $depth = 0; $exceptions = []; $current = $exception; - while ($current !== null && count($exceptions) < $maxExceptions) { + while ($current !== null && $depth < $maxExceptions) { + $depth++; $exceptions[] = new SerializableException($current); $current = $current->getPrevious(); } diff --git a/plugins/exception-render/src/SerializableException.php b/plugins/exception-render/src/SerializableException.php index e91e8a5..1a369a7 100644 --- a/plugins/exception-render/src/SerializableException.php +++ b/plugins/exception-render/src/SerializableException.php @@ -12,14 +12,20 @@ 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()); + parent::__construct($exception->getMessage(), (int)$exception->getCode()); $this->file = $exception->getFile(); $this->line = $exception->getLine(); } + /** + * @return array + */ public function jsonSerialize(): array { $data = [