From e1e7d13f8a34c598ae77579ab4a4dff3a2d1d27f Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sat, 25 Jan 2025 22:57:22 -0500 Subject: [PATCH] Configure `ActiveResource::Base.casing` The `Base.casing` configuration controls how resource classes handle API responses with cases that differ from Ruby's `camel_case` idioms. For example, setting `casing = :camelcase` will configure the resource to transform inbound camelCase JSON to under_score when loading API data: ```ruby payload = { id: 1, firstName: "Matz" }.as_json person = Person.load(payload) person.first_name # => "Matz" ``` Similarly, the casing configures the resource to transform outbound attributes from the intermediate `under_score` idiomatic Ruby names to the original `camelCase` names: ```ruby person.first_name # => "Matz" person.encode # => "{\"id\":1, \"firstName\":\"Matz\"}" ``` By default, resources are configured with `casing = :none`, which does not transform keys. In addition to `:none` and `:camelcase`, the `:underscore` configuration ensures idiomatic Ruby names throughout. When left unconfigured, `casing = :camelcase` will transform keys with a lower case first letter. To transform with upper case letters, construct an instance of `ActiveResource::Casings::CamelcaseCasing`: ```ruby Person.casing = ActiveResource::Casings::CamelcaseCasing.new(:upper) payload = { Id: 1, FirstName: "Matz" }.as_json person = Person.load(payload) person.first_name # => "Matz" person.encode #=> "{\"Id\":1,\"FirstName\":\"Matz\"}" ``` Casing transformations are also applied to query parameters built from `.where` clauses: ```ruby Person.casing = :camelcase Person.where(first_name: "Matz") # => GET /people.json?firstName=Matz ``` --- lib/active_resource.rb | 1 + lib/active_resource/base.rb | 62 ++++++++++++++++++- lib/active_resource/casings.rb | 20 ++++++ .../casings/camelcase_casing.rb | 17 +++++ lib/active_resource/casings/none_casing.rb | 30 +++++++++ .../casings/underscore_casing.rb | 16 +++++ test/cases/base/load_test.rb | 45 ++++++++++++++ test/cases/base_test.rb | 60 ++++++++++++++++++ test/cases/collection_test.rb | 17 +++++ test/cases/finder_test.rb | 20 ++++++ 10 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 lib/active_resource/casings.rb create mode 100644 lib/active_resource/casings/camelcase_casing.rb create mode 100644 lib/active_resource/casings/none_casing.rb create mode 100644 lib/active_resource/casings/underscore_casing.rb diff --git a/lib/active_resource.rb b/lib/active_resource.rb index 8e4f34ecf4..e3cf1d52ad 100644 --- a/lib/active_resource.rb +++ b/lib/active_resource.rb @@ -37,6 +37,7 @@ module ActiveResource autoload :Base autoload :Callbacks + autoload :Casings autoload :Connection autoload :CustomMethods autoload :Formats diff --git a/lib/active_resource/base.rb b/lib/active_resource/base.rb index 29376f081d..98e5f407c5 100644 --- a/lib/active_resource/base.rb +++ b/lib/active_resource/base.rb @@ -328,6 +328,7 @@ def self.logger=(logger) end class_attribute :_format + class_attribute :_casing class_attribute :_collection_parser class_attribute :include_format_in_path self.include_format_in_path = true @@ -592,6 +593,55 @@ def format self._format || ActiveResource::Formats::JsonFormat end + # Set the casing configuration to control how resource classes + # handle API responses with cases that differ from Ruby's camel_case + # idioms + def casing=(value) + self._casing = value.is_a?(Symbol) ? Casings[value].new : value + end + + # The casing configuration controls how resource classes handle API + # responses with cases that differ from Ruby's camel_case idioms. + # + # For example, setting casing = :camelcase will configure the + # resource to transform inbound camelCase JSON to under_score when loading + # API data: + # + # person = Person.load id: 1, firstName: "Matz" + # person.first_name # => "Matz" + # + # Similarly, the casing configures the resource to transform outbound + # attributes from the intermediate under_score idiomatic Ruby names to + # the original camelCase names: + # + # person.first_name # => "Matz" + # person.encode # => "{\"id\":1, \"firstName\":\"Matz\"}" + # + # By default, resources are configured with casing = :none, which + # does not transform keys. In addition to :none and + # :camelcase, the :underscore configuration ensures + # idiomatic Ruby names throughout. + # + # When left unconfigured, casing = :camelcase will transform keys + # with a lower case first letter. To transform with upper case letters, + # construct an instance of ActiveResource::Casings::CamelcaseCasing: + # + # Person.casing = ActiveResource::Casings::CamelcaseCasing.new(:upper) + # + # person = Person.load Id: 1, FirstName: "Matz" + # person.first_name # => "Matz" + # person.encode # => "{\"Id\":1,\"FirstName\":\"Matz\"}" + # + # Casing transformations are also applied to query parameters built from + # .where clauses: + # + # Person.casing = :camelcase + # + # Person.where first_name: "Matz" # => GET /people.json?firstName=Matz + def casing + self._casing || Casings[:none].new + end + # Sets the parser to use when a collection is returned. The parser must be Enumerable. def collection_parser=(parser_instance) parser_instance = parser_instance.constantize if parser_instance.is_a?(String) @@ -1108,6 +1158,8 @@ def find_every(options) format.decode(connection.get(path, headers).body) end + response = response.deep_transform_keys! { |key| casing.decode(key) } if response.is_a?(Hash) + instantiate_collection(response || [], query_options, prefix_options) rescue ActiveResource::ResourceNotFound # Swallowing ResourceNotFound exceptions and return nil - as per @@ -1164,7 +1216,7 @@ def prefix_parameters # Builds the query string for the request. def query_string(options) - "?#{options.to_query}" unless options.nil? || options.empty? + "?#{options.deep_transform_keys { |key| casing.encode(key) }.to_query}" unless options.nil? || options.empty? end # split an option hash into two hashes, one containing the prefix options, @@ -1471,7 +1523,7 @@ def load(attributes, remove_root = false, persisted = false) raise ArgumentError, "expected attributes to be able to convert to Hash, got #{attributes.inspect}" end - attributes = attributes.to_hash + attributes = attributes.to_hash.deep_transform_keys! { |key| self.class.casing.decode(key) } @prefix_options, attributes = split_options(attributes) if attributes.keys.size == 1 @@ -1555,13 +1607,17 @@ def respond_to_missing?(method, include_priv = false) end def to_json(options = {}) - super(include_root_in_json ? { root: self.class.element_name }.merge(options) : options) + super(include_root_in_json ? { root: self.class.casing.encode(self.class.element_name) }.merge(options) : options) end def to_xml(options = {}) super({ root: self.class.element_name }.merge(options)) end + def serializable_hash(options = nil) + super.deep_transform_keys! { |key| self.class.casing.encode(key) } + end + def read_attribute_for_serialization(n) if !attributes[n].nil? attributes[n] diff --git a/lib/active_resource/casings.rb b/lib/active_resource/casings.rb new file mode 100644 index 0000000000..3603ff18bd --- /dev/null +++ b/lib/active_resource/casings.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module ActiveResource + module Casings + extend ActiveSupport::Autoload + + autoload :CamelcaseCasing, "active_resource/casings/camelcase_casing" + autoload :NoneCasing, "active_resource/casings/none_casing" + autoload :UnderscoreCasing, "active_resource/casings/underscore_casing" + + # Lookup the casing class from a reference symbol. Example: + # + # ActiveResource::Casings[:camelcase] # => ActiveResource::Casings::CamelcaseCasing + # ActiveResource::Casings[:none] # => ActiveResource::Casings::NoneCasing + # ActiveResource::Casings[:underscore] # => ActiveResource::Casings::UnderscoreCasing + def self.[](name) + const_get(ActiveSupport::Inflector.camelize(name.to_s) + "Casing") + end + end +end diff --git a/lib/active_resource/casings/camelcase_casing.rb b/lib/active_resource/casings/camelcase_casing.rb new file mode 100644 index 0000000000..16bb3872f8 --- /dev/null +++ b/lib/active_resource/casings/camelcase_casing.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ActiveResource + module Casings + class CamelcaseCasing < UnderscoreCasing + def initialize(first_letter = :lower) + super() + @first_letter = first_letter + end + + private + def encode_key(key) + key.camelcase(@first_letter) + end + end + end +end diff --git a/lib/active_resource/casings/none_casing.rb b/lib/active_resource/casings/none_casing.rb new file mode 100644 index 0000000000..a72050258e --- /dev/null +++ b/lib/active_resource/casings/none_casing.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module ActiveResource + module Casings + class NoneCasing + def encode(key) + transform_key(key, &method(:encode_key)) + end + + def decode(key) + transform_key(key, &method(:decode_key)) + end + + private + def encode_key(key) + key + end + + def decode_key(key) + key + end + + def transform_key(key) + transformed_key = yield key.to_s + + key.is_a?(Symbol) ? transformed_key.to_sym : transformed_key + end + end + end +end diff --git a/lib/active_resource/casings/underscore_casing.rb b/lib/active_resource/casings/underscore_casing.rb new file mode 100644 index 0000000000..afd19f1e97 --- /dev/null +++ b/lib/active_resource/casings/underscore_casing.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ActiveResource + module Casings + class UnderscoreCasing < NoneCasing + private + def encode_key(key) + key.underscore + end + + def decode_key(key) + key.underscore + end + end + end +end diff --git a/test/cases/base/load_test.rb b/test/cases/base/load_test.rb index 94e2915228..1d8150fd10 100644 --- a/test/cases/base/load_test.rb +++ b/test/cases/base/load_test.rb @@ -51,6 +51,7 @@ def setup @first_address = { address: { id: 1, street: "12345 Street" } } @addresses = [@first_address, { address: { id: 2, street: "67890 Street" } }] @addresses_from_json = { street_addresses: @addresses } + @addresses_from_camelcase_json = { streetAddresses: @addresses } @addresses_from_json_single = { street_addresses: [ @first_address ] } @deep = { id: 1, street: { @@ -123,6 +124,30 @@ def test_load_simple_hash assert_equal @matz.stringify_keys, @person.load(@matz).attributes end + def test_load_simple_camelcase_hash + Person.casing = :camelcase + encoded = { firstName: "Matz" } + decoded = { first_name: "Matz" } + + assert_equal Hash.new, @person.attributes + assert_equal decoded.stringify_keys, @person.load(encoded).attributes + assert_equal decoded.stringify_keys, @person.load(decoded).attributes + ensure + Person.casing = nil + end + + def test_load_simple_underscore_hash + Person.casing = :underscore + encoded = { firstName: "Matz" } + decoded = { first_name: "Matz" } + + assert_equal Hash.new, @person.attributes + assert_equal decoded.stringify_keys, @person.load(encoded).attributes + assert_equal decoded.stringify_keys, @person.load(decoded).attributes + ensure + Person.casing = nil + end + def test_load_object_with_implicit_conversion_to_hash assert_equal @matz.stringify_keys, @person.load(FakeParameters.new(@matz)).attributes end @@ -165,6 +190,26 @@ def test_load_collection_with_existing_resource assert_equal @addresses.map { |a| a[:address].stringify_keys }, addresses.map(&:attributes) end + def test_load_underscore_collection_with_existing_resource + Person.casing = :underscore + addresses = @person.load(@addresses_from_json).street_addresses + assert_kind_of Array, addresses + addresses.each { |address| assert_kind_of StreetAddress, address } + assert_equal @addresses.map { |a| a[:address].stringify_keys }, addresses.map(&:attributes) + ensure + Person.casing = nil + end + + def test_load_camelcase_collection_with_existing_resource + Person.casing = :camelcase + addresses = @person.load(@addresses_from_camelcase_json).street_addresses + assert_kind_of Array, addresses + addresses.each { |address| assert_kind_of StreetAddress, address } + assert_equal @addresses.map { |a| a[:address].stringify_keys }, addresses.map(&:attributes) + ensure + Person.casing = nil + end + def test_load_collection_with_unknown_resource Person.__send__(:remove_const, :Address) if Person.const_defined?(:Address) assert_not Person.const_defined?(:Address), "Address shouldn't exist until autocreated" diff --git a/test/cases/base_test.rb b/test/cases/base_test.rb index 3689b4234d..f15f1f84d6 100644 --- a/test/cases/base_test.rb +++ b/test/cases/base_test.rb @@ -1393,6 +1393,21 @@ def test_to_xml Person.format = :json end + def test_to_xml_with_camelcase_casing + Person.format = :xml + Person.casing = :camelcase + matz = Person.new id: 1, firstName: "Matz" + encode = matz.encode + xml = matz.to_xml + + assert_equal encode, xml + assert xml.include?('') + assert xml.include?("Matz") + assert xml.include?('1') + ensure + Person.format = Person.casing = nil + end + def test_to_xml_with_element_name Person.format = :xml old_elem_name = Person.element_name @@ -1441,6 +1456,51 @@ def test_to_json assert_match %r{\}\}$}, json end + def test_to_json_with_camelcase_casing + Person.casing = :camelcase + joe = Person.new id: 6, firstName: "Joe" + encode = joe.encode + json = joe.to_json + + assert_equal encode, json + assert_match %r{^\{"person":\{}, json + assert_match %r{"id":6}, json + assert_match %r{"firstName":"Joe"}, json + assert_match %r{\}\}$}, json + ensure + Person.casing = nil + end + + def test_to_json_with_upper_camelcase_casing + Person.casing = ActiveResource::Casings::CamelcaseCasing.new(:upper) + joe = Person.new id: 6, FirstName: "Joe" + encode = joe.encode + json = joe.to_json + + assert_equal encode, json + assert_match %r{^\{"Person":\{}, json + assert_match %r{"Id":6}, json + assert_match %r{"FirstName":"Joe"}, json + assert_match %r{\}\}$}, json + ensure + Person.casing = nil + end + + def test_to_json_with_underscore_casing + Person.casing = :underscore + joe = Person.new id: 6, first_name: "Joe" + encode = joe.encode + json = joe.to_json + + assert_equal encode, json + assert_match %r{^\{"person":\{}, json + assert_match %r{"id":6}, json + assert_match %r{"first_name":"Joe"}, json + assert_match %r{\}\}$}, json + ensure + Person.casing = nil + end + def test_to_json_without_root ActiveResource::Base.include_root_in_json = false joe = Person.find(6) diff --git a/test/cases/collection_test.rb b/test/cases/collection_test.rb index 94a5496ecd..7ef293bc92 100644 --- a/test/cases/collection_test.rb +++ b/test/cases/collection_test.rb @@ -105,6 +105,23 @@ def test_custom_accessor assert_equal PaginatedPost.find(:all).next_page, @posts_hash[:next_page] end + def test_with_camelcase + PaginatedPost.casing = :camelcase + posts_hash = { "results" => [@post], "nextPage" => "/paginated_posts.json?page=2" } + ActiveResource::HttpMock.respond_to.get "/paginated_posts.json", {}, posts_hash.to_json + + assert_equal PaginatedPost.find(:all).next_page, posts_hash["nextPage"] + ensure + PaginatedPost.casing = nil + end + + def test_with_underscore + PaginatedPost.casing = :underscore + assert_equal PaginatedPost.find(:all).next_page, @posts_hash[:next_page] + ensure + PaginatedPost.casing = nil + end + def test_first_or_create post = PaginatedPost.where(title: "test").first_or_create assert post.valid? diff --git a/test/cases/finder_test.rb b/test/cases/finder_test.rb index 5aea5496ab..2c9751037c 100644 --- a/test/cases/finder_test.rb +++ b/test/cases/finder_test.rb @@ -63,6 +63,26 @@ def test_where_with_clauses assert_kind_of StreetAddress, addresses.first end + def test_where_with_clauses_and_camelcase_casing + Person.casing = :camelcase + ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?firstName=david", {}, @people_david } + people = Person.where(first_name: "david") + assert_equal 1, people.size + assert_kind_of Person, people.first + ensure + Person.casing = nil + end + + def test_where_with_clauses_and_underscore_casing + Person.casing = :underscore + ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?first_name=david", {}, @people_david } + people = Person.where(first_name: "david") + assert_equal 1, people.size + assert_kind_of Person, people.first + ensure + Person.casing = nil + end + def test_where_with_clause_in ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?id%5B%5D=2", {}, @people_david } people = Person.where(id: [2])