diff --git a/src/CommunityToolkit.Aspire.Hosting.Stripe/README.md b/src/CommunityToolkit.Aspire.Hosting.Stripe/README.md index 43a983dc0..8535afa58 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Stripe/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Stripe/README.md @@ -99,6 +99,22 @@ var stripe = builder.AddStripe("stripe", stripeApiKey) events: ["payment_intent.created", "charge.succeeded"]); ``` +### Thin (v2) events + +Stripe's v2 event family (for example the Accounts v2 events used by Connect platforms) is delivered as [thin events](https://docs.stripe.com/webhooks?snapshot-or-thin=thin), which the Stripe CLI forwards separately from snapshot events. Use `WithThinListen` to configure them — it can be combined with `WithListen` on the same resource, sharing a single CLI session and signing secret: + +```csharp +var stripeApiKey = builder.AddParameter("stripe-api-key", "sk_test_default", secret: true); +var webhookEndpoint = builder.AddExternalService("webhook-endpoint", "http://localhost:5082"); +var stripe = builder.AddStripe("stripe", stripeApiKey) + .WithListen(webhookEndpoint, webhookPath: "/webhooks/stripe", + events: ["charge.dispute.created"]) + .WithThinListen(webhookEndpoint, webhookPath: "/webhooks/stripe/thin", + thinEvents: ["v2.core.account[configuration.recipient].capability_status_updated"]); +``` + +This maps to the CLI's `--forward-thin-to` / `--thin-events` options. + ## How it works The Stripe CLI integration: diff --git a/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeExtensions.cs index c0e2a98bc..dd0afdd9b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeExtensions.cs @@ -61,7 +61,7 @@ public static IResourceBuilder WithListen( ArgumentNullException.ThrowIfNull(builder, nameof(builder)); ArgumentNullException.ThrowIfNull(forwardTo, nameof(forwardTo)); - builder.WithArgs("listen"); + builder.EnsureListenCommand(); builder.WithArgs(context => { if (!forwardTo.Resource.TryGetEndpoints(out var endpoints) || !endpoints.Any()) @@ -69,7 +69,7 @@ public static IResourceBuilder WithListen( throw new InvalidOperationException($"The resource '{forwardTo.Resource.Name}' does not have any endpoints defined."); } context.Args.Add("--forward-to"); - context.Args.Add($"{endpoints.First().AllocatedEndpoint}{webhookPath}"); + context.Args.Add(CombineUrl($"{endpoints.First().AllocatedEndpoint}", webhookPath)); }); if (events is not null && events.Any()) @@ -98,12 +98,12 @@ public static IResourceBuilder WithListen( ArgumentNullException.ThrowIfNull(builder, nameof(builder)); ArgumentNullException.ThrowIfNull(forwardTo, nameof(forwardTo)); - builder.WithArgs("listen"); + builder.EnsureListenCommand(); if (forwardTo.Resource.Uri is not null) { builder.WithArgs($"--forward-to"); - builder.WithArgs(ReferenceExpression.Create($"{forwardTo.Resource.Uri.ToString()}{webhookPath}")); + builder.WithArgs(ReferenceExpression.Create($"{CombineUrl(forwardTo.Resource.Uri.ToString(), webhookPath)}")); } else if (forwardTo.Resource.UrlParameter is not null) { @@ -118,7 +118,7 @@ public static IResourceBuilder WithListen( } } context.Args.Add($"--forward-to"); - context.Args.Add(ReferenceExpression.Create($"{url}{webhookPath}")); + context.Args.Add(ReferenceExpression.Create($"{CombineUrl(url!, webhookPath)}")); }); } else @@ -134,6 +134,107 @@ public static IResourceBuilder WithListen( return builder.ResolveSecret(); } + /// + /// Configures the Stripe CLI to listen for thin (v2) events and forward them to the + /// specified resource. Thin events are the delivery style of Stripe's v2 event family + /// (for example v2.core.account[configuration.recipient].capability_status_updated) + /// and are forwarded separately from snapshot events via --forward-thin-to. + /// Can be combined with + /// on the same resource to forward both families from one CLI session. + /// + /// The resource builder. + /// The resource to forward thin events to. + /// The path to the thin-event webhook endpoint. + /// Optional collection of specific thin event types to listen for (e.g., ["v2.core.account[configuration.recipient].capability_status_updated"]). If not specified, all thin events are forwarded. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithThinListen( + this IResourceBuilder builder, + IResourceBuilder forwardTo, + string webhookPath = "/webhooks/stripe/thin", + IEnumerable? thinEvents = null) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentNullException.ThrowIfNull(forwardTo, nameof(forwardTo)); + + builder.EnsureListenCommand(); + builder.WithArgs(context => + { + if (!forwardTo.Resource.TryGetEndpoints(out var endpoints) || !endpoints.Any()) + { + throw new InvalidOperationException($"The resource '{forwardTo.Resource.Name}' does not have any endpoints defined."); + } + context.Args.Add("--forward-thin-to"); + context.Args.Add(CombineUrl($"{endpoints.First().AllocatedEndpoint}", webhookPath)); + }); + + if (thinEvents is not null && thinEvents.Any()) + { + builder.WithArgs("--thin-events", string.Join(",", thinEvents)); + } + + return builder.ResolveSecret(); + } + + /// + /// Configures the Stripe CLI to listen for thin (v2) events and forward them to the + /// specified external service. Thin events are the delivery style of Stripe's v2 event family + /// (for example v2.core.account[configuration.recipient].capability_status_updated) + /// and are forwarded separately from snapshot events via --forward-thin-to. + /// Can be combined with + /// on the same resource to forward both families from one CLI session. + /// + /// The resource builder. + /// The external service to forward thin events to. + /// The path to the thin-event webhook endpoint. + /// Optional collection of specific thin event types to listen for (e.g., ["v2.core.account[configuration.recipient].capability_status_updated"]). If not specified, all thin events are forwarded. + /// A reference to the . + [AspireExport("withThinListenExternalService")] + public static IResourceBuilder WithThinListen( + this IResourceBuilder builder, + IResourceBuilder forwardTo, + string webhookPath = "/webhooks/stripe/thin", + IEnumerable? thinEvents = null) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentNullException.ThrowIfNull(forwardTo, nameof(forwardTo)); + + builder.EnsureListenCommand(); + + if (forwardTo.Resource.Uri is not null) + { + builder.WithArgs($"--forward-thin-to"); + builder.WithArgs(ReferenceExpression.Create($"{CombineUrl(forwardTo.Resource.Uri.ToString(), webhookPath)}")); + } + else if (forwardTo.Resource.UrlParameter is not null) + { + builder.WithArgs(async context => + { + string? url = await forwardTo.Resource.UrlParameter.GetValueAsync(context.CancellationToken).ConfigureAwait(false); + if (!context.ExecutionContext.IsPublishMode) + { + if (!UrlIsValidForExternalService(url, out var _, out var message)) + { + throw new DistributedApplicationException($"The URL parameter '{forwardTo.Resource.UrlParameter.Name}' for the external service '{forwardTo.Resource.Name}' is invalid: {message}"); + } + } + context.Args.Add($"--forward-thin-to"); + context.Args.Add(ReferenceExpression.Create($"{CombineUrl(url!, webhookPath)}")); + }); + } + else + { + throw new InvalidOperationException($"The external service resource '{forwardTo.Resource.Name}' does not have a defined URI."); + } + + if (thinEvents is not null && thinEvents.Any()) + { + builder.WithArgs("--thin-events", string.Join(",", thinEvents)); + } + + return builder.ResolveSecret(); + } + /// /// Configures the Stripe CLI to use a specific API key from a parameter. /// @@ -184,8 +285,47 @@ public static IResourceBuilder WithReference( }); } + /// + /// Adds the listen command argument exactly once, so snapshot + /// () + /// and thin () + /// configuration can be combined on the same resource. + /// + private static void EnsureListenCommand(this IResourceBuilder builder) + { + if (builder.Resource.Annotations.OfType().Any()) + { + return; + } + + builder.Resource.Annotations.Add(new StripeListenCommandAnnotation()); + // Insert rather than append so the command stays valid regardless of the order + // extension methods were called in (e.g. WithApiKey before WithListen). + builder.WithArgs(context => context.Args.Insert(0, "listen")); + } + + /// + /// Joins a base URL and a webhook path with exactly one slash, regardless of + /// whether the base ends with '/' or the path starts with one. + /// + private static string CombineUrl(string baseUrl, string path) => + $"{baseUrl.TrimEnd('/')}/{path.TrimStart('/')}"; + + private sealed class StripeListenCommandAnnotation : IResourceAnnotation; + + private sealed class StripeSecretWatcherAnnotation : IResourceAnnotation; + private static IResourceBuilder ResolveSecret(this IResourceBuilder builder) { + // Both listen configurations may call this; the CLI emits a single signing secret + // per session, so one log watcher suffices. + if (builder.Resource.Annotations.OfType().Any()) + { + return builder; + } + + builder.Resource.Annotations.Add(new StripeSecretWatcherAnnotation()); + builder.OnBeforeResourceStarted((resource, @event, ct) => { return Task.Run(async () => diff --git a/src/CommunityToolkit.Aspire.Hosting.Stripe/api/CommunityToolkit.Aspire.Hosting.Stripe.cs b/src/CommunityToolkit.Aspire.Hosting.Stripe/api/CommunityToolkit.Aspire.Hosting.Stripe.cs index 78d3f219d..193d7cee1 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Stripe/api/CommunityToolkit.Aspire.Hosting.Stripe.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Stripe/api/CommunityToolkit.Aspire.Hosting.Stripe.cs @@ -25,6 +25,12 @@ public static partial class StripeExtensions [AspireExport("withStripeReference")] public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder source, string webhookSigningSecretEnvVarName = "STRIPE_WEBHOOK_SECRET") where TDestination : ApplicationModel.IResourceWithEnvironment { throw null; } + + [AspireExport] + public static ApplicationModel.IResourceBuilder WithThinListen(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder forwardTo, string webhookPath = "/webhooks/stripe/thin", System.Collections.Generic.IEnumerable? thinEvents = null) { throw null; } + + [AspireExport("withThinListenExternalService")] + public static ApplicationModel.IResourceBuilder WithThinListen(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder forwardTo, string webhookPath = "/webhooks/stripe/thin", System.Collections.Generic.IEnumerable? thinEvents = null) { throw null; } } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AddStripeTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AddStripeTests.cs index df6d6218f..4f9f0b7c2 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AddStripeTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AddStripeTests.cs @@ -153,6 +153,170 @@ public void StripeWithListenToEndpointReference() Assert.NotEmpty(argsAnnotation); } + [Fact] + public async Task StripeWithThinListenAddsThinListenArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + var externalEndpoint = builder.AddExternalService("external-api", "http://localhost:5082"); + + builder.AddStripe("stripe", apiKey) + .WithThinListen(externalEndpoint, webhookPath: "webhooks/thin"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var args = await resource.GetArgumentListAsync(); + + Assert.Collection(args, + arg => Assert.Equal("listen", arg), + arg => Assert.Equal("--forward-thin-to", arg), + arg => Assert.Equal("http://localhost:5082/webhooks/thin", arg) + ); + } + + [Fact] + public async Task StripeWithThinListenAndThinEventsAddsThinEventArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + var externalEndpoint = builder.AddExternalService("external-api", "http://localhost:5082"); + + builder.AddStripe("stripe", apiKey) + .WithThinListen(externalEndpoint, webhookPath: "webhooks/thin", + thinEvents: ["v2.core.account[configuration.recipient].capability_status_updated"]); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var args = await resource.GetArgumentListAsync(); + + Assert.Collection(args, + arg => Assert.Equal("listen", arg), + arg => Assert.Equal("--forward-thin-to", arg), + arg => Assert.Equal("http://localhost:5082/webhooks/thin", arg), + arg => Assert.Equal("--thin-events", arg), + arg => Assert.Equal("v2.core.account[configuration.recipient].capability_status_updated", arg) + ); + } + + [Fact] + public async Task StripeWithListenAndThinListenSharesSingleListenCommand() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + var externalEndpoint = builder.AddExternalService("external-api", "http://localhost:5082"); + + builder.AddStripe("stripe", apiKey) + .WithListen(externalEndpoint, webhookPath: "webhooks", events: ["charge.dispute.created"]) + .WithThinListen(externalEndpoint, webhookPath: "webhooks/thin", + thinEvents: ["v2.core.account[configuration.recipient].capability_status_updated"]); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var args = await resource.GetArgumentListAsync(); + + Assert.Collection(args, + arg => Assert.Equal("listen", arg), + arg => Assert.Equal("--forward-to", arg), + arg => Assert.Equal("http://localhost:5082/webhooks", arg), + arg => Assert.Equal("--events", arg), + arg => Assert.Equal("charge.dispute.created", arg), + arg => Assert.Equal("--forward-thin-to", arg), + arg => Assert.Equal("http://localhost:5082/webhooks/thin", arg), + arg => Assert.Equal("--thin-events", arg), + arg => Assert.Equal("v2.core.account[configuration.recipient].capability_status_updated", arg) + ); + } + + [Fact] + public async Task StripeWithApiKeyBeforeListenKeepsListenCommandFirst() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + var externalEndpoint = builder.AddExternalService("external-api", "http://localhost:5082"); + + builder.AddStripe("stripe", apiKey) + .WithApiKey(apiKey) + .WithListen(externalEndpoint, webhookPath: "webhooks"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var args = await resource.GetArgumentListAsync(); + + Assert.Equal("listen", args[0]); + Assert.Contains("--api-key", args); + Assert.Contains("http://localhost:5082/webhooks", args); + } + + [Fact] + public void StripeWithThinListenToEndpointReference() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + + var api = builder.AddProject("api"); + + builder.AddStripe("stripe", apiKey) + .WithThinListen(api); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + // Verify that a CommandLineArgsCallbackAnnotation was added + var argsAnnotation = resource.Annotations.OfType(); + Assert.NotEmpty(argsAnnotation); + } + + [Fact] + public void WithThinListenNullBuilderThrows() + { + IResourceBuilder builder = null!; + + Assert.Throws(() => builder.WithThinListen((IResourceBuilder)null!)); + } + + [Fact] + public void WithThinListenNullUrlThrows() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + var stripe = builder.AddStripe("stripe", apiKey); + + Assert.Throws(() => stripe.WithThinListen((IResourceBuilder)null!)); + } + + [Fact] + public void WithThinListenNullEndpointReferenceThrows() + { + var builder = DistributedApplication.CreateBuilder(); + var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue); + var stripe = builder.AddStripe("stripe", apiKey); + + Assert.Throws(() => stripe.WithThinListen((IResourceBuilder)null!)); + } + [Fact] public void AddStripeNullBuilderThrows() {