Nwht0xn1

WikiHub APIのページネーションの実装Created on 2016-05-11 by r7kamura

WikiHub APIがページネーションに対応しました - WikiHub Help でやった実装についての話。

Total-Count

ページネーションに対応しているエンドポイントでは、全ての要素を一度のレスポンスで返しきれないので、全体の要素の個数の情報だけでもレスポンスに含められるていると嬉しい。WikiHubではTotal-Countというレスポンスヘッダを用意し、このヘッダの値として整数を入れることにした。

この手のメタデータを含める方法として、レスポンスボディに含めるか、レスポンスヘッダに含めるかという選択肢があると思う。これまで幾つか小規模なサービスのREST APIをつくってきたけど、リソース自体ではなくHTTPリクエストに関わるメタデータの種類というのはあまり多くないし、種類が増えていくわけでもないので、レスポンスヘッダに含めるという方法でまだ致命的な問題が起きた経験はない。

それで、WikiHubではレスポンスヘッダを選択して、更に独自のレスポンスヘッダを用意するという選択をした。実装は簡単で、ActiveRecordとKaminariを利用しているので、.total_count というメソッドをRelationに対して呼び出すとSQLでCOUNTクエリが発行されて値が得られる。

次のページや前のページが存在するかどうか (とそれらにアクセスするためのURL) を表現するのには、RFC 5988 - Web Linking で提唱されている、HTTPレスポンスのLinkヘッダを使うことにした。これは別にページネーション用に使うものというわけではなくて、リソース間の関係性とURL1を表現するためのもので、ページネーションにも利用できるのでこれを利用したという経緯になる。

Linkヘッダについて知りたいなら、巷にあまり日本語情報がないが、前述した RFC 5988 - Web Linking だけを見ればよく、あとは関係性を記述するための識別子にどういう種類のものが定義されているかは Link Relations を見れば分かると思う。

Linkヘッダ以外でこの手の情報を表す他の手段として、例えばHerokuのAPIではRangeヘッダが利用されている。Herokuは自社のAPI Designについて interagent/http-api-design: HTTP API design guide extracted from work on the Heroku Platform API というGitHubのレポジトリを用意しているので、興味のある人はその辺りで調べられる。より具体的には Platform API Reference | Heroku Dev Center に説明されているのを見ると分かりやすい。

コード

ページネーションに関係のある部分だけ抜粋した。

module Api
  module V1
    class BaseController < ApplicationController
      def index
        response.headers["Link"] = link_header
        response.headers["Total-Count"] = paginated_relation_for_index_action.total_count.to_s
        render json: paginated_relation_for_index_action.map { |record| representation_class.new(record) }
      end

      private

      # @note Call this action only from #index action
      # @return [String]
      def link_header
        link_header_fields.join(", ")
      end

      # @return [Array<String>]
      def link_header_fields
        [
          link_header_first_field,
          link_header_previous_field,
          link_header_next_field,
          link_header_last_field,
        ].compact
      end

      # @return [String]
      def link_header_first_field
        %(<#{url_for(request.query_parameters.merge(only_path: false, page: 1))}>; rel="first")
      end

      # @return [String, nil]
      def link_header_previous_field
        unless paginated_relation_for_index_action.first_page?
          %(<#{url_for(request.query_parameters.merge(only_path: false, page: paginated_relation_for_index_action.prev_page))}>; rel="prev")
        end
      end

      # @return [String, nil]
      def link_header_next_field
        unless paginated_relation_for_index_action.last_page?
          %(<#{url_for(request.query_parameters.merge(only_path: false, page: paginated_relation_for_index_action.next_page))}>; rel="next")
        end
      end

      # @return [String]
      def link_header_last_field
        %(<#{url_for(request.query_parameters.merge(only_path: false, page: paginated_relation_for_index_action.total_pages))}>; rel="last")
      end
    end
  end
end

余談

完全に余談だけど、IANAのLink Relation Typesに登録されているものを見ると、previousもprevの同義語として使えることに実装してこの記事を書くときに後から気付いた。previousをprevに略すという判断があると、ではなぜ他の文字列は同様の文脈において略す判断をしないのか (例えばfirstはなぜfirstより略さないのか) という曖昧性やコード上に記述されていない何らかの関係性が発生するので、previousが使えるならpreviousを使いたい。


  1. 厳密にはIRI