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システムのフレームワークを提供しています。