Skip to content

Commit e1e7d13

Browse files
committed
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 ```
1 parent 9c8a2ee commit e1e7d13

10 files changed

+285
-3
lines changed

lib/active_resource.rb

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ module ActiveResource
3737

3838
autoload :Base
3939
autoload :Callbacks
40+
autoload :Casings
4041
autoload :Connection
4142
autoload :CustomMethods
4243
autoload :Formats

lib/active_resource/base.rb

+59-3
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ def self.logger=(logger)
328328
end
329329

330330
class_attribute :_format
331+
class_attribute :_casing
331332
class_attribute :_collection_parser
332333
class_attribute :include_format_in_path
333334
self.include_format_in_path = true
@@ -592,6 +593,55 @@ def format
592593
self._format || ActiveResource::Formats::JsonFormat
593594
end
594595

596+
# Set the <tt>casing</tt> configuration to control how resource classes
597+
# handle API responses with cases that differ from Ruby's camel_case
598+
# idioms
599+
def casing=(value)
600+
self._casing = value.is_a?(Symbol) ? Casings[value].new : value
601+
end
602+
603+
# The <tt>casing</tt> configuration controls how resource classes handle API
604+
# responses with cases that differ from Ruby's camel_case idioms.
605+
#
606+
# For example, setting <tt>casing = :camelcase</tt> will configure the
607+
# resource to transform inbound camelCase JSON to under_score when loading
608+
# API data:
609+
#
610+
# person = Person.load id: 1, firstName: "Matz"
611+
# person.first_name # => "Matz"
612+
#
613+
# Similarly, the casing configures the resource to transform outbound
614+
# attributes from the intermediate under_score idiomatic Ruby names to
615+
# the original camelCase names:
616+
#
617+
# person.first_name # => "Matz"
618+
# person.encode # => "{\"id\":1, \"firstName\":\"Matz\"}"
619+
#
620+
# By default, resources are configured with <tt>casing = :none</tt>, which
621+
# does not transform keys. In addition to <tt>:none</tt> and
622+
# <tt>:camelcase</tt>, the <tt>:underscore</tt> configuration ensures
623+
# idiomatic Ruby names throughout.
624+
#
625+
# When left unconfigured, <tt>casing = :camelcase</tt> will transform keys
626+
# with a lower case first letter. To transform with upper case letters,
627+
# construct an instance of ActiveResource::Casings::CamelcaseCasing:
628+
#
629+
# Person.casing = ActiveResource::Casings::CamelcaseCasing.new(:upper)
630+
#
631+
# person = Person.load Id: 1, FirstName: "Matz"
632+
# person.first_name # => "Matz"
633+
# person.encode # => "{\"Id\":1,\"FirstName\":\"Matz\"}"
634+
#
635+
# Casing transformations are also applied to query parameters built from
636+
# <tt>.where</tt> clauses:
637+
#
638+
# Person.casing = :camelcase
639+
#
640+
# Person.where first_name: "Matz" # => GET /people.json?firstName=Matz
641+
def casing
642+
self._casing || Casings[:none].new
643+
end
644+
595645
# Sets the parser to use when a collection is returned. The parser must be Enumerable.
596646
def collection_parser=(parser_instance)
597647
parser_instance = parser_instance.constantize if parser_instance.is_a?(String)
@@ -1108,6 +1158,8 @@ def find_every(options)
11081158
format.decode(connection.get(path, headers).body)
11091159
end
11101160

1161+
response = response.deep_transform_keys! { |key| casing.decode(key) } if response.is_a?(Hash)
1162+
11111163
instantiate_collection(response || [], query_options, prefix_options)
11121164
rescue ActiveResource::ResourceNotFound
11131165
# Swallowing ResourceNotFound exceptions and return nil - as per
@@ -1164,7 +1216,7 @@ def prefix_parameters
11641216

11651217
# Builds the query string for the request.
11661218
def query_string(options)
1167-
"?#{options.to_query}" unless options.nil? || options.empty?
1219+
"?#{options.deep_transform_keys { |key| casing.encode(key) }.to_query}" unless options.nil? || options.empty?
11681220
end
11691221

11701222
# split an option hash into two hashes, one containing the prefix options,
@@ -1471,7 +1523,7 @@ def load(attributes, remove_root = false, persisted = false)
14711523
raise ArgumentError, "expected attributes to be able to convert to Hash, got #{attributes.inspect}"
14721524
end
14731525

1474-
attributes = attributes.to_hash
1526+
attributes = attributes.to_hash.deep_transform_keys! { |key| self.class.casing.decode(key) }
14751527
@prefix_options, attributes = split_options(attributes)
14761528

14771529
if attributes.keys.size == 1
@@ -1555,13 +1607,17 @@ def respond_to_missing?(method, include_priv = false)
15551607
end
15561608

15571609
def to_json(options = {})
1558-
super(include_root_in_json ? { root: self.class.element_name }.merge(options) : options)
1610+
super(include_root_in_json ? { root: self.class.casing.encode(self.class.element_name) }.merge(options) : options)
15591611
end
15601612

15611613
def to_xml(options = {})
15621614
super({ root: self.class.element_name }.merge(options))
15631615
end
15641616

1617+
def serializable_hash(options = nil)
1618+
super.deep_transform_keys! { |key| self.class.casing.encode(key) }
1619+
end
1620+
15651621
def read_attribute_for_serialization(n)
15661622
if !attributes[n].nil?
15671623
attributes[n]

lib/active_resource/casings.rb

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveResource
4+
module Casings
5+
extend ActiveSupport::Autoload
6+
7+
autoload :CamelcaseCasing, "active_resource/casings/camelcase_casing"
8+
autoload :NoneCasing, "active_resource/casings/none_casing"
9+
autoload :UnderscoreCasing, "active_resource/casings/underscore_casing"
10+
11+
# Lookup the casing class from a reference symbol. Example:
12+
#
13+
# ActiveResource::Casings[:camelcase] # => ActiveResource::Casings::CamelcaseCasing
14+
# ActiveResource::Casings[:none] # => ActiveResource::Casings::NoneCasing
15+
# ActiveResource::Casings[:underscore] # => ActiveResource::Casings::UnderscoreCasing
16+
def self.[](name)
17+
const_get(ActiveSupport::Inflector.camelize(name.to_s) + "Casing")
18+
end
19+
end
20+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveResource
4+
module Casings
5+
class CamelcaseCasing < UnderscoreCasing
6+
def initialize(first_letter = :lower)
7+
super()
8+
@first_letter = first_letter
9+
end
10+
11+
private
12+
def encode_key(key)
13+
key.camelcase(@first_letter)
14+
end
15+
end
16+
end
17+
end
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveResource
4+
module Casings
5+
class NoneCasing
6+
def encode(key)
7+
transform_key(key, &method(:encode_key))
8+
end
9+
10+
def decode(key)
11+
transform_key(key, &method(:decode_key))
12+
end
13+
14+
private
15+
def encode_key(key)
16+
key
17+
end
18+
19+
def decode_key(key)
20+
key
21+
end
22+
23+
def transform_key(key)
24+
transformed_key = yield key.to_s
25+
26+
key.is_a?(Symbol) ? transformed_key.to_sym : transformed_key
27+
end
28+
end
29+
end
30+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveResource
4+
module Casings
5+
class UnderscoreCasing < NoneCasing
6+
private
7+
def encode_key(key)
8+
key.underscore
9+
end
10+
11+
def decode_key(key)
12+
key.underscore
13+
end
14+
end
15+
end
16+
end

test/cases/base/load_test.rb

+45
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def setup
5151
@first_address = { address: { id: 1, street: "12345 Street" } }
5252
@addresses = [@first_address, { address: { id: 2, street: "67890 Street" } }]
5353
@addresses_from_json = { street_addresses: @addresses }
54+
@addresses_from_camelcase_json = { streetAddresses: @addresses }
5455
@addresses_from_json_single = { street_addresses: [ @first_address ] }
5556

5657
@deep = { id: 1, street: {
@@ -123,6 +124,30 @@ def test_load_simple_hash
123124
assert_equal @matz.stringify_keys, @person.load(@matz).attributes
124125
end
125126

127+
def test_load_simple_camelcase_hash
128+
Person.casing = :camelcase
129+
encoded = { firstName: "Matz" }
130+
decoded = { first_name: "Matz" }
131+
132+
assert_equal Hash.new, @person.attributes
133+
assert_equal decoded.stringify_keys, @person.load(encoded).attributes
134+
assert_equal decoded.stringify_keys, @person.load(decoded).attributes
135+
ensure
136+
Person.casing = nil
137+
end
138+
139+
def test_load_simple_underscore_hash
140+
Person.casing = :underscore
141+
encoded = { firstName: "Matz" }
142+
decoded = { first_name: "Matz" }
143+
144+
assert_equal Hash.new, @person.attributes
145+
assert_equal decoded.stringify_keys, @person.load(encoded).attributes
146+
assert_equal decoded.stringify_keys, @person.load(decoded).attributes
147+
ensure
148+
Person.casing = nil
149+
end
150+
126151
def test_load_object_with_implicit_conversion_to_hash
127152
assert_equal @matz.stringify_keys, @person.load(FakeParameters.new(@matz)).attributes
128153
end
@@ -165,6 +190,26 @@ def test_load_collection_with_existing_resource
165190
assert_equal @addresses.map { |a| a[:address].stringify_keys }, addresses.map(&:attributes)
166191
end
167192

193+
def test_load_underscore_collection_with_existing_resource
194+
Person.casing = :underscore
195+
addresses = @person.load(@addresses_from_json).street_addresses
196+
assert_kind_of Array, addresses
197+
addresses.each { |address| assert_kind_of StreetAddress, address }
198+
assert_equal @addresses.map { |a| a[:address].stringify_keys }, addresses.map(&:attributes)
199+
ensure
200+
Person.casing = nil
201+
end
202+
203+
def test_load_camelcase_collection_with_existing_resource
204+
Person.casing = :camelcase
205+
addresses = @person.load(@addresses_from_camelcase_json).street_addresses
206+
assert_kind_of Array, addresses
207+
addresses.each { |address| assert_kind_of StreetAddress, address }
208+
assert_equal @addresses.map { |a| a[:address].stringify_keys }, addresses.map(&:attributes)
209+
ensure
210+
Person.casing = nil
211+
end
212+
168213
def test_load_collection_with_unknown_resource
169214
Person.__send__(:remove_const, :Address) if Person.const_defined?(:Address)
170215
assert_not Person.const_defined?(:Address), "Address shouldn't exist until autocreated"

test/cases/base_test.rb

+60
Original file line numberDiff line numberDiff line change
@@ -1393,6 +1393,21 @@ def test_to_xml
13931393
Person.format = :json
13941394
end
13951395

1396+
def test_to_xml_with_camelcase_casing
1397+
Person.format = :xml
1398+
Person.casing = :camelcase
1399+
matz = Person.new id: 1, firstName: "Matz"
1400+
encode = matz.encode
1401+
xml = matz.to_xml
1402+
1403+
assert_equal encode, xml
1404+
assert xml.include?('<?xml version="1.0" encoding="UTF-8"?>')
1405+
assert xml.include?("<firstName>Matz</firstName>")
1406+
assert xml.include?('<id type="integer">1</id>')
1407+
ensure
1408+
Person.format = Person.casing = nil
1409+
end
1410+
13961411
def test_to_xml_with_element_name
13971412
Person.format = :xml
13981413
old_elem_name = Person.element_name
@@ -1441,6 +1456,51 @@ def test_to_json
14411456
assert_match %r{\}\}$}, json
14421457
end
14431458

1459+
def test_to_json_with_camelcase_casing
1460+
Person.casing = :camelcase
1461+
joe = Person.new id: 6, firstName: "Joe"
1462+
encode = joe.encode
1463+
json = joe.to_json
1464+
1465+
assert_equal encode, json
1466+
assert_match %r{^\{"person":\{}, json
1467+
assert_match %r{"id":6}, json
1468+
assert_match %r{"firstName":"Joe"}, json
1469+
assert_match %r{\}\}$}, json
1470+
ensure
1471+
Person.casing = nil
1472+
end
1473+
1474+
def test_to_json_with_upper_camelcase_casing
1475+
Person.casing = ActiveResource::Casings::CamelcaseCasing.new(:upper)
1476+
joe = Person.new id: 6, FirstName: "Joe"
1477+
encode = joe.encode
1478+
json = joe.to_json
1479+
1480+
assert_equal encode, json
1481+
assert_match %r{^\{"Person":\{}, json
1482+
assert_match %r{"Id":6}, json
1483+
assert_match %r{"FirstName":"Joe"}, json
1484+
assert_match %r{\}\}$}, json
1485+
ensure
1486+
Person.casing = nil
1487+
end
1488+
1489+
def test_to_json_with_underscore_casing
1490+
Person.casing = :underscore
1491+
joe = Person.new id: 6, first_name: "Joe"
1492+
encode = joe.encode
1493+
json = joe.to_json
1494+
1495+
assert_equal encode, json
1496+
assert_match %r{^\{"person":\{}, json
1497+
assert_match %r{"id":6}, json
1498+
assert_match %r{"first_name":"Joe"}, json
1499+
assert_match %r{\}\}$}, json
1500+
ensure
1501+
Person.casing = nil
1502+
end
1503+
14441504
def test_to_json_without_root
14451505
ActiveResource::Base.include_root_in_json = false
14461506
joe = Person.find(6)

test/cases/collection_test.rb

+17
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,23 @@ def test_custom_accessor
105105
assert_equal PaginatedPost.find(:all).next_page, @posts_hash[:next_page]
106106
end
107107

108+
def test_with_camelcase
109+
PaginatedPost.casing = :camelcase
110+
posts_hash = { "results" => [@post], "nextPage" => "/paginated_posts.json?page=2" }
111+
ActiveResource::HttpMock.respond_to.get "/paginated_posts.json", {}, posts_hash.to_json
112+
113+
assert_equal PaginatedPost.find(:all).next_page, posts_hash["nextPage"]
114+
ensure
115+
PaginatedPost.casing = nil
116+
end
117+
118+
def test_with_underscore
119+
PaginatedPost.casing = :underscore
120+
assert_equal PaginatedPost.find(:all).next_page, @posts_hash[:next_page]
121+
ensure
122+
PaginatedPost.casing = nil
123+
end
124+
108125
def test_first_or_create
109126
post = PaginatedPost.where(title: "test").first_or_create
110127
assert post.valid?

test/cases/finder_test.rb

+20
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,26 @@ def test_where_with_clauses
6363
assert_kind_of StreetAddress, addresses.first
6464
end
6565

66+
def test_where_with_clauses_and_camelcase_casing
67+
Person.casing = :camelcase
68+
ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?firstName=david", {}, @people_david }
69+
people = Person.where(first_name: "david")
70+
assert_equal 1, people.size
71+
assert_kind_of Person, people.first
72+
ensure
73+
Person.casing = nil
74+
end
75+
76+
def test_where_with_clauses_and_underscore_casing
77+
Person.casing = :underscore
78+
ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?first_name=david", {}, @people_david }
79+
people = Person.where(first_name: "david")
80+
assert_equal 1, people.size
81+
assert_kind_of Person, people.first
82+
ensure
83+
Person.casing = nil
84+
end
85+
6686
def test_where_with_clause_in
6787
ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?id%5B%5D=2", {}, @people_david }
6888
people = Person.where(id: [2])

0 commit comments

Comments
 (0)