CakePHP4 で Swagger3 を使った API ドキュメントの書き方を考える( ref と schema でわけていく )

[CakePHP4 で Swagger3 を使って API ドキュメントを作る](https://qiita.com/katsuhiko/items/22511d5ed5eff3e4616a) の続きです。

上記の記事で、APIドキュメントをかけるようになりましたが、ソースと一致しているかを確かめるのが難しい状態です。
少しでもソースからAPIのI/Fが明確になるような作りを考察してみます。

CakePHP4 では [モデルのないフォーム](https://book.cakephp.org/4/ja/core-libraries/form.html) があるので、これを活用しようと思います。


# この記事でわかること

- CakePHP4 で APIドキュメントを作る重厚型の開発方法。
- この記事内のソースは以下で公開しています。
    - https://github.com/katsuhiko/cakephp-vue-study


# 事前準備

- [CakePHP4 で Swagger3 を使って API ドキュメントを作る](https://qiita.com/katsuhiko/items/22511d5ed5eff3e4616a) の続きです。
- 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 |


# 考え方／方針

前回の書き方だと、Controllerのメソッドへパス情報、リクエスト情報、レスポンス情報と情報が多く書かれています。
これを分割し、かつ、リクエストとレスポンスの情報のスキーマが明確になるようにしたいと思います。

そのため 1API ごとに Controller、RequestForm、ResponseForm の最低3つのクラスを作るようにします。
この RequestForm、ResponseForm という言葉は、僕の造語です。

従来だと 1つの Controller で実装していたことを複数のクラスに分割するので、重厚な作りになります。


# タスク検索APIの実装

タスク検索APIから実装します。


## SearchTaskRequestForm の実装

RequestForm を実装します。通常、Form は `./src/Form` 配下に置くことになるんですが、今回の作りの場合、Form を共有することはほぼないと思っているので、 Controller と同じディレクトリへ配置します。

検索条件として渡ってくる query パラメータを受け入れる RequestForm を作ります。

` $descriptionLike ` プロパティに対して Swagger の Parameter として定義します。

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

namespace App\Controller\Api\Task;

use Cake\Form\Form;
use Cake\Utility\Hash;
use Cake\Validation\Validator;

/**
 * SearchTaskRequestForm
 */
class SearchTaskRequestForm extends Form
{
    /**
     * @OA\Parameter(
     *   parameter="SearchTaskRequestForm_descriptionLike",
     *   name="description_like",
     *   in="query",
     *   required=false,
     *   description="タスク内容検索条件",
     *   @OA\Schema(type="string"),
     *   example="作業"
     * )
     *
     * @var string|null
     */
    private $descriptionLike;

    /**
     * @param \Cake\Validation\Validator $validator Validator
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator): Validator
    {
        $validator
            ->scalar('description_like')
            ->requirePresence('description_like', false)
            ->notEmptyString('description_like');

        return $validator;
    }

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $data = Hash::merge([
            'description_like' => null,
        ], $data);

        $this->descriptionLike = $data['description_like'];

        return true;
    }

    /**
     * @return string|null
     */
    public function descriptionLike(): ?string
    {
        return $this->descriptionLike;
    }
}
```


## SearchTaskResponseForm の実装

API の返却値となる ResponseForm を実装します。
Swagger の response として定義するとともに schema としても定義します。

ただし、詳細については、 TaskDetailForm を利用します。

この ResponseForm でも Validation を行って、期待どおりの返却値をチェックしても良いかなっと思いましたが、それはやりすぎだと思い辞めました（コメント化しました）。
返却値をチェックすれば、契約による設計（DbC）になるかなっと思ったんですが、テストできない（テストをし忘れる）不具合を生むロジックが増えるだけかなっと思ったので辞めました。
一応ですが、 CakePHP4 では NestedValidation の機能があるので入れ子になった返却値のチェックは可能です。

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

namespace App\Controller\Api\Task;

use Cake\Collection\Collection;
use Cake\Controller\Controller;
use Cake\Form\Form;

/**
 * SearchTaskResponseForm
 *
 * @OA\Response(
 *   response="SearchTaskResponseForm",
 *   description="OK",
 *   @OA\JsonContent(ref="#/components/schemas/SearchTaskResponseForm"),
 * )
 *
 * @OA\Schema(
 *   description="タスク検索レスポンス情報",
 *   type="object",
 * )
 */
class SearchTaskResponseForm extends Form
{
    /**
     * @OA\Property(
     *   property="data",
     *   type="array",
     *   description="タスク一覧情報",
     *   @OA\Items(ref="#/components/schemas/TaskDetailForm"),
     * )
     *
     * @var \App\Controller\Api\Task\TaskDetailForm[]
     */
    private $data;

    // /**
    //  * @param \Cake\Validation\Validator $validator Validator
    //  * @return \Cake\Validation\Validator
    //  */
    // public function validationDefault(Validator $validator): Validator
    // {
    //     $taskValidator = (new TaskDetailForm())->validationDefault(new Validator());
    //     $validator->addNestedMany('tasks', $taskValidator);
    //     return $validator;
    // }

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $this->data = (new Collection($data['tasks']))->map(function ($task) {
            $taskDetail = new TaskDetailForm();
            $taskDetail->execute($task);

            return $taskDetail;
        })->toList();

        return true;
    }

    /**
     * @param \Cake\Controller\Controller $controller controller
     * @return void
     */
    public function response(Controller $controller): void
    {
        $data = (new Collection($this->data))->map(function ($taskDetail) {
            return $taskDetail->toArray();
        })->toList();

        $controller->set('data', $data);
        $controller->viewBuilder()->setOption('serialize', ['data']);
    }
}
```


## TaskDetailForm の実装

タスク一覧情報の１レコードづつの情報です。
Swagger の schema として定義する一番シンプルな Form です。
わざわざ Form にする必要はない気がします。普通の POPO(Plain Old PHP Object) でも良い気がしますが、Validation の仕組みを後で入れる可能性はあると思い、 Form にしました。

こちらも Validation はやりすぎだと思ったので外しています。

また、 _execute メソッドで array を解体して、 toArray メソッドでまた array にするっという無駄な手間・処理をかけていますが、明示的にするために仕方ないところかなっと思っています。

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

namespace App\Controller\Api\Task;

use Cake\Form\Form;

/**
 * TaskDetailForm
 *
 * @OA\Schema(
 *   description="タスク詳細情報",
 *   type="object",
 * )
 */
class TaskDetailForm extends Form
{
    /**
     * @OA\Property(
     *   property="id",
     *   type="string",
     *   description="タスクID",
     *   example="c366f5be-360b-45cc-8282-65c80e434f72",
     * )
     *
     * @var string
     */
    private $id;

    /**
     * @OA\Property(
     *   property="description",
     *   type="string",
     *   description="タスク内容",
     *   example="朝の身だしなみチェック",
     * )
     *
     * @var string
     */
    private $description;

    // /**
    //  * @param \Cake\Validation\Validator $validator Validator
    //  * @return \Cake\Validation\Validator
    //  */
    // public function validationDefault(Validator $validator): Validator
    // {
    //     $validator
    //         ->uuid('id')
    //         ->requirePresence('id')
    //         ->notEmptyString('id');
    //     $validator
    //         ->scalar('description')
    //         ->requirePresence('description')
    //         ->notEmptyString('description');
    //     return $validator;
    // }

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $this->id = $data['id'];
        $this->description = $data['description'];

        return true;
    }

    /**
     * @return array{id:string, description:string}
     */
    public function toArray(): array
    {
        return 
            'id' => $this->id,
            'description' => $this->description,
        ];
    }
}
```


## SearchTaskController の実装

Controller での API の実装です。
API の定義を書きますが、 ref を使うことですごくシンプルになったと思います。
処理も

- Request処理
- メイン処理
- Response処理

とわかりやすくなったと思います。
ここも返却値の Validation はやりすぎだと思ったので外しています。

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

namespace App\Controller\Api\Task;

use App\Controller\Api\ValidationErrorResponseForm;
use App\Controller\AppController;

/**
 * SearchTaskController
 *
 * @property \App\Model\Table\TasksTable $Tasks
 */
class SearchTaskController extends AppController
{
    /**
     * @OA\Get(
     *   path="/api/ca-task/search.json",
     *   tags={"CaTask"},
     *   summary="タスクを検索する",
     *   @OA\Parameter(ref="#/components/parameters/SearchTaskRequestForm_descriptionLike"),
     *   @OA\Response(
     *     response="200",
     *     ref="#/components/responses/SearchTaskResponseForm",
     *   ),
     *   @OA\Response(
     *     response="403",
     *     ref="#/components/responses/ValidationErrorResponseForm",
     *   ),
     * )
     *
     * @return void
     */
    public function execute(): void
    {
        $requestForm = new SearchTaskRequestForm();
        if (!$requestForm->execute($this->request->getQuery())) {
            ValidationErrorResponseForm::error($this, $requestForm->getErrors());

            return;
        }

        $this->loadModel('Tasks');
        $query = $this->Tasks->find();
        if (!is_null($requestForm->descriptionLike())) {
            $query->where(['description LIKE' => "%{$requestForm->descriptionLike()}%"]);
        }
        $tasks = $query->enableHydration(false)->toArray();

        $responseForm = new SearchTaskResponseForm();
        // if (!$responseForm->execute(['tasks' => $tasks])) {
        //     ApplicationErrorResponseForm::error($this, $responseForm->getErrors());
        //     return;
        // }
        $responseForm->execute(['tasks' => $tasks]);
        $responseForm->response($this);
    }
}
```

次に エラー処理を行う ResponseForm を作成します。


# エラーレスポンスの実装

もちろん、例外で返却するときもあると思いますが、極力 ResponseForm を使うようにしたので、その ResponseForm の実装です。


## ValidationErrorResponseForm の実装

Swagger の response として定義するとともに schema としても定義します。
ここで、HTTPステータスを定義する方法を見つけれなかったことが残念です。

これも Form である必要はないですが、統一感を出したくて Form にしました。

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

namespace App\Controller\Api;

use Cake\Collection\Collection;
use Cake\Controller\Controller;
use Cake\Form\Form;
use Cake\Utility\Hash;

/**
 * ValidationErrorResponseForm
 *
 * @OA\Response(
 *   response="ValidationErrorResponseForm",
 *   description="バリデーションエラー",
 *   @OA\JsonContent(ref="#/components/schemas/ValidationErrorResponseForm"),
 * )
 *
 * @OA\Schema(
 *   description="バリデーションエラーレスポンス情報",
 *   type="object",
 * )
 */
class ValidationErrorResponseForm extends Form
{
    /**
     * @OA\Property(
     *   property="errors",
     *   type="array",
     *   description="エラー一覧情報",
     *   @OA\Items(ref="#/components/schemas/ErrorDetailForm"),
     * )
     *
     * @var \App\Controller\Api\ErrorDetailForm[]
     */
    private $errors = [];

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $errors = Hash::flatten($data);
        $this->errors = (new Collection($errors))->map(function (string $value, string $key) {
            $errorDetail = new ErrorDetailForm();
            $errorDetail->execute([
                'key' => $key,
                'message' => $value,
            ]);

            return $errorDetail;
        })->toList();

        return true;
    }

    /**
     * @param \Cake\Controller\Controller $controller controller
     * @return void
     */
    public function response(Controller $controller): void
    {
        $errors = (new Collection($this->errors))->map(function (ErrorDetailForm $errorDetail) {
            return $errorDetail->toArray();
        })->toList();

        $controller->setResponse($controller->getResponse()->withStatus(403));
        $controller->set('errors', $errors);
        $controller->viewBuilder()->setOption('serialize', ['errors']);
    }

    /**
     * @param \Cake\Controller\Controller $controller controller
     * @param array $errors errors
     * @return void
     */
    public static function error(Controller $controller, array $errors): void
    {
        $errorForm = new ValidationErrorResponseForm();
        $errorForm->execute($errors);
        $errorForm->response($controller);
    }
}
```


## ErrorDetailForm の実装

エラー一覧情報の１レコードづつのエラー情報です。
Swagger の schema として定義する一番シンプルな Form です。

これも Form である必要はないですが、統一感を出したくて Form にしました。

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

namespace App\Controller\Api;

use Cake\Form\Form;

/**
 * ErrorDetailForm
 *
 * @OA\Schema(
 *   description="エラー詳細情報",
 *   type="object",
 * )
 */
class ErrorDetailForm extends Form
{
    /**
     * @OA\Property(
     *   property="key",
     *   type="string",
     *   description="エラーキー",
     *   example="task.id.required",
     * )
     *
     * @var string
     */
    private $key = '';

    /**
     * @OA\Property(
     *   property="message",
     *   type="string",
     *   description="エラーメッセージ",
     *   example="必須入力項目です。",
     * )
     *
     * @var string
     */
    private $message = '';

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $this->key = $data['key'];
        $this->message = $data['message'];

        return true;
    }

    /**
     * @return array{key:string, message:string}
     */
    public function toArray(): array
    {
        return [
            'key' => $this->key,
            'message' => $this->message,
        ];
    }
}
```


## ApplicationErrorResponseForm の実装

SearchTaskController では使っていませんが、他で使うので、ここで実装を示します。
ValidationErrorResponseForm とほぼ同じ内容ですが、役割が全然違うと僕は思っています。
そのため、このようなクラスを共通のクラスから派生させるのは反対です。
現時点では、似てる処理だと思いますが、入力チェックエラーの表現とアプリケーションエラーの表現は、同じではないと判断しています。

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

namespace App\Controller\Api;

use App\Exception\ApplicationException;
use Cake\Collection\Collection;
use Cake\Controller\Controller;
use Cake\Error\ErrorLogger;
use Cake\Form\Form;
use Cake\Utility\Hash;

/**
 * ApplicationErrorResponseForm
 *
 * @OA\Response(
 *   response="ApplicationErrorResponseForm",
 *   description="アプリケーションエラー",
 *   @OA\JsonContent(ref="#/components/schemas/ApplicationErrorResponseForm"),
 * )
 *
 * @OA\Schema(
 *   description="アプリケーションエラーレスポンス情報",
 *   type="object",
 * )
 */
class ApplicationErrorResponseForm extends Form
{
    /**
     * @OA\Property(
     *   property="errors",
     *   type="array",
     *   description="エラー一覧情報",
     *   @OA\Items(ref="#/components/schemas/ErrorDetailForm"),
     * )
     *
     * @var \App\Controller\Api\ErrorDetailForm[]
     */
    private $errors = [];

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $errors = Hash::flatten($data);
        $this->errors = (new Collection($errors))->map(function (string $value, string $key) {
            $errorDetail = new ErrorDetailForm();
            $errorDetail->execute([
                'key' => $key,
                'message' => $value,
            ]);

            return $errorDetail;
        })->toList();

        return true;
    }

    /**
     * @param \Cake\Controller\Controller $controller controller
     * @return void
     */
    public function response(Controller $controller): void
    {
        $errors = (new Collection($this->errors))->map(function (ErrorDetailForm $errorDetail) {
            return $errorDetail->toArray();
        })->toList();

        // エラー原因がわかるようにログに出力する。
        (new ErrorLogger(['trace' => true]))->log(new ApplicationException($errors), $controller->getRequest());

        $controller->setResponse($controller->getResponse()->withStatus(500));
        $controller->set('errors', $errors);
        $controller->viewBuilder()->setOption('serialize', ['errors']);
    }

    /**
     * @param \Cake\Controller\Controller $controller controller
     * @param array $errors errors
     * @return void
     */
    public static function error(Controller $controller, array $errors): void
    {
        $errorForm = new ApplicationErrorResponseForm();
        $errorForm->execute($errors);
        $errorForm->response($controller);
    }
}
```


# タスク更新APIの実装

タスク更新APIの実装です。更新APIは path パラメータと RequestBody の両方があるので、これをどう記述するかを考えます。

## UpdateTaskRequestForm の実装

Swagger の schema として定義しつつ、 parameter も定義しています。
ややこしいですが、問題なくドキュメント化されました。


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

namespace App\Controller\Api\Task;

use Cake\Form\Form;
use Cake\Validation\Validator;

/**
 * UpdateTaskRequestForm
 *
 * @OA\Schema(
 *   description="タスク更新リクエスト情報",
 *   type="object",
 *   required={"description"},
 * )
 */
class UpdateTaskRequestForm extends Form
{
    /**
     * @OA\Parameter(
     *   parameter="UpdateTaskRequestForm_id",
     *   name="id",
     *   in="path",
     *   required=true,
     *   description="タスクID",
     *   @OA\Schema(type="string"),
     *   example="c366f5be-360b-45cc-8282-65c80e434f72"
     * )
     *
     * @var string
     */
    private $id;

    /**
     * @OA\Property(
     *   property="description",
     *   type="string",
     *   description="タスク内容",
     *   example="朝の身だしなみチェック",
     * )
     *
     * @var string
     */
    private $description;

    /**
     * @param \Cake\Validation\Validator $validator Validator
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator): Validator
    {
        $validator
            ->uuid('id')
            ->requirePresence('id')
            ->notEmptyString('id');

        $validator
            ->scalar('description')
            ->requirePresence('description')
            ->notEmptyString('description');

        return $validator;
    }

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $this->id = $data['id'];
        $this->description = $data['description'];

        return true;
    }

    /**
     * @return string
     */
    public function id(): string
    {
        return $this->id;
    }

    /**
     * @return array{description:string}
     */
    public function data(): array
    {
        return [
            'description' => $this->description,
        ];
    }
}
```


## UpdateTaskResponseForm の実装

Swagger の response と schema を定義します。

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

namespace App\Controller\Api\Task;

use Cake\Controller\Controller;
use Cake\Form\Form;

/**
 * UpdateTaskResponseForm
 *
 * @OA\Response(
 *   response="UpdateTaskResponseForm",
 *   description="OK",
 *   @OA\JsonContent(ref="#/components/schemas/UpdateTaskResponseForm"),
 * )
 *
 * @OA\Schema(
 *   description="タスク更新レスポンス情報",
 *   type="object",
 * )
 */
class UpdateTaskResponseForm extends Form
{
    /**
     * @OA\Property(
     *   property="data",
     *   type="object",
     *   description="タスクID情報",
     *   ref="#/components/schemas/TaskIdForm",
     * )
     *
     * @var \App\Controller\Api\Task\TaskIdForm
     */
    private $id;

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $this->id = new TaskIdForm();
        $this->id->execute($data['task']);

        return true;
    }

    /**
     * @param \Cake\Controller\Controller $controller controller
     * @return void
     */
    public function response(Controller $controller): void
    {
        $data = $this->id->toArray();

        $controller->set('data', $data);
        $controller->viewBuilder()->setOption('serialize', ['data']);
    }
}
```

タスクID のみを返却する Form を作ります。APIドキュメントのためもありますが、APIドキュメントがなかったとしても TaskDetailForm とは役割が異なるので別にすべきと思います。

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

namespace App\Controller\Api\Task;

use Cake\Form\Form;

/**
 * TaskIdForm
 *
 * @OA\Schema(
 *   description="タスクID情報",
 *   type="object",
 * )
 */
class TaskIdForm extends Form
{
    /**
     * @OA\Property(
     *   property="id",
     *   type="string",
     *   description="タスクID",
     *   example="c366f5be-360b-45cc-8282-65c80e434f72",
     * )
     *
     * @var string
     */
    private $id;

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $this->id = $data['id'];

        return true;
    }

    /**
     * @return array{id:string}
     */
    public function toArray(): array
    {
        return [
            'id' => $this->id,
        ];
    }
}
```


## UpdateTaskController の実装

API の定義を記載します。 ref を使うことで記述内容が見やすくなっていると思います。

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

namespace App\Controller\Api\Task;

use App\Controller\Api\ApplicationErrorResponseForm;
use App\Controller\Api\ValidationErrorResponseForm;
use App\Controller\AppController;
use Cake\Utility\Hash;

/**
 * UpdateTaskController
 *
 * @property \App\Model\Table\TasksTable $Tasks
 */
class UpdateTaskController extends AppController
{
    /**
     * @OA\Put(
     *   path="/api/ca-task/update/{id}.json",
     *   tags={"CaTask"},
     *   summary="タスクを更新する",
     *   @OA\Parameter(ref="#/components/parameters/UpdateTaskRequestForm_id"),
     *   @OA\RequestBody(
     *     required=true,
     *     @OA\JsonContent(ref="#/components/schemas/UpdateTaskRequestForm"),
     *   ),
     *   @OA\Response(
     *     response="200",
     *     ref="#/components/responses/UpdateTaskResponseForm",
     *   ),
     *   @OA\Response(
     *     response="403",
     *     ref="#/components/responses/ValidationErrorResponseForm",
     *   ),
     *   @OA\Response(
     *     response="500",
     *     ref="#/components/responses/ApplicationErrorResponseForm",
     *   ),
     * )
     *
     * @param string $id id
     * @return void
     */
    public function execute($id): void
    {
        $requestForm = new UpdateTaskRequestForm();
        if (!$requestForm->execute(Hash::merge($this->request->getData(), ['id' => $id]))) {
            ValidationErrorResponseForm::error($this, $requestForm->getErrors());

            return;
        }

        $this->loadModel('Tasks');
        $task = $this->Tasks->get($requestForm->id());
        $task = $this->Tasks->patchEntity($task, $requestForm->data());
        if ($task->hasErrors()) {
            ApplicationErrorResponseForm::error($this, $task->getErrors());

            return;
        }

        if (!$this->Tasks->save($task)) {
            ApplicationErrorResponseForm::error($this, ['save' => __('更新できませんでした。')]);

            return;
        }

        $responseForm = new UpdateTaskResponseForm();
        $responseForm->execute(['task' => $task->toArray()]);
        $responseForm->response($this);
    }
}
```


# まとめ

重厚な作りになってしまいましたが、わかりやすくなったと思います。
もともと 1ファイル（1つの Controller）で実装していた内容が、17ファイル（５つの Controller、12の Form）で実装することになりました。
これをどうみるかは、プロジェクト次第だと思います。最近、クリーンアーキテクチャ等、保守しやすいアーキテクチャが重視されていると思います。
受託で納品してしばらく終わりのシステムから、サービスとして提供して日々改善するシステムが増えているからではないかと推測します。

API I/Fを明確にして、実装とドキュメントが一体化していることがわかりやすいことも保守しやすいシステムになると思っています。


# 補足）他の実装

## タスク詳細APIの実装

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

namespace App\Controller\Api\Task;

use Cake\Form\Form;
use Cake\Validation\Validator;

/**
 * ViewTaskRequestForm
 */
class ViewTaskRequestForm extends Form
{
    /**
     * @OA\Parameter(
     *   parameter="ViewTaskRequestForm_id",
     *   name="id",
     *   in="path",
     *   required=true,
     *   description="タスクID",
     *   @OA\Schema(type="string"),
     *   example="c366f5be-360b-45cc-8282-65c80e434f72"
     * )
     *
     * @var string
     */
    private $id;

    /**
     * @param \Cake\Validation\Validator $validator Validator
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator): Validator
    {
        $validator
            ->uuid('id')
            ->requirePresence('id')
            ->notEmptyString('id');

        return $validator;
    }

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $this->id = $data['id'];

        return true;
    }

    /**
     * @return string
     */
    public function id(): string
    {
        return $this->id;
    }
}
```

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

namespace App\Controller\Api\Task;

use Cake\Controller\Controller;
use Cake\Form\Form;

/**
 * ViewTaskResponseForm
 *
 * @OA\Response(
 *   response="ViewTaskResponseForm",
 *   description="OK",
 *   @OA\JsonContent(ref="#/components/schemas/ViewTaskResponseForm"),
 * )
 *
 * @OA\Schema(
 *   description="タスク更新レスポンス情報",
 *   type="object",
 * )
 */
class ViewTaskResponseForm extends Form
{
    /**
     * @OA\Property(
     *   property="data",
     *   type="object",
     *   description="タスク詳細情報",
     *   ref="#/components/schemas/TaskDetailForm",
     * )
     *
     * @var \App\Controller\Api\Task\TaskDetailForm
     */
    private $data;

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $this->data = new TaskDetailForm();
        $this->data->execute($data['task']);

        return true;
    }

    /**
     * @param \Cake\Controller\Controller $controller controller
     * @return void
     */
    public function response(Controller $controller): void
    {
        $data = $this->data->toArray();

        $controller->set('data', $data);
        $controller->viewBuilder()->setOption('serialize', ['data']);
    }
}
```

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

namespace App\Controller\Api\Task;

use App\Controller\Api\ValidationErrorResponseForm;
use App\Controller\AppController;
use Cake\Utility\Hash;

/**
 * ViewTaskController
 *
 * @property \App\Model\Table\TasksTable $Tasks
 */
class ViewTaskController extends AppController
{
    /**
     * @OA\Get(
     *   path="/api/ca-task/detail/{id}.json",
     *   tags={"CaTask"},
     *   summary="タスクを参照する",
     *   @OA\Parameter(ref="#/components/parameters/ViewTaskRequestForm_id"),
     *   @OA\Response(
     *     response="200",
     *     ref="#/components/responses/ViewTaskResponseForm",
     *   ),
     *   @OA\Response(
     *     response="403",
     *     ref="#/components/responses/ValidationErrorResponseForm",
     *   ),
     * )
     *
     * @param string $id id
     * @return void
     */
    public function execute($id): void
    {
        $requestForm = new ViewTaskRequestForm();
        if (!$requestForm->execute(Hash::merge($this->request->getData(), ['id' => $id]))) {
            ValidationErrorResponseForm::error($this, $requestForm->getErrors());

            return;
        }

        $this->loadModel('Tasks');
        $task = $this->Tasks->get($requestForm->id());

        $responseForm = new ViewTaskResponseForm();
        $responseForm->execute(['task' => $task->toArray()]);
        $responseForm->response($this);
    }
}
```


## タスク登録APIの実装

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

namespace App\Controller\Api\Task;

use Cake\Form\Form;
use Cake\Validation\Validator;

/**
 * TaskCreateRequestForm
 *
 * @OA\Schema(
 *   description="タスク登録リクエスト情報",
 *   type="object",
 *   required={"description"},
 * )
 */
class TaskCreateRequestForm extends Form
{
    /**
     * @OA\Property(
     *   property="description",
     *   type="string",
     *   description="タスク内容",
     *   example="朝の身だしなみチェック",
     * )
     *
     * @var string
     */
    private $description;

    /**
     * @param \Cake\Validation\Validator $validator Validator
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator): Validator
    {
        $validator
            ->scalar('description')
            ->requirePresence('description')
            ->notEmptyString('description');

        return $validator;
    }

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $this->description = $data['description'];

        return true;
    }

    /**
     * @return array{description:string}
     */
    public function data(): array
    {
        return [
            'description' => $this->description,
        ];
    }
}
```

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

namespace App\Controller\Api\Task;

use Cake\Controller\Controller;
use Cake\Form\Form;

/**
 * TaskCreateResponseForm
 *
 * @OA\Response(
 *   response="TaskCreateResponseForm",
 *   description="OK",
 *   @OA\JsonContent(ref="#/components/schemas/TaskCreateResponseForm"),
 * )
 *
 * @OA\Schema(
 *   description="タスク登録レスポンス情報",
 *   type="object",
 * )
 */
class TaskCreateResponseForm extends Form
{
    /**
     * @OA\Property(
     *   property="data",
     *   type="object",
     *   description="タスクID情報",
     *   ref="#/components/schemas/TaskIdForm",
     * )
     *
     * @var \App\Controller\Api\Task\TaskIdForm
     */
    private $id;

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $this->id = new TaskIdForm();
        $this->id->execute($data['task']);

        return true;
    }

    /**
     * @param \Cake\Controller\Controller $controller controller
     * @return void
     */
    public function response(Controller $controller): void
    {
        $data = $this->id->toArray();

        $controller->set('data', $data);
        $controller->viewBuilder()->setOption('serialize', ['data']);
    }
}
```

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

namespace App\Controller\Api\Task;

use App\Controller\Api\ApplicationErrorResponseForm;
use App\Controller\Api\ValidationErrorResponseForm;
use App\Controller\AppController;

/**
 * TaskCreateController
 *
 * @property \App\Model\Table\TasksTable $Tasks
 */
class TaskCreateController extends AppController
{
    /**
     * @OA\Post(
     *   path="/api/ca-task/create.json",
     *   tags={"CaTask"},
     *   summary="タスクを登録する",
     *   @OA\RequestBody(
     *     required=true,
     *     @OA\JsonContent(ref="#/components/schemas/TaskCreateRequestForm"),
     *   ),
     *   @OA\Response(
     *     response="200",
     *     ref="#/components/responses/TaskCreateResponseForm",
     *   ),
     *   @OA\Response(
     *     response="403",
     *     ref="#/components/responses/ValidationErrorResponseForm",
     *   ),
     *   @OA\Response(
     *     response="500",
     *     ref="#/components/responses/ApplicationErrorResponseForm",
     *   ),
     * )
     *
     * @return void
     */
    public function execute(): void
    {
        $requestForm = new TaskCreateRequestForm();
        if (!$requestForm->execute($this->request->getData())) {
            ValidationErrorResponseForm::error($this, $requestForm->getErrors());

            return;
        }

        $this->loadModel('Tasks');
        $task = $this->Tasks->newEntity($requestForm->data());
        if ($task->hasErrors()) {
            ApplicationErrorResponseForm::error($this, $task->getErrors());

            return;
        }

        if (!$this->Tasks->save($task)) {
            ApplicationErrorResponseForm::error($this, ['save' => __('登録できませんでした。')]);

            return;
        }

        $responseForm = new TaskCreateResponseForm();
        $responseForm->execute(['task' => $task->toArray()]);
        $responseForm->response($this);
    }
}
```


## タスク削除APIの実装

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

namespace App\Controller\Api\Task;

use Cake\Form\Form;
use Cake\Validation\Validator;

/**
 * DeleteTaskRequestForm
 */
class DeleteTaskRequestForm extends Form
{
    /**
     * @OA\Parameter(
     *   parameter="DeleteTaskRequestForm_id",
     *   name="id",
     *   in="path",
     *   required=true,
     *   description="タスクID",
     *   @OA\Schema(type="string"),
     *   example="c366f5be-360b-45cc-8282-65c80e434f72"
     * )
     *
     * @var string
     */
    private $id;

    /**
     * @param \Cake\Validation\Validator $validator Validator
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator): Validator
    {
        $validator
            ->uuid('id')
            ->requirePresence('id')
            ->notEmptyString('id');

        return $validator;
    }

    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        $this->id = $data['id'];

        return true;
    }

    /**
     * @return string
     */
    public function id(): string
    {
        return $this->id;
    }
}
```

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

namespace App\Controller\Api\Task;

use Cake\Controller\Controller;
use Cake\Form\Form;

/**
 * DeleteTaskResponseForm
 *
 * @OA\Response(
 *   response="DeleteTaskResponseForm",
 *   description="No Content",
 * )
 */
class DeleteTaskResponseForm extends Form
{
    /**
     * @param array $data data
     * @return bool
     */
    protected function _execute(array $data): bool
    {
        return true;
    }

    /**
     * @param \Cake\Controller\Controller $controller controller
     * @return void
     */
    public function response(Controller $controller): void
    {
        $controller->setResponse($controller->getResponse()->withStatus(204));
        $controller->viewBuilder()->setOption('serialize', []);
    }
}
```

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

namespace App\Controller\Api\Task;

use App\Controller\Api\ApplicationErrorResponseForm;
use App\Controller\Api\ValidationErrorResponseForm;
use App\Controller\AppController;

/**
 * DeleteTaskController
 *
 * @property \App\Model\Table\TasksTable $Tasks
 */
class DeleteTaskController extends AppController
{
    /**
     * @OA\Delete(
     *   path="/api/ca-task/delete/{id}.json",
     *   tags={"CaTask"},
     *   summary="タスクを削除する",
     *   @OA\Parameter(ref="#/components/parameters/DeleteTaskRequestForm_id"),
     *   @OA\Response(
     *     response="204",
     *     ref="#/components/responses/DeleteTaskResponseForm",
     *   ),
     *   @OA\Response(
     *     response="403",
     *     ref="#/components/responses/ValidationErrorResponseForm",
     *   ),
     *   @OA\Response(
     *     response="500",
     *     ref="#/components/responses/ApplicationErrorResponseForm",
     *   ),
     * )
     *
     * @param string $id id
     * @return void
     */
    public function execute($id): void
    {
        $requestForm = new DeleteTaskRequestForm();
        if (!$requestForm->execute(['id' => $id])) {
            ValidationErrorResponseForm::error($this, $requestForm->getErrors());

            return;
        }

        $this->loadModel('Tasks');
        $task = $this->Tasks->get($requestForm->id());
        if (!$this->Tasks->delete($task)) {
            ApplicationErrorResponseForm::error($this, ['delete' => __('削除できませんでした。')]);

            return;
        }

        $responseForm = new DeleteTaskResponseForm();
        $responseForm->execute(['task' => $task->toArray()]);
        $responseForm->response($this);
    }
}
```


# 補足) phpstan の対応

Form を使うことになり、また ignoreErrors の追加が必要になります。
Form に関する 5 つのエラー除外対象を `./phpstan.neon` を追加します。

```yaml:phpstan.neon
parameters:
  level: max
  excludes_analyse:
    - src/Console/Installer.php
    - src/Controller/PagesController.php
  ignoreErrors:
    - '#Property App\\Model\\Entity\\[a-zA-Z0-9\\_]+::\$_accessible type has no value type specified in iterable type array.#'
    - '#Method App\\Model\\Table\\[a-zA-Z0-9\\_]+::initialize\(\) has parameter \$config with no value type specified in iterable type array.#'
    - '#Method App\\Model\\Table\\[a-zA-Z0-9\\_]+::validationDefault\(\) has parameter \$validator with no value type specified in iterable type Cake\\Validation\\Validator.#'
    - '#Method App\\Model\\Table\\[a-zA-Z0-9\\_]+::validationDefault\(\) return type has no value type specified in iterable type Cake\\Validation\\Validator.#'
    - '#Method App\\Controller\\[a-zA-Z0-9\\_]+Form::validationDefault\(\) has parameter \$validator with no value type specified in iterable type Cake\\Validation\\Validator.#'
    - '#Method App\\Controller\\[a-zA-Z0-9\\_]+Form::validationDefault\(\) return type has no value type specified in iterable type Cake\\Validation\\Validator.#'
    - '#Method App\\Controller\\[a-zA-Z0-9\\_]+Form::_execute\(\) has parameter \$data with no value type specified in iterable type array.#'
    - '#Method App\\Controller\\[a-zA-Z0-9\\_]+Form::error\(\) has parameter \$errors with no value type specified in iterable type array.#'
    - '#Parameter \#1 \$data of method Cake\\Form\\Form::execute\(\) expects array, array\|string\|null given.#'
  reportUnmatchedIgnoredErrors: false
```