Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 127 additions & 6 deletions docs/guide/messaging/transports/sns.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ var host = await Host.CreateDefaultBuilder()
.AutoProvision();
}).StartAsync();
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L25-L37' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_simplistic_aws_sns_setup' title='Start of snippet'>anchor</a></sup>
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L97-L109' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_simplistic_aws_sns_setup' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand All @@ -55,7 +55,7 @@ builder.UseWolverine(opts =>
using var host = builder.Build();
await host.StartAsync();
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L42-L62' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_config_aws_sns_connection' title='Start of snippet'>anchor</a></sup>
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L114-L134' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_config_aws_sns_connection' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand All @@ -73,7 +73,7 @@ var host = await Host.CreateDefaultBuilder()
opts.UseAmazonSnsTransportLocally();
}).StartAsync();
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L11-L20' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_connect_to_sns_and_localstack' title='Start of snippet'>anchor</a></sup>
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L83-L92' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_connect_to_sns_and_localstack' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

And lastly, if you want to explicitly supply an access and secret key for your credentials to SNS, you can use this syntax:
Expand Down Expand Up @@ -103,7 +103,7 @@ builder.UseWolverine(opts =>
using var host = builder.Build();
await host.StartAsync();
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L67-L90' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_setting_aws_sns_credentials' title='Start of snippet'>anchor</a></sup>
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L139-L162' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_setting_aws_sns_credentials' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## Publishing
Expand Down Expand Up @@ -131,9 +131,130 @@ var host = await Host.CreateDefaultBuilder()
});
}).StartAsync();
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L95-L113' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_subscriber_rules_for_sns' title='Start of snippet'>anchor</a></sup>
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L167-L185' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_subscriber_rules_for_sns' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## Connecting to Multiple Brokers <Badge type="tip" text="6.17" />

If you need to connect to more than one Amazon SNS broker (a different AWS account, region, or endpoint) from a single
Wolverine application, register additional, *named* brokers alongside the default one and pin publishing rules to a
specific broker by name:

<!-- snippet: sample_using_multiple_sns_brokers -->
<a id='snippet-sample_using_multiple_sns_brokers'></a>
```cs
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
// The "default" broker
opts.UseAmazonSnsTransport();

// Add a second, named Amazon SNS broker that connects to a
// different account or region
opts.AddNamedAmazonSnsBroker(new BrokerName("americas"), snsConfig =>
{
snsConfig.RegionEndpoint = RegionEndpoint.USEast1;
}).AutoProvision();

// Publish to a topic on the named broker. The Uri scheme for these
// endpoints is the broker name, so you'd see "americas://colors".
opts.PublishMessage<Message1>()
.ToSnsTopicOnNamedBroker(new BrokerName("americas"), "colors");
}).StartAsync();
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L13-L34' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_using_multiple_sns_brokers' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Note that the `Uri` scheme within Wolverine for any endpoints from a "named" Amazon SNS broker is the name that you
supply for the broker. So in the example above, you would see `Uri` values like `americas://colors`.

::: tip
Because SNS is publish-only, the receiving side of a named SNS broker is handled by an SQS queue subscribed to the
topic. A named SNS broker and a same-named SQS broker cannot share a single `BrokerName` (Wolverine keys transports by
their `Uri` scheme, and the SNS broker owns that name), so run the SQS listener that drains the subscribed queue on the
*default* (or a differently-named) SQS broker. The SNS-side subscription provisioning uses its own paired SQS client
that targets the same account/region as the named SNS broker.
:::

## Multi-Tenancy with a Broker per Tenant <Badge type="tip" text="6.17" />

Wolverine can give each tenant its own dedicated Amazon SNS connection (a distinct AWS account/credentials, region, or
`ServiceURL`) while sharing one declared topic topology. Which connection a message is published to is decided at
runtime by the message's [tenant id](/guide/handlers/multi-tenancy), typically set through `DeliveryOptions.TenantId`:

<!-- snippet: sample_sns_broker_per_tenant -->
<a id='snippet-sample_sns_broker_per_tenant'></a>
```cs
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
// The "default" / shared SNS connection
opts.UseAmazonSnsTransport(config =>
{
config.RegionEndpoint = RegionEndpoint.USEast1;
})
.AutoProvision()

// How should Wolverine route a message whose TenantId is null or
// unknown? FallbackToDefault (the default) uses the shared connection;
// TenantIdRequired throws; IgnoreUnknownTenants silently drops it.
.TenantIdBehavior(TenantedIdBehavior.FallbackToDefault)

// Each tenant gets its OWN dedicated SNS connection, but shares the
// topic topology declared below. This tenant inherits the parent's
// AWS credentials, and just re-points at its own region.
.AddTenant("tenant-west", config =>
{
config.RegionEndpoint = RegionEndpoint.USWest2;
})

// Or give the tenant its own dedicated AWS account by supplying
// its own credentials (optionally with its own region/endpoint too):
.AddTenant("tenant-eu", new BasicAWSCredentials("tenant-eu-key", "tenant-eu-secret"),
config =>
{
config.RegionEndpoint = RegionEndpoint.EUWest1;
});

// SNS is publish-only, so broker-per-tenant means tenant-specific PUBLISHERS.
// A message is published to the right tenant's connection at runtime by its
// Envelope.TenantId (e.g. new DeliveryOptions { TenantId = "tenant-west" }).
opts.PublishMessage<Message1>().ToSnsTopic("colors");
}).StartAsync();
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L39-L78' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_sns_broker_per_tenant' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

To route a specific message to a tenant's connection, stamp the tenant id on the send:

```csharp
await bus.SendAsync(new ColorMessage("blue"), new DeliveryOptions { TenantId = "tenant-west" });
```

::: warning SNS is publish-only
SNS has no listening surface in Wolverine, so broker-per-tenant support here means **tenant-specific publishers only** —
Wolverine wraps the outbound topic in a `TenantedSender` that dispatches on `Envelope.TenantId` to a per-tenant SNS
client. There is no per-tenant listener.

The *receiving* side is handled exactly as it is for a single-tenant SNS setup: by subscribing an SQS queue to the
tenant's topic and listening on the SQS side. For full per-tenant **consumption**, pair this with
[Amazon SQS broker-per-tenant](/guide/messaging/transports/sqs/) multi-tenancy so that each tenant's subscribed queue is
consumed on that tenant's own SQS connection. Each SNS tenant already provisions its topic subscription and queue policy
through its own paired SQS client, so the SNS and SQS tenant connections line up on the same account/region.
:::

`TenantIdBehavior(...)` controls what happens when a message has a null or unregistered tenant id:

* `FallbackToDefault` (the default) — publish it to the shared/default connection (the one passed to `UseAmazonSnsTransport`).
* `TenantIdRequired` — throw; every message must carry a known tenant id.
* `IgnoreUnknownTenants` — silently drop the message.

::: tip Named broker vs. broker-per-tenant
Use a **named broker** when a *fixed set of endpoints* should always publish to a *specific* broker. Use
**broker-per-tenant** when the *same logical topic* should be transparently routed to a *different connection per tenant*
based on the runtime tenant id. They are independent features and can be combined.
:::

## Topic Subscriptions

Wolverine gives you the ability to automatically subscribe SQS Queues to SNS topics with it's auto-provision
Expand Down Expand Up @@ -188,7 +309,7 @@ var host = await Host.CreateDefaultBuilder()
});
}).StartAsync();
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L118-L165' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_sns_topic_subscriptions_creation' title='Start of snippet'>anchor</a></sup>
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Transports/AWS/Wolverine.AmazonSns.Tests/Samples/Bootstrapping.cs#L190-L237' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_sns_topic_subscriptions_creation' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## Interoperability
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
using Amazon;
using Amazon.Runtime;
using Shouldly;
using Wolverine.AmazonSns.Internal;
using Wolverine.Configuration;
using Wolverine.Transports.Sending;
using Xunit;

namespace Wolverine.AmazonSns.Tests;

// Broker-per-tenant (GH-3305) unit coverage that needs NO SNS broker/LocalStack: it exercises the tenant
// registration / compile / sender-resolution wiring, not live publishing. Live tenant routing behavior is
// covered by AmazonSnsPerTenantConnectionTests.
public class AmazonSnsPerTenantConfigurationTests
{
[Fact]
public void add_tenant_registers_a_tenant_with_its_own_region()
{
var options = new WolverineOptions();
var sns = options.UseAmazonSnsTransport(c => c.RegionEndpoint = RegionEndpoint.USEast1);
sns.AddTenant("tenantB", c => c.RegionEndpoint = RegionEndpoint.USWest2);

var transport = options.AmazonSnsTransport();
transport.Tenants.Count().ShouldBe(1);

var tenant = transport.Tenants["tenantB"];
tenant.Compile(transport, null!);

tenant.Transport.SnsConfig.RegionEndpoint.ShouldBe(RegionEndpoint.USWest2);
}

[Fact]
public void add_tenant_with_credentials_sets_a_dedicated_credential_source()
{
var options = new WolverineOptions();
var sns = options.UseAmazonSnsTransport(c => c.RegionEndpoint = RegionEndpoint.USEast1);

var credentials = new BasicAWSCredentials("tenant-key", "tenant-secret");
sns.AddTenant("tenantB", credentials, c => c.RegionEndpoint = RegionEndpoint.EUWest1);

var transport = options.AmazonSnsTransport();
var tenant = transport.Tenants["tenantB"];
tenant.Compile(transport, null!);

var credentialSource = tenant.Transport.CredentialSource;
credentialSource.ShouldNotBeNull();
credentialSource(null!).ShouldBeSameAs(credentials);
tenant.Transport.SnsConfig.RegionEndpoint.ShouldBe(RegionEndpoint.EUWest1);
}

[Fact]
public void tenant_inherits_parent_credentials_when_not_overridden()
{
var options = new WolverineOptions();
var parentCredentials = new BasicAWSCredentials("parent-key", "parent-secret");
var sns = options.UseAmazonSnsTransport(c => c.RegionEndpoint = RegionEndpoint.USEast1);
sns.Credentials(parentCredentials);
sns.AddTenant("tenantB", c => c.RegionEndpoint = RegionEndpoint.USWest2);

var transport = options.AmazonSnsTransport();
var tenant = transport.Tenants["tenantB"];
tenant.Compile(transport, null!);

// Inherited the parent credential source (dedicated-region tenant, shared account)...
tenant.Transport.CredentialSource.ShouldBeSameAs(transport.CredentialSource);
// ...but kept its own region.
tenant.Transport.SnsConfig.RegionEndpoint.ShouldBe(RegionEndpoint.USWest2);
}

[Fact]
public void tenant_inherits_parent_region_and_provisioning_when_it_sets_nothing()
{
var options = new WolverineOptions();
var sns = options.UseAmazonSnsTransport(c => c.RegionEndpoint = RegionEndpoint.USEast1);
sns.AutoProvision();
// Register a tenant that only supplies credentials, no region/endpoint of its own.
sns.AddTenant("tenantB", new BasicAWSCredentials("k", "s"));

var transport = options.AmazonSnsTransport();
var tenant = transport.Tenants["tenantB"];
tenant.Compile(transport, null!);

tenant.Transport.SnsConfig.RegionEndpoint.ShouldBe(RegionEndpoint.USEast1);
tenant.Transport.AutoProvision.ShouldBeTrue();
}

[Fact]
public void paired_sqs_client_tracks_the_tenant_sns_account()
{
var options = new WolverineOptions();
var sns = options.UseAmazonSnsTransport(c => c.ServiceURL = "http://localhost:4566");
sns.AddTenant("tenantB", c => c.AuthenticationRegion = "us-west-2");

var transport = options.AmazonSnsTransport();
var tenant = transport.Tenants["tenantB"];
tenant.Compile(transport, null!);

// The paired SQS client used for subscription provisioning must sign for the same account/region as the
// tenant's SNS topic so cross-partition provisioning targets the tenant's own store.
tenant.Transport.SQS.Config.ServiceURL.ShouldBe(tenant.Transport.SnsConfig.ServiceURL);
tenant.Transport.SQS.Config.ServiceURL.ShouldStartWith("http://localhost:4566");
tenant.Transport.SQS.Config.AuthenticationRegion.ShouldBe("us-west-2");
}

[Fact]
public void sns_topics_are_tenant_aware_by_default()
{
var transport = new AmazonSnsTransport();
var topic = transport.Topics["orders"];
topic.TenancyBehavior.ShouldBe(TenancyBehavior.TenantAware);
}

[Fact]
public void build_tenant_sibling_copies_configuration_but_uses_the_tenant_transport()
{
var transport = new AmazonSnsTransport { SQS = new AmazonSqs.Internal.AmazonSqsTransport() };
var tenant = transport.Tenants["tenantB"];

var topic = transport.Topics["orders"];
topic.TopicSubscriptions.Add(new AmazonSnsSubscription("some-queue", AmazonSnsSubscriptionType.Sqs,
new AmazonSnsSubscriptionAttributes { RawMessageDelivery = true }));

var sibling = topic.BuildTenantSibling(tenant);

sibling.ShouldNotBeSameAs(topic);
sibling.TopicName.ShouldBe("orders");
// Subscriptions are deep-copied, not shared (SubscriptionArn is stamped per account).
sibling.TopicSubscriptions.ShouldNotBeSameAs(topic.TopicSubscriptions);
sibling.TopicSubscriptions.Single().Endpoint.ShouldBe("some-queue");
sibling.TopicSubscriptions.Single().Attributes.RawMessageDelivery.ShouldBeTrue();
// Resolved from the tenant transport's own (isolated) topic cache.
tenant.Transport.Topics["orders"].ShouldBeSameAs(sibling);
}

[Fact]
public void tenant_id_behavior_defaults_to_fallback_and_is_overridable()
{
var options = new WolverineOptions();
var sns = options.UseAmazonSnsTransport();

var transport = options.AmazonSnsTransport();
transport.TenantedIdBehavior.ShouldBe(TenantedIdBehavior.FallbackToDefault);

sns.TenantIdBehavior(TenantedIdBehavior.TenantIdRequired);
transport.TenantedIdBehavior.ShouldBe(TenantedIdBehavior.TenantIdRequired);
}
}
Loading
Loading