CakePHP4 で簡単な API を作ります。
単純な Task の API です。
この記事でわかること
- CakePHP4 での API の作り方。
- この記事内のソースは以下で公開しています。
事前準備
- CakePHP4 プロジェクトは以下の記事で作成して、Docker で動かしつつ、最低限の動作をできるようにしています。
- docker-compose を実行し、コンテナを立ち上げます。
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
テーブルを作るためのマイグレーションファイルを作成します。
docker exec -it app bin/cake bake migration CreateTasks
<?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
テーブルを作ります。
docker exec -it app bin/cake migrations migrate
Model の作成
bake コマンドで Model を作成します。
docker exec -it app bin/cake bake model Tasks
Entity と Table それぞれ少し変更します。
Entity は $_accessible
を変更して description
のみへ変更します。
<?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
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
へ定義します。
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 ディレクトリの下に作るようにします。
docker exec -it app bin/cake bake controller Task --prefix Api
作った Controller の内容をごっそり変えます。
メソッド名が REST を意識していないのは完全な趣味です。
実際は基本に忠実なメソッド名にするのが良いと思いますが、基本のメソッドがイマイチな気がするんですよね。やっぱり add/remove、create/delete っという感じの対にしたいんですよね。
<?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', []);
}
}
例外クラスも1つ作っています。
<?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 ってすごく重要なのに、そこが暗黙的や直感的じゃないのは、すごく気になります。
今回、記述量が多いですが、あえてこのような書き方にしています。これも趣味です。
// ...
// 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
とつけるのがポイントです。
検索と登録の2つのみですが、Postman で実行した内容を Export しました。
Postman のメニュー File > Import で取り込んだ後、「cake-vue-study」 コレクションを実行すれば、検索と登録の動きを確認することができます。
{
"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": {}
}
今回、テストコードは書いていませんが、後ほど書いていこうと思います。