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?
- Currently the ClassDocumentHydrator is marked as
final
so it is not possible to extend - Creating a new Hydrator which implements the DocumentHydratorInterface is possible, but seems a bit overkill just to "use" a specific 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 stdClass
es - 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 tostdClass: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 3abstract
methods and type hints against theobject
class (thank you very much for this great idea!) - Changes the
ClassDocumentHydrator
so that it is not afinal
class anymore, it extendsAbstractClassDocumentHydrator
and it implements the 3abstract
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. :)