Add StripeContext object (#1664)

* add stripe context

* pr feedback
This commit is contained in:
David Brownman 2025-09-25 11:53:45 -07:00 committed by GitHub
parent 0f9faf2d63
commit 71858a8f76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 585 additions and 3 deletions

View File

@ -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"

View File

@ -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]

View File

@ -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 " \

View 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

View 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