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 May 28, 2020
1 parent 403f9b2 commit fb3f30e
Show file tree
Hide file tree
Showing 7 changed files with 158 additions and 6 deletions.
6 changes: 5 additions & 1 deletion lib/stripe.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,22 @@
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"
require "stripe/error_object"
require "stripe/api_resource"
require "stripe/singleton_api_resource"
require "stripe/webhook"
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 Down
76 changes: 76 additions & 0 deletions lib/stripe/client_api_operations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 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
class ClientProxy
def initialize(client:, resource: nil)
@client = client
@resource = resource
end

attr_reader :client

def with_client(client)
@client = client
self
end

def method_missing(method, *args)
super unless @resource
@resource.send(method, *args << { client: @client }) || 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 nested 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.transform_keys do |key|
key == "scheduled_query_run" ? "sigma.scheduled_query_run" : key
end

# 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.
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
7 changes: 4 additions & 3 deletions lib/stripe/object_types.rb
Original file line number Diff line number Diff line change
@@ -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 @@ -95,5 +97,4 @@ def self.object_names_to_classes
end
end
end

# rubocop:enable Metrics/MethodLength
9 changes: 7 additions & 2 deletions lib/stripe/stripe_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ module Stripe
# recover both a resource a call returns as well as a response object that
# contains information on the HTTP call.
class StripeClient
include Stripe::ClientAPIOperations

# A set of all known thread contexts across all threads and a mutex to
# synchronize global access to them.
@thread_contexts_with_connection_managers = []
Expand All @@ -17,11 +19,14 @@ class StripeClient
#
# Takes a connection manager object for backwards compatibility only, and
# that use is DEPRECATED.
def initialize(_connection_manager = nil)
def initialize(_connection_manager = nil, api_key: nil)
@system_profiler = SystemProfiler.new
@last_request_metrics = nil
@api_key = api_key
end

attr_reader :api_key

# Gets a currently active `StripeClient`. Set for the current thread when
# `StripeClient#request` is being run so that API operations being executed
# inside of that block can find the currently active client. It's reset to
Expand Down Expand Up @@ -188,7 +193,7 @@ def execute_request(method, path,
unless path.is_a?(String)

api_base ||= Stripe.api_base
api_key ||= Stripe.api_key
api_key ||= self.api_key || Stripe.api_key
params = Util.objects_to_ids(params)

check_api_key!(api_key)
Expand Down
6 changes: 6 additions & 0 deletions lib/stripe/util.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,16 @@ def self.objects_to_ids(obj)
end
end

# Returns a hash of all Stripe object classes.
def self.object_classes
@object_classes ||= Stripe::ObjectTypes.object_names_to_classes
end

# Returns a hash containling only Stripe API object classes.
def self.api_object_classes
@api_object_classes ||= ::Stripe::ObjectTypes.api_object_names_to_classes
end

def self.object_name_matches_class?(object_name, klass)
Util.object_classes[object_name] == klass
end
Expand Down
40 changes: 40 additions & 0 deletions test/stripe/stripe_client_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,46 @@ class StripeClientTest < Test::Unit::TestCase
end
end

context "API resource instance methods" do
should "define methods for all api resources" do
client = StripeClient.new

# Update Sigma name to account for nuance
api_resources = Stripe::Util.api_object_classes.transform_keys do |key|
key == "scheduled_query_run" ? "sigma.scheduled_query_run" : key
end

api_resources.each do |string, _|
if string.include?(".")
resource_module, resource_name = string.split(".")

assert client.respond_to?(resource_module), "#{resource_module} not found"
assert client.send(resource_module).respond_to?("#{resource_name}s"), "#{resource_name} not found"
else
assert client.respond_to?("#{string}s"), "#{string} not found"
end
end
end

should "make expected request on an API resource" do
client = StripeClient.new(api_key: "sk_test_local")
account = client.accounts.retrieve("acct_1234")
assert_requested(:get,
"#{Stripe.api_base}/v1/accounts/acct_1234",
headers: { "Authorization" => "Bearer sk_test_local" })
assert account.is_a?(Stripe::Account)
end

should "make expected request on a namespace API resource" do
client = StripeClient.new(api_key: "sk_test_local")
list = client.radar.value_lists.retrieve("rsl_123")
assert_requested(:get,
"#{Stripe.api_base}/v1/radar/value_lists/rsl_123",
headers: { "Authorization" => "Bearer sk_test_local" })
assert list.is_a?(Stripe::Radar::ValueList)
end
end

context "logging" do
setup do
# Freeze time for the purposes of the `elapsed` parameter that we
Expand Down
20 changes: 20 additions & 0 deletions test/stripe/util_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -398,5 +398,25 @@ def isatty
assert_equal "true", Util.send(:wrap_logfmt_value, true)
end
end

context ".resource_constantize" do
should "return an instance of the class" do
assert_equal Stripe::Account, Util.resource_constantize("accounts")
assert_equal Stripe::WebhookEndpoint, Util.resource_constantize(:webhook_endpoints)
end

should "raise when the String cannot be resolved" do
assert_raises(NameError) { Util.resource_constantize("not_real") }
end
end

context ".resource_classify" do
should "classify the string" do
assert_equal "Account", Util.resource_classify("accounts")
assert_equal "Account", Util.resource_classify(:accounts)
assert_equal "WebhookEndpoint", Util.resource_classify("webhook_endpoints")
assert_equal "Sigma", Util.resource_classify("sigma")
end
end
end
end

0 comments on commit fb3f30e

Please sign in to comment.