Reputation: 20201
I seem to have a bit of a misunderstanding of PHP's Typed properties and uninitialized
state.
Imagine that a REST-like service gets the following JSON object:
{
"firstName": "some name",
"lastName": "some last name",
"groupId": 0,
"dateOfBirth": "2000-01-01"
}
I would much prefer to have a DTO look something like this:
class Person {
private string $firstName;
private string $lastName;
private int $groupId;
private \DateTime $dateOfBirth;
// All the getters/setters, cannot have __construct due to serializer limitation
}
However, since any of these message properties may be omitted (by mistake, not a valid case), my deserialization would leave some of the fields in an unintialized
state.
So, this sucks. I guess I have a couple of options:
null
able and initialize to null
(yuck)null
(declare them null
able for that purpose).Let's say I went for option #2:
class Person {
private string $firstName = '';
private string $lastName = '';
private int $groupId = 0;
private ?\DateTime $dateOfBirth = null;
// The rest
}
In a different part of the code, I have something like this:
function doSomethingWithDate(\DateTime $dateTime): string{
return ...; // does not really matter
}
...
doSomethingWithDate($person->getDateOfBirth());
...
My IDE screams with the warning:
Expected parameter of type '\DateTime', '\DateTime|null' provided
It is obvious why - getter says "hey, I can be nullable", but the method says "no, no" to that.
But what should I do to "convince" it that this is a valid scenario?
Should I have a separate set of DTOs - one for an unsafe state and another for a safe? Seems unlikely...
How would you approach this "problem"?
As much as my question sounded vague and weird (I know it did :D), I'd like to elaborate on it a bit.
failed
number of attempts it tried to push it to the failed
queue, but it could not due to the serialization of uninitialized properties.Upvotes: 2
Views: 1779
Reputation: 4494
(This answer is specific to the Symfony Serializer component and PHP 8.1+.)
Ensuring that incoming data adheres to a certain contract is certainly a good thing to enforce. I also like my property types to be as strict as possible, and I also hate it when PhpStorm yells at me.
Imagine we'd have this DTO:
class Dto1 {
private string $foo;
public function setFoo(string $foo): void { $this->foo = $foo; }
public function getFoo(): string { return $this->foo; }
}
You'd probably deserialize it like so:
$dto = $serializer->deserialize($json, Dto1::class, 'json', [
AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
]);
(Setting ALLOW_EXTRA_ATTRIBUTES
to false ensures that the legacy system cannot sneak in extra properties)
As you noticed, we will now have an issue when the JSON is missing the $foo
property:
$json = '{}';
$dto = $serializer->deserialize($json, Dto1::class, 'json', [
AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
]);
var_dump($dto->foo); // Oops! Uninitialized property access
Unfortunately, there does not seem to be a way to have the Symfony Serializer check for uninitialized properties after deserialization. That leaves us with two other options to tackle this problem.
Ensure that all properties are initialized after deserialization. This probably requires writing a function that looks somewhat like this:
function ensureInitialized(object $o): void {
// There are probably more robust ways to do this,
// this is just an example.
$reflectionClass = new ReflectionClass($o);
foreach ($reflectionClass->getProperties() as $reflectionProperty) {
if (!$reflectionProperty->isInitialized($o)) {
throw new RuntimeException('Uninitialized properties!');
}
}
}
We can use this function to ensure that the deserialized DTO is valid:
$json = '{}';
$dto = $serializer->deserialize($json, Dto1::class, 'json', [
AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
]);
ensureInitialized($dto); // <-- throws exception
However, I would much rather avoid having to check every deserialized DTO. I prefer the next solution.
Since you mentioned you were using PHP 8.1, we can use constructor property promotion and readonly properties for our DTOs.
class Dto2 {
public function __construct(
public readonly string $foo,
) {}
}
We can still deserialize our JSON like normal:
$json = '{"foo": "bar"}';
$dto = $serializer->deserialize($json, Dto2::class, 'json', [
AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
]);
var_dump($dto->foo); // string(3) "bar"
But if the legacy system attempts to trick us again:
$json = '{}';
$dto = $serializer->deserialize($json, Dto2::class, 'json', [
AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
]);
// ^ Will throw: Uncaught Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException: Cannot create an instance of "B" from serialized data because its constructor requires parameter "foo" to be present
You can now simply catch this exception and return a 4XX error as appropriate.
Upvotes: 1