From d7fc80f64bfb0fbf70fc2b7863a29de50fbf5cd6 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Sun, 3 Dec 2023 06:55:26 -0800 Subject: [PATCH] Allow passing custom objects per-request --- .rubocop.yml | 3 ++ lib/x/client.rb | 40 +++++++++------- lib/x/response_parser.rb | 9 +--- sig/x.rbs | 12 ++--- test/x/client_request_test.rb | 85 +++++++++++++++------------------- test/x/error_test.rb | 4 +- test/x/response_parser_test.rb | 60 ++++++++++++------------ 7 files changed, 99 insertions(+), 114 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 53b054f..cbbbcb9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -15,5 +15,8 @@ AllCops: NewCops: enable TargetRubyVersion: 3.0 +Style/MethodCallWithoutArgsParentheses: + AllowedMethods: [array_class, object_class] + Minitest/MultipleAssertions: Max: 5 diff --git a/lib/x/client.rb b/lib/x/client.rb index 46b10a7..c04a9e7 100644 --- a/lib/x/client.rb +++ b/lib/x/client.rb @@ -12,15 +12,13 @@ class Client DEFAULT_BASE_URL = "https://api.twitter.com/2/".freeze - attr_accessor :base_url + attr_accessor :base_url, :array_class, :object_class attr_reader :api_key, :api_key_secret, :access_token, :access_token_secret, :bearer_token def_delegators :@connection, :open_timeout, :read_timeout, :write_timeout, :proxy_url, :debug_output def_delegators :@connection, :open_timeout=, :read_timeout=, :write_timeout=, :proxy_url=, :debug_output= def_delegators :@redirect_handler, :max_redirects def_delegators :@redirect_handler, :max_redirects= - def_delegators :@response_parser, :array_class, :object_class - def_delegators :@response_parser, :array_class=, :object_class= def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil, bearer_token: nil, @@ -34,32 +32,34 @@ def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_toke object_class: nil, max_redirects: RedirectHandler::DEFAULT_MAX_REDIRECTS) - initialize_oauth(api_key, api_key_secret, access_token, access_token_secret) - @bearer_token = bearer_token + initialize_oauth(api_key, api_key_secret, access_token, access_token_secret, bearer_token) initialize_authenticator @base_url = base_url + initialize_response_objects(array_class, object_class) @connection = Connection.new(open_timeout: open_timeout, read_timeout: read_timeout, write_timeout: write_timeout, debug_output: debug_output, proxy_url: proxy_url) @request_builder = RequestBuilder.new @redirect_handler = RedirectHandler.new(connection: @connection, request_builder: @request_builder, max_redirects: max_redirects) - @response_parser = ResponseParser.new(array_class: array_class, object_class: object_class) + @response_parser = ResponseParser.new end - def get(endpoint, headers: {}) - execute_request(:get, endpoint, headers: headers) + def get(endpoint, headers: {}, array_class: array_class(), object_class: object_class()) + execute_request(:get, endpoint, headers: headers, array_class: array_class, object_class: object_class) end - def post(endpoint, body = nil, headers: {}) - execute_request(:post, endpoint, body: body, headers: headers) + def post(endpoint, body = nil, headers: {}, array_class: array_class(), object_class: object_class()) + execute_request(:post, endpoint, body: body, headers: headers, array_class: array_class, + object_class: object_class) end - def put(endpoint, body = nil, headers: {}) - execute_request(:put, endpoint, body: body, headers: headers) + def put(endpoint, body = nil, headers: {}, array_class: array_class(), object_class: object_class()) + execute_request(:put, endpoint, body: body, headers: headers, array_class: array_class, + object_class: object_class) end - def delete(endpoint, headers: {}) - execute_request(:delete, endpoint, headers: headers) + def delete(endpoint, headers: {}, array_class: array_class(), object_class: object_class()) + execute_request(:delete, endpoint, headers: headers, array_class: array_class, object_class: object_class) end def api_key=(api_key) @@ -89,11 +89,17 @@ def bearer_token=(bearer_token) private - def initialize_oauth(api_key, api_key_secret, access_token, access_token_secret) + def initialize_oauth(api_key, api_key_secret, access_token, access_token_secret, bearer_token) @api_key = api_key @api_key_secret = api_key_secret @access_token = access_token @access_token_secret = access_token_secret + @bearer_token = bearer_token + end + + def initialize_response_objects(array_class, object_class) + @array_class = array_class + @object_class = object_class end def initialize_authenticator @@ -109,14 +115,14 @@ def initialize_authenticator end end - def execute_request(http_method, endpoint, headers:, body: nil) + def execute_request(http_method, endpoint, body: nil, headers: {}, array_class: array_class(), object_class: object_class()) uri = URI.join(base_url, endpoint) request = @request_builder.build(http_method: http_method, uri: uri, body: body, headers: headers, authenticator: @authenticator) response = @connection.perform(request: request) response = @redirect_handler.handle(response: response, request: request, base_url: base_url, authenticator: @authenticator) - @response_parser.parse(response: response) + @response_parser.parse(response: response, array_class: array_class, object_class: object_class) end end end diff --git a/lib/x/response_parser.rb b/lib/x/response_parser.rb index c5a467c..3828334 100644 --- a/lib/x/response_parser.rb +++ b/lib/x/response_parser.rb @@ -36,14 +36,7 @@ class ResponseParser }.freeze JSON_CONTENT_TYPE_REGEXP = %r{application/json} - attr_accessor :array_class, :object_class - - def initialize(array_class: nil, object_class: nil) - @array_class = array_class - @object_class = object_class - end - - def parse(response:) + def parse(response:, array_class: nil, object_class: nil) raise error(response) unless response.is_a?(Net::HTTPSuccess) return unless json?(response) diff --git a/sig/x.rbs b/sig/x.rbs index ff44c39..67eef7c 100644 --- a/sig/x.rbs +++ b/sig/x.rbs @@ -193,15 +193,10 @@ module X end class ResponseParser - DEFAULT_ARRAY_CLASS: Class - DEFAULT_OBJECT_CLASS: Class ERROR_MAP: Hash[Integer, singleton(Unauthorized) | singleton(BadRequest) | singleton(Forbidden) | singleton(InternalServerError) | singleton(NotFound) | singleton(PayloadTooLarge) | singleton(ServiceUnavailable) | singleton(TooManyRequests)] JSON_CONTENT_TYPE_REGEXP: Regexp - attr_accessor array_class: Class - attr_accessor object_class: Class - def initialize: (?array_class: Class, ?object_class: Class) -> void - def parse: (response: Net::HTTPResponse) -> untyped + def parse: (response: Net::HTTPResponse, ?array_class: Class?, ?object_class: Class?) -> untyped private def error: (Net::HTTPResponse response) -> (Unauthorized | BadRequest | Forbidden | InternalServerError | NotFound | PayloadTooLarge | ServiceUnavailable | TooManyRequests) @@ -246,9 +241,10 @@ module X def delete: (String endpoint, ?headers: Hash[String, String]) -> untyped private - def initialize_oauth: (String? api_key, String? api_key_secret, String? access_token, String? access_token_secret) -> void + def initialize_oauth: (String? api_key, String? api_key_secret, String? access_token, String? access_token_secret, String? bearer_token) -> void + def initialize_response_objects: (Class? array_class, Class? object_class) -> void def initialize_authenticator: -> Authenticator - def execute_request: (Symbol http_method, String endpoint, headers: Hash[String, String], ?body: String?) -> untyped + def execute_request: (Symbol http_method, String endpoint, ?body: String?, ?headers: Hash[String, String], ?array_class: Class?, ?object_class: Class?) -> untyped end module MediaUploader diff --git a/test/x/client_request_test.rb b/test/x/client_request_test.rb index cde258e..5772ffc 100644 --- a/test/x/client_request_test.rb +++ b/test/x/client_request_test.rb @@ -8,64 +8,53 @@ def setup @client = Client.new end - def test_get_request - stub_request(:get, "https://api.twitter.com/2/tweets") - @client.get("tweets") - - assert_requested :get, "https://api.twitter.com/2/tweets" - end - - def test_get_request_with_headers - headers = {"User-Agent" => "Custom User Agent"} - stub_request(:get, "https://api.twitter.com/2/tweets") - @client.get("tweets", headers: headers) - - assert_requested :get, "https://api.twitter.com/2/tweets", headers: headers - end - - def test_post_request - stub_request(:post, "https://api.twitter.com/2/tweets") - @client.post("tweets") - - assert_requested :post, "https://api.twitter.com/2/tweets" - end + X::RequestBuilder::HTTP_METHODS.each_key do |http_method| + define_method "test_#{http_method}_request" do + stub_request(http_method, "https://api.twitter.com/2/tweets") + @client.public_send(http_method, "tweets") - def test_post_request_with_headers - headers = {"User-Agent" => "Custom User Agent"} - stub_request(:post, "https://api.twitter.com/2/tweets") - @client.post("tweets", headers: headers) - - assert_requested :post, "https://api.twitter.com/2/tweets", headers: headers - end + assert_requested http_method, "https://api.twitter.com/2/tweets" + end - def test_put_request - stub_request(:put, "https://api.twitter.com/2/tweets") - @client.put("tweets") + define_method "test_#{http_method}_request_with_headers" do + headers = {"User-Agent" => "Custom User Agent"} + stub_request(http_method, "https://api.twitter.com/2/tweets") + @client.public_send(http_method, "tweets", headers: headers) - assert_requested :put, "https://api.twitter.com/2/tweets" - end + assert_requested http_method, "https://api.twitter.com/2/tweets", headers: headers + end - def test_put_request_with_headers - headers = {"User-Agent" => "Custom User Agent"} - stub_request(:put, "https://api.twitter.com/2/tweets") - @client.put("tweets", headers: headers) + define_method "test_#{http_method}_request_with_custom_response_objects" do + stub_request(http_method, "https://api.twitter.com/2/tweets") + .to_return(body: '{"set": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) + ostruct = @client.public_send(http_method, "tweets", object_class: OpenStruct, array_class: Set) - assert_requested :put, "https://api.twitter.com/2/tweets", headers: headers - end + assert_kind_of OpenStruct, ostruct + assert_kind_of Set, ostruct.set + assert_equal Set.new([1, 2, 3]), ostruct.set + end - def test_delete_request - stub_request(:delete, "https://api.twitter.com/2/tweets") - @client.delete("tweets") + define_method "test_#{http_method}_request_with_custom_response_objects_client_configuration" do + stub_request(http_method, "https://api.twitter.com/2/tweets") + .to_return(body: '{"set": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) + client = Client.new(object_class: OpenStruct, array_class: Set) + ostruct = client.public_send(http_method, "tweets") - assert_requested :delete, "https://api.twitter.com/2/tweets" + assert_kind_of OpenStruct, ostruct + assert_kind_of Set, ostruct.set + assert_equal Set.new([1, 2, 3]), ostruct.set + end end - def test_delete_request_with_headers - headers = {"User-Agent" => "Custom User Agent"} - stub_request(:delete, "https://api.twitter.com/2/tweets") - @client.delete("tweets", headers: headers) + def test_execute_request_with_custom_response_objects_client_configuration + stub_request(:get, "https://api.twitter.com/2/tweets") + .to_return(body: '{"set": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) + client = Client.new(object_class: OpenStruct, array_class: Set) + ostruct = client.send(:execute_request, :get, "tweets") - assert_requested :delete, "https://api.twitter.com/2/tweets", headers: headers + assert_kind_of OpenStruct, ostruct + assert_kind_of Set, ostruct.set + assert_equal Set.new([1, 2, 3]), ostruct.set end def test_redirect_handler_preserves_authentication diff --git a/test/x/error_test.rb b/test/x/error_test.rb index e145bd9..c4acd66 100644 --- a/test/x/error_test.rb +++ b/test/x/error_test.rb @@ -10,7 +10,7 @@ def setup ResponseParser::ERROR_MAP.each do |status, error_class| name = error_class.name.split("::").last - define_method("test_initialize_#{name.downcase}_error") do + define_method "test_initialize_#{name.downcase}_error" do response = Net::HTTPResponse::CODE_TO_OBJ[status.to_s].new("1.1", status, error_class.name) exception = error_class.new(response: response) @@ -21,7 +21,7 @@ def setup end Connection::NETWORK_ERRORS.each do |error_class| - define_method("test_#{error_class.name.split("::").last.downcase}_raises_network_error") do + define_method "test_#{error_class.name.split("::").last.downcase}_raises_network_error" do stub_request(:get, "https://api.twitter.com/2/tweets").to_raise(error_class) assert_raises NetworkError do diff --git a/test/x/response_parser_test.rb b/test/x/response_parser_test.rb index 8e278e4..4a565c2 100644 --- a/test/x/response_parser_test.rb +++ b/test/x/response_parser_test.rb @@ -15,14 +15,15 @@ def response(uri = @uri) end def test_success_response - stub_request(:get, @uri.to_s).to_return(body: '{"message": "success"}', - headers: {"Content-Type" => "application/json"}) + stub_request(:get, @uri.to_s) + .to_return(body: '{"message": "success"}', headers: {"Content-Type" => "application/json"}) assert_equal({"message" => "success"}, @response_parser.parse(response: response)) end def test_non_json_success_response - stub_request(:get, @uri.to_s).to_return(body: "", headers: {"Content-Type" => "text/html"}) + stub_request(:get, @uri.to_s) + .to_return(body: "", headers: {"Content-Type" => "text/html"}) assert_nil @response_parser.parse(response: response) end @@ -47,74 +48,72 @@ def test_unknown_error_code end def test_too_many_requests_with_headers - stub_request(:get, @uri.to_s).to_return(status: 429, - headers: {"x-rate-limit-remaining" => "0"}) + stub_request(:get, @uri.to_s) + .to_return(status: 429, headers: {"x-rate-limit-remaining" => "0"}) exception = assert_raises(TooManyRequests) { @response_parser.parse(response: response) } assert_predicate exception.rate_limits.first.remaining, :zero? end def test_error_with_title_only - stub_request(:get, @uri.to_s).to_return(status: [400, "Bad Request"], body: '{"title": "Some Error"}', - headers: {"Content-Type" => "application/json"}) + stub_request(:get, @uri.to_s) + .to_return(status: [400, "Bad Request"], body: '{"title": "Some Error"}', headers: {"Content-Type" => "application/json"}) exception = assert_raises(BadRequest) { @response_parser.parse(response: response) } assert_equal "Bad Request", exception.message end def test_error_with_detail_only - stub_request(:get, @uri.to_s).to_return(status: [400, "Bad Request"], body: '{"detail": "Something went wrong"}', - headers: {"Content-Type" => "application/json"}) + stub_request(:get, @uri.to_s) + .to_return(status: [400, "Bad Request"], body: '{"detail": "Something went wrong"}', headers: {"Content-Type" => "application/json"}) exception = assert_raises(BadRequest) { @response_parser.parse(response: response) } assert_equal "Bad Request", exception.message end def test_error_with_title_and_detail_error_message - stub_request(:get, @uri.to_s).to_return(status: 400, - body: '{"title": "Some Error", "detail": "Something went wrong"}', - headers: {"Content-Type" => "application/json"}) + stub_request(:get, @uri.to_s) + .to_return(status: 400, body: '{"title": "Some Error", "detail": "Something went wrong"}', headers: {"Content-Type" => "application/json"}) exception = assert_raises(BadRequest) { @response_parser.parse(response: response) } assert_equal("Some Error: Something went wrong", exception.message) end def test_error_with_error_message - stub_request(:get, @uri.to_s).to_return(status: 400, body: '{"error": "Some Error"}', - headers: {"Content-Type" => "application/json"}) + stub_request(:get, @uri.to_s) + .to_return(status: 400, body: '{"error": "Some Error"}', headers: {"Content-Type" => "application/json"}) exception = assert_raises(BadRequest) { @response_parser.parse(response: response) } assert_equal("Some Error", exception.message) end def test_error_with_errors_array_message - stub_request(:get, @uri.to_s).to_return(status: 400, - body: '{"errors": [{"message": "Some Error"}, {"message": "Another Error"}]}', - headers: {"Content-Type" => "application/json"}) + stub_request(:get, @uri.to_s) + .to_return(status: 400, body: '{"errors": [{"message": "Some Error"}, {"message": "Another Error"}]}', headers: {"Content-Type" => "application/json"}) exception = assert_raises(BadRequest) { @response_parser.parse(response: response) } assert_equal("Some Error, Another Error", exception.message) end def test_error_with_errors_message - stub_request(:get, @uri.to_s).to_return(status: 400, body: '{"errors": {"message": "Some Error"}}', - headers: {"Content-Type" => "application/json"}) + stub_request(:get, @uri.to_s) + .to_return(status: 400, body: '{"errors": {"message": "Some Error"}}', headers: {"Content-Type" => "application/json"}) exception = assert_raises(BadRequest) { @response_parser.parse(response: response) } assert_empty exception.message end def test_non_json_error_response - stub_request(:get, @uri.to_s).to_return(status: [400, "Bad Request"], body: "Bad Request", - headers: {"Content-Type" => "text/html"}) + stub_request(:get, @uri.to_s) + .to_return(status: [400, "Bad Request"], body: "Bad Request", headers: {"Content-Type" => "text/html"}) exception = assert_raises(BadRequest) { @response_parser.parse(response: response) } assert_equal "Bad Request", exception.message end def test_default_response_objects - stub_request(:get, @uri.to_s).to_return(body: '{"array": [1, 2, 2, 3]}', - headers: {"Content-Type" => "application/json"}) + stub_request(:get, @uri.to_s) + .to_return(body: '{"array": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) hash = @response_parser.parse(response: response) assert_kind_of Hash, hash @@ -123,14 +122,13 @@ def test_default_response_objects end def test_custom_response_objects - response_parser = ResponseParser.new(object_class: OpenStruct, array_class: Set) - stub_request(:get, @uri.to_s).to_return(body: '{"array": [1, 2, 2, 3]}', - headers: {"Content-Type" => "application/json"}) - mash = response_parser.parse(response: response) - - assert_kind_of OpenStruct, mash - assert_kind_of Set, mash.array - assert_equal Set.new([1, 2, 2, 3]), mash.array + stub_request(:get, @uri.to_s) + .to_return(body: '{"set": [1, 2, 2, 3]}', headers: {"Content-Type" => "application/json"}) + ostruct = @response_parser.parse(response: response, object_class: OpenStruct, array_class: Set) + + assert_kind_of OpenStruct, ostruct + assert_kind_of Set, ostruct.set + assert_equal Set.new([1, 2, 3]), ostruct.set end end end