From 0342b208233b45b8ede8319698215f120413294e Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sun, 5 Jan 2025 10:32:25 -0500 Subject: [PATCH] Implement `Base#encode` in terms of `format.encode` The problem --- There isn't a conventional way to transform a resource's attributes before they're sent to a remote server. For example, consider a resource that needs to transform snake_case attribute keys (which are idiomatic to Ruby's hash keys) into camelCase keys for a service's JSON API prior to sending them as a request payload. Similarly, consider transforming a response payload's camelCase keys back into snake_case Hash instances to be loaded as attributes. The proposal --- Prior to this commit, the `Base#encode` method *did not* utilize the resource class' configured format's `encode` method. Instead, it relied on the format's `#extension` to invoke the appropriate method (for example, `"xml"` would invoke `#to_xml`, `"json"` would invoke `#to_json`, a hypothetical `"msgpack"` custom format would invoke a hypothetical `#to_msgpack` method, etc.) Since `#to_json` and `#to_xml` (and presumable `#to_msgpack`) result in already-encoded String values, there isn't an opportunity for consumers to transform keys. This means they're responsible for being familiar with the underlying method invocation's interface (for example, that `to_json` calls `as_json`, and that `as_json` calls `serializable_hash`), or they're responsible for decoding and re-encoding after the modifications. To resolve that issue, this commit modifies the `Base#encode` method to delegate to the configured format's `#encode` method. To preserve backwards compatibility and to continue to support out-of-the-box behavior, this commit ensures that those formats invoke the appropriate method on the resource instance (`to_xml` for `XmlFormat`, `to_json` for `JsonFormat`). This change both simplifies the implementation (by removing the `send("to_#{format.extension}", ...)` metaprogramming) *and* introduces a seam for consumers to override behavior. For example, consumers can now declare customized formatters to serve their encoding and decoding needs (like a snake_case->camelCase->snake_case chain): ```ruby module CamelcaseJsonFormat extend ActiveResource::Formats[:json] def self.encode(resource, options = nil) hash = resource.as_json(options) hash = hash.deep_transform_keys! { |key| key.camelcase(:lower) } super(hash) end def decode(json) hash = super hash.deep_transform_keys! { |key| key.underscore } end end Person.format = CamelcaseJsonFormat person = Person.new(first_name: "First", last_name: "Last") person.encode # => "{\"person\":{\"firstName\":\"First\",\"lastName\":\"Last\"}}" Person.format.decode(person.encode) # => {"first_name"=>"First", "last_name"=>"Last"} ``` --- lib/active_resource/base.rb | 60 +++++++++++++++++++++- lib/active_resource/formats/json_format.rb | 4 +- lib/active_resource/formats/xml_format.rb | 4 +- test/cases/format_test.rb | 54 +++++++++++++++++++ 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/lib/active_resource/base.rb b/lib/active_resource/base.rb index 9e556b2014..d0dae3c484 100644 --- a/lib/active_resource/base.rb +++ b/lib/active_resource/base.rb @@ -77,6 +77,58 @@ module ActiveResource # As you can see, these are very similar to Active Record's life cycle methods for database records. # You can read more about each of these methods in their respective documentation. # + # Active Resource objects provide out-of-the-box support for both JSON and XML + # formats. A resource object's format encodes attributes into request payloads and decodes response payloads + # into attributes. + # + # The default format is ActiveResource::Formats::JsonFormat. To change the + # format, configure the resource object class' +format+: + # + # Person.format = :json + # person = Person.find(1) # => GET /people/1.json + # + # person.encode + # # => "{\"person\":{\"id\":1,\"full_name\":\"First Last\"}}" + # + # Person.format.decode(person.encode) + # # => {"id"=>1, "full_name"=>"First Last"} + # + # Person.format = :xml + # person = Person.find(1) # => GET /people/1.xml + # + # person.encode + # # => "1First Last" + # + # Person.format.decode(person.encode) + # # => {"full_name"=>"First Last"} + # + # To customize how attributes are encoded and decoded, declare a format and override + # its +encode+ and +decode+ methods: + # + # module CamelcaseJsonFormat + # extend ActiveResource::Formats[:json] + # + # def self.encode(resource, options = nil) + # hash = resource.as_json(options) + # hash = hash.deep_transform_keys! { |key| key.camelcase(:lower) } + # super(hash) + # end + # + # def decode(json) + # hash = super + # hash.deep_transform_keys! { |key| key.underscore } + # end + # end + # + # Person.format = CamelcaseJsonFormat + # + # person = Person.new(first_name: "First", last_name: "Last") + # person.encode + # # => "{\"person\":{\"firstName\":\"First\",\"lastName\":\"Last\"}}" + # + # Person.format.decode(person.encode) + # # => {"first_name"=>"First", "last_name"=>"Last"} + # # === Custom REST methods # # Since simple CRUD/life cycle methods can't accomplish every task, Active Resource also supports @@ -1459,9 +1511,13 @@ def exists? # Returns the serialized string representation of the resource in the configured # serialization format specified in ActiveResource::Base.format. The options - # applicable depend on the configured encoding format. + # applicable depend on the configured encoding format, and are forwarded to + # the corresponding serializer method. + # + # ActiveResource::Formats::JsonFormat delegates to Base#to_json and + # ActiveResource::Formats::XmlFormat delegates to Base#to_xml. def encode(options = {}) - send("to_#{self.class.format.extension}", options) + self.class.format.encode(self, options) end # A method to \reload the attributes of this object from the remote web service. diff --git a/lib/active_resource/formats/json_format.rb b/lib/active_resource/formats/json_format.rb index 67795f95e3..3bbe8da34a 100644 --- a/lib/active_resource/formats/json_format.rb +++ b/lib/active_resource/formats/json_format.rb @@ -15,8 +15,8 @@ def mime_type "application/json" end - def encode(hash, options = nil) - ActiveSupport::JSON.encode(hash, options) + def encode(resource, options = nil) + resource.to_json(options) end def decode(json) diff --git a/lib/active_resource/formats/xml_format.rb b/lib/active_resource/formats/xml_format.rb index 5fbf967e08..f86ab56115 100644 --- a/lib/active_resource/formats/xml_format.rb +++ b/lib/active_resource/formats/xml_format.rb @@ -15,8 +15,8 @@ def mime_type "application/xml" end - def encode(hash, options = {}) - hash.to_xml(options) + def encode(resource, options = {}) + resource.to_xml(options) end def decode(xml) diff --git a/test/cases/format_test.rb b/test/cases/format_test.rb index 1d0ab4b358..66ece20cbf 100644 --- a/test/cases/format_test.rb +++ b/test/cases/format_test.rb @@ -108,6 +108,60 @@ def test_serialization_of_nested_resource end end + def test_custom_json_format + format_class = Class.new do + include ActiveResource::Formats[:json] + + def initialize(encoder:, decoder:) + @encoder, @decoder = encoder, decoder + end + + def encode(resource, options = nil) + hash = resource.as_json(options) + hash = hash.deep_transform_keys!(&@encoder) + super(hash) + end + + def decode(json) + super.deep_transform_keys!(&@decoder) + end + end + + format = format_class.new(encoder: ->(key) { key.camelcase(:lower) }, decoder: :underscore) + + using_format(Person, format) do + person = Person.new(name: "Joe", likes_hats: true) + json = { person: { name: "Joe", likesHats: true } }.to_json + + assert_equal person, Person.new(format.decode(json)) + assert_equal person.encode, json + end + end + + def test_custom_xml_format + format = Module.new do + extend self, ActiveResource::Formats[:xml] + + def encode(value, options = {}) + xml = value.serializable_hash(options) + xml.deep_transform_keys!(&:camelcase) + super(xml, root: value.class.element_name) + end + + def decode(value) + super.deep_transform_keys!(&:underscore) + end + end + + using_format(Person, format) do + person = Person.new(name: "Joe", likes_hats: true) + xml = { Name: "Joe", "LikesHats": true }.to_xml(root: "person") + + assert_equal person, Person.new(format.decode(xml)) + assert_equal person.encode, xml + end + end + def test_removing_root matz = { name: "Matz" } matz_with_root = { person: matz }