2016年7月29日
RailsのAPIにHATEOASを散りばめてみる : RESTの拡張、HATEOASの詳解と実装例
本記事は、原著者の許諾のもとに翻訳・掲載しております。
概念としてとしてのRESTは、 Roy Fielding が博士論文「 Architectural Styles and the Design of Network-based Software Architectures 」で導入したものです。その16年後、アーキテクチャとしてのRESTは、APIを設計・構築するための最も広く受け入れられた方法となっています。RESTについては私たちはみんな聞いたことがありますし、自分たちが実際にRESTfulなAPIを構築しているとほぼ皆が思っています。しかし、それは本当でしょうか?
「RESTとは何か?」ということを自分たちにもう一度思い出させたうえで、さらにRESTを補う別の方法、「HATEOAS」と呼ばれるものの話に続けていきましょう。
RESTとは何か?をもう一度
私はこれを説明するための良い方法について考えていたのですが、Ryan Tomakoによる「 How I Explained REST to My Wife (私がどのようにしてRESTを妻に説明したか: 和訳あり )」という珠玉の記事があり、これは私が過去に見た中で最高のRESTの説明でした。
ざっくり言えば、REST( REpresentational State Transfer の頭字語)とは、インターネットをその上に構築するためのアーキテクチャです。私の意見では、RESTを最も簡単に説明する方法はRichardson Maturityモデル(RMM)です。ここでは詳細には立ち入りませんが、もしRMMについてより詳しく知りたければ、Martin Fowlerによる「 Richardson Maturity Model, steps toward the glory of REST 」の記事を読むことをお勧めします。
RMMはRESTを4つのレベルに分けて説明します。
- リモートプロシージャコール(Remote Procedure Call)を行うためのプロトコルの使用
- リソースの導入
- HTTPの動詞
- ハイパーメディア・コントロール
これらのレベル全てを、簡単に分析していきましょう。
レベル1
レベル1は、「どこか離れた場所、つまりリモートな場所(サーバ)でプロシージャコールを実行するためのプロトコル(HTTP)を使う必要がある」ということを示します。また、「そのプロトコルが提供するメカニズム自体は一切使わないが、あくまでリクエストやレスポンスを送るためのトンネルとして利用する」ということも意味しています。
レベル2
レベル2は私たち皆にとって大変なじみ深いものではないでしょうか。それはリソースです。過去に遡って、おそらく私が高校生だったころ、RPCはサーバの単一のエンドポイント上でのコミュニケーションで動作していました。これは「サービス・エンドポイント」と呼ばれます。
APIにリソースを実装することで、リソースを表現する複数のエンドポイントを提供することができます。これは、「複数の目的を持つ単一エンドポイントを持つのではなく、APIが提供する各リソースについてエンドポイントを持つ」ということを意味します。
レベル3
レベル1とレベル2では、プロトコルのメカニズムは省略していました。何故かというと、ここではプロトコルを完全に活用する代わりに、呼び出しをトンネルすることだけを考えているからです。レベル3はこれを次のレベルに押し上げるもので(ダジャレのつもりです)、「あらゆるRESTfulなサービスは、クライアントがリソースとやりとりできるよう、HTTPの動詞を使う必要がある」ということを示しています。
もしあなたがRailsの世界から来たのであれば、これについて既に知っているのではないかと思います。GET、POST、PUT、PATCH、DELETEの間には大きな違いがある、といった事ですね。もちろん、これらをバックエンドで違ったやり方で処理することができますし、これこそがRailsの輝く領域です。RailsはRESTに従うことをあなたに強く求めるのです。
もしRMMのレベル3に従わない場合、API側でアクションごとに異なるエンドポイントを持つことになります。例えば、 Pet
リソースがあるとして、新しいエントリを作成するには /pets/create
を、更新するには /pets/update
を持つ、といった具合です。レベル3を使い、HTTP動詞を作用させ始めると、Railsによってとても簡易化された物を使うことができます。 Pet
を得るために GET /pets/:id
を、新しく生成するために POST /pets
を、更新するために PATCH /pets
を使う、といったものです。
レベル4
さて、RailsはRESTfulなAPIを書ける ようになる ための大きな助力をしてくれましたが、レベル4はあなたに降りかかってくる、実装すべきものとなります。これは、実はほとんど皆が従っておらず、またコミュニティとしても未だにその最善の達成手段が見つかっていないものです。
ハイパーメディア・コントロールは、RESTの「聖杯」であり、「RESTfulであること」の究極のレベルです。「APIがレベル4に達しているとき、クライアントは特定のデータフォーマットでリソースを要求でき、ハイパーメディア・コントロールを用いてAPIじゅうをナビゲートできる」と述べられています。
これが分かりにくければ、1歩戻ってブラウザとWebサイトで考えてみましょう。私たちがWebサイトを使うとき、普通は domainname.tld
という形、( ieftimov.com
や google.com
など)のエントリーポイントのみを知っています。そこから、ブラウザを通じてWebサイトとインタラクションすることにより、サイト内の異なるページへのナビゲーションができるようになります。ナビゲーションはリンクのクリックにより行われますね?「リンクがなく、サイト上のあらゆるURLを暗記して手動でタイピングしなければならない」というWebサイトを想像してみれば、こんなひどいサイトは他にない、と思うでしょう。
これを念頭に置くと、ハイパーメディア・コントロールのアイデアは「リソースがリンクを提供することにより、リソースを利用するクライアントが、エントリポイント以外のエンドポイントの知識なしに自在にナビゲーション/インタラクションできるようにする」というものになります。通常のWebサイトにおいてはページが提供するリンクを通じてやり取りができますが、それと同様のものです。
HATEOASなAPIが返すJSONレスポンスのフォーマットに少し話を進めていきますが、まずはHATEOASの重要なメカニズムである「コンテントネゴシエーション」について見ていきます。
コンテントネゴシエーション
コンテントネゴシエーションはHTTPに埋め込まれたメカニズムであり、これによりWebサービスは異なるバージョン(あるいはフォーマット)の文書を提供することができます。RESTの用語でいえば、文書とはつまりリソースです。これはHTTPヘッダの Accept
グループによって得られます。例えば、Webページをリクエストする際、ユーザエージェント(ブラウザ)は以下の(あるいは似たような)ヘッダを送信します。内容はブラウザによって変わります。
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch, br
Accept-Language: en-US,en;q=0.8
これは、サーバに対して「ユーザエージェントは文書を text/html
で受け取ることができ、また文書は指定された圧縮スキームで圧縮されていてもよい。フォーマットは en-US
(イギリス英語)」ということを伝えます。
APIに戻ると、Railsはコンテントネゴシエーションについても大変よい働きをしてくれます。現在アプリを構築する場合、リソースはふつう HTML
か JSON
、あるいは XML
のいずれかを持つものと思われます。以下のようなコードを見たことがあるのではないでしょうか。
respond_to :html, :json, :xml
def index
@pets = Pet.all
respond_with(@pets)
end
あるいは、古いバージョンだとこうなります。
def index
@pets = Pet.all
respond_to do |format|
format.html
format.json { render :json => @pets }
format.xml { render :xml => @pets }
end
end
これにより、 index
アクションに対し、コンテントネゴシエーションを通じて3タイプのフォーマットでのリクエストを許容するよう命令することができます。 HTML
、 JSON
と XML
は実際のメディア(またはMIME)タイプです。
例示したヘッダを見て、 q
というパラメータに気付いたかもしれません。これは Quality Value と呼ばれ、あるコンテントタイプを他のコンテントタイプよりも重要にする、というものです。言い換えれば、 q
パラメータは、紐付られたコンテントタイプについて、その「望ましさ」に相対的な 重み付け を与えます。
例えば、以下のような Accept
ヘッダがあるとします。
Accept: application/json; q=0.5, application/xml; q=0.001
このヘッダは、サーバに対して「クライアントは、XMLよりも断然JSONを望んでいる」ということを伝えます。 q
の値が指定されていなければ、その値はデフォルトで1となります。コンテントネゴシエーションについては、 RFC 7231 でより詳しい説明を読むことができます。
HATEOAS
それでは、HATEOASとは何でしょう?これはRESTと同様に頭字語であり、「 H ypermedia A s T he E ngine O f A pplication S tate」に由来しています。うーん、なんと長く発音しにくいことでしょう?
ここまで言及してきたように、RMMのレベル4は「APIはナビゲーション/インタラクションのためのハイパーメディア・コントロールを提供すべきである」ということを述べています。しかし、HATEOASはこれとどう関係するのでしょう?RESTは「表現可能な状態の転送(Representational State Transfer)」を意味するのに対し、HATEOASは「アプリケーション状態のエンジンとしてのハイパーメディア(Hypermedia As The Engine Of Application State)」を意味する、ということについて考えてみましょう。名前だけに着目すると、HATEOASはRESTのメカニズムの一つだと考えられます。アプリケーションの状態と状態遷移に関するすべてを解決してくれるものです。
複雑すぎるでしょうか?リソースの普通のJSON表現を例として見てみましょう。どこかのサーバでの、私のユーザ情報を例にしてみます。
curl http://awesomeapi.com/users/1
{
"user": {
"first_name": "Ilija",
"last_name": "Eftimov",
"age": 25
}
}
実にシンプルですね。それでは、このリソースの表現にHATEOASを適用すると、このようになります。
{
"user": {
"first_name": "Ilija",
"last_name": "Eftimov",
"age": 25,
"links": [
{
"rel": "self",
"href": "http://awesomeapi.com/users/1"
},
{
"rel": "roles",
"href": "http://awesomeapi.com/users/1/roles"
}
]
}
}
links
を提供することで、アプリケーション状態(API側でのデータ変更)のエンジン(利用可能なアクションのリスト)としてのハイパーメディア(この場合JSON)をクライアントが使うことができます。ここでのアイデアは、「一つのAPIまたはリソースに対してはエントリポイントは単一であるべきで、リソースの表現はそのリソース上で実行できるすべてのアクションを含むべきである」というものです。
これは、「APIを利用するクライアントは User
リソースの self
属性を使うことができ、この属性をアクションを実行できるエンドポイントとして考えることができる」ということを意味します。APIがRESTfulであることを知ることで、クライアントはそのリソースを更新する方法がわかるのです。
curl -X "PUT" -d "{ 'age': 26 }" http://awesomeapi.com/users/1
また、削除する方法もわかります。
curl -X "DELETE" http://awesomeapi.com/users/1
さらに、このユーザのロール全てを見たい場合、ユーザにとって roles
という関係にあるリソースに対して GET
リクエストを実行することもできます。
curl http://awesomeapi.com/users/1/roles
見てわかるとおり、HATEOASを組み合わせることで、クライアントは「リソースからURIを得る」「APIがレベル3のRESTfulであることを仮定する(=HTTP動詞を利用する)」だけでリソースとやりとりできます。
HATEOAS シリアライザ
自分のRailsのAPIをRMMのレベル4実装にするにはどうすればよいでしょう?例を見てみましょう。
ここでのAPIはブログCMSの一部とします。 Author
モデルがあり、このモデルは Article
モデルに対して1対多の関係があります。つまり、 Author
が複数の Article
を持つことができます。
Author
モデルは以下のようになり、
# == Schema Information
#
# Table name: authors
#
# id :integer not null, primary key
# first_name :string
# last_name :string
# created_at :datetime not null
# updated_at :datetime not null
#
class Author < ApplicationRecord
has_many :articles
end
Article
モデルは以下のようになります。
# == Schema Information
#
# Table name: articles
#
# id :integer not null, primary key
# author_id :integer
# title :string
# body :text
# created_at :datetime not null
# updated_at :datetime not null
#
class Article < ApplicationRecord
belongs_to :author
end
ルーティングは次のようになります。
Rails.application.routes.draw do
resources :authors do
resources :articles
end
end
最後に、 Article
と Author
の両モデルについてシリアライザがあります。この例では、 Active Model Serializers を使います。
Authorシリアライザ
Author
オブジェクトは全て AuthorSerializer
によってシリアライズされます。まずはシリアライザに必要な属性を加え、その拡張について後程解説していきましょう。
AuthorSerializer
クラスは以下のようになります。
# == Schema Information
#
# Table name: authors
#
# id :integer not null, primary key
# first_name :string
# last_name :string
# created_at :datetime not null
# updated_at :datetime not null
#
class AuthorSerializer < ActiveModel::Serializer
attributes :id, :first_name, :last_name, :created_at, :updated_at
has_many :articles
type :author
end
とてもシンプルですね!この短い例でも、データのコンテキストを見えたままにしておくために、アノテーションをそのまま残しておきます。私の普段の仕事では、データの構造を忘れてしまった場合、アノテーションの追加ではなくスキーマファイルの確認によってそれを調べています。
GET /authors
リクエストを送って、データがどのようになるか見てみましょう。リファレンスのため、 AuthorsController
のindexアクションの実装にしておきます。
class AuthorsController < ApplicationController
def index
render json: Author.all
end
end
curl http://localhost:3000/authors
このリクエストは、以下のようなJSONを返します。
[
{
"id": 1,
"first_name": "ilija",
"last_name": "eftimov",
"created_at": "2016-06-25T20:52:12.804Z",
"updated_at": "2016-06-25T20:52:12.804Z",
"articles": [
{
"id": 1,
"title": "Lorem ipsum",
"body": "Lorem ipsum dolor sit amet",
"created_at": "2016-06-25T22:25:51.874Z",
"updated_at": "2016-06-25T22:25:51.874Z"
},
{
"id": 2,
"title": "A princess of Mars",
"body": "His reference to the great games of which I had heard so much while among the Tharks convinced me that I had but jumped from purgatory into gehenna. After a few more words with the female, during which she assured him that I was now fully fit to travel, the jed ordered that we mount and ride after the main column. I was strapped securely to as wild and unmanageable a thoat as I had",
"created_at": "2016-06-26T00:20:09.388Z",
"updated_at": "2016-06-26T00:20:09.388Z"
}
]
}
]
データベースに Author
インスタンスが1つしかないため、その1つのみが帰ってきています。また、この Author
は関連する2つの Article
インスタンスを持つため、シリアライザはこれをレスポンスに含めています。
シリアライザにリンクを追加
さて、シリアライザが動くようになりました。次のステップは、これを links
で拡張することです。これはActive Model Serializersを使えばとても些細なことです。 links
属性をシリアライザに加え、属性の実装(実際はこれはメソッドになります)をクラス内に提供することが必要になります。
# == Schema Information
#
# Table name: authors
#
# id :integer not null, primary key
# first_name :string
# last_name :string
# created_at :datetime not null
# updated_at :datetime not null
#
class AuthorSerializer < BaseSerializer
attributes :id, :first_name, :last_name, :created_at, :updated_at, :links
has_many :articles
type :author
def links
[
{
rel: :self,
href: author_path(object)
}
]
end
end
見ての通り、実装は極めてシンプルです。 links
という新しい属性を追加し、メソッドがただハッシュの配列を返します。初心者向けに、ここでは self
リンクだけを持たせました。この self
リンクは、いまその表現を見ているリソースを指しています。
しかし、ここでcurlリクエストを再び行うと、以下のようなエラーが返ってきます。
#<NoMethodError: undefined method `author_path' for #<AuthorSerializer:0x007fe251b4c450>>
Railsのルーティングのヘルパーがシリアライザのスコープに含まれていないために起こっています。シリアライザにこれを含めることで容易に解決できます。
class AuthorSerializer < ActiveModel::Serializer
include Rails.application.routes.url_helpers
# *snip*
end
ここで再びcurlのリクエストを実行すると、以下のように、JSONの中にlinkを見つけられます。
[
{
"id": 1,
"first_name": "ilija",
"last_name": "eftimov",
"created_at": "2016-06-25T20:52:12.804Z",
"updated_at": "2016-06-25T20:52:12.804Z",
"links": [
{
"rel": "self",
"href": "/authors/1"
}
],
"articles": [
{
"id": 1,
"title": "Lorem ipsum",
"body": "Lorem ipsum dolor sit amet",
"created_at": "2016-06-25T22:25:51.874Z",
"updated_at": "2016-06-25T22:25:51.874Z"
},
{
"id": 2,
"title": "A princess of Mars",
"body": "His reference to the great games of which I had heard so much while among the Tharks convinced me that I had but jumped from purgatory into gehenna. After a few more words with the female, during which she assured him that I was now fully fit to travel, the jed ordered that we mount and ride after the main column. I was strapped securely to as wild and unmanageable a thoat as I had",
"created_at": "2016-06-26T00:20:09.388Z",
"updated_at": "2016-06-26T00:20:09.388Z"
}
]
}
]
素晴らしい!リソースに 少しばかり HATEOASを加えることができました。
仕様
さらに多くのハイパーメディアをリソースに加え続ける前に、いくらかの考察を加える必要があります。見てわかるとおり、何も考えずに links
属性をシリアライザに実装しましたね。「私はAPIの製作者で、APIのレスポンスは私が思うままに構築する」とおっしゃることでしょう。
ええ、確かにあなたはAPIの作者ですが、その宣言は正しくありません。「我々がみんな、ハイパーメディアの実装方法を個別に考えたらどうなるか」と想像してみるとどうでしょう?全てのAPIがそれぞれハイパーメディア・コントロールに関して異なってしまい、進むべき道を探しても地獄のようになってしまうでしょう。
そうです、悲しいことに、私たちはまだHATEOASをどう行うかを知らないのです。より良い言い方をするならば、私たちはまだHATEOASを行うための 最善の方法 を知らないのです。それゆえに、人々は仕様を策定しようとしてきましたし、そのうちいくらかは採用されてきました。
HAL
HAL のドキュメントには、以下のように書かれています。
HALは、あなたのAPIのリソース間にハイパーリンクを加えるための簡単かつ一貫した方法を提供する、シンプルなフォーマットです。
見てわかるとおり、HALの背後にあるモチベーションは、ハイパーメディア・コントロールを追加するための簡単かつ一貫した方法を作ることです。他の仕様と同様に、HALはJSONとXMLの両方についてハイパーリンクの表現のための規約を提供しています。
以下はHALを適用したJSONの例です。
{
"_links": {
"self": { "href": "/orders" },
"curies": [{ "name": "ea", "href": "http://example.com/docs/rels/{rel}", "templated": true }],
"next": { "href": "/orders?page=2" },
"ea:find": {
"href": "/orders{?id}",
"templated": true
},
"ea:admin": [{
"href": "/admins/2",
"title": "Fred"
}, {
"href": "/admins/5",
"title": "Kate"
}]
},
"currentlyProcessing": 14,
"shippedToday": 20,
"_embedded": {
"ea:order": [{
"_links": {
"self": { "href": "/orders/123" },
"ea:basket": { "href": "/baskets/98712" },
"ea:customer": { "href": "/customers/7809" }
},
"total": 30.00,
"currency": "USD",
"status": "shipped"
}, {
"_links": {
"self": { "href": "/orders/124" },
"ea:basket": { "href": "/baskets/97213" },
"ea:customer": { "href": "/customers/12369" }
},
"total": 20.00,
"currency": "USD",
"status": "processing"
}]
}
}
JSON API
JSON APIもHALと同様のものです。
「クライアントがどのようにリソースのフェッチ/修正をリクエストをすべきか」「サーバはそのリクエストにどう応答すべきか」のための仕様
2013年にYehuda Katzによって初めて草案が作られました。この最初の草案はEmber DataのRESTアダプタによって暗黙的に定義されたJSON転送からとられています。そこから、JSON APIは多少の指示を得たものの、API仕様のデファクトスタンダードだと確信を持って言うことはできません。
JSON API仕様を適用したJSONの例が以下になります。
{
"data": [{
"type": "articles",
"id": "1",
"attributes": {
"title": "JSON API paints my bikeshed!",
"body": "The shortest article. Ever.",
"created": "2015-05-22T14:56:29.000Z",
"updated": "2015-05-22T14:56:28.000Z"
},
"relationships": {
"author": {
"data": {"id": "42", "type": "people"}
}
}
}],
"included": [
{
"type": "people",
"id": "42",
"attributes": {
"name": "John",
"age": 80,
"gender": "male"
}
}
]
}
他の標準
ご想像の通り、人々がよいJSONの標準を作ろうとしてきた試みは実に多いです。より有名な仕様には JSON for Linking Data (JSON-LDとも)、 Collection+JSON 、 SIREN があります。
JSON APIとActive Model Serializers
JSON APIが勢いをつけてきており、Active Model Serializersがそのとても良いインテグレーションであるため、これに照準を定めて実装を続けていきます。高水準でのActive Model Serializersの動作は、シリアライザとアダプタの2つのパートに分かれます。ドキュメンテーションには以下のように書かれています。
シリアライザは、どの属性/関係がシリアライズされるべきかを説明する。アダプタは、属性/関係がどのようにシリアライズされるべきかを説明する。
私たちのJSONをJSON APIフォーマットにシリアライズするため、異なるアダプタを使う必要があります。信じるかどうかはお任せしますが、これは :json_api
と呼ばれます。個人的には、このタイプのコンフィギュレーションはinitializerファイル内でするのが好きです。
# config/initializers/active_model_serializers.rb
ActiveModelSerializers.config.adapter = :json_api
アダプタを :json_api
に設定することで、APIがJSON API仕様でフォーマットされたJSONを生成/利用するようになります。先ほど行ったAPIの呼び出しと同じものを実行すると、新たなフォーマットのJSONを得ることができます。
{
"data": [
{
"id": "1",
"type": "author",
"attributes": {
"first-name": "ilija",
"last-name": "eftimov",
"created-at": "2016-06-25T20:52:12.804Z",
"updated-at": "2016-06-25T20:52:12.804Z"
},
"relationships": {
"articles": {
"data": [
{
"id": "1",
"type": "article"
},
{
"id": "2",
"type": "article"
}
]
}
},
"links": {
"self": {
"href": "/authors/1"
}
}
}
]
}
お気づきの通り、ここで返ってきたJSONは先ほど得られたJSONと比較すると全く異なる構造になっています。この理由は、使っているJSON APIアダプタがJSONをJSON API仕様にフォーマットしているからです。
JSON APIアダプタに関してもう一つ素晴らしい点は、素晴らしいDSLを使ってリンクをとても明示的に書けることです。
class AuthorSerializer < ActiveModel::Serializer
attributes :id, :first_name, :last_name, :created_at, :updated_at
has_many :articles
type :author
link :self do
href author_path(object)
end
end
また、 links
を属性として指定する必要がなく、先ほど書いたシリアライザでRailsのurlヘルパーをミックスインしていた include
の行を消すことができました。 /authors
エンドポイントを GET
リクエストで再び呼んでみると、拡張されたJSONを見ることができ、これもまたJSON APIの仕様に従っています。
{
"data": [
{
"id": "1",
"type": "author",
"attributes": {
"first-name": "ilija",
"last-name": "eftimov",
"created-at": "2016-06-25T20:52:12.804Z",
"updated-at": "2016-06-25T20:52:12.804Z"
},
"relationships": {
"articles": {
"data": [
{
"id": "1",
"type": "article"
},
{
"id": "2",
"type": "article"
}
]
}
},
"links": {
"self": {
"href": "/authors/1"
}
}
}
]
}
更なるリソースを含める
AMSはとても素晴らしく、controllerに追加のリソースを含めることでJSONにもそのリソースを加えることができます。
class AuthorsController < ApplicationController
def index
render json: Author.all, include: 'articles'
end
end
Article
の関係性を Author
モデルに含めることで、JSONの結果を各ユーザの関連記事で拡張することができます。
{
"data": [
{
"id": "1",
"type": "author",
"attributes": {
"first-name": "ilija",
"last-name": "eftimov",
"created-at": "2016-06-25T20:52:12.804Z",
"updated-at": "2016-06-25T20:52:12.804Z"
},
"relationships": {
"articles": {
"data": [
{
"id": "1",
"type": "article"
},
{
"id": "2",
"type": "article"
}
]
}
},
"links": {
"self": {
"href": "/authors/1"
}
}
}
],
"included": [
{
"id": "1",
"type": "article",
"attributes": {
"title": "Lorem ipsum",
"body": "Lorem ipsum dolor sit amet",
"created-at": "2016-06-25T22:25:51.874Z",
"updated-at": "2016-06-25T22:25:51.874Z"
},
"relationships": {
"author": {
"data": {
"id": "1",
"type": "author"
}
}
},
"links": {
"self": {
"href": "/authors/1/articles/1"
}
}
},
{
"id": "2",
"type": "article",
"attributes": {
"title": "A princess of Mars",
"body": "His reference to the great games of which I had heard so much while among the Tharks convinced me that I had but jumped from purgatory into gehenna. After a few more words with the female, during which she assured him that I was now fully fit to travel, the jed ordered that we mount and ride after the main column. I was strapped securely to as wild and unmanageable a thoat as I had",
"created-at": "2016-06-26T00:20:09.388Z",
"updated-at": "2016-06-26T00:20:09.388Z"
},
"relationships": {
"author": {
"data": {
"id": "1",
"type": "author"
}
}
},
"links": {
"self": {
"href": "/authors/1/articles/2"
}
}
}
]
}
これはとても素晴らしいですが、これを行うとフェッチするすべてのリソースについてN+1クエリが増えることに用心してください。「 show
アクションは追加のリソースを含む傾向があるのに対し、 index
アクションはふつう要求されたリソースしか返さない」ということの理由はこれです。
class AuthorsController < ApplicationController
def index
render json: Author.all
end
def show
author = Author.find(params[:id])
render json: author, include: :articles
end
end
これにより、リクエストしたauthorに関連するarticle全てが追加されます。
{
"data": {
"id": "1",
"type": "author",
"attributes": {
"first-name": "ilija",
"last-name": "eftimov",
"created-at": "2016-06-25T20:52:12.804Z",
"updated-at": "2016-06-25T20:52:12.804Z"
},
"relationships": {
"articles": {
"data": [
{
"id": "1",
"type": "article"
},
{
"id": "2",
"type": "article"
}
]
}
},
"links": {
"self": {
"href": "/authors/1"
}
}
},
"included": [
{
"id": "1",
"type": "article",
"attributes": {
"title": "Lorem ipsum",
"body": "Lorem ipsum dolor sit amet",
"created-at": "2016-06-25T22:25:51.874Z",
"updated-at": "2016-06-25T22:25:51.874Z"
},
"relationships": {
"author": {
"data": {
"id": "1",
"type": "author"
}
}
},
"links": {
"self": {
"href": "/authors/1/articles/1"
}
}
},
{
"id": "2",
"type": "article",
"attributes": {
"title": "A princess of Mars",
"body": "His reference to the great games of which I had heard so much while among the Tharks convinced me that I had but jumped from purgatory into gehenna. After a few more words with the female, during which she assured him that I was now fully fit to travel, the jed ordered that we mount and ride after the main column. I was strapped securely to as wild and unmanageable a thoat as I had",
"created-at": "2016-06-26T00:20:09.388Z",
"updated-at": "2016-06-26T00:20:09.388Z"
},
"relationships": {
"author": {
"data": {
"id": "1",
"type": "author"
}
}
},
"links": {
"self": {
"href": "/authors/1/articles/2"
}
}
}
]
}
お気づきの通り、 has_many
アソシエーションをシリアライザに追加したため、AMSは含まれる各リソースに使うべき正しいシリアライザをわかっています。これにより、含められるリソースとリクエストされるリソースの間の構造を同じにすることができ、結果に一貫性が生まれるとともにJSONのパースが容易になっています。
リソースのナビゲーション
ここで、リソース間のナビゲーションがどれほど容易かを実際に見てみるため、 ArticlesController#show
も実装してみましょう。
class ArticlesController < ApplicationController
def show
article = Article.find_by(author_id: params[:author_id], id: params[:id])
render json: article
end
end
とてもシンプルです。 Article
を author_id
と id
のパラメータによって見つけ、JSONにレンダリングします。 GET /authors/1/articles/2
を呼ぶことで、 Article
オブジェクトのJSON API表現を得ることができます。
{
"data": {
"id": "2",
"type": "article",
"attributes": {
"title": "A princess of Mars",
"body": "His reference to the great games of which I had heard so much while among the Tharks convinced me that I had but jumped from purgatory into gehenna. After a few more words with the female, during which she assured him that I was now fully fit to travel, the jed ordered that we mount and ride after the main column. I was strapped securely to as wild and unmanageable a thoat as I had",
"created-at": "2016-06-26T00:20:09.388Z",
"updated-at": "2016-06-26T00:20:09.388Z"
},
"relationships": {
"author": {
"data": {
"id": "1",
"type": "author"
}
}
},
"links": {
"self": {
"href": "/authors/1/articles/2"
}
}
}
}
もし Postman (私はこれを使うのをとても楽しんでいます)やその他のAPIブラウザをエンドポイントのテストに使っているのであれば、リソースのナビゲーションがどれほど容易かを見ることができるでしょう。
Webブラウザと同じように、分かっているのはエントリポイントの localhost:3000/authors
です。ここから、 Author
の links
プロパティをクリックして単一の Author
リソースを入手します。
このリソースを全ての関連リソースと共に見ることで、 Article
リソースに含まれるリンクのどれかをクリックすることができ、その属性をJSONフォーマットで得ることができます。
これこそが、HATEOASが基本的に意味するところです。リソースのJSON表現内で提供されるエンドポイントを通じてリソースとのナビゲーション/インタラクションを行うのです。
終わりに
ここまで見てきたとおり、HATEOASとは何かを理解・学習するためには、頭に入れておくべき文脈が少しばかりありました。しかし、その全ては時に極めて複雑に見えますが、RailsのおかげでHATEOASなAPIの実装は極めて簡単になっています。その簡単さは例で見てきたとおりです。もちろん、ハイパーメディア・コントロールについてはもっと学ぶべきことが色々ありますが、ここで示した例は「ちょっと体験してみる」というのには十分すぎるほどだったでしょう。
また、HATEOASの概念は一見大変複雑に見えますが、いざ分析してみるととてもシンプルなものです。なぜなら、私たちは同じ挙動をWebブラウザで既に見ているからです。「なぜとても複雑に見えるのか?」という問題ですが、これは「私たちが普通のWebサイトをRESTfulなAPIのように見たことがないから(逆もまた然り)」という理由です。
この記事で使ってきたエンドポイントの実際の実装を見たければ、GitHubの このリポジトリ をチェックしてみてください。
さらに読むなら
- Web Linking – RFC 5988
- Haters gonna HATEOAS
- Richardson Maturity Model, steps toward the glory of REST
- What is the Richardson Maturity Model?
- Media type
- Content negotiation
謝辞
Fotos Georgiadis は、この投稿の草稿に多くのコメントをしてくれました。また、彼は私をRESTやHATEOASに関する議論に引き込んでくれて、その議論をどのようにしたらより濃厚にできるのかについて多くの有用な情報と共に提案してくれました。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa