diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dfd3f9..7b87922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +1.4.0 +===== + +* (feature) Add `ValidJsonVerifier`, that checks, that every normalizer correctly generated JSON-compatible values. + + 1.3.3 ===== diff --git a/config/services.yaml b/config/services.yaml index 31cd779..20fda9b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -11,3 +11,4 @@ services: Torr\SimpleNormalizer\Normalizer\SimpleNormalizer: $objectNormalizers: !tagged_locator {tag: 'torr.normalizer.simple-object-normalizer', default_index_method: 'getNormalizedType'} + $isDebug: '%kernel.debug%' diff --git a/src/Exception/IncompleteNormalizationException.php b/src/Exception/IncompleteNormalizationException.php new file mode 100644 index 0000000..9735e2a --- /dev/null +++ b/src/Exception/IncompleteNormalizationException.php @@ -0,0 +1,10 @@ +recursiveNormalize($value, $context); + + if ($this->isDebug) + { + $this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue); + } + + return $normalizedValue; + } + /** + */ + public function normalizeArray (array $array, array $context = []) : array + { + $normalizedValue = $this->recursiveNormalizeArray($array, $context); + + if ($this->isDebug) + { + $this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue); + } + + return $normalizedValue; + } + + /** + * Normalizes a map of values. + * Will JSON-encode to `{}` when empty. + */ + 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. + $normalizedValue = $this->recursiveNormalizeArray($array, $context) ?: new \stdClass(); + + if ($this->isDebug) + { + $this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue); + } + + return $normalizedValue; + } + + + + /** + * The actual normalize logic, that recursively normalizes the value. + * It must never call one of the public methods above and just normalizes the value. + */ + private function recursiveNormalize (mixed $value, array $context = []) : mixed { if (null === $value || \is_scalar($value)) { @@ -35,7 +90,7 @@ public function normalize (mixed $value, array $context = []) : mixed if (\is_array($value)) { - return $this->normalizeArray($value, $context); + return $this->recursiveNormalizeArray($value, $context); } if (\is_object($value)) @@ -77,15 +132,17 @@ public function normalize (mixed $value, array $context = []) : mixed } /** + * 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. */ - public function normalizeArray (array $array, array $context = []) : array + private function recursiveNormalizeArray (array $array, array $context = []) : array { $result = []; $isList = array_is_list($array); foreach ($array as $key => $value) { - $normalized = $this->normalize($value, $context); + $normalized = $this->recursiveNormalize($value, $context); // if the array was a list and the normalized value is null, just filter it out if ($isList && null === $normalized) @@ -107,14 +164,4 @@ public function normalizeArray (array $array, array $context = []) : array return $result; } - - /** - * Normalizes a map of values. - * Will JSON-encode to `{}` when empty. - */ - 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. - return $this->normalizeArray($array, $context) ?: new \stdClass(); - } } diff --git a/src/Normalizer/Validator/InvalidJsonElement.php b/src/Normalizer/Validator/InvalidJsonElement.php new file mode 100644 index 0000000..04c3d0c --- /dev/null +++ b/src/Normalizer/Validator/InvalidJsonElement.php @@ -0,0 +1,16 @@ +findInvalidJsonElement($value); + + if (null !== $invalidElement) + { + throw new IncompleteNormalizationException( + sprintf( + "Found a JSON-incompatible value when normalizing. Found '%s' at path '%s', but expected only scalars, arrays and empty objects.", + get_debug_type($invalidElement->value), + implode(".", $invalidElement->path), + ), + ); + } + } + + + /** + * Searches through the value and looks for anything that isn't valid JSON + * (scalars, arrays or empty objects). + * + * @return InvalidJsonElement|null Returns null if everything is valid, otherwise the invalid value. + */ + private function findInvalidJsonElement (mixed $value, array $path = ["$"]) : ?InvalidJsonElement + { + // scalars are always valid + if (null === $value || \is_scalar($value)) + { + return null; + } + + // only empty stdClass objects are allowed (as they are used to serialize to `{}`) + if (is_object($value)) + { + return $value instanceof \stdClass && [] === get_object_vars($value) + ? null + : new InvalidJsonElement($value, $path); + } + + if (is_array($value)) + { + foreach ($value as $key => $item) + { + $invalidItem = $this->findInvalidJsonElement( + $item, + [...$path, $key], + ); + + if (null !== $invalidItem) + { + return $invalidItem; + } + } + + return null; + } + + return new InvalidJsonElement($value, $path); + } +} diff --git a/tests/Normalizer/SimpleNormalizerTest.php b/tests/Normalizer/SimpleNormalizerTest.php new file mode 100644 index 0000000..bc35564 --- /dev/null +++ b/tests/Normalizer/SimpleNormalizerTest.php @@ -0,0 +1,150 @@ + [ + static fn (SimpleNormalizer $normalizer, mixed $value) => $normalizer->normalize($value), + ]; + + yield "normalizeArray" => [ + static fn (SimpleNormalizer $normalizer, mixed $value) => $normalizer->normalizeArray($value), + ]; + + yield "normalizeMap" => [ + static fn (SimpleNormalizer $normalizer, mixed $value) => $normalizer->normalizeMap($value), + ]; + } + + /** + * @dataProvider provideJsonVerifierEnabled + */ + public function testJsonVerifierEnabled (callable $call) : void + { + $verifier = $this->createMock(ValidJsonVerifier::class); + + $verifier + ->expects($this->once()) + ->method('ensureValidOnlyJsonTypes'); + + $normalizer = new SimpleNormalizer( + objectNormalizers: $this->createNormalizerObjectNormalizers("ok"), + isDebug: true, + validJsonVerifier: $verifier, + ); + + $call($normalizer, [ + "nested" => [ + "deeply" => [ + "ohai" => new DummyVO(42), + ], + "valid" => true, + ], + ]); + } + + + /** + * + */ + public function testJsonVerifierDisabled () : void + { + $verifier = $this->createMock(ValidJsonVerifier::class); + + $verifier + ->expects($this->never()) + ->method('ensureValidOnlyJsonTypes'); + + $normalizer = new SimpleNormalizer( + objectNormalizers: $this->createNormalizerObjectNormalizers("ok"), + isDebug: false, + validJsonVerifier: $verifier, + ); + + $normalizer->normalize([ + "key" => new DummyVO(42), + ]); + self::assertTrue(true); // Just to ensure the test runs without exceptions + } + + + /** + * + */ + public function testInvalidNormalizer () : void + { + $normalizer = new SimpleNormalizer( + objectNormalizers: $this->createNormalizerObjectNormalizers(new DummyVO(11)), + isDebug: true, + validJsonVerifier: new ValidJsonVerifier(), + ); + + $this->expectException(IncompleteNormalizationException::class); + $this->expectExceptionMessage("Found a JSON-incompatible value when normalizing. Found 'Tests\Torr\SimpleNormalizer\Fixture\DummyVO' at path '$', but expected only scalars, arrays and empty objects."); + $normalizer->normalize(new DummyVO(42)); + } + + + /** + * + */ + public function testInvalidNestedNormalizer () : void + { + $normalizer = new SimpleNormalizer( + objectNormalizers: $this->createNormalizerObjectNormalizers(new DummyVO(11)), + isDebug: true, + validJsonVerifier: new ValidJsonVerifier(), + ); + + $this->expectException(IncompleteNormalizationException::class); + $this->expectExceptionMessage("Found a JSON-incompatible value when normalizing. Found 'Tests\Torr\SimpleNormalizer\Fixture\DummyVO' at path '$.nested.deeply.ohai', but expected only scalars, arrays and empty objects."); + $normalizer->normalize([ + "nested" => [ + "deeply" => [ + "ohai" => new DummyVO(42), + ], + "valid" => true, + ], + ]); + } + + + /** + * + */ + private function createNormalizerObjectNormalizers (mixed $returnValue) : ServiceLocator + { + return new ServiceLocator([ + DummyVO::class => static fn () => new readonly class ($returnValue) implements SimpleObjectNormalizerInterface + { + public function __construct ( + private mixed $returnValue, + ) {} + + public function normalize (object $value, array $context, SimpleNormalizer $normalizer) : mixed + { + return $this->returnValue; + } + + public static function getNormalizedType () : string + { + return DummyVO::class; + } + }, + ]); + } +}