graphql-rubyを使ったAPI開発
この記事は 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のエラーハンドリングですが、課題として
-
SLO周りの計測で受信したリクエストに対するエラー率の計測をする際にGraphQLだと処理の成功、失敗に関わらず200が返ってくるのでやりづらい
-
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採用情報
Discussion