3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Web API開発をするなら、ドキュメントは自動生成にしておこう!(CakePHP編)

Last updated at Posted at 2021-03-27

この記事は何?

PHPerKaigi2021 LT発表のスライド

まずはCakePHPでAPIを用意する

DockerでCakePHPの環境を構築する

まだDockerをインストールされていない方はこちらを先にお読みください。
Docker Compose のインストール

CakePHPのプロジェクト作成には私が用意したDockerテンプレートを利用させていただきます。
以下のコマンドを実行してください。

git clone git@github.com:AkitoTsukahara/cakephp-template.git
cd cakephp-template
make create-project

# CakePHPインストール中にPermissonに関する確認が入りますので[Y]と入力してください。
Set Folder Permissions ? (Default to Y) [Y,n]? # Yを入力

実行が正常に完了していれば、下記のURLからCakePHPのTOPページを確認することができます。

http://localhost/
スクリーンショット 2021-03-27 16.19.29.png

Databaseとの接続を設定する

app_local.phpのDatasources中のhost, username, password, databaseを以下の様に変更してください。変更する値はdocker-compose.ymlのcake_dbコンテナから持ってきてます。

app_local.php

'Datasources' => [
    'default' => [
        'host' => 'cake_db', # ここを変更しました
        /*
         * CakePHP will use the default DB port based on the driver selected
         * MySQL on MAMP uses port 8889, MAMP users will want to uncomment
         * the following line and set the port accordingly
         */
        //'port' => 'non_standard_port_number',

        'username' => 'cake', # ここを変更しました
        'password' => 'password', # ここを変更しました

        'database' => 'cakephp', # ここを変更しました
        /*
         * If not using the default 'public' schema with the PostgreSQL driver
         * set it here.
         */
        //'schema' => 'myapp',

        /*
         * You can use a DSN string to set the entire configuration
         */
        'url' => env('DATABASE_URL', null),
    ],

    /*
     * The test connection is used during the test suite.
     */
    'test' => [
        'host' => 'localhost',
        //'port' => 'non_standard_port_number',
        'username' => 'my_app',
        'password' => 'secret',
        'database' => 'test_myapp',
        //'schema' => 'myapp',
        'url' => env('DATABASE_TEST_URL', null),
    ],
],

Recipesテーブルを作成する

マイグレーションファイルを作成します

docker-compose exec php bin/cake bake migration CreateRecipes

マイグレーションファイルを編集して、フィールドを追加します。

XXXXXXXXXXXXXX_CreateRecipes.php
<?php
declare(strict_types=1);

use Migrations\AbstractMigration;

class CreateRecipes extends AbstractMigration
{
    /**
     * Change Method.
     *
     * More information on this method is available here:
     * https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
     * @return void
     */
    public function change()
    {
        $this->table('recipes', ['id' => false, 'primary_key' => ['id']])
            ->addColumn('id', 'uuid', ['default' => null, 'null' => false])
            ->addColumn('title', 'string', ['default' => null, 'limit' => 255, '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();
    }
}

編集したら、テーブルを生成します。

docker-compose exec php bin/cake migrations migrate

Recipsモデルを作成する

モデルファイルを作成します。

docker-compose exec php bin/cake bake model Recipes

Recipeコントローラーを作成する

コントローラーを作成します

docker-compose exec php bin/cake bake controller Recipe --prefix Api

生成されたものからほとんど手を加えていませんが、APIなのでallowMethodなど追加しています。

RecipeController.php
<?php
declare(strict_types=1);

namespace App\Controller\Api;

use App\Controller\AppController;

/**
 * Recipe Controller
 *
 * @property \App\Model\Table\RecipesTable $Recipes
 */
class RecipeController extends AppController
{

    public function initialize(): void
    {
        parent::initialize();
        $this->loadModel('Recipes');
        $this->loadComponent('RequestHandler');
    }

    public function index(): void
    {
        $recipes = $this->Recipes->find('all');

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

    public function view(string $id): void
    {
        $recipe = $this->Recipes->get($id);
        $this->set('recipe', $recipe);
        $this->viewBuilder()->setOption('serialize', ['recipe']);
    }

    public function add(): void
    {
        $this->request->allowMethod(['post', 'put']);
        $recipe = $this->Recipes->newEntity($this->request->getData());
        if ($this->Recipes->save($recipe)) {
            $message = 'Saved';
        } else {
            $message = 'Error';
        }
        $this->set([
            'message' => $message,
            'recipe' => $recipe,
        ]);
        $this->viewBuilder()->setOption('serialize', ['recipe', 'message']);
    }

    public function edit(string $id): void
    {
        $this->request->allowMethod(['patch', 'post', 'put']);
        $recipe = $this->Recipes->get($id);
        $recipe = $this->Recipes->patchEntity($recipe, $this->request->getData());
        if ($this->Recipes->save($recipe)) {
            $message = 'Saved';
        } else {
            $message = 'Error';
        }
        $this->set([
            'message' => $message,
            'recipe' => $recipe,
        ]);
        $this->viewBuilder()->setOption('serialize', ['recipe', 'message']);
    }

    public function delete(string $id): void
    {
        $this->request->allowMethod(['delete']);
        $recipe = $this->Recipes->get($id);
        $message = 'Deleted';
        if (!$this->Recipes->delete($recipe)) {
            $message = 'Error';
        }
        $this->set('message', $message);
        $this->viewBuilder()->setOption('serialize', ['message']);
    }

}

ルーティングの設定

config/routes.php

use Cake\Http\Middleware\BodyParserMiddleware;
// ...
$routes->scope('/api', ['prefix' => 'Api'], function (RouteBuilder $builder) {
    $builder->registerMiddleware('bodies', new BodyParserMiddleware());
    $builder->applyMiddleware('bodies');

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

    // Recipe
    $builder->connect('/recipe/index', ['controller' => 'Recipe', 'action' => 'index'])->setMethods(['GET']);
    $builder->connect('/recipe/view/:id', ['controller' => 'Recipe', 'action' => 'view'])->setPass(['id'])->setMethods(['GET']);
    $builder->connect('/recipe/add', ['controller' => 'Recipe', 'action' => 'add'])->setMethods(['POST']);
    $builder->connect('/recipe/edit/:id', ['controller' => 'Recipe', 'action' => 'edit'])->setPass(['id'])->setMethods(['PUT']);
    $builder->connect('/recipe/delete/:id', ['controller' => 'Recipe', 'action' => 'delete'])->setPass(['id'])->setMethods(['DELETE']);
});
// ...

ここまでできたらアクセスができるか確認しておきましょう。
以下のURLを開いてページが表示されていればOKです
http://localhost/api/recipe/index.json

Seedingでダミーデータを入れる

docker-compose exec php bin/cake bake seed Recipes

作成されたSeedファイルにダミーデータを記述していきます。

RecipesSeed.php
<?php
declare(strict_types=1);

use Migrations\AbstractSeed;

/**
 * Recipes seed.
 */
class RecipesSeed extends AbstractSeed
{
    /**
     * Run Method.
     *
     * Write your database seeder using this method.
     *
     * More information on writing seeds is available here:
     * https://book.cakephp.org/phinx/0/en/seeding.html
     *
     * @return void
     */
    public function run()
    {
        $datetime = date('Y-m-d H:i:s');
        $data = [
            [
                'id' => 'c366f5be-360b-45cc-8282-65c80e434f72',
                'title' => 'ハンバーグ',
                'description' => 'ジューシーでご飯がすすむ定番のハンバーグレシピです。ソースの材料もケチャップ、ウスターソース、醤油の3つだけとシンプル。',
                'created' => $datetime,
                'modified' => $datetime,
            ],
            [
                'id' => '93d5ef90-be4d-4179-9311-e39bddc26427',
                'title' => '親子丼',
                'description' => 'ふんわりとろっとろな半熟仕立ての卵とだしの相性が抜群!ご飯にからんで食欲そそる、手軽なのに食べごたえもある満足度高めな一品です。',
                'created' => $datetime,
                'modified' => $datetime,
            ]
        ];

        $table = $this->table('recipes');
        $table->insert($data)->save();
    }
}

ダミーデータを投入してみましょう

docker-compose exec php bin/cake migrations seed

先程のURLをもう一度開くとデータが入っていることを確認できるかと思います。

Swaggerを設定する

ここからがSwaggerを導入していく手順になります。

Swaggerをインストールする

docker-compose exec php php /usr/bin/composer require --dev zircote/swagger-php

ドキュメントファイルを生成するコマンドを追加する

この追加は必須ではありませんが、毎度ドキュメントファイルを生成するコマンドを実行するのが面倒なので簡略化したコマンドで呼び出せる様にします。

composer.json
//...
"scripts": {
    "openapi": "openapi --output openapi.json --format y src/Controller/Api/"
},
//...

APIドキュメントの作成

ここからソースコードの中にドキュメント内容を記述していきます。

共通ドキュメント

新しく以下のファイルを作成してください。
src/Controller/Api/swagger.php

swagger.php
<?php
declare(strict_types=1);

/**
 * @OA\Info(
 *   title="CakePHP Swagger",
 *   description="CakePHP Swagger LT API Automatically generate documental",
 *   version="1.0.0",
 * )
 */

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

/**
 * @OA\Schema(
 *   schema="default_error_response_content",
 *   type="object",
 *   @OA\Property(
 *     property="message",
 *     type="string",
 *     description="エラーメッセージ",
 *   ),
 *   example={
 *     "message"="予期しないエラーです"
 *   },
 * )
 */

APIドキュメント

Controllerの各メソッドにドキュメントを記載していきます。
今回はしっかり書いていますが、exampleなど省力してもドキュメントを生成できるので、必要に応じて調整してください。

RecipeController.php
<?php
declare(strict_types=1);

namespace App\Controller\Api;

use App\Controller\AppController;

/**
 * Recipe Controller
 *
 * @property \App\Model\Table\RecipesTable $Recipes
 */
class RecipeController extends AppController
{

    public function initialize(): void
    {
        parent::initialize();
        $this->loadModel('Recipes');
        $this->loadComponent('RequestHandler');
    }

    /**
     * Index method
     *
     * @OA\Get(
     *   path="/api/recipe/index.json",
     *   operationId = "RecipeIndex",
     *   tags={"Recipe"},
     *   summary="レシピをすべて取得する",
     *   @OA\Response(
     *     response=200,
     *     description="OK",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="recipes",
     *         type="array",
     *         description="レシピ一覧",
     *         @OA\Items(
     *           @OA\Property(
     *             property="id",
     *             type="string",
     *             description="レシピID",
     *           ),
     *           @OA\Property(
     *             property="title",
     *             type="string",
     *             description="レシピ名",
     *           ),
     *           @OA\Property(
     *             property="description",
     *             type="string",
     *             description="レシピ内容",
     *           ),
     *         ),
     *         example={
     *           {
     *             "id"="c366f5be-360b-45cc-8282-65c80e434f72",
     *             "title"="ハンバーグ",
     *             "description"="ジューシーでご飯がすすむ定番のハンバーグレシピです。ソースの材料もケチャップ、ウスターソース、醤油の3つだけとシンプル。",
     *           },
     *           {
     *             "id"="93d5ef90-be4d-4179-9311-e39bddc26427",
     *             "title"="親子丼",
     *             "description"="ふんわりとろっとろな半熟仕立ての卵とだしの相性が抜群!ご飯にからんで食欲そそる、手軽なのに食べごたえもある満足度高めな一品です。",
     *           },
     *         },
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response="400",
     *     description="Unexpected Error",
     *     @OA\JsonContent(ref="#/components/schemas/default_error_response_content")
     *   ),
     * )
     *
     * @return void
     */
    public function index(): void
    {
        $recipes = $this->Recipes->find('all');

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

    /**
     * View method
     *
     * @OA\Get(
     *   path="/api/recipe/view/{id}.json",
     *   operationId = "RecipeView",
     *   tags={"Recipe"},
     *   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="recipes",
     *         type="object",
     *         description="レシピ",
     *         @OA\Property(
     *           property="id",
     *           type="string",
     *           description="レシピID",
     *         ),
     *         @OA\Property(
     *           property="title",
     *           type="string",
     *           description="レシピ名",
     *         ),
     *         @OA\Property(
     *           property="description",
     *           type="string",
     *           description="レシピ内容",
     *         ),
     *         example={
     *           "id"="c366f5be-360b-45cc-8282-65c80e434f72",
     *           "title"="ハンバーグ",
     *           "description"="ジューシーでご飯がすすむ定番のハンバーグレシピです。ソースの材料もケチャップ、ウスターソース、醤油の3つだけとシンプル。",
     *         },
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response="400",
     *     description="Unexpected Error",
     *     @OA\JsonContent(ref="#/components/schemas/default_error_response_content")
     *   ),
     * )
     *
     * @param string $id id.
     * @return void
     */
    public function view(string $id): void
    {
        $recipe = $this->Recipes->get($id);
        $this->set('recipe', $recipe);
        $this->viewBuilder()->setOption('serialize', ['recipe']);
    }

    /**
     * Add method
     *
     * @OA\Post(
     *   path="/api/recipe/add.json",
     *   operationId = "RecipeAdd",
     *   tags={"Recipe"},
     *   summary="レシピを登録する",
     *   @OA\RequestBody(
     *     required=true,
     *     @OA\JsonContent(
     *       type="object",
     *       required={"title","description"},
     *       @OA\Property(
     *         property="title",
     *         type="string",
     *         description="レシピ名",
     *       ),
     *       @OA\Property(
     *         property="description",
     *         type="string",
     *         description="レシピ内容",
     *       ),
     *       example={
     *         "title"="ハンバーグ",
     *         "description"="ジューシーでご飯がすすむ定番のハンバーグレシピです。ソースの材料もケチャップ、ウスターソース、醤油の3つだけとシンプル。",
     *       },
     *     ),
     *   ),
     *   @OA\Response(
     *     response=200,
     *     description="OK",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="recipe",
     *         type="object",
     *         description="レシピ",
     *         @OA\Property(
     *           property="id",
     *           type="string",
     *           description="ID",
     *         ),
     *         example={
     *           "id"="c366f5be-360b-45cc-8282-65c80e434f72",
     *         },
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response="400",
     *     description="Unexpected Error",
     *     @OA\JsonContent(ref="#/components/schemas/default_error_response_content")
     *     ),
     *   ),
     * )
     *
     * @return void
     */
    public function add(): void
    {
        $this->request->allowMethod(['post', 'put']);
        $recipe = $this->Recipes->newEntity($this->request->getData());
        if ($this->Recipes->save($recipe)) {
            $message = 'Saved';
        } else {
            $message = 'Error';
        }
        $this->set([
            'message' => $message,
            'recipe' => $recipe,
        ]);
        $this->viewBuilder()->setOption('serialize', ['recipe', 'message']);
    }

    /**
     * Edit method
     *
     * @OA\Put(
     *   path="/api/recipe/edit/{id}.json",
     *   operationId = "RecipeEdit",
     *   tags={"Recipe"},
     *   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={"title", "description"},
     *       @OA\Property(
     *         property="title",
     *         type="string",
     *         description="レシピ名",
     *       ),
     *       @OA\Property(
     *         property="description",
     *         type="string",
     *         description="レシピ内容",
     *       ),
     *       example={
     *         "title"="ハンバーグ",
     *         "description"="ジューシーでご飯がすすむ定番のハンバーグレシピです。ソースの材料もケチャップ、ウスターソース、醤油の3つだけとシンプル。",
     *       },
     *     ),
     *   ),
     *   @OA\Response(
     *     response=200,
     *     description="OK",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="recipe",
     *         type="object",
     *         description="レシピ",
     *         @OA\Property(
     *           property="id",
     *           type="string",
     *           description="ID",
     *         ),
     *         example={
     *           "id"="c366f5be-360b-45cc-8282-65c80e434f72",
     *         },
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response="400",
     *     description="Unexpected Error",
     *     @OA\JsonContent(ref="#/components/schemas/default_error_response_content")
     *     ),
     *   ),
     * )
     *
     * @param string $id id
     * @return void
     */
    public function edit(string $id): void
    {
        $this->request->allowMethod(['patch', 'post', 'put']);
        $recipe = $this->Recipes->get($id);
        $recipe = $this->Recipes->patchEntity($recipe, $this->request->getData());
        if ($this->Recipes->save($recipe)) {
            $message = 'Saved';
        } else {
            $message = 'Error';
        }
        $this->set([
            'message' => $message,
            'recipe' => $recipe,
        ]);
        $this->viewBuilder()->setOption('serialize', ['recipe', 'message']);
    }

    /**
     * Delete method
     *
     * @OA\Delete(
     *   path="/api/recipe/delete/{id}.json",
     *   operationId = "RecipeDelete",
     *   tags={"Recipe"},
     *   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="400",
     *     description="Unexpected Error",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="message",
     *         type="string",
     *         description="エラーメッセージ",
     *       ),
     *      example={
     *        "message"="予期しないエラーです"
     *      },
     *     ),
     *   ),
     * )
     *
     * @param string $id id
     * @return void
     */
    public function delete(string $id): void
    {
        $this->request->allowMethod(['delete']);
        $recipe = $this->Recipes->get($id);
        $message = 'Deleted';
        if (!$this->Recipes->delete($recipe)) {
            $message = 'Error';
        }
        $this->set('message', $message);
        $this->viewBuilder()->setOption('serialize', ['message']);
    }

}

Swagger UIから呼び出せるようにする

Swagger UIからAPIにリクエストしてレスポンスを確認するにはCORSの対応を行う必要があります。
今回のCORS対応は、ローカルで動いているサーバー同士の接続を許可してあげることを意味します。

CORSについてはこちらを参考ください。
なんとなく CORS がわかる...はもう終わりにする。

Middlewareでヘッダ情報を付与する

新しくsrc/Middleware/CorsMiddleware.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;
    }
}

Preflight request の対応

Controller を作り、Preflight request を処理できるようにします。
参考:Preflight request (プリフライトリクエスト)

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

ルーターの設定

作成したCorsMiddlewareCorsControllerを設定します。

config/route.php
use App\Middleware\CorsMiddleware;
//...
$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']);

//...

DockerイメージにSwagger UIとSwagger Editorを追加する

docker-compose.ymlを確認いただくとswagger-editorswagger-uiがコメントアウトされているかと思いますので、コメントアウトを外してください。

docker-compose.yml
//...

# Swagger API
  swagger-editor:
    image: swaggerapi/swagger-editor:v3.14.8
    container_name: cake_swagger-editor
    ports:
      - 8001:8080
    volumes:
      - ./server:/tmp
    environment:
      SWAGGER_FILE: /tmp/openapi.json

  swagger-ui:
    image: swaggerapi/swagger-ui
    container_name: cake_swagger-ui
    ports:
      - 8002:8080
    volumes:
      - ./server:/tmp
    environment:
      SWAGGER_JSON: /tmp/openapi.json

//...

ドキュメントファイルを生成する

 docker-compose exec php php /usr/bin/composer openapi

Dockerを再起動する

make up

実行が完了したら、以下のURLでSwagger UIを確認してみましょう
http://localhost:8002/
スクリーンショット 2021-03-27 20.29.49.png

皆さんのお手元でもちゃんと動いてくれていますでしょうか?
だいぶ長くなってしまいましたね、お疲れ様でした🙇

記事中の操作でもっといい方法がありましたら、アドバイスいただけますと幸いです。
また、上手くいかないところなどありましたら、お気軽にコメントください。
可能な範囲で返信させていただきます。100%解決ことは保証できませんが、精一杯一緒に考えさせていただきます。

少しでも皆さまのお役に立てましたら幸いです🙋

3
2
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
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?