API実装してますか?
弊社では最近APIにLumen(PHP), Zend Expressive(PHP), echo(Go)が利用されています。
そのなかでも徐々にAPIを表題のHATEOASへとシフトしつつあります。
HATEOASに関しては下記を参照ください postd.cc
そのHATEOASですが、Lumenを使って実装する簡単な例を紹介します。
記事を返却するAPI
Article Entity
<?php namespace App\Domain\Entity; /** * Class Article */ final class Article { /** @var int */ private $id; /** @var string */ private $title; /** @var Comment[] */ private $comments; /** * Article constructor. * * @param int $id * @param string $title */ public function __construct(int $id, string $title) { $this->id = $id; $this->title = $title; } /** * @return int */ public function getId() : int { return $this->id; } /*** * @return string */ public function getTitle() : string { return $this->title; } /** * @param Comment $comment */ public function setComment(Comment $comment) { $this->comments[] = $comment; } /** * @return Comment[] */ public function getComments() : array { return $this->comments; } }
Comment Entity
<?php namespace App\Domain\Entity; /** * Class Comment */ final class Comment { /** @var int */ private $id; /** @var string */ private $comment; /** * Article constructor. * * @param int $id * @param string $comment */ public function __construct(int $id, string $comment) { $this->id = $id; $this->comment = $comment; } /** * @return int */ public function getId() : int { return $this->id; } /** * @return string */ public function getComment() { return $this->comment; } }
Repository
データベースなどを使わないシンプルな例です。
<?php namespace App\Domain\Repository; use App\Domain\Entity\Article; use App\Domain\Entity\Comment; /** * Class ArticleRepository */ class ArticleRepository { /** @var Article */ protected $article; /** * @return ArticleRepository */ public function findOne() : ArticleRepository { $article = new Article(1, 'testing'); $article->setComment(new Comment(1, 'comment 1')); $article->setComment(new Comment(2, 'comment 2')); $this->article = $article; return $this; } /** * @return array */ public function toArray() { if (!$this->article instanceof Article) { return []; } return [ 'id' => $this->article->getId(), 'title' => $this->article->getTitle(), 'comments' => array_map(function (Comment $comment) { return [ 'id' => $comment->getId(), 'comment' => $comment->getComment(), ]; }, $this->article->getComments()), ]; } }
Service
<?php namespace App\Domain\Services; use App\Domain\Repository\ArticleRepository; /** * Class ArticleReader */ class ArticleReader { /** @var ArticleRepository */ protected $repository; /** * ArticleReader constructor. * * @param ArticleRepository $repository */ public function __construct(ArticleRepository $repository) { $this->repository = $repository; } /** * @return array */ public function readOne() : array { return $this->repository->findOne()->toArray(); } }
Controller
jsonでレスポンスを返却する簡単な例です
<?php namespace App\Http\Controllers; use App\Domain\Services\ArticleReader; use Symfony\Component\HttpFoundation\Response; /** * Class ArticleController */ class ArticleController extends Controller { /** * @param ArticleReader $reader * * @return Response */ public function invoke(ArticleReader $reader) : Response { return response()->json($reader->readOne()); } }
Response
上記の様に実装すると、次の様に返却されます
{ "id": 1, "title": "testing", "comments": [ { "id": 1, "comment": "comment 1" }, { "id": 2, "comment": "comment 2" } ] }
簡単な例ではあるものの、想定された形式になっていると思います。
routes例
<?php $app->get('/articles/{id}', ['uses' => 'ArticleController@invoke', 'as' => 'articles']);
記事をHATEOASへ
willdurand/hateoasを利用して組み込む例です。
Annotationを利用して、簡単に実装することができます。
コマンドなどで追加します
$ composer require willdurand/hateoas
Responseの実装
lumenでは response()
ヘルパーが返却するインスタンスは変更できないため、
makeを利用するなどで対応しましょう。
下記はhalメソッドを追加する例です。
<?php declare(strict_types = 1); namespace App\Http; use Hateoas\Hateoas; use Hateoas\HateoasBuilder; use Illuminate\Http\Response; use App\Http\Hateoas as HateoasResource; use Hateoas\UrlGenerator\CallableUrlGenerator; /** * Class ResponseFactory */ class ResponseFactory extends \Laravel\Lumen\Http\ResponseFactory { /** @var string[] */ protected $headers = [ 'Content-Type' => 'application/hal+json', ]; /** @var string */ protected $serialization = 'json'; /** * @param HateoasResource $resource * @param int $status * @param array $headers * * @return \Illuminate\Http\Response */ public function hal(HateoasResource $resource, $status = 200, array $headers = []) : Response { return new Response( $this->builder()->serialize($resource, $this->serialization), $status, array_merge($this->headers, $headers) ); } /** * @return Hateoas */ protected function builder() : Hateoas { return HateoasBuilder::create() ->setUrlGenerator( null, new CallableUrlGenerator(function ($route, array $parameters) { return route($route, $parameters); }) ) ->build(); } }
Lumenではrouteヘルパーでurlなどが出力可能ですので、
ルーティングに名前指定して、返却されるレスポンスに様々なurlを追加できる様になります。
application/hal+json
で返却する様にheaderに追加しましょう。
App\Http\Hateoas
を実装したクラスだけを許可する例です。
Article Entity
_links
を追加します。
<?php namespace App\Domain\Entity; use App\Http\Hateoas as HateoasResource; use JMS\Serializer\Annotation as Serializer; use Hateoas\Configuration\Annotation as Hateoas; /** * Class Article * @Hateoas\Relation( * "self", * href = @Hateoas\Route( * "articles", * parameters = { * "id" = "expr(object.getId())" * } * ), * attributes = { "method" = "GET" }, * ) * @Hateoas\Relation( * "comments", * embedded = "expr(object.getComments())", * ) */ final class Article implements HateoasResource { /** @var int */ private $id; /** @var string */ private $title; /** * @var Comment[] * @Serializer\Exclude */ private $comments; /** * Article constructor. * * @param int $id * @param string $title */ public function __construct(int $id, string $title) { $this->id = $id; $this->title = $title; } /** * @return int */ public function getId() : int { return $this->id; } /*** * @return string */ public function getTitle() : string { return $this->title; } /** * @param Comment $comment */ public function setComment(Comment $comment) { $this->comments[] = $comment; } /** * @return Comment[] */ public function getComments() : array { return $this->comments; } }
Comment Entity
<?php namespace App\Domain\Entity; use App\Http\Hateoas as HateoasResource; use JMS\Serializer\Annotation as Serializer; use Hateoas\Configuration\Annotation as Hateoas; /** * Class Comment * @Hateoas\Relation( * "self", * href = "expr('http://example.com/comments/' ~ object.getId())", * attributes = { "method" = "GET" }, * ) */ final class Comment implements HateoasResource { /** @var int */ private $id; /** @var string */ private $comment; /** * Article constructor. * * @param int $id * @param string $comment */ public function __construct(int $id, string $comment) { $this->id = $id; $this->comment = $comment; } /** * @return int */ public function getId() : int { return $this->id; } /** * @return string */ public function getComment() { return $this->comment; } }
Repository
大きく変更はありませんが、シリアライズを利用してレスポンスを返却するため、場合によってはtoArrayの様なメソッドが不要になります
<?php namespace App\Domain\Repository; use App\Domain\Entity\Article; use App\Domain\Entity\Comment; /** * Class ArticleRepository */ class ArticleRepository { /** * @return Article */ public function findOne() : Article { $article = new Article(1, 'testing'); $article->setComment(new Comment(1, 'comment 1')); $article->setComment(new Comment(2, 'comment 2')); return $article; } }
Controller
コントローラのレスポンスを少し変更します
先に紹介したResponseFactory利用例です
<?php namespace App\Http\Controllers; use App\Http\ResponseFactory; use App\Domain\Services\ArticleReader; use Symfony\Component\HttpFoundation\Response; /** * Class ArticleController */ class ArticleController extends Controller { /** * @param ArticleReader $reader * * @return Response */ public function invoke(ArticleReader $reader) : Response { return (new ResponseFactory)->hal($reader->readOne()); } }
Response
これを実行すると、次のレスポンスが返却されます。
{ "id": 1, "title": "testing", "_links": { "self": { "href": "http://localhost:8000/articles/1", "method": "GET" } }, "_embedded": { "comments": [ { "id": 1, "comment": "comment 1", "_links": { "self": { "href": "http://example.com/comments/1", "method": "GET" } } }, { "id": 2, "comment": "comment 2", "_links": { "self": { "href": "http://example.com/comments/2", "method": "GET" } } } ] } }
例えば、commentの削除などを追加することもできます。
下記の様にAnnotationを追加します。
<?php namespace App\Domain\Entity; use App\Http\Hateoas as HateoasResource; use JMS\Serializer\Annotation as Serializer; use Hateoas\Configuration\Annotation as Hateoas; /** * Class Comment * @Hateoas\Relation( * "self", * href = "expr('http://example.com/comments/' ~ object.getId())", * attributes = { "method" = "GET" }, * ) * @Hateoas\Relation( * "delete", * href = "expr('http://example.com/comments/' ~ object.getId())", * attributes = { "method" = "DELETE" }, * ) */ final class Comment implements HateoasResource { // 省略 }
{ "id": 1, "title": "testing", "_links": { "self": { "href": "http://localhost:8000/articles/1", "method": "GET" } }, "_embedded": { "comments": [ { "id": 1, "comment": "comment 1", "_links": { "self": { "href": "http://example.com/comments/1", "method": "GET" }, "delete": { "href": "http://example.com/comments/1", "method": "DELETE" } } }, { "id": 2, "comment": "comment 2", "_links": { "self": { "href": "http://example.com/comments/2", "method": "GET" }, "delete": { "href": "http://example.com/comments/2", "method": "DELETE" } } } ] } }
簡単な実装例を紹介しました。
ぜひ取り入れてみてください。
composer scripts
おまけですが、
LumenにはLaravelで用意されているビルトインサーバ起動の php artisan serve
はデフォルトでは含まれていませんが、
簡単に使いたい方はcomposer.jsonに次の様に追加すると簡単です。
"scripts": { "post-root-package-install": [ "php -r \"copy('.env.example', '.env');\"" ], "serve": "php -S 0.0.0.0:8000 -t ./public" },
composer serve
で実行できます。
artisan serveコマンドの中身と同じですので、わざわざコマンドを追加する必要はありませんよ!