Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Symfony + NelmioApiDocBundle を使った スキーマ駆動開発 / Sc...

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.

Symfony + NelmioApiDocBundle を使った スキーマ駆動開発 / Schema Driven Development with NelmioApiDocBundle

2026/03/20-22 開催の「PHPerKaigi 2026」(https://phperkaigi.jp/2026/ )の登壇資料です。

詳細:https://fortee.jp/phperkaigi-2026/proposal/a54af6b2-679a-4d8b-8f47-427a4a182622

Avatar for Shohei Okada

Shohei Okada

March 18, 2026
Tweet

More Decks by Shohei Okada

Other Decks in Programming

Transcript

  1. 所属:株式会社リンケージ 運営:ぺちぱーティーナイト、TechGYOZA 寄稿: 好き: 音楽ゲーム(IIDX, DDR, Arcaea, BPL 観戦)、 Stardew

    Valley、謎解き、観葉植物 岡田 正平/おかしょい X: @okashoi GitHub: @okashoi \ シルバースポンサー /
  2. 1. スキーマ駆動開発のメリットと課題 00:00~04:00 2. NelmioApiDocBundle 解説 04:00~08:00 3. API エンドポイント実装例

    08:00~17:00 4. 弱み・他の選択肢との比較 17:00~19:30 5. まとめ 19:30~20:00 目次(時間は目安)
  3. 1. スキーマ駆動開発のメリットと課題 00:00~04:00 2. NelmioApiDocBundle 解説 04:00~08:00 3. API エンドポイント実装例

    08:00~17:00 4. 弱み・他の選択肢との比較 17:00~19:30 5. まとめ 19:30~20:00 目次(時間は目安)
  4. OpenAPI Specification paths: /pet: put: tags: - pet summary: Update

    an existing pet. description: Update an existing pet by Id. operationId: updatePet requestBody: description: Update an existent pet in the store content: application/json: schema: $ref: '#/components/schemas/Pet' application/xml: schema: $ref: '#/components/schemas/Pet' application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/Pet' required: true responses: "200": description: Successful operation content: application/json: schema: $ref: '#/components/schemas/Pet' application/xml: schema: $ref: '#/components/schemas/Pet' "400": description: Invalid ID supplied "404":
  5. OpenAPI Specification を直接書くのは難しい paths: /user: post: requestBody: required: true content:

    application/json: schema: type: object properties: name: type: string required: - name $ curl -X POST \ http://localhost/user \ -d '{"name": "okashoi"}'
  6. OpenAPI Specification を直接書くのは難しい paths: /user: post: requestBody: required: true content:

    application/json: schema: type: object properties: name: type: string required: - name $ curl -X POST \ http://localhost/user \ -d '{"name": "okashoi"}'
  7. OpenAPI Specification を直接書くのは難しい paths: /user: post: requestBody: required: true content:

    application/json: schema: type: object properties: name: type: string required: true $ curl -X POST \ http://localhost/user \ -d '{"name": "okashoi"}' 左記は誤りで name は任意 プロパティ扱いのまま
  8. 1. スキーマ駆動開発のメリットと課題 00:00~04:00 2. NelmioApiDocBundle 解説 04:00~08:00 3. API エンドポイント実装例

    08:00~17:00 4. 弱み・他の選択肢との比較 17:00~19:30 5. まとめ 19:30~20:00 目次(時間は目安)
  9. 基本機能 OpenApi Specification に基づく API スキーマの生成 特徴 • swagger-PHP をコアにした

    Symfony 統合 • ルーティングからパスを検出 • Validator や Serializer の設定の反映 • Swagger UI のホスティング NelmioApiDocBundle 基本機能
  10. NelmioApiDocBundle 基本機能 use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route;

    #[Route('api/issue')] class IssueController { #[Route('', methods: ['GET'])] #[OA\Response( response: 200, description: 'List of issues', content: new OA\JsonContent( required: ['issues'], properties: [ new OA\Property( property: 'issues', type: 'array', items: new OA\Items( required: ['id', 'summary', 'createdAt'], properties: [ new OA\Property(property: 'id', type: 'string', format: 'uuid'), new OA\Property(property: 'summary', type: 'string'), new OA\Property(property: 'createdAt', type: 'string', format: 'date-time'), ], type: 'object', ), ), ], type: 'object', ), )] public function list(): Response { throw new \LogicException('Not implemented');
  11. NelmioApiDocBundle 基本機能 use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route;

    #[Route('api/issue')] class IssueController { #[Route('', methods: ['GET'])] #[OA\Response( response: 200, description: 'List of issues', content: new OA\JsonContent( required: ['issues'], properties: [ new OA\Property( property: 'issues', type: 'array', items: new OA\Items( required: ['id', 'summary', 'createdAt'], properties: [ new OA\Property(property: 'id', type: 'string', format: 'uuid'), new OA\Property(property: 'summary', type: 'string'), new OA\Property(property: 'createdAt', type: 'string', format: 'date-time'), ], type: 'object', ), ), ], type: 'object', ), )] public function list(): Response { throw new \LogicException('Not implemented'); 一般的な Controller
  12. NelmioApiDocBundle 基本機能 use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route;

    #[Route('api/issue')] class IssueController { #[Route('', methods: ['GET'])] #[OA\Response( response: 200, description: 'List of issues', content: new OA\JsonContent( required: ['issues'], properties: [ new OA\Property( property: 'issues', type: 'array', items: new OA\Items( required: ['id', 'summary', 'createdAt'], properties: [ new OA\Property(property: 'id', type: 'string', format: 'uuid'), new OA\Property(property: 'summary', type: 'string'), new OA\Property(property: 'createdAt', type: 'string', format: 'date-time'), ], type: 'object', ), ), ], type: 'object', ), )] public function list(): Response { throw new \LogicException('Not implemented'); 一般的な Controller
  13. NelmioApiDocBundle 基本機能 use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route;

    #[Route('api/issue')] class IssueController { #[Route('', methods: ['GET'])] #[OA\Response( response: 200, description: 'List of issues', content: new OA\JsonContent( required: ['issues'], properties: [ new OA\Property( property: 'issues', type: 'array', items: new OA\Items( required: ['id', 'summary', 'createdAt'], properties: [ new OA\Property(property: 'id', type: 'string', format: 'uuid'), new OA\Property(property: 'summary', type: 'string'), new OA\Property(property: 'createdAt', type: 'string', format: 'date-time'), ], type: 'object', ), ), ], type: 'object', ), )] public function list(): Response { throw new \LogicException('Not implemented'); これ自体は swagger-PHP の Attriutes
  14. API スキーマ生成 ./bin/console nelmio:apidoc:dump --format=yaml # 略 # : /api/issue:

    get: operationId: app_issue_list responses: '200': description: 'List of issues' content: application/json: schema: required: - issues properties: issues: { type: array, items: { required: [id, summary, createdAt], properties: { id: { type: string, format: uuid }, summary: { type: string }, createdAt: { type: string, format: date-time } }, type: object } } type: object
  15. これを use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route('api/issue')]

    class IssueController { #[Route('', methods: ['GET'])] #[OA\Response( response: 200, description: 'List of issues', content: new OA\JsonContent( required: ['issues'], properties: [ new OA\Property( property: 'issues', type: 'array', items: new OA\Items( required: ['id', 'summary', 'createdAt'], properties: [ new OA\Property(property: 'id', type: 'string', format: 'uuid'), new OA\Property(property: 'summary', type: 'string'), new OA\Property(property: 'createdAt', type: 'string', format: 'date-time'), ], type: 'object', ), ), ], type: 'object', ), )] public function list(): Response
  16. こうできる use App\UseCase\Issue\ListIssues\ListIssuesOutput; use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; use

    Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route('api/issue')] class IssueController { #[Route('', methods: ['GET'])] #[OA\Response( response: 200, description: 'List of issues', content: new OA\JsonContent(ref: new Model(type: ListIssuesOutput::class)), )] public function list(): Response { throw new \LogicException('Not implemented'); }
  17. こうできる use App\UseCase\Issue\ListIssues\ListIssuesOutput; use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; use

    Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route('api/issue')] class IssueController { #[Route('', methods: ['GET'])] #[OA\Response( response: 200, description: 'List of issues', content: new OA\JsonContent(ref: new Model(type: ListIssuesOutput::class)), )] public function list(): Response { throw new \LogicException('Not implemented'); } ref: new Model(type: ListIssuesOutput::class) Plain Old PHP Object(POPO)で OK
  18. PHP のクラスが schemas として扱われる use App\UseCase\Issue\ListIssues\ListIssuesOutput; use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes

    as OA; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route('api/issue')] class IssueController { #[Route('', methods: ['GET'])] #[OA\Response( response: 200, description: 'List of issues', content: new OA\JsonContent(ref: new Model(type: ListIssuesOutput::class)), )] public function list(): Response { throw new \LogicException('Not implemented'); } /api/issue: get: operationId: app_issue_list responses: '200': description: 'List of issues' content: application/json: schema: $ref: '#/components/schemas/ListIssuesOutput'
  19. PHP の型情報をよしなに解釈してくれる #[OA\Response( response: 200, description: 'List of issues', content:

    new OA\JsonContent(ref: new Model(type: ListIssuesOutput::class)), )] <?php declare(strict_types=1); namespace App\UseCase\Issue\ListIssues; readonly class ListIssuesOutput { /** * @param list<IssueListItem> $issues */ public function __construct( public array $issues, ) { } }
  20. PHP の型情報をよしなに解釈してくれる #[OA\Response( response: 200, description: 'List of issues', content:

    new OA\JsonContent(ref: new Model(type: ListIssuesOutput::class)), )] <?php declare(strict_types=1); namespace App\UseCase\Issue\ListIssues; readonly class ListIssuesOutput { /** * @param list<IssueListItem> $issues */ public function __construct( public array $issues, ) { } } <?php declare(strict_types=1); namespace App\UseCase\Issue\ListIssues; use Symfony\Component\Uid\Uuid; readonly class IssueListItem { public function __construct( public Uuid $id, public string $summary, public \DateTimeImmutable $createdAt, ) { } }
  21. PHP の型情報をよしなに解釈してくれる <?php declare(strict_types=1); namespace App\UseCase\Issue\ListIssues; use Symfony\Component\Uid\Uuid; readonly class

    IssueListItem { public function __construct( public Uuid $id, public string $summary, public \DateTimeImmutable $createdAt, ) { } } components: schemas: IssueListItem: required: - id - summary - createdAt properties: id: type: string format: uuid summary: type: string createdAt: type: string format: date-time type: object
  22. PHP の型情報をよしなに解釈してくれる <?php declare(strict_types=1); namespace App\UseCase\Issue\ListIssues; use Symfony\Component\Uid\Uuid; readonly class

    IssueListItem { public function __construct( public Uuid $id, public string $summary, public \DateTimeImmutable $createdAt, ) { } } components: schemas: IssueListItem: required: - id - summary - createdAt properties: id: type: string format: uuid summary: type: string createdAt: type: string format: date-time type: object
  23. その他にも • PHP の列挙型(Enum)は enum • PHP の Union 型

    は oneOf になる PHP の型情報をよしなに解釈してくれる
  24. プロパティ単位で Attributes 付与も可能 <?php declare(strict_types=1); namespace App\UseCase\User\GetUserDetail; use OpenApi\Attributes as

    OA; use Symfony\Component\Uid\Uuid; readonly class GetUserDetailOutput { public function __construct( public Uuid $id, #[OA\Property(minLength: 5)] public string $name, #[OA\Property(format: 'email')] public string $email, ) { } GetUserDetailOutput: required: - id - name - email properties: id: type: string format: uuid name: type: string minLength: 5 email: type: string format: email type: object
  25. プロパティ単位で Attributes 付与も可能 <?php declare(strict_types=1); namespace App\UseCase\User\GetUserDetail; use OpenApi\Attributes as

    OA; use Symfony\Component\Uid\Uuid; readonly class GetUserDetailOutput { public function __construct( public Uuid $id, #[OA\Property(minLength: 5)] public string $name, #[OA\Property(format: 'email')] public string $email, ) { } GetUserDetailOutput: required: - id - name - email properties: id: type: string format: uuid name: type: string minLength: 5 email: type: string format: email type: object
  26. • OpenApi Specification に基づく API スキーマ生成 • Controller に Attributes

    を付与することで定義 • POPO のプロパティの型情報をよしなに解釈してくれる NelmioApiDocBundle まとめ
  27. • OpenApi Specification に基づく API スキーマ生成 • Controller に Attributes

    を付与することで定義 • POPO のプロパティの型情報をよしなに解釈してくれる NelmioApiDocBundle まとめ 嬉しいポイント
  28. 1. スキーマ駆動開発のメリットと課題 00:00~04:00 2. NelmioApiDocBundle 解説 04:00~08:00 3. API エンドポイント実装例

    08:00~17:00 4. 弱み・他の選択肢との比較 17:00~19:30 5. まとめ 19:30~20:00 目次(時間は目安)
  29. SSoT を PHP で表現できる → PHP に習熟しているチームほど恩恵が大きい • API エンドポイントの実装に型を直接利用できる

    • 使い慣れた IDE, 静的解析ツールを利用できる NelmioApiDocBundle を使ったスキーマ駆動開発
  30. 「課題作成 API」実装例 全体像 #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content:

    new OA\JsonContent(ref: new Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); }
  31. 「課題作成 API」実装例 全体像 #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content:

    new OA\JsonContent(ref: new Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); } API 定義部分
  32. 「課題作成 API」実装例 全体像 #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content:

    new OA\JsonContent(ref: new Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); } 実装部分
  33. #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content: new OA\JsonContent(ref: new

    Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); } リクエスト/レスポンス(正常系)定義 #[OA\RequestBody( required: true, content: new OA\JsonContent(ref: new Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )]
  34. リクエストボディの定義(POPO) use OpenApi\Attributes as OA; use Symfony\Component\Validator\Constraints as Assert; readonly

    class CreateIssueInput { public function __construct( #[OA\Property(minLength: 1)] #[Assert\NotBlank] public string $summary, ) { } } Symfony の Validation と併用も可
  35. #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content: new OA\JsonContent(ref: new

    Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); } MapRequestPayload によるオブジェクトへの変換
  36. #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content: new OA\JsonContent(ref: new

    Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); } MapRequestPayload によるオブジェクトへの変換 #[MapRequestPayload] CreateIssueInput $input, CreateIssueInput::class
  37. #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content: new OA\JsonContent(ref: new

    Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); } MapRequestPayload によるオブジェクトへの変換 #[MapRequestPayload] CreateIssueInput $input, CreateIssueInput::class リクエストボディとして定義した POPO のインスタンスが 引数として渡されることが担保される
  38. #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content: new OA\JsonContent(ref: new

    Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); } UseCase の In/Out に同じ型を用いる
  39. #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content: new OA\JsonContent(ref: new

    Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); } UseCase の In/Out に同じ型を用いる readonly class CreateIssueUseCase { public function execute(CreateIssueInput $input): CreateIssueOutput { // 実装(ここでは割愛) } }
  40. #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content: new OA\JsonContent(ref: new

    Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); } Serializer を用いてレスポンスを返す
  41. #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content: new OA\JsonContent(ref: new

    Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); } エラーレスポンスの整形
  42. #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content: new OA\JsonContent(ref: new

    Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); } エラーレスポンスの整形 readonly class Error { public function __construct( public string $error, ) { } }
  43. EventSubscriber でエラーの整形を行う public function onKernelException(ExceptionEvent $event): void { $exception =

    $event->getThrowable(); $isAuthError = $exception instanceof AuthenticationException || $exception instanceof AccessDeniedException || ($exception instanceof HttpException && $exception->getStatusCode() === Response::HTTP_UNAUTHORIZED); if ($isAuthError) { $event->setResponse(new JsonResponse( new Error(error: 'Full authentication is required to access this resource.'), Response::HTTP_UNAUTHORIZED, )); } } https://symfony.com/doc/current/event_dispatcher.html
  44. EventSubscriber でエラーの整形を行う public function onKernelException(ExceptionEvent $event): void { $exception =

    $event->getThrowable(); $isAuthError = $exception instanceof AuthenticationException || $exception instanceof AccessDeniedException || ($exception instanceof HttpException && $exception->getStatusCode() === Response::HTTP_UNAUTHORIZED); if ($isAuthError) { $event->setResponse(new JsonResponse( new Error(error: 'Full authentication is required to access this resource.'), Response::HTTP_UNAUTHORIZED, )); } } https://symfony.com/doc/current/event_dispatcher.html
  45. EventSubscriber でエラーの整形を行う public function onKernelException(ExceptionEvent $event): void { $exception =

    $event->getThrowable(); $isAuthError = $exception instanceof AuthenticationException || $exception instanceof AccessDeniedException || ($exception instanceof HttpException && $exception->getStatusCode() === Response::HTTP_UNAUTHORIZED); if ($isAuthError) { $event->setResponse(new JsonResponse( new Error(error: 'Full authentication is required to access this resource.'), Response::HTTP_UNAUTHORIZED, )); } } https://symfony.com/doc/current/event_dispatcher.html
  46. EventSubscriber でエラーの整形を行う public function onKernelException(ExceptionEvent $event): void { $exception =

    $event->getThrowable(); $isAuthError = $exception instanceof AuthenticationException || $exception instanceof AccessDeniedException || ($exception instanceof HttpException && $exception->getStatusCode() === Response::HTTP_UNAUTHORIZED); if ($isAuthError) { $event->setResponse(new JsonResponse( new Error(error: 'Full authentication is required to access this resource.'), Response::HTTP_UNAUTHORIZED, )); } } https://symfony.com/doc/current/event_dispatcher.html
  47. #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content: new OA\JsonContent(ref: new

    Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); } リクエストバリデーション
  48. バリデーション用 EventSubscriber class OpenApiValidationSubscriber implements EventSubscriberInterface { private const string

    OPERATION_ADDRESS_ATTRIBUTE = '_openapi_operation_address'; private ServerRequestValidator $requestValidator; private ResponseValidator $responseValidator; private PsrHttpFactory $psrHttpFactory; public function __construct(string $schemaPath) { $builder = new ValidatorBuilder()->fromYamlFile($schemaPath); $this->requestValidator = $builder->getServerRequestValidator(); $this->responseValidator = $builder->getResponseValidator(); $this->psrHttpFactory = new PsrHttpFactory(); } 注)最新の API スキーマがファイル出力されている前提
  49. バリデーション用 EventSubscriber class OpenApiValidationSubscriber implements EventSubscriberInterface { private const string

    OPERATION_ADDRESS_ATTRIBUTE = '_openapi_operation_address'; private ServerRequestValidator $requestValidator; private ResponseValidator $responseValidator; private PsrHttpFactory $psrHttpFactory; public function __construct(string $schemaPath) { $builder = new ValidatorBuilder()->fromYamlFile($schemaPath); $this->requestValidator = $builder->getServerRequestValidator(); $this->responseValidator = $builder->getResponseValidator(); $this->psrHttpFactory = new PsrHttpFactory(); } 注)最新の API スキーマがファイル出力されている前提
  50. バリデーション用 EventSubscriber class OpenApiValidationSubscriber implements EventSubscriberInterface { private const string

    OPERATION_ADDRESS_ATTRIBUTE = '_openapi_operation_address'; private ServerRequestValidator $requestValidator; private ResponseValidator $responseValidator; private PsrHttpFactory $psrHttpFactory; public function __construct(string $schemaPath) { $builder = new ValidatorBuilder()->fromYamlFile($schemaPath); $this->requestValidator = $builder->getServerRequestValidator(); $this->responseValidator = $builder->getResponseValidator(); $this->psrHttpFactory = new PsrHttpFactory(); } 注)最新の API スキーマがファイル出力されている前提 \Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory
  51. リクエストバリデーション@EventSubscriber public function onKernelRequest(RequestEvent $event): void { if (!$event->isMainRequest()) {

    return; } $psrRequest = $this->psrHttpFactory->createRequest($event->getRequest()); try { $operationAddress = $this->requestValidator->validate($psrRequest); } catch (NoPath|NoOperation) { return; } catch (ValidationFailed $e) { $event->setResponse(new JsonResponse( ValidationError::fromValidationFailed($e), Response::HTTP_UNPROCESSABLE_ENTITY, )); return; } $event->getRequest()->attributes->set(self::OPERATION_ADDRESS_ATTRIBUTE, $operationAddress); }
  52. リクエストバリデーション@EventSubscriber public function onKernelRequest(RequestEvent $event): void { if (!$event->isMainRequest()) {

    return; } $psrRequest = $this->psrHttpFactory->createRequest($event->getRequest()); try { $operationAddress = $this->requestValidator->validate($psrRequest); } catch (NoPath|NoOperation) { return; } catch (ValidationFailed $e) { $event->setResponse(new JsonResponse( ValidationError::fromValidationFailed($e), Response::HTTP_UNPROCESSABLE_ENTITY, )); return; } $event->getRequest()->attributes->set(self::OPERATION_ADDRESS_ATTRIBUTE, $operationAddress); }
  53. リクエストバリデーション@EventSubscriber public function onKernelRequest(RequestEvent $event): void { if (!$event->isMainRequest()) {

    return; } $psrRequest = $this->psrHttpFactory->createRequest($event->getRequest()); try { $operationAddress = $this->requestValidator->validate($psrRequest); } catch (NoPath|NoOperation) { return; } catch (ValidationFailed $e) { $event->setResponse(new JsonResponse( ValidationError::fromValidationFailed($e), Response::HTTP_UNPROCESSABLE_ENTITY, )); return; } $event->getRequest()->attributes->set(self::OPERATION_ADDRESS_ATTRIBUTE, $operationAddress); } PSR-7 準拠のリクエストオブジェクトに変換
  54. リクエストバリデーション@EventSubscriber public function onKernelRequest(RequestEvent $event): void { if (!$event->isMainRequest()) {

    return; } $psrRequest = $this->psrHttpFactory->createRequest($event->getRequest()); try { $operationAddress = $this->requestValidator->validate($psrRequest); } catch (NoPath|NoOperation) { return; } catch (ValidationFailed $e) { $event->setResponse(new JsonResponse( ValidationError::fromValidationFailed($e), Response::HTTP_UNPROCESSABLE_ENTITY, )); return; } $event->getRequest()->attributes->set(self::OPERATION_ADDRESS_ATTRIBUTE, $operationAddress); }
  55. リクエストバリデーション@EventSubscriber public function onKernelRequest(RequestEvent $event): void { if (!$event->isMainRequest()) {

    return; } $psrRequest = $this->psrHttpFactory->createRequest($event->getRequest()); try { $operationAddress = $this->requestValidator->validate($psrRequest); } catch (NoPath|NoOperation) { return; } catch (ValidationFailed $e) { $event->setResponse(new JsonResponse( ValidationError::fromValidationFailed($e), Response::HTTP_UNPROCESSABLE_ENTITY, )); return; } $event->getRequest()->attributes->set(self::OPERATION_ADDRESS_ATTRIBUTE, $operationAddress); }
  56. 「課題作成 API」実装例 全体像(再掲) #[Route('', methods: ['POST'])] #[OA\RequestBody( required: true, content:

    new OA\JsonContent(ref: new Model(type: CreateIssueInput::class)), )] #[OA\Response( response: 201, description: 'Issue created', content: new OA\JsonContent(ref: new Model(type: CreateIssueOutput::class)), )] #[OA\Response( response: 401, description: 'Unauthorized', content: new OA\JsonContent(ref: new Model(type: Error::class)), )] #[OA\Response( response: 422, description: 'Validation failed', content: new OA\JsonContent(ref: new Model(type: ValidationError::class)), )] public function create( #[CurrentUser] SecurityUser $user, #[MapRequestPayload] CreateIssueInput $input, CreateIssueUseCase $useCase, ): Response { $output = $useCase->execute($input); return JsonResponse::fromJsonString( $this->serializer->serialize($output, 'json'), Response::HTTP_CREATED, ); }
  57. レスポンスバリデーション public function onKernelRequest(RequestEvent $event): void { if (!$event->isMainRequest()) {

    return; } $psrRequest = $this->psrHttpFactory->createRequest($event->getRequest()); try { $operationAddress = $this->requestValidator->validate($psrRequest); } catch (NoPath|NoOperation) { return; } catch (ValidationFailed $e) { $event->setResponse(new JsonResponse( ValidationError::fromValidationFailed($e), Response::HTTP_UNPROCESSABLE_ENTITY, )); return; } $event->getRequest()->attributes->set(self::OPERATION_ADDRESS_ATTRIBUTE, $operationAddress); }
  58. レスポンスバリデーション public function onKernelResponse(ResponseEvent $event): void { if (!$event->isMainRequest()) {

    return; } $operationAddress = $event->getRequest()->attributes->get(self::OPERATION_ADDRESS_ATTRIBUTE); if (!$operationAddress instanceof OperationAddress) { return; } $psrResponse = $this->psrHttpFactory->createResponse($event->getResponse()); $this->responseValidator->validate($operationAddress, $psrResponse); }
  59. レスポンスバリデーション public function onKernelResponse(ResponseEvent $event): void { if (!$event->isMainRequest()) {

    return; } $operationAddress = $event->getRequest()->attributes->get(self::OPERATION_ADDRESS_ATTRIBUTE); if (!$operationAddress instanceof OperationAddress) { return; } $psrResponse = $this->psrHttpFactory->createResponse($event->getResponse()); $this->responseValidator->validate($operationAddress, $psrResponse); }
  60. レスポンスバリデーション public function onKernelResponse(ResponseEvent $event): void { if (!$event->isMainRequest()) {

    return; } $operationAddress = $event->getRequest()->attributes->get(self::OPERATION_ADDRESS_ATTRIBUTE); if (!$operationAddress instanceof OperationAddress) { return; } $psrResponse = $this->psrHttpFactory->createResponse($event->getResponse()); $this->responseValidator->validate($operationAddress, $psrResponse); } 例外は捕捉せずに 500 エラーにする
  61. 1. スキーマ駆動開発のメリットと課題 00:00~04:00 2. NelmioApiDocBundle 解説 04:00~08:00 3. API エンドポイント実装例

    08:00~17:00 4. 弱み・他の選択肢との比較 17:00~19:30 5. まとめ 19:30~20:00 目次(時間は目安)
  62. readonly class CreateIssueInput { public function __construct( public ?string $summary,

    ) { } } PHP の型表現上の制約 CreateIssueInput: properties: summary: type: string nullable: true type: object optional かつ nullable として解釈される
  63. #[OA\Schema(required: ['summary'])] readonly class CreateIssueInput { public function __construct( public

    ?string $summary, ) { } } PHP の型表現上の制約 CreateIssueInput: required: - summary properties: summary: type: string nullable: true type: object required かつ nullable は Attributes を明示すれば表現可能
  64. PHP の型表現上の制約 #[OA\Schema(required: [])] readonly class CreateIssueInput { public function

    __construct( #[OA\Property(nullable: false)] public ?string $summary, ) { } } CreateIssueInput: required: - summary properties: summary: type: string type: object optional かつ not null は表現できない (required を上書きできない)
  65. PHP の型表現上の制約 #[OA\Schema(required: [])] readonly class CreateIssueInput { public function

    __construct( #[OA\Property(nullable: false)] public ?string $summary, ) { } } CreateIssueInput: required: - summary properties: summary: type: string type: object optional かつ not null は表現できない (required を上書きできない)
  66. 特定キーワード(get, is 等)で始まる public メソッドが対象 • Symfony Serializer の挙動によるもの •

    #[Ignore] で明示的に除外してあげる必要がある メソッドがプロパティとして解釈されてしまう
  67. • swagger-PHP の Attribute の引数は基本的に OpenAPI Specification をそのまま持ってきているだけ • 可能な引数が

    nullable で列挙されており 排他な引数のチェックなどは行われない → そうなると OpenAPI Specification を習得する必要がある 細かい指定をする際の型安全性が低い
  68. 機能は API Platform の方が圧倒的に豊富 • Entity やクラスを起点とした CRUD の自動生成 •

    StateProvider/Processor によるカスタマイズ • JSON-LD、GraphQL 等にも対応 c.f) API Platform
  69. • リンケージでは DB 設計の際にイミュータブルデータモデ ルに基づく考え方をすることが多い • Entity と API リソースが対応しないため、都度カスタマイ

    ズが必要になり API Platform の機能を持て余してしまう →「NelmioApiDocBundle でスキーマを生成するだけ」という  シンプルさを優先した NelmioApiDocBundle を採用した理由
  70. 1. スキーマ駆動開発のメリットと課題 00:00~04:00 2. NelmioApiDocBundle 解説 04:00~08:00 3. API エンドポイント実装例

    08:00~17:00 4. 弱み・他の選択肢との比較 17:00~19:30 5. まとめ 19:30~20:00 目次(時間は目安)
  71. スキーマ駆動開発の恩恵に与るためのNelmioAPIDocBundle • Controller の Attributes から スキーマを出力できる • refs に

    POPO(Plain Old PHP Object)を指定可能 PHP に習熟しているチームほど恩恵が大きい • API エンドポイントの実装に型を直接利用できる • 使い慣れた IDE, 静的解析ツールを利用できる まとめ