概念としてとしての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と同様に頭字語であり、「Hypermedia As The Engine Of Application State」に由来しています。うーん、なんと長く発音しにくいことでしょう?
ここまで言及してきたように、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に関する議論に引き込んでくれて、その議論をどのようにしたらより濃厚にできるのかについて多くの有用な情報と共に提案してくれました。