🐉

API Platformで更新処理をしてみる

2023/12/20に公開

これは API Platform Advent Calendar 2023 の20日目の記事です。

はじめに

以下のAPI Platformに関する記事を書いてから5ヶ月ほどが経ちました。しばらくAPI Platformを触っていなかったのですが、最近ガッツリと触ってどうやって扱ったらいいかわかってきたので、現時点で理解している内容をまとめて記事にします。
https://zenn.dev/gsy0911/articles/ab193f6eba39dc

まとめる内容はPOSTやPATCHなどの作成・更新処理に関するところです。ドキュメントを見ていて、単一/複数のEntityのGETに関する内容はパッと見ても結構わかりました。ただ、ちょっと複雑な更新処理を書き方はかなりじっくり読んでようやくわかる程度でした。そのため、備忘録を兼ねつつこの記事でその辺りをまとめていきます。

API Platformの印象の変遷

本題の前にAPI Platformの触った時々の印象をまとめます。

触って1時間くらいの印象「Entityベースなら扱いやすそうだな〜!FastAPIみたいにAPIのエンドポイントのbodyとかクエリパラメータにクラスを割り当てて行く感じかな〜?ドキュメントとか記事も少ないから不安だけれど、まぁなんとかなるでしょ。」

触って3日くらいの印象Groupsの設定の重要さはわかったけれど、エンティティ周りの更新が扱いづらい。自由度が低そうで、やりたいことが全くできなさそう。Controllerを使うの非推奨って書いてあるけれど、Controller使わないと複雑な操作できなくない?あとやっぱり公式のドキュメントがちょっとわかりづらい、もう少し丁寧にサンプル含めて書いて欲しい。」

触って2週間くらいの今の印象「EasyではにけれどSimpleな作りにはなりそう。最初慣れるのは辛いけれど、運用保守とかは比較的楽になりそう?SymfonyのSerializer/Deserializerがかなり重要でそこを理解すれば良さそう。以下の図のようなイメージを持てたのでとりあえずはなんとかなるかな?」

API Platformでは、SymfonyのSerializer/Deserializerがかなり重要そうです。API Platformを実装していく上で図のようなイメージを持っています。最初見ただけでは図の意味はわからないと思いますが、この記事を最後まで読んでもらえれば理解していただけるかと思います。また、私の理解が間違っていたらその点を指摘していただけると大変ありがたいです。


API Platformのデータの流れ

図を簡単に説明します。中央にあるObjectFormatはSymfonyの機能で公式ドキュメントより参照しています。そのほかの緑色の部分はAPI Platformが提供している機能を表しています。流れとしては以下のようになっています。

  1. StateProviderがパスパラメータを受け取ってDBからObjectを取得する
  2. 取得したObjectへリクエストのbodyからの結果を元にObjectのSetterを介して値を更新する
  3. 更新したObjectはStateProcessorでDBへ処理され、レスポンスの結果として利用される

構成

構成は以下の通りです。最近Symfonyの6.4が出たので使っています。一方でPHPも8.3が出ましたが一旦ここでの利用は見送っています。

  • PHP: 8.2
  • Symfony: 6.4
  • API Platform: 3.2
  • MySQL: 8.0

コード

本記事では次のステップでUserクラスに色々な関係を付与していきます。

  1. Userクラスで、Groupsを使ってSerializer/Deserializerを制御しつつGET/POSTを扱う
  2. UserクラスとManyToOneで紐づくPrefectureクラスを考えて、GET/POSTを扱う
  3. UserクラスとManyToManyで紐づくBookクラスを考えて、GET/POSTを扱う


本記事で扱うエンティティの関係と、掲載する順番を示した図。色付きの四角は影響のあるエンティティの範囲を、矢印はリクエストの流れをイメージしている

この記事のコードは以下のGitHubリポジトリにあります。本記事では省略している箇所もあるので適宜参照してください。

https://github.com/gsy0911/zenn-php-symfony/tree/article4

1. 単一のUserクラスを考える

前回の記事はBookを対象にしていましたが、今回はUserクラスから考えていきます。Groupsのアトリビュートを設定していますが、あとで説明します。

src/Entity/User.php
<?php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\UserRepository;
use App\State\UserProvider;
use App\State\UserProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\Context;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'user', options: ["comment" => '利用者テーブル'])]
#[Gedmo\SoftDeleteable(fieldName: 'deletedAt', timeAware: false)]
#[ApiResource(
    operations: [
        new Get(),
        new GetCollection(),
        new Post(
            normalizationContext: ["groups" => ["user:get"]],
            denormalizationContext: ["groups" => ["user:post"]]
        ),
        new Patch(
            normalizationContext: ["groups" => ["user:get"]],
            denormalizationContext: ["groups" => ["user:patch"]],
            provider: UserProvider::class,
            processor: UserProcessor::class,
        ),
    ],
    normalizationContext: ["groups" => ["user:get"]]
)]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(groups: ['user:get'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank(message: '名前を指定してください')]
    #[Groups(groups: ['user:get', 'user:post', 'user:patch'])]
    private ?string $name = null;

    #[ORM\Column(nullable: true, options: ["comment" => '削除日時'])]
    private ?DateTimeImmutable $deletedAt = null;

    #[Groups(['user:get'])]
    #[ORM\Column(updatable: false, options: [ 'comment' => '作成日時' ])]
    #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
    #[Gedmo\Timestampable(on: 'create')]
    private ?DateTimeImmutable $createdAt = null;

    #[Groups(['user:get'])]
    #[ORM\Column(options: [ 'comment' => '更新日時' ])]
    #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
    #[Gedmo\Timestampable(on: 'update')]
    private ?DateTimeImmutable $updatedAt = null;

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

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

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

    public function getUpdatedAt(): DateTimeImmutable
    {
        return $this->updatedAt;
    }

    public function setName(string $name): static
    {
        $this->name = $name;
        return $this;
    }
}

自動生成のコードですが、UserRepository.phpも一応以下に貼っておきます。

UserRepository.phpのコード。
src/Repository/UserRepository.php
<?php

namespace App\Repository;

use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<User>
 *
 * @method User|null find($id, $lockMode = null, $lockVersion = null)
 * @method User|null findOneBy(array $criteria, array $orderBy = null)
 * @method User[]    findAll()
 * @method User[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class UserRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, User::class);
    }

//    /**
//     * @return User[] Returns an array of User objects
//     */
//    public function findByExampleField($value): array
//    {
//        return $this->createQueryBuilder('b')
//            ->andWhere('b.exampleField = :val')
//            ->setParameter('val', $value)
//            ->orderBy('b.id', 'ASC')
//            ->setMaxResults(10)
//            ->getQuery()
//            ->getResult()
//        ;
//    }

//    public function findOneBySomeField($value): ?User
//    {
//        return $this->createQueryBuilder('b')
//            ->andWhere('b.exampleField = :val')
//            ->setParameter('val', $value)
//            ->getQuery()
//            ->getOneOrNullResult()
//        ;
//    }
}

Groupsの設定と役割

一度理解すればそんなに難しくないのですが、最初はこのGroupsの役割や効果がよくわかりませんでした。API PlatformのやSymfonyにはしっかり書かれています。

私は、Groupsのアトリビュートに関して、「Entityから取得するフィールドや更新対象のフィールドを指定するもの」と理解しています。GETの時はSerializerの対象となるフィールドを指定して、POSTやPATCHの時はbodyのパラメータからどの値をフィールドに設定する対象とするかを指定します。そしてnormalizationContext: ["groups" => ["user:get"]]denormalizationContext: ["groups" => ["user:post"]]のように指定すれば、特定のメソッドの入出力を制御できるわけです。

デフォルトでは、クラスのgettersetterが指定されているフィールドに対しては全てこのSerializer/Deserializerが動くため、#[ApiResource]というアトリビュートを付与しただけで、動くということも理解できます。

GET /api/users/{id}を考える

UserクラスのGroupsアトリビュートがついたフィールドのみ抽出すると以下のようになります。

src/Entity/User.php(一部)
    #[Groups(groups: ['user:get'])]
    private ?int $id = null;

    #[Groups(groups: ['user:get', 'user:post', 'user:patch'])]
    private ?string $name = null;

    #[Groups(['user:get'])]
    private ?DateTimeImmutable $createdAt = null;

    #[Groups(['user:get'])]
    private ?DateTimeImmutable $updatedAt = null;

また、GETメソッドは次のnormalizationContextを指定しています。これは、"user:get"がついたフィールドのみシリアライズするという意味です。

src/Entity/User.php(一部)
#[ApiResource(
    normalizationContext: ["groups" => ["user:get"]]
)]

この状態で、GET /api/users/1を実行すると以下のような結果を得ます。

$ curl -X 'GET' \
  'http://localhost:8080/api/users/1' \
  -H 'accept: application/json' | jq 

{
  "id": "1",
  "name": "Alice",
  "created_at": "2023-12-01",
  "updated_at": "2023-12-01"
}

この状態で、例えば「idフィールドはレスポンスに含めなくてもいいよね」となった場合に以下のようにすれば、レスポンスにはidが含まれないようになります。

src/Entity/User.php(一部)
-   #[Groups(groups: ['user:get'])]
    private ?int $id = null;

    #[Groups(groups: ['user:get', 'user:post', 'user:patch'])]
    private ?string $name = null;

    #[Groups(['user:get'])]
    private ?DateTimeImmutable $createdAt = null;

    #[Groups(['user:get'])]
    private ?DateTimeImmutable $updatedAt = null;
$ curl -X 'GET' \
  'http://localhost:8080/api/users/1' \
  -H 'accept: application/json' | jq 

{
  "name": "Alice",
  "created_at": "2023-12-01",
  "updated_at": "2023-12-01"
}

このようにして、GroupsnormalizationContext: ["groups" => ["user:get"]]でレスポンスの形を制御できます。

今回は、フィールドの宣言にのみGroupsを指定しましたが、getter/setterメソッドに指定しても同様のことができます。詳細は以下の公式記事を参照してください。

https://symfonycasts.com/screencast/api-platform/serialization-groups?cid=apip

POST /api/usersを考える

同じように、POST /api/usersを考えてみます。このPOSTにはnormalizationContextdenormalizationContextを指定しています。normalizationContextはGETの時と同じレスポンス時に何を返すかを指定します。"user:get"を指定しているので、レスポンスの形はGETと同じという意味です。

POSTで重要になってくるのはdenormalizationContextの方だと思っています。これはリクエストの中に含まれるbodyのどの値をクラスのどのフィールドにsetするか指定できます。

src/Entity/User.php(一部)
        new Post(
            normalizationContext: ["groups" => ["user:get"]],
            denormalizationContext: ["groups" => ["user:post"]]
        ),

UserクラスのGroupsアトリビュートの"user:post"がついたフィールドのみ抽出すると以下のようになります。これは、POSTする時には、nameフィールドのみ送信できることを意味しています。

src/Entity/User.php(一部)
    #[Groups(groups: ['user:get', 'user:post', 'user:patch'])]
    private ?string $name = null;

この状態で次のようなPOSTリクエストをすると、レコードが生成されます。

$ curl -X 'POST' \
  'http://localhost:8080/api/users' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "Alice",
}' | jq

{
  "id": 1,
  "name": "Alice",
  "createdAt": "2023-12-01",
  "updatedAt": "2023-12-01"
}

あまりないかもしれませんが、"user:post"を削除すると、POST /api/usersではbodyを受け取らなくなります。

src/Entity/User.php(一部)
-   #[Groups(groups: ['user:get', 'user:post', 'user:patch'])]
+   #[Groups(groups: ['user:get', 'user:patch'])]
    private ?string $name = null;

PATCH /api/usersに関しても、POST /api/usersの場合と同じように、denormalizationContextが重要なだけなので、説明は割愛します。

DBの自動更新

少しだけ話はSerializer/Deserializerから脱線します。テーブルの作成日時・更新日時・削除日などを自動で更新するために以下のパッケージを追加します。

$ composer require gedmo/doctrine-extensions
$ composer require stof/doctrine-extensions-bundle

追加したら、新規の設定ファイルを追加します。

config/packages/stof_doctrine_extensions.yaml
stof_doctrine_extensions:
  default_locale: en_US
  orm:
    default:
      timestampable: true
      softdeleteable: true

最後に、以下のファイルに次の行を追記します。

app/bundles.php
<?php

return [
+    Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true],
]

こうすることで、Entityの作成・更新時に自動的に値を設定してくれます。

2. ManyToOneで紐づくPrefectureクラスを考える

上記の1のような内容は、公式でも十分な資料が提供されていますが、ここからは「ドキュメントから探すのが大変で比較的有益な情報なのでは?」と思っています。とはいえ、ちゃんと公式でも以下のリンクのDenormalizationの項目で言及はされているのですが…。

https://api-platform.com/docs/core/serialization/

UserPrefectureクラスの定義

先にPrefectureクラスを考えます。単純なidと都道府県名を保持するnameフィールドからなるクラスです。リポジトリクラスはUserのと同じなのでコードは載せないです。

src/Entity/Prefecture.php
<?php

namespace App\Entity;

use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\PrefectureRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

#[ORM\Entity(repositoryClass: PrefectureRepository::class)]
#[ORM\Table(name: 'prefecture', options: ["comment" => '都道府県テーブル'])]
#[ApiResource(
    operations: [
        new Get(normalizationContext: ["groups" => ["prefecture:get"]]),
    ],
)]
class Prefecture
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[Groups(groups: ['prefecture:get', "user:get"])]
    #[ORM\Column(length: 8, options: ["comment" => '都道府県名'])]
    private ?string $name = null;

    public function __construct()
    {
    }

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

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

    public function setName(string $name): static
    {
        $this->name = $name;

        return $this;
    }
}

次にUserクラスにPrefectureがManyToOneの関係となるように以下のように追記します。

src/Entity/User.php(一部)
class User
{
(中略)
+   #[ORM\ManyToOne]
+   #[Assert\NotBlank(message: '都道府県を選択してください')]
+   #[Groups(groups: ['user:get', "user:post", "user:patch"])]
+   private ?Prefecture $prefecture = null;

(中略)
+   public function getPrefecture(): ?Prefecture
+   {
+       return $this->prefecture;
+   }

+   public function setPrefecture(?Prefecture $prefecture): static
+   {
+       $this->prefecture = $prefecture;
+       return $this;
+   }
}

これで、GETとPOSTを考える準備ができました。

GET /api/users/{id}を考える

データの存在有無を考えると順番が逆のような気もしますが、GETの方がわかりやすいので先に説明します。上記のクラスの状態でDBにPrefectureUserのデータが存在していて、GET /api/users/1を実行すると以下のような結果を得ます。

$ curl -X 'GET' \
  'http://localhost:8080/api/users/1' \
  -H 'accept: application/json' | jq 

{
  "id": "1",
  "name": "Alice",
  "prefecture": {
    "name": "北海道",
  }
  "created_at": "2023-12-01",
  "updated_at": "2023-12-01"
}

このような結果を得ることができるのは、Prefectureクラスのnameフィールドに"user:get"を指定しているためです。

src/Entity/Prefecture.php
-   #[Groups(groups: ['prefecture:get', "user:get"])]
+   #[Groups(groups: ['prefecture:get'])]
    #[ORM\Column(length: 8, options: ["comment" => '都道府県名'])]
    private ?string $name = null;

もし上記のGroupsが設定されていない以下のような状態の場合、GET /api/users/1を実行すると以下のような、"prefectures"の結果がIRIになってしまいます。

$ curl -X 'GET' \
  'http://localhost:8080/api/users/1' \
  -H 'accept: application/json' | jq 

{
  "id": "1",
  "name": "Alice",
  "prefecture": "/api/prefectures/1", 
  "created_at": "2023-12-01",
  "updated_at": "2023-12-01"
}

これは、以下のドキュメントのnormalizationの項目に書かれています。
https://api-platform.com/docs/core/serialization/

POST /api/usersを考える

Userクラスに新たにprefectureフィールドを追加したので、ユーザーを新規作成するPOST /api/usersを考えてみます。リクエストとレスポンスは次のようになります。

$ curl -X 'POST' \
  'http://localhost:8080/api/users' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "Alice",
  "prefecture": "/api/prefectures/1"
}' | jq

{
  "id": 1,
  "name": "Alice",
  "prefecture": {
    "name": "北海道"
  },
  "createdAt": "2023-12-01",
  "updatedAt": "2023-12-01"
}

IRIprefectureフィールドを指定するところがポイントです。こうすることでUserクラスのsetPrefectureの引数にPrefectureオブジェクトが設定されます。

src/Entity/User.php(一部・再掲)
    public function setPrefecture(?Prefecture $prefecture): static
    {
        $this->prefecture = $prefecture;
        return $this;
    }

このままでも、紐付けは可能なのですがいちいちIRIを指定するのは少し面倒です。そこで、次のようなdenormalizerを作成します。

src/Serializer/PrefectureDenomalizer.php
<?php

namespace App\Serializer;

use ApiPlatform\Api\IriConverterInterface;
use App\Entity\User;
use App\Entity\Prefecture;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;

class PrefectureDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
    use DenormalizerAwareTrait;
    
    public function __construct(
        private readonly IriConverterInterface $iriConverter,
    )
    {
    }

    /**
     * {@inheritdoc}
     * denormalizeの処理。受け取った`id`から`IRI`を生成しているだけ。
     * 返り値は`User`オブジェクトになる点に注意。
     */
    public function denormalize($data, $type, $format = null, array $context = [])
    {
        $data['prefecture'] = $this->iriConverter->getIriFromResource(resource: Prefecture::class, context: ['uri_variables' => ['id' => $data['prefecture']]]);
        return $this->denormalizer->denormalize($data, $type, $format, $context + [__CLASS__ => true]);
    }

    /**
     * {@inheritdoc}
     * この関数で特定のクラス(今回は`User`クラス)のDeserializeが走った時に、
     * $dataの中に特定のフィールド(今回は`prefecture`フィールド)が存在した場合にhookが実行される
     */
    public function supportsDenormalization($data, $type, $format = null, array $context = []): bool
    {
        return \in_array($format, ['json', 'jsonld'], true) && is_a($type, User::class, true) && !empty($data['prefecture']) && !isset($context[__CLASS__]);
    }
}

以下のページの「Plain Identifiers」の項目で説明がされています。ただ、公式のサンプルを利用するとDeprecatedなインタフェースが利用されているので、少し改変しています。一応、公式のサンプルもそのまま載せておきます。
https://api-platform.com/docs/core/serialization/

公式のDenormalizer.phpのサンプル。
Denormalizer.php
<?php
// api/src/Serializer/PlainIdentifierDenormalizer

namespace App\Serializer;

use ApiPlatform\Api\IriConverterInterface;
use App\Entity\Dummy;
use App\Entity\RelatedDummy;
use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;

class PlainIdentifierDenormalizer implements ContextAwareDenormalizerInterface, DenormalizerAwareInterface
{
    use DenormalizerAwareTrait;

    private $iriConverter;

    public function __construct(IriConverterInterface $iriConverter)
    {
        $this->iriConverter = $iriConverter;
    }

    /**
     * {@inheritdoc}
     */
    public function denormalize($data, $class, $format = null, array $context = [])
    {
        $data['relatedDummy'] = $this->iriConverter->getIriFromResource(resource: RelatedDummy::class, context: ['uri_variables' => ['id' => $data['relatedDummy']]]);

        return $this->denormalizer->denormalize($data, $class, $format, $context + [__CLASS__ => true]);
    }

    /**
     * {@inheritdoc}
     */
    public function supportsDenormalization($data, $type, $format = null, array $context = []): bool
    {
        return \in_array($format, ['json', 'jsonld'], true) && is_a($type, Dummy::class, true) && !empty($data['relatedDummy']) && !isset($context[__CLASS__]);
    }
}

上記のようなDenomalizerクラスを作成することで、リクエストのbodyを以下のようにできます。

(request-body)
{
  "name": "Alice",
  "prefecture": "1",
}

3. ManyToManyで紐づくBookクラスを考える

ManyToOneの時の挙動はDenormalizerを利用すればbodyにある文字列をIRIに変換して、オブジェクトを取得できました。少しだけ対応が変わりますが、同じような方法でManyToManyの場合にも対応できます。

UserBookクラスの定義

先ほどと同様に新規作成するBookクラスから考えます。単純なidと本のタイトルを保持するtitleフィールドからなるクラスです。先ほどと同様にリポジトリクラスはUserのと同じなのでコードは載せないです。

src/Entity/Book.php
<?php

namespace App\Entity;

use App\Repository\BookRepository;
use App\State\BookProvider;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: BookRepository::class)]
#[ORM\Table(name: 'book', options: ["comment" => '書籍テーブル'])]
#[Gedmo\SoftDeleteable(fieldName: 'deletedAt', timeAware: false)]
#[ApiResource(
    operations: [
        new Get(),
        new GetCollection(),
        new Post(denormalizationContext: ['groups' => ['book:post']]),
        new Patch(denormalizationContext: ['groups' => ['book:patch']]),
        new Delete(),
    ],
    normalizationContext: ['groups' => ['book:get']],
)]
class Book
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['book:get', 'book:post', 'book:patch', "user:get"])]
    #[Assert\NotBlank(message: 'タイトルを指定してください')]
    private string $title;

    /** @var Collection<User> */
    #[ORM\ManyToMany(targetEntity: User::class, inversedBy: "books")]
    private Collection $users;

    #[ORM\Column(nullable: true, options: ["comment" => '削除日時'])]
    private ?DateTimeImmutable $deletedAt = null;

    #[Groups(['book:get', "user:get"])]
    #[ORM\Column(updatable: false, options: [ 'comment' => '作成日時' ])]
    #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
    #[Gedmo\Timestampable(on: 'create')]
    private ?DateTimeImmutable $createdAt = null;

    #[Groups(['book:get', "user:get"])]
    #[ORM\Column(options: [ 'comment' => '更新日時' ])]
    #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
    #[Gedmo\Timestampable(on: 'update')]
    private ?DateTimeImmutable $updatedAt = null;

    public function __construct()
    {
        $this->users = new ArrayCollection();
    }

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

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): static
    {
        $this->title = $title;

        return $this;
    }

    /** @return Collection<User> */
    public function getUsers(): Collection
    {
        return $this->users;
    }

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

    public function getUpdatedAt(): DateTimeImmutable
    {
        return $this->updatedAt;
    }
}

次に、UserクラスがBookクラスとManyToManyの関係になるように、以下のようにアトリビュートを追記します。

src/Entity/User.php(一部)
+ #[ApiResource(
+   operations: [
+       new Post(
+           uriTemplate: "/users/{id}/books",
+           uriVariables: [
+               'id' => new Link(fromClass: User::class),
+           ],
+           denormalizationContext: ["groups" => ["user-book:post"]],
+           processor: UserBookProcessor::class,
+       ),
+   ],
+   normalizationContext: ["groups" => ["user:get"]],
+ )]
class User
{
(中略)
+   /**
+    * @var Collection<Book>
+    */
+   #[Groups(groups: ["user:get", "user-book:post"])]
+   #[MaxDepth(1)]
+   #[ORM\ManyToMany(targetEntity: Book::class, mappedBy: "users")]
+   private Collection $books;
(中略)
+   public function __construct()
+   {
+       $this->books = new ArrayCollection();
+   }
(中略)
+   /** @return Collection<Book> */
+   public function getBooks(): Collection
+   {
+       return $this->books;
+   }
+   /** @param Collection<Book> $book */
+   public function setBooks(Collection $book): static
+   {
+       $this->books = $book;
+       return $this;
+   }
(中略)
}

これで、準備はできたのでメソッドのリクエストについてみていきます。

GET /api/users/{id}を考える

先ほど同様に、上記のクラスの状態でDBにBookUserPrefectureのすでにデータが存在している状態を考えます。GET /api/users/1を実行すると以下のような結果を得ます。

$ curl -X 'GET' \
  'http://localhost:8080/api/users/1' \
  -H 'accept: application/json' | jq 

{
  "id": "1",
  "name": "Alice",
  "prefecture": {
    "name": "北海道",
  },
  "books": [
    {
      "title": "書籍1",
      "createdAt": "2023-12-01",
      "updatedAt": "2023-12-01"
    },
    {
      "title": "書籍2"
      "createdAt": "2023-12-01",
      "updatedAt": "2023-12-01"
    },
  ],
  "created_at": "2023-12-01",
  "updated_at": "2023-12-01"
}

このような結果を得ることができるのは、Prefectureの時と同様にBookクラスのtitleフィールドに"user:get"を指定しているためです。シリアライズのGroupsはクラスを超えて適用されるので便利ですね。Groupsを外した時の挙動も同じなのでこれ以上の説明はしないです。

POST /api/users/{id}/booksを考える

ManyToManyのPOST /api/users/{id}/booksを考えてみます。これは既存の{id}のユーザーが既存の書籍を新規に所持することを登録するAPIを想定しています。

src/Entity/User.php(一部・再掲)
+ #[ApiResource(
+   operations: [
+       new Post(
+           uriTemplate: "/users/{id}/books",
            // `id`パラメータがどのクラスと対応しているかを指定している
+           uriVariables: [
+               'id' => new Link(fromClass: User::class),
+           ],
+           denormalizationContext: ["groups" => ["user-book:post"]],
+           processor: UserBookProcessor::class,
+       ),
+   ],
+   normalizationContext: ["groups" => ["user:get"]],
+ )]
class User
{
(後略)

上記のエンドポイントに対して、次のリクエストを送信して期待するレスポンスが返ることを考えます。

$ curl -X 'POST' \
  'http://localhost:8080/api/users/1/books' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "books": [
    // すでにid=1の書籍1とid=2の書籍2がDBに登録されており、
    // それを`{id}`のユーザーと紐付ける処理
    "1", "2"
  ]
}' | jq

{
  "id": 1,
  "name": "Alice",
  "books": [
    {
      "title": "書籍1",
      "createdAt": "2023-12-01",
      "updatedAt": "2023-12-01"
    },
    {
      "title": "書籍2",
      "createdAt": "2023-12-01",
      "updatedAt": "2023-12-01"
    }
  ],
  "prefecture": {
    "name": "北海道"
  },
  "createdAt": "2023-12-01",
  "updatedAt": "2023-12-01"
}

ManyToOneの時と同じように、Denormalizerをそのまま利用したら良さそうなのですが、与える引数のがリストと文字列型とで異なるエラーが出てうまくできません。そこで、Denormalizerのクラスに少し工夫を加えます。

denormalize()関数の引数$dataには、リクエストのBodyの内容が含まれています。Prefectureの場合は、単一のIRIprefectureのidが入ってきていたので問題はありませんでした。しかし、Bookの場合はリストで値が入ってきます。Prefectureと同じように実行すると$this->denormalizer->denormalizeの箇所で、期待する型と異なるエラーが出てしまいます。そのため、$data["books"]の要素ごとにBookオブジェクトを作成してリストにする、という処理をしています。

src/Serializer/BookDenomalizer.php
<?php

namespace App\Serializer;

use ApiPlatform\Api\IriConverterInterface;
use Doctrine\Common\Collections\ArrayCollection;
use App\Entity\User;
use App\Entity\Book;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;

class BookDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
    use DenormalizerAwareTrait;

    public function __construct(
        private readonly IriConverterInterface $iriConverter,
    )
    {
    }

    /**
     * {@inheritdoc}
     */
    public function denormalize($data, $type, $format = null, array $context = [])
    {
        $books = new ArrayCollection();
        // $dataにある"books"からBookオブジェクトを生成して、リストに追加
        foreach($data['books'] as $index => $bookData) {
            $iri = $this->iriConverter->getIriFromResource(resource: Book::class, context: ['uri_variables' => ['id' => $bookData]]);
            $book = $this->denormalizer->denormalize($iri, $type, $format, $context + [__CLASS__ => true]);
            $books->add($book);
        }
        // $dataから"books"を削除
        unset($data["books"]);
        $user = $this->denormalizer->denormalize($data, $type, $format, $context + [__CLASS__ => true]);
        $user->setBooks($books);
        return $user;
    }

    /**
     * {@inheritdoc}
     */
    public function supportsDenormalization($data, $type, $format = null, array $context = []): bool
    {
        return \in_array($format, ['json', 'jsonld'], true) && is_a($type, User::class, true) && !empty($data['books']) && !isset($context[__CLASS__]);
    }
}

このようなDenormalizerを間に挟むことで、DBに正しくデータが登録されるようになります。

おわりに

ここまでざっとですが、2週間ほど触ってみて得られた知見です! API Platformを使い初めた時は「めっちゃ使いづらいな」と思う時もありましたが、今は「辛い部分はまだまだ多そうだけれど、Simpleな感じになりそうでいいのでは?」と思うようになりました。

この記事が誰かのお役に立てば幸いです!

GitHubで編集を提案

Discussion