0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OpenAPI × Laravel でプレゼンテーション層を自動生成する

0
Posted at

概要

OpenAPI を使った開発では、一般的に APIスキーマ定義からコードを自動生成することでAPIへのリクエスト/レスポンス処理は自前で書かないというプラクティスがある。
→ プレゼンテーション層以外のレイヤーに集中できる。

Laravelでもそれを実践することが可能なのでそのやり方を記載する。
簡単に内容をまとめると、

  • openapi-generator-cli を使って、OpenAPI スキーマ定義を元にルーティングファイルや Controller を自動生成する。
  • 自動生成したプレゼンテーション層にユースケース層を繋ぎこむ。
  • openapi-psr7-validator を組み合わせてAPIスキーマに沿っているかリクエスト/レスポンスのバリデーションを行う。

となる。

手順

  1. APIスキーマ定義を作成する(又は変更する)
  2. openapi-generator-cli を実行する
  3. ユースケースを繋ぎこむ
  4. バリデーションミドルウェアを設定する

以下、ユーザー管理画面を例に説明していく。

APIスキーマ定義を作成する(又は変更する)

OpenAPI Specification に沿って定義を作成、変更する(参考)。
ここでは、ユーザー登録APIのスキーマ定義を以下のように作成したと仮定する。

api.yaml
openapi: 3.0.0
info:
  title: CMSユーザー管理機能のAPI
  description: |
    ユーザ登録、編集、削除、並び替えなどのAPIを提供する
  version: 1.0.0
paths:
  /dash/{factory_name}/api/back-office/user/register:
    post:
      tags:
        - registerUser
      description: 新規ユーザ登録
      operationId: registerUser
      parameters:
        - name: factory_name
          in: path
          required: true
          schema:
            type: string
          description: 工場名
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - sei
                - mei
                - password
                - permission
                - cs_role
              properties:
                sei:
                  type: string
                  description: 姓
                mei:
                  type: string
                  description: 名
                password:
                  type: string
                  description: パスワード
                permission:
                  type: integer
                  description: 権限
                cs_role:
                  type: integer
                  description: CSロール

      responses:
        200:
          description: Successful operation
          content:
            application/json:
              schema:
                type: object
                required:
                  - id
                  - sei
                  - mei
                  - permission
                  - cs_role
                properties:
                  id:
                    type: integer
                    description: ユーザID
                  sei:
                    type: string
                    description: 姓
                  mei:
                    type: string
                    description: 名
                  permission:
                    type: integer
                    description: 権限
                  cs_role:
                    type: integer
                    description: CSロール
              example:
                id: 5
                sei: "鈴木"
                mei: "次郎"
                permission: 100
                cs_role: 0

        400:
          description: 400 Bad Request
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    description: エラーメッセージ
                example:
                  error: "This is a sample error message."
        500:
          description: 500 Internal Server Error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    description: エラーメッセージ
                example:
                  error: "This is a sample error message."
                description: 500 Internal Server Error

openapi-generator-cli を実行する

まず、openapi-generator-cli をインストールする。

npm install -g @openapitools/openapi-generator-cli@2.23.0

openapi-generator-cli を実行する。

openapi-generator-cli generate \
  -i api.yaml \     # 先ほど作成したAPIスキーマを指定
  -o OpenApiGen \   # OpenApiGenディレクトリにコードを出力
  -g php-laravel \  # laravel 形式で出力
  -c config.json    # 設定ファイルを渡す(任意)

config.json には次のように生成されるクラスの名前空間を指定している。

{
    "invokerPackage": "CmsBackOffice\\Presentation\\User\\OpenApiGen",
    "controllerPackage": "CmsBackOffice\\Presentation\\User\\OpenApiGen\\Http\\Controllers"
}

実行すると次のファイルが作成される。

└── OpenApiGen
      ├── Api  // 後述
      │   └── RegisterUserApiInterface.php
      ├── Http  // コントローラー
      │   └── Controllers
      │         └── RegisterUserController.php
      ├── Model  // レスポンスクラス
      │   ├── RegisterUser200Response.php
      │   ├── RegisterUser400Response.php
      │   ├── RegisterUser500Response.php
      │   └── RegisterUserRequest.php
      └── routes.php  // ルート定義ファイル

ここまでで APIの入り口はできた。

routes.php
<?php declare(strict_types=1);

use Illuminate\Support\Facades\Route;

Route::POST('/dash/{factory_name}/api/back-office/user/register', [\CmsBackOffice\Presentation\User\OpenApiGen\Http\Controllers\RegisterUserController::class, 'registerUser'])->name('registerUser.register.user');
RegisterUserController.php
<?php declare(strict_types=1);

namespace CmsBackOffice\Presentation\User\OpenApiGen\Http\Controllers;

use Crell\Serde\SerdeCommon;
use Illuminate\Routing\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;


use CmsBackOffice\Presentation\User\OpenApiGen\Api\RegisterUserApiInterface;

class RegisterUserController extends Controller
{
    public function __construct(
        private readonly RegisterUserApiInterface $api,
        private readonly SerdeCommon $serde = new SerdeCommon(),
    )
    {
    }

    public function registerUser(Request $request, string $factoryName): JsonResponse
    {
        // パスパラメーターのバリデーション
        $validator = Validator::make(
            array_merge(
                [
                    'factoryName' => $factoryName,
                ],
                $request->all(),
            ),
            [
            ],
        );

        if ($validator->fails()) {
            return response()->json(['error' => 'Invalid input'], 400);
        }


        $registerUserRequest = $this->serde->deserialize($request->getContent(), from: 'json', to: \CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUserRequest::class);


        $apiResult = $this->api->registerUser($factoryName, $registerUserRequest);

        if ($apiResult instanceof \CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUser200Response) {
            return response()->json($this->serde->serialize($apiResult, format: 'array'), 200);
        }

        if ($apiResult instanceof \CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUser400Response) {
            return response()->json($this->serde->serialize($apiResult, format: 'array'), 400);
        }

        if ($apiResult instanceof \CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUser500Response) {
            return response()->json($this->serde->serialize($apiResult, format: 'array'), 500);
        }


        // This shouldn't happen
        return response()->abort(500);
    }
}

ユースケースを繋ぎこむ

先ほど生成された RegisterUserApiInterface.php のコードを見てみると、
引数にパスパラメーターとリクエストボディを受け取り、レスポンスは 200/400/500 を表すクラスを返す形となっている。

<?php declare(strict_types=1);

namespace CmsBackOffice\Presentation\User\OpenApiGen\Api;


interface RegisterUserApiInterface {

    public function registerUser(
            string $factoryName,
            \CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUserRequest $registerUserRequest,
    ):
        \CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUser200Response | 
        \CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUser400Response | 
        \CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUser500Response
    ;

}

このクラスは先ほど生成されたコントローラでDIされ、registerUser メソッドが呼び出されていた。

$apiResult = $this->api->registerUser($factoryName, $registerUserRequest);

if ($apiResult instanceof \CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUser200Response) {
    return response()->json($this->serde->serialize($apiResult, format: 'array'), 200);
}

if ($apiResult instanceof \CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUser400Response) {
    return response()->json($this->serde->serialize($apiResult, format: 'array'), 400);
}

if ($apiResult instanceof \CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUser500Response) {
    return response()->json($this->serde->serialize($apiResult, format: 'array'), 500);
}

このインターフェースを実装したアダプターを作成し、そこでユースケースクラスを繋ぎこむ。

<?php

declare(strict_types=1);

namespace CmsBackOffice\Presentation\User\Adapter;

use Exception;
use CmsBackOffice\App\ACL\GetFactoryMaster;
use CmsBackOffice\App\Service\Notification\ErrorNotifier;
use CmsBackOffice\App\Service\User\RegisterUser\RegisterUserInput;
use CmsBackOffice\App\Service\User\RegisterUser\RegisterUserUseCase;
use CmsBackOffice\Presentation\User\OpenApiGen\Api\RegisterUserApiInterface;
use CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUser200Response;
use CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUser400Response;
use CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUser500Response;
use CmsBackOffice\Presentation\User\OpenApiGen\Model\RegisterUserRequest;

readonly class RegisterUserAdapter implements RegisterUserApiInterface
{
    public function __construct(
        private RegisterUserUseCase $useCase,
        private GetFactoryMaster $getFactoryMaster,
        private ErrorNotifier $errorNotifier,
    ) {
    }

    // インターフェースを実装したメソッド
    public function registerUser(
        string $factoryName,
        RegisterUserRequest $registerUserRequest,
    ): RegisterUser200Response|RegisterUser400Response|RegisterUser500Response {
        $factoryCode = $this->getFactoryMaster->getFactoryCode($factoryName);
        if ($factoryCode === null) {
            // 400レスポンスを返す
            return new RegisterUser400Response("不正なリクエストです。");
        }
        try {
            // ユースケースを実行
            $user = $this->useCase->action(
                new RegisterUserInput(
                    $registerUserRequest->sei,
                    $registerUserRequest->mei,
                    $factoryCode,
                    $registerUserRequest->password,
                    $registerUserRequest->permission,
                    $registerUserRequest->csRole,
                )
            );
        } catch (Exception $e) {
            $errorMessage = "ユーザ登録に失敗しました。";
            $this->errorNotifier->notify($errorMessage, $e);
            // 500レスポンスを返す
            return new RegisterUser500Response($errorMessage);
        }

        assert(is_int($user->id), 'User ID must be int after registration');
        assert(is_int($user->sort), 'User sort must be int after registration');

        // 200レスポンスを返す
        return new RegisterUser200Response(
            id:         $user->id,
            sei:        $user->sei,
            mei:        $user->mei,
            permission: $user->permission,
            csRole:     $user->csRole,
        );
    }
}

インターフェースと実装をサービスプロバイダで紐付ける。

<?php

declare(strict_types=1);

namespace CmsBackOffice\Presentation\User;

use Illuminate\Support\ServiceProvider;
use CmsBackOffice\Presentation\User\Adapter\RegisterUserAdapter;
use CmsBackOffice\Presentation\User\OpenApiGen\Api\RegisterUserApiInterface;

class BackOfficeServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind(RegisterUserApiInterface::class, RegisterUserAdapter::class);
    }
}

これで、自動生成したプレゼンテーション層と自前で作ったユースケース層を繋げる事ができた。

バリデーションミドルウェアを設定する

openapi-psr7-validator の必要性

openapi-generator-cli のコード生成では、パスパラメータのバリデーションは行われるものの、リクエストボディのバリデーションは行われない。

ユーザー登録APIを例に挙げると、リクエストボディのパラメータ sei, mei, password, permission, cs_role は全て必須項目となっている。

paths:
  /dash/{factory_name}/api/back-office/user/register:
    post:
      tags:
        - registerUser
      description: 新規ユーザ登録
      operationId: registerUser
      parameters:
        - name: factory_name
          in: path
          required: true
          schema:
            type: string
          description: 工場名
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - sei
                - mei
                - password
                - permission
                - cs_role
              properties:
                sei:
                  type: string
                  description: 姓
                mei:
                  type: string
                  description: 名
                password:
                  type: string
                  description: パスワード
                permission:
                  type: integer
                  description: 権限
                cs_role:
                  type: integer
                  description: CSロール

一方、自動生成コードでは必須バリデーションが無いので自前で用意しなければならない。結果、重複して定義することになってしまう。

これを避けるために、openapi-psr7-validator を使ってAPIスキーマに沿ってバリデーションを実施できるようにする。

APIスキーマに現れないバリデーションを実施したいときは、上述のアダプター内でバリデーションを行うと良い。(例:リクエストされた工場コードの存在チェック)

バリデーションの設定

バリデーションは OpenApiValidationMiddleware で行うようにしており、OpenApiValidator の内部で openapi-psr7-validator を使用している。

<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use League\OpenAPIValidation\PSR7\Exception\ValidationFailed;
use Symfony\Component\HttpFoundation\Response;

class OpenApiValidationMiddleware
{
    public function handle(Request $request, Closure $next, string $specFilePath): Response
    {
        $validator = new OpenApiValidator($specFilePath);

        try {
            // リクエストバリデーション
            $operationAddress = $validator->validateRequest($request);
        } catch (ValidationFailed $e) {
            return $this->createValidationErrorResponse(
                $e->getMessage() . $e->getPrevious()?->getMessage(),
                Response::HTTP_BAD_REQUEST
            );
        }

        $response = $next($request);

        try {
            // レスポンスバリデーション
            $validator->validateResponse($response, $operationAddress);
        } catch (ValidationFailed $e) {
            return $this->createValidationErrorResponse($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
        }
        return $response;
    }
}
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use League\OpenAPIValidation\PSR7\OperationAddress;
use League\OpenAPIValidation\PSR7\ResponseValidator;
use League\OpenAPIValidation\PSR7\ServerRequestValidator;
use League\OpenAPIValidation\PSR7\ValidatorBuilder;
use Nyholm\Psr7\Factory\Psr17Factory;
use RuntimeException;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class OpenApiValidator
{
    private string $specFilePath;
    private ServerRequestValidator $requestValidator;
    private ResponseValidator $responseValidator;
    private PsrHttpFactory $psrHttpFactory;

    public function __construct(string $specFilePath)
    {
        $this->specFilePath = $specFilePath;
        $this->initializeValidators();
        $psr17Factory = new Psr17Factory();
        $this->psrHttpFactory = new PsrHttpFactory(
            $psr17Factory,
            $psr17Factory,
            $psr17Factory,
            $psr17Factory
        );
    }

    /**
     * @throws \League\OpenAPIValidation\PSR7\Exception\ValidationFailed
     */
    public function validateRequest(Request $request): OperationAddress
    {
        $psrRequest = $this->psrHttpFactory->createRequest($request);
        return $this->requestValidator->validate($psrRequest);
    }

    /**
     * @throws \League\OpenAPIValidation\PSR7\Exception\ValidationFailed
     */
    public function validateResponse(Response $response, OperationAddress $operationAddress)
    {
        $psrResponse = $this->psrHttpFactory->createResponse($response);
        $this->responseValidator->validate($operationAddress, $psrResponse);
    }

    private function initializeValidators(): void
    {
        if (!file_exists($this->specFilePath)) {
            throw new RuntimeException("OpenAPI specification file not found: $this->specFilePath");
        }

        $validatorBuilder = new ValidatorBuilder();
        $validatorBuilder->fromYamlFile($this->specFilePath);

        $this->requestValidator = $validatorBuilder->getServerRequestValidator();
        $this->responseValidator = $validatorBuilder->getResponseValidator();
    }
}

ルートサービスプロバイダで、このミドルウェアを自動生成されたルーティングファイルに適用することで、リクエストバリデーションとレスポンスバリデーションが行われる。

<?php

namespace CmsBackOffice\Presentation\User;

use App\Http\Middleware\OpenApiValidationMiddleware;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;

class BackOfficeRouteServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        if ($_SERVER['HOST_SUFFIX'] === 'wh-plus.com') {
            $this->routes(function () {
                Route::middleware(OpenApiValidationMiddleware::class . ":" . __DIR__ . "/api.yaml")
                    ->group(base_path('lib/CmsBackOffice/Presentation/User/OpenApiGen/routes.php'));
            });
        }
    }
}

備考: ライブラリ選定について

  • openapi-generator-cli
    • OpenAPI スキーマからコードを自動生成するツールはあまりなく、採用実績を考えると openapi generator 一択だった。
    • openapi generator は多言語対応している最も有名なものだが、それ故に言語毎のアップデート頻度が高くない。それが理由で Go ではいくつかの generator ライブラリが出てきているが、PHP にはその動きが無いため選択肢が無かった。
  • openapi-psr7-validator
    • OpenAPI バリデーションライブラリも同様に選択肢が無く一択だった。
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?