indilog

Ruby/Rails/React/Goなどに関して自分が勉強したことなどを投稿しています

クエリ結果を軸としたGraphQLのエラーハンドリング

この記事は GraphQL Advent Calendar 2020 6日目の記事です。

前回の記事は @fossamagna さんの AppSyncのGraphQL APIを@apollo/clientで呼び出す でした。


この記事では以下の記事で紹介されているGraphQLのエラーハンドリングの手法についての紹介と、それを利用するクライアントサイドのメリットについての考察をしていきます。 sachee.medium.com

アプリケーションで生じる様々なエラーと、GraphQLの一般的なハンドリング

GraphQLはリクエストに対してエラーが発生した場合、一般的にレスポンス中のerrorsというキーの中にそのエラーに関する情報を詰め込んだレスポンスを返すというプラクティスがあります。

"errors": [
    {
      "message": "....",
      "locations": [ ... ],
      "path": [...],
      "extensions": { ... },
    }
  ]

このフォーマットでは、認証エラーやinternal server errorというコンテキストの異なったエラーなどが並列な存在として表現されてしまうので、適切にエラーをハンドリングするためには少し工夫をする必要があります。

実際に世にある様々なGraphQLのライブラリ(graphql-ruby, apollo-server など)ではこのフォーマットを踏襲し、また、エラーを分類可能にさせるためにextensionsにどのようなエラーなのかという情報を入れ込むなどして表現するようにしています。

クエリ対象をリクエストの結果としてモデリングしてみる

errorsに埋め込むことでどこでエラーが出ているのか、何が原因なのか、というところを把握することには事足りそうですが、これを利用するクライアントの立場に立ってみると意外と使いにくいと感じる点がありそうです。 例えばUserを取得するクエリ

{
  user(username: "hoge") {
    id
    name
  }
}

に対してエラーが発生する場合、レスポンスとしては以下などが返ってくるかと思います。

{
   ...,
  "errors": [
    { "path": [ "user" ],
      "locations": [ ... ],
      "extensions": {
        "message": "認証エラー",
        "code": "UNAUTHENTICATED"
      }
    }
  ]
}

この時Userのエラーが存在するかを確かめるためには、リクエストの度にerrors中にそれが存在するのかを配列を舐めて見ていく必要があり、また、エラーの取捨選択や、その詳細度をクエリでコントロールできないため、クライアント側で取り扱う際に不便そうです。

そこでこちらの記事で提案されている、リソースではなく、クエリの結果 をクエリ対象としてモデリングした場合を考えてみます。 sachee.medium.com

この クエリの結果 をクエリする考えを取り入れると、Userをクエリするのではなく、以下のようにリソースとエラーの内容をUnionとしてUserResultを定義することができます。

type User {
  id: ID!
  name: String
}

type IsBlocked {
  message: String
  blockedByUser: User
}

type UnAuthorized {
   reason: String
   role: String
}


union UserResult = User | IsBlocked | UnAuthorized

またこれをクエリする際は以下のようになります。

{
  userResult(username: "hoge") {
    __typename
    ... on User {
      id
      name
    }
    ... on IsBlocked {
      message
      blockedByUser {
        username
      }
    }
    ... on UnAuthorized {
      reason
      role
    }
}

このようにクエリできるようになったことによって、Userに関心があるものはUserResultでまとめて表現することができ、かつクライアント側でその詳細度をコントロールできるようになりました。

特にこの例では、UnAuthorizedな状態の時に、どのroleにおいて認証が通らなかったのかなどを表示する/しないはクライアント側の判断に委ねることができるので、管理画面などで表示したい場合はroleを含ませ、普段ユーザが使う場合は詳細度を絞った形で表現するなど、クライアント側がコントロールできるメリットが生じているのが感じられるかと思います。

また、副次的ではありますが、エラーのパターンを意識してサーバ側はスキーマの設計をする必要があることや、クライアント側はそれをクエリで意識的にクエリできるようになることから、どのようなエラーが発生しうるのか、それをどのようにハンドリングするのか、ということをより意識した設計をすることが可能になるかと思います。

まとめ

  • GraphQLでクエリする対象を、リソースではなく、場合によっては クエリの結果 としてモデリングすることできる
  • クエリの結果としてモデリングすることによって、よりクライアント側が使いやすいエラーの設計が可能になる

プロジェクトの種類や、開発の段階によっては採用の善し悪しが別れそうですが、うまく取り入れられればクライアント側でエラーの内容を柔軟に取り扱うことができ、適切な情報を適切なユーザ/コンテキストで表現することができるようになるのでお試しいただければと思います!

Reactのイベントハンドリングについて

はじめに

この記事はReact #2 Advent Calendar 2019 11日目の記事です。

先日JSConfに参加し、今まであまり意識せずなんとなく使ってきたReactのイベントハンドリングについて個人的に新しい発見があったので、記事にしてみます。

本記事ではReactのイベントハンドリングの仕組みについてざっくりと理解することを目的としますが、より詳しい説明や、この仕組みによって puppeteer などでテストする際にこれによってどのような技術的チャレンジが行われているかはBenjamin Gruenbaumさんの The Anatomy of a Clickという発表から知ることができるのでぜひご視聴ください。

www.youtube.com

JavaScriptのイベント伝搬について

まずJavaScriptがイベントを取り扱うのかについて見ていきます。

通常クリックイベントをあるelement以下のDOMに付与しようとすると、以下のように書くと思います。

el.addEventListener('click', listener)

このように書くことによって、 el 以下のDOMがクリックされた時、listener にそのイベントの発火が渡される形になります。このクリックからlistenerへの発火は、実はより細かく見ると、Capture Phase Target Phase Bubble Phaseというフェーズにわかれていて、上で記述した addEventListenerBubbling Phaseでこのイベントリスナーが評価されるようになっている状態です。

逆にCapture Phaseでイベントの処理を行おうとする場合、以下のようにuseCaptureオプションをtrueにします。

el.addEventListener('click', listener, true)

こちらはW3C UIEvents specificationから引用した上記フェーズの説明図となります。

Graphical representation of an event dispatched in a DOM tree using the DOM event flow

まずCapture Phaseでは、Windowから順にその子要素を辿るようにイベントのターゲットである要素まで順々にイベントを処理していきます。 次にTarget Phaseでは今伝搬しているイベントをstopPropagationなどで止めるかどうかなどを判定します。 最後にBubbling Phaseでは、イベントターゲットから親の要素を辿りWindowまでイベントを伝搬していく形になります。 基本的にはBubbling

React的イベントハンドリング

ざっくりとJavaScriptにおけるイベントの仕組みについて把握したので、Reactではどのようにこのフェーズを取り扱っているのかを見ます。 Reactでは、そのイベントシステムの一部として、SyntheticEventというものが存在しています。 例えば、ReactのonClickというSyntheticEventイベントハンドラはユーザが起こすクリックに対するイベントの処理をBubbling Phaseで行うようになっており、Capture Phaseで処理を行おうとする場合は、onClickCaptureなど、~Captureというイベントハンドラを利用することでこれが可能になっています。

また、SyntheticEventはブラウザ間のイベントの仕様の違いなどを吸収してくれています。例えば、以下のようなonMouseEnter のイベントを各種ブラウザでサポートするための以下リンク先の実装を見てみると

github.com

mouseOvermouseOverイベントの組み合わせによってそれを判定していることがわかり、開発者があまり気にしなくても、クロスブラウザ対応をケアしてくれていることがわかるかと思います。

このような抽象化は開発者としてはとてもありがたいのですが、気をつけたいのは、これはonMouseEnterをそのままmouseOvermouseOverイベントの組み合わせに置き換えているため、直接onMouseEnterを発火させた場合、Reactはそのイベントを処理することができないことがある可能性があるということです。テストなどを行う際に、この仕組みを知っていると、もしかしたらデバッグなどに役立つかもしれません。

まとめ

  • JavaScriptのイベントの処理の仕組みにはCapture Phase Target Phase Bubbling Phaseという3つのフェーズが存在している
  • Reactは基本的にはBubbling Phaseでイベントの処理をするようになっているが、Capture Phaseで処理するイベントハンドラも存在している
  • ReactはSyntheticEventを使ってイベントを抽象化しているため、単純にイベントを同じような名前のイベントを直接発火させたとしても、反応しない場合があるので気をつける必要がある

Herokuのreview appsでアプリケーション毎にelasticsearchを利用する

review appsでもelasticsearchを無料プラン内で自動的に設定して、運用をしてみたかったので色々試してみたらできた記録

1. searchkickを導入する

elasticsearchの機能/設定を簡単にrailsに組み込むために、こちらのgemを利用する

github.com

Gemfile

gem 'searchkick'

して、 bundle install

2. herokuのaddonとしてbosaiを追加する

staging環境に関して、いつもaddonを追加している通り、market placeなどからbosaiを検索し、これを追加する

3. ELASTICSEARCH_URLにBONSAI_URLを設定する

searchkickはELASTICSEARCH_URLをelastic searchで参照できる場所と認識するため、こちらにはBONSAI_URLを設定する必要がある

そのために、review appsにbonsaiが追加され、その環境変数が設定された後に以下のようなrake taskを lib/tasks/bootstrap_review_app_tasks.rake などといったファイルとして追加する ここで定義しているタスクは、review appsの環境変数 ELASTICSEARCH_URL に対して、ENV['BONSAI_URL'] で取得できる値を設定するというものになっている また、 HEROKU_API_TOKEN に関してはherokuのプロフィールページから参照することができるので、そちらを利用する

desc 'Bootstrap review app'
namespace :dev do
  task bootstrap: ['db:schema:load', 'db:seed_fu'] do
    heroku = PlatformAPI.connect_oauth(ENV['HEROKU_API_TOKEN'])
    heroku.config_var.update(ENV['HEROKU_APP_NAME'], 'ELASTICSEARCH_URL' => ENV['BONSAI_URL'])
  end
end

4. review appsの起動スクリプトを編集する

3で定義したスクリプトを実行する処理をapp.jsonのpostdeployの項に対して以下のように記述する

postdeployなどに関してはこちらを参照:Release Phase | Heroku Dev Center

{
  "scripts": {
    "postdeploy": "bundle exec rake dev:bootstrap"
  }
}

Layout/EmptyLineAfterGuardClause

Rubocopの0.59.0からLayout/EmptyLineAfterGuardClauseというcopがデフォルトになったみたい

github.com

早期リターンの後に行を空けると、どこまでが早期リターンで、どこからが中身の処理なのかがわかりやすくなるから良いよね

STIを利用している場合のFactoryBotのfactory定義について

STIを活用しているモデルは、スーパークラスでfactoryを定義して、テストでオブジェクトの比較をすると、スーパークラスのままのものと、 サブクラスに変換されているものとの比較になってしまうことがあり、そのためテストが落ちることがある

以下のように書くことによって、typeを元にSTIのクラスを取得し、それを使ってオブジェクトが初期化できるようになる

FactoryBot.define do
  factory :factory_name do
    initialize_with do
      klass = type.constantize
      klass.new(attributes)
    end

    # 以下factoryの定義
  end
end

RSpecのexpectとis_expectedの挙動の違いについて

subject(:actual_object) { ... }

it { is_expected.to eq(expected_object) }

subject(:actual_object) { ... }

it { expect(actual_object).to eq(expected_object) }

では挙動が異なるという話

上の方を actual_objectexpected_object を同じ属性などを定義した状態で比較しようとすると、

Compared using equal?, which compares object identity, but expected and actual are not the same object. Use expect(actual).to eq(expected) if you don't care about object identity in this example

という注意書きが表示され、上の方法は同じオブジェクトかどうかをobjectのIDで判定していることがわかる

一方で下の方は属性の一致で比較をするため、別オブジェクトであっても、属性さえ一致していればテストが通ることになる

つまり、同じオブジェクトかどうかを比較したい場合は上の方法、同じ属性を持っているかどうかを比較したい場合は下の方の方法を使えば良さそうだ

Model周りのRSpecを書く時に意識していること

正しい方法かはわからないけど、最近Model周りのテストコードを書いていて自分でしっくりくるような書き方を考え出したのでつらつらと書いていきたい

テストコードの基本形

あるModel内のインスタンスメソッドをテストする場合の基本形としては、このような形になってくる

describe '#instance_method' do
  subject { instance.instance_method }

  context 'when first context' do
    let(:instance) { build(:factory_for_first_context) }

    it { is_expected.to ... }
  end

  context 'when second context' do
    let(:instance) { build(:factory_for_second_context) }

    it { is_expected.to ... }
  end
end

意識している箇所

1. describeの直下の行にはsubjectを置く

describe '#instance_method' do
  subject { instance.instance_method }

このようにdescribeでテストしたいメソッドを記述し、その下に今回のテスト対象をsubjectと利用して定義する

2. contextでそのコンテキストに合致したinstanceを定義

  context 'when first context' do
    let(:instance) { build(:factory_for_first_context) }

contextで条件を記述しつつ、それに対応したinstanceをなかで定義することにより、その下の

    it { is_expected.to ... }

でsubjectが評価される時に、contextのスコープで定義したinstanceが採用されるようになる 別のコンテキストでテストしたい時も同じようにそのcontextのスコープで定義を行えば、同等の結果が得られるので、すっきりとした印象のテストができる