JSON.generateしてほしいのにJSON.parseされてしまうHash

2022/06/03に公開

Rubyの話。

Rubyでは下記のように書くと文字列ならHashを返し、Hashを渡すとJSONにして返してくれるJSON(object)というメソッドがある。

h = {a: 1}
JSON(h) #=> "{\"a\": 1}"

j = "{\"a\": 1}"
JSON(j) #=> {a: 1}

しかしこれは一部の行儀の悪いmethod_missingで拡張されたHashに対しては機能しない。例えばRails7のRails encrypted credentialsの場合がそれである。

h = Rails.credentials.hoge
h.to_a? Hash #=> true
JSON(h) #=> TypeError: no implicit conversion of nil into String

JSON(h)実装を読んでみると下記のようになっている。objectのrespond_to? :to_strがtrueならそれを文字列と見なしJSON.parse、そうでないならJSON.generateを実行している。

def JSON(object, *args)
  if object.respond_to? :to_str
    JSON.parse(object.to_str, args.first)
  else
    JSON.generate(object, args.first)
  end
end

先の例だとh.respond_to? :to_strはtrueになる。これはRails credentialsがmethod_missingで全ての呼び出し(to_strを含む)に対して何かしらを返すようになっているからである。だから下記のようになる

h = Rails.credentials.hoge
h.respond_to? :to_str #=> true
h.to_str #=> nil

結果的にJSON(h)内部ではStringライクなオブジェクトと見なされてJSON.parseされてしまいエラーが発生する。

これはJSON側の実装というよりもActiveSupportの方の問題という気もするが、一応Stringライクなオブジェクトであるというチェックにrespond_to? :to_strだけでなく実際にto_strの結果がStringであるかのチェックも入れたほうが厳密な気がするのでPRを出しておいた。まぁ取り込まれなくても全然問題ないが。そもそも明示的JSON.generateを使えば済む話ではあるので。。
https://github.com/flori/json/pull/499

Discussion