RESTfulなリソースをRubyのインスタンスとして扱うことのできる her
というgemが存在します。
例として、 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を持つインスタンスが返ってくる💀
- レスポンスのbodyが
過去の事例では一番最後のパターンを引きました。
レスポンスのステータスがエラー
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レスポンスに code
と user.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件のパターンも確認した方がよい、という話でした。