mirror of
https://github.com/stripe/stripe-ruby.git
synced 2025-10-04 00:00:47 -04:00
parent
0f9faf2d63
commit
71858a8f76
@ -34,6 +34,7 @@ require "stripe/object_types"
|
||||
require "stripe/event_types"
|
||||
require "stripe/request_options"
|
||||
require "stripe/request_params"
|
||||
require "stripe/stripe_context"
|
||||
require "stripe/util"
|
||||
require "stripe/connection_manager"
|
||||
require "stripe/multipart_encoder"
|
||||
|
@ -38,8 +38,10 @@ module Stripe
|
||||
@type = event_payload[:type]
|
||||
@created = event_payload[:created]
|
||||
@livemode = event_payload[:livemode]
|
||||
@context = event_payload[:context]
|
||||
@reason = EventReason.new(event_payload[:reason]) if event_payload[:reason]
|
||||
if event_payload[:context] && !event_payload[:context].empty?
|
||||
@context = StripeContext.parse(event_payload[:context])
|
||||
end
|
||||
# private unless a child declares an attr_reader
|
||||
@related_object = RelatedObject.new(event_payload[:related_object]) if event_payload[:related_object]
|
||||
|
||||
|
@ -29,6 +29,16 @@ module Stripe
|
||||
OPTS_USER_SPECIFIED - Set[:idempotency_key, :stripe_context]
|
||||
).freeze
|
||||
|
||||
# helper method to figure out what the true value of the stripe_context header should be
|
||||
# given a pair of StripeContext|string
|
||||
# req should take precedence if non-nil
|
||||
private_class_method def self.merge_context(config_ctx, req_ctx)
|
||||
str_with_precedence = (req_ctx || config_ctx)&.to_s
|
||||
return nil if str_with_precedence.nil? || str_with_precedence.empty?
|
||||
|
||||
str_with_precedence
|
||||
end
|
||||
|
||||
# Merges requestor options on a StripeConfiguration object
|
||||
# with a per-request options hash, giving precedence
|
||||
# to the per-request options. Expects StripeConfiguration and hash.
|
||||
@ -42,7 +52,7 @@ module Stripe
|
||||
api_key: req_opts[:api_key] || config.api_key,
|
||||
idempotency_key: req_opts[:idempotency_key],
|
||||
stripe_account: req_opts[:stripe_account] || config.stripe_account,
|
||||
stripe_context: req_opts[:stripe_context] || config.stripe_context,
|
||||
stripe_context: merge_context(config.stripe_context, req_opts[:stripe_context]),
|
||||
stripe_version: req_opts[:stripe_version] || config.api_version,
|
||||
headers: req_opts[:headers] || {},
|
||||
}
|
||||
@ -62,7 +72,7 @@ module Stripe
|
||||
api_key: req_opts[:api_key] || object_opts[:api_key],
|
||||
idempotency_key: req_opts[:idempotency_key],
|
||||
stripe_account: req_opts[:stripe_account] || object_opts[:stripe_account],
|
||||
stripe_context: req_opts[:stripe_context] || object_opts[:stripe_context],
|
||||
stripe_context: merge_context(object_opts[:stripe_context], req_opts[:stripe_context]),
|
||||
stripe_version: req_opts[:stripe_version] || object_opts[:stripe_version],
|
||||
headers: req_opts[:headers] || {},
|
||||
}
|
||||
@ -100,6 +110,7 @@ module Stripe
|
||||
val = normalized_opts[opt]
|
||||
next if val.nil?
|
||||
next if val.is_a?(String)
|
||||
next if opt == :stripe_context && val.is_a?(StripeContext)
|
||||
|
||||
raise ArgumentError,
|
||||
"request option '#{opt}' should be a string value " \
|
||||
|
68
lib/stripe/stripe_context.rb
Normal file
68
lib/stripe/stripe_context.rb
Normal file
@ -0,0 +1,68 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Stripe
|
||||
# Represents hierarchical context for Stripe API operations.
|
||||
#
|
||||
# This class is immutable - all methods return new instances rather than
|
||||
# modifying the existing instance. It provides utilities for building
|
||||
# context hierarchies and converting to/from string representations.
|
||||
class StripeContext
|
||||
include Comparable
|
||||
|
||||
attr_reader :segments
|
||||
|
||||
# Creates a new StripeContext with the given segments.
|
||||
def initialize(segments = nil)
|
||||
@segments = (segments || []).map(&:to_s).freeze
|
||||
end
|
||||
|
||||
# Parses a context string into a StripeContext instance.
|
||||
def self.parse(context_str)
|
||||
return new if context_str.nil? || context_str.empty?
|
||||
|
||||
new(context_str.split("/"))
|
||||
end
|
||||
|
||||
# Creates a new StripeContext with an additional segment appended.
|
||||
def push(segment)
|
||||
segment_str = segment.to_s.strip
|
||||
raise ArgumentError, "Segment cannot be empty or whitespace" if segment_str.empty?
|
||||
|
||||
new_segments = @segments + [segment_str]
|
||||
self.class.new(new_segments)
|
||||
end
|
||||
|
||||
# Creates a new StripeContext with the last segment removed.
|
||||
# If there are no segments, returns a new empty StripeContext.
|
||||
def pop
|
||||
raise IndexError, "No segments to pop" if @segments.empty?
|
||||
|
||||
new_segments = @segments[0...-1]
|
||||
self.class.new(new_segments)
|
||||
end
|
||||
|
||||
# Converts this context to its string representation.
|
||||
def to_s
|
||||
@segments.join("/")
|
||||
end
|
||||
|
||||
# Checks equality with another StripeContext.
|
||||
def ==(other)
|
||||
other.is_a?(StripeContext) && @segments == other.segments
|
||||
end
|
||||
|
||||
# Alias for == to support eql? method
|
||||
alias eql? ==
|
||||
|
||||
# Returns a human-readable representation for debugging.
|
||||
def inspect
|
||||
"#<#{self.class}:0x#{object_id.to_s(16)} segments=#{@segments.inspect}>"
|
||||
end
|
||||
|
||||
# Returns true if the context has no segments.
|
||||
# @return [Boolean] true if empty, false otherwise
|
||||
def empty?
|
||||
@segments.empty?
|
||||
end
|
||||
end
|
||||
end
|
500
test/stripe/stripe_context_test.rb
Normal file
500
test/stripe/stripe_context_test.rb
Normal file
@ -0,0 +1,500 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require File.expand_path("../test_helper", __dir__)
|
||||
|
||||
module Stripe
|
||||
class StripeContextTest < Test::Unit::TestCase
|
||||
context "constructor" do
|
||||
should "create empty context with no segments" do
|
||||
context = StripeContext.new
|
||||
assert_equal [], context.segments
|
||||
assert context.empty?
|
||||
end
|
||||
|
||||
should "create context with segments array" do
|
||||
segments = %w[a b c]
|
||||
context = StripeContext.new(segments)
|
||||
assert_equal segments, context.segments
|
||||
refute context.empty?
|
||||
end
|
||||
|
||||
should "create context with nil segments" do
|
||||
context = StripeContext.new(nil)
|
||||
assert_equal [], context.segments
|
||||
assert context.empty?
|
||||
end
|
||||
|
||||
should "convert non-string segments to strings" do
|
||||
context = StripeContext.new([1, :symbol, "string"])
|
||||
assert_equal %w[1 symbol string], context.segments
|
||||
end
|
||||
|
||||
should "freeze segments array" do
|
||||
context = StripeContext.new(%w[a b])
|
||||
assert context.segments.frozen?
|
||||
end
|
||||
end
|
||||
|
||||
context "parse" do
|
||||
should "parse nil string" do
|
||||
context = StripeContext.parse(nil)
|
||||
assert_equal [], context.segments
|
||||
end
|
||||
|
||||
should "parse empty string" do
|
||||
context = StripeContext.parse("")
|
||||
assert_equal [], context.segments
|
||||
end
|
||||
|
||||
should "parse single segment" do
|
||||
context = StripeContext.parse("workspace")
|
||||
assert_equal ["workspace"], context.segments
|
||||
end
|
||||
|
||||
should "parse multiple segments" do
|
||||
context = StripeContext.parse("workspace/account/customer")
|
||||
assert_equal %w[workspace account customer], context.segments
|
||||
end
|
||||
|
||||
should "handle empty segments in string" do
|
||||
context = StripeContext.parse("a//b")
|
||||
assert_equal ["a", "", "b"], context.segments
|
||||
end
|
||||
end
|
||||
|
||||
context "push" do
|
||||
should "add segment to empty context" do
|
||||
context = StripeContext.new
|
||||
new_context = context.push("segment")
|
||||
|
||||
assert_equal [], context.segments
|
||||
assert_equal ["segment"], new_context.segments
|
||||
refute_same context, new_context
|
||||
end
|
||||
|
||||
should "add segment to existing context" do
|
||||
context = StripeContext.new(%w[a b])
|
||||
new_context = context.push("c")
|
||||
|
||||
assert_equal %w[a b], context.segments
|
||||
assert_equal %w[a b c], new_context.segments
|
||||
refute_same context, new_context
|
||||
end
|
||||
|
||||
should "raise error for nil segment" do
|
||||
context = StripeContext.new
|
||||
assert_raises(ArgumentError) { context.push(nil) }
|
||||
end
|
||||
|
||||
should "raise error for empty segment" do
|
||||
context = StripeContext.new
|
||||
assert_raises(ArgumentError) { context.push("") }
|
||||
end
|
||||
|
||||
should "raise error for whitespace segment" do
|
||||
context = StripeContext.new
|
||||
assert_raises(ArgumentError) { context.push(" ") }
|
||||
end
|
||||
|
||||
should "convert non-string segment to string" do
|
||||
context = StripeContext.new
|
||||
new_context = context.push(123)
|
||||
assert_equal ["123"], new_context.segments
|
||||
end
|
||||
|
||||
should "strip whitespace from segment" do
|
||||
context = StripeContext.new
|
||||
new_context = context.push(" segment ")
|
||||
assert_equal ["segment"], new_context.segments
|
||||
end
|
||||
end
|
||||
|
||||
context "pop" do
|
||||
should "remove last segment" do
|
||||
context = StripeContext.new(%w[a b c])
|
||||
new_context = context.pop
|
||||
|
||||
assert_equal %w[a b c], context.segments
|
||||
assert_equal %w[a b], new_context.segments
|
||||
refute_same context, new_context
|
||||
end
|
||||
|
||||
should "raise for popping empty context" do
|
||||
context = StripeContext.new
|
||||
assert_raises(IndexError) { context.pop }
|
||||
end
|
||||
|
||||
should "handle single segment context" do
|
||||
context = StripeContext.new(["single"])
|
||||
new_context = context.pop
|
||||
|
||||
assert_equal ["single"], context.segments
|
||||
assert_equal [], new_context.segments
|
||||
end
|
||||
end
|
||||
|
||||
context "to_s" do
|
||||
should "return empty string for empty context" do
|
||||
context = StripeContext.new
|
||||
assert_equal "", context.to_s
|
||||
end
|
||||
|
||||
should "return single segment" do
|
||||
context = StripeContext.new(["workspace"])
|
||||
assert_equal "workspace", context.to_s
|
||||
end
|
||||
|
||||
should "return slash-separated segments" do
|
||||
context = StripeContext.new(%w[workspace account customer])
|
||||
assert_equal "workspace/account/customer", context.to_s
|
||||
end
|
||||
end
|
||||
|
||||
context "utility methods" do
|
||||
should "return correct empty status" do
|
||||
empty_context = StripeContext.new
|
||||
non_empty_context = StripeContext.new(["a"])
|
||||
|
||||
assert empty_context.empty?
|
||||
refute non_empty_context.empty?
|
||||
end
|
||||
end
|
||||
|
||||
context "inspect" do
|
||||
should "return debug representation" do
|
||||
context = StripeContext.new(%w[a b])
|
||||
inspect_str = context.inspect
|
||||
|
||||
assert_includes inspect_str, "StripeContext"
|
||||
assert_includes inspect_str, 'segments=["a", "b"]'
|
||||
end
|
||||
end
|
||||
|
||||
context "immutability" do
|
||||
should "not modify original context when calling push" do
|
||||
original = StripeContext.new(%w[a b])
|
||||
pushed = original.push("c")
|
||||
popped = original.pop
|
||||
|
||||
assert_equal %w[a b], original.segments
|
||||
assert_equal %w[a b c], pushed.segments
|
||||
assert_equal ["a"], popped.segments
|
||||
|
||||
assert_not_same original, pushed
|
||||
assert_not_same original, popped
|
||||
assert_not_same pushed, popped
|
||||
end
|
||||
end
|
||||
|
||||
context "usage patterns" do
|
||||
should "support hierarchical context building" do
|
||||
base_context = StripeContext.parse("workspace_123")
|
||||
child_context = base_context.push("account_456")
|
||||
grandchild_context = child_context.push("customer_789")
|
||||
|
||||
assert_equal "workspace_123", base_context.to_s
|
||||
assert_equal "workspace_123/account_456", child_context.to_s
|
||||
assert_equal "workspace_123/account_456/customer_789", grandchild_context.to_s
|
||||
|
||||
# Go back up the hierarchy
|
||||
back_to_child = grandchild_context.pop
|
||||
back_to_base = back_to_child.pop
|
||||
|
||||
assert_equal "workspace_123/account_456", back_to_child.to_s
|
||||
assert_equal "workspace_123", back_to_base.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class StripeContextIntegrationTest < Test::Unit::TestCase
|
||||
context "RequestOptions integration" do
|
||||
should "accept string stripe_context" do
|
||||
opts = { stripe_context: "workspace/account" }
|
||||
request_opts = RequestOptions.extract_opts_from_hash(opts)
|
||||
assert_equal "workspace/account", request_opts[:stripe_context]
|
||||
end
|
||||
|
||||
should "accept StripeContext object" do
|
||||
context = StripeContext.new(%w[workspace account])
|
||||
opts = { stripe_context: context }
|
||||
request_opts = RequestOptions.extract_opts_from_hash(opts)
|
||||
assert_equal context, request_opts[:stripe_context]
|
||||
end
|
||||
|
||||
should "reject invalid context types" do
|
||||
assert_raises(ArgumentError) do
|
||||
RequestOptions.extract_opts_from_hash({ stripe_context: 123 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "RequestOptions.merge_config_and_opts" do
|
||||
should "merge string contexts" do
|
||||
config = StripeConfiguration.setup do |c|
|
||||
c.stripe_context = "config_context"
|
||||
end
|
||||
|
||||
opts = { stripe_context: "request_context" }
|
||||
merged = RequestOptions.merge_config_and_opts(config, opts)
|
||||
|
||||
assert_equal "request_context", merged[:stripe_context]
|
||||
end
|
||||
|
||||
should "merge StripeContext objects" do
|
||||
config = StripeConfiguration.setup do |c|
|
||||
c.stripe_context = StripeContext.new(["config"])
|
||||
end
|
||||
|
||||
opts = { stripe_context: StripeContext.new(["request"]) }
|
||||
merged = RequestOptions.merge_config_and_opts(config, opts)
|
||||
|
||||
assert_equal "request", merged[:stripe_context]
|
||||
end
|
||||
|
||||
should "fall back to config context" do
|
||||
config = StripeConfiguration.setup do |c|
|
||||
c.stripe_context = "config_context"
|
||||
end
|
||||
|
||||
opts = {}
|
||||
merged = RequestOptions.merge_config_and_opts(config, opts)
|
||||
|
||||
assert_equal "config_context", merged[:stripe_context]
|
||||
end
|
||||
|
||||
should "handle StripeContext in config fallback" do
|
||||
config = StripeConfiguration.setup do |c|
|
||||
c.stripe_context = StripeContext.new(%w[workspace account])
|
||||
end
|
||||
|
||||
opts = {}
|
||||
merged = RequestOptions.merge_config_and_opts(config, opts)
|
||||
|
||||
assert_equal "workspace/account", merged[:stripe_context]
|
||||
end
|
||||
|
||||
should "let request override config" do
|
||||
config = StripeConfiguration.setup do |c|
|
||||
c.stripe_context = StripeContext.new(%w[workspace account])
|
||||
end
|
||||
|
||||
opts = { stripe_context: StripeContext.new }
|
||||
merged = RequestOptions.merge_config_and_opts(config, opts)
|
||||
|
||||
assert_nil merged[:stripe_context]
|
||||
end
|
||||
|
||||
should "not let request override config" do
|
||||
config = StripeConfiguration.setup do |c|
|
||||
c.stripe_context = StripeContext.new(%w[workspace account])
|
||||
end
|
||||
|
||||
opts = { stripe_context: nil }
|
||||
merged = RequestOptions.merge_config_and_opts(config, opts)
|
||||
|
||||
assert_equal "workspace/account", merged[:stripe_context]
|
||||
end
|
||||
|
||||
should "handle both empty contexts" do
|
||||
config = StripeConfiguration.new
|
||||
|
||||
opts = {}
|
||||
merged = RequestOptions.merge_config_and_opts(config, opts)
|
||||
|
||||
assert_nil merged[:stripe_context]
|
||||
end
|
||||
|
||||
should "allow empty config contexts" do
|
||||
config = StripeConfiguration.new
|
||||
|
||||
opts = { stripe_context: StripeContext.new(["request"]) }
|
||||
merged = RequestOptions.merge_config_and_opts(config, opts)
|
||||
|
||||
assert_equal "request", merged[:stripe_context]
|
||||
end
|
||||
end
|
||||
|
||||
context "RequestOptions.combine_opts" do
|
||||
should "combine string contexts" do
|
||||
object_opts = { stripe_context: "object_context" }
|
||||
request_opts = { stripe_context: "request_context" }
|
||||
|
||||
merged = RequestOptions.combine_opts(object_opts, request_opts)
|
||||
assert_equal "request_context", merged[:stripe_context]
|
||||
end
|
||||
|
||||
should "combine StripeContext objects" do
|
||||
object_context = StripeContext.new(["object"])
|
||||
request_context = StripeContext.new(["request"])
|
||||
|
||||
object_opts = { stripe_context: object_context }
|
||||
request_opts = { stripe_context: request_context }
|
||||
|
||||
merged = RequestOptions.combine_opts(object_opts, request_opts)
|
||||
assert_equal "request", merged[:stripe_context]
|
||||
end
|
||||
|
||||
should "handle empty req" do
|
||||
object_context = StripeContext.new(["object"])
|
||||
|
||||
object_opts = { stripe_context: object_context }
|
||||
request_opts = {}
|
||||
|
||||
merged = RequestOptions.combine_opts(object_opts, request_opts)
|
||||
assert_equal "object", merged[:stripe_context]
|
||||
end
|
||||
|
||||
should "handle empty obj" do
|
||||
request_context = StripeContext.new(["request"])
|
||||
|
||||
object_opts = {}
|
||||
request_opts = { stripe_context: request_context }
|
||||
|
||||
merged = RequestOptions.combine_opts(object_opts, request_opts)
|
||||
assert_equal "request", merged[:stripe_context]
|
||||
end
|
||||
|
||||
should "let empty context overwrite object" do
|
||||
object_context = StripeContext.new(["object"])
|
||||
request_context = StripeContext.new
|
||||
|
||||
object_opts = { stripe_context: object_context }
|
||||
request_opts = { stripe_context: request_context }
|
||||
|
||||
merged = RequestOptions.combine_opts(object_opts, request_opts)
|
||||
assert_nil merged[:stripe_context]
|
||||
end
|
||||
end
|
||||
|
||||
context "EventNotification integration" do
|
||||
should "return StripeContext object from stripe_context method" do
|
||||
client = StripeClient.new("sk_test_123")
|
||||
event_payload = {
|
||||
id: "evt_123",
|
||||
type: "test.event",
|
||||
created: Time.now.to_i,
|
||||
livemode: false,
|
||||
context: "workspace/account/customer",
|
||||
}
|
||||
|
||||
notification = Stripe::V2::EventNotification.new(event_payload, client)
|
||||
|
||||
assert_not_nil notification.context
|
||||
assert_equal "workspace/account/customer", notification.context.to_s
|
||||
assert_kind_of StripeContext, notification.context
|
||||
assert_equal %w[workspace account customer], notification.context.segments
|
||||
end
|
||||
|
||||
should "return nil context for nil context" do
|
||||
client = StripeClient.new("sk_test_123")
|
||||
event_payload = {
|
||||
id: "evt_123",
|
||||
type: "test.event",
|
||||
created: Time.now.to_i,
|
||||
livemode: false,
|
||||
context: nil,
|
||||
}
|
||||
|
||||
notification = Stripe::V2::EventNotification.new(event_payload, client)
|
||||
|
||||
assert_nil notification.context
|
||||
end
|
||||
|
||||
should "return nil stripe_context for empty context" do
|
||||
client = StripeClient.new("sk_test_123")
|
||||
event_payload = {
|
||||
id: "evt_123",
|
||||
type: "test.event",
|
||||
created: Time.now.to_i,
|
||||
livemode: false,
|
||||
context: "",
|
||||
}
|
||||
|
||||
notification = Stripe::V2::EventNotification.new(event_payload, client)
|
||||
|
||||
assert_nil notification.context
|
||||
end
|
||||
end
|
||||
|
||||
context "Context builder pattern" do
|
||||
should "work with request options" do
|
||||
base_context = StripeContext.parse("workspace_123")
|
||||
opts = { stripe_context: base_context.push("account_456") }
|
||||
|
||||
request_opts = RequestOptions.extract_opts_from_hash(opts)
|
||||
context = request_opts[:stripe_context]
|
||||
|
||||
assert_kind_of StripeContext, context
|
||||
assert_equal "workspace_123/account_456", context.to_s
|
||||
end
|
||||
end
|
||||
|
||||
context "StripeConfiguration integration" do
|
||||
should "accept StripeContext object" do
|
||||
context = StripeContext.new(%w[workspace account])
|
||||
|
||||
config = StripeConfiguration.setup do |c|
|
||||
c.stripe_context = context
|
||||
end
|
||||
|
||||
assert_equal context, config.stripe_context
|
||||
end
|
||||
|
||||
should "work in client initialization" do
|
||||
context = StripeContext.new(%w[workspace account])
|
||||
client = StripeClient.new("sk_test_123", stripe_context: context)
|
||||
|
||||
# The client should accept the StripeContext and work with it
|
||||
assert_not_nil client
|
||||
end
|
||||
end
|
||||
|
||||
context "backward compatibility" do
|
||||
should "continue to work with string contexts" do
|
||||
# Existing string-based context should continue to work
|
||||
opts = { stripe_context: "workspace/account" }
|
||||
request_opts = RequestOptions.extract_opts_from_hash(opts)
|
||||
|
||||
assert_equal "workspace/account", request_opts[:stripe_context]
|
||||
end
|
||||
|
||||
should "handle mixed string and StripeContext usage" do
|
||||
config = StripeConfiguration.setup do |c|
|
||||
c.stripe_context = "config_string"
|
||||
end
|
||||
|
||||
context_object = StripeContext.new(%w[request object])
|
||||
opts = { stripe_context: context_object }
|
||||
|
||||
merged = RequestOptions.merge_config_and_opts(config, opts)
|
||||
assert_equal "request/object", merged[:stripe_context]
|
||||
end
|
||||
end
|
||||
|
||||
context "API Requestor integration" do
|
||||
should "convert StripeContext to header string" do
|
||||
# This test verifies that when a StripeContext is used in request options,
|
||||
# it gets properly converted to a string header value
|
||||
context = StripeContext.new(%w[workspace account])
|
||||
|
||||
# Mock a simple request to verify header conversion
|
||||
stub_request(:get, "https://api.stripe.com/v1/customers")
|
||||
.with(
|
||||
headers: {
|
||||
"Stripe-Context" => "workspace/account",
|
||||
}
|
||||
).to_return(
|
||||
status: 200,
|
||||
body: JSON.generate({ object: "list", data: [] }),
|
||||
headers: { "Content-Type" => "application/json" }
|
||||
)
|
||||
|
||||
client = StripeClient.new("sk_test_123")
|
||||
client.raw_request(:get, "/v1/customers", opts: { stripe_context: context })
|
||||
|
||||
# If we get here without an exception, the header conversion worked
|
||||
assert true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
x
Reference in New Issue
Block a user