lib/recurly/client.rb in recurly-3.4.1 vs lib/recurly/client.rb in recurly-3.5.0

- old
+ new

@@ -1,9 +1,10 @@ require "logger" require "erb" require "net/https" require "base64" +require "securerandom" require_relative "./schema/json_parser" require_relative "./schema/file_parser" module Recurly class Client @@ -16,10 +17,12 @@ "application/pdf", ] JSON_CONTENT_TYPE = "application/json" MAX_RETRIES = 3 + BASE36_ALPHABET = ("0".."9").to_a + ("a".."z").to_a + # Initialize a client. It requires an API key. # # @example # API_KEY = '83749879bbde395b5fe0cc1a5abf8e5' # client = Recurly::Client.new(api_key: API_KEY) @@ -51,51 +54,49 @@ # execute block with this client if given yield(self) if block_given? end - def next_page(pager) - path = extract_path(pager.next) - request = Net::HTTP::Get.new path - set_headers(request) - http_response = run_request(request) - handle_response! request, http_response - end - protected + # Used by the operations.rb file to interpolate paths + attr_reader :site_id + def pager(path, **options) - path = scope_by_site(path, **options) Pager.new( client: self, path: path, options: options, ) end + def head(path, **options) + request = Net::HTTP::Head.new build_url(path, options) + set_headers(request, options[:headers]) + http_response = run_request(request, options) + handle_response! request, http_response + end + def get(path, **options) - path = scope_by_site(path, **options) - request = Net::HTTP::Get.new path + request = Net::HTTP::Get.new build_url(path, options) set_headers(request, options[:headers]) http_response = run_request(request, options) handle_response! request, http_response end def post(path, request_data, request_class, **options) request_class.new(request_data).validate! - path = scope_by_site(path, **options) - request = Net::HTTP::Post.new path + request = Net::HTTP::Post.new build_url(path, options) request.set_content_type(JSON_CONTENT_TYPE) set_headers(request, options[:headers]) request.body = JSON.dump(request_data) http_response = run_request(request, options) handle_response! request, http_response end def put(path, request_data = nil, request_class = nil, **options) - path = scope_by_site(path, **options) - request = Net::HTTP::Put.new path + request = Net::HTTP::Put.new build_url(path, options) request.set_content_type(JSON_CONTENT_TYPE) set_headers(request, options[:headers]) if request_data request_class.new(request_data).validate! json_body = JSON.dump(request_data) @@ -105,22 +106,16 @@ http_response = run_request(request, options) handle_response! request, http_response end def delete(path, **options) - path = scope_by_site(path, **options) - request = Net::HTTP::Delete.new path + request = Net::HTTP::Delete.new build_url(path, options) set_headers(request, options[:headers]) http_response = run_request(request, options) handle_response! request, http_response end - protected - - # Used by the operations.rb file to interpolate paths - attr_reader :site_id - private # @return [Logger] attr_reader :logger @@ -137,11 +132,19 @@ retries = 0 begin http.start unless http.started? - http.request(request) + response = http.request(request) + + # GETs are safe to retry after a server error, requests with an Idempotency-Key will return the prior response + if response.kind_of?(Net::HTTPServerError) && request.is_a?(Net::HTTP::Get) + retries += 1 + response = http.request(request) if retries < MAX_RETRIES + end + + response rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ECONNABORTED, Errno::EPIPE, Errno::ETIMEDOUT, Net::OpenTimeout, EOFError, SocketError => ex retries += 1 if retries < MAX_RETRIES retry @@ -165,14 +168,27 @@ def set_headers(request, additional_headers = {}) request["Accept"] = "application/vnd.recurly.#{api_version}".chomp # got this method from operations.rb request["Authorization"] = "Basic #{Base64.encode64(@api_key)}".chomp request["User-Agent"] = "Recurly/#{VERSION}; #{RUBY_DESCRIPTION}" + unless request.is_a?(Net::HTTP::Get) || request.is_a?(Net::HTTP::Head) + request["Idempotency-Key"] ||= generate_idempotency_key + end + # TODO this is undocumented until we finalize it additional_headers.each { |header, v| request[header] = v } if additional_headers end + # from https://github.com/rails/rails/blob/6-0-stable/activesupport/lib/active_support/core_ext/securerandom.rb + def generate_idempotency_key(n = 16) + SecureRandom.random_bytes(n).unpack("C*").map do |byte| + idx = byte % 64 + idx = SecureRandom.random_number(36) if idx >= 36 + BASE36_ALPHABET[idx] + end.join + end + def set_http_options(http, options) http.open_timeout = options[:open_timeout] || 20 http.read_timeout = options[:read_timeout] || 60 http.set_debug_output(logger) if @log_level <= Logger::INFO && !http.started? @@ -249,21 +265,26 @@ def set_api_key(api_key) @api_key = api_key end - def scope_by_site(path, **options) - if site = site_id || options[:site_id] - "/sites/#{site}#{path}" + def build_url(path, options) + path = scope_by_site(path, options) + if options.any? + "#{path}?#{URI.encode_www_form(options)}" else path end end - # Returns just the path and parameters so we can safely reuse the connection - def extract_path(uri_or_path) - uri = URI(uri_or_path) - uri.kind_of?(URI::HTTP) ? uri.request_uri : uri_or_path + def scope_by_site(path, **options) + if site = site_id || options[:site_id] + # Ensure that we are only including the site_id once because the Pager operations + # will use the cursor returned from the API which may already have these components + path.start_with?("/sites/#{site}") ? path : "/sites/#{site}#{path}" + else + path + end end def set_options(options) @log_level = options[:log_level] || Logger::WARN @logger = Logger.new(STDOUT)