LoginSignup
6
6

More than 3 years have passed since last update.

CakePHP4 で Swagger3 を使って API ドキュメントを作る

Last updated at Posted at 2020-03-27

API ドキュメントを作るために Swagger3(OpenAPI) を使えるようにします。

PHPでSwagger3(OpenAPI Specification 3)

上記の記事がすごく参考になりました。

この記事でわかること

事前準備

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

Swagger3 の導入

swagger-php を追加します。
開発時のみ利用するため --dev オプションを付けています。

docker exec -it app php composer.phar require --dev zircote/swagger-php

Composer scripts への追加と変更

OpenAPIファイルの作成処理を Composer 経由で実行させるため、 composer.json の scripts へコマンドを追加します。

APIドキュメントを書く場合、スペースが重要になります。CakePHP用の CodeSniffer で実行した場合、

ERROR | [x] Expected 1 space after asterisk; 3 found

のようなエラーが大量に発生します。
エラーを回避するために Squiz.Commenting.DocCommentAlignment を利用しない( exclude )ようにします。

composer.json
    // ... snip
    "scripts": {
        // ... snip
        "cs-check": "phpcs --colors -p --standard=vendor/cakephp/cakephp-codesniffer/CakePHP --exclude=Squiz.Commenting.DocCommentAlignment src/ tests/",
        "cs-fix": "phpcbf --colors --standard=vendor/cakephp/cakephp-codesniffer/CakePHP --exclude=Squiz.Commenting.DocCommentAlignment src/ tests/",
        // ... snip
        "openapi": "openapi --output openapi.yml --format y src/Controller/Api/"
    },
    // ... snip

YAML 形式が好きなのでフォーマットをYAML ( --format y )へしています。

.gitignore への追加

生成されるファイル openapi.yml をリポジトリの管理対象から外します。

.gitignore
# ... snip
# Swagger
openapi.yml
# ... snip

API ドキュメントの作成

共通ドキュメント

共通のドキュメント内容を記述するために ./src/Controller/Api/swagger.php を作成します。
このファイルは、 Swagger の記述のみのファイルです。

src/Controller/Api/swagger.php
<?php
declare(strict_types=1);

/**
 * @OA\Info(
 *   title="CakePHP Vue Study",
 *   description="CakePHP Vue Study API List",
 *   version="1.0.0",
 * )
 */

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

APIドキュメント

Controller へ APIドキュメントを記述します。

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
     *
     * @OA\Get(
     *   path="/api/task/search.json",
     *   tags={"Task"},
     *   summary="タスクを検索する",
     *   @OA\Parameter(
     *     name="description_like",
     *     in="query",
     *     required=false,
     *     description="タスク内容検索条件",
     *     @OA\Schema(type="string"),
     *     example="作業"
     *   ),
     *   @OA\Response(
     *     response=200,
     *     description="OK",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="tasks",
     *         type="array",
     *         description="タスク一覧",
     *         @OA\Items(
     *           @OA\Property(
     *             property="id",
     *             type="string",
     *             description="タスクID",
     *           ),
     *           @OA\Property(
     *             property="description",
     *             type="string",
     *             description="タスク内容",
     *           ),
     *         ),
     *         example={
     *           {
     *             "id"="c366f5be-360b-45cc-8282-65c80e434f72",
     *             "description"="朝の身だしなみチェック",
     *           },
     *           {
     *             "id"="93d5ef90-be4d-4179-9311-e39bddc26427",
     *             "description"="寝る前の作業",
     *           },
     *         },
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response="default",
     *     description="Unexpected Error",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="message",
     *         type="string",
     *         description="エラーメッセージ",
     *       ),
     *     ),
     *   ),
     * )
     *
     * @return void
     */
    public function search(): void
    {
        $this->loadModel('Tasks');
        $descriptionLike = strval($this->request->getQuery('description_like'));

        $query = $this->Tasks->find();
        if ($descriptionLike) {
            $tasks = $query->where(['description LIKE' => "%{$descriptionLike}%"]);
        }
        $tasks = $query->all();

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

    /**
     * View method
     *
     * @OA\Get(
     *   path="/api/task/view/{id}.json",
     *   tags={"Task"},
     *   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="task",
     *         type="object",
     *         description="タスク",
     *         @OA\Property(
     *           property="id",
     *           type="string",
     *           description="ID",
     *           example="c366f5be-360b-45cc-8282-65c80e434f72",
     *         ),
     *         @OA\Property(
     *           property="description",
     *           type="string",
     *           description="タスク内容",
     *           example="朝の身だしなみチェック",
     *         ),
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response="default",
     *     description="Unexpected Error",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="message",
     *         type="string",
     *         description="エラーメッセージ",
     *       ),
     *     ),
     *   ),
     * )
     *
     * @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
     *
     * @OA\Post(
     *   path="/api/task/create.json",
     *   tags={"Task"},
     *   summary="タスクを登録する",
     *   @OA\RequestBody(
     *     required=true,
     *     @OA\JsonContent(
     *       type="object",
     *       required={"description"},
     *       @OA\Property(
     *         property="description",
     *         type="string",
     *         description="タスク内容",
     *         example="朝の身だしなみチェック",
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response=200,
     *     description="OK",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="task",
     *         type="object",
     *         description="タスク",
     *         @OA\Property(
     *           property="id",
     *           type="string",
     *           description="ID",
     *           example="c366f5be-360b-45cc-8282-65c80e434f72",
     *         ),
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response="default",
     *     description="Unexpected Error",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="message",
     *         type="string",
     *         description="エラーメッセージ",
     *       ),
     *     ),
     *   ),
     * )
     *
     * @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
     *
     * @OA\Put(
     *   path="/api/task/update/{id}.json",
     *   tags={"Task"},
     *   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={"description"},
     *       @OA\Property(
     *         property="description",
     *         type="string",
     *         description="タスク内容",
     *         example="朝の身だしなみチェック",
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response=200,
     *     description="OK",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="task",
     *         type="object",
     *         description="タスク",
     *         @OA\Property(
     *           property="id",
     *           type="string",
     *           description="ID",
     *           example="c366f5be-360b-45cc-8282-65c80e434f72",
     *         ),
     *       ),
     *     ),
     *   ),
     *   @OA\Response(
     *     response="default",
     *     description="Unexpected Error",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="message",
     *         type="string",
     *         description="エラーメッセージ",
     *       ),
     *     ),
     *   ),
     * )
     *
     * @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
     *
     * @OA\Delete(
     *   path="/api/task/delete/{id}.json",
     *   tags={"Task"},
     *   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="default",
     *     description="Unexpected Error",
     *     @OA\JsonContent(
     *       type="object",
     *       @OA\Property(
     *         property="message",
     *         type="string",
     *         description="エラーメッセージ",
     *       ),
     *     ),
     *   ),
     * )
     *
     * @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', []);
    }
}

ドキュメントの作成

Composer の scripts へコマンドを追加しているので以下のように実行すれば作成できます。

docker exec -it app php composer.phar openapi

補足) CORS の対応

Swagger UI (僕は、 VSCode の Swagger Viewer
を使っています) からは API を呼び出すことができますが、 CORS に対応する必要があります。

今回は開発時のみ有効としたいと思っているので、SERVER環境変数が設定されていない、もしくは false のとき、ローカル開発とみなして CORS を許可します。

GET 等の 単純リクエストの対応

Middleware で必要なヘッダ情報を付与することで対応します。

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;
    }
}

この CorsMiddleware はルーターで利用するように設定します。
後ほど、 ./config/routes.php の記述例を載せます。

Preflight request の対応

Controller を作り、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/routes.php
// ... snip
use App\Middleware\CorsMiddleware;
// ... snip
// API
$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']);

    // ... snip
});
// ... snip

これで、Swagger UI からも呼び出せるはずです。

補足

一応は、APIドキュメントがかけるようになりましたが、ドキュメント内容と処理内容が一致しているか、確認しづらい状態です。
この点をなんとかする方法を模索したいと思っています。

CakePHP4 で Swagger3 を使った API ドキュメントの書き方を考える こちらで ref と schema を使った API ドキュメント作成について記述しています。

6
6
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
6
6