API PlatformのOpenAPI生成で、nullableなエンティティプロパティの型をanyOfではなくoneOfで出力する
背景
- バックエンドを API Platform で実装
- フロントエンドでは、API Platformによって自動生成されたOpenAPIを openapi2aspida に読ませてAPIの型定義を自動生成して利用
という構成で開発をしていたところ、openapi2aspidaが生成する型定義に一部期待と異なるところがあり非常に不便な思いをしました。
端的に言うと
- エンティティ型のnullableなプロパティがあると、API Platformが生成するOpenAPIにおいてそのプロパティの型は anyOf: [ { $ref: エンティティの型 } ]になる
- それをopenapi2aspidaに読ませると、Partial<エンティティの型> | null | undefinedという型が生成される
- 
Partial型になっているせいで、requiredであるはずのプロパティも含めてすべてのプロパティがundfinedableになってしまい、フロントエンドで無駄な型チェックが大量に必要とされた
というものです。
この場合、フロントエンドの型として期待しているのは Partial<エンティティの型> | null | undefined ではなく エンティティの型 | null | undefined です。
API Platformのバージョンは 2.6.8、openapi2aspidaのバージョンは 0.19.0 で、ともに記事執筆時点で最新の安定版です。
具体例
もう少し具体的な例で説明します。
バックエンドに、以下のように Person エンティティが Profile エンティティをnullableで持っている という構造があるとします。
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Entity\Profile;
use App\Repository\PersonRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PersonRepository::class)]
class Person
{
    #[ORM\OneToOne(targetEntity: Profile::class, mappedBy: 'profile', orphanRemoval: true)]
    private ?Profile $profile = null;
    
    // ...
}
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Entity\Person;
use App\Repository\ProfileRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ProfileRepository::class)]
class Profile
{
    #[ORM\OneToOne(targetEntity: Person::class, inversedBy: 'person')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Person $person = null;
    
    // ...
}
このとき、API Platformが自動生成するOpenAPIの定義は以下のようになります。(関連箇所のみ抜粋)
{
  "components": {
    "schemas": {
      "Person": {
        "type": "object",
        "properties": {
          "profile": {
            "nullable": true,
            "anyOf": [
              {
                "$ref": "#/components/schemas/Profile"
              }
            ]
          },
          # ...
        }
      },
      "Profile": {
        "type": "object",
        "properties": {
          # ...
        }
      }
    }
  }
}
注目は Person#profile の型のところで、
"anyOf": [
  {
    "$ref": "#/components/schemas/Profile"
  }
]
となっていますね。
これをopenapi2aspidaに読ませると、生成される型定義は以下のようになります。
export type Person = {
  profile?: Partial<Profile> | null | undefined
  // ...
}
ここが
- profile?: Partial<Profile> | null | undefined
+ profile?: Profile | null | undefined
こうなっていてほしい、という話です。
API Platformのコードにおける原因箇所
OpenAPIの仕様を確認すると
- oneOf – validates the value against exactly one of the subschemas
- allOf – validates the value against all the subschemas
- anyOf – validates the value against any (one or more) of the subschemas
https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/
とのことなので、openapi2aspidaが anyOf をTypeScriptの型に変換する際に Partial<対象の型> に変換するのはまあ分かる気がします。
例えば、
"anyOf": [ { "$ref": "#/components/schemas/Foo" }, { "$ref": "#/components/schemas/Bar" } ]をopenapi2aspidaに読ませると、
Partial<Foo & Bar>という型が生成されますが、これは完璧ではないにしろそれなりに妥当な処理に思われます。
なのでおそらく、API Platformが anyOf としてOpenAPIを生成していることがそもそもの原因だろうと推測されます。(実は以前に手動で anyOf な型定義を書いたことがあって、そのときにフロントエンドで Partial 型が生成された経験があったので当たりは付いていました)
というわけで、アナログに vendor/api-platform 配下を anyOf でgrepしてみたところ、ApiPlatform\Core\JsonSchema\TypeFactory クラスのこの部分 だけがヒットしました。
試しにここを
- 'anyOf' => [$jsonSchema],
+ 'oneOf' => [$jsonSchema],
と書き換えて一連の処理を実行してみたところ、フロントエンドの型が期待どおり Partial<Profile> | null | undefined から Profile | null | undefined に変わることが確認できました💡
 API Platformの TypeFactory を拡張する
コードを見れば分かるとおり anyOf はハードコードされていてフレームワークとして拡張ポイントは特に提供されていないため、元の TypeFactory クラスの代わりに自作のクラスが使われるように細工してあげる必要がありそうです。
vendor/api-platform 配下を ApiPlatform\Core\JsonSchema\TypeFactory でgrepしてみると、api_platform.json_schema.type_factory (および  ApiPlatform\Core\JsonSchema\TypeFactoryInterface というエイリアス)というサービスIDでSymfonyにサービスとして登録されていることが分かります。
なので、Symfonyのサービスデコレート機能 を使って以下のようにサービスを差し替えてあげればよさそうです。
# config/services.yaml
services:
  App\ApiPlatform\TypeFactory: # というクラスを自作する
    decorates: api_platform.json_schema.type_factory
では、肝心の自作するクラスの内容はどのようにすればよいでしょうか。
元の TypeFactory クラスは final クラスであり、問題の anyOf がハードコードされている箇所も private メソッドなので、拡張するのは一筋縄では行かなさそうです🤔
が、処理をよくよく見ると、結局本体の getType() メソッドが返すのは単純な
[
    'nullable' => true,
    'anyOf' => [/* 何か */],
]
といった形の配列なので、
- デコレートした元のメソッドをひとまず実行する
- その戻り値の内容を確認して、上記の形になっている場合のみ anyOfをoneOfに変更して返す
- それ以外の場合は何もせず元の実行結果を返す
とすれば目的を果たせそうです。
つまり、自作するクラスの内容は以下のようにすればよいでしょう💪
<?php
declare(strict_types=1);
namespace App\ApiPlatform;
use ApiPlatform\Core\JsonSchema\Schema;
use ApiPlatform\Core\JsonSchema\TypeFactoryInterface;
use Symfony\Component\PropertyInfo\Type;
/**
 * @see \ApiPlatform\Core\JsonSchema\TypeFactory
 */
final class TypeFactory implements TypeFactoryInterface
{
    public function __construct(private TypeFactoryInterface $decorated)
    {
    }
    public function getType(Type $type, string $format = 'json', ?bool $readableLink = null, ?array $serializerContext = null, Schema $schema = null): array
    {
        $result = $this->decorated->getType($type, $format, $readableLink, $serializerContext, $schema);
        if ($type->isCollection()) {
            return $result;
        }
        if (isset($result['nullable']) && isset($result['anyOf']) && true === $result['nullable']) {
            $result['oneOf'] = $result['anyOf'];
            unset($result['anyOf']);
        }
        return $result;
    }
}
結果
上記のとおり自作クラスを書いてサービスを decorates によって差し替えた結果、API Platformが生成するOpenAPIの内容は
  {
    "components": {
      "schemas": {
        "Person": {
          "type": "object",
          "properties": {
            "profile": {
              "nullable": true,
-             "anyOf": [
+             "oneOf": [
                {
                  "$ref": "#/components/schemas/Profile"
                }
              ]
            },
            # ...
          }
        },
        "Profile": {
          "type": "object",
          "properties": {
            # ...
          }
        }
      }
    }
  }
と期待どおり変化し、これをopenapi2aspidaに読ませると、生成される型定義は
  export type Person = {
-   profile?: Partial<Profile> | null | undefined
+   profile?: Profile | null | undefined
    // ...
  }
とこちらも期待どおり変化しました🙌
これで、フロントエンドで無駄な型チェックが不要になり、無事に開発体験が爆上がりしましたとさ。めでたしめでたし🍵





Discussion