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 a proof-of-concept to add convenience methods to
access API resources through a StripeClient for per-client
configuration. This first iteration only allows for the `api_key` to be
configured but can be extended to allow other options such as
`stripe_version`, which should solve stripe#872.

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.
  • Loading branch information
joeltaylor committed Sep 25, 2020
1 parent 0620436 commit 0f2e73c
Show file tree
Hide file tree
Showing 18 changed files with 611 additions and 82 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
1 change: 1 addition & 0 deletions lib/stripe/api_operations/list.rb
Expand Up @@ -9,6 +9,7 @@ def list(filters = {}, opts = {})
resp, opts = execute_resource_request(:get, resource_url, filters, opts)
obj = ListObject.construct_from(resp.data, opts)

filters ||= {}
# set filters so that we can fetch the same limit, expansions, and
# predicates when accessing the next and previous pages
obj.filters = filters.dup
Expand Down
91 changes: 91 additions & 0 deletions lib/stripe/client_api_operations.rb
@@ -0,0 +1,91 @@
# 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. 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.
def method_missing(method, *args)
super unless @resource
opts_pos = @resource.method(method).parameters.index(%i[opt opts])
normalized_opts = Stripe::Util.normalize_opts(args[opts_pos] || {})

args[opts_pos] = { client: @client }.merge(normalized_opts)

@resource.public_send(method, *args) || super
end

def respond_to_missing?(symbol, include_private = false)
super unless @resource
@resource.respond_to?(symbol) || super
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
15 changes: 8 additions & 7 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,14 +118,14 @@ 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
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 @@ -138,10 +139,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 @@ -96,5 +98,4 @@ def self.object_names_to_classes
end
end
end

# rubocop:enable Metrics/MethodLength
1 change: 1 addition & 0 deletions lib/stripe/resources/account.rb
Expand Up @@ -136,6 +136,7 @@ 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

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 0f2e73c

Please sign in to comment.