Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Symfony Serializer is inconsistent in deserialization #57546

Copy link
Copy link
Closed as not planned
Closed as not planned
Copy link
@idbentley

Description

@idbentley
Issue body actions

Symfony version(s) affected

7.2 and earlier

Description

The Symfony Serializer is inconsistent in it's handling of types depending on if they are in an object, or not.

This ticket is a followup to: #57432

There are major differences in deserialization behaviour between values passed to deserialize directly versus when handling properties of Objects.

In 57432 we see that the type specification syntax is different when calling deserialize, versus properties on an object.

However, this inconsistency goes even further. In the reproduction seen hereafter, we see that union types are unsupported when passed to deserialize directly, versus when a property on an object contains a union.

Example 1 - demonstrating Union support:

class ObjectOne
{
    /**
     * $foo
     *
     * @var int
     */
    public int $foo;

    public function __construct(int $foo)
    {
        $this->foo = $foo;
    }
}

class ObjectTwo
{
    /**
     * $bar
     *
     * @var int
     */
    public int $bar;

    public function __construct(int $bar)
    {
        $this->bar = $bar;
    }
}

class UnionWrapper
{
    /**
     * $foo
     *
     * @var ObjectOne|ObjectTwo
     */
    public ObjectOne|ObjectTwo $obj;

    public function __construct(ObjectOne|ObjectTwo $obj)
    {
        $this->obj = $obj;
    }

}

$bodyTopLevel = '{
    "foo": 1
  }';

$bodyObject = '{
    "obj": {
        "foo": 1
    }
  }';

$phpDocExtractor = new PhpDocExtractor();
$propertyTypeExtractor = new PropertyInfoExtractor(
    typeExtractors: [$phpDocExtractor],
);
$classMetadataFactory = new ClassMetadataFactory(new AttributeLoader());
$encoders = [new JsonEncoder()];
$normalizers = [
    new ArrayDenormalizer(),
    new ObjectNormalizer(propertyTypeExtractor: $propertyTypeExtractor, classMetadataFactory: $classMetadataFactory),
];

$serializer = new Serializer($normalizers, $encoders);

$responseObject = $serializer->deserialize($bodyObject, 'UnionWrapper', 'json', [AbstractNormalizer::REQUIRE_ALL_PROPERTIES => true]);
var_dump($responseObject);
/*
object(UnionWrapper)#163 (1) {
  ["obj"]=>
  object(ObjectOne)#194 (1) {
    ["foo"]=>
    int(1)
  }
}
*/

$responseTopLevel = $serializer->deserialize($bodyTopLevel, 'ObjectOne|ObjectTwo', 'json', [AbstractNormalizer::REQUIRE_ALL_PROPERTIES => true]);
// Uncaught Symfony\Component\Serializer\Exception\NotNormalizableValueException: Could not denormalize object of type "ObjectOne|ObjectTwo", no supporting normalizer found

A user of the API can support Union's passed directly to the deserializer by providing their own UnionDenormalizer

final class UnionDenormalizer implements DenormalizerAwareInterface, DenormalizerInterface
{
    use DenormalizerAwareTrait;

    public function setDenormalizer(DenormalizerInterface $denormalizer): void
    {
        $this->denormalizer = $denormalizer;
    }

    public function getSupportedTypes(?string $format): array
    {
        return ['*' => true];
    }

    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
    {
        if ($this->denormalizer === null) {
            throw new \BadMethodCallException(sprintf('The nested denormalizer needs to be set to allow "%s()" to be used.', __METHOD__));
        }
        if (str_contains($type, '|')) {
            $possibleTypes = explode('|', $type);
            $support = true;

            // all possible types must be supported
            foreach ($possibleTypes as $possibleType) {
                $typeSupport = $this->denormalizer->supportsDenormalization($data, $possibleType, $format, $context);
                $support = $support && $typeSupport;
            }
            return $support;
        }

        return false;
    }

    /** @phpstan-ignore-next-line */
    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
    {
    // ...snip...
    }
}

But this just brings up another deserialization inconsistency, because any Unions that are properties of objects will use the implementation that's buried in the AbstractObjectNormalizer, while Unions passed directly to deserialize may use the UnionDenormalizer class. Similarly (this is related to 57432, but not captured in that ticket) array handling when calling the deserialize function will be handled by the ArrayDenormalizer (if it's provided to the serializer), while arrays that are embedded in an object, will use the implementation in AbstractObjectNormalizer - those implementations may be identical, but it's certainly not clear.

How to reproduce

See above

Possible Solution

My proposed solution here is to provide an alternative operation mode for the ObjectNormalizer that bypasses the implementations in AbstractObjectNormalizer for special types, and instead uses ObjectNormalizer as a recursive DenormalizerAwareInterface. Each property on the object is denormalized by invoking the appropriate denormalizer as passed to the Serializer constructor. I've prepared a Pull Request that shows a proof of concept of this implementation. I would like to get feedback from your team before I invest more time in pursuing this approach.

Additional Context

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      Morty Proxy This is a proxified and sanitized view of the page, visit original site.