Rails APIモードでレスポンスのハッシュに任意の値を持たせつつネストさせたい時の注意点

やりたいこと

  • テーブルのカラム以外にインスタンスメソッドの返り値もレスポンスに含めたい
  • レスポンスのデータはネストした構造にしたい
// GET: /api/v1/ideas のレスポンス
{
  // dataに配列が入っている構造
  "data": [
    {
      "id": 1,
      "body": "Go言語の本を読む",
      // これが idea.rb に定義したメソッドの返り値と想定
      "category_name": "勉強"
    }
    // ...
  ]
}

前提知識

as_jsonto_json

as_jsonto_json を使うと ActiveRecord_Relation を Hash の配列に変換できます。用意されているオプションなどはほぼ同じなのですが、as_jsonto_json には以下のような違いがあります。

  • as_json: Hash を返す
  • to_json: String を返す
> User.first.as_json
=> Hash
> User.first.to_json
=> String

methods オプションを指定することで「インスタンスメソッドの返り値もレスポンスに含めたい」の要件は実装できます。

user.as_json(methods: :permalink)
# => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
#      "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true,
#      "permalink" => "1-konata-izumi" }

ActiveModel::Serializers::JSON

to_json で実装しているとぶつかる壁

まずはto_jsonを使わず、シンプルな実装をしてみます。この実装ではまずレスポンスのデータが想定している構造で問題なく出力されます。

def index
  render json: { data: Idea.order(:id) }
end
// curl localhost:3000 | json_pp を実行
{
  "data": [
    {
      "body": "Go言語の本を読む",
      "id": 1,
      "created_at": "2021-03-20T02:33:36.893Z",
      "category_id": 1,
      "updated_at": "2021-03-20T02:33:36.893Z"
    },
    {
      "created_at": "2021-03-20T02:33:36.916Z",
      "category_id": 1,
      "updated_at": "2021-03-20T02:33:36.916Z",
      "body": "Ruby on RailsでAPI開発",
      "id": 2
    }
  ]
}

上記ではto_jsonを呼び出していませんが、Idea.order(:id)を Rails がよしなにハッシュの配列に変換してくれています。

出力するオブジェクトに対して to_json を呼び出す必要はありません。:json オプションが指定されていれば、render によって to_json が自動的に呼び出されるようになっています。
レイアウトとレンダリング - Rails ガイド

「テーブルのカラム以外にインスタンスメソッドの返り値もレスポンスに含めたい」の要件を実装するためto_jsonがオプションとして用意している、methodsを使ってみます。しかし、下記の実装では data の値が文字列になってしまいます。

def index
  render json: { data: Idea.order(:id).to_json(methods: [:category_name]) }
end
// curl localhost:3000 | json_pp
{
  "data": "[{\"id\":1,\"body\":\"Go言語の本を読む\",\"category_id\":1,\"created_at\":\"2021-03-20T02:33:36.893Z\",\"updated_at\":\"2021-03-20T02:33:36.893Z\",\"category_name\":\"勉強\"}, ...]"
}

原因

状況を整理するため、data のネストを剥がして、methods オプションなしでto_jsonを使ってみます。こうすると、先程のような文字列にはならず綺麗に整形されたデータを返します。

def index
  render json: Idea.order(:id).to_json
end
// curl localhost:3000 | json_pp
[
  {
    "id": 1,
    "body": "Go言語の本を読む",
    "category_id": 1,
    "created_at": "2021-03-20T02:33:36.893Z",
    "updated_at": "2021-03-20T02:33:36.893Z"
  },
  {
    "body": "Ruby on RailsでAPI開発",
    "id": 2,
    "created_at": "2021-03-20T02:33:36.916Z",
    "updated_at": "2021-03-20T02:33:36.916Z",
    "category_id": 1
  }
]

jsonに Hash が渡された時の挙動も確かめてみます。

def index
  render json: { data: 'hoge' }
end
{
  "data": "hoge"
}

なので状況を整理すると、以下のようになります。

  • jsonで指定するオブジェクトに対してto_jsonを呼び出す必要はない
  • jsonで指定するオブジェクトがto_jsonされて文字列の場合、それを整形して返す
  • jsonで指定するオブジェクトが Hash の場合は、それをそのまま返す

先程のこのコードの場合 { data: 'to_jsonによって生成された文字列' } という Hash が渡されたことになるので、整形してくれません。

def index
  render json: { data: Idea.order(:id).to_json(methods: [:category_name]) }
end

解決策

今回の場合は as_json を使って Hash の配列を data に渡す必要があります。

def index
  render json: { data: Idea.order(:id).as_json(methods: [:category_name]) }
end

Source code