Skip to content

Configure ActiveResource::Base.casing #421

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/active_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ module ActiveResource

autoload :Base
autoload :Callbacks
autoload :Casings
autoload :Connection
autoload :CustomMethods
autoload :Formats
Expand Down
62 changes: 59 additions & 3 deletions lib/active_resource/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -592,6 +593,55 @@ def format
self._format || ActiveResource::Formats::JsonFormat
end

# Set the <tt>casing</tt> 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 <tt>casing</tt> configuration controls how resource classes handle API
# responses with cases that differ from Ruby's camel_case idioms.
#
# For example, setting <tt>casing = :camelcase</tt> 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 <tt>casing = :none</tt>, which
# does not transform keys. In addition to <tt>:none</tt> and
# <tt>:camelcase</tt>, the <tt>:underscore</tt> configuration ensures
# idiomatic Ruby names throughout.
#
# When left unconfigured, <tt>casing = :camelcase</tt> 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
# <tt>.where</tt> 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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
20 changes: 20 additions & 0 deletions lib/active_resource/casings.rb
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions lib/active_resource/casings/camelcase_casing.rb
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions lib/active_resource/casings/none_casing.rb
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions lib/active_resource/casings/underscore_casing.rb
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions test/cases/base/load_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
60 changes: 60 additions & 0 deletions test/cases/base_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?('<?xml version="1.0" encoding="UTF-8"?>')
assert xml.include?("<firstName>Matz</firstName>")
assert xml.include?('<id type="integer">1</id>')
ensure
Person.format = Person.casing = nil
end

def test_to_xml_with_element_name
Person.format = :xml
old_elem_name = Person.element_name
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions test/cases/collection_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
20 changes: 20 additions & 0 deletions test/cases/finder_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading