Skip to content

Commit e60f0d3

Browse files
committed
feat: A callback for reporting unexpected enum values
1 parent 2bf7891 commit e60f0d3

File tree

6 files changed

+120
-15
lines changed

6 files changed

+120
-15
lines changed

README.md

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,9 @@ to use in order of priority:
137137

138138
```php
139139
(new SerializerBuilder())
140-
->addMapperLast(new TestMapper()) // then this one
141-
->addFactoryLast(new TestFactory()) // and this one last
142-
->addFactory(new TestFactory()) // attempted first
140+
->addMapperLast(new TestMapper()) // #2 - then this one
141+
->addFactoryLast(new TestFactory()) // #3 - and this one last
142+
->addFactory(new TestFactory()) // #1 - attempted first
143143
```
144144

145145
A factory has the following signature:
@@ -205,11 +205,11 @@ methods: `serialize` and `deserialize`. They do exactly what they're called.
205205

206206
## Naming of keys
207207

208-
By default serializer preserves the naming of keys, but this is easily customizable (in order of priority):
208+
By default, serializer preserves the naming of keys, but this is easily customizable (in order of priority):
209209

210210
- specify a custom property name using the `#[SerializedName]` attribute
211211
- specify a custom naming strategy per class using the `#[SerializedName]` attribute
212-
- specify a custom global naming strategy (use one of the built in or write your own)
212+
- specify a custom global (default) naming strategy (use one of the built-in or write your own)
213213

214214
Here's an example:
215215

@@ -235,7 +235,7 @@ class Item2 {
235235
```
236236

237237
Out of the box, strategies for `snake_case`, `camelCase` and `PascalCase` are provided,
238-
but you it's trivial to implement your own:
238+
but it's trivial to implement your own:
239239

240240
```php
241241
class PrefixedNaming implements NamingStrategy {
@@ -255,7 +255,7 @@ class SiftTrackData {}
255255

256256
## Required, nullable, optional and default values
257257

258-
By default if a property is missing in serialized payload:
258+
By default, if a property is missing in serialized payload:
259259

260260
- nullable properties are just set to null
261261
- properties with a default value - use the default value
@@ -319,6 +319,51 @@ $adapter->serialize(
319319
);
320320
```
321321

322+
## Use default value for unexpected
323+
324+
There are situations where you're deserializing data from a third party that doesn't have an API documentation
325+
or one that can't keep a backwards compatibility promise. One such case is when a third party uses an enum
326+
and you expect that new enum values might get added in the future by them. For example, imagine this structure:
327+
328+
```php
329+
enum CardType: string
330+
{
331+
case CLUBS = 'clubs';
332+
case DIAMONDS = 'diamonds';
333+
case HEARTS = 'hearts';
334+
case SPADES = 'spades';
335+
}
336+
337+
readonly class Card {
338+
public function __construct(
339+
public CardType $type,
340+
public string $value,
341+
) {}
342+
}
343+
```
344+
345+
If you get an unexpected value for `type`, you'll get an exception:
346+
347+
```php
348+
// UnexpectedEnumValueException: Expected one of [clubs, diamonds, hearts, spades], but got 'joker'
349+
$adapter->deserialize('{"type": "joker"}');
350+
```
351+
352+
So if you suspect that might happen, add a default value you wish to use (anything) and
353+
a `#[UseDefaultForUnexpected]` attribute:
354+
355+
```php
356+
readonly class Card {
357+
public function __construct(
358+
#[UseDefaultForUnexpected]
359+
public CardType $type = null,
360+
// Can be any other valid default value
361+
#[UseDefaultForUnexpected]
362+
public CardType $type2 = CardType::SPADES,
363+
) {}
364+
}
365+
```
366+
322367
## Error handling
323368

324369
This is expected to be used with client-provided data, so good error descriptions is a must.
@@ -369,9 +414,9 @@ There are some alternatives to this, but all of them will lack at least one of t
369414

370415
- doesn't rely on inheritance, hence allows serializing third-party classes
371416
- parses existing PHPDoc information instead of duplicating it through attributes
372-
- supports generic types which are extremely useful for wrapper types
417+
- supports generic types which are quite useful for wrapper types
373418
- allows simple extension through mappers and complex stuff through type adapters
374419
- produces developer-friendly error messages for invalid data
375-
- correctly handles optional (missing keys) and `null` values as separate concepts
420+
- correctly handles optional (missing keys) and `null` values as separate concerns
376421
- simple to extend with additional formats
377-
- simple internal structure: no node tree, no value wrappers, no PHP parsing, no inherent limitations
422+
- simple internal structure: no node tree, no value/JSON wrappers, no custom reflection / PHP parsing, no inherent limitations

src/SerializerBuilder.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use GoodPhp\Serialization\Serializer\Registry\Cache\MemoizingTypeAdapterRegistry;
1010
use GoodPhp\Serialization\Serializer\Registry\Factory\FactoryTypeAdapterRegistryBuilder;
1111
use GoodPhp\Serialization\Serializer\TypeAdapterRegistrySerializer;
12+
use GoodPhp\Serialization\TypeAdapter\Exception\UnexpectedValueException;
1213
use GoodPhp\Serialization\TypeAdapter\Json\FromPrimitiveJsonTypeAdapterFactory;
1314
use GoodPhp\Serialization\TypeAdapter\Primitive\BuiltIn\ArrayMapper;
1415
use GoodPhp\Serialization\TypeAdapter\Primitive\BuiltIn\BackedEnumMapper;
@@ -19,6 +20,7 @@
1920
use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Naming\BuiltInNamingStrategy;
2021
use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Naming\NamingStrategy;
2122
use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Naming\SerializedNameAttributeNamingStrategy;
23+
use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Property\BoundClassProperty;
2224
use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Property\BoundClassPropertyFactory;
2325
use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Property\DefaultBoundClassPropertyFactory;
2426
use GoodPhp\Serialization\TypeAdapter\Primitive\Illuminate\CollectionMapper;
@@ -44,6 +46,9 @@ final class SerializerBuilder
4446

4547
private ?MapperMethodFactory $mapperMethodFactory = null;
4648

49+
/** @var callable(BoundClassProperty<object>, UnexpectedValueException): void|null */
50+
private mixed $reportUnexpectedDefault = null;
51+
4752
public function withReflector(Reflector $reflector): self
4853
{
4954
Assert::null($this->reflector, 'You must set the reflector before adding any mappers or factories.');
@@ -126,6 +131,19 @@ public function addMapperLast(object $adapter): self
126131
return $that;
127132
}
128133

134+
/**
135+
* Define a callback for cases where a default value is substituted when using #[UseDefaultForUnexpected]
136+
*
137+
* @param callable(BoundClassProperty<object>, UnexpectedValueException): void|null $callback
138+
*/
139+
public function reportUnexpectedDefault(?callable $callback): self
140+
{
141+
$that = clone $this;
142+
$that->reportUnexpectedDefault = $callback;
143+
144+
return $that;
145+
}
146+
129147
public function build(): Serializer
130148
{
131149
$typeAdapterRegistryBuilder = $this->typeAdapterRegistryBuilder()
@@ -139,7 +157,9 @@ public function build(): Serializer
139157
->addFactoryLast(new ClassPropertiesPrimitiveTypeAdapterFactory(
140158
new SerializedNameAttributeNamingStrategy($this->namingStrategy ?? BuiltInNamingStrategy::PRESERVING),
141159
$this->hydrator ?? new ConstructorHydrator(),
142-
$this->boundClassPropertyFactory ?? new DefaultBoundClassPropertyFactory(),
160+
$this->boundClassPropertyFactory ?? new DefaultBoundClassPropertyFactory(
161+
$this->reportUnexpectedDefault,
162+
),
143163
))
144164
->addFactoryLast(new FromPrimitiveJsonTypeAdapterFactory());
145165

src/TypeAdapter/Primitive/ClassProperties/Property/DefaultBoundClassProperty.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@
2626
final class DefaultBoundClassProperty implements BoundClassProperty
2727
{
2828
/**
29-
* @param PropertyReflection<T, HasProperties<T>> $property
30-
* @param TypeAdapter<mixed, mixed> $typeAdapter
29+
* @param PropertyReflection<T, HasProperties<T>> $property
30+
* @param TypeAdapter<mixed, mixed> $typeAdapter
31+
* @param callable(BoundClassProperty<object>, UnexpectedValueException): void|null $reportUnexpectedDefault
3132
*/
3233
public function __construct(
3334
private readonly PropertyReflection $property,
@@ -37,6 +38,7 @@ public function __construct(
3738
private readonly bool $hasDefaultValue,
3839
private readonly bool $nullable,
3940
private readonly bool $useDefaultForUnexpected,
41+
private readonly mixed $reportUnexpectedDefault = null,
4042
) {
4143
if ($this->useDefaultForUnexpected) {
4244
Assert::true($this->hasDefaultValue, 'When using #[UseDefaultForUnexpected], the property must have a default value.');
@@ -92,6 +94,10 @@ public function deserialize(array $data): array
9294
];
9395
} catch (UnexpectedValueException $e) {
9496
if ($this->useDefaultForUnexpected) {
97+
if ($this->reportUnexpectedDefault !== null) {
98+
($this->reportUnexpectedDefault)($this, $e);
99+
}
100+
95101
return [];
96102
}
97103

src/TypeAdapter/Primitive/ClassProperties/Property/DefaultBoundClassPropertyFactory.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use GoodPhp\Reflection\Type\Type;
1111
use GoodPhp\Serialization\MissingValue;
1212
use GoodPhp\Serialization\Serializer;
13+
use GoodPhp\Serialization\TypeAdapter\Exception\UnexpectedValueException;
1314
use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Property\Flattening\Flatten;
1415
use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Property\Flattening\FlatteningBoundClassProperty;
1516
use Webmozart\Assert\Assert;
@@ -18,8 +19,12 @@ class DefaultBoundClassPropertyFactory implements BoundClassPropertyFactory
1819
{
1920
private readonly NamedType $missingValueType;
2021

21-
public function __construct()
22-
{
22+
/**
23+
* @param callable(BoundClassProperty<object>, UnexpectedValueException): void|null $reportUnexpectedDefault
24+
*/
25+
public function __construct(
26+
private readonly mixed $reportUnexpectedDefault = null,
27+
) {
2328
$this->missingValueType = new NamedType(MissingValue::class);
2429
}
2530

@@ -49,6 +54,7 @@ public function create(
4954
hasDefaultValue: $this->hasDefaultValue($property),
5055
nullable: $type instanceof NullableType,
5156
useDefaultForUnexpected: $property->attributes()->has(UseDefaultForUnexpected::class),
57+
reportUnexpectedDefault: $this->reportUnexpectedDefault,
5258
);
5359
}
5460

tests/Integration/JsonSerializationTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Tests\Stubs\BackedEnumStub;
2424
use Tests\Stubs\ClassStub;
2525
use Tests\Stubs\NestedStub;
26+
use Tests\Stubs\UseDefaultStub;
2627
use Tests\Stubs\ValueEnumStub;
2728
use Throwable;
2829

@@ -335,6 +336,18 @@ public static function deserializesProvider(): iterable
335336
),
336337
'{"primitive":1,"nested":{},"date":"2020-01-01T00:00:00.000000Z","carbonImmutable":"2020-01-01T00:00:00.000000Z"}',
337338
];
339+
340+
yield '#[UseDefaultForUnexpected] with unexpected values' => [
341+
new NamedType(UseDefaultStub::class),
342+
new UseDefaultStub(),
343+
'{"null":"unknown value","enum":"also unknown"}',
344+
];
345+
346+
yield '#[UseDefaultForUnexpected] with expected values' => [
347+
new NamedType(UseDefaultStub::class),
348+
new UseDefaultStub(BackedEnumStub::ONE, BackedEnumStub::TWO),
349+
'{"null":"one","enum":"two"}',
350+
];
338351
}
339352

340353
/**

tests/Stubs/UseDefaultStub.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace Tests\Stubs;
4+
5+
use GoodPhp\Serialization\TypeAdapter\Primitive\ClassProperties\Property\UseDefaultForUnexpected;
6+
7+
class UseDefaultStub
8+
{
9+
public function __construct(
10+
#[UseDefaultForUnexpected]
11+
public ?BackedEnumStub $null = null,
12+
#[UseDefaultForUnexpected]
13+
public BackedEnumStub $enum = BackedEnumStub::ONE,
14+
) {}
15+
}

0 commit comments

Comments
 (0)