前回の記事でSymfony4でユーザーを認証・管理するAPIを作りました。
今回は受注データ登録・更新・削除から受注一覧をログインユーザーのみに絞り込める受注APIを作成する方法を調べたのでその備忘録です。
追記:ログインユーザーはログインカスタマー、受注は注文に読み替えて下さい。
カスタムアノテーションを作成
以下のように受注一覧をログインユーザーのみに絞り込むためのアノテーションを作成して下さい。
<?php
namespace App\Annotation;
use Doctrine\Common\Annotations\Annotation;
use Doctrine\Common\Annotations\Annotation\Target;
/**
 * 特定ユーザーのアイテムのみに絞り込むためのアノテーション
 *
 * Class UserAware
 * @package App\Annotation
 *
 * @Annotation
 * @Target("CLASS")
 */
final class UserAware
{
    public $userFieldName;
}
受注エンティティにUserAwareアノテーションを追加
以下のようなAPIに対応した受注エンティティがあるとします。
このエンティティに上記で作成したUserAwareアノテーションを追加して下さい。
userFieldNameはorders.user_idカラムを指定しています。
<?php
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Annotation\UserAware;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
 * API利用条件:
 *      ・ログイン時は注文一覧取得可能
 *      ・ログイン時は注文登録可能
 *      ・ログイン時は自分の注文情報閲覧可能
 *
 *      ・idは閲覧可能
 *      ・orderNoは閲覧・書き込み可能
 *      ・userは閲覧可能
 * @ApiResource(
 *      collectionOperations={
 *          "get"={"access_control"="is_granted('ROLE_USER')"},
 *          "post"={"access_control"="is_granted('ROLE_USER')"}
 *      },
 *      itemOperations={
 *          "get"={"access_control"="is_granted('ROLE_USER') and object.getUser() == user"},
 *          "put"={"access_control"="is_granted('ROLE_ADMIN')},
 *          "delete"={"access_control"="is_granted('ROLE_ADMIN')}
 *      },
 *      normalizationContext={
 *          "groups"={"read"}
 *      },
 *      denormalizationContext={
 *          "groups"={"write"}
 *      }
 * )
 * @ORM\Table(name="orders")
 * @ORM\Entity(repositoryClass="App\Repository\OrderRepository")
 * @UserAware(userFieldName="user_id")
 */
class Order
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     * @Groups({"read"})
     */
    private $id;
    /**
     * @ORM\Column(type="string", length=255)
     * @Assert\NotBlank()
     * @Groups({"read", "write"})
     */
    private $orderNo;
    /**
     * @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="orders")
     * @ORM\JoinColumn(nullable=false)
     * @Groups({"read"})
     */
    private $user;
    public function getId(): ?int
    {
        return $this->id;
    }
    public function getUser(): ?User
    {
        return $this->user;
    }
    public function getOrderNo(): ?string
    {
        return $this->orderNo;
    }
    public function setOrderNo(string $orderNo): self
    {
        $this->orderNo = $orderNo;
        return $this;
    }
    public function setUser(?User $user): self
    {
        $this->user = $user;
        return $this;
    }
}
指定したユーザーで受注一覧を絞り込むためのDoctrineフィルターを作成
ApiResourceアノテーションのitemOperationsで受注の取得、更新、削除の権限指定は設定できるのですが、受注一覧を特定ユーザーで絞り込むことが出来ないのでDoctrineのフィルターを使って絞り込みます。
以下のように、エンティティにUserAwareアノテーションが設定され、そのエンティティがUserエンティティとリレーションしている場合に指定したユーザーで絞り込むためのDoctrinのフィルターを作成します。
<?php
namespace App\Doctrine\Filter;
use App\Annotation\UserAware;
use Doctrine\Common\Annotations\Reader;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
class UserFilter extends SQLFilter
{
    private $reader;
    /**
     * エンティティにUserAwareアノテーションが設定され、
     * そのエンティティがUserエンティティとリレーションしている場合に
     * 指定したユーザーで絞り込むフィルター。
     *
     * @param ClassMetaData $targetEntity
     * @param string $targetTableAlias
     *
     * @return string 制限するSQLまたは空の値
     */
    public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
    {
        if(null === $this->reader) {
            throw new \RuntimeException(sprintf('Annotation Readerが必要です。"%s::setAnnotationReader()"でセットして下さい。', __CLASS__));
        }
        // UserAwareアノテーションを取得
        $userAware = $this->reader->getClassAnnotation($targetEntity->getReflectionClass(), UserAware::class);
        if(!$userAware) {
            return '';
        }
        // UserAwareアノテーションで設定されたUserフィールド名
        $fieldName = $userAware->userFieldName;
        try {
            // セットされたuserIdを取得
            $userId = $this->getParameter('id');
        } catch (\InvalidArgumentException $e) {
            return '';
        }
        if(empty($fieldName) || empty($userId)) {
            return '';
        }
        // クエリにユーザーで絞り込む条件を追加
        return sprintf('%s.%s = %s', $targetTableAlias, $fieldName, $userId);
    }
    public function setAnnotationReader(Reader $reader): void
    {
        $this->reader = $reader;
    }
}
Doctrineフィルターの登録
api_platform.yamlに上記で作成したDoctrineフィルターを登録して下さい。
api_platform:
    title: Sample
    version: 1.0.0
#    formats:
#        json: ['application/json']
#        html: ['text/html']
    mapping:
        paths: ['%kernel.project_dir%/src/Entity']
doctrine:
    orm:
        filters:
            # user_filterというキーでUserFilterを登録
            user_filter:
                class: App\Doctrine\Filter\UserFilter
リクエスト毎にDoctrineフィルターを初期化するイベントを作成
ユーザーがログインしていた場合、リクエスト毎に上記で作成したDoctrineフィルターのUserFilterを初期化するイベントを作成します。
<?php
namespace App\EventSubscriber;
use ApiPlatform\Core\EventListener\EventPriorities;
use Doctrine\Common\Annotations\Reader;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\User\UserInterface;
class UserFilterSubscriber implements EventSubscriberInterface
{
    /**
     * @var EntityManagerInterface
     */
    private $em;
    /**
     * @var TokenStorageInterface
     */
    private $tokenStorage;
    /**
     * @var Reader
     */
    private $reader;
    public function __construct(
        EntityManagerInterface $em,
        TokenStorageInterface $tokenStorage,
        Reader $reader
    )
    {
        $this->em = $em;
        $this->tokenStorage = $tokenStorage;
        $this->reader = $reader;
    }
    /**
     * ユーザーがログインしていた場合、
     * リクエスト毎にDoctrineフィルターのUserFilterを初期化
     *
     * @param RequestEvent $event
     */
    public function onKernelRequest(RequestEvent $event)
    {
        if(!$user = $this->getUser()) {
            return;
        }
        // 作成したDoctrineフィルターのUserFilterを有効化
        $filter = $this->em->getFilters()->enable('user_filter');
        // UserIdをフィルターにセット
        $filter->setParameter('id', $user->getId());
        // アノテーションリーダーをフィルターにセット
        $filter->setAnnotationReader($this->reader);
    }
    /**
     * ユーザー情報を取得
     *
     * @return UserInterface|null
     */
    private function getUser(): ?UserInterface
    {
        if(!$token = $this->tokenStorage->getToken()) {
            return null;
        }
        $user = $token->getUser();
        return $user instanceof UserInterface ? $user : null;
    }
    public static function getSubscribedEvents()
    {
        return [
           'kernel.request' => ['onKernelRequest', EventPriorities::PRE_READ],
        ];
    }
}
受注登録時にユーザーIDを保存するイベントを追加
受注登録時にログインユーザーのユーザーIDを保存するイベントを追加します。
<?php
namespace App\EventSubscriber;
use ApiPlatform\Core\EventListener\EventPriorities;
use App\Entity\Order;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\User\UserInterface;
class OrderEntitySubscriber implements EventSubscriberInterface
{
    /**
     * @var TokenStorageInterface
     */
    private $tokenStorage;
    public function __construct(TokenStorageInterface $tokenStorage)
    {
        $this->tokenStorage = $tokenStorage;
    }
    public function onKernelView(ViewEvent $event)
    {
        if(!$user = $this->getUser()) {
            return;
        }
        $entity = $event->getControllerResult();
        $method = $event->getRequest()->getMethod();
        $contentType = $event->getRequest()->getContentType();
        if(Request::METHOD_POST !== $method || "json" !== $contentType) {
            return;
        }
        if($entity instanceof Order) {
            $entity->setUser($user);
        }
    }
    /**
     * @return UserInterface|null
     */
    private function getUser(): ?UserInterface
    {
        if(!$token = $this->tokenStorage->getToken()) {
            return null;
        }
        $user = $token->getUser();
        return $user instanceof UserInterface ? $user : null;
    }
    public static function getSubscribedEvents()
    {
        return [
           'kernel.view' => ['onKernelView', EventPriorities::PRE_VALIDATE],
        ];
    }
}
以上で完成です。
APIの使い方
以下のAPIの使い方は、前回の記事を参考に認証処理を実装していることを前提としています。
ログイン
curl -X POST -H "Content-Type: application/json" http://localhost:8000/authentication_token -d '{"email":"info@a-zumi.net","password":"password"}'
認証に成功すると以下のようなトークンが返ってきます。このトークを使って受注データを操作します。
{
   "token" : "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXUyJ9.eyJleHAiOjE0MzQ3Mjc1MzYsInVzZXJuYW1lIjoia29ybGVvbiIsImlhdCI6IjE0MzQ2NDExMzYifQ.nh0L_wuJy6ZKIQWh6OrW5hdLkviTs1_bau2GqYdDCB0Yqy_RplkFghsuqMpsFls8zKEErdX5TYCOR7muX0aQvQxGQ4mpBkvMDhJ4-pE4ct2obeMTr_s4X8nC00rBYPofrOONUOR4utbzvbd4d2xT_tj4TdR_0tsr91Y7VskCRFnoXAnNT-qQb7ci7HIBTbutb9zVStOFejrb4aLbr7Fl4byeIEYgp2Gd7gY"
}
受注データ登録
curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer ****************token******************" http://localhost:8000/api/orders -d {"orderNo": "123456789"}
受注一覧データ取得
curl -H "Content-Type: application/json" -H "Authorization: Bearer ****************token******************" http://localhost:8000/api/orders
受注データ更新
curl -X PUT -H "Content-Type: application/json" -H "Authorization: Bearer ****************token******************" http://localhost:8000/api/orders/1
受注データ削除
curl -X DELETE -H "Content-Type: application/json" -H "Authorization: Bearer ****************token******************" http://localhost:8000/api/orders/1
