API Platformで更新処理をしてみる
これは API Platform Advent Calendar 2023 の20日目の記事です。
はじめに
以下のAPI Platformに関する記事を書いてから5ヶ月ほどが経ちました。しばらくAPI Platformを触っていなかったのですが、最近ガッツリと触ってどうやって扱ったらいいかわかってきたので、現時点で理解している内容をまとめて記事にします。
まとめる内容は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のデータの流れ
図を簡単に説明します。中央にあるObject
とFormat
はSymfonyの機能で公式ドキュメントより参照しています。そのほかの緑色の部分はAPI Platformが提供している機能を表しています。流れとしては以下のようになっています。
-
StateProvider
がパスパラメータを受け取ってDBからObjectを取得する - 取得したObjectへリクエストのbodyからの結果を元にObjectのSetterを介して値を更新する
- 更新したObjectは
StateProcessor
でDBへ処理され、レスポンスの結果として利用される
構成
構成は以下の通りです。最近Symfonyの6.4が出たので使っています。一方でPHPも8.3が出ましたが一旦ここでの利用は見送っています。
- PHP: 8.2
- Symfony: 6.4
- API Platform: 3.2
- MySQL: 8.0
コード
本記事では次のステップでUser
クラスに色々な関係を付与していきます。
-
User
クラスで、Groups
を使ってSerializer/Deserializer
を制御しつつGET/POSTを扱う -
User
クラスとManyToOneで紐づくPrefecture
クラスを考えて、GET/POSTを扱う -
User
クラスとManyToManyで紐づくBook
クラスを考えて、GET/POSTを扱う
本記事で扱うエンティティの関係と、掲載する順番を示した図。色付きの四角は影響のあるエンティティの範囲を、矢印はリクエストの流れをイメージしている
この記事のコードは以下のGitHubリポジトリにあります。本記事では省略している箇所もあるので適宜参照してください。
User
クラスを考える
1. 単一の前回の記事はBook
を対象にしていましたが、今回はUser
クラスから考えていきます。Groups
のアトリビュートを設定していますが、あとで説明します。
<?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のコード。
<?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"]]
のように指定すれば、特定のメソッドの入出力を制御できるわけです。
デフォルトでは、クラスのgetter
とsetter
が指定されているフィールドに対しては全てこのSerializer/Deserializer
が動くため、#[ApiResource]
というアトリビュートを付与しただけで、動くということも理解できます。
GET /api/users/{id}
を考える
UserクラスのGroups
アトリビュートがついたフィールドのみ抽出すると以下のようになります。
#[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"
がついたフィールドのみシリアライズするという意味です。
#[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
が含まれないようになります。
- #[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"
}
このようにして、Groups
とnormalizationContext: ["groups" => ["user:get"]]
でレスポンスの形を制御できます。
今回は、フィールドの宣言にのみGroups
を指定しましたが、getter/setter
メソッドに指定しても同様のことができます。詳細は以下の公式記事を参照してください。
POST /api/users
を考える
同じように、POST /api/users
を考えてみます。このPOST
にはnormalizationContext
とdenormalizationContext
を指定しています。normalizationContext
はGETの時と同じレスポンス時に何を返すかを指定します。"user:get"
を指定しているので、レスポンスの形はGETと同じという意味です。
POSTで重要になってくるのはdenormalizationContext
の方だと思っています。これはリクエストの中に含まれるbodyのどの値をクラスのどのフィールドにsetするか指定できます。
new Post(
normalizationContext: ["groups" => ["user:get"]],
denormalizationContext: ["groups" => ["user:post"]]
),
UserクラスのGroups
アトリビュートの"user:post"
がついたフィールドのみ抽出すると以下のようになります。これは、POSTする時には、name
フィールドのみ送信できることを意味しています。
#[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を受け取らなくなります。
- #[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
追加したら、新規の設定ファイルを追加します。
stof_doctrine_extensions:
default_locale: en_US
orm:
default:
timestampable: true
softdeleteable: true
最後に、以下のファイルに次の行を追記します。
<?php
return [
+ Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true],
]
こうすることで、Entityの作成・更新時に自動的に値を設定してくれます。
Prefecture
クラスを考える
2. ManyToOneで紐づく上記の1のような内容は、公式でも十分な資料が提供されていますが、ここからは「ドキュメントから探すのが大変で比較的有益な情報なのでは?」と思っています。とはいえ、ちゃんと公式でも以下のリンクのDenormalization
の項目で言及はされているのですが…。
User
とPrefecture
クラスの定義
先にPrefecture
クラスを考えます。単純なid
と都道府県名を保持するname
フィールドからなるクラスです。リポジトリクラスはUser
のと同じなのでコードは載せないです。
<?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の関係となるように以下のように追記します。
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にPrefecture
とUser
のデータが存在していて、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"
を指定しているためです。
- #[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
の項目に書かれています。
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"
}
IRI
でprefecture
フィールドを指定するところがポイントです。こうすることでUser
クラスのsetPrefecture
の引数にPrefecture
オブジェクトが設定されます。
public function setPrefecture(?Prefecture $prefecture): static
{
$this->prefecture = $prefecture;
return $this;
}
このままでも、紐付けは可能なのですがいちいちIRI
を指定するのは少し面倒です。そこで、次のようなdenormalizer
を作成します。
<?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なインタフェースが利用されているので、少し改変しています。一応、公式のサンプルもそのまま載せておきます。
公式の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を以下のようにできます。
{
"name": "Alice",
"prefecture": "1",
}
Book
クラスを考える
3. ManyToManyで紐づくManyToOneの時の挙動はDenormalizerを利用すればbodyにある文字列をIRI
に変換して、オブジェクトを取得できました。少しだけ対応が変わりますが、同じような方法でManyToManyの場合にも対応できます。
User
とBook
クラスの定義
先ほどと同様に新規作成するBook
クラスから考えます。単純なid
と本のタイトルを保持するtitle
フィールドからなるクラスです。先ほどと同様にリポジトリクラスはUser
のと同じなのでコードは載せないです。
<?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の関係になるように、以下のようにアトリビュートを追記します。
+ #[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にBook
とUser
とPrefecture
のすでにデータが存在している状態を考えます。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を想定しています。
+ #[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
の場合は、単一のIRI
かprefectureのid
が入ってきていたので問題はありませんでした。しかし、Book
の場合はリストで値が入ってきます。Prefecture
と同じように実行すると$this->denormalizer->denormalize
の箇所で、期待する型と異なるエラーが出てしまいます。そのため、$data["books"]
の要素ごとにBook
オブジェクトを作成してリストにする、という処理をしています。
<?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な感じになりそうでいいのでは?」と思うようになりました。
この記事が誰かのお役に立てば幸いです!
Discussion