Core: Using Serialization Groups with DTO

Created on 12 Sep 2019  路  6Comments  路  Source: api-platform/core

Hello,

Is it possible to use Serialization Groups with DTO?

Example:

<?php
// api/src/Dto/BookOutput.php

namespace App\Dto;

use App\Entity\Author;
use Symfony\Component\Serializer\Annotation\Groups;

class BookOutput
{
    private $id;

    private $title;

    /**
     * @var Author
     *
     * @Groups({"read"})
     */
    private $author;

    // ...
}
<?php
// api/src/Entity/Author.php

namespace App\Entity;

use Symfony\Component\Serializer\Annotation\Groups;

class Author
{
    /**
     * @Groups({"read"})
     */
    private $id;

    /**
     * @Groups({"read"})
     */
    private $name;

    private $email;

    private $phoneNumber;

    // ...
}

Thank you for your help 馃槂

Most helpful comment

It should work though, Output class goes through the serializer and we're even using them in our tests: https://github.com/api-platform/core/search?q=path%3Atests%2FFixtures%2FTestBundle%2FDto+groups&unscoped_q=path%3Atests%2FFixtures%2FTestBundle%2FDto+groups I'd like to reproduce this.

All 6 comments

Short answer: No

The Output is not here to know your object's context. (and doesn't know it).
Someone once told me "a DataTransformer is here to transform your whole data structure"

From my current experience, the best you can achieve is handling your context's conditions in your DataTransformer class to only set the properties awaited for your current context.

Or having one Output class per context/operation/...

It should work though, Output class goes through the serializer and we're even using them in our tests: https://github.com/api-platform/core/search?q=path%3Atests%2FFixtures%2FTestBundle%2FDto+groups&unscoped_q=path%3Atests%2FFixtures%2FTestBundle%2FDto+groups I'd like to reproduce this.

Hi, just wanted to add that I am currently struggling with this myself. I have dtos working just fine with transformer classes to/from - all good there. However for the read model, specifically associations, they are being serialised as JSON objects (rather than IRIs)

It feels like I shouldn't have to do both serialisation groups AND dtos. Options as I see them:

  1. Generate the IRIs myself in the transformer (potentially lose json-ld info + feels like wrong place for this)
  2. Add serialisation groups to all properties in the dto (feels like duplicating the whole point of using dto)
  3. Decorate item normaliser, e.g., any entity that is not the 'root' should be an iri (feels like this rule is too strict)

Any thoughts on best approach here?

For anyone reading in the future, I've solved above with what I think is probably a good trade-off..

So, for some reason, when using dtos, the default behaviour or API platform (to use IRIs for related resources) is not applied. Instead, related entities are always fully expanded. However, when using serialization groups (i.e., 'Groups' annotations on each property of the DTO), alongside 'normalizationContext'/'denormalizationContext' settings on the entity (inside the ApiResource annotation) we are able to restore the desired behaviour.

The downside to above is that your dto's end up being cluttered with noisy Groups annotations, so they go from this:

class ContactOutput
{
    public int $id;
    public ?string $title;
    public ?string $forename;
    public ?string $middleName;
    public ?string $surname;
    public ?string $fullName;
    public ?string $addressLine1;
    public ?string $addressLine2;

to this

class ContactOutput
{
    /**
     * @Groups({"contact:read"})
     */
    public int $id;

    /**
     * @Groups({"contact:read"})
     */
    public ?string $title;

    /**
     * @Groups({"contact:read"})
     */
    public ?string $forename;

    /**
     * @Groups({"contact:read"})
     */
    public ?string $middleName;

    /**
     * @Groups({"contact:read"})
     */
    public ?string $surname;

So my solution was to implement a new 'helper' Symfony serialization annotation loader service named 'DefaultPropertyGroups' and inject it into the 'serializer.mapping.chain_loader' service

<?php declare(strict_types=1);

namespace App\Infrastructure\Symfony\Serialization;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use ACME\AnnotationLoader;

class AddCustomSerializerLoader implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        // Get array of loaders
        $chainLoader = $container->getDefinition('serializer.mapping.chain_loader');
        $serializerLoaders = $chainLoader->getArgument(0);

        // Inject my new one
        $annotationLoader = new Definition(
            // BELOW IS MY NEW CLASS
            AnnotationLoader::class,
            [new Reference('annotation_reader')]
        );
        $annotationLoader->setPublic(false);
        $serializerLoaders[] = $annotationLoader;
        $chainLoader->replaceArgument(0, $serializerLoaders);
    }
}

And the annotation loader (note that I apply the default group to both properties as well as methods

<?php declare(strict_types=1);

namespace App\Infrastructure\Symfony\Serialization;

use Doctrine\Common\Annotations\Reader;
use Symfony\Component\Serializer\Mapping\AttributeMetadata;
use Symfony\Component\Serializer\Mapping\ClassMetadataInterface;
use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface;

class AnnotationLoader implements LoaderInterface
{
    private Reader $reader;

    public function __construct(Reader $reader)
    {
        $this->reader = $reader;
    }

    /**
     * @inheritDoc
     */
    public function loadClassMetadata(ClassMetadataInterface $classMetadata): void
    {
        $reflectionClass = $classMetadata->getReflectionClass();
        foreach ($this->reader->getClassAnnotations($reflectionClass) as $annotation) {
            if ($annotation instanceof DefaultPropertyGroups) {
                $this->setGroupOnAllProperties($classMetadata, $annotation);
                $this->setGroupsOnAllMethods($classMetadata, $annotation);
            }
        }
    }

    /**
     * @param ClassMetadataInterface $classMetadata
     * @param DefaultPropertyGroups $annotation
     */
    protected function setGroupOnAllProperties(ClassMetadataInterface $classMetadata, DefaultPropertyGroups $annotation): void
    {
        $reflectionClass = $classMetadata->getReflectionClass();
        $attributesMetadata = $classMetadata->getAttributesMetadata();
        foreach ($reflectionClass->getProperties() as $property) {
            foreach ($annotation->getGroups() as $group) {
                $attributesMetadata[$property->name]->addGroup($group);
            }
        }
    }

    /**
     * @param ClassMetadataInterface $classMetadata
     * @param DefaultPropertyGroups $annotation
     */
    protected function setGroupsOnAllMethods(
        ClassMetadataInterface $classMetadata,
        DefaultPropertyGroups $annotation
    ): void {
        $reflectionClass = $classMetadata->getReflectionClass();
        $className = $reflectionClass->name;
        $attributesMetadata = $classMetadata->getAttributesMetadata();
        foreach ($reflectionClass->getMethods() as $method) {
            if ($method->getDeclaringClass()->name !== $className) {
                continue;
            }
            $accessorOrMutator = preg_match('/^(get|is|has|set)(.+)$/i', $method->name, $matches);
            if ($accessorOrMutator) {
                $attributeName = lcfirst($matches[2]);
                if (isset($attributesMetadata[$attributeName])) {
                    $attributeMetadata = $attributesMetadata[$attributeName];
                } else {
                    $attributesMetadata[$attributeName] = $attributeMetadata = new AttributeMetadata($attributeName);
                    $classMetadata->addAttributeMetadata($attributeMetadata);
                }
                foreach ($annotation->getGroups() as $group) {
                    $attributeMetadata->addGroup($group);
                }
            }
        }
    }
}

Now I can just do this in my DTOs

/**
 * @DefaultPropertyGroups({"contact:read"})
 */
class ContactOutput
{
    public int $id;
    public ?string $title;
    public ?string $forename;
    public ?string $middleName;
    public ?string $surname;
    public ?string $fullName;
    public ?string $addressLine1;
    public ?string $addressLine2;

To summarise, I think my earlier question assumed that dtos should have been a full replacement for serialisation groups. Now I don't think this is the case. The serialiser is excellent at recursively deserialising relations (and sometimes this is what we want to do if say, we want some entity to only exist and be presented as part of some other entity). Groups give us the flexibility to do that without having to add a lot more logic into our entity -> DTO transformer logic.

With this approach, I can just plop entity relations into the DTO as the entity type and the serialiser will auto-convert to IRIs (or expanded elements) depending on the API Resource annotation configuration on the root entity. It does feel a little weird pushing entities on to my DTO but I'm pretty happy now with the result. Future work might be to tweak the serialiser loader to add 'GroupException' annotation (i.e., for cases where I want to enforce "admin:read" only group on a privileged contact property)

Final note just to say that I had to add a new EntityToIriNormalizer class to ensure that relations were being represented by IRIs and not an empty array (in the case of JSON format type). Accomplished using this code:

<?php declare(strict_types=1);

namespace App\Api\Common;

use ApiPlatform\Core\Api\IriConverterInterface;
use Doctrine\Common\Persistence\Proxy;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;

use function count;
use function is_array;

class EmptyRelationToIriNormalizer implements NormalizerInterface
{
    private ObjectNormalizer $objectNormalizer;
    private IriConverterInterface $iriConverter;
    private EntityManagerInterface $entityManager;
    private array $currentlyChecking;

    public function __construct(
        ObjectNormalizer $objectNormalizer,
        IriConverterInterface $iriConverter,
        EntityManagerInterface $entityManager
    ) {
        $this->objectNormalizer = $objectNormalizer;
        $this->iriConverter = $iriConverter;
        $this->entityManager = $entityManager;
        $this->currentlyChecking = [];
    }

    /**
     * {@inheritdoc}
     * @see AbstractItemNormalizer::normalize()
     */
    public function normalize($object, string $format = null, array $context = [])
    {
        $iri = $this->iriConverter->getIriFromItem($object);
        $context['iri'] = $iri;
        $context['api_normalize'] = true;
        $resourceHash = spl_object_id($object);
        $this->currentlyChecking[$resourceHash] = get_class($object);
        if (isset($context['resources'])) {
            $context['resources'][$iri] = $iri;
        }

        $data = $this->objectNormalizer->normalize($object, $format, $context);
        unset($this->currentlyChecking[$resourceHash]);
        if (is_array($data) && count($data) === 0) {
            return $iri;
        }

        return $data;
    }

    /**
     * {@inheritdoc}
     */
    public function supportsNormalization($data, string $format = null, array $context = []): bool
    {
        if (!is_object($data)) {
            return false;
        }

        if (!isset($context['api_resource'])) {
            return false;
        }

        // Prevent infinite recursion..
        $resourceHash = spl_object_id($data);
        if (isset($this->currentlyChecking[$resourceHash])) {
            return false;
        }

        if (!$this->isEntity($data) || !$this->isEntity($context['api_resource'])) {
            return false;
        }

        return true;
    }

    /**
     * @param string|object $class
     * @return bool
     */
    public function isEntity($class): bool
    {
        if (is_object($class)) {
            $class = ($class instanceof Proxy)
                ? get_parent_class($class)
                : get_class($class);
        }

        return !$this->entityManager->getMetadataFactory()->isTransient($class);
    }
}

This solution is absurdly complex. The correct solution is to make this work just like with JMS - you can set groups on everything, even ad hoc arrays and they just work. This is simply a bug.

Was this page helpful?
0 / 5 - 0 ratings