BEAR.SundayでHALを使ったRESTfulなWeb APIを作成します。
1. アプリケーションスケルトンをインストール
アプリケーション名はPSRに準拠したMyVendor.MyApiとしてスケルトンをインストールします。
composer create-project bear/skeleton -n MyVendor.MyApi ~1.0@dev
cd MyVendor.MyApi
composer install
2. DBライブラリのインストール
Aura.Sqlモジュールをcomposerで取得します。
composer require ray/aura-sql-module ~1.0
3. DBデータを作成
DBデータを作成します。
mkdir var/db
sqlite3 var/db/post.sqlite3
sqlite> create table post(id integer primary key, title, body);
sqlite> create table comment(id integer primary key, post_id integer, body);
sqlite> .exit
4. DBモジュールのインストール
src/Module/AppModule.phpでAura.Sqlのモジュールをインストールします。
<?php
namespace MyVendor\MyApi\Module;
use BEAR\Package\PackageModule;
use Ray\Di\AbstractModule;
use Ray\AuraSqlModule\AuraSqlModule; // この行を追加
class AppModule extends AbstractModule
{
protected function configure()
{
$this->install(new PackageModule);
// この2行を追加
$dbConfig = 'sqlite:' . dirname(dirname(__DIR__)). '/var/db/post.sqlite3';
$this->install(new AuraSqlModule($dbConfig));
}
}
3.リソースの作成
Post(投稿)とComment(コメント)の2つリソースを作成します。
<?php
namespace MyVendor\MyApi\Resource\App;
use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\Resource\Annotation\Embed;
use BEAR\Resource\Annotation\Link;
use BEAR\Resource\Exception\ResourceNotFoundException;
use BEAR\Resource\ResourceObject;
use Ray\AuraSqlModule\AuraSqlInject;
/**
* @Cacheable
*/
class Post extends ResourceObject
{
use AuraSqlInject;
/**
* @Embed(rel="comment", src="app://self/comment?post_id={id}")
* @Link(rel="comment", href="app://self/comment?post_id={id}")
*/
public function onGet($id)
{
$sql = 'SELECT * FROM post WHERE id = :id';
$bind = ['id' => $id];
$post = $this->pdo->fetchOne($sql, $bind);
if (! $post) {
throw new ResourceNotFoundException;
}
$this->body += $post;
return $this;
}
public function onPost($title, $body)
{
$sql = 'INSERT INTO post (title, body) VALUES(:title, :body)';
$statement = $this->pdo->prepare($sql);
$bind = [
'title' => $title,
'body' => $body
];
$statement->execute($bind);
$id = $this->pdo->lastInsertId();
$this->code = 201;
$this->headers['Location'] = "/post?id={$id}";
return $this;
}
}
<?php
namespace MyVendor\MyApi\Resource\App;
use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\RepositoryModule\Annotation\Refresh;
use BEAR\Resource\ResourceObject;
use Ray\AuraSqlModule\AuraSqlInject;
/**
* @Cacheable
*/
class Comment extends ResourceObject
{
use AuraSqlInject;
public function onGet($post_id)
{
$sql = 'SELECT * FROM comment WHERE post_id = :post_id';
$bind = ['post_id' => $post_id];
$this->body = $this->pdo->fetchAll($sql, $bind);
return $this;
}
/**
* @Refresh(uri="app://self/post?id={post_id}")
*/
public function onPost($post_id, $body)
{
$sql = 'INSERT INTO comment (post_id, body) VALUES(:post_id, :body)';
$statement = $this->pdo->prepare($sql);
$bind = [
'post_id' => $post_id,
'body' => $body
];
$statement->execute($bind);
$id = $this->pdo->lastInsertId();
$this->code = 201;
$this->headers['Location'] = "/comment?id={$id}";
return $this;
}
}
4.完成
これで実行準備は完了しました。
まずは記事リソースでどのメソッドが利用可能かコンソールでOPTIONSしてみましょう。
php bootstrap/api.php options 'app://self/post'
200 OK
allow: get, post
次に新しい記事をPOSTします。
php bootstrap/api.php post 'app://self/post?title=greeting&body=hello'
201 Created
Location: /post/?id=1
content-type: application/hal+json
{
"_links": {
"self": {
"href": "/post?title=greeting&body=hello"
}
}
}
レスポンスコード201でリソースが作成されたことが確認できます。
作成されたURIはLocationヘッダーで/post/?id=1と分かります。
もし以下のようなエラーが出たらエラーのログvar/log/app.cli-hal-api.logを見てみましょう。
500 Internal Server Error
content-type: application/vnd.error+json
{"message":"500 Server Error"}
次に作成された投稿リソースをGETしてみましょう。
php bootstrap/api.php get "app://self/post?id=1"
200 OK
content-type: application/hal+json
Etag: 3940718867
Last-Modified: Fri, 15 May 2015 03:07:42 GMT
{
"id": "1",
"title": "greeting",
"body": "hello",
"_embedded": {
"comment": {
"_links": {
"self": {
"href": "/comment?post_id=1"
}
}
}
},
"_links": {
"self": {
"href": "/post?id=1"
},
"comment": {
"href": "app://self/comment?post_id=1"
}
}
}
投稿したtitle、body、インデックスのidの他に_embeddedや_linksといったリソースのメタ情報が埋め込まれています。これはHALの仕様です。_embeddedを透過的に扱うクライアントを用意するとHTTPのリクエストをまとめることができます。
まだコメントが投稿されていないので、投稿リソースのは空です。コメントを投稿してみましょう。コメントのURIは_linksを利用します。1つの記事に複数のコメントを投稿してみましょう。
php bootstrap/api.php post 'app://self/comment?post_id=1&body=nice post !'
php bootstrap/api.php post 'app://self/comment?post_id=1&body=awesome post !'
201 Created
Location: /comment/?id=5
content-type: application/hal+json
...
投稿と同様にコードとLocationヘッダーに作成されたリソースURIが表されています。
投稿リソースをGETしてコメントが埋め込まれたのを確認します。
php bootstrap/api.php get 'app://self/post?id=1'
200 OK
content-type: application/hal+json
{
"id": "23",
"title": "greeting",
"body": "hello",
"_embedded": {
"comment": {
"0": {
"id": "1",
"post_id": "1",
"body": "nice post"
},
"1": {
"id": "2",
"post_id": "1",
"body": "awesome post"
},
"_links": {
"self": {
"href": "/comment?post_id=1"
}
}
}
},
"_links": {
"self": {
"href": "/post?id=1"
},
"comment": {
"href": "app://self/comment?post_id=1"
}
}
}
HALの埋め込みリソースを利用すると複数のリソースを効率よく扱えます。
APIサーバー
次にコンソールアプリケーションではなく、HTTPアプリケーションで動作を確認します。
コンテキスト
Webサービスをprodのhalでサービスするためにbootファイル var/www/index.php のコンテキストを変更します。
<?php
$context = 'prod-hal-api-app'; // プロダクション用のHAL APIアプリケーション
require dirname(dirname(__DIR__)) . '/bootstrap/bootstrap.php';
304 (Not Modified)
HttpCacheをスクリプトで使うためにAppクラスでHttpCacheInjectのtraitを使ってHttpCacheをインジェクトします。
<?php
namespace MyVendor\MyApi\Module;
use BEAR\QueryRepository\HttpCacheInject; // この行を追加
use BEAR\Sunday\Extension\Application\AbstractApp;
use Ray\Di\Di\Inject;
class App extends AbstractApp
{
use HttpCacheInject; // この行を追加
}
bootstrap/bootstrap.phpのroute:のセクションを変更してif文を追加して、コンテンツに変更がないときは304を返すようにします。
...
route: {
/* @var $app App */
$app = (new Bootstrap)->getApp(__NAMESPACE__, $context);
if ($app->httpCache->isNotModified($_SERVER)) { //このif文を追加
http_response_code(304);
exit(0);
}
$request = $app->router->match($GLOBALS, $_SERVER);
}
スクリプトの準備は完了しました。
PHPサーバーを立ち上げます。
php -S 127.0.0.1:8080 var/www/index.php
curlでコンソールと同じように操作してみましょう。まずは同じようにOPTIONSから始めます。
curl -i 'http://127.0.0.1:8080/post' -X OPTIONS
HTTP/1.1 200 OK
Host: 127.0.0.1:8080
Connection: close
X-Powered-By: PHP/5.6.8
allow: get, post
Content-type: text/html; charset=UTF-8
同じようにallow: get, postが確認できます。
次はGETです。
curl -i 'http://127.0.0.1:8080/post?id=1'
HTTP/1.1 200 OK
Host: 127.0.0.1:8080
Connection: close
X-Powered-By: PHP/5.6.8
content-type: application/hal+json
Etag: 2793553754
Last-Modified: Fri, 15 May 2015 01:25:12 GMT
...
このリソースは@CacheableなのでGETを繰り返してもLast-Modifiedに変更がありません。確かめてみましょう。PostのonGetでerror_logなどを記述しても実行されないことを確認しましょう。キャッシュされています。
次にGETリクエストでEtagヘッダーに与えられたETagを使てリクエストを行います。(1234567890の部分は表示されたEtagの値に変更します)
curl -i 'http://127.0.0.1:8080/post?id=1' --header 'If-None-Match: 1234567890'
HTTP/1.1 304 Not Modified
Host: 127.0.0.1:8080
Connection: close
X-Powered-By: PHP/5.6.8
Content-type: text/html; charset=UTF-8
コンテンツに変更がないので304のレスポンスが返っています。記事リソースに変更がない限りこのレスポンスは変わりません。
次に新しいコメントをPOSTして同じ記事にコメントを追加します。
curl -i http://127.0.0.1:8080/comment -X POST -d'post_id=1&body=marvelous post !'
HTTP/1.1 201 Created
Host: 127.0.0.1:8080
Connection: close
X-Powered-By: PHP/5.6.8
Location: /comment/?id=51
Content-type: text/html; charset=UTF-8
...
元の記事リソースをGETしますがコメントリソースは@Refreshで記事のリフレッシュを要求したので、有効だったETagが無効になってるはずです。試してみましょう。
curl -i 'http://127.0.0.1:8080/post?id=1' --header 'If-None-Match: 1234567890'
HTTP/1.1 200 OK
Host: 127.0.0.1:8080
Connection: close
X-Powered-By: PHP/5.6.8
content-type: application/hal+json
ETag: 3895878753
Last-Modified: Sun, 17 May 2015 02:30:36 GMT
304ではなく、レスポンスコード200で新しいETagがレスポンスされました。コンテンツが更新されたので古いETagが無効になっています。
クライントは新しいETagを使ってリクエストすることができます。
curl -i 'http://127.0.0.1:8080/post?id=1' --header 'If-None-Match: {new etag}'
HTTP/1.1 304 Not Modified
....
BEAR.SundayのEtagは単にコンテンツのハッシュを返してネットワークの転送量を減らしているだけではありません。ハイパーリンクされたコンテンツはキャッシュ管理され次回更新までメソッドが実行されることがありません。
Truly* RESTful
いかがだったでしょうか?
このAPIはRESTを単なるHTTPのCRUDシステムとしてとらえず、ハイパーメディア制約を使い、HTTPをアプリケーションプロトコルとしたRESTful APIです。
- 適切なレスポンスコードを返します。(
200,201,304,403,404,500) - レスポンスにはにはエンティティタグ
ETagがありIf-None-Match:リクエストに対応しています。 - 最終更新日付が
Last-Modifiedで表現されます。
RFC2616: HTTP/1.1 servers SHOULD send Last-Modified whenever feasible.
- ハイパーメディア
application/hal+jsonを使用しています。(application/jsonはハイパーメディアではありません) - 作成されたリソースURIを
Locationヘッダーで伝えます。 - リソースの関係性を
REL属性で持ちハイパーリンクでリンクしています。 -
HALの__embedを使って他のリソースを自身のリソースに埋め込んでいます。 - エラーは
HALと互換性のあるapplication/vnd.error+jsonメディアタイプでレスポンスを返します。 - キャッシュコントロールをサーバーサイドで行っています(リソースの自己記述性)
- 起点となるAPIの以外の
URIはサーバーから受け取り、クライアントでURIを組み立てません。 - リソースの階層がレイヤーになっています。
BEAR.SundayはRESTシステムのフレームワークを提供しています。