Skip to content

Commit 2595024

Browse files
committed
Add ValidJsonVerifier, that checks, that every normalizer correctly generated JSON-compatible values
1 parent 9198e18 commit 2595024

File tree

7 files changed

+319
-13
lines changed

7 files changed

+319
-13
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
1.4.0
2+
=====
3+
4+
* (feature) Add `ValidJsonVerifier`, that checks, that every normalizer correctly generated JSON-compatible values.
5+
6+
17
1.3.3
28
=====
39

config/services.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ services:
1111

1212
Torr\SimpleNormalizer\Normalizer\SimpleNormalizer:
1313
$objectNormalizers: !tagged_locator {tag: 'torr.normalizer.simple-object-normalizer', default_index_method: 'getNormalizedType'}
14+
$isDebug: '%kernel.debug%'
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Torr\SimpleNormalizer\Exception;
4+
5+
/**
6+
* @final
7+
*/
8+
class IncompleteNormalizationException extends \RuntimeException implements NormalizerExceptionInterface
9+
{
10+
}

src/Normalizer/SimpleNormalizer.php

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@
77
use Symfony\Component\DependencyInjection\ServiceLocator;
88
use Torr\SimpleNormalizer\Exception\ObjectTypeNotSupportedException;
99
use Torr\SimpleNormalizer\Exception\UnsupportedTypeException;
10+
use Torr\SimpleNormalizer\Normalizer\Validator\ValidJsonVerifier;
1011

1112
/**
1213
* The normalizer to use in your app.
1314
*
1415
* Can't be readonly, as it needs to be mock-able.
1516
*
17+
* The verifier is done on the top-level of every method (instead of at the point where the invalid values could occur
18+
* = the object normalizers), as this way we can provide a full path to the invalid element in the JSON.
19+
*
20+
* @readonly
1621
* @final
1722
*/
1823
class SimpleNormalizer
@@ -22,11 +27,61 @@ class SimpleNormalizer
2227
*/
2328
public function __construct (
2429
private readonly ServiceLocator $objectNormalizers,
30+
private readonly bool $isDebug = false,
31+
private readonly ?ValidJsonVerifier $validJsonVerifier = null,
2532
) {}
2633

2734
/**
2835
*/
2936
public function normalize (mixed $value, array $context = []) : mixed
37+
{
38+
$normalizedValue = $this->recursiveNormalize($value, $context);
39+
40+
if ($this->isDebug)
41+
{
42+
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue);
43+
}
44+
45+
return $normalizedValue;
46+
}
47+
/**
48+
*/
49+
public function normalizeArray (array $array, array $context = []) : array
50+
{
51+
$normalizedValue = $this->recursiveNormalizeArray($array, $context);
52+
53+
if ($this->isDebug)
54+
{
55+
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue);
56+
}
57+
58+
return $normalizedValue;
59+
}
60+
61+
/**
62+
* Normalizes a map of values.
63+
* Will JSON-encode to `{}` when empty.
64+
*/
65+
public function normalizeMap (array $array, array $context = []) : array|\stdClass
66+
{
67+
// return stdClass if the array is empty here, as it will be automatically normalized to `{}` in JSON.
68+
$normalizedValue = $this->recursiveNormalizeArray($array, $context) ?: new \stdClass();
69+
70+
if ($this->isDebug)
71+
{
72+
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue);
73+
}
74+
75+
return $normalizedValue;
76+
}
77+
78+
79+
80+
/**
81+
* The actual normalize logic, that recursively normalizes the value.
82+
* It must never call one of the public methods above and just normalizes the value.
83+
*/
84+
private function recursiveNormalize (mixed $value, array $context = []) : mixed
3085
{
3186
if (null === $value || \is_scalar($value))
3287
{
@@ -35,7 +90,7 @@ public function normalize (mixed $value, array $context = []) : mixed
3590

3691
if (\is_array($value))
3792
{
38-
return $this->normalizeArray($value, $context);
93+
return $this->recursiveNormalizeArray($value, $context);
3994
}
4095

4196
if (\is_object($value))
@@ -77,15 +132,17 @@ public function normalize (mixed $value, array $context = []) : mixed
77132
}
78133

79134
/**
135+
* The actual customized normalization logic for arrays, that recursively normalizes the value.
136+
* It must never call one of the public methods above and just normalizes the value.
80137
*/
81-
public function normalizeArray (array $array, array $context = []) : array
138+
private function recursiveNormalizeArray (array $array, array $context = []) : array
82139
{
83140
$result = [];
84141
$isList = array_is_list($array);
85142

86143
foreach ($array as $key => $value)
87144
{
88-
$normalized = $this->normalize($value, $context);
145+
$normalized = $this->recursiveNormalize($value, $context);
89146

90147
// if the array was a list and the normalized value is null, just filter it out
91148
if ($isList && null === $normalized)
@@ -107,14 +164,4 @@ public function normalizeArray (array $array, array $context = []) : array
107164

108165
return $result;
109166
}
110-
111-
/**
112-
* Normalizes a map of values.
113-
* Will JSON-encode to `{}` when empty.
114-
*/
115-
public function normalizeMap (array $array, array $context = []) : array|\stdClass
116-
{
117-
// return stdClass if the array is empty here, as it will be automatically normalized to `{}` in JSON.
118-
return $this->normalizeArray($array, $context) ?: new \stdClass();
119-
}
120167
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Torr\SimpleNormalizer\Normalizer\Validator;
4+
5+
/**
6+
* @final
7+
*/
8+
readonly class InvalidJsonElement
9+
{
10+
/**
11+
*/
12+
public function __construct (
13+
public mixed $value,
14+
public array $path,
15+
) {}
16+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Torr\SimpleNormalizer\Normalizer\Validator;
4+
5+
use Torr\SimpleNormalizer\Exception\IncompleteNormalizationException;
6+
7+
/**
8+
* Helper to verify that a given value is valid JSON (scalars, lists or empty objects)
9+
*
10+
* @final
11+
*/
12+
class ValidJsonVerifier
13+
{
14+
/**
15+
* Ensures that only valid JSON types are present in the value, that means scalars, arrays and empty objects.
16+
*/
17+
public function ensureValidOnlyJsonTypes (mixed $value) : void
18+
{
19+
$invalidElement = $this->findInvalidJsonElement($value);
20+
21+
if (null !== $invalidElement)
22+
{
23+
throw new IncompleteNormalizationException(
24+
sprintf(
25+
"Found a JSON-incompatible value when normalizing. Found '%s' at path '%s', but expected only scalars, arrays and empty objects.",
26+
get_debug_type($invalidElement->value),
27+
implode(".", $invalidElement->path),
28+
),
29+
);
30+
}
31+
}
32+
33+
34+
/**
35+
* Searches through the value and looks for anything that isn't valid JSON
36+
* (scalars, arrays or empty objects).
37+
*
38+
* @return InvalidJsonElement|null Returns null if everything is valid, otherwise the invalid value.
39+
*/
40+
private function findInvalidJsonElement (mixed $value, array $path = ["$"]) : ?InvalidJsonElement
41+
{
42+
// scalars are always valid
43+
if (null === $value || \is_scalar($value))
44+
{
45+
return null;
46+
}
47+
48+
// only empty stdClass objects are allowed (as they are used to serialize to `{}`)
49+
if (is_object($value))
50+
{
51+
return $value instanceof \stdClass && [] === get_object_vars($value)
52+
? null
53+
: new InvalidJsonElement($value, $path);
54+
}
55+
56+
if (is_array($value))
57+
{
58+
foreach ($value as $key => $item)
59+
{
60+
$invalidItem = $this->findInvalidJsonElement(
61+
$item,
62+
[...$path, $key],
63+
);
64+
65+
if (null !== $invalidItem)
66+
{
67+
return $invalidItem;
68+
}
69+
}
70+
71+
return null;
72+
}
73+
74+
return new InvalidJsonElement($value, $path);
75+
}
76+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Tests\Torr\SimpleNormalizer\Normalizer;
4+
5+
use Symfony\Component\DependencyInjection\ServiceLocator;
6+
use Tests\Torr\SimpleNormalizer\Fixture\DummyVO;
7+
use Torr\SimpleNormalizer\Exception\IncompleteNormalizationException;
8+
use Torr\SimpleNormalizer\Normalizer\SimpleNormalizer;
9+
use PHPUnit\Framework\TestCase;
10+
use Torr\SimpleNormalizer\Normalizer\SimpleObjectNormalizerInterface;
11+
use Torr\SimpleNormalizer\Normalizer\Validator\ValidJsonVerifier;
12+
13+
class SimpleNormalizerTest extends TestCase
14+
{
15+
/**
16+
*
17+
*/
18+
public static function provideJsonVerifierEnabled () : iterable
19+
{
20+
yield "normalize" => [
21+
static fn (SimpleNormalizer $normalizer, mixed $value) => $normalizer->normalize($value),
22+
];
23+
24+
yield "normalizeArray" => [
25+
static fn (SimpleNormalizer $normalizer, mixed $value) => $normalizer->normalizeArray($value),
26+
];
27+
28+
yield "normalizeMap" => [
29+
static fn (SimpleNormalizer $normalizer, mixed $value) => $normalizer->normalizeMap($value),
30+
];
31+
}
32+
33+
/**
34+
* @dataProvider provideJsonVerifierEnabled
35+
*/
36+
public function testJsonVerifierEnabled (callable $call) : void
37+
{
38+
$verifier = $this->createMock(ValidJsonVerifier::class);
39+
40+
$verifier
41+
->expects($this->once())
42+
->method('ensureValidOnlyJsonTypes');
43+
44+
$normalizer = new SimpleNormalizer(
45+
objectNormalizers: $this->createNormalizerObjectNormalizers("ok"),
46+
isDebug: true,
47+
validJsonVerifier: $verifier,
48+
);
49+
50+
$call($normalizer, [
51+
"nested" => [
52+
"deeply" => [
53+
"ohai" => new DummyVO(42),
54+
],
55+
"valid" => true,
56+
],
57+
]);
58+
}
59+
60+
61+
/**
62+
*
63+
*/
64+
public function testJsonVerifierDisabled () : void
65+
{
66+
$verifier = $this->createMock(ValidJsonVerifier::class);
67+
68+
$verifier
69+
->expects($this->never())
70+
->method('ensureValidOnlyJsonTypes');
71+
72+
$normalizer = new SimpleNormalizer(
73+
objectNormalizers: $this->createNormalizerObjectNormalizers("ok"),
74+
isDebug: false,
75+
validJsonVerifier: $verifier,
76+
);
77+
78+
$normalizer->normalize([
79+
"key" => new DummyVO(42),
80+
]);
81+
self::assertTrue(true); // Just to ensure the test runs without exceptions
82+
}
83+
84+
85+
/**
86+
*
87+
*/
88+
public function testInvalidNormalizer () : void
89+
{
90+
$normalizer = new SimpleNormalizer(
91+
objectNormalizers: $this->createNormalizerObjectNormalizers(new DummyVO(11)),
92+
isDebug: true,
93+
validJsonVerifier: new ValidJsonVerifier(),
94+
);
95+
96+
$this->expectException(IncompleteNormalizationException::class);
97+
$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.");
98+
$normalizer->normalize(new DummyVO(42));
99+
}
100+
101+
102+
/**
103+
*
104+
*/
105+
public function testInvalidNestedNormalizer () : void
106+
{
107+
$normalizer = new SimpleNormalizer(
108+
objectNormalizers: $this->createNormalizerObjectNormalizers(new DummyVO(11)),
109+
isDebug: true,
110+
validJsonVerifier: new ValidJsonVerifier(),
111+
);
112+
113+
$this->expectException(IncompleteNormalizationException::class);
114+
$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.");
115+
$normalizer->normalize([
116+
"nested" => [
117+
"deeply" => [
118+
"ohai" => new DummyVO(42),
119+
],
120+
"valid" => true,
121+
],
122+
]);
123+
}
124+
125+
126+
/**
127+
*
128+
*/
129+
private function createNormalizerObjectNormalizers (mixed $returnValue) : ServiceLocator
130+
{
131+
return new ServiceLocator([
132+
DummyVO::class => static fn () => new readonly class ($returnValue) implements SimpleObjectNormalizerInterface
133+
{
134+
public function __construct (
135+
private mixed $returnValue,
136+
) {}
137+
138+
public function normalize (object $value, array $context, SimpleNormalizer $normalizer) : mixed
139+
{
140+
return $this->returnValue;
141+
}
142+
143+
public static function getNormalizedType () : string
144+
{
145+
return DummyVO::class;
146+
}
147+
},
148+
]);
149+
}
150+
}

0 commit comments

Comments
 (0)