API ドキュメントを作るために Swagger3(OpenAPI) を使えるようにします。
PHPでSwagger3(OpenAPI Specification 3)
上記の記事がすごく参考になりました。
この記事でわかること
- CakePHP4 へ Swagger3 を導入する手順。
- この記事内のソースは以下で公開しています。
事前準備
- 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 |
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
)ようにします。
// ... 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
をリポジトリの管理対象から外します。
# ... snip
# Swagger
openapi.yml
# ... snip
API ドキュメントの作成
共通ドキュメント
共通のドキュメント内容を記述するために ./src/Controller/Api/swagger.php
を作成します。
このファイルは、 Swagger の記述のみのファイルです。
<?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ドキュメントを記述します。
<?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 で必要なヘッダ情報を付与することで対応します。
<?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 を処理できるようにします。
<?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', []);
}
}
ルーターへの設定
CorsMiddleware
と CorsController
を設定します。
// ... 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 ドキュメント作成について記述しています。