Skip to content

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.

Merge request reports