Jack Skeletron
Jack Skeletron

Reputation: 1501

ApiPlatform - Passing DTO's fields to different service after normalization

I have the structure like below.

----------------
MESSAGE
----------------
id
subject
body
----------------


----------------
USER
----------------
id
name
category
region
----------------


----------------
RECIPIENT
----------------
user_id
message_id
is_read
read_at
----------------

So Message 1:n Recipient m:1 User. Recipient is not an @ApiResource.

A Backoffice user will "write" a message and choose the audience by a set of specific criteria (user region, user category, user tags...).

To POST the message i'm using a Dto

class MessageInputDto
{
    /**
     * @var string
     *
     * @Groups({"msg_message:write"})
     */
    public string $subject;
    /**
     * @var string
     *
     * @Groups({"msg_message:write"})
     */
    public string $body;
    /**
     * @var bool
     *
     * @Groups({"msg_message:write"})
     */
    public bool $isPublished;
    /**
     * @var DateTimeInterface
     *
     * @Groups({"msg_message:write"})
     */
    public DateTimeInterface $publishDate;
    /**
     * @var DateTimeInterface|null
     *
     * @Groups({"msg_message:write"})
     */
    public ?DateTimeInterface $expiryDate = null;
    /**
     * @var MessageCategory|null
     *
     * @Groups({"msg_message:write"})
     */
    public ?MessageCategory $category = null;
    /**
     * @var array
     */
    public array $criteria = [];
}

The $criteria field is used to choose the audience of that message and is skipped by the DataTransformer as it is not a mapped field, a property of Message Entity that is returned by the transformer.

class MessageInputDataTransformer implements \ApiPlatform\Core\DataTransformer\DataTransformerInterface
{

    /**
     * @var MessageInputDto $object
     * @inheritDoc
     */
    public function transform($object, string $to, array $context = [])
    {
         $message = new Message($object->subject, $object->body);
         $message->setIsPublished($object->isPublished);
         $message->setPublishDate($object->publishDate);
         $message->setExpiryDate($object->expiryDate);
         $message->setCategory($object->category);

         return $message;
    }

    /**
     * @inheritDoc
     */
    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        // in the case of an input, the value given here is an array (the JSON decoded).
        // if it's a book we transformed the data already
        if ($data instanceof Message) {
            return false;
        }

        return Message::class === $to && null !== ($context['input']['class'] ?? null);
    }
}

As side effect, will be performed a bulk insert in the join table (Recipient) that keeps the m:n relations between Message and User.

My problem is how/where to perform this bulk insert and how pass the $criteria to the service that will manage it.

The only solution that i've found now (and it's working but i don't think is a good practice) is to put the bulk insert procedure in the POST_WRITE event of the Message, get the Request object and process the $criteria contained there.

class MessageSubscriber implements EventSubscriberInterface
{

    /**
     * @inheritDoc
     */
    public static function getSubscribedEvents()
    {
        return [
            KernelEvents::VIEW => [
                ['handleCriteria', EventPriorities::POST_WRITE]
            ],
        ];
    }

   
    public function handleCriteria(ViewEvent $event)
    {
        /** @var Message $message */
        $message = $event->getControllerResult();
        $method = $event->getRequest()->getMethod();
        $e = $event->getRequest();
        $collectionOperation = $e->get('_api_collection_operation_name');

        if (!$message instanceof Message ||
            $method !== Request::METHOD_POST ||
            $collectionOperation !== 'post') {
            return;
        }

        $content = json_decode($event->getRequest()->getContent(), true);

        if(array_key_exists('audienceCriteria', $content)){
            $criteria = Criteria::createFromArray($content['audienceCriteria']);
            // Todo: Create the audience
        }
    }
}

So the idea is that, when the Message is persisted, the system must generate the "relations" public.

This is why i think that the post write event could be a good choice, but as i said i'm not sure this could be a good practice.

Any idea? Thanks.

Upvotes: 2

Views: 4263

Answers (2)

MetaClass
MetaClass

Reputation: 1428

As the docs on DTO's state: "in most cases the DTO pattern should be implemented using an API Resource class representing the public data model exposed through the API and a custom data provider. In such cases, the class marked with @ApiResource will act as a DTO."

IOW specifying an Input or an Output Data Representation and a DataTransformer is the exception. It does not work if the DTO holds more data then the entity or if the dto's are not one to one with the entities (for example with a report that does a group by).

Here is your DTO class as a resource:

namespace App\DTO;

use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
use App\Entity\Message;

/**
 * Class defining Message data transfer
 *
 * @ApiResource(
 *     denormalizationContext= {"groups" = {"msg_message:write"}},
 *     itemOperations={
 *     },
 *     collectionOperations={
 *          "post"={
 *             "path"="/messages",
 *             "openapi_context" = {
 *                 "summary"     = "Creates a Message",
 *                 "description" = "Creates a Message"
 *             }
 *          }
 *     },
 *     output=Message::class
 * )
 */
class MessageInputDto
{
    /**
     * @var string
     *
     * @Groups({"msg_message:write"})
     */
    public string $subject;
    /**
     * @var string
     *
     * @Groups({"msg_message:write"})
     */
    public string $body;
    /**
     * @var bool
     *
     * @Groups({"msg_message:write"})
     */
    public bool $isPublished;
    /**
     * @var \DateTimeInterface
     *
     * @Groups({"msg_message:write"})
     */
    public \DateTimeInterface $publishDate;
    /**
     * @var \DateTimeInterface|null
     *
     * @Groups({"msg_message:write"})
     */
    public ?\DateTimeInterface $expiryDate = null;
    /**
     * @var MessageCategory|null
     *
     * @Groups({"msg_message:write"})
     */
    public ?MessageCategory $category = null;
    /**
     * @var array
     * @Groups({"msg_message:write"})
     */
    public array $criteria = [];
}

Make sure the folder your class is in is in the paths list in api/config/packages/api_platform.yaml. There usually is the following configuration:

api_platform:
    mapping:
        paths: ['%kernel.project_dir%/src/Entity']

If MessageInputDto is in /src/DTO make it like:

api_platform:
    mapping:
        paths: 
          - '%kernel.project_dir%/src/Entity'
          - '%kernel.project_dir%/src/DTO'

The post operation may have the same path as dhe default post operation on your Message resource. Remove that by explicitly defining collectionOperations for your Message resource without "post".

The post operation of MessageInputDto will deserialize the MessageInputDto. Your DataTransformer will not act on it so that it will arrive as is to the DataPersister:

namespace App\DataPersister;

use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use App\DTO\MessageInputDto;
use App\Entity\Message;
use Doctrine\Persistence\ManagerRegistry;
use App\DataTransformer\MessageInputDataTransformer;
use ApiPlatform\Core\Exception\InvalidArgumentException;

class MessageDataPersister implements ContextAwareDataPersisterInterface
{
    private $dataPersister;
    private $entityManager;
    private $dataTransformer;

    public function __construct(ContextAwareDataPersisterInterface $dataPersister, ManagerRegistry $managerRegistry, MessageInputDataTransformer $dataTransformer)
    {
        $this->dataPersister = $dataPersister;
        $this->entityManager = $managerRegistry->getManagerForClass(Message::class);
        $this->dataTransformer = $dataTransformer;
    }

    public function supports($data, array $context = []): bool
    {
        $transformationContext = ['input' => ['class' => Message::class]];

        return get_class($data) == MessageInputDto::class
            && $this->dataTransformer->supportsTransformation($data, Message::class, $transformationContext)
            && null !== $this->entityManager;
    }

    public function persist($data, array $context = [])
    {
        $message = $this->dataTransformer->transform($data, Message::class);

        // dataPersister will flush the entityManager but we do not want incomplete data inserted
        $this->entityManager->beginTransaction();
        $commit = true;

        $result = $this->dataPersister->persist($message, []);

        if(!empty($data->criteria)){
            $criteria = Criteria::createFromArray($data->criteria);

            try {
                // Todo: Create the audience, preferably with a single INSERT query SELECTing FROM user_table WHERE meeting the criteria
            // (Or maybe better postpone until message is really sent, user region, category, tags may change over time)

            } catch (\Exception $e) {
                $commit = false;
                $this->entityManager->rollback();
            }
        }
        if ($commit) {
            $this->entityManager->commit();
        }

        return $result;
    }

    public function remove($data, array $context = [])
    {
        throw new InvalidArgumentException('Operation not supported: delete');
    }

}

(Maybe it should have been called MessageInputDtoDataPersister - depending on how you look at it)

Even with service autowiring and autoconfiguration enabled, you must still configure it to get the right dataPersister to delegate to:

# api/config/services.yaml
services:
    # ...
    'App\DataPersister\MessageDataPersister':
        arguments:
            $dataPersister: '@api_platform.doctrine.orm.data_persister'

This way you do not need MessageSubscriber.

Be aware that all the other phases inbetween deserialization and data persist (validation, security post denormalize) work on the MessageInputDto.

Upvotes: 4

vuryss
vuryss

Reputation: 1300

One solution when you have to generate multiple custom entities is to use data persisters: https://api-platform.com/docs/core/data-persisters/

There you have 2 options:

  1. Decorate the doctrine persister - meaning the message will still be saved by Doctrine, but you can do something before or afterwards.
  2. Implement a custom persister - saving both message and other related entities that you like. Or doing something completely custom, without calling Doctrine at all.

Upvotes: 0

Related Questions