Ruby
GraphQL
graphql-ruby

GraphQL、GraphQL-Rubyを使う上での困り事や便利な拡張などをご紹介します。

ActiveRecord::Relation以外のデータソースでConnectionを使う

GraphQL-RubyではActiveRecord::RelationをConnection型にresolveするとfirst, last, after, betoreからよしなにlimit, offsetをセットしてくれます
ただし他のデータソースから取得する場合、自前でConnectionを実装する必要があります。

例: Elasticsearchから取得する場合

elasticsearch_connection.rb
class ElasticsearchConnection < GraphQL::Relay::BaseConnection
  def cursor_from_node(item)
    item_index = paged_nodes_array.index(item)
    if item_index.nil?
      raise("Can't generate cursor, item not found in connection: #{item}")
    else
      offset = paged_nodes.from + item_index + 1
      encode(offset.to_s)
    end
  end

  def has_next_page
    if (es_total = sliced_nodes.search_result.dig("hits", "total")) &&
       sliced_nodes.from.present? && sliced_nodes.size.present?
      return (sliced_nodes.from + sliced_nodes.size) < es_total
    end

    false
  end

  def has_previous_page
    if (es_total = sliced_nodes.search_result.dig("hits", "total")) &&
       sliced_nodes.from.present? && sliced_nodes.size.present?
      from_cursor = sliced_nodes.from - sliced_nodes.size
      return from_cursor >= 0 && from_cursor <= es_total
    end

    false
  end

  private

  def first
    return @first if defined? @first

    @first = get_limited_arg(:first)
    @first = max_page_size if @first && max_page_size && @first > max_page_size
    @first
  end

  def last
    return @last if defined? @last

    @last = get_limited_arg(:last)
    @last = max_page_size if @last && max_page_size && @last > max_page_size
    @last
  end

  def paged_nodes
    return @paged_nodes if defined? @paged_nodes
    items = sliced_nodes

    if first
      items.size = first
    end

    if last
      if !items.size
        items.from = calc_offset(items.from, items.count, last)
      elsif last <= items.size
        items.from = calc_offset(items.from, items.size, last)
      end
      items.size = last
    end

    @paged_nodes = items
  end

  def calc_offset(from, size, last)
    (from || 0) + size - last
  end

  def sliced_nodes
    @sliced_nodes = nodes
    if first
      @sliced_nodes.size = first
    end
    if after
      after_i = offset_from_cursor(after)
      @sliced_nodes.from = after_i
    end
    if before && after
      after_i = offset_from_cursor(after)
      before_i = offset_from_cursor(before)
      @sliced_nodes.size = before_i - after_i - 1
    elsif before
      before_i = offset_from_cursor(before)
      @sliced_nodes.size = before_i - 1
    end
    @sliced_nodes
  end

  def offset_from_cursor(cursor)
    decode(cursor).to_i
  end

  def paged_nodes_array
    return @paged_nodes_array if defined?(@paged_nodes_array)
    @paged_nodes_array = paged_nodes.fetch
  end
end

このConnectionを特定のクラスのインスタンスをresolveした場合に作動させるよう登録します

config/initializers/graphql_connection.rb
GraphQL::Relay::BaseConnection.register_connection_implementation(ElasticsearchRepository, ElasticsearchConnection)

そしてElasticsearchRepositoryを実装します
簡易的に書きますが、以下のようになります

elasticsearch_repository.rb
class ElasticsearchRepository
  # ポイント1
  extend Forwardable
  def_delegators :fetch, *Enumerable.instance_methods
  # ポイント2
  attr_accessor :size, :from

  def fetch
    # ここに検索処理を実装します
  end

  def search_result
    @es_result
  end

  def hits
    @es_result.dig("hits", "total")
  end
end

ポイント1

GraphQL-rubyの内部でElasticsearchRepositoryのインスタンスに対しmapなどを経由してデータを取得するメソッドコールが発生するためfetchに流します
※ 1.8がでた当初に確認した事象なので現在は変更となっている場合があります

ポイント2

elasticsearch_connection.rbからsize, fromをセットできるようにしておきます
この2つをfetch内での検索クエリの構築に利用します

あとはElasticsearchRepositoryのインスタンスをresolveすることでConnectionに対応ができます。

エラーハンドリング

私のチームの場合、graphql-errorsを活用してmutation、queryのエラーハンドリングを一括して行っています。

GraphQL::Errors.configure(schema) do
  rescue_from SomeError do |_exception|
    raise ApiError.new(exception.message, "SomeError")
  end

  rescue_from ActiveRecord::RecordInvalid do |exception|
    raise ApiError.new(exception.message, "InvalidInput")
  end
end

同時にエラーにtypeフィールドを加えることでクライアントサイドでの特定を容易にしています。

class ApiError < GraphQL::ExecutionError
  attr_reader :type
  def initialize(message, type = nil)
    @type = type
    super(message)
  end

  def to_h
    super.merge("type" => type)
  end
end

graphql-voyagerの活用

GraphQLのIDE、ドキュメントとしてgraphiqlがよく買うつようされているように思いますが、型同士のつながり、特定の型をどういう経路で参照しづらい場合があります。
graphql-voyagerも併用することで視覚的に探索しやすくなります。

ActiveRecordのEnumをGraphQLのEnumにきれいにドキュメント化する

class SomeEnum < Types::BaseEnum
  SomeAr.statuses.each do |key, _val|
    value(key, SomeAr.human_attribute_name("status.#{key}"))
  end
end

each,human_attribute_nameを使うことで誰でも読める形式でEnumを定義することができます。

まとめ

いかがでしたでしょうか。
GraphQL-Rubyは更新も盛んで自身で手を入れることでより使いやすくすることができるようになってきました。
また別の機会にアクセス制御やテストなど今回触れなかったGraphQLの実装例についてご紹介できればと思います。

最後に

DeNA TechCon2019に登壇します!
KubernetesやGraphQLの活用事例を紹介致しますのでぜひご参加ください!
https://techcon.dena.com/2019/