Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
1.4.0
=====

* (feature) Add `ValidJsonVerifier`, that checks, that every normalizer correctly generated JSON-compatible values.


1.3.3
=====

Expand Down
1 change: 1 addition & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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%'
10 changes: 10 additions & 0 deletions src/Exception/IncompleteNormalizationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php declare(strict_types=1);

namespace Torr\SimpleNormalizer\Exception;

/**
* @final
*/
class IncompleteNormalizationException extends \RuntimeException implements NormalizerExceptionInterface
{
}
73 changes: 60 additions & 13 deletions src/Normalizer/SimpleNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@
use Symfony\Component\DependencyInjection\ServiceLocator;
use Torr\SimpleNormalizer\Exception\ObjectTypeNotSupportedException;
use Torr\SimpleNormalizer\Exception\UnsupportedTypeException;
use Torr\SimpleNormalizer\Normalizer\Validator\ValidJsonVerifier;

/**
* The normalizer to use in your app.
*
* Can't be readonly, as it needs to be mock-able.
*
* The verifier is done on the top-level of every method (instead of at the point where the invalid values could occur
* = the object normalizers), as this way we can provide a full path to the invalid element in the JSON.
*
* @readonly
* @final
*/
class SimpleNormalizer
Expand All @@ -22,11 +27,61 @@ class SimpleNormalizer
*/
public function __construct (
private readonly ServiceLocator $objectNormalizers,
private readonly bool $isDebug = false,
private readonly ?ValidJsonVerifier $validJsonVerifier = null,
) {}

/**
*/
public function normalize (mixed $value, array $context = []) : mixed
{
$normalizedValue = $this->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))
{
Expand All @@ -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))
Expand Down Expand Up @@ -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)
Expand All @@ -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();
}
}
16 changes: 16 additions & 0 deletions src/Normalizer/Validator/InvalidJsonElement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php declare(strict_types=1);

namespace Torr\SimpleNormalizer\Normalizer\Validator;

/**
* @final
*/
readonly class InvalidJsonElement
{
/**
*/
public function __construct (
public mixed $value,
public array $path,
) {}
}
76 changes: 76 additions & 0 deletions src/Normalizer/Validator/ValidJsonVerifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php declare(strict_types=1);

namespace Torr\SimpleNormalizer\Normalizer\Validator;

use Torr\SimpleNormalizer\Exception\IncompleteNormalizationException;

/**
* Helper to verify that a given value is valid JSON (scalars, lists or empty objects)
*
* @final
*/
class ValidJsonVerifier
{
/**
* Ensures that only valid JSON types are present in the value, that means scalars, arrays and empty objects.
*/
public function ensureValidOnlyJsonTypes (mixed $value) : void
{
$invalidElement = $this->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);
}
}
150 changes: 150 additions & 0 deletions tests/Normalizer/SimpleNormalizerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php declare(strict_types=1);

namespace Tests\Torr\SimpleNormalizer\Normalizer;

use Symfony\Component\DependencyInjection\ServiceLocator;
use Tests\Torr\SimpleNormalizer\Fixture\DummyVO;
use Torr\SimpleNormalizer\Exception\IncompleteNormalizationException;
use Torr\SimpleNormalizer\Normalizer\SimpleNormalizer;
use PHPUnit\Framework\TestCase;
use Torr\SimpleNormalizer\Normalizer\SimpleObjectNormalizerInterface;
use Torr\SimpleNormalizer\Normalizer\Validator\ValidJsonVerifier;

class SimpleNormalizerTest extends TestCase
{
/**
*
*/
public static function provideJsonVerifierEnabled () : iterable
{
yield "normalize" => [
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;
}
},
]);
}
}
Loading