CakePHP4 で簡単な API を作る

CakePHP4 で簡単な API を作ります。
単純な Task の API です。


# この記事でわかること

- CakePHP4 での API の作り方。
- この記事内のソースは以下で公開しています。
    - 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)
- 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 |


# テーブルの作成

` tasks ` テーブルを作るためのマイグレーションファイルを作成します。

```sh
docker exec -it app bin/cake bake migration CreateTasks
```

```php:config/Migrations/yyyymmddhhmiss_CreateTasks.php
<?php
declare(strict_types=1);

use Migrations\AbstractMigration;

class CreateTasks extends AbstractMigration
{
    /**
     * Change Method.
     *
     * More information on this method is available here:
     * http://docs.phinx.org/en/latest/migrations.html#the-change-method
     * @return void
     */
    public function change(): void
    {
        $this->table('tasks', ['id' => false, 'primary_key' => ['id']])
            ->addColumn('id', 'uuid', ['default' => null, 'null' => false])
            ->addColumn('description', 'text', ['default' => null, 'limit' => null, 'null' => false])
            ->addColumn('created', 'datetime', ['default' => 'CURRENT_TIMESTAMP', 'limit' => null, 'null' => false])
            ->addColumn('modified', 'datetime', ['default' => 'CURRENT_TIMESTAMP', 'update' => 'CURRENT_TIMESTAMP', 'limit' => null, 'null' => false])
            ->create();
    }
}
```

マイグレーションファイルを実行して ` tasks ` テーブルを作ります。

```sh
docker exec -it app bin/cake migrations migrate
```


# Model の作成

bake コマンドで Model を作成します。

```sh
docker exec -it app bin/cake bake model Tasks
```

Entity と Table それぞれ少し変更します。
Entity は ` $_accessible ` を変更して ` description ` のみへ変更します。

```php:src/Model/Entity/Task.php
<?php
declare(strict_types=1);

namespace App\Model\Entity;

use Cake\ORM\Entity;

/**
 * Task Entity
 *
 * @property string $id
 * @property string $description
 * @property \Cake\I18n\FrozenTime $created
 * @property \Cake\I18n\FrozenTime $modified
 */
class Task extends Entity
{
    /**
     * @var array
     */
    protected $_accessible = [
        'description' => true,
    ];
}
```

Table は使っていない Task Entity を use しないようにします。

```php:src/Model/Table/TasksTable.php
<?php
declare(strict_types=1);

namespace App\Model\Table;

use Cake\ORM\Table;
use Cake\Validation\Validator;

/**
 * Tasks Model
 *
 * @method \App\Model\Entity\Task newEmptyEntity()
 * @method \App\Model\Entity\Task newEntity(array $data, array $options = [])
 * @method \App\Model\Entity\Task[] newEntities(array $data, array $options = [])
 * @method \App\Model\Entity\Task get($primaryKey, $options = [])
 * @method \App\Model\Entity\Task findOrCreate($search, ?callable $callback = null, $options = [])
 * @method \App\Model\Entity\Task patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
 * @method \App\Model\Entity\Task[] patchEntities(iterable $entities, array $data, array $options = [])
 * @method \App\Model\Entity\Task|false save(\Cake\Datasource\EntityInterface $entity, $options = [])
 * @method \App\Model\Entity\Task saveOrFail(\Cake\Datasource\EntityInterface $entity, $options = [])
 * @method \App\Model\Entity\Task[]|\Cake\Datasource\ResultSetInterface|false saveMany(iterable $entities, $options = [])
 * @method \App\Model\Entity\Task[]|\Cake\Datasource\ResultSetInterface saveManyOrFail(iterable $entities, $options = [])
 * @method \App\Model\Entity\Task[]|\Cake\Datasource\ResultSetInterface|false deleteMany(iterable $entities, $options = [])
 * @method \App\Model\Entity\Task[]|\Cake\Datasource\ResultSetInterface deleteManyOrFail(iterable $entities, $options = [])
 *
 * @mixin \Cake\ORM\Behavior\TimestampBehavior
 */
class TasksTable extends Table
{
    /**
     * Initialize method
     *
     * @param array $config The configuration for the Table.
     * @return void
     */
    public function initialize(array $config): void
    {
        parent::initialize($config);

        $this->setTable('tasks');
        $this->setDisplayField('id');
        $this->setPrimaryKey('id');

        $this->addBehavior('Timestamp');
    }

    /**
     * Default validation rules.
     *
     * @param \Cake\Validation\Validator $validator Validator instance.
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator): Validator
    {
        $validator
            ->uuid('id')
            ->allowEmptyString('id', null, 'create');

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

        return $validator;
    }
}
```


# phpstan の対応

上記の Entity、Table だと、 phpstan で引っかかります。
記述内容を修正することもできますが、毎回手を入れるのも大変なので、エラーの対象からはずすために ` ignoreErrors ` へ定義します。

```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.#'
  reportUnmatchedIgnoredErrors: false
```

# Controller の作成

bake コマンドで Controller を作成しますが、内容はごっそり変えます。
Tasks テーブルと結びついて、暗黙的に Model が利用できるうようになるのを避けたいので、あえて単数形の Task で Controller を作成します。
また Api ディレクトリの下に作るようにします。

```sh
docker exec -it app bin/cake bake controller Task --prefix Api
```

作った Controller の内容をごっそり変えます。
メソッド名が REST を意識していないのは完全な趣味です。
実際は基本に忠実なメソッド名にするのが良いと思いますが、基本のメソッドがイマイチな気がするんですよね。やっぱり add/remove、create/delete っという感じの対にしたいんですよね。

```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
     *
     * @return void
     */
    public function search(): void
    {
        $this->loadModel('Tasks');
        $tasks = $this->Tasks->find()->all();

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

    /**
     * View method
     *
     * @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
     *
     * @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
     *
     * @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
     *
     * @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', []);
    }
}
```

例外クラスも１つ作っています。

```php:src/Exception/ApplicationException.php
<?php
declare(strict_types=1);

namespace App\Exception;

use Cake\Core\Exception\Exception;

class ApplicationException extends Exception
{
    /**
     * @var int
     */
    protected $_defaultCode = 500;
}
```

Web API を作るとき、 POST、PUT、DELETE の返却HTTPステータスと返却JSONは悩みますよね。
今回は、 POST・PUT は 200 OK で id のみを返却し、 DELETE は 204 No Content を返却することにしました。
このAPIの返却値は見直す必要があります。
ちょっとヘビーですが、長く開発をするのであれば、 JSON API( https://jsonapi.org/ ) 
, HAL ( http://stateless.co/hal_specification.html ) の形式にするのがいろいろ対応できるし、統一もできるので良いと思います。 


# ルーティング

これも悩みますよね。 REST のメソッド名にしていない理由は、このルーティングが関係しています。
ルーティングは明示的にしたほうが何かと便利な気がしています。
URL ってすごく重要なのに、そこが暗黙的や直感的じゃないのは、すごく気になります。

今回、記述量が多いですが、あえてこのような書き方にしています。これも趣味です。

```php:config/routes.php
// ...

// API
$routes->scope('/api', ['prefix' => 'Api'], function (RouteBuilder $builder) {
    $builder->registerMiddleware('bodies', new BodyParserMiddleware());
    $builder->applyMiddleware('bodies');

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

    // Task
    $builder->connect('/task/search', ['controller' => 'Task', 'action' => 'search'])->setMethods(['GET']);
    $builder->connect('/task/view/:id', ['controller' => 'Task', 'action' => 'view'])->setPass(['id'])->setMethods(['GET']);
    $builder->connect('/task/create', ['controller' => 'Task', 'action' => 'create'])->setMethods(['POST']);
    $builder->connect('/task/update/:id', ['controller' => 'Task', 'action' => 'update'])->setPass(['id'])->setMethods(['PUT']);
    $builder->connect('/task/delete/:id', ['controller' => 'Task', 'action' => 'delete'])->setPass(['id'])->setMethods(['DELETE']);
});
// ...
```

ぜんぜん REST じゃないですよね、、、
どうせ内部で使うAPIなんで、ここまで割り切ったほうが悩まなくて楽な気がするんです。

これで完成です。


# 動作確認

Postman( https://www.postman.com/ )を使って、動作確認を行います。
URL の最後に ` .json ` とつけるのがポイントです。

検索と登録の２つのみですが、Postman で実行した内容を Export しました。
Postman のメニュー File > Import で取り込んだ後、「cake-vue-study」 コレクションを実行すれば、検索と登録の動きを確認することができます。


```json
{
	"info": {
		"_postman_id": "955b1ae9-a5c2-444e-9f1d-f51aadefb8b3",
		"name": "cakephp-vue-study",
		"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
	},
	"item": [
		{
			"name": "localhost/api/task/search.json",
			"request": {
				"method": "GET",
				"header": [
					{
						"key": "Content-Type",
						"value": "application/json",
						"type": "text"
					}
				],
				"url": {
					"raw": "localhost/api/task/search.json",
					"host": [
						"localhost"
					],
					"path": [
						"api",
						"task",
						"search.json"
					]
				}
			},
			"response": []
		},
		{
			"name": "localhost/api/task/create.json",
			"request": {
				"method": "POST",
				"header": [
					{
						"key": "Content-Type",
						"value": "application/json",
						"type": "text"
					}
				],
				"body": {
					"mode": "raw",
					"raw": "{\n\t\"description\": \"test\"\n}",
					"options": {
						"raw": {
							"language": "json"
						}
					}
				},
				"url": {
					"raw": "localhost/api/task/create.json",
					"host": [
						"localhost"
					],
					"path": [
						"api",
						"task",
						"create.json"
					]
				}
			},
			"response": []
		}
	],
	"protocolProfileBehavior": {}
}
```

今回、テストコードは書いていませんが、後ほど書いていこうと思います。