diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index fef4d632..1e31f56a 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -32,6 +32,7 @@ jobs:
Hosting.Flagd.Tests,
Hosting.GoFeatureFlag.Tests,
Hosting.Golang.Tests,
+ Hosting.JavaScript.Extensions.Tests,
Hosting.Java.Tests,
Hosting.k6.Tests,
Hosting.Keycloak.Extensions.Tests,
@@ -44,7 +45,6 @@ jobs:
Hosting.MongoDB.Extensions.Tests,
Hosting.MySql.Extensions.Tests,
Hosting.Ngrok.Tests,
- Hosting.JavaScript.Extensions.Tests,
Hosting.Ollama.Tests,
Hosting.OpenTelemetryCollector.Tests,
Hosting.PapercutSmtp.Tests,
@@ -58,6 +58,7 @@ jobs:
Hosting.SqlDatabaseProjects.Tests,
Hosting.Sqlite.Tests,
Hosting.SqlServer.Extensions.Tests,
+ Hosting.Stripe.Tests,
Hosting.SurrealDb.Tests,
# Client integration tests
diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx
index a66c47b9..d41f01c2 100644
--- a/CommunityToolkit.Aspire.slnx
+++ b/CommunityToolkit.Aspire.slnx
@@ -157,6 +157,10 @@
+
+
+
+
@@ -198,6 +202,7 @@
+
@@ -250,6 +255,7 @@
+
diff --git a/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/CommunityToolkit.Aspire.Hosting.Stripe.Api.csproj b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/CommunityToolkit.Aspire.Hosting.Stripe.Api.csproj
new file mode 100644
index 00000000..375cccf7
--- /dev/null
+++ b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/CommunityToolkit.Aspire.Hosting.Stripe.Api.csproj
@@ -0,0 +1,3 @@
+
+
+
diff --git a/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/Program.cs b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/Program.cs
new file mode 100644
index 00000000..358930ff
--- /dev/null
+++ b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/Program.cs
@@ -0,0 +1,22 @@
+var builder = WebApplication.CreateBuilder(args);
+
+var app = builder.Build();
+
+app.MapGet("/", () => "Stripe Webhook API");
+
+app.MapPost("/payments/stripe-webhook", async (HttpContext context, IConfiguration configuration) =>
+{
+ var webhookSecret = configuration["STRIPE_WEBHOOK_SECRET"];
+
+ using var reader = new StreamReader(context.Request.Body);
+ var json = await reader.ReadToEndAsync();
+
+ // In a real application, you would verify the webhook signature here
+ // using the STRIPE_WEBHOOK_SECRET
+
+ return Results.Ok(new { received = true, hasSecret = !string.IsNullOrEmpty(webhookSecret) });
+});
+
+app.MapGet("/health", () => Results.Ok());
+
+app.Run();
diff --git a/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/Properties/launchSettings.json b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/Properties/launchSettings.json
new file mode 100644
index 00000000..599e75e8
--- /dev/null
+++ b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/Properties/launchSettings.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17227;http://localhost:15019",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15019",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
+
diff --git a/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/CommunityToolkit.Aspire.Hosting.Stripe.AppHost.csproj b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/CommunityToolkit.Aspire.Hosting.Stripe.AppHost.csproj
new file mode 100644
index 00000000..ab727dc6
--- /dev/null
+++ b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/CommunityToolkit.Aspire.Hosting.Stripe.AppHost.csproj
@@ -0,0 +1,13 @@
+
+
+
+ Exe
+ 7e518d7d-87e8-4337-8806-1c99acce5e01
+
+
+
+
+
+
+
+
diff --git a/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/Program.cs b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/Program.cs
new file mode 100644
index 00000000..c0d1381e
--- /dev/null
+++ b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/Program.cs
@@ -0,0 +1,15 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+var api = builder.AddProject("api");
+
+// Provide a development default; override via configuration in real projects.
+var stripeApiKey = builder.AddParameter("stripe-api-key", secret: true);
+
+// Forward Stripe webhooks to the API's webhook endpoint
+var stripe = builder.AddStripe("stripe", stripeApiKey)
+ .WithListen(api);
+
+// The API will receive the webhook signing secret via STRIPE_WEBHOOK_SECRET environment variable
+api.WithReference(stripe);
+
+builder.Build().Run();
diff --git a/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/Properties/launchSettings.json b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000..4dca3055
--- /dev/null
+++ b/examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/Properties/launchSettings.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17217;http://localhost:15269",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:22180",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22179"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15269",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19031",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20128"
+ }
+ }
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Stripe/CommunityToolkit.Aspire.Hosting.Stripe.csproj b/src/CommunityToolkit.Aspire.Hosting.Stripe/CommunityToolkit.Aspire.Hosting.Stripe.csproj
new file mode 100644
index 00000000..e0caed34
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Stripe/CommunityToolkit.Aspire.Hosting.Stripe.csproj
@@ -0,0 +1,12 @@
+
+
+
+ hosting stripe payments webhooks
+ A .NET Aspire integration for the Stripe CLI for local webhook forwarding and testing.
+
+
+
+
+
+
+
diff --git a/src/CommunityToolkit.Aspire.Hosting.Stripe/README.md b/src/CommunityToolkit.Aspire.Hosting.Stripe/README.md
new file mode 100644
index 00000000..53ad2c51
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Stripe/README.md
@@ -0,0 +1,123 @@
+# CommunityToolkit.Aspire.Hosting.Stripe library
+
+Provides extension methods and resource definitions for a .NET Aspire AppHost to configure the Stripe CLI for local webhook forwarding and testing.
+
+## Getting Started
+
+### Prerequisites
+
+The Stripe CLI must be installed on your machine. You can install it by following the [official Stripe CLI installation guide](https://stripe.com/docs/stripe-cli#install).
+
+### Install the package
+
+In your AppHost project, install the package using the following command:
+
+```dotnetcli
+dotnet add package CommunityToolkit.Aspire.Hosting.Stripe
+```
+
+### Example usage
+
+Then, in the _Program.cs_ file of your AppHost project, add the Stripe CLI and configure it to forward webhooks to your API:
+
+```csharp
+var builder = DistributedApplication.CreateBuilder(args);
+
+var stripeApiKey = builder.AddParameter("stripe-api-key", "sk_test_default", secret: true); // Override for real keys
+
+var externalEndpoint = builder.AddExternalService("webhook-endpoint", "http://localhost:5082");
+var stripe = builder.AddStripe("stripe", stripeApiKey)
+ .WithListen(externalEndpoint, webhookPath: "/payments/stripe-webhook");
+
+var api = builder.AddProject("api")
+ .WithReference(stripe);
+
+builder.Build().Run();
+```
+
+This will:
+
+1. Start the Stripe CLI listening for webhook events
+2. Forward all webhook events to `http://localhost:5082/payments/stripe-webhook`
+3. Provide the Stripe API key to the container via the `STRIPE_API_KEY` environment variable
+4. Make the webhook signing secret available to the API project via the `STRIPE_WEBHOOK_SECRET` environment variable
+
+### Forwarding to an Aspire endpoint
+
+You can also construct URLs dynamically using Aspire endpoint references:
+
+```csharp
+var builder = DistributedApplication.CreateBuilder(args);
+
+var api = builder.AddProject("api")
+ .WithHttpEndpoint(port: 5082, name: "http");
+
+var stripeApiKey = builder.AddParameter("stripe-api-key", "sk_test_default", secret: true);
+
+var stripe = builder.AddStripe("stripe", stripeApiKey)
+ .WithListen(api, webhookPath: "/payments/stripe-webhook",
+ events: ["payment_intent.created", "charge.succeeded"]);
+
+api.WithReference(stripe);
+
+builder.Build().Run();
+```
+
+Note: When constructing URLs with paths, you need to use `ReferenceExpression.Create` to combine the endpoint URL with your webhook path.
+
+### Using a custom environment variable for the webhook secret
+
+By default, the webhook signing secret is exposed as `STRIPE_WEBHOOK_SECRET`. You can customize this:
+
+```csharp
+var api = builder.AddProject("api")
+ .WithReference(stripe, webhookSigningSecretEnvVarName: "STRIPE_SECRET");
+```
+
+### Configuring API key
+
+`AddStripe` requires an `IResourceBuilder` that supplies your Stripe API key. The value is exposed to the container as the `STRIPE_API_KEY` environment variable. You can optionally reuse the same parameter to add the `--api-key` command-line argument:
+
+```csharp
+var apiKey = builder.AddParameter("stripe-api-key", "sk_test_default", secret: true);
+
+var webhookEndpoint = builder.AddExternalService("webhook-endpoint", "http://localhost:5082");
+var stripe = builder.AddStripe("stripe", apiKey)
+ .WithListen(webhookEndpoint, webhookPath: "/webhooks")
+ .WithApiKey(apiKey); // optional: forwards the key to the CLI via --api-key
+```
+
+### Filtering events
+
+You can filter which webhook events the Stripe CLI listens for:
+
+```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",
+ events: ["payment_intent.created", "charge.succeeded"]);
+```
+
+## How it works
+
+The Stripe CLI integration:
+
+- Runs `stripe listen --forward-to ` to listen for webhook events from Stripe's test environment
+- Forwards those events to your local application endpoint
+- Exposes the webhook signing secret so your application can verify webhook authenticity
+- Provides a development-friendly way to test webhook integrations without deploying to production
+
+## Additional Information
+
+For more information about the Stripe CLI:
+
+- [Stripe CLI Documentation](https://stripe.com/docs/stripe-cli)
+- [Testing webhooks locally](https://stripe.com/docs/webhooks/test)
+
+https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-stripe
+
+## Feedback & contributing
+
+https://github.com/CommunityToolkit/Aspire
+
diff --git a/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeContainerImageTags.cs
new file mode 100644
index 00000000..7d2090b5
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeContainerImageTags.cs
@@ -0,0 +1,11 @@
+namespace CommunityToolkit.Aspire.Hosting.Stripe;
+
+internal static class StripeContainerImageTags
+{
+ /// docker.io
+ public const string Registry = "docker.io";
+ /// stripe/stripe-cli
+ public const string Image = "stripe/stripe-cli";
+ /// v1.33.0
+ public const string Tag = "v1.33.0";
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeExtensions.cs
new file mode 100644
index 00000000..4cb83ae2
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeExtensions.cs
@@ -0,0 +1,303 @@
+using Aspire.Hosting.ApplicationModel;
+using CommunityToolkit.Aspire.Hosting.Stripe;
+using Microsoft.Extensions.DependencyInjection;
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace Aspire.Hosting;
+
+///
+/// Extension methods for adding Stripe CLI to a .
+///
+public static class StripeExtensions
+{
+ ///
+ /// Adds the Stripe CLI to the application model for local webhook forwarding.
+ ///
+ /// The to add the resource to.
+ /// The name of the resource.
+ /// The parameter builder providing the Stripe API key.
+ /// A reference to the .
+ public static IResourceBuilder AddStripe(
+ this IDistributedApplicationBuilder builder,
+ [ResourceName] string name,
+ IResourceBuilder apiKey)
+ {
+ ArgumentNullException.ThrowIfNull(builder, nameof(builder));
+ ArgumentException.ThrowIfNullOrEmpty(name, nameof(name));
+ ArgumentNullException.ThrowIfNull(apiKey, nameof(apiKey));
+
+ StripeResource resource = new(name);
+
+ return builder.AddResource(resource)
+ .WithImage(StripeContainerImageTags.Image)
+ .WithImageTag(StripeContainerImageTags.Tag)
+ .WithImageRegistry(StripeContainerImageTags.Registry)
+ .WithEnvironment(context =>
+ {
+ context.EnvironmentVariables.Add("STRIPE_API_KEY", ReferenceExpression.Create($"{apiKey}"));
+ })
+ .ExcludeFromManifest();
+ }
+
+ ///
+ /// Configures the Stripe CLI to listen for webhooks and forward them to the specified URL expression.
+ ///
+ /// The resource builder.
+ /// The resource to forward webhooks to.
+ /// The path to the webhook endpoint.
+ /// Optional collection of specific webhook events to listen for (e.g., ["payment_intent.created", "charge.succeeded"]). If not specified, all events are forwarded.
+ /// A reference to the .
+ public static IResourceBuilder WithListen(
+ this IResourceBuilder builder,
+ IResourceBuilder forwardTo,
+ string webhookPath = "/webhooks/stripe",
+ IEnumerable? events = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder, nameof(builder));
+ ArgumentNullException.ThrowIfNull(forwardTo, nameof(forwardTo));
+
+ builder.WithArgs("listen");
+ 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-to");
+ context.Args.Add($"{endpoints.First().AllocatedEndpoint}{webhookPath}");
+ });
+
+ if (events is not null && events.Any())
+ {
+ builder.WithArgs("--events", string.Join(",", events));
+ }
+
+ return builder.ResolveSecret();
+ }
+
+ ///
+ /// Configures the Stripe CLI to listen for webhooks and forward them to the specified URL expression.
+ ///
+ /// The resource builder.
+ /// The resource to forward webhooks to.
+ /// The path to the webhook endpoint.
+ /// Optional collection of specific webhook events to listen for (e.g., ["payment_intent.created", "charge.succeeded"]). If not specified, all events are forwarded.
+ /// A reference to the .
+ public static IResourceBuilder WithListen(
+ this IResourceBuilder builder,
+ IResourceBuilder forwardTo,
+ string webhookPath = "/webhooks/stripe",
+ IEnumerable? events = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder, nameof(builder));
+ ArgumentNullException.ThrowIfNull(forwardTo, nameof(forwardTo));
+
+ builder.WithArgs("listen");
+
+ if (forwardTo.Resource.Uri is not null)
+ {
+ builder.WithArgs($"--forward-to");
+ builder.WithArgs(ReferenceExpression.Create($"{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-to");
+ context.Args.Add(ReferenceExpression.Create($"{url}{webhookPath}"));
+ });
+ }
+ else
+ {
+ throw new InvalidOperationException($"The external service resource '{forwardTo.Resource.Name}' does not have a defined URI.");
+ }
+
+ if (events is not null && events.Any())
+ {
+ builder.WithArgs("--events", string.Join(",", events));
+ }
+
+ return builder.ResolveSecret();
+ }
+
+ ///
+ /// Configures the Stripe CLI to use a specific API key from a parameter.
+ ///
+ /// The resource builder.
+ /// The parameter containing the Stripe API key to use.
+ /// A reference to the .
+ public static IResourceBuilder WithApiKey(
+ this IResourceBuilder builder,
+ IResourceBuilder apiKey)
+ {
+ ArgumentNullException.ThrowIfNull(builder, nameof(builder));
+ ArgumentNullException.ThrowIfNull(apiKey, nameof(apiKey));
+
+ return builder.WithArgs(context =>
+ {
+ context.Args.Add($"--api-key");
+ context.Args.Add(apiKey);
+ });
+ }
+
+ ///
+ /// Adds a reference to a Stripe CLI resource for accessing its webhook signing secret.
+ ///
+ /// The resource builder.
+ /// The Stripe CLI resource to reference.
+ /// Optional environment variable name to use for the webhook signing secret. Defaults to "STRIPE_WEBHOOK_SECRET".
+ /// A reference to the .
+ public static IResourceBuilder WithReference(
+ this IResourceBuilder builder,
+ IResourceBuilder source,
+ string webhookSigningSecretEnvVarName = "STRIPE_WEBHOOK_SECRET")
+ where TDestination : IResourceWithEnvironment
+ {
+ ArgumentNullException.ThrowIfNull(builder, nameof(builder));
+ ArgumentNullException.ThrowIfNull(source, nameof(source));
+ ArgumentException.ThrowIfNullOrEmpty(webhookSigningSecretEnvVarName, nameof(webhookSigningSecretEnvVarName));
+
+ if (builder is IResourceBuilder waitResource)
+ {
+ waitResource.WaitFor(source);
+ }
+
+ return builder.WithEnvironment(context =>
+ {
+ context.EnvironmentVariables.Add(webhookSigningSecretEnvVarName, ReferenceExpression.Create($"{source.Resource.WebhookSigningSecret}"));
+ });
+ }
+
+ private static IResourceBuilder ResolveSecret(this IResourceBuilder builder)
+ {
+ builder.OnBeforeResourceStarted((resource, @event, ct) =>
+ {
+ return Task.Run(async () =>
+ {
+ var notificationService = @event.Services.GetRequiredService();
+ var loggerService = @event.Services.GetRequiredService();
+
+ await foreach (var resourceEvent in notificationService.WatchAsync(ct).ConfigureAwait(false))
+ {
+ if (!string.Equals(resource.Name, resourceEvent.Resource.Name, StringComparison.InvariantCultureIgnoreCase))
+ {
+ continue;
+ }
+
+ _ = WatchResourceLogsAsync(resourceEvent.ResourceId, loggerService, ct);
+ break;
+ }
+ }, ct);
+
+ async Task WatchResourceLogsAsync(string resourceId, ResourceLoggerService loggerService, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach (var logEvent in loggerService.WatchAsync(resourceId).WithCancellation(cancellationToken).ConfigureAwait(false))
+ {
+ foreach (var line in logEvent.Where(l => !string.IsNullOrWhiteSpace(l.Content)))
+ {
+ if (TryExtractSigningSecret(line.Content, out var signingSecret))
+ {
+ resource.WebhookSigningSecret = signingSecret;
+ return;
+ }
+ }
+ }
+ }
+ catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Expected when the resource is shutting down.
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Expected when the resource is shutting down.
+ }
+ }
+
+ static bool TryExtractSigningSecret(string? content, out string? secret)
+ {
+ secret = null;
+
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ return false;
+ }
+
+ const string Prefix = "whsec_";
+ var startIndex = content.IndexOf(Prefix, StringComparison.OrdinalIgnoreCase);
+ if (startIndex < 0)
+ {
+ return false;
+ }
+
+ var endIndex = startIndex + Prefix.Length;
+ while (endIndex < content.Length && IsSecretCharacter(content[endIndex]))
+ {
+ endIndex++;
+ }
+
+ var candidate = content.Substring(startIndex, endIndex - startIndex).TrimEnd('.', ';', ',', ')', '"');
+
+ if (candidate.Length <= Prefix.Length)
+ {
+ return false;
+ }
+
+ secret = candidate;
+ return true;
+
+ static bool IsSecretCharacter(char value) => char.IsLetterOrDigit(value) || value is '_' or '-';
+ }
+ });
+
+ return builder;
+ }
+
+ internal static bool UrlIsValidForExternalService(string? url, [NotNullWhen(true)] out Uri? uri, [NotNullWhen(false)] out string? message)
+ {
+ if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out uri))
+ {
+ uri = null;
+ message = "The URL for the external service must be an absolute URI.";
+ return false;
+ }
+
+ if (GetUriValidationException(uri) is { } exception)
+ {
+ message = exception.Message;
+ uri = null;
+ return false;
+ }
+
+ message = null;
+
+ return true;
+ }
+
+ private static ArgumentException? GetUriValidationException(Uri uri)
+ {
+ if (!uri.IsAbsoluteUri)
+ {
+ return new ArgumentException("The URI for the external service must be absolute.", nameof(uri));
+ }
+ if (uri.AbsolutePath != "/")
+ {
+ return new ArgumentException("The URI absolute path must be \"/\".", nameof(uri));
+ }
+ if (!string.IsNullOrEmpty(uri.Fragment))
+ {
+ return new ArgumentException("The URI cannot contain a fragment.", nameof(uri));
+ }
+ return null;
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeResource.cs b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeResource.cs
new file mode 100644
index 00000000..671507e9
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Stripe/StripeResource.cs
@@ -0,0 +1,13 @@
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a Stripe CLI container resource for local webhook forwarding and testing.
+///
+/// The name of the resource.
+public class StripeResource(string name) : ContainerResource(name)
+{
+ ///
+ /// Gets the webhook signing secret retrieved from the Stripe CLI.
+ ///
+ public string? WebhookSigningSecret { get; internal set; }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AddStripeTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AddStripeTests.cs
new file mode 100644
index 00000000..5982c766
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AddStripeTests.cs
@@ -0,0 +1,325 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using CommunityToolkit.Aspire.Hosting.Stripe;
+
+namespace CommunityToolkit.Aspire.Hosting.Stripe.Tests;
+
+public class AddStripeTests
+{
+ private const string TestApiKeyValue = "sk_test_123";
+
+ [Fact]
+ public void StripeConfiguresContainerImage()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue);
+
+ builder.AddStripe("stripe", apiKey);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+
+ var containerAnnotation = Assert.Single(resource.Annotations.OfType());
+ Assert.Equal(StripeContainerImageTags.Image, containerAnnotation.Image);
+ Assert.Equal(StripeContainerImageTags.Tag, containerAnnotation.Tag);
+ Assert.Equal(StripeContainerImageTags.Registry, containerAnnotation.Registry);
+ }
+
+ [Fact]
+ public async Task StripeWithListenAddsListenArgs()
+ {
+ 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");
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+
+ var args = await resource.GetArgumentValuesAsync();
+
+ Assert.Collection(args,
+ arg => Assert.Equal("listen", arg),
+ arg => Assert.Equal("--forward-to", arg),
+ arg => Assert.Equal("http://localhost:5082/webhooks", arg)
+ );
+ }
+
+ [Fact]
+ public async Task StripeWithListenAndEventsAddsEventArgs()
+ {
+ 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: ["payment_intent.created", "charge.succeeded"]);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+
+ var args = await resource.GetArgumentValuesAsync();
+
+ 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("payment_intent.created,charge.succeeded", arg)
+ );
+ }
+
+ [Fact]
+ public async Task StripeWithApiKeyAddsApiKeyArg()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var externalEndpoint = builder.AddExternalService("external-api", "http://localhost:5082");
+ var apiKey = builder.AddParameter("api-key", "sk_test_123");
+
+ builder.AddStripe("stripe", apiKey)
+ .WithListen(externalEndpoint)
+ .WithApiKey(apiKey);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+
+ var args = await resource.GetArgumentValuesAsync();
+
+ Assert.Contains(args, arg => arg == "--api-key");
+ Assert.Contains(args, arg => arg == "sk_test_123");
+ }
+
+ [Fact]
+ public void StripeWithApiKeyParameterAddsApiKeyArg()
+ {
+ 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)
+ .WithApiKey(apiKey);
+
+ 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 StripeWithListenToEndpointReference()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue);
+
+ var api = builder.AddProject("api");
+
+ builder.AddStripe("stripe", apiKey)
+ .WithListen(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 AddStripeNullBuilderThrows()
+ {
+ IDistributedApplicationBuilder builder = null!;
+ var apiKey = DistributedApplication.CreateBuilder().AddParameter("stripe-api-key", TestApiKeyValue);
+
+ Assert.Throws(() => builder.AddStripe("stripe", apiKey));
+ }
+
+ [Fact]
+ public void AddStripeNullNameThrows()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue);
+
+ Assert.Throws(() => builder.AddStripe(null!, apiKey));
+ }
+
+ [Fact]
+ public void AddStripeEmptyNameThrows()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue);
+
+ Assert.Throws(() => builder.AddStripe(""!, apiKey));
+ }
+
+ [Fact]
+ public void WithListenNullBuilderThrows()
+ {
+ IResourceBuilder builder = null!;
+
+ Assert.Throws(() => builder.WithListen((IResourceBuilder)null!));
+ }
+
+ [Fact]
+ public void WithListenNullUrlThrows()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue);
+ var stripe = builder.AddStripe("stripe", apiKey);
+
+ Assert.Throws(() => stripe.WithListen((IResourceBuilder)null!));
+ }
+
+ [Fact]
+ public void WithListenNullEndpointReferenceThrows()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue);
+ var stripe = builder.AddStripe("stripe", apiKey);
+
+ Assert.Throws(() => stripe.WithListen((IResourceBuilder)null!));
+ }
+
+ [Fact]
+ public void WithApiKeyNullBuilderThrows()
+ {
+ IResourceBuilder builder = null!;
+
+ var ex = Record.Exception(() => builder.WithApiKey(null!));
+
+ var aex = Assert.IsType(ex);
+ Assert.Equal("builder", aex.ParamName);
+ }
+
+ [Fact]
+ public void WithApiKeyNullKeyThrows()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue);
+ var stripe = builder.AddStripe("stripe", apiKey);
+
+ var ex = Record.Exception(() => stripe.WithApiKey(null!));
+
+ var aex = Assert.IsType(ex);
+ Assert.Equal("apiKey", aex.ParamName);
+ }
+
+ [Fact]
+ public void WithReferenceAddsWebhookSecretEnvironmentVariable()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue);
+
+ var api = builder.AddProject("api");
+
+ var stripe = builder.AddStripe("stripe", apiKey)
+ .WithListen(api);
+
+ api.WithReference(stripe);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var apiResource = Assert.Single(appModel.Resources.OfType());
+
+ // Verify that an EnvironmentCallbackAnnotation was added
+ var envAnnotations = apiResource.Annotations.OfType();
+ Assert.NotEmpty(envAnnotations);
+ }
+
+ [Fact]
+ public void WithReferenceCustomEnvironmentVariableName()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue);
+
+ var api = builder.AddProject("api");
+ var stripe = builder.AddStripe("stripe", apiKey)
+ .WithListen(api);
+
+ api.WithReference(stripe, webhookSigningSecretEnvVarName: "CUSTOM_STRIPE_SECRET");
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var apiResource = Assert.Single(appModel.Resources.OfType());
+
+ // Verify that an EnvironmentCallbackAnnotation was added
+ var envAnnotations = apiResource.Annotations.OfType();
+ Assert.NotEmpty(envAnnotations);
+ }
+
+ [Fact]
+ public void WithReferenceNullBuilderThrows()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue);
+ var stripe = builder.AddStripe("stripe", apiKey);
+
+ IResourceBuilder apiBuilder = null!;
+
+ Assert.Throws(() => apiBuilder.WithReference(stripe));
+ }
+
+ [Fact]
+ public void WithReferenceNullSourceThrows()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var api = builder.AddProject("api");
+
+ Assert.Throws(() => api.WithReference((IResourceBuilder)null!));
+ }
+
+ [Fact]
+ public void WithReferenceNullEnvVarNameThrows()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue);
+ var stripe = builder.AddStripe("stripe", apiKey);
+ var api = builder.AddProject("api");
+
+ Assert.Throws(() => api.WithReference(stripe, webhookSigningSecretEnvVarName: null!));
+ }
+
+ [Fact]
+ public void WithReferenceEmptyEnvVarNameThrows()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var apiKey = builder.AddParameter("stripe-api-key", TestApiKeyValue);
+ var stripe = builder.AddStripe("stripe", apiKey);
+ var api = builder.AddProject("api");
+
+ Assert.Throws(() => api.WithReference(stripe, webhookSigningSecretEnvVarName: ""));
+ }
+}
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AppHostTests.cs
new file mode 100644
index 00000000..83901481
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/AppHostTests.cs
@@ -0,0 +1,39 @@
+using Aspire.Components.Common.Tests;
+using CommunityToolkit.Aspire.Testing;
+
+namespace CommunityToolkit.Aspire.Hosting.Stripe.Tests;
+
+[RequiresDocker]
+public class AppHostTests(AspireIntegrationTestFixture fixture)
+ : IClassFixture>
+{
+ [Fact]
+ [RequiresAuthenticatedTool("stripe")]
+ public async Task ResourceStartsAndRespondsOk()
+ {
+ var resourceName = "api";
+
+ await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName)
+ .WaitAsync(TimeSpan.FromMinutes(1));
+
+ var httpClient = fixture.CreateHttpClient(resourceName);
+
+ var response = await httpClient.GetAsync("/");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.Equal("Stripe Webhook API", body);
+ }
+
+ [Fact]
+ [RequiresAuthenticatedTool("stripe")]
+ public async Task StripeResourceIsCreated()
+ {
+ var app = fixture.App;
+ var appModel = app.Services.GetRequiredService();
+
+ var stripeResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(stripeResource);
+ Assert.Equal("stripe", stripeResource.Name);
+ }
+}
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests.csproj
new file mode 100644
index 00000000..3d0ffba0
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests.csproj
@@ -0,0 +1,8 @@
+ 737bbb5a-133b-43e8-8bb6-b86441528f8e
+
+
+
+
+
+
+
diff --git a/tests/CommunityToolkit.Aspire.Testing/RequiresAuthenticatedToolAttribute.cs b/tests/CommunityToolkit.Aspire.Testing/RequiresAuthenticatedToolAttribute.cs
new file mode 100644
index 00000000..2a343d15
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Testing/RequiresAuthenticatedToolAttribute.cs
@@ -0,0 +1,37 @@
+using Aspire.Components.Common.Tests;
+using Xunit.Sdk;
+
+namespace CommunityToolkit.Aspire.Testing;
+
+///
+/// Marks a test or test class as requiring an authenticated external tool.
+/// Adds a trait to propagate the required tool name to the xUnit pipeline.
+///
+[TraitDiscoverer("CommunityToolkit.Aspire.Testing.RequiresAuthenticatedToolDiscoverer", "CommunityToolkit.Aspire.Testing")]
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
+public sealed class RequiresAuthenticatedToolAttribute : Attribute, ITraitAttribute
+{
+ /// Initializes a new instance of the class.
+ /// The name of the external tool required for the test.
+ /// An optional reason describing why the tool is required.
+ /// Thrown when is null or whitespace.
+ public RequiresAuthenticatedToolAttribute(string toolName, string? reason = null)
+ {
+ if (string.IsNullOrWhiteSpace(toolName))
+ {
+ throw new ArgumentException("Tool name cannot be null or whitespace.", nameof(toolName));
+ }
+
+ ToolName = toolName;
+ Reason = reason;
+ }
+
+ /// Gets the name of the external tool required for the test.
+ public string ToolName { get; }
+
+ /// Gets the optional reason describing why the tool is required.
+ public string? Reason { get; }
+
+ /// Gets a value indicating whether authenticated tools are supported in the current environment.
+ public static bool IsSupported => !PlatformDetection.IsRunningOnCI;
+}
diff --git a/tests/CommunityToolkit.Aspire.Testing/RequiresAuthenticatedToolDiscoverer.cs b/tests/CommunityToolkit.Aspire.Testing/RequiresAuthenticatedToolDiscoverer.cs
new file mode 100644
index 00000000..9acb5b20
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Testing/RequiresAuthenticatedToolDiscoverer.cs
@@ -0,0 +1,36 @@
+using Xunit.Abstractions;
+using Xunit.Sdk;
+
+namespace CommunityToolkit.Aspire.Testing;
+
+///
+/// Discovers traits for .
+/// Adds a RequiresTools trait with the tool name and
+/// categorizes tests as unsupported-platform when authenticated tools are not supported.
+///
+public sealed class RequiresAuthenticatedToolDiscoverer : ITraitDiscoverer
+{
+ ///
+ public IEnumerable> GetTraits(IAttributeInfo traitAttribute)
+ {
+ string? toolName = null;
+ foreach (object? argument in traitAttribute.GetConstructorArguments().Where(a => a is string))
+ {
+ if (argument is string value)
+ {
+ toolName = value;
+ break;
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(toolName))
+ {
+ yield return new KeyValuePair("RequiresTools", toolName);
+ }
+
+ if (!RequiresAuthenticatedToolAttribute.IsSupported)
+ {
+ yield return new KeyValuePair("category", "unsupported-platform");
+ }
+ }
+}