🎻

API PlatformのOpenAPI生成で、プロパティのrequiredをreadのスキーマにだけ適用する

2022/12/03に公開約9,000字

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 になるまで待つ戦略をとっています😓
  • 本稿の内容は、過去記事 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 は当然として)あえて statefoo: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 のスキーマ

どちらもまったく同じ内容で、両方とも $staterequired になっています。(そう設定したのだから当然ですが)

しかし思い出してください。foo:write 文脈においては $state はリクエストボディに含めたところでデシリアライズされず無視されます。つまり、write文脈においては $staterequired とするべきではない のです。

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 さんです!お楽しみに!

GitHubで編集を提案

Discussion

ログインするとコメントできます