API PlatformのOpenAPI生成で、プロパティのrequiredをreadのスキーマにだけ適用する
Symfony Advent Calendar 2022 の4日目の記事です!🎄🌙
ちなみに、僕はよく TwitterにもSymfonyネタを呟いている ので、よろしければぜひ フォローしてやってください🕊🤲
昨日は @bezeklik さんの "Symfony 6 + EasyAdmin 4 で管理画面を生成する" でした✨
はじめに
- 本稿で取り扱うAPI Platformのバージョンは、記事執筆時点で
old-stable
にあたる 2.6.8 です- 2.7 が
stable
なのですが、2.6→2.7で色々と後方互換性が壊されている(semverとは…)ため、どうせ修正が必要ならばと、僕は v3 がstable
になるまで待つ戦略をとっています😓
- 2.7 が
- 本稿の内容は、過去記事 API PlatformのOpenAPI生成で、エンティティのidをrequiredにする にも関連するので、よろしければあわせてどうぞ🍵
背景
API Platform によって自動生成されたOpenAPIにおいて、プロパティを required
にするには、
PHPアトリビュートの場合
class Foo
{
#[ApiProperty(required: true)]
private ?string $name = null;
}
YAMLの場合
App\Entity\Foo:
properties:
name:
required: true
のように設定すればよいです。
しかし、このように設定した場合、このプロパティは 生成されるすべてのスキーマにおいて required
として出力されます。
read文脈とwrite文脈で Serialization Groups を分けている場合に、readのときは required
にしたいけど、write
のときはしたくない ということが多々あります。(詳しくは後述します)
残念ながら、少なくともAPI Platform 2.6.8にはこのようなニーズに応えるための設定や機能は用意されていません。
そこで、API PlatformのOpenAPI生成処理を拡張することで強引に解決する方法を解説します。
具体例
例えば以下のようなエンティティを考えてみましょう。
/**
* Foo
*/
#[ORM\Entity(repositoryClass: FooRepository::class)]
class Foo
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
/**
* 内容
*/
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
private ?string $content = null;
/**
* ステータス
*/
#[ORM\Column(type: 'string', length: 255)]
#[Assert\Choice(choices: ['未着手', '対応中', '完了'])]
private string $state = '未着手';
}
-
$content
はPHPレベルではnullable
だがDBレベルではnon-nullable
-
$state
はPHPレベルでもDBレベルでもnon-nullable
という点がポイントです。
このエンティティについて、CollectionPostオペレーションとItemGetオペレーションをYAMLで定義してみましょう。
本稿ではこれ以降、APIリソース定義はPHPアトリビュートではなくYAMLで記述します。(僕の好みにより)
App\Entity\Foo:
attributes:
route_prefix: /v1/foos
properties:
content:
required: true # 注目
state:
required: true # 注目
attributes:
openapi_context:
enum: [処理中, 完了]
collectionOperations:
post:
normalization_context:
groups: [foo:read] # 注目
denormalization_context:
groups: [foo:write] # 注目
itemOperations:
get:
normalization_context:
groups: [foo:read] # 注目
各オペレーションには normalization_context
denormalization_context
でread時とwrite時それぞれのSerialization Groupsを設定 しています。
この例では、read文脈では foo:read
グループ、write文脈では foo:write
グループに含まれるプロパティだけがシリアライズ(またはデシリアライズ)されることになります。
また、$content
$state
はいずれもDBレベルで non-nullable
なプロパティなので、取得時に必ず値が入っているという意味で両方とも required
にしてあります。
では、この内容に合わせてエンティティのシリアライズ設定もYAMLで書いてみましょう。
本稿ではこれ以降、シリアライズ設定もPHPアトリビュートではなくYAMLで記述します。(僕の好みにより)
App\Entity\Foo:
attributes:
id:
groups:
- foo:read
content:
groups:
- foo:read
- foo:write
state:
groups:
- foo:read
(id
は当然として)あえて state
に foo:write
をセットしていない点に注目してください。
$state
プロパティはPHPレベルで初期値が 未着手
となっていて、エンティティ新規作成時に $state
プロパティの値を指定する必要がないため、write時のデシリアライズの対象から外している わけです。
Foo
クラスの実装を見返していただくと、$content
には#[Assert\NotBlank]
を付けているのに$state
にはあえて付けていませんでした。これは、$state
はユーザーの入力した値を直接セットすることを想定していなかったためです。
ところで、API Platformでは、オペレーションにSerialization Groupsを設定した場合、Serialization Groupsごとに別々のスキーマが生成され、それぞれが各オペレーションに適切に割り当てられます。
つまり、今回の例では、
1️⃣ foo:read
文脈における Foo
のスキーマ
2️⃣ foo:write
文脈における Foo
のスキーマ
の2つが生成され、
対象 | スキーマ |
---|---|
POST /api/v1/foos のリクエストボディ |
2️⃣ |
POST /api/v1/foos のレスポンス |
1️⃣ |
GET /api/v1/foos/{id} のレスポンス |
1️⃣ |
のように割り当てられることになります。
さて、前置きがとても長くなりましたが、この状態で、API Platformによって自動生成されるOpenAPIをSwagger UIで見てみると以下のようになります。
1️⃣ foo:read
文脈における Foo
のスキーマ
2️⃣ foo:write
文脈における Foo
のスキーマ
どちらもまったく同じ内容で、両方とも $state
が required
になっています。(そう設定したのだから当然ですが)
しかし思い出してください。foo:write
文脈においては $state
はリクエストボディに含めたところでデシリアライズされず無視されます。つまり、write文脈においては $state
は required
とするべきではない のです。
read文脈においては $state
は必ず値が入っているという意味で required
としておきたいので、read文脈かwrite文脈かによって required
にするかしないかを制御したい というニーズがここにあるわけです。
解決方法
やっと前提の説明が終わりましたw
本稿の冒頭にも書いたように、現状、API Platformには(少なくとも 2.6.8 には)このようなニーズに応えるための設定や機能は用意されていません。
そこで、API PlatformのOpenAPI生成処理を拡張することで強引に解決します。
OpenAPIのスキーマの生成は SchemaFactory というクラスが行っています。
詳細は API PlatformのOpenAPI生成で、エンティティのidをrequiredにする
#api-platformのコードにおける原因箇所
をご参照ください。
このクラスを Symfonyのサービスデコレート機能 を使って以下のようにサービスを差し替えてあげます。
# config/services.yaml
services:
App\ApiPlatform\SchemaFactory: # というクラスを自作する
decorates: api_platform.hydra.json_schema.schema_factory
自作するクラスの内容は以下のようにします。
こちらも API PlatformのOpenAPI生成で、エンティティのidをrequiredにする
#api-platformの-schemafactory-を拡張する
にもう少し詳細な説明がありますのであわせてご参照ください。
<?php
declare(strict_types=1);
namespace App\ApiPlatform;
use ApiPlatform\Core\JsonSchema\Schema;
use ApiPlatform\Core\JsonSchema\SchemaFactoryInterface;
/**
* @see \ApiPlatform\Core\Hydra\JsonSchema\SchemaFactory
*/
final class SchemaFactory implements SchemaFactoryInterface
{
public function __construct(private SchemaFactoryInterface $decorated)
{
}
public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?string $operationType = null, ?string $operationName = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
{
$schema = $this->decorated->buildSchema($className, $format, $type, $operationType, $operationName, $schema, $serializerContext, $forceCollection);
/** @var \ArrayObject<string, array<array<array<string>>>> $definitions */
$definitions = $schema->getDefinitions();
if ($key = $schema->getRootDefinitionKey()) {
// descriptionに "#requiredOnRead" が含まれるプロパティを、"read" と名の付くスキーマにおいてのみrequiredに
foreach ($definitions[$key]['properties'] ?? [] as $name => $property) {
$description = $property['description'] ?? '';
$definitions[$key]['properties'][$name]['description'] = preg_replace('/\s*#requiredOnRead\s*/', '', $description);
if (preg_match('/#requiredOnRead/', $description) && preg_match('/\.read(\.|$)/i', $key)) {
$definitions[$key]['required'][] = $name;
}
}
}
return $schema;
}
}
コードの細かな解説は今回は割愛させていただきますが、プロパティのdescriptionに #requiredOnRead
という文字列が含まれる場合に、そのプロパティを、「read
と名のつくスキーマにおいてのみ、required
にする という何とも強引なことをやっています。
当然ながら、read系/write系のSerialization Groupsの命名規則として、read系には必ず read
という文字列を含める、write系には read
という文字列は含めない、を徹底することが前提となります。
一般的な
{リソース名}:read
{リソース名}:write
や、{リソース名}:collection:read
{リソース名}:item:write
のような命名規則を採用しておけば、特に問題になることはないでしょう。
この上で、プロパティのDocコメント(これが自動でOpenAPIのプロパティのdescriptionになります)に #requiredOnRead
という文字列を追記すれば対応完了です。
/**
* Foo
*/
#[ORM\Entity(repositoryClass: FooRepository::class)]
class Foo
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
/**
* 内容
*/
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
private ?string $content = null;
/**
- * ステータス
+ * ステータス #requiredOnRead
*/
#[ORM\Column(type: 'string', length: 255)]
#[Assert\Choice(choices: ['未着手', '対応中', '完了'])]
private string $state = '未着手';
}
結果
1️⃣ foo:read
文脈における Foo
のスキーマ
2️⃣ foo:write
文脈における Foo
のスキーマ
という感じで、無事に $state
プロパティの required
を、readのスキーマにだけ適用することができました 🙌
めでたしめでたし🍵
Symfony Advent Calendar 2022、明日は @mako5656 さんです!お楽しみに!
Discussion