Using Api Platform, automatically assign user to Object (OneToMany)
Asked Answered
H

4

11

Here is my scenario:

I have an Article entity. Each Article has an owner (a User). A user can own many articles. The user can post an article over the API.

I want to have the user_id column for the article set automatically based on the Bearer token (I am using JWT auth).

I cannot find any documentation anywhere on how to do this. Can someone please help with how to achieve this?

Note: I am looking for solutions that would avoid having to use additional extensions (or controllers) in Symfony, if possible. I believe Api Platform should be able to achieve this using built-in technology, but I could be wrong.

Here are my entities:

User:

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @ApiResource()
 * @ORM\Table(name="users")
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 * @UniqueEntity(fields="email", message="Email already taken")
 */
class User implements UserInterface, \Serializable
{

    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string $password
     *
     * @ORM\Column(type="string", length=64)
     * @Assert\NotBlank()
     */
    private $password;

    /**
     * @var string $plainPassword
     *
     * @Assert\NotBlank()
     * @Assert\Length(max=4096)
     */
    private $plainPassword;

    /**
     * @var string $email
     *
     * @ORM\Column(type="string", length=254, unique=true)
     * @Assert\NotBlank()
     * @Assert\Email()
     */
    private $email;

    /**
     * @var bool $isActive
     *
     * @ORM\Column(name="is_active", type="boolean")
     */
    private $isActive;

    /**
     * @ORM\OneToMany(targetEntity="Article", mappedBy="user")
     */
    private $articles;

    /**
     * @ORM\Column(type="array")
     */
    private $roles;

    public function __construct($email)
    {
        $this->isActive = true;
        $this->email = $email;
        $this->articles = new ArrayCollection();
    }

    public function getId()
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getUsername()
    {
        return $this->email;
    }

    /**
     * @return string
     */
    public function getEmail()
    {
        return $this->email;
    }

    /**
     * @param string $email
     *
     * @return $this
     */
    public function setEmail($email)
    {
        $this->email = $email;

        return $this;
    }

    /**
     * @return null|string
     */
    public function getSalt()
    {
        return null;
    }

    /**
     * @return string
     */
    public function getPassword()
    {
        return $this->password;
    }

    /**
     * @param string $password
     *
     * @return $this
     */
    public function setPassword($password)
    {
        $this->password = $password;

        return $this;
    }

    /**
     * @return array
     */
    public function getRoles()
    {
        return ['ROLE_USER'];
    }

    public function eraseCredentials()
    {
    }

    /** @see \Serializable::serialize() */
    public function serialize()
    {
        return serialize(array(
            $this->id,
            $this->email,
            $this->password,
        ));
    }

    /** @see \Serializable::unserialize()
     * @param $serialized
     */
    public function unserialize($serialized)
    {
        list (
            $this->id,
            $this->email,
            $this->password,
            ) = unserialize($serialized, array('allowed_classes' => false));
    }
}

Article

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * A User's article
 *
 * @ORM\Table(name="articles")
 * @ApiResource(
 *     attributes={"access_control"="is_granted('ROLE_USER')"},
 *     collectionOperations={
 *         "get",
 *         "post"={"access_control"="is_granted('ROLE_USER')"}
 *     },
 *     itemOperations={
 *         "get"={"access_control"="is_granted('ROLE_USER') and object.owner == user"}
 *     }
 * )
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks()
 */
class Article
{

    /**
     * @var int $id
     *
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @var string $user
     *
     * @ORM\ManyToOne(targetEntity="User", inversedBy="articles")
     */
    private $user;

    /**
     * @var string $name
     *
     * @ORM\Column(type="text")
     * @Assert\NotBlank()
     */
    private $name;

    /**
     * @var string $location
     *
     * @ORM\Column(type="text")
     */
    private $location;

    /**
     * @var \DateTimeInterface $createdAt
     *
     * @ORM\Column(type="datetime_immutable")
     */
    private $createdAt;

    /**
     * @var \DateTimeInterface $updatedAt
     *
     * @ORM\Column(type="date_immutable", nullable=true)
     */
    private $updatedAt;

    /**
     * @ORM\PrePersist()
     */
    public function setCreatedAt()
    {
        $this->createdAt = new \DateTime();
    }

    /**
     * @ORM\PreUpdate()
     */
    public function setUpdatedAt()
    {
        $this->updatedAt = new \DateTime();
    }

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

    /**
     * @param int $id
     */
    public function setId(int $id): void
    {
        $this->id = $id;
    }

    /**
     * @return string
     */
    public function getUser(): string
    {
        return $this->user;
    }

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * @param string $name
     */
    public function setName(string $name): void
    {
        $this->name = $name;
    }

    /**
     * @return string
     */
    public function getLocation(): string
    {
        return $this->location;
    }

    /**
     * @param string $location
     */
    public function setLocation(string $location): void
    {
        $this->location = $location;
    }


}
Hardboiled answered 27/9, 2018 at 3:17 Comment(0)
W
16

This should be possible using an EventListener: https://api-platform.com/docs/core/events

With these you can hook into the internal process of ApiPlatform process without a new controller. Perfect fit for your usecase.

An implementation could look like this:

<?php
// api/src/EventSubscriber/AddOwnerToArticleSubscriber.php

namespace App\EventSubscriber;

use ApiPlatform\Core\EventListener\EventPriorities;
use App\Entity\Article;
use App\Entity\User;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

final class AddOwnerToArticleSubscriber implements EventSubscriberInterface
{

    /**
     * @var TokenStorageInterface
     */
    private $tokenStorage;

    public function __construct(TokenStorageInterface $tokenStorage)
    {

        $this->tokenStorage = $tokenStorage;
    }

    public static function getSubscribedEvents()
    {
        return [
            KernelEvents::VIEW => ['attachOwner', EventPriorities::PRE_WRITE],
        ];
    }

    public function attachOwner(GetResponseForControllerResultEvent $event)
    {
        $article = $event->getControllerResult();
        $method = $event->getRequest()->getMethod();

        if (!$article instanceof Article || Request::METHOD_POST !== $method) {

            // Only handle Article entities (Event is called on any Api entity)
            return;
        }

        // maybe these extra null checks are not even needed
        $token = $this->tokenStorage->getToken();
        if (!$token) {
            return;
        }

        $owner = $token->getUser();
        if (!$owner instanceof User) {
            return;
        }


        // Attach the user to the not yet persisted Article
        $article->setUser($owner);

    }
}
Whitten answered 27/9, 2018 at 4:10 Comment(7)
Great answer, thank you. So there is no way to automate this through the entity or through doctrine?Hardboiled
Another way would be atlantic18.github.io/DoctrineExtensions/doc/blameable.html but I recommend the explicit way like above. Doctrine listerners tend to become messy, I always try to avoid them and model the domain constraints explictly myself. Also eases testing since you can test behaivor without persisting.Whitten
@Wildcard27, no there isn't a way afaik to automate this. nothing to do with api platform per se. entity should not be depending on service container, but you should set the data (in this case user) from outside. plus: your demanding attitude sounds a bit unfriendly to me in an open source effort like api platform.Dogmatic
@Hardboiled Accept my answer as solution?Whitten
@Whitten I am just implementing this now. Sorry for the delay. Once tested I will comment or mark as acceptedHardboiled
@Dogmatic Thank you for your comment, I have rephrased the question. I did not intend to come across as demanding at all. I appreciate that people take time out of their day to help.Hardboiled
Notice that starting on Symfony 4.3 GetResponseForControllerResultEvent was renamed to ViewEventIhram
M
3

You can create an entity named Base and, you can have some property like "createdBy", "modifiedBy", "createdAt", "modifiedAt", "status" in this class.

<?php
   
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

/**
 * @ORM\MappedSuperclass()
 */
class Base implements PublishedInfoEntityInterface
{

    /**
     * @ORM\Column(type="datetime", nullable=true)
     */
    private $createdAt;

    /**
     * @ORM\Column(type="datetime", nullable=true)
     */
    private $modifiedAt;

    /**
     * @ORM\ManyToOne(targetEntity="User")
     * @ORM\JoinColumn(nullable=true)
     */
    private $createdBy;

    /**
     * @ORM\ManyToOne(targetEntity="User")
     * @ORM\JoinColumn(nullable=true)
     */
    private $modifiedBy;

    /**
     * @ORM\Column(type="integer", nullable=true, length=2)
     */
    private $status;

    public function getCreatedAt()
    {
        return $this->createdAt;
    }

    public function setCreatedAt(\DateTimeInterface $createdAt): PublishedInfoEntityInterface
    {
        $this->createdAt = $createdAt;

        return $this;
    }

    public function getModifiedAt()
    {
        return $this->modifiedAt;
    }

    public function setModifiedAt(\DateTimeInterface $modifiedAt): PublishedInfoEntityInterface
    {
        $this->modifiedAt = $modifiedAt;

        return $this;
    }

    /**
     * @return User
     */
    public function getCreatedBy()
    {
        return $this->createdBy;
    }

    /**
     * @param User $createdBy
     * @return Base
     */
    public function setCreatedBy($createdBy): self
    {
        $this->createdBy = $createdBy;
        return $this;
    }

    /**
     * @return User
     */
    public function getModifiedBy()
    {
        return $this->modifiedBy;
    }

    /**
     * @param User $modifiedBy
     * @return Base
     */
    public function setModifiedBy($modifiedBy): self
    {
        $this->modifiedBy = $modifiedBy;
        return $this;
    }

    /**
     * @return int
     */
    public function getStatus()
    {
        return $this->status;
    }

    /**
     * @param integer $status
     */
    public function setStatus($status): void
    {
        $this->status = $status;
        return $this;
    }
}

Create a subscriber class for set automatic createdBy and modifiedBy like this code

<?php

namespace App\EventSubscriber;

use ApiPlatform\Core\EventListener\EventPriorities;
use App\Entity\Base;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Security;

class AuthoredEntitySubscriber implements EventSubscriberInterface
{
    private $entityManager;
    /**
     * @var Security
     */
    private $security;

    public function __construct(EntityManagerInterface $entityManager,Security $security)
    {
        $this->entityManager = $entityManager;
        $this->security = $security;
    }

    public static function getSubscribedEvents()
    {
        return [KernelEvents::VIEW => ['setAuthor', EventPriorities::PRE_WRITE]];
    }

    public function setAuthor(ViewEvent $event)
    {
        $entity = $event->getControllerResult();
        $method = $event->getRequest()->getMethod();
        $role = $this->security->getToken()->getRoleNames();

        if (!$entity instanceof Base || !in_array($method, [Request::METHOD_POST, Request::METHOD_PUT]) || !$role) {
            return;
        }

         $entity->setModifiedBy($this->security->getUser());

         if (Request::METHOD_POST === $method) {
             $entity->setCreatedBy($this->security->getUser());
         }
    }
}

If you want to add automatic createdAt and modifiedAt you must create a interface class named PublishedInfoEntityInterface for createdAt and modifiedAt and this class write this code:

<?php

namespace App\Entity;

interface PublishedInfoEntityInterface
{
    public function setCreatedAt(\DateTimeInterface $dateTime): PublishedInfoEntityInterface;

    public function setModifiedAt(\DateTimeInterface $dateTime): PublishedInfoEntityInterface;
}

and create an subscriber for automatic fill createdAt and modifiedAt like this

<?php

namespace App\EventSubscriber;


use ApiPlatform\Core\EventListener\EventPriorities;
use App\Entity\PublishedInfoEntityInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class PublishedInfoEntitySubscriber implements EventSubscriberInterface
{

    public static function getSubscribedEvents()
    {
        return [KernelEvents::VIEW => ['setDataTime', EventPriorities::PRE_WRITE]];
    }

    public function setDataTime(ViewEvent $event)
    {
        $entity = $event->getControllerResult();
        $method = $event->getRequest()->getMethod();

        if (!$entity instanceof PublishedInfoEntityInterface || !in_array($method, [Request::METHOD_POST, Request::METHOD_PUT])) {
            return;
        }

        $entity->setCreatedAt(new \DateTime());

        if (Request::METHOD_POST === $method){
            $entity->setModifiedAt(new \DateTime());
        }
    }
}

Finally each class you want to have those property you just extend this class like this

class User extends Base implements UserInterface

And create a migration,

Mulvaney answered 26/9, 2020 at 5:8 Comment(1)
It's a very smart way to add traceability in entity.Jung
S
0

Another way would be to use a Doctrine Entity Listener.


class SetUserListener
{

    private Security $security;

    public function __construct(Security $security)
    {
        $this->security = $security;
    }

    public function prePersist($obj)
    {
        if (!is_a($obj, Timer::class) &&
            !is_a($obj, DailySummary::class) &&
            !is_a($obj, Task::class)
        ) {
            return;
        }

        if ($this->security->getUser()) {
            $obj->setUser($this->security->getUser());
        }
    }
}

Make sure to hook up the Entity Listener in your services

    App\Doctrine\SetUserListener:
            tags: [ doctrine.orm.entity_listener ]
Sticktight answered 16/11, 2020 at 5:59 Comment(0)
S
0

In my case, I am using Gedmo bundle and Blameable annotation. I have created a trait instead of a mapped super class like this below :

<?php

namespace App\ORM\Traits;

use App\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;

trait OwnerTrait
{
    /**
     * @var User|null
     * @ORM\ManyToOne(targetEntity="App\Entity\User")
     * @ORM\JoinColumn(name="created_by", referencedColumnName="id", nullable=true, onDelete="SET NULL")
     *
     * @Gedmo\Blameable(on="create")
     */
    protected ?User $createdBy;

    /**
     * @var User|null
     * @ORM\ManyToOne(targetEntity="App\Entity\User")
     * @ORM\JoinColumn(name="updated_by", referencedColumnName="id", nullable=true, onDelete="SET NULL")
     *
     * @Gedmo\Blameable(on="update")
     */
    protected ?User $updatedBy;

    /**
     * Set createdBy
     * @param User|null $createdBy
     * @return $this
     */
    public function setCreatedBy(?User $createdBy)
    {
        $this->createdBy = $createdBy;

        return $this;
    }

    /**
     * Returns the user who create the object
     * @return User|null
     */
    public function getCreatedBy(): ?User
    {
        return $this->createdBy;
    }

    /**
     * Set updatedBy
     * @param User|null $updatedBy
     * @return $this
     */
    public function setUpdatedBy(?User $updatedBy)
    {
        $this->updatedBy = $updatedBy;

        return $this;
    }

    /**
     * Returns user who is the last to modify object
     * @return User|null
     */
    public function getUpdatedBy(): ?User
    {
        return $this->updatedBy;
    }
}

And on entities

<?php

class Article
{
   use OwnerTrait;
}
Scurlock answered 2/6, 2021 at 9:9 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.