LaunchKit
← All posts
Video · 4 min read · by Mehdi Farsi · 1 views

Parsing JSON straight into objects, with a hook almost nobody uses

Watch on YouTube

Text version

You receive a data file from a third-party API or an SFTP drop. It is a list of records, each one tagged with what it is and carrying its own attributes:

[
  { "resource_type": "Legend", "data": { "email": "jim@example.com", "fullname": "Jim Weirich" } },
  { "resource_type": "Legend", "data": { "email": "chris@example.com", "fullname": "Chris Seaton" } }
]

The usual answer is two passes. JSON.parse turns the file into an array of hashes, then you walk that array and turn each hash into a model. Both loops are over the same data, and the first one builds something you throw away.

Ruby's JSON library has a hook that removes the second pass. It is in the standard library, it has been there for years, and almost nobody reaches for it.

The key that names the class

Start in IRB, because the default is the thing worth seeing:

require "json"

JSON.create_id
# => "json_class"

create_id is the key the parser looks for in each object to decide which class to hand it to. The default is json_class, which is what Ruby's own serialisation writes. It is also a setter, and that is the whole trick:

JSON.create_id = "resource_type"

Now the parser looks for resource_type, which is the key that already exists in the file. Nothing was negotiated with the provider and nothing was rewritten on the way in. Their vocabulary became your dispatch key.

The hook a class opts in with

A class joins in by defining one class method:

class Legend
  attr_reader :email, :fullname

  def initialize(json)
    @email    = json["email"]
    @fullname = json["fullname"]
  end

  def self.json_create(object)
    new(object["data"])
  end
end

json_create receives the whole entry, so it can reach wherever the payload keeps the attributes. Here they sit under data, which is why the hook passes object["data"] to the constructor rather than the entry itself. That indirection is the point: the shape of their file and the shape of your initialiser no longer have to match, and the one method that reconciles them is the only place the mapping lives.

Note what it returns. Nothing forces it to return an instance of the class it is defined on. It returns whatever that entry should become.

The flag without which none of it runs

JSON.parse(source, create_additions: true)
# => [#<Legend jim@example.com>, #<Legend chris@example.com>]

Drop create_additions: true and you get an array of hashes. No error, no warning, no hint that a hook existed and was skipped. The flag is opt-in because the feature instantiates classes named inside the document, and a parser that did that by default on any input would be a liability.

Which is also the rule for using it at all: turn it on for data whose shape you control or whose provider you trust. It is a tool for an agreed format, not for parsing the open internet.

What happens to a record that does not match

Take the same file and remove the key from the second entry:

[
  { "resource_type": "Legend", "data": { "email": "jim@example.com" } },
  { "data": { "email": "chris@example.com" } }
]
JSON.parse(source, create_additions: true).map(&:class)
# => [Legend, Hash]

The first becomes a Legend, the second stays a Hash. The degradation is per record, not per file: one unrecognised entry does not take the import down, and it arrives in a form you can inspect and route yourself.

What this is actually worth

The saving is one iteration, which on a large import is real but is not the interesting part.

The interesting part is where the mapping ends up. Without the hook, the knowledge of how their records become your objects lives in whatever loop happens to consume the parse, and it gets copied the second time someone imports the same feed. With it, that knowledge sits in one class method on the class that cares, and the parse is where it is applied.

What this does not do

It is not a schema validator. json_create will hand your constructor whatever the payload contains, including nothing, so validation remains yours to write.

It is not for untrusted input, for the reason above.

And it is not the Ruby-to-Ruby round trip that json/add/core gives you, where Time, Symbol, Range and friends serialise themselves and come back as themselves. That is the same machinery pointed at your own data rather than at somebody else's.

#ruby #video #json

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.