Skip to content

Commit 0b42176

Browse files
committed
Add API resource instance methods to StripeClient
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.
1 parent 403f9b2 commit 0b42176

File tree

6 files changed

+138
-6
lines changed

6 files changed

+138
-6
lines changed

lib/stripe.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,22 @@
3030
require "stripe/util"
3131
require "stripe/connection_manager"
3232
require "stripe/multipart_encoder"
33-
require "stripe/stripe_client"
3433
require "stripe/stripe_object"
3534
require "stripe/stripe_response"
3635
require "stripe/list_object"
3736
require "stripe/error_object"
3837
require "stripe/api_resource"
3938
require "stripe/singleton_api_resource"
4039
require "stripe/webhook"
40+
require "stripe/client_api_operations"
4141

4242
# Named API resources
4343
require "stripe/resources"
4444

45+
# StripeClient requires API Resources to be loaded
46+
# due to dynamic methods defined by ClientAPIOperations
47+
require "stripe/stripe_client"
48+
4549
# OAuth
4650
require "stripe/oauth"
4751

lib/stripe/client_api_operations.rb

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# frozen_string_literal: true
2+
3+
module Stripe
4+
# Define instance methods on the including class (i.e. StripeClient)
5+
# to access API resources.
6+
module ClientAPIOperations
7+
# Proxy object to inject the client into API resources
8+
class ClientProxy
9+
def initialize(client:, resource: nil)
10+
@client = client
11+
@resource = resource
12+
end
13+
14+
attr_reader :client
15+
16+
def with_client(client)
17+
@client = client
18+
self
19+
end
20+
21+
def method_missing(method, *args)
22+
super unless @resource
23+
@resource.send(method, *args << { client: @client }) || super
24+
end
25+
26+
def respond_to_missing?(symbol, include_private = false)
27+
super unless @resource
28+
@resource.respond_to?(symbol) || super
29+
end
30+
end
31+
32+
def self.included(base)
33+
base.class_eval do
34+
# Sigma, unlike other namespaced API objects, is not separated by a
35+
# period so we modify the object name to follow the expected convention.
36+
api_resources = Stripe::Util.api_object_classes
37+
sigma_class = api_resources.delete("scheduled_query_run")
38+
api_resources["sigma.scheduled_query_run"] = sigma_class
39+
40+
# Group namespaces that have mutiple resourses
41+
grouped_resources = api_resources.group_by do |key, _|
42+
key.include?(".") ? key.split(".").first : key
43+
end
44+
45+
grouped_resources.each do |resource_namespace, resources|
46+
# Namespace resource names are separated with a period by convention.
47+
if resources[0][0].include?(".")
48+
49+
# Defines the methods required for chaining calls for resources that
50+
# are namespaced. A proxy object is created so that all resource
51+
# methods can be defined at once.
52+
proxy = ClientProxy.new(client: nil)
53+
resources.each do |resource_name, resource_class|
54+
method_name = resource_name.split(".").last
55+
proxy.define_singleton_method("#{method_name}s") do
56+
ClientProxy.new(client: proxy.client, resource: resource_class)
57+
end
58+
end
59+
60+
# Defines the first method for resources that are namespaced. By
61+
# convention these methods are singular. A proxy object is returned
62+
# so that the client can be injected along the method chain.
63+
define_method(resource_namespace) do
64+
proxy.with_client(self)
65+
end
66+
else
67+
# Defines plural methods for non-namespaced resources
68+
define_method("#{resource_namespace}s".to_sym) do
69+
ClientProxy.new(client: self, resource: resources[0][1])
70+
end
71+
end
72+
end
73+
end
74+
end
75+
end
76+
end

lib/stripe/object_types.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
# frozen_string_literal: true
22

33
# rubocop:disable Metrics/MethodLength
4-
54
module Stripe
65
module ObjectTypes
76
def self.object_names_to_classes
87
{
98
# data structures
109
ListObject::OBJECT_NAME => ListObject,
10+
}.merge(api_object_names_to_classes)
11+
end
1112

12-
# business objects
13+
def self.api_object_names_to_classes
14+
{
1315
Account::OBJECT_NAME => Account,
1416
AccountLink::OBJECT_NAME => AccountLink,
1517
AlipayAccount::OBJECT_NAME => AlipayAccount,
@@ -95,5 +97,4 @@ def self.object_names_to_classes
9597
end
9698
end
9799
end
98-
99100
# rubocop:enable Metrics/MethodLength

lib/stripe/stripe_client.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ module Stripe
77
# recover both a resource a call returns as well as a response object that
88
# contains information on the HTTP call.
99
class StripeClient
10+
include Stripe::ClientAPIOperations
11+
1012
# A set of all known thread contexts across all threads and a mutex to
1113
# synchronize global access to them.
1214
@thread_contexts_with_connection_managers = []
@@ -17,11 +19,14 @@ class StripeClient
1719
#
1820
# Takes a connection manager object for backwards compatibility only, and
1921
# that use is DEPRECATED.
20-
def initialize(_connection_manager = nil)
22+
def initialize(_connection_manager = nil, api_key: nil)
2123
@system_profiler = SystemProfiler.new
2224
@last_request_metrics = nil
25+
@api_key = api_key
2326
end
2427

28+
attr_reader :api_key
29+
2530
# Gets a currently active `StripeClient`. Set for the current thread when
2631
# `StripeClient#request` is being run so that API operations being executed
2732
# inside of that block can find the currently active client. It's reset to
@@ -188,7 +193,7 @@ def execute_request(method, path,
188193
unless path.is_a?(String)
189194

190195
api_base ||= Stripe.api_base
191-
api_key ||= Stripe.api_key
196+
api_key ||= self.api_key || Stripe.api_key
192197
params = Util.objects_to_ids(params)
193198

194199
check_api_key!(api_key)

lib/stripe/util.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,16 @@ def self.objects_to_ids(obj)
3939
end
4040
end
4141

42+
# Returns a hash of all Stripe object classes.
4243
def self.object_classes
4344
@object_classes ||= Stripe::ObjectTypes.object_names_to_classes
4445
end
4546

47+
# Returns a hash containling only Stripe API object classes.
48+
def self.api_object_classes
49+
@api_object_classes ||= ::Stripe::ObjectTypes.api_object_names_to_classes
50+
end
51+
4652
def self.object_name_matches_class?(object_name, klass)
4753
Util.object_classes[object_name] == klass
4854
end

test/stripe/stripe_client_test.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,46 @@ class StripeClientTest < Test::Unit::TestCase
334334
end
335335
end
336336

337+
context "API resource instance methods" do
338+
should "define methods for all api resources" do
339+
client = StripeClient.new
340+
341+
# Update Sigma name to account for nuance
342+
api_resources = Stripe::Util.api_object_classes
343+
sigma_class = api_resources.delete("scheduled_query_run")
344+
api_resources["sigma.scheduled_query_run"] = sigma_class
345+
346+
api_resources.each do |string, _|
347+
if string.include?(".")
348+
resource_module, resource_name = string.split(".")
349+
350+
assert client.respond_to?(resource_module), "#{resource_module} not found"
351+
assert client.send(resource_module).respond_to?("#{resource_name}s"), "#{resource_name} not found"
352+
else
353+
assert client.respond_to?("#{string}s"), "#{string} not found"
354+
end
355+
end
356+
end
357+
358+
should "make expected request on an API resource" do
359+
client = StripeClient.new(api_key: "sk_test_local")
360+
account = client.accounts.retrieve("acct_1234")
361+
assert_requested(:get,
362+
"#{Stripe.api_base}/v1/accounts/acct_1234",
363+
headers: { "Authorization" => "Bearer sk_test_local" })
364+
assert account.is_a?(Stripe::Account)
365+
end
366+
367+
should "make expected request on a namespace API resource" do
368+
client = StripeClient.new(api_key: "sk_test_local")
369+
list = client.radar.value_lists.retrieve("rsl_123")
370+
assert_requested(:get,
371+
"#{Stripe.api_base}/v1/radar/value_lists/rsl_123",
372+
headers: { "Authorization" => "Bearer sk_test_local" })
373+
assert list.is_a?(Stripe::Radar::ValueList)
374+
end
375+
end
376+
337377
context "logging" do
338378
setup do
339379
# Freeze time for the purposes of the `elapsed` parameter that we

0 commit comments

Comments
 (0)