diff --git a/src/main/java/com/stripe/StripeClient.java b/src/main/java/com/stripe/StripeClient.java index 8031aa125ef..77723ece24c 100644 --- a/src/main/java/com/stripe/StripeClient.java +++ b/src/main/java/com/stripe/StripeClient.java @@ -41,6 +41,73 @@ protected StripeResponseGetter getResponseGetter() { return responseGetter; } + /** Gets the current StripeContext from the client's configuration. Used in unit testing. */ + protected String getContext() { + // TODO(major): add getOptions to the StripeResponseGetter interface? that would simplify this + if (!(responseGetter instanceof LiveStripeResponseGetter)) { + return null; + } + + LiveStripeResponseGetter liveGetter = (LiveStripeResponseGetter) responseGetter; + StripeResponseGetterOptions options = liveGetter.getOptions(); + + return options.getStripeContext(); + } + + /** + * Creates a new StripeClient with the same configuration as this client but with a custom + * StripeContext. This method is useful for creating thread-safe clients with different contexts, + * such as when processing webhooks in parallel where each webhook has its own context. + * + *

The new client will share the same configuration (API key, timeouts, proxy settings, etc.) + * and HTTP client as this client, but will have the specified context. This allows for efficient + * parallel processing without reinitializing HTTP connections. + * + * @param context the custom stripe_context to use for the new client + * @return a new StripeClient with the custom context + * @throws IllegalStateException if this client doesn't use a LiveStripeResponseGetter + */ + public StripeClient withStripeContext(StripeContext context) { + // Convert StripeContext to String + String contextString = (context == null) ? null : context.toString(); + + StripeResponseGetter responseGetter = this.getResponseGetter(); + + // We can only create a new client for LiveStripeResponseGetter + if (!(responseGetter instanceof LiveStripeResponseGetter)) { + throw new IllegalStateException( + "Cannot create a client with custom context for non-Live response getters"); + } + + LiveStripeResponseGetter liveGetter = (LiveStripeResponseGetter) responseGetter; + + // Create a new LiveStripeResponseGetter with updated context, reusing the HTTP client + LiveStripeResponseGetter newResponseGetter = + liveGetter.withNewOptions( + options -> { + ClientStripeResponseGetterOptions existingOptions = + (ClientStripeResponseGetterOptions) options; + + return new ClientStripeResponseGetterOptions( + existingOptions.getAuthenticator(), + existingOptions.getClientId(), + existingOptions.getConnectTimeout(), + existingOptions.getReadTimeout(), + existingOptions.getMaxNetworkRetries(), + existingOptions.getConnectionProxy(), + existingOptions.getProxyCredential(), + existingOptions.getApiBase(), + existingOptions.getFilesBase(), + existingOptions.getConnectBase(), + existingOptions.getMeterEventsBase(), + existingOptions.getStripeAccount(), + contextString); + }); + + // Create and return a new StripeClient with the new response getter + return new StripeClient(newResponseGetter); + } + /** * Returns an StripeEvent instance using the provided JSON payload. Throws a JsonSyntaxException * if the payload is not valid JSON, and a SignatureVerificationException if the signature @@ -1412,4 +1479,9 @@ public StripeResponse rawRequest( public StripeObject deserialize(String rawJson, ApiMode apiMode) throws StripeException { return StripeObject.deserializeStripeObject(rawJson, this.getResponseGetter(), apiMode); } + + public StripeEventNotificationHandler notificationHandler( + String webhookSecret, StripeEventNotificationHandler.FallbackCallback fallbackCallback) { + return new StripeEventNotificationHandler(webhookSecret, this, fallbackCallback); + } } diff --git a/src/main/java/com/stripe/StripeContext.java b/src/main/java/com/stripe/StripeContext.java index cd7b15b19e8..a86572595ed 100644 --- a/src/main/java/com/stripe/StripeContext.java +++ b/src/main/java/com/stripe/StripeContext.java @@ -64,8 +64,8 @@ public StripeContext pop() { /** * Converts the context to a string by joining segments with '/'. * - * @return string representation of the context segments joined by '/', `null` if there are no - * segments (useful for clearing context) + * @return string representation of the context segments joined by '/'. If there are no segments, + * returns an empty string (useful for clearing context). */ @Override public String toString() { diff --git a/src/main/java/com/stripe/StripeEventNotificationHandler.java b/src/main/java/com/stripe/StripeEventNotificationHandler.java new file mode 100644 index 00000000000..e0843146789 --- /dev/null +++ b/src/main/java/com/stripe/StripeEventNotificationHandler.java @@ -0,0 +1,541 @@ +package com.stripe; + +// event-notification-class-imports: The beginning of the section generated from our OpenAPI spec +// - hack because we can't format java files whose imports aren't a single contiguous block +// - so _any_ imports in this file have to come from codegen +// - as do these comments, explaining the whole thing +import com.stripe.events.V1BillingMeterErrorReportTriggeredEventNotification; +import com.stripe.events.V1BillingMeterNoMeterFoundEventNotification; +import com.stripe.events.V2CoreAccountClosedEventNotification; +import com.stripe.events.V2CoreAccountCreatedEventNotification; +import com.stripe.events.V2CoreAccountIncludingConfigurationCustomerCapabilityStatusUpdatedEventNotification; +import com.stripe.events.V2CoreAccountIncludingConfigurationCustomerUpdatedEventNotification; +import com.stripe.events.V2CoreAccountIncludingConfigurationMerchantCapabilityStatusUpdatedEventNotification; +import com.stripe.events.V2CoreAccountIncludingConfigurationMerchantUpdatedEventNotification; +import com.stripe.events.V2CoreAccountIncludingConfigurationRecipientCapabilityStatusUpdatedEventNotification; +import com.stripe.events.V2CoreAccountIncludingConfigurationRecipientUpdatedEventNotification; +import com.stripe.events.V2CoreAccountIncludingConfigurationStorerCapabilityStatusUpdatedEventNotification; +import com.stripe.events.V2CoreAccountIncludingConfigurationStorerUpdatedEventNotification; +import com.stripe.events.V2CoreAccountIncludingDefaultsUpdatedEventNotification; +import com.stripe.events.V2CoreAccountIncludingIdentityUpdatedEventNotification; +import com.stripe.events.V2CoreAccountIncludingRequirementsUpdatedEventNotification; +import com.stripe.events.V2CoreAccountLinkReturnedEventNotification; +import com.stripe.events.V2CoreAccountPersonCreatedEventNotification; +import com.stripe.events.V2CoreAccountPersonDeletedEventNotification; +import com.stripe.events.V2CoreAccountPersonUpdatedEventNotification; +import com.stripe.events.V2CoreAccountUpdatedEventNotification; +import com.stripe.events.V2CoreEventDestinationPingEventNotification; +import com.stripe.events.V2CoreHealthEventGenerationFailureResolvedEventNotification; +import com.stripe.events.V2MoneyManagementAdjustmentCreatedEventNotification; +import com.stripe.events.V2MoneyManagementFinancialAccountCreatedEventNotification; +import com.stripe.events.V2MoneyManagementFinancialAccountUpdatedEventNotification; +import com.stripe.events.V2MoneyManagementFinancialAddressActivatedEventNotification; +import com.stripe.events.V2MoneyManagementFinancialAddressFailedEventNotification; +import com.stripe.events.V2MoneyManagementInboundTransferAvailableEventNotification; +import com.stripe.events.V2MoneyManagementInboundTransferBankDebitFailedEventNotification; +import com.stripe.events.V2MoneyManagementInboundTransferBankDebitProcessingEventNotification; +import com.stripe.events.V2MoneyManagementInboundTransferBankDebitQueuedEventNotification; +import com.stripe.events.V2MoneyManagementInboundTransferBankDebitReturnedEventNotification; +import com.stripe.events.V2MoneyManagementInboundTransferBankDebitSucceededEventNotification; +import com.stripe.events.V2MoneyManagementOutboundPaymentCanceledEventNotification; +import com.stripe.events.V2MoneyManagementOutboundPaymentCreatedEventNotification; +import com.stripe.events.V2MoneyManagementOutboundPaymentFailedEventNotification; +import com.stripe.events.V2MoneyManagementOutboundPaymentPostedEventNotification; +import com.stripe.events.V2MoneyManagementOutboundPaymentReturnedEventNotification; +import com.stripe.events.V2MoneyManagementOutboundPaymentUpdatedEventNotification; +import com.stripe.events.V2MoneyManagementOutboundTransferCanceledEventNotification; +import com.stripe.events.V2MoneyManagementOutboundTransferCreatedEventNotification; +import com.stripe.events.V2MoneyManagementOutboundTransferFailedEventNotification; +import com.stripe.events.V2MoneyManagementOutboundTransferPostedEventNotification; +import com.stripe.events.V2MoneyManagementOutboundTransferReturnedEventNotification; +import com.stripe.events.V2MoneyManagementOutboundTransferUpdatedEventNotification; +import com.stripe.events.V2MoneyManagementPayoutMethodUpdatedEventNotification; +import com.stripe.events.V2MoneyManagementReceivedCreditAvailableEventNotification; +import com.stripe.events.V2MoneyManagementReceivedCreditFailedEventNotification; +import com.stripe.events.V2MoneyManagementReceivedCreditReturnedEventNotification; +import com.stripe.events.V2MoneyManagementReceivedCreditSucceededEventNotification; +import com.stripe.events.V2MoneyManagementReceivedDebitCanceledEventNotification; +import com.stripe.events.V2MoneyManagementReceivedDebitFailedEventNotification; +import com.stripe.events.V2MoneyManagementReceivedDebitPendingEventNotification; +import com.stripe.events.V2MoneyManagementReceivedDebitSucceededEventNotification; +import com.stripe.events.V2MoneyManagementReceivedDebitUpdatedEventNotification; +import com.stripe.events.V2MoneyManagementTransactionCreatedEventNotification; +import com.stripe.events.V2MoneyManagementTransactionUpdatedEventNotification; +import com.stripe.exception.SignatureVerificationException; +import com.stripe.model.v2.core.EventNotification; +import java.util.HashMap; + +// event-notification-class-imports: The end of the section generated from our OpenAPI spec + +public class StripeEventNotificationHandler { + /** + * Functional interface for callback functions. It describes the signature of the functions you'll + * register on the StripeEventRouter to process incoming event notifications. + */ + @FunctionalInterface + public interface Callback { + // this is an internal-facing method name that dictates how we call the stored method + void process(T event, StripeClient client); + } + + /** + * Functional interface for handling otherwise unhandled events. It's similar to {@link Callback}, + * but includes additional information about the unhandled event to help debug it. + */ + @FunctionalInterface + public interface FallbackCallback { + // this is an internal-facing method name that dictates how we call the stored method + void process( + EventNotification event, StripeClient client, UnhandledNotificationDetails details); + } + + /** + * Information about an unhandled event notification to make it easier to respond (and potentially + * update your integration). + */ + public static class UnhandledNotificationDetails { + private boolean isKnownEventType; + + private UnhandledNotificationDetails(boolean isKnownEventType) { + this.isKnownEventType = isKnownEventType; + } + + /** + * If true, the unhandled event's type is known to the SDK (i.e., it was successfully + * deserialized into a specific `EventNotification` subclass). + */ + public boolean isKnownEventType() { + return isKnownEventType; + } + } + + private boolean hasHandledEvent = false; + + private final String webhookSecret; + private final StripeClient client; + private final FallbackCallback fallbackCallback; + private final HashMap> registeredHandlers = + new HashMap<>(); + + public StripeEventNotificationHandler( + String webhookSecret, StripeClient client, FallbackCallback fallbackCallback) { + this.webhookSecret = webhookSecret; + this.client = client; + this.fallbackCallback = fallbackCallback; + } + + private StripeEventNotificationHandler register( + String eventType, Callback handler) { + if (hasHandledEvent) { + throw new IllegalStateException("Cannot register handlers after handling an event"); + } + + if (this.registeredHandlers.containsKey(eventType)) { + throw new IllegalArgumentException("Handler already registered for event type: " + eventType); + } + this.registeredHandlers.put(eventType, handler); + return this; + } + + /** + * Handle an incoming webhook event notification. + * + * @param webhookBody the incoming webhook body + * @param sigHeader the incoming webhook signature header + * @throws SignatureVerificationException if the validation of the webhook signature fails + * @throws IllegalArgumentException if no handler is registered for the event type + */ + @SuppressWarnings("unchecked") + public void handle(String webhookBody, String sigHeader) throws SignatureVerificationException { + // setting this naiively isn't technically thread-safe, but we expect the all callbacks to be + // registered syncronously on startup, so this should be fine + hasHandledEvent = true; + + EventNotification eventNotification = + this.client.parseEventNotification(webhookBody, sigHeader, this.webhookSecret); + + Callback handler = + registeredHandlers.get(eventNotification.getType()); + + // Create a new client with the event's context for thread-safe processing + StripeClient eventClient = this.client.withStripeContext(eventNotification.context); + + if (handler == null) { + boolean isKnownEventType = + !(eventNotification instanceof com.stripe.events.UnknownEventNotification); + UnhandledNotificationDetails details = new UnhandledNotificationDetails(isKnownEventType); + + this.fallbackCallback.process(eventNotification, eventClient, details); + } else { + // this is technically unsafe but we control the registration API so should be ok + ((Callback) handler).process(eventNotification, eventClient); + } + } + + // notification-handler-methods: The beginning of the section generated from our OpenAPI spec + public StripeEventNotificationHandler onV1BillingMeterErrorReportTriggered( + Callback callback) { + this.register("v1.billing.meter.error_report_triggered", callback); + return this; + } + + public StripeEventNotificationHandler onV1BillingMeterNoMeterFound( + Callback callback) { + this.register("v1.billing.meter.no_meter_found", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountClosed( + Callback callback) { + this.register("v2.core.account.closed", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountCreated( + Callback callback) { + this.register("v2.core.account.created", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountUpdated( + Callback callback) { + this.register("v2.core.account.updated", callback); + return this; + } + + public StripeEventNotificationHandler + onV2CoreAccountIncludingConfigurationCustomerCapabilityStatusUpdated( + Callback< + V2CoreAccountIncludingConfigurationCustomerCapabilityStatusUpdatedEventNotification> + callback) { + this.register("v2.core.account[configuration.customer].capability_status_updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountIncludingConfigurationCustomerUpdated( + Callback callback) { + this.register("v2.core.account[configuration.customer].updated", callback); + return this; + } + + public StripeEventNotificationHandler + onV2CoreAccountIncludingConfigurationMerchantCapabilityStatusUpdated( + Callback< + V2CoreAccountIncludingConfigurationMerchantCapabilityStatusUpdatedEventNotification> + callback) { + this.register("v2.core.account[configuration.merchant].capability_status_updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountIncludingConfigurationMerchantUpdated( + Callback callback) { + this.register("v2.core.account[configuration.merchant].updated", callback); + return this; + } + + public StripeEventNotificationHandler + onV2CoreAccountIncludingConfigurationRecipientCapabilityStatusUpdated( + Callback< + V2CoreAccountIncludingConfigurationRecipientCapabilityStatusUpdatedEventNotification> + callback) { + this.register("v2.core.account[configuration.recipient].capability_status_updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountIncludingConfigurationRecipientUpdated( + Callback callback) { + this.register("v2.core.account[configuration.recipient].updated", callback); + return this; + } + + public StripeEventNotificationHandler + onV2CoreAccountIncludingConfigurationStorerCapabilityStatusUpdated( + Callback< + V2CoreAccountIncludingConfigurationStorerCapabilityStatusUpdatedEventNotification> + callback) { + this.register("v2.core.account[configuration.storer].capability_status_updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountIncludingConfigurationStorerUpdated( + Callback callback) { + this.register("v2.core.account[configuration.storer].updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountIncludingDefaultsUpdated( + Callback callback) { + this.register("v2.core.account[defaults].updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountIncludingIdentityUpdated( + Callback callback) { + this.register("v2.core.account[identity].updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountIncludingRequirementsUpdated( + Callback callback) { + this.register("v2.core.account[requirements].updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountLinkReturned( + Callback callback) { + this.register("v2.core.account_link.returned", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountPersonCreated( + Callback callback) { + this.register("v2.core.account_person.created", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountPersonDeleted( + Callback callback) { + this.register("v2.core.account_person.deleted", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreAccountPersonUpdated( + Callback callback) { + this.register("v2.core.account_person.updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreEventDestinationPing( + Callback callback) { + this.register("v2.core.event_destination.ping", callback); + return this; + } + + public StripeEventNotificationHandler onV2CoreHealthEventGenerationFailureResolved( + Callback callback) { + this.register("v2.core.health.event_generation_failure.resolved", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementAdjustmentCreated( + Callback callback) { + this.register("v2.money_management.adjustment.created", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementFinancialAccountCreated( + Callback callback) { + this.register("v2.money_management.financial_account.created", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementFinancialAccountUpdated( + Callback callback) { + this.register("v2.money_management.financial_account.updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementFinancialAddressActivated( + Callback callback) { + this.register("v2.money_management.financial_address.activated", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementFinancialAddressFailed( + Callback callback) { + this.register("v2.money_management.financial_address.failed", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementInboundTransferAvailable( + Callback callback) { + this.register("v2.money_management.inbound_transfer.available", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementInboundTransferBankDebitFailed( + Callback callback) { + this.register("v2.money_management.inbound_transfer.bank_debit_failed", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementInboundTransferBankDebitProcessing( + Callback callback) { + this.register("v2.money_management.inbound_transfer.bank_debit_processing", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementInboundTransferBankDebitQueued( + Callback callback) { + this.register("v2.money_management.inbound_transfer.bank_debit_queued", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementInboundTransferBankDebitReturned( + Callback callback) { + this.register("v2.money_management.inbound_transfer.bank_debit_returned", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementInboundTransferBankDebitSucceeded( + Callback callback) { + this.register("v2.money_management.inbound_transfer.bank_debit_succeeded", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementOutboundPaymentCanceled( + Callback callback) { + this.register("v2.money_management.outbound_payment.canceled", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementOutboundPaymentCreated( + Callback callback) { + this.register("v2.money_management.outbound_payment.created", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementOutboundPaymentFailed( + Callback callback) { + this.register("v2.money_management.outbound_payment.failed", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementOutboundPaymentPosted( + Callback callback) { + this.register("v2.money_management.outbound_payment.posted", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementOutboundPaymentReturned( + Callback callback) { + this.register("v2.money_management.outbound_payment.returned", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementOutboundPaymentUpdated( + Callback callback) { + this.register("v2.money_management.outbound_payment.updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementOutboundTransferCanceled( + Callback callback) { + this.register("v2.money_management.outbound_transfer.canceled", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementOutboundTransferCreated( + Callback callback) { + this.register("v2.money_management.outbound_transfer.created", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementOutboundTransferFailed( + Callback callback) { + this.register("v2.money_management.outbound_transfer.failed", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementOutboundTransferPosted( + Callback callback) { + this.register("v2.money_management.outbound_transfer.posted", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementOutboundTransferReturned( + Callback callback) { + this.register("v2.money_management.outbound_transfer.returned", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementOutboundTransferUpdated( + Callback callback) { + this.register("v2.money_management.outbound_transfer.updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementPayoutMethodUpdated( + Callback callback) { + this.register("v2.money_management.payout_method.updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementReceivedCreditAvailable( + Callback callback) { + this.register("v2.money_management.received_credit.available", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementReceivedCreditFailed( + Callback callback) { + this.register("v2.money_management.received_credit.failed", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementReceivedCreditReturned( + Callback callback) { + this.register("v2.money_management.received_credit.returned", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementReceivedCreditSucceeded( + Callback callback) { + this.register("v2.money_management.received_credit.succeeded", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementReceivedDebitCanceled( + Callback callback) { + this.register("v2.money_management.received_debit.canceled", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementReceivedDebitFailed( + Callback callback) { + this.register("v2.money_management.received_debit.failed", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementReceivedDebitPending( + Callback callback) { + this.register("v2.money_management.received_debit.pending", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementReceivedDebitSucceeded( + Callback callback) { + this.register("v2.money_management.received_debit.succeeded", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementReceivedDebitUpdated( + Callback callback) { + this.register("v2.money_management.received_debit.updated", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementTransactionCreated( + Callback callback) { + this.register("v2.money_management.transaction.created", callback); + return this; + } + + public StripeEventNotificationHandler onV2MoneyManagementTransactionUpdated( + Callback callback) { + this.register("v2.money_management.transaction.updated", callback); + return this; + } + // notification-handler-methods: The end of the section generated from our OpenAPI spec + + /** + * Get a sorted list of all registered event types. + * + * @return A sorted list of event type strings + */ + public java.util.List getRegisteredEventTypes() { + java.util.List eventTypes = new java.util.ArrayList<>(this.registeredHandlers.keySet()); + java.util.Collections.sort(eventTypes); + return eventTypes; + } +} diff --git a/src/main/java/com/stripe/net/LiveStripeResponseGetter.java b/src/main/java/com/stripe/net/LiveStripeResponseGetter.java index d8fdc544cf7..742d88c33e4 100644 --- a/src/main/java/com/stripe/net/LiveStripeResponseGetter.java +++ b/src/main/java/com/stripe/net/LiveStripeResponseGetter.java @@ -76,6 +76,27 @@ public LiveStripeResponseGetter(StripeResponseGetterOptions options, HttpClient this.httpClient = (httpClient != null) ? httpClient : buildDefaultHttpClient(); } + /** + * Creates a new LiveStripeResponseGetter with the same configuration and HTTP client as this + * instance, but with a different stripe_context. This allows for efficient cloning when you need + * to make requests with different contexts (e.g., webhook processing) without reinitializing HTTP + * connections. + * + * @param contextCreator a function that takes the existing options and returns new options with + * the desired context + * @return a new LiveStripeResponseGetter with the updated options and the same HTTP client + */ + public LiveStripeResponseGetter withNewOptions( + java.util.function.Function + contextCreator) { + StripeResponseGetterOptions newOptions = contextCreator.apply(this.options); + return new LiveStripeResponseGetter(newOptions, this.httpClient); + } + + public StripeResponseGetterOptions getOptions() { + return this.options; + } + private StripeRequest toStripeRequest(ApiRequest apiRequest, RequestOptions mergedOptions) throws StripeException { String fullUrl = fullUrl(apiRequest); diff --git a/src/test/java/com/stripe/StripeEventNotificationHandlerTest.java b/src/test/java/com/stripe/StripeEventNotificationHandlerTest.java new file mode 100644 index 00000000000..eaa77ac4bd2 --- /dev/null +++ b/src/test/java/com/stripe/StripeEventNotificationHandlerTest.java @@ -0,0 +1,517 @@ +package com.stripe; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.stripe.events.UnknownEventNotification; +import com.stripe.events.V1BillingMeterErrorReportTriggeredEventNotification; +import com.stripe.events.V2CoreAccountCreatedEventNotification; +import com.stripe.exception.SignatureVerificationException; +import com.stripe.model.v2.core.EventNotification; +import com.stripe.net.Webhook; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class StripeEventNotificationHandlerTest { + private static final String DUMMY_WEBHOOK_SECRET = "whsec_test_secret"; + + private StripeClient stripeClient; + private StripeEventNotificationHandler.FallbackCallback fallbackCallback; + private StripeEventNotificationHandler eventNotificationHandler; + + private String v1BillingMeterPayload; + private String v2AccountCreatedPayload; + private String unknownEventPayload; + + @BeforeEach + public void setUp() { + // Create a StripeClient with context + stripeClient = + StripeClient.builder() + .setApiKey("sk_test_1234") + .setStripeContext("original_context_123") + .build(); + + // Create mock handler for unhandled events + fallbackCallback = mock(StripeEventNotificationHandler.FallbackCallback.class); + + // Create event router + eventNotificationHandler = + new StripeEventNotificationHandler(DUMMY_WEBHOOK_SECRET, stripeClient, fallbackCallback); + + // Set up test payloads + v1BillingMeterPayload = + "{" + + "\"id\": \"evt_123\"," + + "\"object\": \"v2.core.event\"," + + "\"type\": \"v1.billing.meter.error_report_triggered\"," + + "\"livemode\": false," + + "\"created\": \"2022-02-15T00:27:45.330Z\"," + + "\"context\": \"event_context_456\"," + + "\"related_object\": {" + + "\"id\": \"mtr_123\"," + + "\"type\": \"billing.meter\"," + + "\"url\": \"/v1/billing/meters/mtr_123\"" + + "}" + + "}"; + + v2AccountCreatedPayload = + "{" + + "\"id\": \"evt_789\"," + + "\"object\": \"v2.core.event\"," + + "\"type\": \"v2.core.account.created\"," + + "\"livemode\": false," + + "\"created\": \"2022-02-15T00:27:45.330Z\"," + + "\"context\": null," + + "\"related_object\": {" + + "\"id\": \"acct_abc\"," + + "\"type\": \"account\"," + + "\"url\": \"/v2/core/accounts/acct_abc\"" + + "}" + + "}"; + + unknownEventPayload = + "{" + + "\"id\": \"evt_unknown\"," + + "\"object\": \"v2.core.event\"," + + "\"type\": \"llama.created\"," + + "\"livemode\": false," + + "\"created\": \"2022-02-15T00:27:45.330Z\"," + + "\"context\": \"event_context_unknown\"," + + "\"related_object\": {" + + "\"id\": \"llama_123\"," + + "\"type\": \"llama\"," + + "\"url\": \"/v1/llamas/llama_123\"" + + "}" + + "}"; + } + + private String generateSigHeader(String payload) + throws NoSuchAlgorithmException, InvalidKeyException { + Map options = new HashMap<>(); + options.put("payload", payload); + options.put("secret", DUMMY_WEBHOOK_SECRET); + return generateSigHeader(options); + } + + private String generateSigHeader(Map options) + throws NoSuchAlgorithmException, InvalidKeyException { + final long timestamp = + (options.get("timestamp") != null) + ? ((Long) options.get("timestamp")).longValue() + : Webhook.Util.getTimeNow(); + final String payload = (String) options.get("payload"); + final String secret = (String) options.get("secret"); + final String scheme = + (options.get("scheme") != null) + ? (String) options.get("scheme") + : Webhook.Signature.EXPECTED_SCHEME; + String signature = (String) options.get("signature"); + + if (signature == null) { + final String payloadToSign = String.format("%d.%s", timestamp, payload); + signature = Webhook.Util.computeHmacSha256(secret, payloadToSign); + } + + return String.format("t=%d,%s=%s", timestamp, scheme, signature); + } + + @Test + public void testRoutesEventToRegisteredHandler() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Test that a registered event type is routed to the correct handler + @SuppressWarnings("unchecked") + StripeEventNotificationHandler.Callback + handler = mock(StripeEventNotificationHandler.Callback.class); + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader); + + verify(handler, times(1)) + .process(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + verify(fallbackCallback, never()) + .process( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void testRoutesDifferentEventsToCorrectHandlers() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Test that different event types route to their respective handlers + StripeEventNotificationHandler.Callback + billingHandler = mock(StripeEventNotificationHandler.Callback.class); + StripeEventNotificationHandler.Callback accountHandler = + mock(StripeEventNotificationHandler.Callback.class); + + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(billingHandler); + eventNotificationHandler.onV2CoreAccountCreated(accountHandler); + + String sigHeader1 = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader1); + + String sigHeader2 = generateSigHeader(v2AccountCreatedPayload); + eventNotificationHandler.handle(v2AccountCreatedPayload, sigHeader2); + + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader1); + + verify(billingHandler, times(2)) + .process(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + verify(accountHandler, times(1)) + .process(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + verify(fallbackCallback, never()) + .process( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + public void testHandlerReceivesCorrectRuntimeType() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Test that handlers receive the correctly typed event notification + AtomicReference receivedEvent = new AtomicReference<>(); + AtomicReference receivedClient = new AtomicReference<>(); + + StripeEventNotificationHandler.Callback + handler = + (event, client) -> { + receivedEvent.set(event); + receivedClient.set(client); + }; + + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader); + + assertInstanceOf( + V1BillingMeterErrorReportTriggeredEventNotification.class, receivedEvent.get()); + V1BillingMeterErrorReportTriggeredEventNotification notification = + (V1BillingMeterErrorReportTriggeredEventNotification) receivedEvent.get(); + assertEquals("v1.billing.meter.error_report_triggered", notification.getType()); + assertEquals("evt_123", notification.getId()); + assertEquals("mtr_123", notification.getRelatedObject().getId()); + assertNotNull(receivedClient.get()); + } + + @SuppressWarnings("unchecked") + @Test + public void testCannotRegisterHandlerAfterHandling() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Test that registering handlers after handle() raises IllegalStateException + StripeEventNotificationHandler.Callback + handler = mock(StripeEventNotificationHandler.Callback.class); + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader); + + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> + eventNotificationHandler.onV2CoreAccountCreated( + mock(StripeEventNotificationHandler.Callback.class))); + + assertTrue(exception.getMessage().contains("Cannot register handlers after handling an event")); + } + + @SuppressWarnings("unchecked") + @Test + public void testCannotRegisterDuplicateHandler() { + // Test that registering the same event type twice raises IllegalArgumentException + StripeEventNotificationHandler.Callback + handler1 = mock(StripeEventNotificationHandler.Callback.class); + StripeEventNotificationHandler.Callback + handler2 = mock(StripeEventNotificationHandler.Callback.class); + + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler1); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler2)); + + assertTrue( + exception + .getMessage() + .contains( + "Handler already registered for event type: v1.billing.meter.error_report_triggered")); + } + + @Test + public void testHandlerUsesEventStripeContext() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Test that the handler receives a client with stripe_context from the event + AtomicReference receivedContext = new AtomicReference<>(); + + StripeEventNotificationHandler.Callback + handler = + (event, client) -> { + receivedContext.set(client.getContext()); + }; + + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler); + + assertEquals("original_context_123", stripeClient.getContext()); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader); + + assertEquals("event_context_456", receivedContext.get()); + } + + @Test + public void testStripeContextRestoredAfterHandlerSuccess() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Test that the original stripe_context is restored after successful handler execution + StripeEventNotificationHandler.Callback + handler = + (event, client) -> { + assertEquals("event_context_456", client.getContext()); + }; + + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler); + + assertEquals("original_context_123", stripeClient.getContext()); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader); + + assertEquals("original_context_123", stripeClient.getContext()); + } + + @Test + public void testStripeContextRestoredAfterHandlerError() + throws NoSuchAlgorithmException, InvalidKeyException { + // Test that the original stripe_context is restored even when handler raises an exception + StripeEventNotificationHandler.Callback + handler = + (event, client) -> { + assertEquals("event_context_456", client.getContext()); + throw new RuntimeException("Handler error!"); + }; + + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler); + + assertEquals("original_context_123", stripeClient.getContext()); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + + RuntimeException exception = + assertThrows( + RuntimeException.class, + () -> eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader)); + assertEquals("Handler error!", exception.getMessage()); + + assertEquals("original_context_123", stripeClient.getContext()); + } + + @Test + public void testStripeContextSetToNullWhenEventHasNoContext() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Test that stripe_context is set to null when event context is null + AtomicReference receivedContext = new AtomicReference<>(); + + StripeEventNotificationHandler.Callback handler = + (event, client) -> { + receivedContext.set(client.getContext()); + }; + + eventNotificationHandler.onV2CoreAccountCreated(handler); + + assertEquals("original_context_123", stripeClient.getContext()); + + String sigHeader = generateSigHeader(v2AccountCreatedPayload); + eventNotificationHandler.handle(v2AccountCreatedPayload, sigHeader); + + assertNull(receivedContext.get()); + assertEquals("original_context_123", stripeClient.getContext()); + } + + @Test + public void testUnknownEventRoutesToOnUnhandled() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Test that events without SDK types rout handler + String sigHeader = generateSigHeader(unknownEventPayload); + eventNotificationHandler.handle(unknownEventPayload, sigHeader); + + verify(fallbackCallback, times(1)) + .process( + org.mockito.ArgumentMatchers.argThat( + event -> + event instanceof UnknownEventNotification + && event.getType().equals("llama.created")), + org.mockito.ArgumentMatchers.any(StripeClient.class), + org.mockito.ArgumentMatchers.argThat(info -> info.isKnownEventType() == false)); + } + + @Test + public void testKnownUnregisteredEventRoutesToOnUnhandled() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Test that known event types without a registered handler rout + String sigHeader = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader); + + verify(fallbackCallback, times(1)) + .process( + org.mockito.ArgumentMatchers.argThat( + event -> + event instanceof V1BillingMeterErrorReportTriggeredEventNotification + && event.getType().equals("v1.billing.meter.error_report_triggered")), + org.mockito.ArgumentMatchers.any(StripeClient.class), + org.mockito.ArgumentMatchers.argThat(info -> info.isKnownEventType() == true)); + } + + @SuppressWarnings("unchecked") + @Test + public void testRegisteredEventDoesNotCallOnUnhandled() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Test that registered events don't tri + StripeEventNotificationHandler.Callback + handler = mock(StripeEventNotificationHandler.Callback.class); + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader); + + verify(handler, times(1)) + .process(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + verify(fallbackCallback, never()) + .process( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + public void testHandlerClientRetainsConfiguration() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Test that the client passed to handlers retains all configuration except stripe_context + String originalContext = "original_context_xyz"; + + StripeClient customClient = + StripeClient.builder() + .setApiKey("sk_test_custom_key") + .setStripeContext(originalContext) + .build(); + + StripeEventNotificationHandler customRouter = + new StripeEventNotificationHandler(DUMMY_WEBHOOK_SECRET, customClient, fallbackCallback); + + AtomicReference receivedContext = new AtomicReference<>(); + + StripeEventNotificationHandler.Callback + handler = + (event, client) -> { + receivedContext.set(client.getContext()); + }; + + customRouter.onV1BillingMeterErrorReportTriggered(handler); + + String sigHeader = generateSigHeader(v1BillingMeterPayload); + customRouter.handle(v1BillingMeterPayload, sigHeader); + + assertEquals("event_context_456", receivedContext.get()); + assertEquals(originalContext, customClient.getContext()); + } + + @Test + public void testOnUnhandledReceivesCorrectInfoForUnknown() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Test receives correct UnhandledNotificationDetails for unknown events + String sigHeader = generateSigHeader(unknownEventPayload); + eventNotificationHandler.handle(unknownEventPayload, sigHeader); + + verify(fallbackCallback, times(1)) + .process( + org.mockito.ArgumentMatchers.any(EventNotification.class), + org.mockito.ArgumentMatchers.any(StripeClient.class), + org.mockito.ArgumentMatchers.argThat(info -> info.isKnownEventType() == false)); + } + + @Test + public void testOnUnhandledReceivesCorrectInfoForKnownUnregistered() + throws SignatureVerificationException, NoSuchAlgorithmException, InvalidKeyException { + // Test receives correct UnhandledNotificationDetails for known unregistered + // events + String sigHeader = generateSigHeader(v1BillingMeterPayload); + eventNotificationHandler.handle(v1BillingMeterPayload, sigHeader); + + verify(fallbackCallback, times(1)) + .process( + org.mockito.ArgumentMatchers.any(EventNotification.class), + org.mockito.ArgumentMatchers.any(StripeClient.class), + org.mockito.ArgumentMatchers.argThat(info -> info.isKnownEventType() == true)); + } + + @Test + public void testValidatesWebhookSignature() { + // Test that invalid webhook signatures are rejected + assertThrows( + SignatureVerificationException.class, + () -> eventNotificationHandler.handle(v1BillingMeterPayload, "invalid_signature")); + } + + @Test + public void testRegisteredEventTypesEmpty() { + // Test that registered_event_types returns empty list when no handlers are registered + List eventTypes = eventNotificationHandler.getRegisteredEventTypes(); + assertNotNull(eventTypes); + assertTrue(eventTypes.isEmpty()); + } + + @SuppressWarnings("unchecked") + @Test + public void testRegisteredEventTypesSingle() { + // Test that registered_event_types returns a single event type + StripeEventNotificationHandler.Callback + handler = mock(StripeEventNotificationHandler.Callback.class); + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler); + + List eventTypes = eventNotificationHandler.getRegisteredEventTypes(); + assertEquals(1, eventTypes.size()); + assertEquals("v1.billing.meter.error_report_triggered", eventTypes.get(0)); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + @Test + public void testRegisteredEventTypesMultipleAlphabetized() { + // Test that registered_event_types returns multiple event types in alphabetical order + StripeEventNotificationHandler.Callback handler = + mock(StripeEventNotificationHandler.Callback.class); + + // Register in non-alphabetical order + eventNotificationHandler.onV2CoreAccountUpdated(handler); + eventNotificationHandler.onV1BillingMeterErrorReportTriggered(handler); + eventNotificationHandler.onV2CoreAccountCreated(handler); + + List expected = + Arrays.asList( + "v1.billing.meter.error_report_triggered", + "v2.core.account.created", + "v2.core.account.updated"); + + List eventTypes = eventNotificationHandler.getRegisteredEventTypes(); + assertEquals(expected, eventTypes); + } +}