Zenn
📜

ScrambleによるOpenAPI仕様生成あれこれ

2025/04/02に公開

Laravelの実装からOpenAPI仕様を生成するツール、Scrambleを使って手書きしていたOpenAPI仕様を置き換えた。
OpenAPI仕様はOrvalによるAPIクライアント生成に利用している都合上、なるべく既存のOpenAPI仕様を再現したかった。そのため生成結果を比較的厳密に制御するよう調整したので、その際に必要となった対応を中心にまとめる。

https://scramble.dedoc.co/

環境

  • PHP: 8.3.19
  • Laravel: 11.9
  • Scramble: 0.12.10

Scrambleの導入と基本設定

手順通りインストールして対象とするAPIパスを設定するだけで、/docs/apiにStoplight ElementによるOpenAPI仕様のUIがホスティングされる。


Laravel OpenAPI (Swagger) Documentation Generator - Scramble

Installation & setup - Scramble

# Composerでインストール
composer require dedoc/scramble

# 設定ファイルをpublish
php artisan vendor:publish --provider="Dedoc\Scramble\ScrambleServiceProvider" --tag="scramble-config"

生成対象とするAPIパスには規定でapiプレフィクスを期待しているため、変更している場合は以下を設定する必要があることに留意

config/scramble.php
    /*
     * Your API path. By default, all routes starting with this path will be added to the docs.
     * If you need to change this behavior, you can add your custom routes resolver using `Scramble::routes()`.
     */
    'api_path' => 'api',

JSON形式のOpenAPI仕様はdocs/api.jsonから取得するか、scramble:exportコマンドでエクスポートできる。(生成されるJSONファイルはUnicodeエスケープされているので、読みやすさのためにはjq等でデコードするとよい)

php artisan scramble:export --path api.json

ルートリゾルバの拡張

Sanctumのような機能が暗黙に提供するエンドポイント(例: /sanctum/csrf-cookie)はAPIプレフィクスを持たないため、デフォルトでは対象とならない場合がある。
こうしたエンドポイントもドキュメントに含めたい場合、AppServiceProviderで以下のように解決ロジックを変更する。

app/Providers/AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    // ...

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Scramble::configure()
            ->routes(function (Route $route) {
                return str_starts_with($route->uri, 'api') || str_starts_with($route->uri, 'sanctum');
            })

生成結果の調整

Scrambleはルートモデルバインディングやバリデーションルールの実装から、各種パラメータやその型、ちょっとした説明(description)まで、かなりよしなに生成してくれる。自動生成では不足する内容や、例として特定の値を示したい場合は、基本的にPHPDocを記述することで対応することができる。

ただしPHPDocでの記述には限界があるようなので、各種仕様を表現するためにはScrambleの提供するアトリビュートを利用したり、少々Scrambleに都合の良い書き方に修正する必要がある。

API全体で共通の要素

認証ヘッダなど、API全体で共通する要素等は特別に何かPHPDocで表現する方法はないようなので、Scrambleによる生成結果を直接編集できるDocument transformers, Operations transformersを利用して設定する。

Document transformers

ScrambleがPHPDocや実装を元に生成した結果は、Document transformersという機能で自由に編集することができる。
少々煩雑になるが、PHPDocで表現できない内容は、最終的に全てここで処理してしまえばよい。

Customize OpenAPI documents - Scramble

認証ヘッダ

securityセクションのような共通の要素はここで定義する。

app/Providers/AppServiceProvider.php
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;

class AppServiceProvider extends ServiceProvider
{
    // ...

    public function boot(): void
    {
        Scramble::configure()
            ->routes(function (Route $route) {
                // ...
            })
            ->withDocumentTransformers(function (OpenApi $openApi) {
                $cookieAuth = SecurityScheme::apiKey('cookie', 'laravel_session')
                    ->setDescription('SPA認証を利用する場合にセッションIDを指定');
                $bearerAuth = SecurityScheme::http('bearer')
                    ->setDescription('APIトークン認証を利用する場合にAPIトークンを指定');

                // SPA認証か、APIトークン認証のいずれかを要求
                $openApi->secure($cookieAuth);
                $openApi->secure($bearerAuth);
生成結果
curl -s localhost:8080/docs/api.json | jq '{security, components: {securitySchemes: .components.securitySchemes}}'
{
  "security": [
    {
      "apiKey": []
    },
    {
      "http": []
    }
  ],
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "apiKey",
        "description": "SPA認証を利用する場合にセッションIDを指定",
        "in": "cookie",
        "name": "laravel_session"
      },
      "http": {
        "type": "http",
        "description": "APIトークン認証を利用する場合にAPIトークンを指定",
        "scheme": "bearer"
      }
    }
  }
}

/sanctum/csrf-cookieエンドポイントの仕様定義

先ほど生成対象に含めた/sanctum/csrf-cookieエンドポイントはSanctumが暗黙的に提供しているため、PHPDocを書く場所がない。こうした場合もDocument transformersで対象のエンドポイントを表現するオブジェクトを取得して、直に内容を変更してやればよい。
(ラッパーを用意してそこに書いてもいいかもしれないが、不要な実装は避ける方針で)

app/Providers/AppServiceProvider.php
->withDocumentTransformers(function (OpenApi $openApi) {
    // ...

    // Model, Resource等のPHPDocでは表現できないレスポンスを定義
    foreach ($openApi->paths as $path) {
        foreach ($path->operations as $operation) {
            // Sanctumにより自動提供され、実装を扱わないAPIはここで仕様を記述
            if (str_ends_with($operation->path, 'sanctum/csrf-cookie')) {
                $operation->summary('CSRFトークン取得API');
                $operation->description('SPA認証で使用するCSRFトークンを取得する');
                $operation->setOperationId('get-csrf');
                $operation->setTags(['auth']);
                $operation->addSecurity([]);
                $operation->addResponse(
                    Response::make(204)
                        ->description(
                            '以下のCookieを設定'
                            .'<ul>'
                            .'<li>XSRF-TOKEN: CSRF保護用トークン</li>'
                            .'<li>laravel_session: セッションID</li>'
                            .'</ul>'
                        )
                    // NOTE: (Scramble v0.12.10) Response がレスポンスヘッダの定義をサポートしていない
                    // Parameter::make('Set-Cookie', 'header')
                );
            }

共通エラーの追加

特別意味のある情報でもないが、既存のOpenAPI仕様で用意していたため再現する。
Scrambleは自動的にルートモデルバインディングを使用しているエンドポイントに404を追加するが、419や500は特に定義してくれないようなのでここで追加しておく。

app/Providers/AppServiceProvider.php
// NOTE: Schemaとして用意するため、`withDocumentTransformers`で定義
$openApi->components->schemas['CsrfTokenError'] = Schema::createFromParameters([
    Parameter::make('message', 'body')
        ->setSchema(Schema::fromType(new StringType))
        ->description('エラーメッセージ')
        ->example('CSRF token mismatch.'),
]);
$openApi->components->schemas['CommonError'] = Schema::createFromParameters([
    Parameter::make('message', 'body')
        ->setSchema(Schema::fromType(new StringType))
        ->description('エラーメッセージ')
        ->example('An unexpected error occurred.'),
]);
// 共通の一般的なエラー(419, 500)を各APIに追加
foreach ($openApi->paths as $path) {
    foreach ($path->operations as $operation) {
        if (
            // CSRFトークンの不要なAPIは除外
            ! (str_ends_with($operation->path, 'sanctum/csrf-cookie') ||
            str_ends_with($operation->path, 'auth/token'))
        ) {
            $operation->addResponse(
                Response::make(419)
                    ->description('無効なCSRFトークン')
                    ->setContent(
                        'application/json',
                        $openApi->components->schemas['CsrfTokenError']
                    )
            );
        }
        // すべてのAPIは500を返却し得る
        $operation->addResponse(
            Response::make(500)
                ->description('予期せぬエラー')
                ->setContent(
                    'application/json',
                    $openApi->components->schemas['CommonError']
                )
        );
    }
}

Operation transformers

Document transformersでも同じことができるが、Operation transformersでは各operation(paths./some/api/path.{post|get|put|delete}以下の構造のこと)に適用する処理を与えられるので、以下のようなループを回す必要がない。

foreach ($openApi->paths as $path) { foreach ($path->operations as $operation) { /* ... */ } }

SPA認証でのAPI実行時に必要なCSRFトークン用ヘッダなど、すべてのAPIに適用したい共通の要素の設定にはこれを使うと便利

app/Providers/AppServiceProvider.php
->withOperationTransformers(function (Operation $operation, RouteInfo $routeInfo) {
    // 認証状態に関係のないAPIは除外
    if (
        str_ends_with($routeInfo->route->uri, 'sanctum/csrf-cookie') ||
        str_ends_with($routeInfo->route->uri, 'auth/token')
    ) {
        return;
    }

    // CSRFトークン用のヘッダ定義
    $csrfTokenHeader = new Parameter('X-XSRF-TOKEN', 'header');
    $csrfTokenHeader->setSchema(Schema::fromType(new StringType));
    $csrfTokenHeader->example('eyJpdiI6Imp0aF...');
    $csrfTokenApiLink = '<a href="/operations/get-csrf">CSRFトークン取得API</a>';
    if (
        str_ends_with($routeInfo->route->uri, 'auth/login') ||
        str_ends_with($routeInfo->route->uri, 'auth/logout')
    ) {
        // SPA認証のログイン/ログアウトAPIではCSRFトークンが必須
        $csrfTokenHeader->required = true;
        $csrfTokenHeader->description(
            $csrfTokenApiLink.'で取得したトークンを指定'
        );
    } else {
        // 通常のAPIでは利用する認証方式に依るため、Optionalとする
        $csrfTokenHeader->required = false;
        $csrfTokenHeader->description(
            'SPA認証を利用する場合は、'.$csrfTokenApiLink.'で取得したトークンを指定<br/>'
            .'(APIトークン認証を利用する場合は不要)'
        );
    }
    $operation->addParameters([
        $csrfTokenHeader,
    ]);
})

タグ

tagsはPHPDocの@tagsタグで定義できるが、ScrambleはControllerクラス単位でAPIをグルーピングするためにクラス名から作成したタグを付与していて、PHPDocのタグによる記法ではこれに追加することになる。

Grouping endpoints | Requests - Scramble

/**
 * @tags auth
 */
class AuthController extends Controller

AuthControllerであればAuthタグがすでに設定されている。

// curl -s localhost:8080/docs/api.json | jq '.paths."/api/auth/login".post.tags'
["auth", "Auth"]

自動で付与されたタグを排除したい場合は、Groupアトリビュートを利用する。

#[Group('auth', '認証API', 0)]
class AuthController extends Controller

こちらは自動で付与されたタグを置き換えることができる。

// curl -s localhost:8080/docs/api.json | jq '.paths."/api/auth/login".post.tags'
["auth"]

パスパラメータ

パスパラメータは自動的に解析される上、PHPDocの@paramタグでdescriptionを記載することもできる。
さらにPathParameterアトリビュートを使えば、formatexampleも設定することができる。

#[PathParameter(
    'organization',
    description: '組織ID',
    type: 'string',
    format: 'ulid',
    example: '01JP4DYJBQ95RDX254342ZNBDM',
)]
public function register(Request $request, Organization $organization)

ただし、名前にはモデルバインディングで利用しているモデルの名前がそのまま利用される。

// curl -s localhost:8080/docs/api.json | jq '.paths."/api/organizations/{organization}/users".post.parameters'
[
  {
    "name": "organization",
    "in": "path",
    "required": true,
    "description": "The organization ID",
    "schema": {
      "type": "string"
    }
]

API側の実装としてはモデルがバインドされるのでこの命名でいいのだが、API仕様としてはそのIDを受け付けるので-idサフィックスを与えたい。こうしたズレをPHPDocやPathParameterアトリビュートで解決する方法はなさそうだったので、今回は以下のような拡張機能を追加して対応した。

Extensions - Scramble

app/Scramble/Extensions/PathParameterIdSuffixExtension.php
<?php

namespace App\Scramble\Extensions;

use Dedoc\Scramble\Extensions\OperationExtension;
use Dedoc\Scramble\Support\Generator\Operation;
use Dedoc\Scramble\Support\RouteInfo;

class PathParameterIdSuffixExtension extends OperationExtension
{
    public function handle(Operation $operation, RouteInfo $routeInfo)
    {
        // パスパラメータにサフィックスとして`-id`を付与する
        // NOTE: ルートキーとして`id`以外の属性を使用するモデルが必要になれば要改修
        foreach ($operation->parameters as $parameter) {
            if ($parameter->in === 'path') {
                $parameter->setName(
                    // Controllerメソッドの引数名が元となるため、
                    // 複数単語で構成されるキャメルケースの変数名は規則統一のためケバブケースに変換
                    preg_replace_callback(
                        '/[A-Z]/',
                        fn ($m) => '-'.strtolower($m[0]),
                        $parameter->name
                    ).'-id'
                );
            }
        }
        $operation->setPath(preg_replace_callback(
            '/\{(\w+)\}/',
            fn ($m) => '{'.preg_replace_callback('/[A-Z]/', fn ($n) => '-'.strtolower($n[0]), $m[1]).'-id}',
            $routeInfo->route->uri
        ));
    }
}

作成した拡張機能は設定ファイルで適用する。

config/scramble.php
use App\Scramble\Extensions\PathParameterIdSuffixExtension;

return [
    // ...
    'extensions' => [
        PathParameterIdSuffixExtension::class,
    ],
];

クエリパラメータ

クエリパラメータは特に工夫もないが、prohibitedルールを利用している場合などには@ignoreParamして結果から除外しておくとよい。

/**
 * 商品ID
 *
 * @ignoreParam
 *
 * @example 01JPHMKE52QHNCMY9G4WGQPBN9
 */
'product_id' => 'prohibited',

レスポンス

401

Gateファサードを利用している場合は401が追加されないので、トレイトを介して利用する。

use Illuminate\Support\Facades\Gate;

class SampleController extends Controller
{
    public function register(Request $request, Organization $organization)
    {
        Gate::authorize('create', $organization);
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;

class SampleController extends Controller
{
    use AuthorizesRequests;

    public function register(Request $request, Organization $organization)
    {
        $this->authorize('create', $organization);

404

モデルバインディングを使っている場合は404を追加してくれるはずが、PathParameterアトリビュートを利用すると404が検知されない。これは@throwsで明示的にNotFoundExceptionを指定して解決する。

Model binding in route | Responses - Scramble

/**
 * ...
 *
 * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
 */

クロージャで成形するレスポンス

レスポンスは基本的にResourceクラスを使って成形するようにし、このPHPDocに詳細を書くようにする。ネストして他のResourceクラスを使ったり、whenLoadedにより条件付きでフィールドを追加する場合も期待通り解析してくれる。

Automatically supported fields types | Responses - Scramble

ただし、map等でクロージャを使ってその場で成形するような処理(whenLoaded等で第二引数にクロージャを渡す場合も同様)はうまく解釈してくれないようで、クロージャ自体にタインプヒントしたり、連想配列にPHPDocを記述しても意味がなかった。ここはかなり微妙な気がするが、PHPDocの@varタグに与えるためだけのResourceクラスを作って対応している。

/**
 * 試合結果
 *
 * @var array<int, FightResultResource>
 */
'fight_results' => $this->results->map(function ($result) {
    return [
        'id' => $result->id,
        'match_title' => $result->match_title,
        'score' => $result->pivot->score,
    ];
})

@deprecatedでマークしつつ、Resources/Schemasなど適当なディレクトリに隔離して配置

app/Http/Resources/Schemas/FightResultResource.php
/**
 * 試合結果
 *
 * @deprecated ScrambleによるOpenAPI仕様生成のためPHPDocを記載する目的で定義
 * 変換前のデータ構造をそのまま返す冗長な実装のため、実際には使用しない
 */
class FightResultResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            /**
             * 試合ID
             *
             * @example 01JPKY214TQ7ZT28BWQND27AY5
             */
            'id' => $this->resource['id'],
            /**
             * タイトル
             *
             * @example 振り返り小テスト
             */
            'match_title' => $this->resource['match_title'],
            /**
             * スコア
             *
             * @example 10
             */
            'score' => $this->resource['score'],
        ];
    }
}

またこの方法を取る場合、PHPDoc内でFQCNを参照するのみでは期待通り解決されないため、明示的にuseする必要がある。
(参照されていないResourceクラスがcomponents.schemasに載らないため?)

use App\Http\Resources\Schemas\FightResultResource;

その他

PHPDocではマークダウン記法が利用できるため、改行のためにtrailing whitespaceのルールは無効化しておくとよい。

.editorconfig
[*.php]
trim_trailing_whitespace = false
pint.json
{
    "preset": "laravel",
    "rules": {
        "no_trailing_whitespace_in_comment": false
    }
}

所感

  • transformerでこねくり回さないと表現できない部分があるものの、素の実装だけでも最低限のものを出力してくれるのは嬉しい。かなり感動的ツール
  • レスポンスヘッダが設定できなかったり、アトリビュートで書き換えると挙動が変わる部分など、未完成な部分もあるが今後のv1.0リリースでの改善に期待
  • 手書きしていた既存のOpenAPI仕様をほとんど再現できたが、リクエストボディを表現していたschemaなど、やはり一部制御できず変わる部分は出てくるので最初から導入しておきたい
    • 導入コストはほぼないはずなので、とりあえず入れておいて後から詰めればよい

Discussion

ログインするとコメントできます