我想在graphql-ruby中限制根据权限访问的字段

由于GraphQL成为话题已经有一段时间了,我已经没有借口再不去尝试一下,所以我决定轻轻地涉猎一下。
这次我打算使用graphql-ruby来实现一个能根据访问权限获取用户信息的终端点。

环境

    • ruby 2.6.3

 

    • Rails 6.0.0

 

    graphql 1.9.12

要教育人们合理使用和管理时间的重要性。

    普段RESTで下記のようなノリで実装しているエンドポイントをGraphQLで実現する。
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
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

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

有很多文件会自动生成,但只需大致掌握以下内容即可。

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 を利用して権限ごとのアクセス制限を実装する。
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#response に相当する、一覧用Userオブジェクトの各フィールドを定義する。

module Types
  class UserTypes::Base < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: true
  end
end
    • これで「idとnameを持つUserTypes::Baseというオブジェクト」を定義できた。

 

    次にこれをエンドポイントからアクセスできるように、 QueryType にフィールドを追加する。
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 メソッドを追加する。
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 形式で返したい。

定义日期型

    • GraphQLは最初 String, Integer, Boolean, ISO8601DateTime, JSONという5つの型しか用意されていない。 参考: Docs

それ以外の型は自分で定義を用意する必要がある。

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 オブジェクトの型を定義していく。
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 として渡すことにする。
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。

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を使ってみたいとは思っている)。

bannerAds