Skip to content

Commit a5072e7

Browse files
committed
Introduce Base.query_format for URL encoding values
Follow-up to [#421][] Problem --- Not all HTTP APIs support `snake_case` query parameters. Active Resource's built-in finders (like `.find(:all, params: { … })`, `.all(params: { … })`, `where(…)`) do not support transforming keys prior to their encoding. If an API expects camelCase keys (like `?firstName=Matz` rather than `?first_name=Matz`), Active Resource's does not provide a method or hook to override. The already defined `Base.query_string` method used by the current implementation is `private`. If a consumer were to override it (despite depending on or overriding `private` methods being discouraged), they would need to be responsible for transforming the `Hash` *and* encoding it to a `String`. Proposal --- This commit proposed the introduction of the `ActiveResource::Formats::UrlEncodedFormat`. It's modeled after the `XmlFormat` and `JsonFormat`, and defines an `encode` method with the prior behavior (a call to [Hash#to_query][]). Along with the new class, this commit also introduce a new `.query_format` class attribute (with getter and setter methods), modeled after the `.format` class attribute. Consumers can provide their own implementation with or without depending on the `ActiveResource::Formats::UrlEncodedFormat`. For example, callers can camelCase keys: ```ruby module CamelcaseUrlEncodedFormat extend self, ActiveResource::Formats::UrlEncodedFormat def encode(params, options = nil) params = params.deep_transform { |key| key.to_s.camelcase(:lower) } super end end class Person < ActiveResource::Base self.site = "https://example.com" self.query_format = CamelcaseUrlEncodedFormat end Person.where(first_name: "Sean") # => GET https://example.com/people.json?firstName=Sean ``` The URL encoding only applies to query parameters. Prefix options remain snake_case: ```ruby class Person < ActiveResource::Base self.site = "https://example.com" self.prefix = "/teams/:team_id" self.query_format = CamelcaseUrlEncodedFormat end Person.where(team_id: 1, first_name: "Sean") # => GET https://example.com/teams/1/people.json?firstName=Sean ``` This commit also includes changes to the `README.md` example code's query parameters to demonstrate that keys are `snake_case` by default. [#421]: #421 [Hash#to_query]: https://api.rubyonrails.org/classes/Hash.html#method-i-to_query
1 parent bd154d2 commit a5072e7

File tree

7 files changed

+125
-18
lines changed

7 files changed

+125
-18
lines changed

README.md

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ response:
132132
```ruby
133133
# Expects a response of
134134
#
135-
# {"id":1,"first":"Tyler","last":"Durden"}
135+
# {"id":1,"first_name":"Tyler","last_name":"Durden"}
136136
#
137137
# for GET http://api.people.com:3000/people/1.json
138138
#
@@ -144,14 +144,14 @@ JSON element becoming an attribute on the object.
144144

145145
```ruby
146146
tyler.is_a? Person # => true
147-
tyler.last # => 'Durden'
147+
tyler.last_name # => 'Durden'
148148
```
149149

150150
Any complex element (one that contains other elements) becomes its own object:
151151

152152
```ruby
153153
# With this response:
154-
# {"id":1,"first":"Tyler","address":{"street":"Paper St.","state":"CA"}}
154+
# {"id":1,"first_name":"Tyler","address":{"street":"Paper St.","state":"CA"}}
155155
#
156156
# for GET http://api.people.com:3000/people/1.json
157157
#
@@ -166,15 +166,30 @@ Collections can also be requested in a similar fashion
166166
# Expects a response of
167167
#
168168
# [
169-
# {"id":1,"first":"Tyler","last":"Durden"},
170-
# {"id":2,"first":"Tony","last":"Stark",}
169+
# {"id":1,"first_name":"Tyler","last_name":"Durden"},
170+
# {"id":2,"first_name":"Tony","last_name":"Stark",}
171171
# ]
172172
#
173173
# for GET http://api.people.com:3000/people.json
174174
#
175175
people = Person.all
176-
people.first # => <Person::xxx 'first' => 'Tyler' ...>
177-
people.last # => <Person::xxx 'first' => 'Tony' ...>
176+
people.first # => <Person::xxx 'first_name' => 'Tyler' ...>
177+
people.last # => <Person::xxx 'first_name' => 'Tony' ...>
178+
```
179+
180+
Collections can be filtered with query parameters
181+
182+
```ruby
183+
# Expects a response of
184+
#
185+
# [
186+
# {"id":1,"first_name":"Tyler","last_name":"Durden"},
187+
# ]
188+
#
189+
# for GET http://api.people.com:3000/people.json?last_name=Durden
190+
#
191+
people = Person.where(last_name: "Durden")
192+
people.first # => <Person::xxx 'first_name' => 'Tyler' ...>
178193
```
179194

180195
### Create
@@ -185,12 +200,12 @@ id of the newly created resource is parsed out of the Location response header a
185200
as the id of the ARes object.
186201

187202
```ruby
188-
# {"first":"Tyler","last":"Durden"}
203+
# {"first_name":"Tyler","last_name":"Durden"}
189204
#
190205
# is submitted as the body on
191206
#
192-
# if include_root_in_json is not set or set to false => {"first":"Tyler"}
193-
# if include_root_in_json is set to true => {"person":{"first":"Tyler"}}
207+
# if include_root_in_json is not set or set to false => {"first_name":"Tyler"}
208+
# if include_root_in_json is set to true => {"person":{"first_name":"Tyler"}}
194209
#
195210
# POST http://api.people.com:3000/people.json
196211
#
@@ -199,7 +214,7 @@ as the id of the ARes object.
199214
#
200215
# Response (201): Location: http://api.people.com:3000/people/2
201216
#
202-
tyler = Person.new(:first => 'Tyler')
217+
tyler = Person.new(:first_name => 'Tyler')
203218
tyler.new? # => true
204219
tyler.save # => true
205220
tyler.new? # => false
@@ -213,21 +228,21 @@ with the exception that no response headers are needed -- just an empty response
213228
server side was successful.
214229

215230
```ruby
216-
# {"first":"Tyler"}
231+
# {"first_name":"Tyler"}
217232
#
218233
# is submitted as the body on
219234
#
220-
# if include_root_in_json is not set or set to false => {"first":"Tyler"}
221-
# if include_root_in_json is set to true => {"person":{"first":"Tyler"}}
235+
# if include_root_in_json is not set or set to false => {"first_name":"Tyler"}
236+
# if include_root_in_json is set to true => {"person":{"first_name":"Tyler"}}
222237
#
223238
# PUT http://api.people.com:3000/people/1.json
224239
#
225240
# when save is called on an existing Person object. An empty response is
226241
# is expected with code (204)
227242
#
228243
tyler = Person.find(1)
229-
tyler.first # => 'Tyler'
230-
tyler.first = 'Tyson'
244+
tyler.first_name # => 'Tyler'
245+
tyler.first_name = 'Tyson'
231246
tyler.save # => true
232247
```
233248

lib/active_resource/base.rb

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ def self.logger=(logger)
380380
@@logger = logger
381381
end
382382

383+
class_attribute :_query_format
383384
class_attribute :_format
384385
class_attribute :_collection_parser
385386
class_attribute :include_format_in_path
@@ -623,6 +624,24 @@ def auth_type=(auth_type)
623624
@auth_type = auth_type
624625
end
625626

627+
# Sets the URL format that attributes are sent and received in from a mime type reference:
628+
#
629+
# Person.query_format = ActiveResource::Formats::UrlEncodedFormat
630+
# Person.where(first_name: "Matz") # => GET /people.json?first_name=Matz
631+
#
632+
# Person.query_format = CustomCamelcaseUrlEncodedFormat
633+
# Person.where(first_name: "Matz") # => GET /people.json?firstName=Matz
634+
#
635+
# Default format is <tt>ActiveResource::Formats::UrlEncodedFormat</tt>.
636+
def query_format=(mime_type_reference_or_format)
637+
self._query_format = mime_type_reference_or_format
638+
end
639+
640+
# Returns the current parameters format, default is ActiveResource::Formats::UrlEncodedFormat
641+
def query_format
642+
self._query_format || ActiveResource::Formats::UrlEncodedFormat
643+
end
644+
626645
# Sets the format that attributes are sent and received in from a mime type reference:
627646
#
628647
# Person.format = :json
@@ -1032,7 +1051,8 @@ def create!(attributes = {})
10321051
# ==== Options
10331052
#
10341053
# * <tt>:from</tt> - Sets the path or custom method that resources will be fetched from.
1035-
# * <tt>:params</tt> - Sets query and \prefix (nested URL) parameters.
1054+
# * <tt>:params</tt> - Sets query and \prefix (nested URL) parameters. Query keys are URL
1055+
# encoded using the resource's +query_format+ (the ActiveResource::Formats::UrlEncodedFormat by default).
10361056
#
10371057
# ==== Examples
10381058
# Person.find(1)
@@ -1118,6 +1138,8 @@ def all(*args)
11181138
WhereClause.new(self, *args)
11191139
end
11201140

1141+
# This is an alias for all. You can pass in all the same
1142+
# arguments to this method as you can to <tt>all</tt> and <tt>find(:all)</tt>
11211143
def where(clauses = {})
11221144
clauses = sanitize_forbidden_attributes(clauses)
11231145
raise ArgumentError, "expected a clauses Hash, got #{clauses.inspect}" unless clauses.is_a? Hash
@@ -1243,7 +1265,7 @@ def prefix_parameters
12431265

12441266
# Builds the query string for the request.
12451267
def query_string(options)
1246-
"?#{options.to_query}" unless options.nil? || options.empty?
1268+
"?#{query_format.encode(options)}" unless options.nil? || options.empty?
12471269
end
12481270

12491271
# split an option hash into two hashes, one containing the prefix options,

lib/active_resource/formats.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module ActiveResource
44
module Formats
55
autoload :XmlFormat, "active_resource/formats/xml_format"
66
autoload :JsonFormat, "active_resource/formats/json_format"
7+
autoload :UrlEncodedFormat, "active_resource/formats/url_encoded_format"
78

89
# Lookup the format class from a mime type reference symbol. Example:
910
#
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
require "active_support/core_ext/array/wrap"
4+
5+
module ActiveResource
6+
module Formats
7+
module UrlEncodedFormat
8+
extend self
9+
10+
# URL encode the parameters Hash
11+
def encode(params, options = nil)
12+
params.to_query
13+
end
14+
end
15+
end
16+
end

test/abstract_unit.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def setup_response
3232
@joe = { person: { id: 6, name: "Joe", likes_hats: true } }.to_json
3333
@people = { people: [ { person: { id: 1, name: "Matz" } }, { person: { id: 2, name: "David" } } ] }.to_json
3434
@people_david = { people: [ { person: { id: 2, name: "David" } } ] }.to_json
35+
@people_joe = { people: [ { id: 6, name: "Joe", likes_hats: true } ] }.to_json
3536
@addresses = { addresses: [ { address: { id: 1, street: "12345 Street", country: "Australia" } } ] }.to_json
3637
@post = { id: 1, title: "Hello World", body: "Lorem Ipsum" }.to_json
3738
@posts = [ { id: 1, title: "Hello World", body: "Lorem Ipsum" }, { id: 2, title: "Second Post", body: "Lorem Ipsum" } ].to_json

test/cases/finder_test.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,24 @@
99
require "fixtures/pet"
1010
require "active_support/core_ext/hash/conversions"
1111

12+
module CamelcaseUrlEncodedFormat
13+
extend ActiveResource::Formats::UrlEncodedFormat
14+
15+
def self.encode(params, options = nil)
16+
params = params.deep_transform_keys { |key| key.to_s.camelcase(:lower) }
17+
18+
super
19+
end
20+
end
21+
22+
class CamelcasePerson < Person
23+
self.query_format = CamelcaseUrlEncodedFormat
24+
end
25+
26+
class CamelcasePet < Pet
27+
self.query_format = CamelcaseUrlEncodedFormat
28+
end
29+
1230
class FinderTest < ActiveSupport::TestCase
1331
def setup
1432
setup_response # find me in abstract_unit
@@ -143,6 +161,15 @@ def test_where_with_clause_in
143161
assert_equal "David", people.first.name
144162
end
145163

164+
def test_where_with_clause_in_custom_query_format
165+
ActiveResource::HttpMock.respond_to { |m| m.get "/camelcase_people.json?likesHats=true", {}, @people_joe }
166+
people = CamelcasePerson.where(likes_hats: true)
167+
assert_equal 1, people.size
168+
assert_kind_of CamelcasePerson, people.first
169+
assert_equal "Joe", people.first.name
170+
assert_predicate people.first, :likes_hats
171+
end
172+
146173
def test_where_with_invalid_clauses
147174
error = assert_raise(ArgumentError) { Person.where(nil) }
148175
assert_equal "expected a clauses Hash, got nil", error.message
@@ -230,6 +257,18 @@ def test_find_all_by_from_with_prefix
230257
assert_equal ({ person_id: 1 }), pets.second.prefix_options
231258
end
232259

260+
def test_find_all_with_prefix_and_custom_query_format
261+
ActiveResource::HttpMock.respond_to { |m| m.get "/people/1/camelcase_pets.json?queryParam=1", {}, @pets }
262+
263+
pets = CamelcasePet.find(:all, params: { person_id: 1, query_param: 1 })
264+
assert_equal 2, pets.size
265+
assert_equal "Max", pets.first.name
266+
assert_equal ({ person_id: 1 }), pets.first.prefix_options
267+
268+
assert_equal "Daisy", pets.second.name
269+
assert_equal ({ person_id: 1 }), pets.second.prefix_options
270+
end
271+
233272
def test_find_all_by_symbol_from
234273
ActiveResource::HttpMock.respond_to { |m| m.get "/people/managers.json", {}, @people_david }
235274

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
require "abstract_unit"
4+
5+
class UrlEncodedFormatTest < ActiveSupport::TestCase
6+
test "#encode transforms a Hash into an application/x-www-form-urlencoded query string" do
7+
params = { "a" => 1, "b" => 2, "c" => [ 3, 4 ] }
8+
9+
encoded = ActiveResource::Formats::UrlEncodedFormat.encode(params)
10+
11+
assert_equal "a=1&b=2&c%5B%5D=3&c%5B%5D=4", encoded
12+
end
13+
end

0 commit comments

Comments
 (0)