POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

FeedlyRSSTwitterFacebook
Ilija Eftimov

本記事は、原著者の許諾のもとに翻訳・掲載しております。

概念としてとしての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と同様に頭字語であり、「 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

最後に、 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に関する議論に引き込んでくれて、その議論をどのようにしたらより濃厚にできるのかについて多くの有用な情報と共に提案してくれました。

監修者
監修者_古川陽介
古川陽介
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
複合機メーカー、ゲーム会社を経て、2016年に株式会社リクルートテクノロジーズ(現リクルート)入社。 現在はAPソリューショングループのマネジャーとしてアプリ基盤の改善や運用、各種開発支援ツールの開発、またテックリードとしてエンジニアチームの支援や育成までを担う。 2019年より株式会社ニジボックスを兼務し、室長としてエンジニア育成基盤の設計、技術指南も遂行。 Node.js 日本ユーザーグループの代表を務め、Node学園祭などを主宰。