Transform attributes during `ActiveModel::Serialization::JSON#from_json`
Created by: seanpdoyle
Motivation / Background
The problem
Loading JSON into an Active Model instance with
ActiveModel::Serializers:JSON#from_json
assumes that the property casings will match the class attribute casings. This works well with snake_casing, since idiomatic Ruby methods are snake_cased.
When #from_json
loads JSON properties that are camelCased, it silently ignores them:
class Person
include ActiveModel::Serializers::JSON
attr_accessor :name, :born_on
def attributes=(hash)
hash.each { |key, value| send("#{key}=", value) }
end
end
payload <<~JSON
{ "name": "Alice", "bornOn": "2024-03-08" }
JSON
person = Person.new.from_json(payload)
person.name # => "Alice"
person.born_on # => nil
Detail
The proposal
This commit proposes extending #from_json
to accept a block. After the JSON string is decoded (and un-nested from its root, depending on the model's configuration), yield the Hash
to a block if one is passed as an argument:
payload <<~JSON
{ "name": "Alice", "bornOn": "2024-03-08" }
JSON
person = Person.new.from_json(payload) { _1.deep_transform_keys!(&:underscore) }
person.name # => "Alice"
person.born_on # => "2024-03-08"
Supporting a block can be useful for context-specific overrides. If a class needs to provide a default transformation, it can override #from_json
:
class PersonFromCamelCaseAPI < Person
def from_json(*, &block)
default_transform = proc { _1.deep_transform_keys!(&:underscore) }
super(*, &(block || defaul_transform))
end
end
Additional information
Without built-in support for transforming camelCased properties into snake_cased attributes, callers are responsible decoding the JSON themselves. In addition to the key transformations, they're also responsible for re-creating both the JSON decoding and the configurable root un-nesting provided by #from_json
:
payload <<~JSON
{ "name": "Alice", "bornOn": "2024-03-08" }
JSON
attributes = ActiveSupport::JSON.decode(payload)
attributes.deep_transform_keys!(&:underscore)
person = Person.new(attributes)
person.name # => "Alice"
person.born_on # => "2024-03-08"
nested_payload <<~JSON
{ "person": { "name": "Alice", "bornOn": "2024-03-08" } }
JSON
attributes = ActiveSupport::JSON.decode(nested_payload)
attributes.deep_transform_keys!(&:underscore)
person = Person.new(attributes["person"])
person.name # => "Alice"
person.born_on # => "2024-03-08"
Checklist
Before submitting the PR make sure the following are checked:
-
This Pull Request is related to one change. Changes that are unrelated should be opened in separate PRs. -
Commit message has a detailed description of what changed and why. If this PR fixes a related issue include it in the commit message. Ex: [Fix #issue-number]
-
Tests are added or updated if you fix a bug or add a feature. -
CHANGELOG files are updated for the changed libraries if there is a behavior change or additional feature. Minor bug fixes and documentation changes should not be included.