[ ].blue

JSON Model Mapping in Ruby

June 28, 2013

I ran upon an interesting situation for which I couldn't find a simple solution. A good majority of Rails apps are built with ActiveRecord and an relational database. However I needed to build a site where reading and writing data was being provided by a bulk import/export JSON type API. The way this API worked was that a "read" returned the entire JSON document. Similarly, to write a POST was sent to the server with the POST body being the same JSON document (of course modified).

I wanted to allow the Ruby code to behave exactly as if it was ActiveRecord and to at the model level abstract over this slightly different scenario while at the same time leveraging features of ActiveModel such as validations. To solve this problem I reached into the meta-programming back of tricks and wrote a module to handle both filling a model from JSON and creating JSON from that model. Since these are effectively the same relationship I wanted to declare in one place how the JSON and model were related and let the library handle the rest.

There was also a secondary concern that I had: making the property names in Ruby's favored snakecase instead of Javascript's camelcase.

What I came up with (complete source below) takes a hash table of symbol/string, with the symbol being the property name and the string being the path in the JSON.

Example

Assume the following JSON.

1
2
3
4
5
6
7
8
9
10
11
12
[
  {
    "name": "Bob",
    "emailAddress": "bob@example.com",
    "relationships": {
      "projectManagers": [
        "Bill", "Susan"
      ]
    }
  },
  // ...
]

In this we want a simple way to turn each item of the outer array int a class Person. We don't want to have camelcase names like emailAddress and projectManagers, and we also want to flatten out the relationships so that we don't have to deal with it on our model. The JsonMapper module I wrote handles both of these cases and to map the above JSON to a model it would only require:

1
2
3
4
5
6
7
8
class Person
  include JsonMapper

  attr_accessor :name, :email, :project_managers

  json_map name: "name",
           email: "emailAddress",
           project_managers: "relationships.projectManagers"

Once the json_map directive is called a new class method from_json is now on the class, allowing a JSON string to be passed in and the values used to return a model filled with those values.

1
Person.from_json("raw JSON string")

It also defines an as_json method for turning an instance of a Person model into the exact JSON we see above.

Module Source

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
module JsonMapper
  extend ActiveSupport::Concern

  def as_json(options={})
    json = {}
    self.class.json_mappings.each do |class_attr, json_attr|
      set_hash_attr(json, json_attr.to_s, self.send(class_attr))
    end
    json
  end

  included do
    @json_mapping = {}
  end

  module ClassMethods
    # attr_accessor :json_mapping
    def json_mappings; @json_mapping;  end
    def json_mappings=(value); @json_mapping = value; end

    def json_map(class_attr_or_hash, json_attr=nil)
      if class_attr_or_hash.is_a? Hash
        class_attr_or_hash.each { |k,v| json_mappings[k.to_sym] = v.to_s }
      else
        json_mappings[class_attr_or_hash.to_sym] = json_attr.to_s
      end
    end

    def from_json(json)
      instance = self.new
      json = JSON.parse(json) if json.is_a? String
      set_instance_attrs(instance, json)
      instance
    end

    private

    def set_instance_attrs(instance, json, parent="")
      json.each do |key, value|
        path = parent + key.to_s
        if value.is_a? Hash
          set_instance_attrs(instance, value, path + ".")
        else
          attr = find_mapped_path_attr(path)
          instance.send("#{attr}=", value) if attr and instance.respond_to?("#{attr}=")
        end
      end
    end

    def find_mapped_path_attr(path)
      json_mappings.keys.select { |key| json_mappings[key] == path }.first
    end
  end

  private

  def set_hash_attr(hash, attr_path, value)
    if attr_path.include? "."
      segments = attr_path.split(".")
      root_attr = segments.shift
      hash[root_attr] = {} unless hash[root_attr]
      return set_hash_attr(hash[root_attr], segments.join("."), value)
    else
      value = value.as_json if value.respond_to? :as_json
      hash[attr_path] = value
      return hash
    end
  end
end