⚠️ Add strongly typed EventNotifications (#1650)

* manual changes

* move eventNotification to v2 namespace

* updated rbi

* add tests

* Add basic rbi

* generate event data types

* move some things, fix tests

* add missing attributes

* update gemspec, examples, and rbi

* fix example
This commit is contained in:
David Brownman 2025-09-24 15:55:40 -07:00 committed by GitHub
parent 4cde8ca569
commit cf0db6f745
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 478 additions and 138 deletions

View File

@ -19,6 +19,7 @@ Layout/LineLength:
- "lib/stripe/stripe_client.rb"
- "lib/stripe/resources/**/*.rb"
- "lib/stripe/services/**/*.rb"
- "lib/stripe/events/**/*.rb"
- "test/**/*.rb"
Lint/MissingSuper:

View File

@ -2,7 +2,8 @@
// Show the repo name in the top window bar.
"window.title": "${rootName}${separator}${activeEditorMedium}",
"editor.formatOnSave": true,
// formatting on save is very slow in ruby
"editor.formatOnSave": false,
"files.trimTrailingWhitespace": true,
// Rubocop settings

View File

@ -5,7 +5,7 @@ From the examples folder, run:
e.g.
`RUBYLIB=../lib ruby thinevent_webhook_handler.rb`
`RUBYLIB=../lib ruby event_notification_webhook_handler.rb`
## Adding a new example

View File

@ -1,12 +1,12 @@
# frozen_string_literal: true
# typed: false
# thinevent_webhook_handler.rb - receive and process thin events like the
# event_notification_webhook_handler.rb - receive and process event notification like the
# v1.billing.meter.error_report_triggered event.
#
# In this example, we:
# - create a StripeClient called client
# - use client.parse_thin_event to parse the received thin event webhook body
# - use client.parse_event_notification to parse the received event notification webhook body
# - call client.v2.core.events.retrieve to retrieve the full event object
# - if it is a V1BillingMeterErrorReportTriggeredEvent event type, call
# event.fetchRelatedObject to retrieve the Billing Meter object associated
@ -24,12 +24,10 @@ client = Stripe::StripeClient.new(api_key)
post "/webhook" do
webhook_body = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
thin_event = client.parse_thin_event(webhook_body, sig_header, webhook_secret)
event_notification = client.parse_event_notification(webhook_body, sig_header, webhook_secret)
# Fetch the event data to understand the failure
event = client.v2.core.events.retrieve(thin_event.id)
if event.instance_of? Stripe::V1BillingMeterErrorReportTriggeredEvent
meter = event.fetch_related_object
if event_notification.instance_of?(Stripe::Events::V1BillingMeterErrorReportTriggeredEventNotification)
meter = event_notification.fetch_related_object
meter_id = meter.id
puts "Success!", meter_id
end

View File

@ -51,7 +51,7 @@ require "stripe/api_resource_test_helpers"
require "stripe/singleton_api_resource"
require "stripe/webhook"
require "stripe/stripe_configuration"
require "stripe/thin_event"
require "stripe/event_notification"
# Named API resources
require "stripe/resources"

View File

@ -0,0 +1,70 @@
# frozen_string_literal: true
module Stripe
module V2
class EventReasonRequest
attr_reader :id, :idempotency_key
def initialize(event_reason_request_payload = {})
@id = event_reason_request_payload[:id]
@idempotency_key = event_reason_request_payload[:idempotency_key]
end
end
class EventReason
attr_reader :type, :request
def initialize(event_reason_payload = {})
@type = event_reason_payload[:type]
@request = EventReasonRequest.new(event_reason_payload[:request])
end
end
class RelatedObject
attr_reader :id, :type, :url
def initialize(related_object)
@id = related_object[:id]
@type = related_object[:type]
@url = related_object[:url]
end
end
class EventNotification
attr_reader :id, :type, :created, :context, :livemode, :reason
def initialize(event_payload, client)
@id = event_payload[:id]
@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]
# private unless a child declares an attr_reader
@related_object = RelatedObject.new(event_payload[:related_object]) if event_payload[:related_object]
# internal use
@client = client
end
# Retrieves the Event that generated this EventNotification.
def fetch_event
resp = @client.raw_request(:get, "/v2/core/events/#{id}", opts: { stripe_context: context },
usage: ["fetch_event"])
@client.deserialize(resp.http_body, api_mode: :v2)
end
end
class UnknownEventNotification < EventNotification
attr_reader :related_object
def fetch_related_object
return nil if @related_object.nil?
resp = @client.raw_request(:get, related_object.url, opts: { stripe_context: context },
usage: ["fetch_related_object"])
@client.deserialize(resp.http_body, api_mode: Util.get_api_mode(related_object.url))
end
end
end
end

View File

@ -2,13 +2,27 @@
module Stripe
module EventTypes
def self.thin_event_names_to_classes
def self.v2_event_types_to_classes
{
# The beginning of the section generated from our OpenAPI spec
V1BillingMeterErrorReportTriggeredEvent.lookup_type => V1BillingMeterErrorReportTriggeredEvent,
V1BillingMeterNoMeterFoundEvent.lookup_type => V1BillingMeterNoMeterFoundEvent,
V2CoreEventDestinationPingEvent.lookup_type => V2CoreEventDestinationPingEvent,
# The end of the section generated from our OpenAPI spec
# v2 event types: The beginning of the section generated from our OpenAPI spec
Events::V1BillingMeterErrorReportTriggeredEvent.lookup_type =>
Events::V1BillingMeterErrorReportTriggeredEvent,
Events::V1BillingMeterNoMeterFoundEvent.lookup_type => Events::V1BillingMeterNoMeterFoundEvent,
Events::V2CoreEventDestinationPingEvent.lookup_type => Events::V2CoreEventDestinationPingEvent,
# v2 event types: The end of the section generated from our OpenAPI spec
}
end
def self.event_notification_types_to_classes
{
# event notification types: The beginning of the section generated from our OpenAPI spec
Events::V1BillingMeterErrorReportTriggeredEventNotification.lookup_type =>
Events::V1BillingMeterErrorReportTriggeredEventNotification,
Events::V1BillingMeterNoMeterFoundEventNotification.lookup_type =>
Events::V1BillingMeterNoMeterFoundEventNotification,
Events::V2CoreEventDestinationPingEventNotification.lookup_type =>
Events::V2CoreEventDestinationPingEventNotification,
# event notification types: The end of the section generated from our OpenAPI spec
}
end
end

View File

@ -2,22 +2,122 @@
# frozen_string_literal: true
module Stripe
module Events
# Occurs when a Meter has invalid async usage events.
class V1BillingMeterErrorReportTriggeredEvent < Stripe::V2::Event
def self.lookup_type
"v1.billing.meter.error_report_triggered"
end
# There is additional data present for this event, accessible with the `data` property.
# See the Stripe API docs for more information.
# Retrieves the related object from the API. Make an API request on every call.
class V1BillingMeterErrorReportTriggeredEventData < Stripe::StripeObject
class Reason < Stripe::StripeObject
class ErrorType < Stripe::StripeObject
class SampleError < Stripe::StripeObject
class Request < Stripe::StripeObject
# The request idempotency key.
attr_reader :identifier
def self.inner_class_types
@inner_class_types = {}
end
def self.field_remappings
@field_remappings = {}
end
end
# The error message.
attr_reader :error_message
# The request causes the error.
attr_reader :request
def self.inner_class_types
@inner_class_types = { request: Request }
end
def self.field_remappings
@field_remappings = {}
end
end
# Open Enum.
attr_reader :code
# The number of errors of this type.
attr_reader :error_count
# A list of sample errors of this type.
attr_reader :sample_errors
def self.inner_class_types
@inner_class_types = { sample_errors: SampleError }
end
def self.field_remappings
@field_remappings = {}
end
end
# The total error count within this window.
attr_reader :error_count
# The error details.
attr_reader :error_types
def self.inner_class_types
@inner_class_types = { error_types: ErrorType }
end
def self.field_remappings
@field_remappings = {}
end
end
# This contains information about why meter error happens.
attr_reader :reason
# Extra field included in the event's `data` when fetched from /v2/events.
attr_reader :developer_message_summary
# The start of the window that is encapsulated by this summary.
attr_reader :validation_start
# The end of the window that is encapsulated by this summary.
attr_reader :validation_end
def self.inner_class_types
@inner_class_types = { reason: Reason }
end
def self.field_remappings
@field_remappings = {}
end
end
def self.inner_class_types
@inner_class_types = { data: V1BillingMeterErrorReportTriggeredEventData }
end
attr_reader :data, :related_object
# Retrieves the related object from the API. Makes an API request on every call.
def fetch_related_object
_request(
method: :get,
path: related_object.url,
base_address: :api,
opts: { stripe_account: context }
opts: { stripe_context: context }
)
end
end
# Occurs when a Meter has invalid async usage events.
class V1BillingMeterErrorReportTriggeredEventNotification < Stripe::V2::EventNotification
def self.lookup_type
"v1.billing.meter.error_report_triggered"
end
attr_reader :related_object
# Retrieves the Meter related to this EventNotification from the Stripe API. Makes an API request on every call.
def fetch_related_object
resp = @client.raw_request(
:get,
related_object.url,
opts: { stripe_context: context },
usage: ["fetch_related_object"]
)
@client.deserialize(resp.http_body, api_mode: Util.get_api_mode(related_object.url))
end
end
end
end

View File

@ -2,12 +2,99 @@
# frozen_string_literal: true
module Stripe
module Events
# Occurs when a Meter's id is missing or invalid in async usage events.
class V1BillingMeterNoMeterFoundEvent < Stripe::V2::Event
def self.lookup_type
"v1.billing.meter.no_meter_found"
end
# There is additional data present for this event, accessible with the `data` property.
# See the Stripe API docs for more information.
class V1BillingMeterNoMeterFoundEventData < Stripe::StripeObject
class Reason < Stripe::StripeObject
class ErrorType < Stripe::StripeObject
class SampleError < Stripe::StripeObject
class Request < Stripe::StripeObject
# The request idempotency key.
attr_reader :identifier
def self.inner_class_types
@inner_class_types = {}
end
def self.field_remappings
@field_remappings = {}
end
end
# The error message.
attr_reader :error_message
# The request causes the error.
attr_reader :request
def self.inner_class_types
@inner_class_types = { request: Request }
end
def self.field_remappings
@field_remappings = {}
end
end
# Open Enum.
attr_reader :code
# The number of errors of this type.
attr_reader :error_count
# A list of sample errors of this type.
attr_reader :sample_errors
def self.inner_class_types
@inner_class_types = { sample_errors: SampleError }
end
def self.field_remappings
@field_remappings = {}
end
end
# The total error count within this window.
attr_reader :error_count
# The error details.
attr_reader :error_types
def self.inner_class_types
@inner_class_types = { error_types: ErrorType }
end
def self.field_remappings
@field_remappings = {}
end
end
# This contains information about why meter error happens.
attr_reader :reason
# Extra field included in the event's `data` when fetched from /v2/events.
attr_reader :developer_message_summary
# The start of the window that is encapsulated by this summary.
attr_reader :validation_start
# The end of the window that is encapsulated by this summary.
attr_reader :validation_end
def self.inner_class_types
@inner_class_types = { reason: Reason }
end
def self.field_remappings
@field_remappings = {}
end
end
def self.inner_class_types
@inner_class_types = { data: V1BillingMeterNoMeterFoundEventData }
end
attr_reader :data
end
# Occurs when a Meter's id is missing or invalid in async usage events.
class V1BillingMeterNoMeterFoundEventNotification < Stripe::V2::EventNotification
def self.lookup_type
"v1.billing.meter.no_meter_found"
end
end
end
end

View File

@ -2,20 +2,43 @@
# frozen_string_literal: true
module Stripe
module Events
# A ping event used to test the connection to an EventDestination.
class V2CoreEventDestinationPingEvent < Stripe::V2::Event
def self.lookup_type
"v2.core.event_destination.ping"
end
# Retrieves the related object from the API. Make an API request on every call.
# Retrieves the related object from the API. Makes an API request on every call.
def fetch_related_object
_request(
method: :get,
path: related_object.url,
base_address: :api,
opts: { stripe_account: context }
opts: { stripe_context: context }
)
end
attr_reader :related_object
end
# A ping event used to test the connection to an EventDestination.
class V2CoreEventDestinationPingEventNotification < Stripe::V2::EventNotification
def self.lookup_type
"v2.core.event_destination.ping"
end
attr_reader :related_object
# Retrieves the EventDestination related to this EventNotification from the Stripe API. Makes an API request on every call.
def fetch_related_object
resp = @client.raw_request(
:get,
related_object.url,
opts: { stripe_context: context },
usage: ["fetch_related_object"]
)
@client.deserialize(resp.http_body, api_mode: Util.get_api_mode(related_object.url))
end
end
end
end

View File

@ -59,7 +59,7 @@ module Stripe
extend Gem::Deprecate
deprecate :request, :raw_request, 2024, 9
def parse_thin_event(payload, sig_header, secret, tolerance: Webhook::DEFAULT_TOLERANCE)
def parse_event_notification(payload, sig_header, secret, tolerance: Webhook::DEFAULT_TOLERANCE)
payload = payload.force_encoding("UTF-8") if payload.respond_to?(:force_encoding)
# v2 events use the same signing mechanism as v1 events
@ -67,15 +67,17 @@ module Stripe
parsed = JSON.parse(payload, symbolize_names: true)
Stripe::ThinEvent.new(parsed)
cls = Util.event_notification_classes.fetch(parsed[:type], Stripe::V2::UnknownEventNotification)
cls.new(parsed, self)
end
def raw_request(method, url, base_address: :api, params: {}, opts: {})
def raw_request(method, url, base_address: :api, params: {}, opts: {}, usage: nil)
opts = Util.normalize_opts(opts)
req_opts = RequestOptions.extract_opts_from_hash(opts)
params = params.to_h if params.is_a?(Stripe::RequestParams)
resp, = @requestor.send(:execute_request_internal, method, url, base_address, params, req_opts, usage: ["raw_request"])
resp, = @requestor.send(:execute_request_internal, method, url, base_address, params, req_opts, usage: usage || ["raw_request"])
@requestor.interpret_response(resp)
end

View File

@ -1,37 +0,0 @@
# frozen_string_literal: true
module Stripe
class EventReasonRequest
attr_reader :id, :idempotency_key
def initialize(event_reason_request_payload = {})
@id = event_reason_request_payload[:id]
@idempotency_key = event_reason_request_payload[:idempotency_key]
end
end
class EventReason
attr_reader :type, :request
def initialize(event_reason_payload = {})
@type = event_reason_payload[:type]
@request = EventReasonRequest.new(event_reason_payload[:request])
end
end
class ThinEvent
attr_reader :id, :type, :created, :context, :related_object, :livemode, :reason
def initialize(event_payload = {})
@id = event_payload[:id]
@type = event_payload[:type]
@created = event_payload[:created]
@context = event_payload[:context]
@livemode = event_payload[:livemode]
@related_object = event_payload[:related_object]
return if event_payload[:reason].nil?
@reason = EventReason.new(event_payload[:reason])
end
end
end

View File

@ -30,8 +30,12 @@ module Stripe
@v2_object_classes ||= Stripe::ObjectTypes.v2_object_names_to_classes
end
def self.thin_event_classes
@thin_event_classes ||= Stripe::EventTypes.thin_event_names_to_classes
def self.v2_event_classes
@v2_event_classes ||= Stripe::EventTypes.v2_event_types_to_classes
end
def self.event_notification_classes
@event_notification_classes ||= Stripe::EventTypes.event_notification_types_to_classes
end
def self.object_name_matches_class?(object_name, klass)
@ -137,8 +141,7 @@ module Stripe
data.map { |i| convert_to_stripe_object(i, opts, api_mode: api_mode, requestor: requestor, klass: klass) }
when Hash
# TODO: This is a terrible hack.
# Waiting on https://jira.corp.stripe.com/browse/API_SERVICES-3167 to add
# an object in v2 lists
# Waiting on https://go/j/API_SERVICES-3167 to add an object in v2 lists
if api_mode == :v2 && data.include?(:data) && data.include?(:next_page_url)
return V2::ListObject.construct_from(data, opts, last_response, api_mode, requestor)
end
@ -152,8 +155,8 @@ module Stripe
elsif api_mode == :v2
if v2_deleted_object
V2::DeletedObject
elsif object_name == "v2.core.event" && thin_event_classes.key?(object_type)
thin_event_classes.fetch(object_type)
elsif object_name == "v2.core.event" && v2_event_classes.key?(object_type)
v2_event_classes.fetch(object_type)
else
v2_object_classes.fetch(
object_name, StripeObject

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
# typed: true
module Stripe
module V2
class EventReasonRequest
sig { returns(String) }
def id; end
sig { returns(String) }
def idempotency_key; end
sig { params(event_reason_request_payload: T::Hash[T.untyped, T.untyped]).void }
def initialize(event_reason_request_payload = {}); end
end
class EventReason
sig { returns(String) }
def type; end
sig { returns(::Stripe::V2::EventReasonRequest) }
def request; end
sig { params(event_reason_payload: T::Hash[T.untyped, T.untyped]).void }
def initialize(event_reason_payload = {}); end
end
class EventNotification
sig { returns(String) }
def id; end
sig { returns(String) }
def type; end
sig { returns(String) }
def created; end
sig { returns(T.nilable(String)) }
def context; end
sig { returns(T::Boolean) }
def livemode; end
sig { returns(T.nilable(::Stripe::V2::EventReason)) }
def reason; end
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
# typed: true
module Stripe
class StripeClient
sig do
params(
payload: String,
sig_header: String,
secret: String,
tolerance: T.nilable(Integer)
)
.returns(::Stripe::V2::EventNotification)
end
def parse_event_notification(payload, sig_header, secret, tolerance:); end
end
end

View File

@ -27,20 +27,17 @@ Gem::Specification.new do |s|
"rubygems_mfa_required" => "false",
}
ignored = Regexp.union(
/\A\.editorconfig/,
/\A\.git/,
/\A\.rubocop/,
/\A\.travis.yml/,
/\A\.vscode/,
/\Abin/,
/\Asorbet/,
/\Atest/,
# Ignores the contents of rbi/stripe/** but keeps rbi/stripe.rbi
# Only rbi/stripe.rbi is included in the gem
%r{\Arbi/stripe/}
included = Regexp.union(
%r{\Alib/},
%r{\Aexe/},
# generated RBI files
%r{\Arbi/stripe\.rbi\z},
# Handwritten RBIs
# TODO(helenye): http://go/j/DEVSDK-2769
%r{\Arbi/stripe/stripe_client.rbi\z},
%r{\Arbi/stripe/event_notification.rbi\z}
)
s.files = `git ls-files`.split("\n").grep_v(ignored)
s.files = `git ls-files`.split("\n").grep(included)
s.bindir = "exe"
s.executables = `git ls-files -- exe/*`.split("\n").map { |f| File.basename(f) }
s.require_paths = ["lib"]

View File

@ -6,11 +6,7 @@ require "json"
module Stripe
class V2EventTest < Test::Unit::TestCase
def parse_signed_event(payload, secret = Test::WebhookHelpers::SECRET)
@client.parse_thin_event(payload, Test::WebhookHelpers.generate_header(payload: payload), secret)
end
def retrieve_event(evt_id)
@client.v2.core.events.retrieve(evt_id)
@client.parse_event_notification(payload, Test::WebhookHelpers.generate_header(payload: payload), secret)
end
context "V2 Events" do
@ -24,6 +20,13 @@ module Stripe
"created" => "2022-02-15T00:27:45.330Z",
}.to_json
@v2_payload_fake_event = {
"id" => "evt_234",
"object" => "v2.core.event",
"type" => "whatever",
"created" => "2022-02-15T00:27:45.330Z",
}.to_json
@v2_push_payload = {
"id" => "evt_234",
"object" => "v2.core.event",
@ -92,7 +95,7 @@ module Stripe
context ".event_signing" do
should "parse v2 events" do
event = parse_signed_event(@v2_push_payload)
assert event.is_a?(Stripe::ThinEvent)
assert event.is_a?(Stripe::V2::EventNotification)
assert_equal "evt_234", event.id
assert_equal "v1.billing.meter.error_report_triggered", event.type
assert_equal "2022-02-15T00:27:45.330Z", event.created
@ -101,7 +104,10 @@ module Stripe
should "parse v2 events with livemode and reason" do
event = parse_signed_event(@v2_push_payload_with_livemode_and_reason)
assert event.is_a?(Stripe::ThinEvent)
assert event.is_a?(Stripe::V2::EventNotification)
assert event.related_object.is_a?(Stripe::V2::RelatedObject)
assert event.reason.is_a?(Stripe::V2::EventReason)
assert_equal "evt_234", event.id
assert_equal "v1.billing.meter.error_report_triggered", event.type
assert_equal "2022-02-15T00:27:45.330Z", event.created
@ -119,35 +125,52 @@ module Stripe
end
end
should "retrieve event data" do
event = parse_signed_event(@v2_push_payload)
assert event.is_a?(Stripe::ThinEvent)
context "Event notifications" do
should "parse event notifications and pull data" do
event_notif = parse_signed_event(@v2_push_payload)
assert event_notif.instance_of?(Stripe::Events::V1BillingMeterErrorReportTriggeredEventNotification)
stub_request(:get, "#{Stripe::DEFAULT_API_BASE}/v2/core/events/evt_234")
.to_return(body: @v2_pull_payload)
ret_event = retrieve_event(event.id)
assert ret_event.is_a?(Stripe::V1BillingMeterErrorReportTriggeredEvent)
assert ret_event.data.error == "bufo"
assert ret_event.data.reason.error_types[0].code == "meter_event_invalid_value"
end
should "fetch object" do
stub_request(:get, "#{Stripe::DEFAULT_API_BASE}/v1/billing/meters/mtr_123")
.to_return(body: JSON.generate({ "id" => "mtr_123", "object" => "billing.meter" }))
stub_request(:get, "#{Stripe::DEFAULT_API_BASE}/v2/core/events/evt_234")
.to_return(body: @v2_pull_payload)
event = parse_signed_event(@v2_push_payload)
assert event.is_a?(Stripe::ThinEvent)
meter = event_notif.fetch_related_object
assert meter.instance_of?(Stripe::Billing::Meter)
assert meter.id == "mtr_123"
ret_event = retrieve_event(event.id)
assert ret_event.is_a?(Stripe::V1BillingMeterErrorReportTriggeredEvent)
event = event_notif.fetch_event
assert event.is_a?(Stripe::Events::V1BillingMeterErrorReportTriggeredEvent)
assert_equal "a", event.data.reason.error_types.first.sample_errors.first.request.identifier
end
mtr = @client.v1.billing.meters.retrieve("mtr_123")
should "correctly retrieve events" do
event_notif = parse_signed_event(@v2_push_payload)
stub_request(:get, "#{Stripe::DEFAULT_API_BASE}/v2/core/events/evt_234")
.to_return(body: @v2_pull_payload)
assert mtr.is_a?(Stripe::Billing::Meter)
assert mtr.id == "mtr_123"
event = @client.v2.core.events.retrieve(event_notif.id)
assert event.is_a?(Stripe::Events::V1BillingMeterErrorReportTriggeredEvent)
assert event.data.error == "bufo"
assert event.data.reason.error_types[0].code == "meter_event_invalid_value"
end
should "parse unknown events" do
event_notif = parse_signed_event(@v2_payload_fake_event)
assert event_notif.instance_of?(Stripe::V2::UnknownEventNotification)
stub_request(:get, "#{Stripe::DEFAULT_API_BASE}/v2/core/events/evt_234")
.to_return(body: {
"id" => "evt_234",
"object" => "v2.core.event",
"type" => "whatever",
"created" => "2022-02-15T00:27:45.330Z",
}.to_json)
event = event_notif.fetch_event
assert event.instance_of?(Stripe::V2::Event)
end
end
end
end