Skip to content

Commit

Permalink
Add API resource instance methods to StripeClient
Browse files Browse the repository at this point in the history
This change introduces convenience methods to access API resources
through `StripeClient` for per-client configuration. The instance client
can be configured with the same global Stripe configurations. As a
result, an instance of `StripeClient` is able to override or fallback
to the global configuration that was present at the time of
initialization.

Here's an example:

```ruby
Stripe::Customer.list() == StripeClient.new.customers.list()
```

The primary workhorse for this feature is a new module called
`Stripe::ClientAPIOperations` that defines instance methods on
`StripeClient` when it is included. A `ClientProxy` is used to send any
method calls to an API resource with the instantiated client injected.
There are a few noteworthy aspects of this approach:

- Many resources are namespaced, which introduces a unique challenge
  when it comes to method chaining calls (e.g.
  client.issuing.authorizations).  In order to handle those cases, we
  create a `ClientProxy` object for the root namespace (e.g., "issuing")
  and define all resource methods (e.g. "authorizations") at once to
  avoid re-defining the proxy object when there are multiple resources
  per namespace.

- Sigma deviates from other namespaced API resources and does not have
  an `OBJECT_NAME` separated by a period. We account for that nuance
  directly.

- `method_missing` is substantially slower than direct calls. Therefore,
  methods are defined where possible but `method_missing` is still used
  at the last step when delegating resource methods to the actual
  resource.

- Each API resource is pluralized to align with the conventions of other
  Stripe libraries (e.g. Node and PHP). The pluralization itself is
  quite naive but can easily be switched out for something more advanced
  once the need arises.

- Each API resource spec was converted to use instance based
  methods and was done to ensure adequate test coverage. Since this
  entire feature is built on proxying methods, testing via the client
  implicitly tests the original implementation for "free".
  • Loading branch information
joeltaylor committed Nov 16, 2020
1 parent 9787913 commit 47e8dc1
Show file tree
Hide file tree
Showing 86 changed files with 1,074 additions and 528 deletions.
8 changes: 7 additions & 1 deletion lib/stripe.rb
Expand Up @@ -31,7 +31,6 @@
require "stripe/util"
require "stripe/connection_manager"
require "stripe/multipart_encoder"
require "stripe/stripe_client"
require "stripe/stripe_object"
require "stripe/stripe_response"
require "stripe/list_object"
Expand All @@ -40,10 +39,15 @@
require "stripe/singleton_api_resource"
require "stripe/webhook"
require "stripe/stripe_configuration"
require "stripe/client_api_operations"

# Named API resources
require "stripe/resources"

# StripeClient requires API Resources to be loaded
# due to dynamic methods defined by ClientAPIOperations
require "stripe/stripe_client"

# OAuth
require "stripe/oauth"

Expand All @@ -62,6 +66,8 @@ module Stripe
class << self
extend Forwardable

attr_reader :configuration

# User configurable options
def_delegators :@configuration, :api_key, :api_key=
def_delegators :@configuration, :api_version, :api_version=
Expand Down
106 changes: 106 additions & 0 deletions lib/stripe/client_api_operations.rb
@@ -0,0 +1,106 @@
# frozen_string_literal: true

module Stripe
# Define instance methods on the including class (i.e. StripeClient)
# to access API resources.
module ClientAPIOperations
# Proxy object to inject the client into API resources. When included,
# all resources are defined as singleton methods on the client in the
# plural form (e.g. Stripe::Account => client.accounts).
class ClientProxy
def initialize(client:, resource: nil)
@client = client
@resource = resource
end

attr_reader :client

def with_client(client)
@client = client
self
end

# Used to either send a method to the API resource or the nested
# ClientProxy when a resource is namespaced.
def method_missing(method, *args)
super unless @resource.respond_to?(method)

update_args_with_client!(method, args)

@resource.public_send(method, *args)
end

def respond_to_missing?(symbol, include_private = false)
super unless @resource
@resource.respond_to?(symbol) || super
end

# Since the method signature differs when operating on a collection versus
# a singular resource, it's required to perform introspection on the
# parameters to respect any passed in options or overrides.
#
# Two noteworthy caveats:
# 1) Does not merge into methods that use `_opts` as that means
# the param is unused.
# 2) Preserves incorrect options (e.g. passing nil) so that APIResource
# can handle errors.
def update_args_with_client!(method, args)
opts_pos = @resource.method(method).parameters.index(%i[opt opts])

return unless opts_pos

opts = opts_pos >= args.length ? {} : args[opts_pos]

normalized_opts = Stripe::Util.normalize_opts(opts)
args[opts_pos] = { client: @client }.merge(normalized_opts)
end
end

def self.included(base)
base.class_eval do
# Sigma, unlike other namespaced API objects, is not separated by a
# period so we modify the object name to follow the expected convention.
api_resources = Stripe::Util.api_object_classes
sigma_class = api_resources.delete("scheduled_query_run")
api_resources["sigma.scheduled_query_run"] = sigma_class

# Group namespaces that have mutiple resourses
grouped_resources = api_resources.group_by do |key, _|
key.include?(".") ? key.split(".").first : key
end

grouped_resources.each do |resource_namespace, resources|
# Namespace resource names are separated with a period by convention.
if resources[0][0].include?(".")

# Defines the methods required for chaining calls for resources that
# are namespaced. A proxy object is created so that all resource
# methods can be defined at once.
#
# NOTE: At some point, a smarter pluralization scheme may be
# necessary for resource names with complex pluralization rules.
proxy = ClientProxy.new(client: nil)
resources.each do |resource_name, resource_class|
method_name = resource_name.split(".").last
proxy.define_singleton_method("#{method_name}s") do
ClientProxy.new(client: proxy.client, resource: resource_class)
end
end

# Defines the first method for resources that are namespaced. By
# convention these methods are singular. A proxy object is returned
# so that the client can be injected along the method chain.
define_method(resource_namespace) do
proxy.with_client(self)
end
else
# Defines plural methods for non-namespaced resources
define_method("#{resource_namespace}s".to_sym) do
ClientProxy.new(client: self, resource: resources[0][1])
end
end
end
end
end
end
end
17 changes: 9 additions & 8 deletions lib/stripe/connection_manager.rb
Expand Up @@ -16,7 +16,8 @@ class ConnectionManager
# garbage collected or not.
attr_reader :last_used

def initialize
def initialize(config = Stripe.configuration)
@config = config
@active_connections = {}
@last_used = Util.monotonic_time

Expand Down Expand Up @@ -117,17 +118,17 @@ def execute_request(method, uri, body: nil, headers: nil, query: nil)
# reused Go's default for `DefaultTransport`.
connection.keep_alive_timeout = 30

connection.open_timeout = Stripe.open_timeout
connection.read_timeout = Stripe.read_timeout
if connection.respond_to?(:write_timeout=)
connection.write_timeout = Stripe.write_timeout
connection.write_timeout = @config.write_timeout
end
connection.open_timeout = @config.open_timeout
connection.read_timeout = @config.read_timeout

connection.use_ssl = uri.scheme == "https"

if Stripe.verify_ssl_certs
if @config.verify_ssl_certs
connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
connection.cert_store = Stripe.ca_store
connection.cert_store = @config.ca_store
else
connection.verify_mode = OpenSSL::SSL::VERIFY_NONE
warn_ssl_verify_none
Expand All @@ -141,10 +142,10 @@ def execute_request(method, uri, body: nil, headers: nil, query: nil)
# out those pieces to make passing them into a new connection a little less
# ugly.
private def proxy_parts
if Stripe.proxy.nil?
if @config.proxy.nil?
[nil, nil, nil, nil]
else
u = URI.parse(Stripe.proxy)
u = URI.parse(@config.proxy)
[u.host, u.port, u.user, u.password]
end
end
Expand Down
8 changes: 5 additions & 3 deletions lib/stripe/oauth.rb
Expand Up @@ -7,9 +7,10 @@ module OAuthOperations

def self.execute_resource_request(method, url, params, opts)
opts = Util.normalize_opts(opts)
opts[:client] ||= StripeClient.active_client
opts[:api_base] ||= Stripe.connect_base
opts[:client] ||= params[:client] || StripeClient.active_client
opts[:api_base] ||= opts[:client].config.connect_base

params.delete(:client)
super(method, url, params, opts)
end
end
Expand All @@ -29,7 +30,8 @@ def self.get_client_id(params = {})
end

def self.authorize_url(params = {}, opts = {})
base = opts[:connect_base] || Stripe.connect_base
client = params[:client] || StripeClient.active_client
base = opts[:connect_base] || client.config.connect_base

path = "/oauth/authorize"
path = "/express" + path if opts[:express]
Expand Down
7 changes: 4 additions & 3 deletions lib/stripe/object_types.rb
@@ -1,15 +1,17 @@
# frozen_string_literal: true

# rubocop:disable Metrics/MethodLength

module Stripe
module ObjectTypes
def self.object_names_to_classes
{
# data structures
ListObject::OBJECT_NAME => ListObject,
}.merge(api_object_names_to_classes)
end

# business objects
def self.api_object_names_to_classes
{
Account::OBJECT_NAME => Account,
AccountLink::OBJECT_NAME => AccountLink,
AlipayAccount::OBJECT_NAME => AlipayAccount,
Expand Down Expand Up @@ -97,5 +99,4 @@ def self.object_names_to_classes
end
end
end

# rubocop:enable Metrics/MethodLength
11 changes: 3 additions & 8 deletions lib/stripe/resources/account.rb
Expand Up @@ -45,12 +45,8 @@ def resource_url
end

# @override To make id optional
def self.retrieve(id = ARGUMENT_NOT_PROVIDED, opts = {})
id = if id.equal?(ARGUMENT_NOT_PROVIDED)
nil
else
Util.check_string_argument!(id)
end
def self.retrieve(id = nil, opts = {})
Util.check_string_argument!(id) if id

# Account used to be a singleton, where this method's signature was
# `(opts={})`. For the sake of not breaking folks who pass in an OAuth
Expand Down Expand Up @@ -136,11 +132,10 @@ def deauthorize(client_id = nil, opts = {})
client_id: client_id,
stripe_user_id: id,
}
opts = @opts.merge(Util.normalize_opts(opts))
OAuth.deauthorize(params, opts)
end

ARGUMENT_NOT_PROVIDED = Object.new

private def serialize_additional_owners(legal_entity, additional_owners)
original_value =
legal_entity
Expand Down
3 changes: 2 additions & 1 deletion lib/stripe/resources/file.rb
Expand Up @@ -25,8 +25,9 @@ def self.create(params = {}, opts = {})
end
end

config = opts[:client]&.config || Stripe.configuration
opts = {
api_base: Stripe.uploads_base,
api_base: config.uploads_base,
content_type: MultipartEncoder::MULTIPART_FORM_DATA,
}.merge(Util.normalize_opts(opts))
super
Expand Down

0 comments on commit 47e8dc1

Please sign in to comment.