iKnow gemをXMLフォーマットに対応させる

NovさんのiKnow gemgithubでフォークしてきて、JRubyで動かせるようにはしたんですが、今回iKnowのアプリコンテストで作ろうと思ってたアプリに必要な要素である"transliteration"(音訳、とかの意味)が、iKnow gemが使っているJSONフォーマットで入っていないことがわかりました。
いまさらxmlパーサをベタに書いてアプリ作るのも面倒なので、iKnow gemにXMLフォーマットが使えるようにできないか、やってみました。

調べてみると、どうもiknow gemのJSON処理の肝は lib/iknow/rest_client/base.rb にあるようです。ここを何とかすればXML対応できそうです。

  def self.handle_json_response(json_response)
    hash = JSON.parse(json_response)
    # エラー処理
    hash
  end

JSONライブラリが出力するhashという変数に入っている内容をXMLをパースして出せればOKでしょうか?
ということで、hashの中身を見てみます。

[{"responses"=>[{"type"=>"meaning", "text"=>"\343\203\255\343\203\201\343\202\247\343\202\271\343\202\277\343\203\274(\350\213\261\345\233\275\357\274\211", "language"=>"ja"}], "id"=>514298, "cue"=>{"text"=>"Rochester", "language"=>"en", "sound"=>"http://media1.iknow.co.jp/contents/halpern/en_male/E0087719.mp3", "part_of_speech"=>"Proper Noun"}},

これに対応するXMLは、

<vocabulary xsi:schemaLocation="http://api.iknow.co.jp/specifications/vocabulary/1.0 http://api.iknow.co.jp/specifications/vocabulary/1.0" version="1.0">
  <items>
    <item href="http://www.iknow.co.jp/item/514298" language="en" id="514298">
      <cue part_of_speech="Proper Noun" language="en">
        <text>Rochester</text>
        <sound>http://media1.iknow.co.jp/contents/halpern/en_male/E0087719.mp3</sound>
        <transliterations/>
      </cue>
      <responses>
        <response type="meaning" language="ja">
          <text>ロチェスター(英国)</text>
        </response>
      </responses>
    <dc:creator>kyoya</dc:creator>
  </item>

おおもとがArrayで、その中の1itemがHashになっていて、各属性やtext要素が出てきている、という感じですね。。ここからかなり試行錯誤したのですが、以下のことがわかりました。(JSON詳しくないので、かなり基本的なこと言ってるかもしれません。。)

  1. items->itemのように、複数を束ねるものはArrayとする
  2. 束ねられた単数(itemとか)の属性(attribute)は、そのHashの1要素となる
  3. 子要素がそれ以上ないものは、そのタグ名でHashの1要素となる

厳密ではないかもしれませんが、このようなルールに従ってパースしてみて、さらにかなりの回数試行錯誤してみた結果のコードはこのようになりました。パースにはrexmlを使っています。

  def handle_response(xml_response)
    doc = REXML::Document.new(xml_response)
    hash = []
    doc.root.elements.each do |e| # TODO need to fetch first significant element
      hash = extract_xml_to_hash(e)
    end
    hash
  end

  def extract_xml_to_hash(parent)
    if has_children_same_element_name? parent
      arr = []
      parent.elements.each do |c|
        arr << extract_xml_to_hash(c)
      end
      return arr
    else
      hash = {}
      hash[parent.name] = parent.text unless parent.elements.size > 0
      parent.elements.each do |c|
        hash[c.name] = c.elements.size > 0 ? extract_xml_to_hash(c) : c.text
      end
      parent.attributes.each do |name, value|
        hash[name] = value
      end
      return hash
    end
  end
   
  def has_children_same_element_name?(parent)
    parent.name.reverse.slice(0, 1) == 's' # last letter
  end

エントリのhandle_response(xml_response)メソッドでは、とりあえずトップ要素(のさらに1階層下)を再帰用メソッドに突っ込んでいます。(ここはもう少しスマートにできるはず)
再帰用メソッドextract_xml_to_hashは、まず、子要素に同じ名前の単数形(ここは横着で自分の名前の最後がsで終わっている、とかの適当実装ですが、とりあえずこれでOKのようです)があるかどうかで、あれば配列に子要素の再帰結果を突っ込むということをしています。
次に、自分が子要素を持たなければ、そのtext要素をhashに突っ込みます。(じつはこれがtransliterationの部分)
また、属性がセットしてあれば、それもhashの要素として入れます。

あとは、JSONXMLのフォーマット切り替えですが、これはconfigクラスで指定して切り替えられるようにしました。:format属性を用意して、デフォルトはJSONですが、これをxmlとするとXMLで取ってくる(つまり上記の実装を使う)ようになるので、たとえばIknow::Item.transliterationsに値が入るようになります。
ちなみに、年明けにはJSONにもこの要素は入るようになる、という話です。(iknow-devメーリングリストで)

XML対応したiKnow! gemはgithubに登録してあります。

http://github.com/fujibee/iknow/tree/master

にあるので、興味がある方は見てください。