Symfony + API PlatformでGraphQL APIを実装する方法
メリークリスマスイブ!
Symfony Advent Calendar 2021 の24日目の記事です!🎄🌙
ちなみに、僕はよく TwitterにもSymfonyネタを呟いている ので、よろしければぜひ フォローしてやってください🕊🤲
昨日は @ippey_s さんの Symfony版Livewireこと、Live Componentさん でした✨
API Platformとは
API Platform はSymfony + DoctrineベースのオープンソースAPIフレームワークで、簡単な設定を書くだけでSymfonyアプリにREST APIやGraphQL APIを実装することができる便利なやつです。
今回はSymfony + API PlatformでGraphQL APIを実装する手順を順を追って解説してみたいと思います👍
なお、この記事で実装したコードは以下のリポジトリで公開していますので、ぜひあわせてご参照ください。
https://github.com/ttskch/symfony-api-platform-graphql-example
1. まずはSymfonyアプリを作成
記事執筆時点(2021/12/22時点)で API PlatformがまだSymfony 6に対応していない ので、今回はSymfony 5を使用します。
$ composer create-project symfony/skeleton:^5.0 symfony-api-platform-graphql-example
$ cd symfony-api-platform-graphql-example
2. API Platformをインストール
$ composer require api -n # 確認なしでレシピを実行
この時点で、Symfonyアプリを起動して /api
にアクセスすれば、Swagger UIで書かれたREST APIドキュメントを見ることができます。(まだ内容は空ですが)
$ symfony serve -d
$ open -a "Google Chrome" http://localhost:8000/api
GraphQLに対応させるには少し追加の設定が必要なのですが、まずは一旦REST APIの動作を確認しながら基本的な準備を進めていくことにしましょう✋
3. エンティティを作る
ではまず何か適当なエンティティを作ってみましょう。
API Platformの依存としてDoctrineはすでに一式インストールされているので、.env
で DATABASE_URL
を設定するだけですぐにDBを利用できます。今回はSQLiteを使うことにします。
# .env
- # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
+ DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7"
- DATABASE_URL="postgresql://symfony:ChangeMe@127.0.0.1:5432/app?serverVersion=13&charset=utf8"
+ # DATABASE_URL="postgresql://symfony:ChangeMe@127.0.0.1:5432/app?serverVersion=13&charset=utf8"
$ bin/console doctrine:database:create
これでDBは開通なので、次は実際にエンティティを作ります。
楽をしたいので MakerBundle を入れておきましょう。
$ composer require maker
make
コマンドを使ってエンティティを作ります。ここでは「ブログ投稿」を表す Post
というエンティティを作ってみましょう。
$ bin/console make:entity Post
created: src/Entity/Post.php
created: src/Repository/PostRepository.php
# 略
New property name (press <return> to stop adding fields):
> title
Field type (enter ? to see all types) [string]:
> text
Can this field be null in the database (nullable) (yes/no) [no]:
>
updated: src/Entity/Post.php
Add another property? Enter the property name (or press <return> to stop adding fields):
> body
Field type (enter ? to see all types) [string]:
> text
Can this field be null in the database (nullable) (yes/no) [no]:
>
updated: src/Entity/Post.php
Add another property? Enter the property name (or press <return> to stop adding fields):
> published
Field type (enter ? to see all types) [string]:
> boolean
Can this field be null in the database (nullable) (yes/no) [no]:
> yes
updated: src/Entity/Post.php
Add another property? Enter the property name (or press <return> to stop adding fields):
> date
Field type (enter ? to see all types) [string]:
> date
Can this field be null in the database (nullable) (yes/no) [no]:
>
updated: src/Entity/Post.php
Add another property? Enter the property name (or press <return> to stop adding fields):
>
Success!
これで、以下の4つのプロパティを持つ Post
エンティティができました。
プロパティ名 | 型 | 用途 |
---|---|---|
$title |
text | タイトル |
$body |
text | 本文 |
$published |
boolean | 公開済みフラグ |
$date |
date | 投稿日 |
DBに反映します。
$ bin/console doctrine:migrations:diff
$ bin/console doctrine:migrations:migrate -n
最後に、エンティティをAPI Platformに認識させるため、以下のように @ApiResource()
アノテーションを付加します。
今回はSymfony 5の流儀に従ってアトリビュートではなくアノテーションで書きます🙏
+ use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\PostRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity(repositoryClass=PostRepository::class)
+ * @ApiResource()
*/
class Post
この状態で /api
の画面をリロードしてみると、以下のように Post
エンティティのCRUDのAPIが作成されていることが分かります。便利!
試しに、REST API経由で実際にデータを作成してみましょう。
動いていますね!
4. GraphQL APIを有効にする
REST APIで動作確認するのはここまでにして、ここからはGraphQL APIを作っていきましょう。
API PlatformはREST APIだけでなく GraphQL APIにも対応しています。
アプリケーションに webonyx/graphql-php をインストールすれば、それだけでGraphQL APIが有効になります。
$ composer require webonyx/graphql-php
これで、/api/graphql
で GraphiQL が、/api/graphql/graphql_playground
で GraphQL Playground が使えるようになります。
もしこれらのIDEが不要な場合は、以下のようにして無効にすることもできます。
# api/config/packages/api_platform.yaml api_platform: graphql: graphiql: enabled: false graphql_playground: enabled: false
試しにGraphiQLを使って実際に Post
の一覧を取得してみましょう。
問題なく取得できていますね!
Resolver
)
5. オペレーションを自作する(デフォルトでは基本的なCRUDに必要な以下のオペレーションがすべて有効になります。(参考)
- Query
item_query
collection_query
- Mutation
create
update
delete
特定のオペレーションだけを有効にしたい場合は、以下のようにエンティティの @ApiResource()
アノテーション内で有効にしたいオペレーションを明示します。
/**
* @ORM\Entity(repositoryClass=PostRepository::class)
* @ApiResource(
* graphql={"item_query", "create"}
* )
*/
class Post
こうすると item_query
と create
以外のオペレーションは無効になります。
また、より複雑なAPIを実装する場合、オペレーションを自作することも必要になってくるでしょう。このような場合には、Resolver
と呼ばれるクラスを作り、そこにオペレーションの内容を記述します。
ここでは例として、create
オペレーションをカスタマイズして、投稿作成時に date
引数が省略された場合には Post::date
を自動でセットするようにしてみましょう。
このような処理はAPIのレイヤーで行うよりDoctrineのレイヤーで永続化の直前に行うほうが一般的かつ適切だと思いますが、他に適当な例が思いつかなかったのでここでは気にせず受け入れてください🙏
まず、以下のような Resolver
クラスを作成します。
<?php
// src/ApiPlatform/GraphQL/Resolver/Post/CreateResolver.php
namespace App\ApiPlatform\GraphQL\Resolver\Post;
use ApiPlatform\Core\GraphQl\Resolver\MutationResolverInterface;
use App\Entity\Post;
class CreateResolver implements MutationResolverInterface
{
/**
* @param Post|null $post
*/
public function __invoke($post, array $context): ?Post
{
if (!$post instanceof Post) {
return null;
}
// $context['args'] にオペレーションに渡された引数が入っている
$post->setDate(new \DateTime($context['args']['input']['date'] ?? 'today'));
return $post;
}
}
そして、Post
エンティティの @ApiResource()
アノテーションを以下のように変更します。
/**
* @ORM\Entity(repositoryClass=PostRepository::class)
* @ApiResource(
* graphql={
* "item_query",
* "collection_query",
* "create"={
* "mutation"=CreateResolver::class,
* "args"={
* "title"={
* "type"="String!",
* },
* "body"={
* "type"="String!",
* },
* "published"={
* "type"="Boolean",
* },
* "date"={
* "type"="String",
* },
* },
* },
* "update",
* "delete",
* }
* )
*/
class Post
- デフォルトの5つのオペレーションを明示(
create
だけしか書かないと他のオペレーションが無効になってしまうので) -
create
オペレーションをCreateResolver
を使ったMutationとして定義 -
create
オペレーションの引数を、date
の型だけをデフォルトのString!
からString
(任意)に変えて列挙(date
だけしか書かないと他の引数が無効になってしまうので)
ということをやっています。
では、実際に date
引数を指定せずに create
オペレーションを実行してみましょう。
実際のオペレーション名には、API Platformによって末尾にエンティティ名(ここでは
Post
)が付加されます。具体的なインターフェースはGraphiQLの右カラムに表示されているドキュメントから知ることができます。
期待どおりに動きましたね!(記事執筆時点の日付が2021/12/22なので、date
が自動的に2021/12/22になっています)
mutation
だけでなく、item_query
や collection_query
の自作もほぼ同様の手順で対応可能です。
また、もちろん今回のようにデフォルトのオペレーションを上書きするだけでなく、新たなオペレーションを追加することも可能です。
詳細は公式ドキュメントの以下の箇所あたりをご参照ください。
6. アノテーションではなくYAMLで設定する
ところで、Post
エンティティの @ApiResource()
アノテーションがすでに長すぎて可読性が低いので、ここらでアノテーションではなくYAMLで設定するように変更しておきましょう✋
アノテーションをごっそり削除して、
/**
* @ORM\Entity(repositoryClass=PostRepository::class)
- * @ApiResource(
- * graphql={
- * "item_query",
- * "collection_query",
- * "create"={
- * "mutation"=CreateResolver::class,
- * "args"={
- * "title"={
- * "type"="String!",
- * },
- * "body"={
- * "type"="String!",
- * },
- * "published"={
- * "type"="Boolean",
- * },
- * "date"={
- * "type"="String",
- * },
- * },
- * },
- * "update",
- * "delete",
- * }
- * )
*/
class Post
代わりに config/packages/api_platform/post.yaml
といったファイルに以下のようにYAML形式で記述します。
App\Entity\Post:
graphql:
item_query: ~
collection_query: ~
create:
mutation: App\ApiPlatform\GraphQL\Resolver\Post\CreateResolver
args:
title:
type: String!
body:
type: String!
published:
type: Boolean
date:
type: String
update: ~
delete: ~
そして、config/packages/api_platform.yaml
で以下のようにこのYAMLファイルを読み込むようにします。
api_platform:
mapping:
- paths: ['%kernel.project_dir%/src/Entity']
+ paths:
+ - '%kernel.project_dir%/src/Entity'
+ - '%kernel.project_dir%/config/packages/api_platform'
これで、@ApiResource()
アノテーションに加えて config/packages/api_platform/
配下のYAMLファイルでもAPIリソースを定義できるようになりました。
DataProvider
)
7. クエリの結果セットをカスタムする(オペレーションの自作だけでなく、クエリの結果セットをカスタムしたくなることも多々あります。このような場合には、DataProvider
と呼ばれるクラスを作り、そこに結果セットの構築処理を記述します。
例として、投稿の一覧取得では 公開済みフラグが true
のものだけしか出力しない ようにしてみましょう。
以下のような DataProvider
クラスを作成すれば、supports()
メソッドの働きによって自動でAPIの挙動に適用されます。(ただし、後述しますがこの時点では実装として未完成です)
<?php
// src/ApiPlatform/DataProvider/PostCollectionDataProvider.php
namespace App\ApiPlatform\DataProvider;
use ApiPlatform\Core\DataProvider\ArrayPaginator;
use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use App\Entity\Post;
use App\Repository\PostRepository;
class PostCollectionDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface
{
public function __construct(private PostRepository $repository)
{
}
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return $resourceClass === Post::class;
}
public function getCollection(string $resourceClass, string $operationName = null, array $context = []): iterable
{
$array = $this->repository->createQueryBuilder('p')
->andWhere('p.published = 1')
->getQuery()
->getResult()
;
// 戻り値は \ApiPlatform\Core\DataProvider\PartialPaginatorInterface の実装である必要がある
return new ArrayPaginator($array, 0, count($array));
}
}
collection_query
オペレーションを実行してみると、
投稿自体は現在2件存在しているはずですが、公開済みの投稿が存在しないため結果が0件となっています👌
update
オペレーションを使って2件目の投稿のみ公開済みに変更してみましょう。
これで再度 collection_query
オペレーションを実行してみると、
確かに公開済みにした1件だけが出力されました👌
DataProvider
でページネーションやフィルタなどの基本機能を有効にする
8. 自作した ところで、実は先ほどの PostCollectionDataProvider
の実装では、デフォルトでは特に何も考えなくても使えていた ページネーションやフィルタといった基本機能が使えなくなっています。
試しに collection_query
オペレーションを (first: 0)
(最初の0件を取得、つまり1件も取得しない)という引数付きで実行してみると、
普通に1件が取得されてしまってページネーションが効いていないことが分かります。
実はAPI Platformでは(デフォルトの実装も含めた)DataProvider
用の基本機能が Extension
という形でモジュール化されており、DataProvider
を自作した場合はデフォルトで用意されている Extension
を明示的に適用してあげる必要があります。(参考)
具体的には、以下のようにコンストラクタインジェクションで iterable $collectionExtensions
を受け取って、それらを順に適用していく、というコードを追記します。
「適用する」手順については特に深く考えずにドキュメントのとおりに書いても動きますが、詳しく知りたい方は以下のデフォルトの実装を見ると参考になるかと思います。
<?php
namespace App\ApiPlatform\DataProvider;
+ use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryResultCollectionExtensionInterface;
+ use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGenerator;
use ApiPlatform\Core\DataProvider\ArrayPaginator;
use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use App\Entity\Post;
use App\Repository\PostRepository;
class PostCollectionDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface
{
- public function __construct(private PostRepository $repository)
- {
- }
+ public function __construct(
+ private PostRepository $repository,
+ private iterable $collectionExtensions,
+ ) {}
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return $resourceClass === Post::class;
}
public function getCollection(string $resourceClass, string $operationName = null, array $context = []): iterable
{
- $array = $this->repository->createQueryBuilder('p')
+ $qb = $this->repository->createQueryBuilder('p')
->andWhere('p.published = 1')
- ->getQuery()
- ->getResult()
;
+ $queryNameGenerator = new QueryNameGenerator();
+ foreach ($this->collectionExtensions as $extension) {
+ $extension->applyToCollection($qb, $queryNameGenerator, $resourceClass, $operationName, $context);
+
+ if ($extension instanceof QueryResultCollectionExtensionInterface && $extension->supportsResult($resourceClass, $operationName, $context)) {
+ return $extension->getResult($qb);
+ }
+ }
+
+ $array = $qb->getQuery()->getResult();
+
return new ArrayPaginator($array, 0, count($array));
}
}
コンストラクタ引数の iterable $collectionExtensions
は config/services.yaml
で以下のように明示的に渡してあげる必要があります。
services:
App\ApiPlatform\DataProvider\PostCollectionDataProvider:
arguments:
$collectionExtensions: !tagged api_platform.doctrine.orm.query_extension.collection
これもドキュメントに書かれているとおりですが、基本機能の Extension
はすべて api_platform.doctrine.orm.query_extension.collection
でタグ付けされているので、何も考えずにこれらをまとめて注入してあげればよいというわけです。
これで、無事にページネーション(等の基本機能)が動作するようになりました👍
ちなみに、複数のエンティティについて一覧系の DataProvider
を自作する場合、すべての DataProvider
に「Extension
を適用する処理」を書かなければならないので、以下のような感じで Trait
にしておくと便利だと思います。
<?php
// src/ApiPlatform/DataProvider/Traits/CollectionDataProviderTrait.php
namespace App\ApiPlatform\DataProvider\Traits;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryResultCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGenerator;
use ApiPlatform\Core\DataProvider\ArrayPaginator;
use Doctrine\ORM\QueryBuilder;
/**
* @property iterable<QueryCollectionExtensionInterface> $collectionExtensions
*/
trait CollectionDataProviderTrait
{
protected function getResult(QueryBuilder $qb, string $resourceClass, string $operationName = null, array $context = []): iterable
{
$queryNameGenerator = new QueryNameGenerator();
foreach ($this->collectionExtensions as $extension) {
$extension->applyToCollection($qb, $queryNameGenerator, $resourceClass, $operationName, $context);
if ($extension instanceof QueryResultCollectionExtensionInterface && $extension->supportsResult($resourceClass, $operationName, $context)) {
return $extension->getResult($qb);
}
}
$array = $qb->getQuery()->getResult();
return new ArrayPaginator($array, 0, count($array));
}
}
<?php
namespace App\ApiPlatform\DataProvider;
- use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryResultCollectionExtensionInterface;
- use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGenerator;
- use ApiPlatform\Core\DataProvider\ArrayPaginator;
use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
+ use App\ApiPlatform\DataProvider\Traits\CollectionDataProviderTrait;
use App\Entity\Post;
use App\Repository\PostRepository;
class PostCollectionDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface
{
+ use CollectionDataProviderTrait;
+
public function __construct(
private PostRepository $repository,
private iterable $collectionExtensions,
) {}
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return $resourceClass === Post::class;
}
public function getCollection(string $resourceClass, string $operationName = null, array $context = []): iterable
{
$qb = $this->repository->createQueryBuilder('p')
->andWhere('p.published = 1')
;
- $queryNameGenerator = new QueryNameGenerator();
- foreach ($this->collectionExtensions as $extension) {
- $extension->applyToCollection($qb, $queryNameGenerator, $resourceClass, $operationName, $context);
-
- if ($extension instanceof QueryResultCollectionExtensionInterface && $extension->supportsResult($resourceClass, $operationName, $context)) {
- return $extension->getResult($qb);
- }
- }
-
- $array = $qb->getQuery()->getResult();
-
- return new ArrayPaginator($array, 0, count($array));
+ return $this->getResult($qb, $resourceClass, $operationName, $context);
}
}
また、collection_query
だけでなく item_query
のカスタマイズもほぼ同様の手順で対応可能です。
詳細は(既出ですが)下記の公式ドキュメントをご参照ください。
9. 一覧取得APIに絞り込みと並べ替えを実装する
collection_query
の結果セットを絞り込んだり並べ替えたりできる フィルタ という機能があります。
例えば、投稿一覧について
- 投稿日で絞り込めるように
- 投稿日で並べ替えられるように
するには、以下のようにすればよいです。
まず、絞り込み用と並べ替え用のフィルタをそれぞれサービスとして定義します。
# config/services.yaml
services:
api.post.date_filter:
parent: api_platform.doctrine.orm.date_filter
arguments: [{date: ~}]
tags: [api_platform.filter]
autowire: false
autoconfigure: false
api.post.order_filter:
parent: api_platform.doctrine.orm.order_filter
arguments:
$properties: {date: ~}
$orderParameterName: order
tags: [api_platform.filter]
autowire: false
autoconfigure: false
そして、config/packages/api_platform/post.yaml
に以下のように設定を追加します。
App\Entity\Post:
+ attributes:
+ filters:
+ - api.post.date_filter
+ - api.post.order_filter
graphql:
# 略
あるいは、YAMLとアノテーションに設定が分散することを許容するなら(または設定をすべてアノテーションで書くなら)、わざわざサービスを定義せずに以下のようにアノテーションを2行書くだけでも同じ設定が可能です。
+ use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\DateFilter; + use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\OrderFilter; /** * @ORM\Entity(repositoryClass=PostRepository::class) + * @ApiFilter(DateFilter::class, properties={"date"}) + * @ApiFilter(OrderFilter::class, properties={"date"}, arguments={"orderParameterName"="order"}) */ class Post
(いくつか公開済みの投稿を増やした上で)実行すると以下のような感じになります👌
詳細は下記の公式ドキュメントをご参照ください。
ドキュメントの間違い?
ちなみに、https://api-platform.com/docs/core/graphql/#filters を見ると
App\Entity\Post:
attributes:
filters:
- api.post.date_filter
- api.post.order_filter
ではなく
App\Entity\Post:
graphql:
collection_query:
filters:
- api.post.date_filter
- api.post.order_filter
とすることで、REST APIや他のオペレーションに波及させずにGraphQL APIの collection_query
にだけフィルタを適用することができるようなことが書いてありますが、記事執筆時点ではこの書き方だと期待どおり動作しません🤔
ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\FilterExtension
のここApiPlatform\Core\Bridge\Doctrine\Orm\Extension\OrderExtension
のここ
あたりで ApiPlatform\Core\Metadata\Resource\ResourceMetadata::getCollectionOperationAttribute()
を呼んでいますが、その先で呼ばれる ApiPlatform\Core\Metadata\Resource\ResourceMetadata::findOperationAttribute()
の実装 を見る限り、APIリソース設定の 'graphql'
キーに対する設定値は別の変数に格納されていて ここでは使用されないように見えます。
IssueやPRも見つけられず、バグなのか仕様なのかもよく分かっていません😓もし詳しい方いらっしゃったらぜひ 教えていただけると 嬉しいです🙏
10. REST APIを無効にする
GraphQL APIだけを有効にしてREST APIは無効にしておきたいという場合も多いと思いますが、残念ながらシュッと設定する方法は用意されておらず、
- 有効なエンドポイントをアイテムのGETとコレクションのGETのみにする
- それぞれのレスポンスを常に404にする
という泥臭い設定をする必要があります。
api_platform:
mapping:
paths:
- '%kernel.project_dir%/src/Entity'
- '%kernel.project_dir%/config/packages/api_platform'
patch_formats:
json: ['application/merge-patch+json']
swagger:
versions: [3]
+
+ defaults:
+ item_operations:
+ get:
+ controller: ApiPlatform\Core\Action\NotFoundAction
+ read: false
+ output: false
+ collection_operations:
+ get:
+ controller: ApiPlatform\Core\Action\NotFoundAction
+ read: false
+ output: false
参考:https://github.com/api-platform/core/issues/2796#issuecomment-606729715
おまけ:ログインユーザーごとにアクセス可否を制御
Securityコンポーネントを導入してログインユーザーごとにアクセス可否を制御したい場合は、APIリソース設定に security
というキーが用意されていて、お馴染みの is_granted
などが使えるので、通常のSymfonyアプリを作るときと同じ要領で対応可能です👍
App\Entity\Post:
graphql:
item_query:
security: is_granted('VIEW', object) # Postエンティティ用のSecurity VoterでVIEWという権限が実装されているイメージ
collection_query:
security: is_granted('ROLE_USER')
詳細は下記の公式ドキュメントをご参照ください。
おわりに
というわけで、Symfony + API Platform でGraphQL APIを実装する手順をまあまあ細かく解説してみました!
これだけ知っていれば基本的なGraphQL APIはまあだいたい実装できるのではないかと思います。よろしければ参考にしてみてください!
Symfony Advent Calendar 2021、明日は @77web さんです!お楽しみに!
Discussion