Parsing JSON straight into objects, with a hook almost nobody uses
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.
Comments
No comments yet. Be the first.