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
に変わることが確認できました💡
TypeFactory
を拡張する
API Platformの コードを見れば分かるとおり 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