LoginSignup
5
8

More than 3 years have passed since last update.

CakePHP4 で簡単な API を作る

Last updated at Posted at 2020-03-23

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

この記事でわかること

事前準備

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
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 テーブルを作ります。

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 のみへ変更します。

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 しないようにします。

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 へ定義します。

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 ディレクトリの下に作るようにします。

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

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

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', []);
    }
}

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

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 ってすごく重要なのに、そこが暗黙的や直感的じゃないのは、すごく気になります。

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

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 とつけるのがポイントです。

検索と登録の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": {}
}

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

5
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
8