stripe-ruby/lib/stripe.rb
Brandur 2a9413e155 Move away from rest-client's "symbol header names"
This moves away from rest-client's convention of using symbols as header
names so as to present less obfuscation as to how these are actually
named when they go over the wire.

Because headers can be injected via the bindings' API I was initially
worried that this change might break something, but upon inspection of
rest-client source, I can see now that headers take precedence as
assigned by their insertion order into the header hash, and are
"stringified" in that same loop [1]. This means that even if a user
injects a symbolized header name (`:idempotency_key`), it will still
correctly overwrite the one generated by stripe-ruby despite that using
the string format (`"Idempotency-Key"`).

[1] https://github.com/rest-client/rest-client/blob/master/lib/restclient/request.rb#L603,L625
2016-08-25 11:42:44 -07:00

475 lines
15 KiB
Ruby

# Stripe Ruby bindings
# API spec at https://stripe.com/docs/api
require 'cgi'
require 'openssl'
require 'rbconfig'
require 'set'
require 'socket'
require 'rest-client'
require 'json'
# Version
require 'stripe/version'
# API operations
require 'stripe/api_operations/create'
require 'stripe/api_operations/save'
require 'stripe/api_operations/delete'
require 'stripe/api_operations/list'
require 'stripe/api_operations/request'
# Resources
require 'stripe/util'
require 'stripe/stripe_object'
require 'stripe/api_resource'
require 'stripe/singleton_api_resource'
require 'stripe/list_object'
require 'stripe/account'
require 'stripe/balance'
require 'stripe/balance_transaction'
require 'stripe/customer'
require 'stripe/invoice'
require 'stripe/invoice_item'
require 'stripe/charge'
require 'stripe/country_spec'
require 'stripe/plan'
require 'stripe/file_upload'
require 'stripe/coupon'
require 'stripe/token'
require 'stripe/event'
require 'stripe/transfer'
require 'stripe/recipient'
require 'stripe/bank_account'
require 'stripe/card'
require 'stripe/subscription'
require 'stripe/application_fee'
require 'stripe/refund'
require 'stripe/reversal'
require 'stripe/application_fee_refund'
require 'stripe/bitcoin_receiver'
require 'stripe/bitcoin_transaction'
require 'stripe/dispute'
require 'stripe/product'
require 'stripe/sku'
require 'stripe/order'
require 'stripe/order_return'
require 'stripe/alipay_account'
require 'stripe/three_d_secure'
require 'stripe/source'
# Errors
require 'stripe/errors/stripe_error'
require 'stripe/errors/api_error'
require 'stripe/errors/api_connection_error'
require 'stripe/errors/card_error'
require 'stripe/errors/invalid_request_error'
require 'stripe/errors/authentication_error'
require 'stripe/errors/rate_limit_error'
module Stripe
DEFAULT_CA_BUNDLE_PATH = File.dirname(__FILE__) + '/data/ca-certificates.crt'
# HTTP status exceptions which we'd like to retry. Most HTTP status
# exceptions are from a Stripe API server response, so we generally _don't_
# retry because doing so would have the same result as the original request.
RETRY_HTTP_EXCEPTIONS = [
# A server may respond with a 409 to indicate that there is a concurrent
# request executing with the same idempotency key. In the case that a
# request failed due to a connection problem and the client has retried too
# early, but the server is still executing the old request, we would like
# the client to continue retrying until getting a "real" response status
# back.
RestClient::Conflict,
# Retry on timeout-related problems. This shouldn't be lumped in with HTTP
# exceptions, but with RestClient it is.
RestClient::RequestTimeout,
].freeze
@api_base = 'https://api.stripe.com'
@connect_base = 'https://connect.stripe.com'
@uploads_base = 'https://uploads.stripe.com'
@max_network_retries = 0
@max_network_retry_delay = 2
@initial_network_retry_delay = 0.5
@ca_bundle_path = DEFAULT_CA_BUNDLE_PATH
@ca_store = nil
@verify_ssl_certs = true
@open_timeout = 30
@read_timeout = 80
class << self
attr_accessor :stripe_account, :api_key, :api_base, :verify_ssl_certs, :api_version, :connect_base, :uploads_base,
:open_timeout, :read_timeout
attr_reader :max_network_retry_delay, :initial_network_retry_delay
end
def self.api_url(url='', api_base_url=nil)
(api_base_url || @api_base) + url
end
def self.request(method, url, api_key, params={}, headers={}, api_base_url=nil)
api_base_url = api_base_url || @api_base
unless api_key ||= @api_key
raise AuthenticationError.new('No API key provided. ' \
'Set your API key using "Stripe.api_key = <API-KEY>". ' \
'You can generate API keys from the Stripe web interface. ' \
'See https://stripe.com/api for details, or email support@stripe.com ' \
'if you have any questions.')
end
if api_key =~ /\s/
raise AuthenticationError.new('Your API key is invalid, as it contains ' \
'whitespace. (HINT: You can double-check your API key from the ' \
'Stripe web interface. See https://stripe.com/api for details, or ' \
'email support@stripe.com if you have any questions.)')
end
if verify_ssl_certs
request_opts = {:verify_ssl => OpenSSL::SSL::VERIFY_PEER,
:ssl_cert_store => ca_store}
else
request_opts = {:verify_ssl => false}
unless @verify_ssl_warned
@verify_ssl_warned = true
$stderr.puts("WARNING: Running without SSL cert verification. " \
"You should never do this in production. " \
"Execute 'Stripe.verify_ssl_certs = true' to enable verification.")
end
end
params = Util.objects_to_ids(params)
url = api_url(url, api_base_url)
case method.to_s.downcase.to_sym
when :get, :head, :delete
# Make params into GET parameters
url += "#{URI.parse(url).query ? '&' : '?'}#{Util.encode_parameters(params)}" if params && params.any?
payload = nil
else
if headers[:content_type] && headers[:content_type] == "multipart/form-data"
payload = params
else
payload = Util.encode_parameters(params)
end
end
request_opts.update(:headers => request_headers(api_key, method).update(headers),
:method => method, :open_timeout => open_timeout,
:payload => payload, :url => url, :timeout => read_timeout)
response = execute_request_with_rescues(request_opts, api_base_url)
[parse(response), api_key]
end
# The location of a file containing a bundle of CA certificates. By default
# the library will use an included bundle that can successfully validate
# Stripe certificates.
def self.ca_bundle_path
@ca_bundle_path
end
def self.ca_bundle_path=(path)
@ca_bundle_path = path
# empty this field so a new store is initialized
@ca_store = nil
end
# A certificate store initialized from the the bundle in #ca_bundle_path and
# which is used to validate TLS on every request.
#
# This was added to the give the gem "pseudo thread safety" in that it seems
# when initiating many parallel requests marshaling the certificate store is
# the most likely point of failure (see issue #382). Any program attempting
# to leverage this pseudo safety should make a call to this method (i.e.
# `Stripe.ca_store`) in their initialization code because it marshals lazily
# and is itself not thread safe.
def self.ca_store
@ca_store ||= begin
store = OpenSSL::X509::Store.new
store.add_file(ca_bundle_path)
store
end
end
def self.max_network_retries
@max_network_retries
end
def self.max_network_retries=(val)
@max_network_retries = val.to_i
end
private
def self.execute_request_with_rescues(request_opts, api_base_url, retry_count = 0)
begin
response = execute_request(request_opts)
# We rescue all exceptions from a request so that we have an easy spot to
# implement our retry logic across the board. We'll re-raise if it's a type
# of exception that we didn't expect to handle.
rescue => e
if should_retry?(e, retry_count)
retry_count = retry_count + 1
sleep sleep_time(retry_count)
retry
end
case e
when SocketError
response = handle_restclient_error(e, request_opts, retry_count, api_base_url)
when RestClient::ExceptionWithResponse
if e.response
handle_api_error(e.response)
else
response = handle_restclient_error(e, request_opts, retry_count, api_base_url)
end
when RestClient::Exception, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError
response = handle_restclient_error(e, request_opts, retry_count, api_base_url)
# Only handle errors when we know we can do so, and re-raise otherwise.
# This should be pretty infrequent.
else
raise
end
end
response
end
def self.user_agent
@uname ||= get_uname
lang_version = "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE})"
{
:bindings_version => Stripe::VERSION,
:lang => 'ruby',
:lang_version => lang_version,
:platform => RUBY_PLATFORM,
:engine => defined?(RUBY_ENGINE) ? RUBY_ENGINE : '',
:publisher => 'stripe',
:uname => @uname,
:hostname => Socket.gethostname,
}
end
def self.get_uname
if File.exist?('/proc/version')
File.read('/proc/version').strip
else
case RbConfig::CONFIG['host_os']
when /linux|darwin|bsd|sunos|solaris|cygwin/i
_uname_uname
when /mswin|mingw/i
_uname_ver
else
"unknown platform"
end
end
end
def self._uname_uname
(`uname -a 2>/dev/null` || '').strip
rescue Errno::ENOMEM # couldn't create subprocess
"uname lookup failed"
end
def self._uname_ver
(`ver` || '').strip
rescue Errno::ENOMEM # couldn't create subprocess
"uname lookup failed"
end
# DEPRECATED. Use `Util#encode_parameters` instead.
def self.uri_encode(params)
Util.encode_parameters(params)
end
class << self
extend Gem::Deprecate
deprecate :uri_encode, "Stripe::Util#encode_parameters", 2016, 01
end
def self.request_headers(api_key, method)
headers = {
'User-Agent' => "Stripe/v1 RubyBindings/#{Stripe::VERSION}",
'Authorization' => "Bearer #{api_key}",
'Content-Type' => 'application/x-www-form-urlencoded'
}
# It is only safe to retry network failures on post and delete
# requests if we add an Idempotency-Key header
if [:post, :delete].include?(method) && self.max_network_retries > 0
headers['Idempotency-Key'] ||= SecureRandom.uuid
end
headers['Stripe-Version'] = api_version if api_version
headers['Stripe-Account'] = stripe_account if stripe_account
begin
headers.update('X-Stripe-Client-User-Agent' => JSON.generate(user_agent))
rescue => e
headers.update('X-Stripe-Client-Raw-User-Agent' => user_agent.inspect,
:error => "#{e} (#{e.class})")
end
end
def self.execute_request(opts)
RestClient::Request.execute(opts)
end
def self.parse(response)
begin
# Would use :symbolize_names => true, but apparently there is
# some library out there that makes symbolize_names not work.
response = JSON.parse(response.body)
rescue JSON::ParserError
raise general_api_error(response.code, response.body)
end
Util.symbolize_names(response)
end
def self.general_api_error(rcode, rbody)
APIError.new("Invalid response object from API: #{rbody.inspect} " +
"(HTTP response code was #{rcode})", rcode, rbody)
end
def self.handle_api_error(resp)
begin
error_obj = JSON.parse(resp.body)
error_obj = Util.symbolize_names(error_obj)
error = error_obj[:error]
raise StripeError.new unless error && error.is_a?(Hash)
rescue JSON::ParserError, StripeError
raise general_api_error(resp.code, resp.body)
end
case resp.code
when 400, 404
raise invalid_request_error(error, resp, error_obj)
when 401
raise authentication_error(error, resp, error_obj)
when 402
raise card_error(error, resp, error_obj)
when 429
raise rate_limit_error(error, resp, error_obj)
else
raise api_error(error, resp, error_obj)
end
end
def self.invalid_request_error(error, resp, error_obj)
InvalidRequestError.new(error[:message], error[:param], resp.code,
resp.body, error_obj, resp.headers)
end
def self.authentication_error(error, resp, error_obj)
AuthenticationError.new(error[:message], resp.code, resp.body, error_obj,
resp.headers)
end
def self.rate_limit_error(error, resp, error_obj)
RateLimitError.new(error[:message], resp.code, resp.body, error_obj,
resp.headers)
end
def self.card_error(error, resp, error_obj)
CardError.new(error[:message], error[:param], error[:code],
resp.code, resp.body, error_obj, resp.headers)
end
def self.api_error(error, resp, error_obj)
APIError.new(error[:message], resp.code, resp.body, error_obj, resp.headers)
end
def self.handle_restclient_error(e, request_opts, retry_count, api_base_url=nil)
api_base_url = @api_base unless api_base_url
connection_message = "Please check your internet connection and try again. " \
"If this problem persists, you should check Stripe's service status at " \
"https://twitter.com/stripestatus, or let us know at support@stripe.com."
case e
when RestClient::RequestTimeout
message = "Could not connect to Stripe (#{api_base_url}). #{connection_message}"
when RestClient::ServerBrokeConnection
message = "The connection to the server (#{api_base_url}) broke before the " \
"request completed. #{connection_message}"
when OpenSSL::SSL::SSLError
message = "Could not establish a secure connection to Stripe, you may " \
"need to upgrade your OpenSSL version. To check, try running " \
"'openssl s_client -connect api.stripe.com:443' from the " \
"command line."
when RestClient::SSLCertificateNotVerified
message = "Could not verify Stripe's SSL certificate. " \
"Please make sure that your network is not intercepting certificates. " \
"(Try going to https://api.stripe.com/v1 in your browser.) " \
"If this problem persists, let us know at support@stripe.com."
when SocketError
message = "Unexpected error communicating when trying to connect to Stripe. " \
"You may be seeing this message because your DNS is not working. " \
"To check, try running 'host stripe.com' from the command line."
else
message = "Unexpected error communicating with Stripe. " \
"If this problem persists, let us know at support@stripe.com."
end
if retry_count > 0
message += " Request was retried #{retry_count} times."
end
raise APIConnectionError.new(message + "\n\n(Network error: #{e.message})")
end
def self.should_retry?(e, retry_count)
return false if retry_count >= self.max_network_retries
# Certificate validation problem: do not retry.
return false if e.is_a?(RestClient::SSLCertificateNotVerified)
# Generally don't retry when we got a successful response back from the
# Stripe API server, but with some exceptions (for more details, see notes
# on RETRY_HTTP_EXCEPTIONS).
return false if e.is_a?(RestClient::ExceptionWithResponse) &&
!RETRY_HTTP_EXCEPTIONS.any? { |klass| e.is_a?(klass) }
return true
end
def self.sleep_time(retry_count)
# Apply exponential backoff with initial_network_retry_delay on the number
# of attempts so far as inputs. Do not allow the number to exceed
# max_network_retry_delay.
sleep_seconds = [initial_network_retry_delay * (2 ** (retry_count - 1)), max_network_retry_delay].min
# Apply some jitter by randomizing the value in the range of (sleep_seconds
# / 2) to (sleep_seconds).
sleep_seconds = sleep_seconds * (0.5 * (1 + rand()))
# But never sleep less than the base sleep seconds.
sleep_seconds = [initial_network_retry_delay, sleep_seconds].max
sleep_seconds
end
end