Help us understand the problem. What is going on with this article?

graphql-rubyで権限ごとにアクセスできるfieldを制限したい

GraphQLが話題になってからしばらく経ってしまい、そろそろ言い訳もきかなくなってきたので軽く触ってみることにした。
今回は graphql-ruby を用いて、アクセス権限に応じたユーザー情報を fetch できるエンドポイントを実装してみる。

環境

  • ruby 2.6.3
  • Rails 6.0.0
  • graphql 1.9.12

目標

  • 普段RESTで下記のようなノリで実装しているエンドポイントをGraphQLで実現する。
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :authenticate_user!, only: [:me]

  def index
    users = User.all
    render json: { users: users.map(&:reponse) }
  end

  def show
    user = User.find(params[:id])
    render json: { user: user.detail }
  end

  def me
    render json: { me: current_user.me }
  end
end
app/models/user.rb
class User < ApplicationRecord
  def response
    {
      id: id,
      name: name
    }
  end

  def detail
    response.merge(
      age: age
    )
  end

  def me
    detail.merge(
      email: email,
      birthday: birthday&.strftime("%Y-%m-%d")
    )
  end
end

要するに、下記を満たすエンドポイントを実装したい。

  • ユーザー一覧では id, name にのみアクセスできる
  • ユーザー詳細では id, name, age にアクセスできる
  • 本人によるリクエストの場合のみ id, name, age に加えて email, birthday にアクセスできる

事前準備

  • users テーブルを作っておく。
$ bundle exec rails db:create
$ bundle exec rails g model user name email birthday:date
$ bundle exec rails db:migrate

GraphQLの導入

Gemfile
gem 'graphql-ruby'
$ bundle install
$ bundle exec rails g graphql:install

色んなファイルが自動で生成されるが、ざっくり下記だけ抑えておけばOK。

app/controllers/graphql_controller.rb
class GraphqlController < ApplicationController
  def execute
    variables = ensure_hash(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]
    context = {
      # Query context goes here, for example:
      # current_user: current_user,
    }
    result = AppSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
    render json: result
  rescue => e
    raise e unless Rails.env.development?
    handle_error_in_development e
  end

  # === 以下省略 ===
end
  • GraphQLのエンドポイントとなるコントローラ。 AppSchema.execute でクエリを実行している。
  • 後で具体的に説明するけど、 context を利用して権限ごとのアクセス制限を実装する。
app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    # Add root-level fields here.
    # They will be entry points for queries on your schema.

    # TODO: remove me
    field :test_field, String, null: false,
      description: "An example field added by the generator"
    def test_field
      "Hello World!"
    end
  end
end
  • この QueryType に field を追加することで、エンドポイントからアクセスできるオブジェクトを追加していく。

Userオブジェクトの定義を追加

  • 目標User#response に相当する、一覧用Userオブジェクトの各フィールドを定義する。
app/graphql/types/user_types/base.rb
module Types
  class UserTypes::Base < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: true
  end
end
  • これで「idとnameを持つUserTypes::Baseというオブジェクト」を定義できた。
  • 次にこれをエンドポイントからアクセスできるように、 QueryType にフィールドを追加する。
app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :users, [UserTypes::Base], null: true do
      description "User List"
    end

    def users
      User.all
    end
  end
end
  • 元あった test_field みたいなのは要らないので消して、ユーザー一覧を取得できるフィールドを追加。
  • [UserTypes::Base] が、さっき定義したUserオブジェクトを配列でレスポンスで返すよ、という意味。
  • users というオブジェクトを要求した時に実際に呼び出される処理が def users に定義されている。今回は引数などは無しで単に User.all を呼び出すことにする。
  • ここまで書くと、 rails console でクエリの実行結果を確認できる。
> User.create(name: "Taro", birthday: "1990-10-25", email: "xxx@gmail.com")
> AppSchema.execute("{ users() { id, name } }").to_h
# => {"data"=>{"users"=>[{"id"=>"1", "name"=>"Taro"}]}}
  • 文字列で { users() { id, name } } というのがGraphQLのクエリ。
  • 無事にidとnameを持つオブジェクトを配列で取得できていることが分かる。

詳細表示用のオブジェクト定義を追加

  • 同じ要領で、今度は UserTypes::Detail を追加する。
module Types
  class UserTypes::Detail < Types::UserTypes::Base
    field :age, Integer, null: true
  end
end
  • さっき作った Types::UserTypes::Base を継承することで id, name を重複して定義しなくて済んでいる
  • ところで、 users テーブルに age というカラムは無い。birthdayカラムから計算して出したいので、 User モデルに User#age メソッドを追加する。
app/models/user.rb
class User < ApplicationRecord
  def age
    birthday && (Date.today.strftime("%Y%m%d").to_i - birthday.strftime("%Y%m%d").to_i) / 10000
  end
end
  • こうするとGraphQLでもageフィールドを生やすことができる。
  • あとは QueryType クラスに User 詳細を取得するフィールドを追加する。
module Types
  class QueryType < Types::BaseObject
    description "The query root of this schema"

    field :users, [UserTypes::Base], null: false do
      description "User List"
    end

    field :user, UserTypes::Detail, null: false do
      description "User Detail"
      argument :id, ID, required: true
    end

    def users
      User.all
    end

    def user(id:)
      User.find(id)
    end
  end
end
  • user というフィールドを追加して、 UserTypes::Detail を型定義として指定している。
  • ブロック内の argument :id, ID, required: true で、GraphQLのクエリで引数IDを必須で取ることを定義している。
  • def user で引数idに応じたUserインスタンスを取得する処理を定義している。
  • rails console でクエリの実行結果を確認してみよう。
> AppSchema.execute("{ user(id: 1) { id, name, age } }").to_h
# => {"data"=>{"user"=>{"id"=>"1", "name"=>"Taro", "age"=>28}}}
  • 無事に age フィールドを取得できていることが分かる。

ユーザー認証を通ったユーザーにのみ特定フィールドのアクセスを許可する

  • 最後に、誕生日とメールアドレスは本人のみアクセスできるようにする。
  • ついでに誕生日は Date 型なので、レスポンス時は文字列の YYYY-MM-DD 形式で返したい。

Date型を定義する

  • GraphQLは最初 String, Integer, Boolean, ISO8601DateTime, JSONという5つの型しか用意されていない。 参考: Docs
  • それ以外の型は自分で定義を用意する必要がある。
app/graphql/types/date_type.rb
class Types::DateType < Types::BaseScalar
  RESPONSE_FORMAT = "%Y-%m-%d"

  description "A date transported as a string"

  class << self
    def coerce_input(input, context)
      Date.parse(input)
    end

    def coerce_result(value, context)
      value.strftime(RESPONSE_FORMAT)
    end
  end
end
  • 独自定義の型には coerce_input と coerce_result というクラスメソッドを追加する必要がある。
  • 今回 mutation を使わないので coerce_input の方はどうでも良いが、 coerce_result はレスポンスの定義なので返したいフォーマットを指定しておく。
  • 本当は同じ要領で email も独自定義の型を用意して、文字列がメールアドレスになってるかどうかバリデーションかけることもできるのだけど、今回は mutation を使わないので省略する。

UserTypes::Me オブジェクトを定義する

  • 上記で追加した DateType を使って、 email と birthday を含む User オブジェクトの型を定義していく。
app/graphql/types/user_types/me.rb
module Types
  class UserTypes::Me < Types::UserTypes::Detail
    field :birthday, DateType, null: true
    field :email, String, null: true
  end
end
  • 例によって Types::UserTypes::Detail を継承することで id, name, age を重複して定義しなくても済むようにしている
  • birthday フィールドの型定義に、さっき作った DateType を指定している。(本当は Types::DateType なのだけど、この UserTypes::Me クラスもTypesモジュールのスコープ内にあるので Types:: を省略できる)

ユーザー認証の結果をGraphQLに反映する

  • アクセス権限を判定するため、コントローラ側で current_user を取得して AppSchema に渡す
  • 本来はユーザー認証ロジックを書くべきだが、それは本稿のスコープ外なので単に User.first を認証された current_user として渡すことにする。
app/controllers/graphql_controller.rb
class GraphqlController < ApplicationController
  def execute
    variables = ensure_hash(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]
    context = {
      current_user: User.first,
    }
    result = AppSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
    render json: result
  rescue => e
    raise e unless Rails.env.development?
    handle_error_in_development e
  end

  # === 以下省略 ===
end
  • こうすることで、 QueryTypes 等のクラス内で使える context というハッシュ経由で current_user にアクセスできるようになる。

QueryType に本人のみがアクセスできる User オブジェクトを定義する

app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    description "The query root of this schema"

    field :users, [UserTypes::Base], null: false do
      description "User List"
    end

    field :user, UserTypes::Detail, null: false do
      description "User Detail"
      argument :id, ID, required: true
    end

    field :me, UserTypes::Me, null: false do
      description "Self Detail"
    end

    def users
      User.all
    end

    def user(id:)
      User.where(id: id).first
    end

    def me
      context[:current_user] || {}
    end
  end
end
  • field :me, UserTypes::Me を追加した。
  • def me 内で context[:current_user] をクエリ対象のオブジェクトとして返している。
  • これを UserTypes::Me 型としてシリアライズするので、本人しかアクセスできない!
  • rails console でクエリ実行結果を見てみる。
> AppSchema.execute("{ me() { id, name, age, birthday, email } }", context: { current_user: User.first }).to_h
# => {"data"=>{"me"=>{"id"=>"1", "name"=>"Taro", "age"=>28, "birthday"=>"1990-10-25", "email"=>"xxx@gmail.com"}}}
  • console で試す場合はキーワード引数 context にハッシュで current_user を渡す。
  • birthday が無事に YYYY-MM-DD 形式の文字列で返ってきていることが分かる。

まとめ

  • 以上の手順で「権限に応じてアクセスできるフィールドを変える」という実装が実現した。
  • Types::BaseObject を継承した XxxType クラスはシリアライザーみたいなものなんだなと理解した。
  • context をうまく使えばユーザー認証に応じてレスポンスを出し分ける処理は書けそう。
  • 今回は User に関わるフィールドだったので QueryType 内で制御するだけで済んだけど、実際に認証結果に応じたフィールドの制限とかは Authorization / Authentication みたいな方法があるらしいよ。
  • この後は関連モデルのJOIN(GraphQLで言うところのconnection)とかwhere句の書き方とかN+1の解決方法とかを勉強してみたい。そこまでできれば、最低限のアプリケーションを作るための基礎は培えそうな気がする。
  • client側の実装についてはまた今度(TypeScriptでApolloを使ってみたいとは思っている)。
Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away