diff --git a/lib/active_resource/connection.rb b/lib/active_resource/connection.rb index c40ed7c070..88bbfe5736 100644 --- a/lib/active_resource/connection.rb +++ b/lib/active_resource/connection.rb @@ -5,6 +5,19 @@ require "date" require "time" +# Define the Net::HTTP::Query class for runtimes where it is not yet available. +# +# See https://github.com/ruby/net-http/pull/309 +# See https://www.rfc-editor.org/rfc/rfc10008.html +# +unless Net::HTTP.const_defined?(:Query) + class Net::HTTP::Query < Net::HTTPRequest + METHOD = "QUERY" + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true + end +end + module ActiveResource # Class to handle connections to remote web services. # This class is used by ActiveResource::Base to interface with REST @@ -12,6 +25,7 @@ module ActiveResource class Connection HTTP_FORMAT_HEADER_NAMES = { get: "Accept", + query: "Content-Type", put: "Content-Type", post: "Content-Type", patch: "Content-Type", @@ -20,6 +34,7 @@ class Connection } HTTP_METHODS = { get: Net::HTTP::Get, + query: Net::HTTP::Query, put: Net::HTTP::Put, post: Net::HTTP::Post, patch: Net::HTTP::Patch, @@ -91,6 +106,16 @@ def get(path, headers = {}) with_auth { request(:get, path, headers) } end + # Executes a QUERY request (see the HTTP QUERY method, + # https://www.rfc-editor.org/rfc/rfc10008.html, if unfamiliar). + # Used to get (find) resources by transmitting the query in the request + # +body+ instead of the request URI. Like GET, QUERY is safe and idempotent, + # but it accepts a request body so that larger or more complex queries can be + # expressed without encoding them into the path. + def query(path, body = "", headers = {}) + with_auth { request(:query, path, body.to_s, headers) } + end + # Executes a DELETE request (see HTTP protocol documentation if unfamiliar). # Used to delete resources. def delete(path, headers = {}) diff --git a/lib/active_resource/custom_methods.rb b/lib/active_resource/custom_methods.rb index e61b014994..a35edacf3e 100644 --- a/lib/active_resource/custom_methods.rb +++ b/lib/active_resource/custom_methods.rb @@ -61,6 +61,23 @@ def get(custom_method_name, options = {}) derooted.is_a?(Array) ? derooted.map { |e| Formats.remove_root(e) } : derooted end + # Invokes a QUERY to a given custom REST method. Like GET, QUERY is safe + # and idempotent, but the query is transmitted in the request +body+ + # rather than the request URI. For example: + # + # Person.query(:search, {}, { name: "Ryan" }.to_json) # QUERY /people/search.json + # # => [{:id => 1, :name => 'Ryan'}] + # + # Note: the objects returned from this method are not automatically converted + # into ActiveResource::Base instances - they are ordinary Hashes. If you are expecting + # ActiveResource::Base instances, use the find class method with the + # :from option. + def query(custom_method_name, options = {}, body = "") + hashified = format.decode(connection.query(custom_method_collection_url(custom_method_name, options), body, headers).body) + derooted = Formats.remove_root(hashified) + derooted.is_a?(Array) ? derooted.map { |e| Formats.remove_root(e) } : derooted + end + def post(custom_method_name, options = {}, body = "") connection.post(custom_method_collection_url(custom_method_name, options), body, headers) end @@ -95,6 +112,10 @@ def get(method_name, options = {}) self.class.format.decode(connection.get(custom_method_element_url(method_name, options), self.class.headers).body) end + def query(method_name, options = {}, body = "") + self.class.format.decode(connection.query(custom_method_element_url(method_name, options), body, self.class.headers).body) + end + def post(method_name, options = {}, body = nil) request_body = body.blank? ? encode : body if new? diff --git a/lib/active_resource/http_mock.rb b/lib/active_resource/http_mock.rb index bb832808d8..f09332d91b 100644 --- a/lib/active_resource/http_mock.rb +++ b/lib/active_resource/http_mock.rb @@ -17,8 +17,8 @@ class InvalidRequestError < StandardError; end # :nodoc: # # mock.http_method(path, request_headers = {}, body = nil, status = 200, response_headers = {}) # - # * http_method - The HTTP method to listen for. This can be +get+, +post+, +patch+, +put+, +delete+ or - # +head+. + # * http_method - The HTTP method to listen for. This can be +get+, +post+, +patch+, +put+, +delete+, + # +head+, or +query+. # * path - A string, starting with a "/", defining the URI that is expected to be # called. # * request_headers - Headers that are expected along with the request. This argument uses a @@ -82,7 +82,7 @@ def initialize(responses) @responses = responses end - [ :post, :patch, :put, :get, :delete, :head ].each do |method| + [ :post, :patch, :put, :get, :delete, :head, :query ].each do |method| # def post(path, request_headers = {}, body = nil, status = 200, response_headers = {}, options: {}) # @responses[Request.new(:post, path, nil, request_headers, options)] = Response.new(body || "", status, response_headers) # end @@ -267,7 +267,7 @@ def net_connection_disabled? end # body? methods - { true => %w[post patch put], + { true => %w[post patch put query], false => %w[get delete head] }.each do |has_body, methods| methods.each do |method| # def post(path, body, headers, options = {}) diff --git a/test/cases/base/custom_methods_test.rb b/test/cases/base/custom_methods_test.rb index 1a03941c5c..954de0204b 100644 --- a/test/cases/base/custom_methods_test.rb +++ b/test/cases/base/custom_methods_test.rb @@ -44,6 +44,8 @@ def setup mock.get "/products/1/inventories/1/shallow.json", {}, @inventory mock.put "/products/1/inventories/1/promote.json?name=Warehouse", {}, nil, 204 mock.delete "/products/1/inventories/1/deactivate.json", {}, nil, 200 + mock.query "/people/search.json", {}, @matz_array + mock.query "/people/1/similar.json", {}, @matz end Person.user = nil @@ -91,6 +93,16 @@ def test_custom_element_method StreetAddress.find(1, params: { person_id: 1 }).put(:normalize_phone, locale: "US") end + def test_custom_collection_query_method + # QUERY against a collection URL, transmitting the query in the request body + assert_equal([ { "id" => 1, "name" => "Matz" } ], Person.query(:search, {}, { name: "Matz" }.to_json)) + end + + def test_custom_element_query_method + # QUERY against an element URL + assert_equal({ "id" => 1, "name" => "Matz" }, Person.find(1).query(:similar, {}, { name: "Matz" }.to_json)) + end + def test_custom_new_element_method # Test POST against a new element URL ryan = Person.new(name: "Ryan") diff --git a/test/cases/connection_test.rb b/test/cases/connection_test.rb index 99417f0dba..06ef6dee40 100644 --- a/test/cases/connection_test.rb +++ b/test/cases/connection_test.rb @@ -31,6 +31,8 @@ def setup mock.post "/people.json", {}, nil, 201, "Location" => "/people/5.json" mock.post "/members.json", {}, @header, 201, "Location" => "/people/6.json" mock.head "/people/1.json", {}, nil, 200 + mock.query "/people/search.json", {}, @people + mock.query "/members/search.json", @header, @david end end @@ -188,6 +190,17 @@ def test_head assert_equal 200, response.code end + def test_query + people = decode(@conn.query("/people/search.json", { name: "Matz" }.to_json)) + assert_equal "Matz", people.dig(0, "person", "name") + assert_equal "David", people.dig(1, "person", "name") + end + + def test_query_with_header + david = decode(@conn.query("/members/search.json", { name: "David" }.to_json, @header)) + assert_equal "David", david["name"] + end + def test_get_with_header david = decode(@conn.get("/people/2.json", @header)) assert_equal "David", david["name"] diff --git a/test/cases/http_mock_test.rb b/test/cases/http_mock_test.rb index 3e9de19a45..921b616b7f 100644 --- a/test/cases/http_mock_test.rb +++ b/test/cases/http_mock_test.rb @@ -34,7 +34,7 @@ class HttpMockTest < ActiveSupport::TestCase end end - [ :post, :patch, :put, :get, :delete, :head ].each do |method| + [ :post, :patch, :put, :get, :delete, :head, :query ].each do |method| test "responds to simple #{method} request" do ActiveResource::HttpMock.respond_to do |mock| mock.send(method, "/people/1", { FORMAT_HEADER[method] => "application/json" }, "Response") @@ -281,7 +281,7 @@ class HttpMockTest < ActiveSupport::TestCase end def request(method, path, headers = {}, body = nil) - if method.in?([ :patch, :put, :post ]) + if method.in?([ :patch, :put, :post, :query ]) @http.send(method, path, body, headers) else @http.send(method, path, headers)