概要
OpenAPI を使った開発では、一般的に APIスキーマ定義からコードを自動生成することでAPIへのリクエスト/レスポンス処理は自前で書かないというプラクティスがある。
→ プレゼンテーション層以外のレイヤーに集中できる。
Laravelでもそれを実践することが可能なのでそのやり方を記載する。
簡単に内容をまとめると、
- openapi-generator-cli を使って、OpenAPI スキーマ定義を元にルーティングファイルや Controller を自動生成する。
- 自動生成したプレゼンテーション層にユースケース層を繋ぎこむ。
- openapi-psr7-validator を組み合わせてAPIスキーマに沿っているかリクエスト/レスポンスのバリデーションを行う。
となる。
手順
- APIスキーマ定義を作成する(又は変更する)
- openapi-generator-cli を実行する
- ユースケースを繋ぎこむ
- バリデーションミドルウェアを設定する
以下、ユーザー管理画面を例に説明していく。
APIスキーマ定義を作成する(又は変更する)
OpenAPI Specification に沿って定義を作成、変更する(参考)。
ここでは、ユーザー登録APIのスキーマ定義を以下のように作成したと仮定する。
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の入り口はできた。
<?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');
<?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 バリデーションライブラリも同様に選択肢が無く一択だった。