この記事は何?
- PHPerKaigi2021で発表するスライドの補足資料
- 発表で紹介したサンプルコードと同じものを作成する手順のまとめ
- Swaggerの導入分だけ読みたい方はこちら
- この記事を作成する上で参考にさせていただいた記事。(丁寧な解説でとても分かりやすかったです。🙇)
- 作成するAPIはCakePHPクックブックのREST APIを参考
- サンプルコード(コードだけみたい方はこちら)
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ページを確認することができます。
Databaseとの接続を設定する
app_local.php
のDatasources中のhost, username, password, databaseを以下の様に変更してください。変更する値はdocker-compose.yml
のcake_dbコンテナから持ってきてます。
'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
マイグレーションファイルを編集して、フィールドを追加します。
<?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
など追加しています。
<?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']);
}
}
ルーティングの設定
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ファイルにダミーデータを記述していきます。
<?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
ドキュメントファイルを生成するコマンドを追加する
この追加は必須ではありませんが、毎度ドキュメントファイルを生成するコマンドを実行するのが面倒なので簡略化したコマンドで呼び出せる様にします。
//...
"scripts": {
"openapi": "openapi --output openapi.json --format y src/Controller/Api/"
},
//...
APIドキュメントの作成
ここからソースコードの中にドキュメント内容を記述していきます。
共通ドキュメント
新しく以下のファイルを作成してください。
src/Controller/Api/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など省力してもドキュメントを生成できるので、必要に応じて調整してください。
<?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
を作成します。
<?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 (プリフライトリクエスト)
<?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
を設定します。
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-editor
とswagger-ui
がコメントアウトされているかと思いますので、コメントアウトを外してください。
//...
# 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/
皆さんのお手元でもちゃんと動いてくれていますでしょうか?
だいぶ長くなってしまいましたね、お疲れ様でした🙇
記事中の操作でもっといい方法がありましたら、アドバイスいただけますと幸いです。
また、上手くいかないところなどありましたら、お気軽にコメントください。
可能な範囲で返信させていただきます。100%解決ことは保証できませんが、精一杯一緒に考えさせていただきます。
少しでも皆さまのお役に立てましたら幸いです🙋