LaravelでAPI定義を管理する
Laravel/Vue勉強会#2@2017/11/24
KenjiroKubota
Pro le
Kenjiro Kubota
istyle.inc
自称テクノロジー本部銀髪担当
PHP, HHVM/Hack, Javascript
今日話すこと
ADR
REST API Level3
Swagger PHP
Vueの話はありません。ごめんなさい。
REST APIの定義管理ってどうしてます
Wiki?
Markdownファイル?
何かしらのAPIドキュメントツール?
API利用者(フロントエンジニア)にこんなこと言われない?
「実際に返ってくるパラメータと定義書違くない 」
(あ、ドキュメント更新してないっす)
理想は実装 = 定義書ですよね
実装から定義書を生成する話
踏まえて、今日話すこと
ADR = API実装の話
REST API Level3 = レスポンス形式の話
Swagger PHP = APIドキュメントの話
ADR
ADR
Action Domain Responder
の略です。Laralab vol.1という勉強会で紹介させていただきました。
Responsableを使ったADR実装
https://www.slideshare.net/KenjiroKubota/responsableadr
MVCから派生したUIアーキテクチャパターン
Model Domain
View Responder
Controller Action
MVCと違うところ
1つのEndpointに1つのActionClass
参考実装
今週発売のゲームソフトを返すエンドポイントを作ります。
(返すのはダミーデータです)
Github: kubotak-is/laravel-swagger-sample
<?php
namespace AppHttpActionApi;
final class GetThisWeeksGameSoftwareRelease
{
private $service;
public function __construct(ThisWeeksGameSoftwareRelease $service)
{
$this->service = $service;
}
public function __invoke(GetThisWeeksGameSoftwareReleaseRequest $request): Responder
{
$validated = $request->validated();
$limit = $validated['limit'] ?? 3;
$offset = $validated['offset'] ?? 0;
$collection = $this->service->getCollection((int) $limit, (int) $offset);
return new GetThisWeeksGameSoftwareReleaseResponder($collection);
}
}
class RouteServiceProvider extends ServiceProvider
{
public function register()
{
parent::register();
/** @var Router $router */
$router = $this->app['router'];
$router->group(['prefix' => 'api'], function (Router $router) {
$router->get(
'/game_software/release/week',
['uses' => GetThisWeeksGameSoftwareRelease::class]
);
});
}
}
RouterでDispatchされたClassは __invoke() が実行される
FatContollerにならない
(おまけ)FormRequest
<?php
namespace AppHttpRequestApi;
use IlluminateFoundationHttpFormRequest;
class GetThisWeeksGameSoftwareReleaseRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'limit' => 'integer|min:1',
'offset' => 'integer|min:0',
];
}
}
ActionClass
メソッドインジェクションしたFormRequestは既にバリデーション済
みのパラメータを取得できる。
Laraevl5.5からは ->validated(); でバリデーション定義されてかつ、バ
リデーションされた値のみ取得が可能
public function __invoke(GetThisWeeksGameSoftwareReleaseRequest $request): Responder
{
$validated = $request->validated();
$limit = $validated['limit'] ?? 3;
$offset = $validated['offset'] ?? 0;
$collection = $this->service->getCollection((int) $limit, (int) $offset);
return new Responder($collection);
}
Responder
<?php
namespace AppHttpResponderApi;
use AppHttpResponderHateoasResponder;
use IlluminateContractsSupportResponsable;
use AppDomainCollectionGameSoftwareCollection;
class GetThisWeeksGameSoftwareReleaseResponder implements Responsable
{
use HateoasResponder;
private $resource;
public function __construct(GameSoftwareCollection $collection)
{
$this->resource = $collection;
}
public function toResponse($request): IlluminateHttpResponse
{
return $this->hal($this->resource);
}
}
REST API Level3
RESTの4段階のModel
https://martinfowler.com/articles/richardsonMaturityModel.html
REST API Model
Level 0
HTTPを用いてXMLレスポンスを返却すること
Level 1
URLでリソースを表すこと
Level 2
HTTPメソッドを正しく使い分けること
Level 3
ハイパーメディアコントロール
ハイパーメディアコントロール
Webサイトの様にテキストのリンクを利用して利用者をナビゲートす
るように、
APIのレスポンスにも別のエンドポイントへのナビゲートを含めること
HATEOAS
Hypermedia As The Engine Of Application State
HATEOASとは
RestfullAPIを拡張するアーキテクチャパターン
状態遷移を表現する
レスポンス内にリンクを含める
そのリンクを辿ることで状態遷移を表現する
willdurand/hateoas
https://github.com/willdurand/Hateoas
アノテーションを追加することでレスポンスパラメータを拡張するライ
ブラリ
$ composer require willdurand/hateoas
Laravelでアノテーションを利用できるようにする
<?php
namespace AppProviders;
use DoctrineCommonAnnotationsAnnotationReader;
use DoctrineCommonAnnotationsAnnotationRegistry;
use IlluminateSupportServiceProvider;
class AnnotationRegisterServiceProvider extends ServiceProvider
{
public function register()
{
$loader = require base_path().'/vendor/autoload.php';
AnnotationRegistry::registerLoader([$loader, 'loadClass']);
}
}
AppProvidersAnnotationRegisterServiceProvider::class,
Entity
<?php
namespace AppDomainEntity;
use AppHttpResponderHateoasResource;
use PHPMentorsDomainKataEntityEntityInterface;
class GameSoftware implements EntityInterface, HateoasResource
{
protected $id;
protected $title;
protected $description;
protected $releaseDate;
protected $price;
protected $retailPriceDesired;
protected $platform;
protected $thumbnail;
}
Add Annotation
/**
* Class GameSoftware
* @package AppDomainEntity
* @HateoasRelation(
* "self",
* href = "expr('/api/game_software/' ~ object.getId())"
* )
* @HateoasRelation(
* "page",
* href = "expr('/game_software/' ~ object.getId())"
* )
*/
class GameSoftware implements EntityInterface, HateoasResource
{
/**
* @var string
* @Accessor("getReleaseDate")
*/
protected $releaseDate;
/**
* @var int
* @Type("int")
*/
protected $price;
public function getId(): int
{
return $this->id;
}
public function getReleaseDate(string $format = "Y-m-d H:i:s"): string
{
return (new DateTime($this->releaseDate))->format($format);
}
<?php
namespace AppHttpResponder;
use HateoasHateoas;
use HateoasHateoasBuilder;
use IlluminateHttpResponse;
use HateoasUrlGeneratorCallableUrlGenerator;
trait HateoasResponder
{
protected function hal(
HateoasResource $resource,
int $status = 200,
array $headers = []
): Response
{
return new Response(
$this->builder()->serialize($resource, 'json'),
$status,
array_merge(['Content-Type' => 'application/hal+json'], $headers)
);
}
protected function builder(): Hateoas
{
return HateoasBuilder::create()
->setUrlGenerator(
null,
new CallableUrlGenerator(function ($route, array $parameters) {
return route($route, $parameters);
})
)
->build();
}
}
<?php
namespace AppHttpResponderApi;
use AppHttpResponderHateoasResponder;
use IlluminateContractsSupportResponsable;
use AppDomainCollectionGameSoftwareCollection;
class GetThisWeeksGameSoftwareReleaseResponder implements Responsable
{
use HateoasResponder;
private $resource;
public function __construct(GameSoftwareCollection $collection)
{
$this->resource = $collection;
}
public function toResponse($request): IlluminateHttpResponse
{
return $this->hal($this->resource);
}
}
こんな感じのレスポンスになります。
{
"id": 1,
"title": "スーパーマリオオデッセイ",
"description": "マリオ、世界の旅へ。Nintendo Switch向けソフトに新作3Dマリオが登場します。…",
"release_date": "2017-10-27 00:00:00",
"price": 5545,
"retail_price_desired": 6458,
"platform": "Nintendo Switch",
"thumbnail": "https://images-na.ssl-images-amazon.com/images/I/….jpg",
"_links": {
"self": {
"href": "/api/game_software/1"
},
"page": {
"href": "/game_software/1"
}
}
}
(おまけ)今回使ったHateoas以外のライブラリの紹介
zendframework/zend-hydrator
https://docs.zendframework.com/zend-hydrator/
public function findReleaseThisWeek(int $limit, int $offset): GameSoftwareCollection
{
$data = $this->criteria->getReleaseThisWeek($limit, $offset);
$collection = new GameSoftwareCollection();
foreach ($data as $item) {
$hydrator = new ReflectionHydrator();
$namingStrategy = new CompositeNamingStrategy([
'release_date' => new MapNamingStrategy([
'release_date' => 'releaseDate'
]),
'retail_price_desired' => new MapNamingStrategy([
'retail_price_desired' => 'retailPriceDesired'
]),
]);
$hydrator->setNamingStrategy($namingStrategy);
$gameSoft = $hydrator->hydrate(
$item,
new GameSoftware()
);
$collection->add($gameSoft);
}
return $collection;
}
protected, privateプロパティに対して
constructやsetterなしでオブジェクトマッピングしてくれます
Swagger PHP
Swaggerとは
https://swagger.io
THE WORLD'S MOST POPULAR API TOOLING
RESTful APIのドキュメントや、サーバ、クライアントコード、エディ
タ、またそれらを扱うための仕様などを提供するフレームワーク
Swagger Speci cationをSwagger UIに読み込ませることで定義書を生
成する
Swagger PHPではアノテーションを利用して
Swagger Speci cation(json)を生成
実装 = 定義書ができる!
DarkaOnLine/L5-Swagger
https://github.com/DarkaOnLine/L5-Swagger
LaravelでSwaggerPHPを使うライブラリ
ArtisanコマンドでSwagger Speci cationを生成してSwaggerUIに適用
したページが表示できる
$ composer require "darkaonline/l5-swagger:5.5.*"
L5SwaggerL5SwaggerServiceProvider::class,
開発環境のみ利用したいのでProviderでLocalとDevelop環境の場合の
み登録する
$env = $this->app->environment();
if ($env === 'develop' || $env === 'local') {
$this->app->register(L5SwaggerL5SwaggerServiceProvider::class);
}
$ php artisan l5-swagger:publish
con g/l5-swagger.php
public/vendor/l5-swagger
resources/views/vendor/l5-swagger
必要なファイルをpublishで移動
HATEOASで利用したアノテーション登録でSwagger PHPのアノテーシ
ョン @SWG のClassが見つからないエラーが発生するのでこのアノテーシ
ョンはIgnoreさせる
class AnnotationRegisterServiceProvider extends ServiceProvider
{
public function register()
{
$loader = require base_path().'/vendor/autoload.php';
AnnotationRegistry::registerLoader([$loader, 'loadClass']);
AnnotationReader::addGlobalIgnoredNamespace('SWG'); // Add
}
}
Modelの定義
/**
* Class GameSoftware
* @package AppDomainEntity
* @HateoasRelation(
* "self",
* href = "expr('/api/game_software/' ~ object.getId())"
* )
* @HateoasRelation(
* "page",
* href = "expr('/game_software/' ~ object.getId())"
* )
* @SWGDefinition(
* type="object",
* @SWGXml(name="GameSoftware")
* )
*/
class GameSoftware implements EntityInterface, HateoasResource
プロパティのアノテーション
/**
* @var int
* @SWGProperty(format="int64")
*/
protected $id;
/**
* @var string
* @SWGProperty()
*/
protected $title;
/**
* @SWGProperty(
* property="_links",
* type="object",
* @SWGProperty(
* property="self",
* type="object",
* @SWGProperty(
* property="href",
* type="string"
* )
* ),
* @SWGProperty(
* property="page",
* type="object",
* @SWGProperty(
* property="href",
* type="string"
* )
* )
* )
*/
Collectionの定義
/**
* Class GameSoftwareCollection
* @package AppDomainCollection
* @SWGDefinition(
* type="object",
* @SWGXml(name="GameSoftwareCollection")
* )
*/
class GameSoftwareCollection implements EntityCollectionInterface, HateoasResource
{
/**
* @var GameSoftware[]
* @SerializedName("game_software")
* @SWGProperty(
* property="game_software",
* @SWGXml(name="GameSoftware", wrapped=true)
* )
*/
protected $entities = [];
Responseの定義
(ResponderClass)
/**
* @param IlluminateHttpRequest $request
* @return IlluminateHttpResponse
* @SWGResponse(
* response="GetThisWeeksGameSoftwareReleaseResponder",
* description="今週発売のゲームを返す",
* @SWGSchema(ref="#/definitions/GameSoftwareCollection")
* )
*/
public function toResponse($request): IlluminateHttpResponse
{
return $this->hal($this->resource);
}
RequestParameterの定義
/**
* @SWGParameter(
* parameter="GetThisWeeksGameSoftwareReleaseRequest_limit",
* name="limit",
* description="取得件数",
* in="query",
* required=false,
* type="integer",
* format="int32"
* )
* @SWGParameter(
* parameter="GetThisWeeksGameSoftwareReleaseRequest_offset",
* name="offset",
* description="取得位置",
* in="query",
* required=false,
* type="integer",
* format="int32"
* )
*/
public function rules(): array
Endpointの定義
/**
* Class GetThisWeeksGameSoftwareRelease
* @package AppHttpActionApi
* @SWGGet(
* path="/game_software/release/week",
* summary="今週発売のゲームソフト",
* description="",
* consumes={"application/json"},
* produces={"application/hal+json"},
* @SWGParameter(ref="#/parameters/GetThisWeeksGameSoftwareReleaseRequest_limit"),
* @SWGParameter(ref="#/parameters/GetThisWeeksGameSoftwareReleaseRequest_offset"),
* @SWGResponse(
* response="default",
* ref="#/responses/GetThisWeeksGameSoftwareReleaseResponder"
* )
* )
*/
final class GetThisWeeksGameSoftwareRelease
swagger.jsonの生成
$ php artisan l5-swagger:generate
開発環境にデプロイする際に実行
もしくは config/l5-swagger.php の 'generate_always' => true にすることで
SwaggerUIにアクセスするたびに生成されます。
/api/documentation にアクセスするとSwaggerUIが展開される。
エンドポイントを変更したい場合は config/l5-swagger.php の
'api' => 'api/documentation', の項目から変更できます。
まとめ
ADRで責務を明確に
REST API Level3はHAETOASで実現
Swagger-php(L5-Swagger)を利用して実装=定義書
ADRで実装することでアノテーションもModel,Response,Request
で分離できる
PRレビューを行っている場合はアノテーションと実装が一致して
いるかを確認事項にできる
フロントエンジニアに最新の状態の定義書を提供できる
thanks :)

LaravelでAPI定義を管理する