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つのレベルに分けて説明します。

  1. リモートプロシージャコール(Remote Procedure Call)を行うためのプロトコルの使用
  2. リソースの導入
  3. HTTPの動詞
  4. ハイパーメディア・コントロール

これらのレベル全てを、簡単に分析していきましょう。

レベル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.comgoogle.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はコンテントネゴシエーションについても大変よい働きをしてくれます。現在アプリを構築する場合、リソースはふつうHTMLJSON、あるいは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タイプのフォーマットでのリクエストを許容するよう命令することができます。HTMLJSONXMLは実際のメディア(または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

最後に、ArticleAuthorの両モデルについてシリアライザがあります。この例では、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+JSONSIRENがあります。

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

とてもシンプルです。Articleauthor_ididのパラメータによって見つけ、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です。ここから、Authorlinksプロパティをクリックして単一のAuthorリソースを入手します。

このリソースを全ての関連リソースと共に見ることで、Articleリソースに含まれるリンクのどれかをクリックすることができ、その属性をJSONフォーマットで得ることができます。

これこそが、HATEOASが基本的に意味するところです。リソースのJSON表現内で提供されるエンドポイントを通じてリソースとのナビゲーション/インタラクションを行うのです。

終わりに

ここまで見てきたとおり、HATEOASとは何かを理解・学習するためには、頭に入れておくべき文脈が少しばかりありました。しかし、その全ては時に極めて複雑に見えますが、RailsのおかげでHATEOASなAPIの実装は極めて簡単になっています。その簡単さは例で見てきたとおりです。もちろん、ハイパーメディア・コントロールについてはもっと学ぶべきことが色々ありますが、ここで示した例は「ちょっと体験してみる」というのには十分すぎるほどだったでしょう。

また、HATEOASの概念は一見大変複雑に見えますが、いざ分析してみるととてもシンプルなものです。なぜなら、私たちは同じ挙動をWebブラウザで既に見ているからです。「なぜとても複雑に見えるのか?」という問題ですが、これは「私たちが普通のWebサイトをRESTfulなAPIのように見たことがないから(逆もまた然り)」という理由です。

この記事で使ってきたエンドポイントの実際の実装を見たければ、GitHubのこのリポジトリをチェックしてみてください。

さらに読むなら


謝辞

Fotos Georgiadisは、この投稿の草稿に多くのコメントをしてくれました。また、彼は私をRESTやHATEOASに関する議論に引き込んでくれて、その議論をどのようにしたらより濃厚にできるのかについて多くの有用な情報と共に提案してくれました。