Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Question: How to handle de-/serializing unions (using native type hints)? #1502

Closed
Anticom opened this issue Jul 26, 2023 · 5 comments
Closed

Comments

@Anticom
Copy link
Contributor

Anticom commented Jul 26, 2023

Q A
Bug report? no
Feature request? no
BC Break report? no
RFC? no

cc: @dgafka

According to #1330 we should have some capabilities of handling union types.

  1. Are those capabilities restricted exclusively to DocBlock annotations?
  2. If 1. is true, how would the below snippet have to be defined in order to work?
    class ClassWithUnionType
    {
        /**
         * @var string|ComplexType[] $propertyUnderTest
         */
        public string|array $propertyUnderTest;
    }
    Won't work either.
  3. Is implementing a custom UnionHandler the only/best way to get this working?
    I could imagine that especially deserialization is tricky since some pattern matching would have to be done in order to infer the actual desired type. Even then ambiguities will remain an issue if complex type definitions overlap.

I thought about implementing a custom handler for this, supporting only two arguments as a union and restricting the first argument to a primitive type to mitigate those pattern matching issues.
Would you be interested in a contribution to this project for such a restricted union support?

Steps required to reproduce the problem

class ComplexType
{
    public string $foo;
    public string $bar;
}

class ClassWithUnionType
{
    #[\JMS\Serializer\Annotation\Type('string|array<ComplexType>')]
    public string|array $propertyUnderTest;
}

$serializer = \JMS\Serializer\SerializerBuilder::create()->build();

// serialization
$simpleObject = new ClassWithUnionType();
$simpleObject->propertyUnderTest = 'foo';

$simpleJson = $serializer->serialize($simpleObject, 'json');
var_dump($simpleJson);

// ---

$complexType = new ComplexType();
$complexType->foo = 'foo';
$complexType->bar = 'bar';

$complexObject = new ClassWithUnionType();
$complexObject->propertyUnderTest = [
    $complexType,
];

$complexJson = $serializer->serialize($complexObject, 'json');
var_dump($complexJson);

// deserialization
$simpleJson = <<<JSON
{
    "property_under_test": "foo"
}
JSON;

$simpleObject = $serializer->deserialize($simpleJson, ClassWithUnionType::class, 'json');
var_dump($simpleObject);

// ---

$complexJson = <<<JSON
{
    "property_under_test": [
        {
            "foo": "foo",
            "bar": "bar"
        }
    ]
}
JSON;

$complexObject = $serializer->deserialize($complexJson, ClassWithUnionType::class, 'json');
var_dump($complexObject);

Expected Result

string(29) "{"property_under_test":"foo"}"
string(51) "{"property_under_test":[{"foo":"foo","bar":"bar"}]}"
object(ClassWithUnionType)#42 (1) {
  ["propertyUnderTest"]=>
  string(3) "foo"
}
object(ClassWithUnionType)#51 (1) {
  ["propertyUnderTest"]=>
  array(1) {
    [0]=>
    object(ComplexType)#49 (2) {
      ["foo"]=>
      string(3) "foo"
      ["bar"]=>
      string(3) "bar"
    }
  }
}

Actual Result

string(29) "{"property_under_test":"foo"}"
PHP Warning:  Array to string conversion in /path/to/vendor/jms/serializer/src/GraphNavigator/SerializationGraphNavigator.php on line 150

Warning: Array to string conversion in /path/to/vendor/jms/serializer/src/GraphNavigator/SerializationGraphNavigator.php on line 150
string(31) "{"property_under_test":"Array"}"
object(ClassWithUnionType)#55 (1) {
  ["propertyUnderTest"]=>
  string(3) "foo"
}
PHP Fatal error:  Uncaught JMS\Serializer\Exception\NonStringCastableTypeException: Cannot convert value of type "array" to string in /path/to/vendor/jms/serializer/src/AbstractVisitor.php:56
Stack trace:
#0 /path/to/vendor/jms/serializer/src/JsonDeserializationVisitor.php(58): JMS\Serializer\AbstractVisitor->assertValueCanBeCastToString(Array)
#1 /path/to/vendor/jms/serializer/src/GraphNavigator/DeserializationGraphNavigator.php(123): JMS\Serializer\JsonDeserializationVisitor->visitString(Array, Array)
#2 /path/to/vendor/jms/serializer/src/JsonDeserializationVisitor.php(188): JMS\Serializer\GraphNavigator\DeserializationGraphNavigator->accept(Array, Array)
#3 /path/to/vendor/jms/serializer/src/GraphNavigator/DeserializationGraphNavigator.php(214): JMS\Serializer\JsonDeserializationVisitor->visitProperty(Object(JMS\Serializer\Metadata\PropertyMetadata), Array)
#4 /path/to/vendor/jms/serializer/src/Serializer.php(256): JMS\Serializer\GraphNavigator\DeserializationGraphNavigator->accept(Array, Array)
#5 /path/to/vendor/jms/serializer/src/Serializer.php(182): JMS\Serializer\Serializer->visit(Object(JMS\Serializer\GraphNavigator\DeserializationGraphNavigator), Object(JMS\Serializer\JsonDeserializationVisitor), Object(JMS\Serializer\DeserializationContext), Array, 'json', Array)
#6 /path/to/union.php(59): JMS\Serializer\Serializer->deserialize('{\n    "property...', 'ClassWithUnionT...', 'json')
#7 {main}
  thrown in /path/to/vendor/jms/serializer/src/AbstractVisitor.php on line 56

Fatal error: Uncaught JMS\Serializer\Exception\NonStringCastableTypeException: Cannot convert value of type "array" to string in /path/to/vendor/jms/serializer/src/AbstractVisitor.php:56
Stack trace:
#0 /path/to/vendor/jms/serializer/src/JsonDeserializationVisitor.php(58): JMS\Serializer\AbstractVisitor->assertValueCanBeCastToString(Array)
#1 /path/to/vendor/jms/serializer/src/GraphNavigator/DeserializationGraphNavigator.php(123): JMS\Serializer\JsonDeserializationVisitor->visitString(Array, Array)
#2 /path/to/vendor/jms/serializer/src/JsonDeserializationVisitor.php(188): JMS\Serializer\GraphNavigator\DeserializationGraphNavigator->accept(Array, Array)
#3 /path/to/vendor/jms/serializer/src/GraphNavigator/DeserializationGraphNavigator.php(214): JMS\Serializer\JsonDeserializationVisitor->visitProperty(Object(JMS\Serializer\Metadata\PropertyMetadata), Array)
#4 /path/to/vendor/jms/serializer/src/Serializer.php(256): JMS\Serializer\GraphNavigator\DeserializationGraphNavigator->accept(Array, Array)
#5 /path/to/vendor/jms/serializer/src/Serializer.php(182): JMS\Serializer\Serializer->visit(Object(JMS\Serializer\GraphNavigator\DeserializationGraphNavigator), Object(JMS\Serializer\JsonDeserializationVisitor), Object(JMS\Serializer\DeserializationContext), Array, 'json', Array)
#6 /path/to/union.php(59): JMS\Serializer\Serializer->deserialize('{\n    "property...', 'ClassWithUnionT...', 'json')
#7 {main}
  thrown in /path/to/vendor/jms/serializer/src/AbstractVisitor.php on line 56

Aside:
GitHub Discussions may be a perfect fit for those kind of questions. Is there any chance we get those enabled for this repo?

@dgafka
Copy link
Contributor

dgafka commented Jul 26, 2023

Hello,
Serialization should work in this scenario, as JMS will know what type of reference is serialized object holding.
However for the deserialization JMS can't know which type it should pick for deserialization, therefore it will fail.

You can set up Type Annotation to state what kind of type it should deserialize too however.

@Anticom
Copy link
Contributor Author

Anticom commented Jul 27, 2023

Hi, thanks for the quick reply!

You can set up Type Annotation to state what kind of type it should deserialize too however.

I don't quite understand how I'm supposed to declare that union using type annotations other than what I've tried. Can you please elaborate on this?


Background:

I'm working on an API client for a 3rd party vendor API I don't have any control over.
They have implemented a mechanism, that allows you to "expand" certain fields in the response. If those fields are not expanded, they have a string value. If they are expanded they contain an array of complex types.

Example

Request:

GET /api/foo

Response:

{
    "bar": "bar is collapsed, you can expand it using ?expand=bar"
}

Request:

GET /api/foo?expand=bar

Response:

{
    "bar": [
        { "id": 1, "message": "Now suddenly" },
        { "id": 2, "message": "bar is an array" },
        { "id": 3, "message": "of objects" },
    ]
}

Unfortunately I can't solve this by having just a second method on my client since there's multiple fields that can be "expanded" that way.

To be precise we're talking about 6 fields.
That would add up to 2^6=64 possible combinations of whether to expand certain fields or not which is unfeasible bloat and complexity in the client implementation.

@scyzoryck
Copy link
Collaborator

Hello!

Looking at the code it looks like as of 3.26 version this package has limited support.

  • It does not work with Type annotation - works only with typed properties
  • It does not work with deserialisation.
    To solve your problem I would suggest:
  1. Create some fake type like:
class ClassWithUnionType
{
    #[\JMS\Serializer\Annotation\Type('expandable<ComplexType>')]
    public string|array $propertyUnderTest;
}
  1. Register custom handler for your type that will check if your data is a string or array of objects. Inside you will get access to DeserializationVisitorInterface, and type, so you will be able to deserialise it to array of objects or to string.

@Anticom
Copy link
Contributor Author

Anticom commented Aug 2, 2023

Hi!

Sorry for the late reply.
What I don't understand is how I can delegate to other handlers (or whatever the correct terminology is here).

Currently I'm declaring the expandable types as #[Type('expandable<array<ComplexType>>')] which is giving me the type argument in my handler fn with the following shape (as expected):

$type = [
    'name' => 'expandable',
    'params' => [
        [
            'name' => 'array',
            'params' => [
                [
                    'name' => 'ComplexType',
                    'params' => [],
                ],
            ],
        ],
    ],
];

Now what I'd like to do is (pseudo code):

class ExpandableHandler
{
    // ...

    public function deserializeExpandableFromJson(JsonDeserializationVisitor $visitor, $expandableData, array $type, Context $context)
    {
        // since in the collapsed state, $expandableData is guaranteed to be a string, we can return it without further processing
        if ($this->isCollapsed($expandableData)) {
            return $expandableData;
        }

        // delegate back with an unwrapped type
        return $this->whatMethodToCallHere($visitor, $expandableData, $type['params'][0], $context);
        //                                                            ^
        //                                                            Basically "unwrap" the expandable type here
    }
}

Aside: The reason I'm declaring the type with an explicit array is, that I've found out that certain expandable fields won't expand into an array of complex types but a singular complex type instead.

@Anticom
Copy link
Contributor Author

Anticom commented Aug 2, 2023

Nevermind, I've figured it out looking at the EnumHandler.php

Thanks for the support guys!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants