Lumenで実装するAPI REST拡張HATEOAS

API実装してますか?

弊社では最近APIにLumen(PHP), Zend Expressive(PHP), echo(Go)が利用されています。
そのなかでも徐々にAPIを表題のHATEOASへとシフトしつつあります。

HATEOASに関しては下記を参照ください postd.cc

そのHATEOASですが、Lumenを使って実装する簡単な例を紹介します。

記事を返却するAPI

jsonで返却する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を利用して、簡単に実装することができます。

github.com

コマンドなどで追加します

$ 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コマンドの中身と同じですので、わざわざコマンドを追加する必要はありませんよ!