LoginSignup
23
22

More than 5 years have passed since last update.

BEAR.SundayでRESTful Web API

Last updated at Posted at 2015-05-19

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.phpAura.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つリソースを作成します。

src/Resource/App/Post.php
<?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;
    }
}

src/Resource/App/Comment.php
<?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"
        }
    }
}

投稿したtitlebody、インデックスの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サービスをprodhalでサービスするために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をインジェクトします。

src/Module/App.php
<?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.phproute:のセクションを変更してif文を追加して、コンテンツに変更がないときは304を返すようにします。

bootstrap/bootstrap.php
...
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に変更がありません。確かめてみましょう。PostonGeterror_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システムのフレームワークを提供しています。

23
22
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
22