Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: her gemを使うときには検索結果が0件のパターンも確認しよう

RESTfulなリソースをRubyのインスタンスとして扱うことのできる her というgemが存在します。

remi/her - GitHub

例として、 https://example.co.jp/api/users/00001 というURLを参照すると、her はユーザーリソースの情報を持つuserインスタンスを生成することができます。

# 外部サーバーからのAPIレスポンスの例
{
    "code": "0",
    "user": {
        "name": "田中 太郎",
        "code": "00001"
    }
}
module IntraCompany
  class User
    include Her::Model
    parse_root_in_json true, format: :active_model_serializers
  end
end
> IntraCompany::User.find('00001')
request.faraday: GET 'https://example.co.jp/api/users/00001' takes 0.0053135 seconds
=> #<IntraCompany::User(users/00001) name="田中 太郎" code="00001">

期待通りのインスタンスが生成されるには「APIレスポンスが期待通りの内容・フォーマットである」という条件が必要です。
過去の事例として、この条件が満たされていないため不適切なインスタンスが生成されることがありました。今回の記事ではその事例を紹介します。

検証環境の情報は以下のとおりです。

  • her: 1.1.1
  • HTTPリクエストに faraday_middleware 1.2.0 を使用
  • フレームワークに rails 7.0.8.5 を使用

URLに対応するリソースが0件の場合を考える

code: 99999 のユーザーリソースが存在しないときに IntraCompany::User.find('99999') を実行したらどうなるか考えてみます。
筆者としては、存在しないリソースを検索している以上、エラーが起きるなりnilが返ってくるなりしてほしいところです。

この結果はAPIレスポンスの内容やフォーマットに依存しています。

  • レスポンスのステータスがエラー(404など): nilが返ってくる👌
  • レスポンスのステータスが成功(200など):
    • レスポンスのbodyが "user": {} あるいは "user": [] : attributeを持たないインスタンスが返ってくる⚠️
    • レスポンスのbodyが "user": null : 不適切なattributeを持つインスタンスが返ってくる💀

過去の事例では一番最後のパターンを引きました。

レスポンスのステータスがエラー

response.success?(ステータスコードが200-299)ではない場合、her#find の結果としてnilを返すようになっています。

          @parent.request(request_params) do |parsed_data, response|
            if response.success?
              resource = @parent.new_from_parsed_data(parsed_data)
              resource.run_callbacks :find
            else
              return nil
            end
          end
> IntraCompany::User.find('99999')
=> nil

レスポンスのステータスが成功、bodyが "user": {} あるいは "user": []

her はインスタンスを生成しようとしてAPIレスポンスのparseを試みます。途中で実行される root_element_included メソッドによってAPIレスポンスの user キーの値をチェックし、これがHashもしくはArrayであればインスタンスのattributeとしてセットします。

        def parse(data)
          if parse_root_in_json? && root_element_included?(data)
            if json_api_format?
              data.fetch(parsed_root_element).first
            else
              data.fetch(parsed_root_element) { data }
            end
          else
            data
          end
        end
        def root_element_included?(data)
          element = data[parsed_root_element]
          element.is_a?(Hash) || element.is_a?(Array)
        end

例を挙げると以下のようになり、Userインスタンスの生成は成功してしまいます。attributeに何もセットされていないのがまだマシと言ったところです。

# 外部サーバーからのAPIレスポンスの例
{
    "code": "0",
    "user": {}
}
> user = IntraCompany::User.find('99999')
=> #<IntraCompany::User(users) >
> user.present?
=> true

懸念される事態と対応方法

業務上存在しないリソースに対してこのようなRubyインスタンスが生成されると、メソッド呼び出しが可能になり、不適切な処理が通ってしまう可能性があります。

module IntraCompany
  class User
    def customers
      # 実装次第では、存在しないユーザーの顧客一覧として何かしらを取得してしまう
    end
  end
end

このような事態を防ぐには、例えば主キー相当の code がインスタンスにセットされているかをチェックしたり、

module IntraCompany
  class User
    def customers
      return [] if self.try(:code).blank?
      # ...
    end
  end
end

attributes が存在しないインスタンスをinvalid扱いしたりする、といった対応がありそうです。

module IntraCompany
  class User
    validates :attributes, presence: true

    def customers
      return [] unless valid?
      # ...
    end
  end
end

レスポンスのステータスが成功、bodyが "user": null

このパターンはややこしいので先に結果を示します。

# 外部サーバーからのAPIレスポンスの例
{
    "code": "0",
    "user": null
}
> IntraCompany::User.find('99999')
=> #<IntraCompany::User(users) code="0" user=nil>

なぜか code, user というattributeを持つインスタンスが生成されています。
このパターンではインスタンスが attributes を持っているので、「レスポンスのステータスが成功、bodyが "user": {} あるいは "user": []」のパターンで実装した早期returnのような処理が通用しません。

module IntraCompany
  class User
    validates :attributes, presence: true

    def customers
      return [] unless valid? # valid? == true になってしまう
      # ...
    end
  end

なぜこのようなインスタンスが生成されるのか、ポイントは先ほど紹介した her のparse処理にあります。

        def parse(data)
          if parse_root_in_json? && root_element_included?(data)
            if json_api_format?
              data.fetch(parsed_root_element).first
            else
              data.fetch(parsed_root_element) { data }
            end
          else # APIレスポンスが `"user": null` だとこの分岐に入る
            data
          end
        end

今回のパターンでは data 変数をそのままparseしてattributeにセットしようとしています。

通常は user プロパティの値をRubyインスタンスにセットするところが、今回はAPIレスポンスをそのままattributeにセットしてしまうんですね。

しかも今回のように、APIレスポンスに codeuser.code のような同名の code キーが存在していると状況が悪化します。

例①

# 外部サーバーからのAPIレスポンスの例
{
    "code": "0",
    "user": {
        "name": "田中 太郎",
        "code": "00001" # IntraCompany::User#code の戻り値
    }
}
> IntraCompany::User.find('00001').code
=> 00001

例②

# 外部サーバーからのAPIレスポンスの例
{
    "code": "0", # IntraCompany::User#code の戻り値
    "user": null
}
> IntraCompany::User.find('99999').code
=> 0

なんと、同じ IntraCompany::User#code メソッドが指す内容が完全に異なってしまいます。これは分かりづらい。

対応方法

"user": null というレスポンスパターンを her が想定していないために奇妙なインスタンスが生成されています。
いっそのことレスポンスを "user": {} に変換してしまいましょう。

# config/initializers/her.rb
Rails.application.reloader.to_prepare do
  Her::API.setup url: INTRA_COMPANY_API_ENDPOINT do |c|
    c.use IntraCompanyResponseConverter
  end
end
class IntraCompanyResponseConverter < Faraday::Middleware
  def on_complete(env)
    if env.success?
      data = env.body[:data]
      data[:user] = {} if data&.key?(:user) && data[:user].nil?
    end
  end
end
> IntraCompany::User.find('99999')
=> #<IntraCompany::User(users) >

ここまで対応すれば、あとは「レスポンスのステータスが成功、bodyが "user": {} あるいは "user": []」のパターンと同じように処理できます。


対応方法をどうするにせよ、アプリケーション外部からリソース取得するときには、結果が0件のパターンも確認した方がよい、という話でした。



CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。