Skip to content

Commit 1c67bee

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 c517a70 commit 1c67bee

File tree

6 files changed

+238
-6
lines changed

6 files changed

+238
-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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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. When included,
8+
# all resources are defined as singleton methods on the client in the
9+
# plural form (e.g. Stripe::Account => client.accounts).
10+
class ClientProxy
11+
def initialize(client:, resource: nil)
12+
@client = client
13+
@resource = resource
14+
end
15+
16+
attr_reader :client
17+
18+
def with_client(client)
19+
@client = client
20+
self
21+
end
22+
23+
# Used to either send a method to the API resource or the nested
24+
# ClientProxy when a resource is namespaced. Since the method signature
25+
# differs when operating on a collection versus a singular resource, it's
26+
# required to perform introspection on the parameters to respect any
27+
# passed in options or overrides.
28+
def method_missing(method, *args)
29+
super unless @resource
30+
opts_pos = @resource.method(method).parameters.index(%i[opt opts])
31+
args[opts_pos] = { client: @client }.merge(args[opts_pos] || {})
32+
33+
@resource.public_send(method, *args) || super
34+
end
35+
36+
def respond_to_missing?(symbol, include_private = false)
37+
super unless @resource
38+
@resource.respond_to?(symbol) || super
39+
end
40+
end
41+
42+
def self.included(base)
43+
base.class_eval do
44+
# Sigma, unlike other namespaced API objects, is not separated by a
45+
# period so we modify the object name to follow the expected convention.
46+
api_resources = Stripe::Util.api_object_classes
47+
sigma_class = api_resources.delete("scheduled_query_run")
48+
api_resources["sigma.scheduled_query_run"] = sigma_class
49+
50+
# Group namespaces that have mutiple resourses
51+
grouped_resources = api_resources.group_by do |key, _|
52+
key.include?(".") ? key.split(".").first : key
53+
end
54+
55+
grouped_resources.each do |resource_namespace, resources|
56+
# Namespace resource names are separated with a period by convention.
57+
if resources[0][0].include?(".")
58+
59+
# Defines the methods required for chaining calls for resources that
60+
# are namespaced. A proxy object is created so that all resource
61+
# methods can be defined at once.
62+
#
63+
# NOTE: At some point, a smarter pluralization scheme may be
64+
# necessary for resource names with complex pluralization rules.
65+
proxy = ClientProxy.new(client: nil)
66+
resources.each do |resource_name, resource_class|
67+
method_name = resource_name.split(".").last
68+
proxy.define_singleton_method("#{method_name}s") do
69+
ClientProxy.new(client: proxy.client, resource: resource_class)
70+
end
71+
end
72+
73+
# Defines the first method for resources that are namespaced. By
74+
# convention these methods are singular. A proxy object is returned
75+
# so that the client can be injected along the method chain.
76+
define_method(resource_namespace) do
77+
proxy.with_client(self)
78+
end
79+
else
80+
# Defines plural methods for non-namespaced resources
81+
define_method("#{resource_namespace}s".to_sym) do
82+
ClientProxy.new(client: self, resource: resources[0][1])
83+
end
84+
end
85+
end
86+
end
87+
end
88+
end
89+
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,
@@ -96,5 +98,4 @@ def self.object_names_to_classes
9698
end
9799
end
98100
end
99-
100101
# 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: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,133 @@ 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 a singular 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 namespaces 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+
376+
should "allow for listing a resource" do
377+
client = StripeClient.new(api_key: "sk_test_local")
378+
accounts = client.accounts.list
379+
assert_requested(:get,
380+
"#{Stripe.api_base}/v1/accounts",
381+
headers: { "Authorization" => "Bearer sk_test_local" })
382+
assert accounts.data.is_a?(Array)
383+
assert accounts.data[0].is_a?(Stripe::Account)
384+
end
385+
386+
should "allow for listing a resource with filters" do
387+
client = StripeClient.new(api_key: "sk_test_local")
388+
accounts = client.accounts.list({ limit: 10 })
389+
assert_requested(:get,
390+
"#{Stripe.api_base}/v1/accounts?limit=10",
391+
headers: { "Authorization" => "Bearer sk_test_local" })
392+
assert accounts.data.is_a?(Array)
393+
assert accounts.data[0].is_a?(Stripe::Account)
394+
end
395+
396+
should "allow for deleting a resource" do
397+
client = StripeClient.new(api_key: "sk_test_local")
398+
account = client.accounts.retrieve("acct_123")
399+
account = account.delete
400+
assert_requested :delete, "#{Stripe.api_base}/v1/accounts/#{account.id}"
401+
assert account.is_a?(Stripe::Account)
402+
end
403+
404+
should "allow for creating a resource" do
405+
client = StripeClient.new(api_key: "sk_test_local")
406+
charge = client.charges.create(
407+
amount: 100,
408+
currency: "USD",
409+
source: "src_123"
410+
)
411+
assert_requested :post, "#{Stripe.api_base}/v1/charges"
412+
assert charge.is_a?(Stripe::Charge)
413+
end
414+
415+
should "allow for updating a resource" do
416+
client = StripeClient.new(api_key: "sk_test_local")
417+
account = client.accounts.update("acct_123", metadata: { foo: "bar" })
418+
assert_requested(:post,
419+
"#{Stripe.api_base}/v1/accounts/acct_123",
420+
headers: { "Authorization" => "Bearer sk_test_local" },
421+
body: { metadata: { foo: "bar" } })
422+
assert account.is_a?(Stripe::Account)
423+
end
424+
425+
should "allow for overrides when operating on a collection" do
426+
client = StripeClient.new(api_key: "sk_test_local")
427+
accounts = client.accounts.list({}, { api_key: "sk_test_other" })
428+
assert_requested(:get,
429+
"#{Stripe.api_base}/v1/accounts",
430+
headers: { "Authorization" => "Bearer sk_test_other" })
431+
assert accounts.data.is_a?(Array)
432+
assert accounts.data[0].is_a?(Stripe::Account)
433+
end
434+
435+
should "allow for overrides when operating on a resource" do
436+
client = StripeClient.new(api_key: "sk_test_local")
437+
account = client.accounts.update("acct_123",
438+
{},
439+
{ api_key: "sk_test_other" })
440+
assert_requested(:post,
441+
"#{Stripe.api_base}/v1/accounts/acct_123",
442+
headers: { "Authorization" => "Bearer sk_test_other" })
443+
assert account.is_a?(Stripe::Account)
444+
end
445+
446+
should "allow for overrides when retrieving a resource" do
447+
client = StripeClient.new(api_key: "sk_test_local")
448+
account = client.accounts.retrieve("acct_123", { api_key: "sk_test_other" })
449+
assert_requested(:get, "#{Stripe.api_base}/v1/accounts/acct_123",
450+
headers: { "Authorization" => "Bearer sk_test_other" })
451+
assert account.is_a?(Stripe::Account)
452+
end
453+
454+
should "allow for retrieving a resource with options" do
455+
client = Stripe::StripeClient.new(api_key: "sk_test_local")
456+
account = client.charges.retrieve(id: "acct_123", expand: ["customer"])
457+
assert_requested(:get, "#{Stripe.api_base}/v1/charges/acct_123",
458+
headers: { "Authorization" => "Bearer sk_test_local" },
459+
query: { "expand[]" => "customer" })
460+
assert account.is_a?(Stripe::Charge)
461+
end
462+
end
463+
337464
context "logging" do
338465
setup do
339466
# Freeze time for the purposes of the `elapsed` parameter that we

0 commit comments

Comments
 (0)