💎

graphql-rubyを使ったAPI開発

2022/12/11に公開

この記事は MICIN Advent Calendar 2022 の11日目の記事です。

前回は郷田さんのGlide(NoCode)が社内ツールとして利用可能か試してみるでした。

今回は自社で開発しているオンライン診療アプリ curonのAPIにて利用しているgraphql-rubyを用いるにあたりcuronの開発チームで行っていることについて紹介します。

curonで使用しているGraphQL周りの技術スタック

バックエンド
graphql-ruby

フロントエンド
Apollo Client
GraphQL-Code-Generator

curonはwebとネイティブアプリに対応しています。バックエンドは Railsを使ったGraphQLで、フロントエンドはweb側はNext.js ,ネイティブアプリ側はReact Nativeで開発しておりweb,ネイティブアプリともGraphQL クライアントとして Apollo Clientを使用しております。

今回はgraphql-rubyに焦点を絞って説明していきます。

graphql-rubyとは

GraphQLのAPIサーバをRubyのコードで記述するためのgemです。

# GraphQLで記述した場合

query User($id: ID!) {
  user(id: $id) {
    id
  }
}


# graphql-rubyで記述した場合

 field :user, Hoge::Graph::Types::UserType, null: false do
   argument :id, ID, required: true
 end
 def user(id:)
   User.find(id)
 end

上記のようにRubyで

  • fieldにqueryの名称、queryで返したいデータのtypeを記述

  • argumentにデータをqueryで返すためにフロントエンドから渡されるデータを記述

  • メソッド内にfieldにて定義したデータのtypeを返すための処理をargumentで受け取った値を元に実行

queryに関する処理を記述することでGraphQLの処理を実行することができます。

ちなみにmutationを書く際の例は下記ですが、queryがDBのデータを取得して返しているのに対してmutationは処理の中でデータの作成ないし、更新を行ったデータを返しております。

# graphql

mutation DestroyUser($id: ID!) {
  destroyUser(input: {id: $id})
}

# graphql-ruby 

module Mutations
  class DestroyUser < BaseMutation
    type Boolean
    argument :id, ID, required: true

    def resolve(id:)
      user = User.find(id)
      user.destroy!
  
      true
    end
  end
end

queryはCRUDでいうところのread
mutationはCRUDでいうところの create update delete

と考えると分かりやすいかもしれないです。

graphql-rubyでのエラーハンドリング

GraphQLのエラーハンドリングの特徴として

  • 処理の成功または失敗に関わらず必ず200番ステータスが返ってくる

  • 正常系はdataオブジェクトに成功した処理の戻り値が含まれる

  • 準正常系、異常系はエラーに関する情報が入った errorsが返される

が挙げられます。

{
  "errors": [
    {
      "message": "Cannot query field \"nonexistentField\" on type \"Query\".",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "extensions": {
        "code": "GRAPHQL_VALIDATION_FAILED",
        "exception": {
          "stacktrace": [
            "GraphQLError: Cannot query field \"nonexistentField\" on type \"Query\".",
            "...additional lines..."
          ]
        }
      }
    }
  ],
  "data": null
}

REST APIとは大きく異なるGraphQLのエラーハンドリングですが、課題として

  1. SLO周りの計測で受信したリクエストに対するエラー率の計測をする際にGraphQLだと処理の成功、失敗に関わらず200が返ってくるのでやりづらい

  2. errorオブジェクトのmessageに記載されるエラーメッセージをパッと見てクライアント側またはサーバー側に起因したものなのかが判別しずらい

の2点が挙げられます。

そこでcuronでは明示的にエラーを返す際に用いる

raise GraphQL::ExecutionError

をカスタマイズして

errorオブジェクト内にある

  • codeにエラーの分類ができるようにステータスコードが入るように

  • detail_codeに詳細なエラー理由が入るように

  • messageには明示的に指定したキーに対応するエラーメッセージが入るように(デバッグがしやすいように同じエラーメッセージを返すとしても errors のキーを分けています)

なっています。

# hoge_ja.yml

ja:
 hoge:
      errors:
      # 左のキーを指定すると対応するエラーメッセージが割り当てられる
        forbidden: 操作に必要なものが割り当てられていません。
        uncaught_error_bad_request: 問題が発生しました。時間を置いて再度お試しください。
        uncaught_error_record_not_found: 問題が発生しました。時間を置いて再度お試しください。
        uncaught_error_record_invalid: 問題が発生しました。時間を置いて再度お試しください。
        uncaught_error_standard_error: 問題が発生しました。時間を置いて再度お試しください。
module Hoge::Graph::Errors
  include Hoge::Graph::Errors::Definition
  include Hoge::Graph::Errors::Creation

  define :forbidden, code: 403

  define :uncaught_error_bad_request, code: 400
  define :uncaught_error_record_not_found, code: 404
  define :uncaught_error_record_invalid, code: 422
  define :uncaught_error_standard_error, code: 500
end
module Hoge::Graph::Errors::Definition
  extend ActiveSupport::Concern

  module ClassMethods
    def define(key, code:)
      @errors ||= {}
      @errors[key] = {
        key: key,
        code: code,
      }
    end

    def errors
      @errors
    end
  end
end
module Hoge::Graph::Errors::Creation
  extend ActiveSupport::Concern

  module ClassMethods
    def create(key, message: nil)
      raise ArgumentError, "An error named #{key} is not defined." unless @errors[key]

      message ||= I18n.t("hoge.errors.#{key}")
      GraphQL::ExecutionError.new(message,
      extensions: {
        code: @errors[key][:code],
        detailed_code: key.to_s.upcase,
      })
    end
  end
end

上記の実装をすることでqueryやmutationの処理内で下記のように書くと

raise Hoge::Graph::Errors.create(:forbidden)
{
"data":null,
//forbiddenのキーに対応するエラーメッセージが返ってくる
"errors":[{"message":"操作に必要なものが割り当てられていません。"
","locations":[{"line":2,"column":3}],"path":["hoges"],
//レスポンスは200だがcodeを明示的に付与しておくことでクライアント側かサーバー側によるものかが分かりやすくなる
"extensions":{"code":403,"detailed_code":"FORBIDDEN"}}]
}

上記の対応によりGraphQLのレスポンスからエラーの詳細がより分かりやすく確認することができます。

SLOの計測でもcodeが500のものに絞れば計測できるのでとても判別がしやすくなりました。

graphql-rubyを使ったRSpecの書き方

graphql-rubyでのRspecの記述方法はいくつかありますが、curonでは主に

  • executeを叩く(curonではやや使われている)

  • resolverを直接叩く(curonでは結構使われている)

の二つが使われております。

executeを叩く方法

queryやmutationを実行する前にexecuteを直接叩く方法です。

RSpec.describe 'updateUser' do
  subject do
    HogeHoge::Graph::Schema.execute(mutation, context: {}, variables: variables).to_h.deep_symbolize_keys
  end
    
  let(:mutation) do
    <<~GQL
      mutation (
       $input: UpdateUserInput
      ) {
        updateUser (input: $input) {
          id
          firstName
          lastName
        }
      }
    GQL
  end
  
  let(:user) { create(:user) }
  let(:variables) do
    {
      id: user.id,
      firstName: '太郎',
      lastName: '山田',
    }.as_json
  end
  
  it do
    is_expected.to eq (
      data: {
        updateUser: {
          id: user.id,
          firstName: '太郎',
          lastName: '山田',
        },
      },
    )
  end
end

メリット

contextが渡せます(current userをqueryの処理に含められる)。

直接GraphQLのクエリを書くのでテストの安全性があります。

デメリット

GraphQLのqueryを直接書いたりquery実行後の戻り値をjson形式から抜き出したりするのでコードの記述量が増えます。

resolverだけ叩く方法

定義したmutationやqueryのクラスをnewして直接resolveメソッドを読み込む。

RSpec.describe HogeHoge::Graph::Mutations::UpdateUser do
  subject do
    described_class.new(
      context: {}
      field: nil,
      object: nil,
    ).resolve(
      user: {
        first_name: '太郎',
        last_name: '山田',
      },
    )
  end
  
  let(:user) { create(:user) }
  
  it 'update user' do
    subject
    expect(user).to have_attributes(
      first_name: '太郎',
      last_name: '山田',
    )
  end
end

メリット

GraphQLのqueryを直接書いたりしないのと、resolveの戻り値をそのままexpectに投げて検証できるのでテストが書きやすく、手間がかからないです。

デメリット

graphql-rubyの入出力の変換処理が実行されず、queryをGraphQLで直接書いていないので本体コードに記述していたデータ型が間違っていたり、追加したいfieldが抜け漏れていてもテストが意図せず成功してしまうことがあります。

2つの方法はそれぞれ

  • テストの安全性

  • コードの記述量

の双方のトレードオフとなっています。

終わりに

graphql-rubyをそのまま使うと運用をしていくにつれてエラーハンドリング周りで課題が出てきたりはしますが、今回述べさせていただいたようなカスタマイズをすることでGraphQL特有の使いづらい仕様を改善できたりするので、運用で出てきた課題に対してgraphql-rubyをどうカスタマイズするかという視点で考えるとまだまだ改善案はありそうです。

今回はgraphql-rubyのみの紹介でしたがApollo-Client とGraphQL-CodeGeneratorとgraphql-rubyを使った開発はバックエンドとフロントエンドのapiの繋ぎ込みが簡単にできるのがとても魅力的で、個人的に開発効率がとても良いです。

最近ではcuronだけでなく社内のプロダクト管理ツールや curon スマートパスなども同様の技術スタックで開発されており、今後もより開発が効率化できる仕組みやライブラリを見つけ次第取り入れていければなと思います。

MICIN Advent Calendar で明日は佐藤さんの「治験領域でスクラム開発を8ヶ月やってみて直面した3つの課題と解決策をエンジニア視点で述べてみる」です。


MICIN ではメンバーを大募集しています。
「とりあえず話を聞いてみたい」でも大歓迎ですので、お気軽にご応募ください!
MICIN 採用ページ:MICIN採用情報

株式会社MICIN

Discussion