woohoolabs / yang

The efficient and elegant, PSR-7 compliant JSON:API 1.1 client library for PHP

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Best practice to hydrate to a specific class?

holtkamp opened this issue · comments

Suppose I got a simple class:

namespace My\Domain;
class Article extends \stdClass
{
    /** @var int */
    public $id;

    /** @var string */
    public $name;
}

what would be the advised approach to hydrate results from a JSON API "into" such kind of class?

Maybe we can set some "mapping information" type => FQCN in the existing ClassDocumentHydrator which could be used when assembling a new object?

$classMap = [
  'article' => \My\Domain\Article::class,
  'user' => \My\Domain\User::class,
  /* ... */
];
$hydrator = new ClassDocumentHydrator();
$hydrator->setClassMap($classMap);

and then consider the classMap in the ClassDocumentHydrator():

private function hydrateResource(ResourceObject $resource, Document $document, array &$resourceMap): object //Note the return type "was" stdClass
    {
        // Fill basic attributes of the resource
        $className = $this->classMap[$resource->type()] ?? stdClass::class;
        $result = new $className();
        $result->type = $resource->type();
        /* etc */
}

Just thinking out loud, or am I missing some existing functionality here?

Another approach might be to make the ClassDocumentHydrator extensible (drop final) and have a protected function getClassNameForResourceType(string $resourceType) : string which defaults to 'stdClass'. This would allow users to do whatever they want 🤓

Hi @holtkamp !

This is an interesting question! Before trying to answer it, could you tell me why you want to hydrate the response to child classes of stdClass? How do you make use of it?

To be honest, I just realized that I made a mistake while naming the ClassDocumentHydrator: it should have been StdClassDocumentHydrator. :) That said, it is supposed to hydrate the response into stdClasses - exclusively.

This is why this class is final. If someone needs to do something differently, they can easily create their own hydrator. Ok, probably, it is not that easy to do, but at least the ClassDocumentHydrator is there for a start.

Another solution is that I remove the final modifier, and extract some part of the logic to protected methods which then can be overridden. With this solution, end users gain some flexibility, but lose some stability - because it is now more possible that a breaking change in the internals of this hydrator will also break their app/tests/static analysis. That's what i wanted to avoid in the first place.

I am willing to go down on the second road (actually, I've just implemented it locally :) ), however, I am not sure if this is the better solution or copy-pasting the hydrator.

Thanks again for the swift and detailed answer!

could you tell me why you want to hydrate the response to child classes of stdClass?

Well, we use your JSON-API client to integrate with an application that acts in another domain than our own primary application domain. To prevent JSON-API specific concepts (such as "resource") to leak into our own (application) domain, we use a number of DomainModels (such as Article, User, Order, etc) and include this "bridge" between domains using Composer with a name like other-domain-api-client.

Additionally: when only working with stdClass, both the IDE and static analysis tools like PHPStan will complain about properties / relationships that do not exist on the objects of class stdClass. Additionally, it is easier to "read" when using explicit class names when integrating with the other domain.

Also note I only extended stdClass in the example to allow the return types to keep on working. Later on I realized we can use object as return type as of PHP 7.2, which would eliminate the need of extending stdClass.

To be honest, I just realized that I made a mistake while naming the ClassDocumentHydrator: it should have been StdClassDocumentHydrator. :) That said, it is supposed to hydrate the response into stdClasses - exclusively.

Ah, ok, that makes sense indeed. But maybe this "mistake" can now be exploited, to allow other classes as well! 😄

This is why this class is final. If someone needs to do something differently, they can easily create their own hydrator. Ok, probably, it is not that easy to do, but at least the ClassDocumentHydrator is there for a start.

I think you are right aiming for strictness, so keeping the default hydrator final would be best.

I also just implemented the second approach locally and it works great using an "resourceTypeToClassNameMapping" array : resource type => FQCN.

The following approach would be quite simple and backwards compatible:

  • add private property ClassDocumentHydrator::$resourceTypeToClassName which is an array
  • add public setter ClassDocumentHydrator::setResourceTypeToClassNameMapping(array $resourceTypeToClassNameMapping): void which allows to set the property
  • add a private getter: ClassDocumentHydrator::getApplicableClassNameForResourceType(string $resourceType): string which check the mapping property and defaults to stdClass:class

Then as soon as PHP 7.2 can be used, replace all stdClass return types to object.

I will try to come up with a small PR to demonstrate the idea.

I am on holiday now, I'll answer you when I am back. :)

To be honest, I think we misunderstood each other, sorry for that. What I meant is that I wouldn't like to accept such a customization to a general module. My reason is that even though this mapping will work for you, I am almost sure that somebody else will want it to work a little bit differently. :)

That why, instead of specializing the class, I think it will be much more useful to further generalize it. Although this solution has the cost that I won't have so much freedom from now on than before because the protected methods of the hydrators will also become part of the public API of Yang, I think it will still worth it.

Please, have a look at my PR: #23. In short, it does the following:

  • raises the minimum PHP version to 7.2
  • Adds a AbstractClassDocumentHydrator which has 3 abstract methods and type hints against the object class (thank you very much for this great idea!)
  • Changes the ClassDocumentHydrator so that it is not a final class anymore, it extends AbstractClassDocumentHydrator and it implements the 3 abstract methods mentioned before

This way, if you want a customization, you can extend either the AbstractClassDocumentHydrator or the ClassDocumentHydrator class depending how much you want to change the original behaviour. As far as I see, your use-case only needs the ClassDocumentHydrator::createObject() method to be overridden.

I hope that you'll like this solution as well, but feel free to express your concerns about it. :)

Ok, have fun :) I'll merge my PR (and in the same time, close yours) so I'd like to ask you to test the functionality when you get back. We can then fine-tune the hydrators if needed before a final release.

@kocsismate I had a look and it works really smooth.

My approach to use a "ClassMap":

declare(strict_types=1);

namespace MyDomain\Api\Hydrator;

use WoohooLabs\Yang\JsonApi\Hydrator\ClassDocumentHydrator;
use WoohooLabs\Yang\JsonApi\Schema\Resource\ResourceObject;

final class ClassMapDocumentHydrator extends ClassDocumentHydrator
{
    /** @var array<string, string> */
    private $resourceTypeToClassMapping;

    public function __construct(array $resourceTypeToClassMapping)
    {
        $this->resourceTypeToClassMapping = $resourceTypeToClassMapping;
    }

    protected function createObject(ResourceObject $resource) : object
    {
        $classname = $this->getApplicableClassname($resource);

        return is_string($classname) ? new $classname() : parent::createObject($resource);
    }

    private function getApplicableClassname(ResourceObject $resource) : ?string
    {
        return $this->resourceTypeToClassMapping[$resource->type()] ?? null;
    }
}

So +1 from me 😄

I am happy to hear! I am closing the issue as I've just released Yang 2.2 with this change. :)