From 13f120517d6e86f6a044625ddf9c96ba3ebd9603 Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Mon, 27 Apr 2026 11:45:00 +0200 Subject: [PATCH 1/3] Add tests to verify stack trace in message --- tests/Normalizer/ObjectNormalizationTest.php | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/Normalizer/ObjectNormalizationTest.php b/tests/Normalizer/ObjectNormalizationTest.php index 9496315..2d7ae9c 100644 --- a/tests/Normalizer/ObjectNormalizationTest.php +++ b/tests/Normalizer/ObjectNormalizationTest.php @@ -6,6 +6,7 @@ use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\DependencyInjection\ServiceLocator; use Tests\Torr\SimpleNormalizer\Fixture\DummyVO; +use Torr\SimpleNormalizer\Context\ContextBag; use Torr\SimpleNormalizer\Exception\ObjectTypeNotSupportedException; use Torr\SimpleNormalizer\Normalizer\SimpleNormalizer; use Torr\SimpleNormalizer\Normalizer\SimpleObjectNormalizerInterface; @@ -125,4 +126,40 @@ public function testMissingNormalizerKeepsPreviousException () : void self::assertSame($previous, $exception->getPrevious()); } } + + /** + */ + public function testContextBagErrorsIncludeStackInThrownExceptionMessage () : void + { + $normalizer = $this->createNormalizer(new class() implements SimpleObjectNormalizerInterface { + public function normalize (object $value, array $context, SimpleNormalizer $normalizer) : null + { + $bag = new ContextBag($context); + $bag->getString("required"); + + return null; + } + + public static function getNormalizedType () : string + { + return DummyVO::class; + } + }); + + foreach ([[], ["required" => 1]] as $context) + { + try + { + $normalizer->normalize(new DummyVO(11), $context); + self::fail("Expected an exception to be thrown."); + } + catch (\Throwable $exception) + { + self::assertStringContainsString( + "at Tests\Torr\SimpleNormalizer\Fixture\DummyVO", + $exception->getMessage(), + ); + } + } + } } From 8ec5dfa555a46ee8aa220f17d2af076f3ed17f9f Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Mon, 27 Apr 2026 12:02:08 +0200 Subject: [PATCH 2/3] Improve error handling --- CHANGELOG.md | 2 + src/Exception/AbstractNormalizerException.php | 33 ++++++++ .../Context/InvalidContextTypeException.php | 2 +- .../Context/MissingContextException.php | 2 +- .../NormalizationFailedException.php | 2 +- .../ObjectTypeNotSupportedException.php | 2 +- src/Exception/UnsupportedTypeException.php | 2 +- src/Normalizer/SimpleNormalizer.php | 83 ++++++++++++++----- .../NormalizationFailedExceptionTest.php | 24 ++++++ .../UnsupportedTypeExceptionTest.php | 24 ++++++ 10 files changed, 151 insertions(+), 25 deletions(-) create mode 100644 src/Exception/AbstractNormalizerException.php create mode 100644 tests/Exception/NormalizationFailedExceptionTest.php create mode 100644 tests/Exception/UnsupportedTypeExceptionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 98dc78c..4a8f7fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ * (improvement) Add dedicated verifier tests for deep-path reporting and first-invalid-element detection. * (improvement) Optimize empty `stdClass` detection by using an `(array)` cast check instead of `get_object_vars()`. * (improvement) Add a default max-depth guard (128) for normalization and JSON verification to mitigate deep-nesting DoS risk. +* (improvement) Add stack trace to all relevant exceptions as getter. +* (improvement) Add stack trace to NormalizationFailedExceptions thrown in custom object normalizers. 1.5.1 diff --git a/src/Exception/AbstractNormalizerException.php b/src/Exception/AbstractNormalizerException.php new file mode 100644 index 0000000..299893d --- /dev/null +++ b/src/Exception/AbstractNormalizerException.php @@ -0,0 +1,33 @@ + */ + private array $normalizationStack; + + /** + * @param list $normalizationStack + */ + public function __construct ( + string $message = "", + int $code = 0, + ?\Throwable $previous = null, + array $normalizationStack = [], + ) + { + parent::__construct($message, $code, $previous); + $this->normalizationStack = $normalizationStack; + } + + /** + * Returns the stack to the normalization issue. + * + * @return list + */ + public function getNormalizationStack () : array + { + return $this->normalizationStack; + } +} diff --git a/src/Exception/Context/InvalidContextTypeException.php b/src/Exception/Context/InvalidContextTypeException.php index a719bba..1b1294b 100644 --- a/src/Exception/Context/InvalidContextTypeException.php +++ b/src/Exception/Context/InvalidContextTypeException.php @@ -15,7 +15,7 @@ public static function create ( mixed $value, string $expected, ?\Throwable $previous = null, - ) : static + ) : self { return new self( \sprintf( diff --git a/src/Exception/Context/MissingContextException.php b/src/Exception/Context/MissingContextException.php index 2d1312c..3c3d605 100644 --- a/src/Exception/Context/MissingContextException.php +++ b/src/Exception/Context/MissingContextException.php @@ -15,7 +15,7 @@ public static function create ( string $missingKey, array $allKeys, ?\Throwable $previous = null, - ) : static + ) : self { return new self( \sprintf( diff --git a/src/Exception/NormalizationFailedException.php b/src/Exception/NormalizationFailedException.php index c5bfd73..70086ef 100644 --- a/src/Exception/NormalizationFailedException.php +++ b/src/Exception/NormalizationFailedException.php @@ -5,6 +5,6 @@ /** * Generic failure exception, for usage inside custom normalizers. */ -class NormalizationFailedException extends \RuntimeException implements NormalizerExceptionInterface +class NormalizationFailedException extends AbstractNormalizerException { } diff --git a/src/Exception/ObjectTypeNotSupportedException.php b/src/Exception/ObjectTypeNotSupportedException.php index 56d19c7..ff30f86 100644 --- a/src/Exception/ObjectTypeNotSupportedException.php +++ b/src/Exception/ObjectTypeNotSupportedException.php @@ -2,6 +2,6 @@ namespace Torr\SimpleNormalizer\Exception; -final class ObjectTypeNotSupportedException extends \RuntimeException implements NormalizerExceptionInterface +final class ObjectTypeNotSupportedException extends AbstractNormalizerException { } diff --git a/src/Exception/UnsupportedTypeException.php b/src/Exception/UnsupportedTypeException.php index 3bb28ce..afed3b2 100644 --- a/src/Exception/UnsupportedTypeException.php +++ b/src/Exception/UnsupportedTypeException.php @@ -2,6 +2,6 @@ namespace Torr\SimpleNormalizer\Exception; -final class UnsupportedTypeException extends \RuntimeException implements NormalizerExceptionInterface +final class UnsupportedTypeException extends AbstractNormalizerException { } diff --git a/src/Normalizer/SimpleNormalizer.php b/src/Normalizer/SimpleNormalizer.php index 67b061e..41ea815 100644 --- a/src/Normalizer/SimpleNormalizer.php +++ b/src/Normalizer/SimpleNormalizer.php @@ -87,8 +87,9 @@ public function normalizeArray (array $array, array $context = []) : array */ public function normalizeMap (array $array, array $context = []) : array|\stdClass { - // return stdClass if the array is empty here, as it will be automatically normalized to `{}` in JSON. $stack = []; + + // return stdClass if the array is empty here, as it will be automatically normalized to `{}` in JSON. $normalizedValue = $this->recursiveNormalizeArray($array, $context, $stack) ?: new \stdClass(); if ($this->isDebug) @@ -100,8 +101,10 @@ public function normalizeMap (array $array, array $context = []) : array|\stdCla } /** - * The actual normalize logic, that recursively normalizes the value. + * The actual normalization logic that recursively normalizes the value. * It must never call one of the public methods above and just normalizes the value. + * + * @param list $stack */ private function recursiveNormalize (mixed $value, array $context, array &$stack) : mixed { @@ -113,13 +116,17 @@ private function recursiveNormalize (mixed $value, array $context, array &$stack if (\count($stack) >= $this->maxDepth) { $extendedStack = [...$stack, get_debug_type($value)]; - - throw new UnsupportedTypeException(\sprintf( - "Maximum normalization depth of %d exceeded when normalizing type %s in stack %s", - $this->maxDepth, - get_debug_type($value), - implode(" > ", array_reverse($extendedStack)), - )); + $normalizationStack = self::prepareStack($extendedStack); + + throw new UnsupportedTypeException( + message: \sprintf( + "Maximum normalization depth of %d exceeded when normalizing type %s in stack %s", + $this->maxDepth, + get_debug_type($value), + implode(" > ", $normalizationStack), + ), + normalizationStack: $normalizationStack, + ); } $stack[] = get_debug_type($value); @@ -150,30 +157,53 @@ private function recursiveNormalize (mixed $value, array $context, array &$stack } catch (ServiceNotFoundException $exception) { - throw new ObjectTypeNotSupportedException(\sprintf( - "Can't normalize type '%s' in stack %s", - get_debug_type($value), - implode(" > ", array_reverse($stack)), - ), 0, $exception); + $normalizationStack = self::prepareStack($stack); + + throw new ObjectTypeNotSupportedException( + message: \sprintf( + "Can't normalize type '%s' in stack %s", + get_debug_type($value), + implode(" > ", $normalizationStack), + ), + previous: $exception, + normalizationStack: $normalizationStack, + ); } catch (MissingContextException|InvalidContextTypeException $exception) { + $normalizationStack = self::prepareStack($stack); + throw new NormalizationFailedException( message: \sprintf( "Normalization failed: %s at %s", $exception->getMessage(), - implode(" > ", array_reverse($stack)), + implode(" > ", $normalizationStack), ), previous: $exception, + normalizationStack: $normalizationStack, + ); + } + catch (NormalizationFailedException $exception) + { + // rewrap to add normalization stack + throw new NormalizationFailedException( + message: $exception->getMessage(), + previous: $exception, + normalizationStack: self::prepareStack($stack), ); } } - throw new UnsupportedTypeException(\sprintf( - "Can't normalize type %s in stack %s", - get_debug_type($value), - implode(" > ", array_reverse($stack)), - )); + $normalizationStack = self::prepareStack($stack); + + throw new UnsupportedTypeException( + message: \sprintf( + "Can't normalize type %s in stack %s", + get_debug_type($value), + implode(" > ", $normalizationStack), + ), + normalizationStack: $normalizationStack, + ); } finally { @@ -209,6 +239,8 @@ private function normalizeClassName (string $className) : string /** * The actual customized normalization logic for arrays, that recursively normalizes the value. * It must never call one of the public methods above and just normalizes the value. + * + * @param list $stack */ private function recursiveNormalizeArray (array $array, array $context, array &$stack) : array { @@ -239,4 +271,15 @@ private function recursiveNormalizeArray (array $array, array $context, array &$ return $result; } + + /** + * @return list + */ + private static function prepareStack (array $rawStack) : array + { + $stack = array_reverse($rawStack); + \assert(array_is_list($stack)); + + return $stack; + } } diff --git a/tests/Exception/NormalizationFailedExceptionTest.php b/tests/Exception/NormalizationFailedExceptionTest.php new file mode 100644 index 0000000..afd266d --- /dev/null +++ b/tests/Exception/NormalizationFailedExceptionTest.php @@ -0,0 +1,24 @@ +getNormalizationStack()); + } +} diff --git a/tests/Exception/UnsupportedTypeExceptionTest.php b/tests/Exception/UnsupportedTypeExceptionTest.php new file mode 100644 index 0000000..7319e7e --- /dev/null +++ b/tests/Exception/UnsupportedTypeExceptionTest.php @@ -0,0 +1,24 @@ +getNormalizationStack()); + } +} From 68b9f006c1f9d4a728f8f5024b554db7b30bc4ec Mon Sep 17 00:00:00 2001 From: Jannik Zschiesche Date: Mon, 27 Apr 2026 12:04:10 +0200 Subject: [PATCH 3/3] Add test for stack traces in custom NormalizationFailedExceptions --- tests/Normalizer/ObjectNormalizationTest.php | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/Normalizer/ObjectNormalizationTest.php b/tests/Normalizer/ObjectNormalizationTest.php index 2d7ae9c..17952f5 100644 --- a/tests/Normalizer/ObjectNormalizationTest.php +++ b/tests/Normalizer/ObjectNormalizationTest.php @@ -7,6 +7,7 @@ use Symfony\Component\DependencyInjection\ServiceLocator; use Tests\Torr\SimpleNormalizer\Fixture\DummyVO; use Torr\SimpleNormalizer\Context\ContextBag; +use Torr\SimpleNormalizer\Exception\NormalizationFailedException; use Torr\SimpleNormalizer\Exception\ObjectTypeNotSupportedException; use Torr\SimpleNormalizer\Normalizer\SimpleNormalizer; use Torr\SimpleNormalizer\Normalizer\SimpleObjectNormalizerInterface; @@ -162,4 +163,31 @@ public static function getNormalizedType () : string } } } + + /** + */ + public function testCustomNormalizationFailedExceptionGetsStack () : void + { + $normalizer = $this->createNormalizer(new class() implements SimpleObjectNormalizerInterface { + public function normalize (object $value, array $context, SimpleNormalizer $normalizer) : mixed + { + throw new NormalizationFailedException("Custom failure"); + } + + public static function getNormalizedType () : string + { + return DummyVO::class; + } + }); + + try + { + $normalizer->normalize(new DummyVO(11)); + self::fail("Expected NormalizationFailedException to be thrown."); + } + catch (NormalizationFailedException $exception) + { + self::assertSame([DummyVO::class], $exception->getNormalizationStack()); + } + } }