CakePHP4 で Swagger3 を使って API ドキュメントを作る

API ドキュメントを作るために Swagger3(OpenAPI) を使えるようにします。

[PHPでSwagger3(OpenAPI Specification 3)](https://qiita.com/horikeso/items/34ad3c91a6864e868d34)

上記の記事がすごく参考になりました。


# この記事でわかること

- CakePHP4 へ Swagger3 を導入する手順。
- この記事内のソースは以下で公開しています。
    - https://github.com/katsuhiko/cakephp-vue-study


# 事前準備

- CakePHP4 プロジェクトは以下の記事で作成して、Docker で動かしつつ、最低限の動作をできるようにしています。
    - [CakePHP4 を docker-compose で動くようにする](https://qiita.com/katsuhiko/items/f18c488457af33abdf66)
    - [CakePHP4 で phpstan を使えるようにする](https://qiita.com/katsuhiko/items/d58a9d8dbfbe3b12f108)
    - [CakePHP4 で Session 情報を DB へ可能するようにする](https://qiita.com/katsuhiko/items/b61383a21c2ebc2027b4)
    - [CakePHP4 で簡単な API を作る](https://qiita.com/katsuhiko/items/5fd845351b011fb70eaf)
- docker-compose を実行し、コンテナを立ち上げます。

```sh
docker-compose up -d
```

|  | バージョン |
|:-----------------|:------------------|
| docker           | Docker version 19.03.8, build afacb8b |
| docker-compose   | docker-compose version 1.25.4, build 8d51620a |
| CakePHP4         | 4.0.4 |


# Swagger3 の導入

`swagger-php` を追加します。
開発時のみ利用するため `--dev` オプションを付けています。

```sh
docker exec -it app php composer.phar require --dev zircote/swagger-php
```

# Composer scripts への追加と変更

OpenAPIファイルの作成処理を Composer 経由で実行させるため、 composer.json の scripts へコマンドを追加します。

APIドキュメントを書く場合、スペースが重要になります。CakePHP用の CodeSniffer で実行した場合、

```
ERROR | [x] Expected 1 space after asterisk; 3 found
```

のようなエラーが大量に発生します。
エラーを回避するために `Squiz.Commenting.DocCommentAlignment` を利用しない( `exclude` )ようにします。

```json:composer.json
    // ... snip
    "scripts": {
        // ... snip
        "cs-check": "phpcs --colors -p --standard=vendor/cakephp/cakephp-codesniffer/CakePHP --exclude=Squiz.Commenting.DocCommentAlignment src/ tests/",
        "cs-fix": "phpcbf --colors --standard=vendor/cakephp/cakephp-codesniffer/CakePHP --exclude=Squiz.Commenting.DocCommentAlignment src/ tests/",
        // ... snip
        "openapi": "openapi --output openapi.yml --format y src/Controller/Api/"
    },
    // ... snip
```

YAML 形式が好きなのでフォーマットをYAML ( `--format y` )へしています。


# .gitignore への追加

生成されるファイル `openapi.yml` をリポジトリの管理対象から外します。

```:.gitignore
# ... snip
# Swagger
openapi.yml
# ... snip
```


# API ドキュメントの作成

## 共通ドキュメント

共通のドキュメント内容を記述するために `./src/Controller/Api/swagger.php` を作成します。
このファイルは、 Swagger の記述のみのファイルです。

```php:src/Controller/Api/swagger.php
<?php
declare(strict_types=1);

/**
 * @OA\Info(
 *   title="CakePHP Vue Study",
 *   description="CakePHP Vue Study API List",
 *   version="1.0.0",
 * )
 */

/**
 * @OA\Server(
 *   description="localhost",
 *   url="http://localhost"
 * )
 */
```


## APIドキュメント

Controller へ APIドキュメントを記述します。

```php:src/Controller/Api/TaskController.php
<?php
declare(strict_types=1);

namespace App\Controller\Api;

use App\Controller\AppController;
use App\Exception\ApplicationException;

/**
 * Task Controller
 *
 * @property \App\Model\Table\TasksTable $Tasks
 */
class TaskController extends AppController
{
    /**
     * Search method
     *
     * @OA\Get(
     *   path="/api/task/search.json",
     *   tags={"Task"},
     *   summary="タスクを検索する",
     *   @OA\Parameter(
     *     name="description_like",
     *     in="query",
     *     required=false,
     *     description="タスク内容検索条件",
     *     @OA\Schema(type="string"),
     *     example="作業"
     *   ),
     *   @OA\Response(
     *     response=200,
     *     description="OK",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="tasks",
     *         type="array",
     *         description="タスク一覧",
     *         @OA\Items(
     *           @OA\Property(
     *             property="id",
     *             type="string",
     *             description="タスクID",
     *           ),
     *           @OA\Property(
     *             property="description",
     *             type="string",
     *             description="タスク内容",
     *           ),
     *         ),
     *         example={
     *           {
     *             "id"="c366f5be-360b-45cc-8282-65c80e434f72",
     *             "description"="朝の身だしなみチェック",
     *           },
     *           {
     *             "id"="93d5ef90-be4d-4179-9311-e39bddc26427",
     *             "description"="寝る前の作業",
     *           },
     *         },
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response="default",
     *     description="Unexpected Error",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="message",
     *         type="string",
     *         description="エラーメッセージ",
     *       ),
     *     ),
     *   ),
     * )
     *
     * @return void
     */
    public function search(): void
    {
        $this->loadModel('Tasks');
        $descriptionLike = strval($this->request->getQuery('description_like'));

        $query = $this->Tasks->find();
        if ($descriptionLike) {
            $tasks = $query->where(['description LIKE' => "%{$descriptionLike}%"]);
        }
        $tasks = $query->all();

        $this->set('tasks', $tasks);
        $this->viewBuilder()->setOption('serialize', ['tasks']);
    }

    /**
     * View method
     *
     * @OA\Get(
     *   path="/api/task/view/{id}.json",
     *   tags={"Task"},
     *   summary="タスクを参照する",
     *   @OA\Parameter(
     *     name="id",
     *     in="path",
     *     required=true,
     *     description="タスクID",
     *     @OA\Schema(type="string"),
     *     example="c366f5be-360b-45cc-8282-65c80e434f72"
     *   ),
     *   @OA\Response(
     *     response=200,
     *     description="OK",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="task",
     *         type="object",
     *         description="タスク",
     *         @OA\Property(
     *           property="id",
     *           type="string",
     *           description="ID",
     *           example="c366f5be-360b-45cc-8282-65c80e434f72",
     *         ),
     *         @OA\Property(
     *           property="description",
     *           type="string",
     *           description="タスク内容",
     *           example="朝の身だしなみチェック",
     *         ),
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response="default",
     *     description="Unexpected Error",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="message",
     *         type="string",
     *         description="エラーメッセージ",
     *       ),
     *     ),
     *   ),
     * )
     *
     * @param string $id id.
     * @return void
     */
    public function view(string $id): void
    {
        $this->loadModel('Tasks');
        $task = $this->Tasks->get($id);

        $this->set('task', $task);
        $this->viewBuilder()->setOption('serialize', ['task']);
    }

    /**
     * Create method
     *
     * @OA\Post(
     *   path="/api/task/create.json",
     *   tags={"Task"},
     *   summary="タスクを登録する",
     *   @OA\RequestBody(
     *     required=true,
     *     @OA\JsonContent(
     *       type="object",
     *       required={"description"},
     *       @OA\Property(
     *         property="description",
     *         type="string",
     *         description="タスク内容",
     *         example="朝の身だしなみチェック",
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response=200,
     *     description="OK",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="task",
     *         type="object",
     *         description="タスク",
     *         @OA\Property(
     *           property="id",
     *           type="string",
     *           description="ID",
     *           example="c366f5be-360b-45cc-8282-65c80e434f72",
     *         ),
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response="default",
     *     description="Unexpected Error",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="message",
     *         type="string",
     *         description="エラーメッセージ",
     *       ),
     *     ),
     *   ),
     * )
     *
     * @return void
     * @throws \App\Exception\ApplicationException
     */
    public function create(): void
    {
        $this->loadModel('Tasks');
        $data = $this->request->getData();

        $task = $this->Tasks->newEntity($data);
        if ($task->hasErrors()) {
            $this->set('errors', $task->getErrors());
            $this->viewBuilder()->setOption('serialize', ['errors']);

            return;
        }

        if (!$this->Tasks->save($task)) {
            throw new ApplicationException(__('登録できませんでした。'));
        }

        $this->set('task', ['id' => $task->id]);
        $this->viewBuilder()->setOption('serialize', ['task']);
    }

    /**
     * Update method
     *
     * @OA\Put(
     *   path="/api/task/update/{id}.json",
     *   tags={"Task"},
     *   summary="タスクを更新する",
     *   @OA\Parameter(
     *     name="id",
     *     in="path",
     *     required=true,
     *     description="タスクID",
     *     @OA\Schema(type="string"),
     *     example="c366f5be-360b-45cc-8282-65c80e434f72"
     *   ),
     *   @OA\RequestBody(
     *     required=true,
     *     @OA\JsonContent(
     *       type="object",
     *       required={"description"},
     *       @OA\Property(
     *         property="description",
     *         type="string",
     *         description="タスク内容",
     *         example="朝の身だしなみチェック",
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response=200,
     *     description="OK",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="task",
     *         type="object",
     *         description="タスク",
     *         @OA\Property(
     *           property="id",
     *           type="string",
     *           description="ID",
     *           example="c366f5be-360b-45cc-8282-65c80e434f72",
     *         ),
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response="default",
     *     description="Unexpected Error",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="message",
     *         type="string",
     *         description="エラーメッセージ",
     *       ),
     *     ),
     *   ),
     * )
     *
     * @param string $id id
     * @return void
     * @throws \App\Exception\ApplicationException
     */
    public function update(string $id): void
    {
        $this->loadModel('Tasks');
        $data = $this->request->getData();
        $task = $this->Tasks->get($id);

        $task = $this->Tasks->patchEntity($task, $data);
        if ($task->hasErrors()) {
            $this->set('errors', $task->getErrors());
            $this->viewBuilder()->setOption('serialize', ['errors']);

            return;
        }

        if (!$this->Tasks->save($task)) {
            throw new ApplicationException(__('更新できませんでした。'));
        }

        $this->set('task', ['id' => $id]);
        $this->viewBuilder()->setOption('serialize', ['task']);
    }

    /**
     * Delete method
     *
     * @OA\Delete(
     *   path="/api/task/delete/{id}.json",
     *   tags={"Task"},
     *   summary="タスクを削除する",
     *   @OA\Parameter(
     *     name="id",
     *     in="path",
     *     required=true,
     *     description="タスクID",
     *     @OA\Schema(type="string"),
     *     example="c366f5be-360b-45cc-8282-65c80e434f72"
     *   ),
     *   @OA\Response(
     *     response=204,
     *     description="No Content",
     *   ),
     *   @OA\Response(
     *     response="default",
     *     description="Unexpected Error",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="message",
     *         type="string",
     *         description="エラーメッセージ",
     *       ),
     *     ),
     *   ),
     * )
     *
     * @param string $id id
     * @return void
     * @throws \App\Exception\ApplicationException
     */
    public function delete(string $id): void
    {
        $this->loadModel('Tasks');
        $task = $this->Tasks->get($id);

        if (!$this->Tasks->delete($task)) {
            throw new ApplicationException(__('削除できませんでした。'));
        }

        $this->response = $this->response->withStatus(204);
        $this->viewBuilder()->setOption('serialize', []);
    }
}
```


# ドキュメントの作成

Composer の scripts へコマンドを追加しているので以下のように実行すれば作成できます。

```sh
docker exec -it app php composer.phar openapi
```


# 補足) CORS の対応

Swagger UI (僕は、 VSCode の Swagger Viewer
 を使っています) からは API を呼び出すことができますが、　CORS に対応する必要があります。

今回は開発時のみ有効としたいと思っているので、SERVER環境変数が設定されていない、もしくは false のとき、ローカル開発とみなして CORS を許可します。


## GET 等の 単純リクエストの対応

Middleware で必要なヘッダ情報を付与することで対応します。

```php:src/Middleware/CorsMiddleware.php
<?php
declare(strict_types=1);

namespace App\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class CorsMiddleware implements MiddlewareInterface
{
    /**
     * @param \Psr\Http\Message\ServerRequestInterface $request request
     * @param \Psr\Http\Server\RequestHandlerInterface $handler handler
     * @return \Psr\Http\Message\ResponseInterface
     */
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);

        // ローカル開発モードのときのみ CORS に対応する
        if (filter_var(env('SERVER', false))) {
            return $response;
        }

        $response = $response->withHeader('Access-Control-Allow-Origin', '*');
        $response = $response->withHeader('Access-Control-Allow-Methods', '*');
        $response = $response->withHeader('Access-Control-Allow-Headers', 'Content-Type');
        $response = $response->withHeader('Access-Control-Max-Age', '172800');

        return $response;
    }
}
```

この `CorsMiddleware` はルーターで利用するように設定します。
後ほど、 ./config/routes.php の記述例を載せます。


## Preflight request の対応

Controller を作り、Preflight request を処理できるようにします。

```php:src/Controller/Api/CorsController.php
<?php
declare(strict_types=1);

namespace App\Controller\Api;

use App\Controller\AppController;
use Cake\Http\Exception\NotFoundException;

/**
 * CORS Controller
 */
class CorsController extends AppController
{
    /**
     * @return void
     */
    public function options(): void
    {
        // ローカル開発モードのときのみ CORS に対応する
        if (filter_var(env('SERVER', false))) {
            throw new NotFoundException('Not support CORS.');
        }

        $this->viewBuilder()->setOption('serialize', []);
    }
}
```

## ルーターへの設定

`CorsMiddleware` と `CorsController` を設定します。

```php:config/routes.php
// ... snip
use App\Middleware\CorsMiddleware;
// ... snip
// API
$routes->scope('/api', ['prefix' => 'Api'], function (RouteBuilder $builder) {
    $builder->registerMiddleware('bodies', new BodyParserMiddleware());
    $builder->applyMiddleware('bodies');
    $builder->registerMiddleware('cors', new CorsMiddleware());
    $builder->applyMiddleware('cors');

    $builder->setExtensions(['json']);

    // Preflight request
    $builder->connect('/*', ['controller' => 'Cors', 'action' => 'options'])->setMethods(['OPTIONS']);

    // ... snip
});
// ... snip
```

これで、Swagger UI からも呼び出せるはずです。


# 補足

一応は、APIドキュメントがかけるようになりましたが、ドキュメント内容と処理内容が一致しているか、確認しづらい状態です。
この点をなんとかする方法を模索したいと思っています。

[CakePHP4 で Swagger3 を使った API ドキュメントの書き方を考える](https://qiita.com/katsuhiko/items/731fdf73d8fdfaa25ca6) こちらで ref と schema を使った API ドキュメント作成について記述しています。