From a79a6b41baf693ecd647cb5b9f379386557ad765 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Fri, 4 Sep 2026 13:52:51 -0700 Subject: [PATCH 01/31] Solution folders, plus some spacing nudges --- RedShirt.Example.Api.slnx | 177 +++++++++++++++----------------------- 1 file changed, 69 insertions(+), 108 deletions(-) diff --git a/RedShirt.Example.Api.slnx b/RedShirt.Example.Api.slnx index 7e4e0f0..4b3ec97 100644 --- a/RedShirt.Example.Api.slnx +++ b/RedShirt.Example.Api.slnx @@ -1,146 +1,107 @@ - - + + - + - - - + + + - - + + - - - - - - - + + + + + + + - - - + + + - - + + - - - - + + + + - + - - - - - - - - - + + + + + + + + + - - + + - - + + + - + - - - + + + - - - - - - - - - - - + + + + + + + + + + + - - - - + + + + - + - - - - - - - - + + + + + + + + - + + From 54f3691f474961a6b4368a09106fe2cb3a40456f Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Fri, 4 Sep 2026 18:02:54 -0700 Subject: [PATCH 02/31] Initial Cursor output. --- RedShirt.Example.Api.slnx | 188 +++++++++++------- .../Extensions/ServiceCollectionExtensions.cs | 14 ++ .../Models/ExampleMessageModel.cs | 6 + ...le.Api.ClientEvents.Domains.Example.csproj | 17 ++ .../Services/ExampleMessageReceiveService.cs | 26 +++ .../Services/ExampleMessageSendService.cs | 25 +++ .../Utilities/ExampleMessageTopicNames.cs | 6 + .../Exceptions/ApiClientEventsException.cs | 27 +++ .../Models/ApiClientEventReceived.cs | 12 ++ .../Models/ApiClientEventSendRequest.cs | 15 ++ ...ample.Api.ClientEvents.Library.Core.csproj | 9 + .../Services/IApiClientEventReceiver.cs | 21 ++ .../Services/IApiClientEventSender.cs | 17 ++ .../Extensions/ServiceCollectionExtensions.cs | 34 ++++ .../Factories/MqttClientFactory.cs | 186 +++++++++++++++++ .../MqttClientEventsExceptionArbiterReport.cs | 10 + ...ntEvents.Library.MqttImplementation.csproj | 22 ++ .../Services/MqttApiClientEventReceiver.cs | 132 ++++++++++++ .../Services/MqttApiClientEventSender.cs | 98 +++++++++ ...MqttClientEventsExceptionArbiterService.cs | 82 ++++++++ .../MqttClientEventsRetryWrapperService.cs | 165 +++++++++++++++ .../RedShirt.Example.Api.Core.csproj | 1 + .../Send/SendExampleMessageCommand.cs | 3 + .../Send/SendExampleMessageCommandHandler.cs | 21 ++ .../SendExampleMessageCommandValidator.cs | 17 ++ .../Stream/StreamExampleMessagesQuery.cs | 3 + .../StreamExampleMessagesQueryHandler.cs | 19 ++ .../StreamExampleMessagesQueryValidator.cs | 13 ++ .../Controllers/MessageController.cs | 74 +++++++ .../ExceptionHandlers/ApiExceptionHandler.cs | 2 + .../Extensions/ServiceCollectionExtensions.cs | 6 +- .../Messages/ExampleMessagePostRequest.cs | 6 + .../RedShirt.Example.Api.csproj | 3 + .../RedShirt.Example.Api.Interop/swagger.json | 76 +++++++ test/local/README.md | 43 ++++ test/local/docker-compose.yaml | 8 + .../local/scripts/messages/listen-messages.py | 86 ++++++++ .../messages/messages_script_common.py | 39 ++++ test/local/scripts/messages/send-message.py | 57 ++++++ 39 files changed, 1519 insertions(+), 70 deletions(-) create mode 100644 src/RedShirt.Example.Api.ClientEvents.Domains.Example/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Domains.Example/Models/ExampleMessageModel.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Domains.Example/RedShirt.Example.Api.ClientEvents.Domains.Example.csproj create mode 100644 src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageReceiveService.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageSendService.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Domains.Example/Utilities/ExampleMessageTopicNames.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.Core/Exceptions/ApiClientEventsException.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventReceived.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventSendRequest.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.Core/RedShirt.Example.Api.ClientEvents.Library.Core.csproj create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventReceiver.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventSender.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttClientEventsExceptionArbiterReport.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.csproj create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventReceiver.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventSender.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsExceptionArbiterService.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsRetryWrapperService.cs create mode 100644 src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommand.cs create mode 100644 src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandHandler.cs create mode 100644 src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandValidator.cs create mode 100644 src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQuery.cs create mode 100644 src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryHandler.cs create mode 100644 src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryValidator.cs create mode 100644 src/RedShirt.Example.Api/Controllers/MessageController.cs create mode 100644 src/RedShirt.Example.Api/Models/Messages/ExampleMessagePostRequest.cs create mode 100755 test/local/scripts/messages/listen-messages.py create mode 100755 test/local/scripts/messages/messages_script_common.py create mode 100755 test/local/scripts/messages/send-message.py diff --git a/RedShirt.Example.Api.slnx b/RedShirt.Example.Api.slnx index 4b3ec97..3216efb 100644 --- a/RedShirt.Example.Api.slnx +++ b/RedShirt.Example.Api.slnx @@ -1,107 +1,157 @@ - - + + - + - - - + + + - - + + - - - - - - - + + + + + + + - - - + + + - - + + - - - - + + + + - + - - - - - - - - - + + + + + + + + + - - + + - - + + + + + + + + + - - + - - - + + + - - - - - - - - - - - + + + + + + + + + + + - - - - + + + + - + - - - - - - - - + + + + + + + + - + - + diff --git a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Extensions/ServiceCollectionExtensions.cs b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..318784f --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using RedShirt.Example.Api.ClientEvents.Domains.Example.Services; + +namespace RedShirt.Example.Api.ClientEvents.Domains.Example.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddExampleClientEventsDomain(this IServiceCollection services) + { + return services + .AddSingleton() + .AddSingleton(); + } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Models/ExampleMessageModel.cs b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Models/ExampleMessageModel.cs new file mode 100644 index 0000000..c7fb5c5 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Models/ExampleMessageModel.cs @@ -0,0 +1,6 @@ +namespace RedShirt.Example.Api.ClientEvents.Domains.Example.Models; + +public sealed class ExampleMessageModel +{ + public required string Message { get; init; } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/RedShirt.Example.Api.ClientEvents.Domains.Example.csproj b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/RedShirt.Example.Api.ClientEvents.Domains.Example.csproj new file mode 100644 index 0000000..8bc2da7 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/RedShirt.Example.Api.ClientEvents.Domains.Example.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageReceiveService.cs b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageReceiveService.cs new file mode 100644 index 0000000..7ae5f9c --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageReceiveService.cs @@ -0,0 +1,26 @@ +using RedShirt.Example.Api.ClientEvents.Domains.Example.Models; +using RedShirt.Example.Api.ClientEvents.Library.Core.Services; +using RedShirt.Example.Api.ClientEvents.Domains.Example.Utilities; + +namespace RedShirt.Example.Api.ClientEvents.Domains.Example.Services; + +public interface IExampleMessageReceiveService +{ + IAsyncEnumerable ReceiveForUserAsync(string userId, + CancellationToken cancellationToken = default); +} + +internal sealed class ExampleMessageReceiveService(IApiClientEventReceiver eventReceiver) + : IExampleMessageReceiveService +{ + public async IAsyncEnumerable ReceiveForUserAsync(string userId, + [System.Runtime.CompilerServices.EnumeratorCancellation] + CancellationToken cancellationToken = default) + { + var topic = ExampleMessageTopicNames.ForUser(userId); + await foreach (var received in eventReceiver.ReceiveAsync([topic], cancellationToken)) + { + yield return received.Payload; + } + } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageSendService.cs b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageSendService.cs new file mode 100644 index 0000000..21888a7 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageSendService.cs @@ -0,0 +1,25 @@ +using RedShirt.Example.Api.ClientEvents.Domains.Example.Models; +using RedShirt.Example.Api.ClientEvents.Library.Core.Models; +using RedShirt.Example.Api.ClientEvents.Library.Core.Services; +using RedShirt.Example.Api.ClientEvents.Domains.Example.Utilities; + +namespace RedShirt.Example.Api.ClientEvents.Domains.Example.Services; + +public interface IExampleMessageSendService +{ + Task SendToUserAsync(string userId, ExampleMessageModel message, CancellationToken cancellationToken = default); +} + +internal sealed class ExampleMessageSendService(IApiClientEventSender eventSender) + : IExampleMessageSendService +{ + public Task SendToUserAsync(string userId, ExampleMessageModel message, + CancellationToken cancellationToken = default) + { + return eventSender.SendAsync(new ApiClientEventSendRequest + { + Payload = message, + Topic = ExampleMessageTopicNames.ForUser(userId) + }, cancellationToken); + } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Utilities/ExampleMessageTopicNames.cs b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Utilities/ExampleMessageTopicNames.cs new file mode 100644 index 0000000..ac89579 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Utilities/ExampleMessageTopicNames.cs @@ -0,0 +1,6 @@ +namespace RedShirt.Example.Api.ClientEvents.Domains.Example.Utilities; + +internal static class ExampleMessageTopicNames +{ + public static string ForUser(string userId) => $"example-message/user/{userId}"; +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Exceptions/ApiClientEventsException.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Exceptions/ApiClientEventsException.cs new file mode 100644 index 0000000..3a5af7e --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Exceptions/ApiClientEventsException.cs @@ -0,0 +1,27 @@ +namespace RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; + +/// +/// Classified failure from a client-events operation. +/// +public sealed class ApiClientEventsException : Exception +{ + /// + /// When true, a retry wrapper inside the client-events layer has already exhausted retries + /// for the underlying cause; outer retry layers should not retry again. + /// + public required bool IsHandled { get; init; } + + /// + /// When true, suggests a possible transient or environmental cause could be resolved outside the application + /// process (with an infrastructure change, for example) without restarting the application. + /// + public required bool CouldBeTransient { get; init; } + + public ApiClientEventsException(Exception innerException) : base(innerException.Message, innerException) + { + } + + public ApiClientEventsException(string message) : base(message) + { + } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventReceived.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventReceived.cs new file mode 100644 index 0000000..732173f --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventReceived.cs @@ -0,0 +1,12 @@ +namespace RedShirt.Example.Api.ClientEvents.Library.Core.Models; + +/// +/// A client event received from the transport layer. +/// +/// Event body type. +public sealed class ApiClientEventReceived +{ + public required string Topic { get; init; } + + public required TPayload Payload { get; init; } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventSendRequest.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventSendRequest.cs new file mode 100644 index 0000000..c2acff7 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventSendRequest.cs @@ -0,0 +1,15 @@ +namespace RedShirt.Example.Api.ClientEvents.Library.Core.Models; + +/// +/// Payload and optional routing metadata for a client event publish operation. +/// +/// Event body type. +public sealed class ApiClientEventSendRequest +{ + public required TPayload Payload { get; init; } + + /// + /// Optional MQTT topic. When omitted, the implementation applies its default routing. + /// + public string? Topic { get; init; } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Core/RedShirt.Example.Api.ClientEvents.Library.Core.csproj b/src/RedShirt.Example.Api.ClientEvents.Library.Core/RedShirt.Example.Api.ClientEvents.Library.Core.csproj new file mode 100644 index 0000000..e0ad4b5 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Core/RedShirt.Example.Api.ClientEvents.Library.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventReceiver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventReceiver.cs new file mode 100644 index 0000000..f459006 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventReceiver.cs @@ -0,0 +1,21 @@ +using RedShirt.Example.Api.ClientEvents.Library.Core.Models; + +namespace RedShirt.Example.Api.ClientEvents.Library.Core.Services; + +/// +/// Streams typed client events. Implementations are expected to use MQTT as the transport; +/// topic names should be MQTT-shaped (for example domain/entity/id). +/// +/// Event body type. +public interface IApiClientEventReceiver +{ + /// + /// Streams client events from the transport. When is null or empty, + /// no topic filter is applied. Otherwise only events published on the listed MQTT-shaped topics are yielded. + /// + /// Optional MQTT topic filter list. + /// Token used to cancel the stream. + IAsyncEnumerable> ReceiveAsync( + IReadOnlyList? topics = null, + CancellationToken cancellationToken = default); +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventSender.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventSender.cs new file mode 100644 index 0000000..645d38a --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventSender.cs @@ -0,0 +1,17 @@ +using RedShirt.Example.Api.ClientEvents.Library.Core.Models; + +namespace RedShirt.Example.Api.ClientEvents.Library.Core.Services; + +/// +/// Publishes typed client events. Implementations are expected to use MQTT as the transport; +/// topic names should be MQTT-shaped (for example domain/entity/id). +/// +/// Event body type. +public interface IApiClientEventSender +{ + /// + /// Publishes a client event. When is set, + /// it should be an MQTT-shaped topic understood by the implementation. + /// + Task SendAsync(ApiClientEventSendRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..1ec7aaf --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using RedShirt.Example.Api.ClientEvents.Library.Core.Services; +using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Factories; +using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; +using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services.Resilience; +using RedShirt.Example.Api.Common.Extensions; + +namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Extensions; + +public static class ServiceCollectionExtensions +{ + public const string ConfigurationSectionName = "ClientEvents:Mqtt"; + + public static IServiceCollection AddMqttClientEvents(this IServiceCollection services, + IConfiguration configuration) + { + services + .AddCommonServices() + .Configure( + configuration.GetSection(ConfigurationSectionName)) + .Configure( + configuration.GetSection(ConfigurationSectionName)); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(typeof(IApiClientEventSender<>), typeof(MqttApiClientEventSender<>)); + services.TryAddSingleton(typeof(IApiClientEventReceiver<>), typeof(MqttApiClientEventReceiver<>)); + + return services; + } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs new file mode 100644 index 0000000..45f26e9 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs @@ -0,0 +1,186 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MQTTnet; +using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; +using RedShirt.Example.Api.Common.SecretManagers.Core.Services; +using System.Net.Sockets; + +namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Factories; + +internal interface IMqttClientFactory +{ + Task CreateConnectedClientAsync(CancellationToken cancellationToken); +} + +internal sealed class ApiMqttClientFactory( + IOptions configuration, + ISecretManagerService secretManagerService, + ILogger logger) : IMqttClientFactory +{ + private const string DefaultClientIdPrefix = "RedShirt.Example.Api"; + + private static readonly string ServerHostname = + string.IsNullOrWhiteSpace(Environment.MachineName) ? "unknown" : Environment.MachineName; + + public async Task CreateConnectedClientAsync(CancellationToken cancellationToken) + { + var config = configuration.Value; + if (string.IsNullOrWhiteSpace(config.BrokerUrl)) + { + throw new ApiClientEventsException("ClientEvents MQTT broker URL is not configured.") + { + CouldBeTransient = false, + IsHandled = false + }; + } + + var mqttFactory = new MqttClientFactory(); + var client = mqttFactory.CreateMqttClient(); + var optionsBuilder = new MqttClientOptionsBuilder() + .WithClientId($"{config.EffectiveClientIdPrefix}/{ServerHostname}/{Guid.NewGuid():N}") + .WithCleanSession(); + + ApplyBrokerAddress(optionsBuilder, config.BrokerUrl); + + if (await TryResolveCredentialsAsync(config, cancellationToken) is { } credentials) + { + optionsBuilder.WithCredentials(credentials.Username, credentials.Password); + } + + try + { + var connectResult = await client.ConnectAsync(optionsBuilder.Build(), cancellationToken); + if (connectResult.ResultCode != MqttClientConnectResultCode.Success) + { + throw new ApiClientEventsException( + $"MQTT broker connection failed with result code {connectResult.ResultCode}.") + { + CouldBeTransient = true, + IsHandled = false + }; + } + } + catch (ApiClientEventsException) + { + client.Dispose(); + throw; + } + catch (Exception exception) + { + client.Dispose(); + logger.LogError(exception, "Failed to connect to MQTT broker at {BrokerUrl}", config.BrokerUrl); + throw new ApiClientEventsException(exception) + { + CouldBeTransient = true, + IsHandled = false + }; + } + + return client; + } + + /// + /// Maps a configured onto . + /// + /// + /// MQTTnet does not connect from a single opaque URL string. Each transport must be configured explicitly + /// ( for native MQTT, + /// + /// for MQTT-over-WebSocket). Deployment environments also use different schemes for the same broker + /// (for example ws:// against MiniStack locally and mqtts:// in production), so this helper + /// normalizes the configured URL into the builder call the transport requires. + /// + private static void ApplyBrokerAddress(MqttClientOptionsBuilder optionsBuilder, string brokerUrl) + { + // Host-only values (no scheme) are treated as a TCP host name for plain MQTT. + if (!Uri.TryCreate(brokerUrl, UriKind.Absolute, out var uri)) + { + optionsBuilder.WithTcpServer(brokerUrl); + return; + } + + // WebSocket transports: MQTTnet expects "host[:port][/path]" without the ws/wss scheme prefix. + if (uri.Scheme.Equals("ws", StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase)) + { + var webSocketUri = uri.IsDefaultPort + ? $"{uri.Host}{uri.PathAndQuery}" + : $"{uri.Host}:{uri.Port}{uri.PathAndQuery}"; + + optionsBuilder.WithWebSocketServer(webSocketBuilder => + webSocketBuilder.WithUri(webSocketUri)); + return; + } + + // Native MQTT over TCP/TLS: map mqtt/mqtts/tcp URIs to host and port (default 1883 when omitted). + // ReSharper disable once InvertIf + if (uri.Scheme.Equals("tcp", StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals("mqtt", StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals("mqtts", StringComparison.OrdinalIgnoreCase)) + { + optionsBuilder.WithTcpServer(uri.Host, uri.IsDefaultPort ? 1883 : uri.Port); + return; + } + + throw new ApiClientEventsException($"Unsupported MQTT broker URL scheme '{uri.Scheme}'.") + { + CouldBeTransient = false, + IsHandled = false + }; + } + + private async Task TryResolveCredentialsAsync(ConfigurationModel config, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(config.UsernameSecretPath) + || string.IsNullOrWhiteSpace(config.PasswordSecretPath)) + { + return null; + } + + var secrets = await secretManagerService.GetSecretsAsync( + [config.UsernameSecretPath, config.PasswordSecretPath], + cancellationToken); + + if (!secrets.TryGetValue(config.UsernameSecretPath, out var username) + || !secrets.TryGetValue(config.PasswordSecretPath, out var password)) + { + throw new ApiClientEventsException("MQTT username or password secret could not be resolved.") + { + CouldBeTransient = false, + IsHandled = false + }; + } + + return new MqttBrokerCredentials + { + Username = username, + Password = password + }; + } + + private sealed class MqttBrokerCredentials + { + public required string Username { get; init; } + + public required string Password { get; init; } + } + + internal sealed class ConfigurationModel + { + /// + /// Prefix for MQTT client ids ({prefix}/{hostname}/{guid}). When null or whitespace, + /// is used. + /// + public string? ClientIdPrefix { get; init; } + + public string? BrokerUrl { get; init; } + + public string? UsernameSecretPath { get; init; } + + public string? PasswordSecretPath { get; init; } + + public string EffectiveClientIdPrefix => + string.IsNullOrWhiteSpace(ClientIdPrefix) ? DefaultClientIdPrefix : ClientIdPrefix; + } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttClientEventsExceptionArbiterReport.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttClientEventsExceptionArbiterReport.cs new file mode 100644 index 0000000..18cbbe7 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttClientEventsExceptionArbiterReport.cs @@ -0,0 +1,10 @@ +namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Models; + +internal sealed class MqttClientEventsExceptionArbiterReport +{ + public required bool AlreadyHandled { get; init; } + + public required bool IsExpected { get; init; } + + public required bool CouldBeTransient { get; init; } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.csproj b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.csproj new file mode 100644 index 0000000..62a4db0 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventReceiver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventReceiver.cs new file mode 100644 index 0000000..cd7c50f --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventReceiver.cs @@ -0,0 +1,132 @@ +using Microsoft.Extensions.Logging; +using MQTTnet; +using MQTTnet.Protocol; +using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; +using RedShirt.Example.Api.ClientEvents.Library.Core.Models; +using RedShirt.Example.Api.ClientEvents.Library.Core.Services; +using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Factories; +using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services.Resilience; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading.Channels; + +namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; + +internal sealed class MqttApiClientEventReceiver( + IMqttClientFactory mqttClientFactory, + IMqttClientEventsRetryWrapperService retryWrapperService, + ILogger> logger) : IApiClientEventReceiver +{ + public async IAsyncEnumerable> ReceiveAsync( + IReadOnlyList? topics = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + IMqttClient? client = null; + Channel>? channel = null; + + try + { + client = await retryWrapperService.RunAsync( + mqttClientFactory.CreateConnectedClientAsync, + cancellationToken); + + channel = Channel.CreateUnbounded>( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + + // MQTT pushes messages on this callback thread; ReceiveAsync exposes an IAsyncEnumerable to callers. + // The channel bridges those models: WriteAsync enqueues from the handler, and then the ReadAllAsync invocation + // below dequeues messages into the async iterator that SSE (and any other consumers) pull from. + client.ApplicationMessageReceivedAsync += async args => + { + try + { + var payloadText = args.ApplicationMessage.ConvertPayloadToString(); + var payload = JsonSerializer.Deserialize(payloadText); + if (payload is null) + { + return; + } + + // Must forward into the channel: the handler cannot yield return, and the reader is blocked + // on ReadAllAsync until a message arrives from the broker. WriteAsync is in-process only + // (an unbounded Channel queue). It does not send MQTT traffic. The broker already delivered + // the payload before this callback ran. + await channel.Writer.WriteAsync(new ApiClientEventReceived + { + Topic = args.ApplicationMessage.Topic, + Payload = payload + }, cancellationToken); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to deserialize MQTT client event payload"); + } + }; + + var subscribeBuilder = new MqttClientSubscribeOptionsBuilder(); + if (topics is { Count: > 0 }) + { + foreach (var topic in topics.Where(topic => !string.IsNullOrWhiteSpace(topic))) + { + subscribeBuilder.WithTopicFilter(topic, MqttQualityOfServiceLevel.AtLeastOnce); + } + } + else + { + subscribeBuilder.WithTopicFilter("#", MqttQualityOfServiceLevel.AtLeastOnce); + } + + await retryWrapperService.RunAsync(async token => + { + var subscribeResult = await client.SubscribeAsync(subscribeBuilder.Build(), token); + if (subscribeResult.Items.Any(item => + item.ResultCode is MqttClientSubscribeResultCode.UnspecifiedError + or MqttClientSubscribeResultCode.NotAuthorized + or MqttClientSubscribeResultCode.TopicFilterInvalid)) + { + throw new ApiClientEventsException("MQTT subscription was not fully granted.") + { + CouldBeTransient = true, + IsHandled = false + }; + } + }, cancellationToken); + + await foreach (var received in channel.Reader.ReadAllAsync(cancellationToken)) + { + yield return received; + } + } + finally + { + channel?.Writer.TryComplete(); + + if (client is not null) + { + await DisconnectSafelyAsync(client, cancellationToken); + client.Dispose(); + } + } + } + + private static async Task DisconnectSafelyAsync(IMqttClient client, CancellationToken cancellationToken) + { + if (!client.IsConnected) + { + return; + } + + try + { + await client.DisconnectAsync(cancellationToken: cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // Best-effort disconnect during cleanup. + } + } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventSender.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventSender.cs new file mode 100644 index 0000000..706ba0b --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventSender.cs @@ -0,0 +1,98 @@ +using Microsoft.Extensions.Logging; +using MQTTnet; +using MQTTnet.Protocol; +using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; +using RedShirt.Example.Api.ClientEvents.Library.Core.Models; +using RedShirt.Example.Api.ClientEvents.Library.Core.Services; +using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Factories; +using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services.Resilience; +using System.Text.Json; + +namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; + +internal sealed class MqttApiClientEventSender( + IMqttClientFactory mqttClientFactory, + IMqttClientEventsRetryWrapperService retryWrapperService, + ILogger> logger) : IApiClientEventSender +{ + public Task SendAsync(ApiClientEventSendRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Topic)) + { + throw new ApiClientEventsException("An MQTT-shaped topic is required to publish a client event.") + { + CouldBeTransient = false, + IsHandled = false + }; + } + + return retryWrapperService.RunAsync(async token => + { + var payload = JsonSerializer.Serialize(request.Payload); + IMqttClient? client = null; + + try + { + client = await mqttClientFactory.CreateConnectedClientAsync(token); + var publishResult = await client.PublishAsync(new MqttApplicationMessageBuilder() + .WithTopic(request.Topic) + .WithPayload(payload) + .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) + .Build(), token); + + if (publishResult.ReasonCode is MqttClientPublishReasonCode.UnspecifiedError + or MqttClientPublishReasonCode.ImplementationSpecificError) + { + throw new ApiClientEventsException( + $"MQTT publish failed with reason code {publishResult.ReasonCode}.") + { + CouldBeTransient = true, + IsHandled = false + }; + } + } + catch (ApiClientEventsException) + { + throw; + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish MQTT client event to topic {Topic}", request.Topic); + throw new ApiClientEventsException(exception) + { + CouldBeTransient = true, + IsHandled = false + }; + } + finally + { + if (client is not null) + { + await DisconnectSafelyAsync(client, token); + client.Dispose(); + } + } + }, cancellationToken); + } + + private static async Task DisconnectSafelyAsync(IMqttClient client, CancellationToken cancellationToken) + { + if (!client.IsConnected) + { + return; + } + + try + { + await client.DisconnectAsync(cancellationToken: cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // Best-effort disconnect during cleanup. + } + } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsExceptionArbiterService.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsExceptionArbiterService.cs new file mode 100644 index 0000000..69347f4 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsExceptionArbiterService.cs @@ -0,0 +1,82 @@ +using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; +using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Models; +using RedShirt.Example.Api.Common.SecretManagers.Core.Exceptions; +using System.Net.Sockets; +using System.Text.Json; + +namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services.Resilience; + +/// +/// Classifies MQTT client-events exceptions for retry decisions. +/// +internal interface IMqttClientEventsExceptionArbiterService +{ + MqttClientEventsExceptionArbiterReport GetReport(Exception exception); +} + +/// +/// MQTT-oriented exception arbiter modelled after the connector / distributed arbiters: +/// known infrastructure failures may be transient; caller cancel and configuration errors are not. +/// +internal sealed class MqttClientEventsExceptionArbiterService : IMqttClientEventsExceptionArbiterService +{ + private static MqttClientEventsExceptionArbiterReport Fresh(bool isExpected, bool couldBeTransient) + { + return new MqttClientEventsExceptionArbiterReport + { + AlreadyHandled = false, + IsExpected = isExpected, + CouldBeTransient = couldBeTransient + }; + } + + private static MqttClientEventsExceptionArbiterReport Handled(bool isExpected, bool couldBeTransient) + { + return new MqttClientEventsExceptionArbiterReport + { + AlreadyHandled = true, + IsExpected = isExpected, + CouldBeTransient = couldBeTransient + }; + } + + private static MqttClientEventsExceptionArbiterReport ClassifyApiClientEventsException( + ApiClientEventsException exception) + { + if (exception.IsHandled) + { + return Handled(true, exception.CouldBeTransient); + } + + if (!exception.CouldBeTransient) + { + return Fresh(true, false); + } + + return Fresh(true, true); + } + + public MqttClientEventsExceptionArbiterReport GetReport(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + + while (exception is AggregateException {InnerExceptions.Count: 1, InnerException: not null} aggregate) + { + exception = aggregate.InnerException!; + } + + return exception switch + { + ApiClientEventsException clientEvents => ClassifyApiClientEventsException(clientEvents), + ApiSecretManagerException secretManager => + Handled(true, secretManager is {IsHandled: false, CouldBeTransient: true}), + SocketException + or TimeoutException => Fresh(true, true), + JsonException => Fresh(true, false), + TaskCanceledException => Fresh(true, true), + OperationCanceledException => Fresh(true, false), + ArgumentException => Fresh(true, false), + _ => Fresh(false, false) + }; + } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsRetryWrapperService.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsRetryWrapperService.cs new file mode 100644 index 0000000..285fa30 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsRetryWrapperService.cs @@ -0,0 +1,165 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Retry; +using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; +using RedShirt.Example.Api.Common.Services.Utility; + +namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services.Resilience; + +/// +/// Retries MQTT client-events operations that fail with expected transient exceptions, +/// then surfaces remaining failures as . +/// +internal interface IMqttClientEventsRetryWrapperService +{ + /// + /// Executes with retry for expected transient MQTT client-events failures. + /// + Task RunAsync(Func> func, CancellationToken cancellationToken = default); + + /// + /// Executes with retry for expected transient MQTT client-events failures. + /// + Task RunAsync(Func func, CancellationToken cancellationToken = default); +} + +/// +/// Polly v8-based retry wrapper for MQTT client-events calls. +/// Retries when reports an expected transient failure, +/// using exponential backoff via . +/// +internal sealed class MqttClientEventsRetryWrapperService( + IMqttClientEventsExceptionArbiterService exceptionArbiterService, + ILogger logger, + ISleepService sleepService, + IOptions options) + : IMqttClientEventsRetryWrapperService +{ + private const int DefaultRetryCount = 3; + + private ResiliencePipeline? _retryPipeline; + + private ResiliencePipeline GetRetryPipeline() + { + return _retryPipeline ??= new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = options.Value.EffectiveRetryCount, + ShouldHandle = args => + { + // ReSharper disable once DuplicatedSequentialIfBodies + if (args.Outcome.Exception is not { } exception) + { + return PredicateResult.False(); + } + + if (args.Context.CancellationToken.IsCancellationRequested) + { + return PredicateResult.False(); + } + + var report = exceptionArbiterService.GetReport(exception); + return report is {IsExpected: true, CouldBeTransient: true} + ? PredicateResult.True() + : PredicateResult.False(); + }, + DelayGenerator = static _ => new ValueTask(TimeSpan.Zero), + OnRetry = async args => + { + logger.LogWarning(args.Outcome.Exception, + "Retrying MQTT client-events operation after attempt {AttemptNumber}", + args.AttemptNumber); + await sleepService.DelayAsync(TimeSpan.FromSeconds(Math.Pow(2, args.AttemptNumber)), + args.Context.CancellationToken); + } + }) + .Build(); + } + + private bool TryGetWrappedException(Exception exception, out Exception? wrappedException) + { + wrappedException = null; + + var report = exceptionArbiterService.GetReport(exception); + + if (report.AlreadyHandled && exception is ApiClientEventsException) + { + return false; + } + + if (!report.IsExpected) + { + return false; + } + + wrappedException = new ApiClientEventsException(exception) + { + CouldBeTransient = report.CouldBeTransient, + IsHandled = true + }; + + return true; + } + + public async Task RunAsync(Func> func, + CancellationToken cancellationToken = default) + { + try + { + return await GetRetryPipeline().ExecuteAsync( + async token => await func(token), + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + if (TryGetWrappedException(exception, out var wrappedException) && wrappedException is not null) + { + throw wrappedException; + } + + throw; + } + } + + public async Task RunAsync(Func func, CancellationToken cancellationToken = default) + { + try + { + await GetRetryPipeline().ExecuteAsync( + async token => await func(token), + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + if (TryGetWrappedException(exception, out var wrappedException) && wrappedException is not null) + { + throw wrappedException; + } + + throw; + } + } + + internal sealed class ConfigurationModel + { + /// + /// Maximum number of retry attempts for expected transient MQTT client-events failures. + /// When null, is used. + /// + public required int? RetryCount { get; init; } + + /// + /// Effective retry attempt count (floored at zero). + /// + public int EffectiveRetryCount => Math.Max(0, RetryCount ?? DefaultRetryCount); + } +} diff --git a/src/RedShirt.Example.Api.Core/RedShirt.Example.Api.Core.csproj b/src/RedShirt.Example.Api.Core/RedShirt.Example.Api.Core.csproj index d41754d..0719031 100644 --- a/src/RedShirt.Example.Api.Core/RedShirt.Example.Api.Core.csproj +++ b/src/RedShirt.Example.Api.Core/RedShirt.Example.Api.Core.csproj @@ -24,6 +24,7 @@ + diff --git a/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommand.cs b/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommand.cs new file mode 100644 index 0000000..dd32fa7 --- /dev/null +++ b/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommand.cs @@ -0,0 +1,3 @@ +namespace RedShirt.Example.Api.Core.UseCases.Messages.Commands.Send; + +public sealed record SendExampleMessageCommand(string UserId, string Message); diff --git a/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandHandler.cs b/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandHandler.cs new file mode 100644 index 0000000..9277acc --- /dev/null +++ b/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandHandler.cs @@ -0,0 +1,21 @@ +using RedShirt.Example.Api.ClientEvents.Domains.Example.Models; +using RedShirt.Example.Api.ClientEvents.Domains.Example.Services; +using RedShirt.Example.Api.Core.Cqrs; + +namespace RedShirt.Example.Api.Core.UseCases.Messages.Commands.Send; + +public interface ISendExampleMessageCommandHandler : ICqrsHandler; + +internal sealed class SendExampleMessageCommandHandler( + IExampleMessageSendService exampleMessageSendService, + ICoreRequestValidator coreRequestValidator) : ISendExampleMessageCommandHandler +{ + public async Task Handle(SendExampleMessageCommand command, CancellationToken cancellationToken = default) + { + await coreRequestValidator.ValidateAsync(command, cancellationToken); + await exampleMessageSendService.SendToUserAsync(command.UserId, new ExampleMessageModel + { + Message = command.Message + }, cancellationToken); + } +} diff --git a/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandValidator.cs b/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandValidator.cs new file mode 100644 index 0000000..79ffc5b --- /dev/null +++ b/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; + +namespace RedShirt.Example.Api.Core.UseCases.Messages.Commands.Send; + +public sealed class SendExampleMessageCommandValidator : AbstractValidator +{ + public SendExampleMessageCommandValidator() + { + RuleFor(command => command.UserId) + .Must(userId => !string.IsNullOrWhiteSpace(userId)) + .WithMessage("UserId is required"); + + RuleFor(command => command.Message) + .Must(message => !string.IsNullOrWhiteSpace(message)) + .WithMessage("Message is required"); + } +} diff --git a/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQuery.cs b/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQuery.cs new file mode 100644 index 0000000..3c589ea --- /dev/null +++ b/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQuery.cs @@ -0,0 +1,3 @@ +namespace RedShirt.Example.Api.Core.UseCases.Messages.Queries.Stream; + +public sealed record StreamExampleMessagesQuery(string UserId); diff --git a/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryHandler.cs b/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryHandler.cs new file mode 100644 index 0000000..07fa22c --- /dev/null +++ b/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryHandler.cs @@ -0,0 +1,19 @@ +using RedShirt.Example.Api.ClientEvents.Domains.Example.Models; +using RedShirt.Example.Api.ClientEvents.Domains.Example.Services; +using RedShirt.Example.Api.Core.Cqrs; + +namespace RedShirt.Example.Api.Core.UseCases.Messages.Queries.Stream; + +public interface IStreamExampleMessagesQueryHandler : ICqrsHandler>; + +internal sealed class StreamExampleMessagesQueryHandler( + IExampleMessageReceiveService exampleMessageReceiveService, + ICoreRequestValidator coreRequestValidator) : IStreamExampleMessagesQueryHandler +{ + public async Task> Handle(StreamExampleMessagesQuery query, + CancellationToken cancellationToken = default) + { + await coreRequestValidator.ValidateAsync(query, cancellationToken); + return exampleMessageReceiveService.ReceiveForUserAsync(query.UserId, cancellationToken); + } +} diff --git a/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryValidator.cs b/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryValidator.cs new file mode 100644 index 0000000..aef32c8 --- /dev/null +++ b/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace RedShirt.Example.Api.Core.UseCases.Messages.Queries.Stream; + +public sealed class StreamExampleMessagesQueryValidator : AbstractValidator +{ + public StreamExampleMessagesQueryValidator() + { + RuleFor(query => query.UserId) + .Must(userId => !string.IsNullOrWhiteSpace(userId)) + .WithMessage("UserId is required"); + } +} diff --git a/src/RedShirt.Example.Api/Controllers/MessageController.cs b/src/RedShirt.Example.Api/Controllers/MessageController.cs new file mode 100644 index 0000000..dd7fb5c --- /dev/null +++ b/src/RedShirt.Example.Api/Controllers/MessageController.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using RedShirt.Example.Api.Attributes.Authorization; +using RedShirt.Example.Api.ClientEvents.Domains.Example.Models; +using RedShirt.Example.Api.Authorization.Extensions; +using RedShirt.Example.Api.Constants; +using RedShirt.Example.Api.Core.UseCases.Messages.Commands.Send; +using RedShirt.Example.Api.Core.UseCases.Messages.Queries.Stream; +using RedShirt.Example.Api.Models.Messages; +using System.Runtime.CompilerServices; + +namespace RedShirt.Example.Api.Controllers; + +[ApiController] +[EnableRateLimiting(RateLimitingConstants.PolicyHeaderDefault)] +[Route("messages")] +public class MessageController : ControllerBase +{ + [HttpPost] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task Post( + [FromBody] + ExampleMessagePostRequest request, + [FromServices] + ISendExampleMessageCommandHandler sendExampleMessageCommandHandler, + CancellationToken cancellationToken) + { + if (!User.TryGetUserId(out var userId)) + { + return BadRequest("Authenticated user id is required."); + } + + await sendExampleMessageCommandHandler.Handle(new SendExampleMessageCommand(userId, request.Message), + cancellationToken); + + return Accepted(); + } + + [HttpGet("event-stream")] + [ApproveReadOnly] + [Produces("text/event-stream")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task GetEventStream( + [FromServices] + IStreamExampleMessagesQueryHandler streamExampleMessagesQueryHandler, + CancellationToken cancellationToken) + { + if (!User.TryGetUserId(out var userId)) + { + return TypedResults.BadRequest("Authenticated user id is required."); + } + + var messageStream = + await streamExampleMessagesQueryHandler.Handle(new StreamExampleMessagesQuery(userId), cancellationToken); + + return TypedResults.ServerSentEvents( + ToServerSentEvents(messageStream, cancellationToken), + eventType: "message"); + } + + private static async IAsyncEnumerable ToServerSentEvents( + IAsyncEnumerable messages, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var message in messages.WithCancellation(cancellationToken)) + { + yield return message.Message; + } + } +} diff --git a/src/RedShirt.Example.Api/ExceptionHandlers/ApiExceptionHandler.cs b/src/RedShirt.Example.Api/ExceptionHandlers/ApiExceptionHandler.cs index 67d10e3..035b15e 100644 --- a/src/RedShirt.Example.Api/ExceptionHandlers/ApiExceptionHandler.cs +++ b/src/RedShirt.Example.Api/ExceptionHandlers/ApiExceptionHandler.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; +using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; using RedShirt.Example.Api.Common.Exceptions.Responses; using RedShirt.Example.Api.Connectors.Bar.Core.Exceptions; using RedShirt.Example.Api.Connectors.Foo.Core.Exceptions; @@ -42,6 +43,7 @@ private static bool TryMapException(Exception exception, out int statusCode, out return true; case FooConnectorException: case BarConnectorException: + case ApiClientEventsException: statusCode = StatusCodes.Status502BadGateway; title = "Bad Gateway"; return true; diff --git a/src/RedShirt.Example.Api/Extensions/ServiceCollectionExtensions.cs b/src/RedShirt.Example.Api/Extensions/ServiceCollectionExtensions.cs index 98124b1..6424c1b 100644 --- a/src/RedShirt.Example.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/RedShirt.Example.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,3 +1,5 @@ +using RedShirt.Example.Api.ClientEvents.Domains.Example.Extensions; +using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Extensions; using RedShirt.Example.Api.Common.Aws.SsmSecretManager.Extensions; using RedShirt.Example.Api.Common.Distributed.Extensions; using RedShirt.Example.Api.Common.Extensions; @@ -58,6 +60,8 @@ internal static IServiceCollection ConfigureApiServices(this IServiceCollection .AddCustomers(configuration) // Customer store via Entity Framework Core (MariaDB / MySQL). .AddOrders() .AddProducts(configuration) - .AddUploads(configuration); + .AddUploads(configuration) + .AddMqttClientEvents(configuration) + .AddExampleClientEventsDomain(); } } \ No newline at end of file diff --git a/src/RedShirt.Example.Api/Models/Messages/ExampleMessagePostRequest.cs b/src/RedShirt.Example.Api/Models/Messages/ExampleMessagePostRequest.cs new file mode 100644 index 0000000..5826c67 --- /dev/null +++ b/src/RedShirt.Example.Api/Models/Messages/ExampleMessagePostRequest.cs @@ -0,0 +1,6 @@ +namespace RedShirt.Example.Api.Models.Messages; + +public sealed class ExampleMessagePostRequest +{ + public required string Message { get; init; } +} diff --git a/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj b/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj index 7a37681..bbfd6b4 100644 --- a/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj +++ b/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj @@ -33,6 +33,9 @@ + + + diff --git a/swagger/RedShirt.Example.Api.Interop/swagger.json b/swagger/RedShirt.Example.Api.Interop/swagger.json index 1a63f6e..7c9bb6d 100644 --- a/swagger/RedShirt.Example.Api.Interop/swagger.json +++ b/swagger/RedShirt.Example.Api.Interop/swagger.json @@ -830,6 +830,70 @@ } } }, + "/messages": { + "post": { + "tags": [ + "Message" + ], + "operationId": "Message_Post", + "requestBody": { + "x-name": "request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExampleMessagePostRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "202": { + "description": "" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "" + } + } + } + }, + "/messages/event-stream": { + "get": { + "tags": [ + "Message" + ], + "operationId": "Message_GetEventStream", + "responses": { + "200": { + "description": "" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "" + } + } + } + }, "/orders/{id}": { "delete": { "tags": [ @@ -2544,6 +2608,18 @@ } } }, + "ExampleMessagePostRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, "OrderDto": { "type": "object", "additionalProperties": false, diff --git a/test/local/README.md b/test/local/README.md index 2c9b1a3..5f109f0 100644 --- a/test/local/README.md +++ b/test/local/README.md @@ -383,3 +383,46 @@ To update the client secret in SSM *and* WireMock's in-memory stubs (token bodyP ``` This only updates in-memory WireMock stubs. Restarting `wiremock-bar` restores the mapping files under `wiremock/bar/`. After the script finishes, call `GET /bar/{id}` or `POST /bar` again — the connector should 401 once with the old bearer, refresh client credentials + token, then succeed with the rotated bearer. + +## Messages (Server-Sent Events) + +The API publishes example message events to MiniStack's embedded IoT MQTT broker and streams them to clients over Server-Sent Events at `GET /messages/event-stream`. Each authenticated user receives events on the MQTT topic `example-message/user/{userId}` derived from the JWT subject. + +Local Compose points the API at MiniStack's anonymous MQTT-over-WebSocket endpoint (`ws://ministack:4566`). Username and password secret paths are intentionally `null` so the MQTT client connects without credentials. + +| Variable | Local default | +|---|---| +| `CLIENT_EVENTS__MQTT__BROKER_URL` | `ws://ministack:4566` | +| `CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX` | `RedShirt.Example.Api` | +| `CLIENT_EVENTS__MQTT__USERNAME_SECRET_PATH` | `null` | +| `CLIENT_EVENTS__MQTT__PASSWORD_SECRET_PATH` | `null` | +| `CLIENT_EVENTS__MQTT__RETRY_COUNT` | `3` | + +MiniStack also exposes native MQTT over TLS on port `8883` when needed; the compose file maps that port for host-side tooling. + +### Scripts + +Mock Python scripts under `scripts/messages/` exercise the event flow locally. Run them from the `test/local` directory: + +```bash +chmod +x ./scripts/messages/*.py # once +export API_JWT_TOKEN="$(./get-bearer-token.py)" +``` + +In one terminal, listen for events: + +```bash +./scripts/messages/listen-messages.py +``` + +In another terminal, publish a message (connected clients for the same user should receive it immediately): + +```bash +./scripts/messages/send-message.py "Hello over SSE" +``` + +Notes: + +* Both endpoints require a bearer token with `api:write` for `POST /messages` and `api:read` for `GET /messages/event-stream`. +* Events are scoped to the authenticated user's id (`sub` / name identifier claim). +* If MQTT connectivity fails, the API returns **502 Bad Gateway** with a problem-details body. diff --git a/test/local/docker-compose.yaml b/test/local/docker-compose.yaml index 04f9c3a..2079572 100644 --- a/test/local/docker-compose.yaml +++ b/test/local/docker-compose.yaml @@ -3,6 +3,7 @@ services: image: ministackorg/ministack:latest ports: - 4566:4566 + - 8883:8883 redis: image: redis:latest @@ -171,3 +172,10 @@ services: UPLOADS__BUCKET_VERIFIED_ITEMS: "${UPLOADS__BUCKET_VERIFIED_ITEMS-verified-uploads}" UPLOADS__MAX_UPLOAD_SIZE_BYTES: "${UPLOADS__MAX_UPLOAD_SIZE_BYTES-}" UPLOADS__PRESIGNED_URL_LIFETIME_MINUTES: "${UPLOADS__PRESIGNED_URL_LIFETIME_MINUTES-15}" + # Client events (MiniStack IoT MQTT broker) + ## + CLIENT_EVENTS__MQTT__BROKER_URL: "${CLIENT_EVENTS__MQTT__BROKER_URL-ws://ministack:4566}" + CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX: "${CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX-RedShirt.Example.Api}" + CLIENT_EVENTS__MQTT__USERNAME_SECRET_PATH: null + CLIENT_EVENTS__MQTT__PASSWORD_SECRET_PATH: null + CLIENT_EVENTS__MQTT__RETRY_COUNT: "${CLIENT_EVENTS__MQTT__RETRY_COUNT-3}" diff --git a/test/local/scripts/messages/listen-messages.py b/test/local/scripts/messages/listen-messages.py new file mode 100755 index 0000000..c70af0c --- /dev/null +++ b/test/local/scripts/messages/listen-messages.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Listen to GET /messages/event-stream and print each example-message event.""" + +from __future__ import annotations + +import sys +import urllib.error +import urllib.request + +from messages_script_common import ( + create_parser, + get_api_base_url, + require_api_jwt_token, +) + + +def parse_sse_stream(response: urllib.response.addinfourl) -> None: + event_name = None + data_lines: list[str] = [] + + while True: + line = response.readline() + if not line: + break + + text = line.decode("utf-8", errors="replace").rstrip("\r\n") + if not text: + if data_lines: + payload = "\n".join(data_lines) + label = event_name or "message" + print(f"[{label}] {payload}") + sys.stdout.flush() + event_name = None + data_lines = [] + continue + + if text.startswith(":"): + continue + + if text.startswith("event:"): + event_name = text[len("event:") :].strip() + continue + + if text.startswith("data:"): + data_lines.append(text[len("data:") :].strip()) + continue + + +def main() -> int: + parser = create_parser( + "Listen to GET /messages/event-stream and print each example-message event" + ) + args = parser.parse_args() + _ = args + + token = require_api_jwt_token() + base_url = get_api_base_url() + url = f"{base_url.rstrip('/')}/messages/event-stream" + + request = urllib.request.Request( + url, + method="GET", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "text/event-stream", + }, + ) + + print(f"Listening on {url} (Ctrl+C to stop)...", flush=True) + + try: + with urllib.request.urlopen(request, timeout=None) as response: + parse_sse_stream(response) + except KeyboardInterrupt: + print("\nStopped.", flush=True) + return 0 + except urllib.error.HTTPError as error: + body_text = error.read().decode("utf-8", errors="replace") + print(f"HTTP {error.code}: {body_text}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/local/scripts/messages/messages_script_common.py b/test/local/scripts/messages/messages_script_common.py new file mode 100755 index 0000000..c529293 --- /dev/null +++ b/test/local/scripts/messages/messages_script_common.py @@ -0,0 +1,39 @@ +"""Shared helpers for local message event-stream test scripts.""" + +from __future__ import annotations + +import argparse +import os + +DEFAULT_API_BASE = "http://localhost:9000" +API_JWT_TOKEN_ENV = "API_JWT_TOKEN" +API_BASE_URL_ENV = "API_BASE_URL" + +ENV_HELP_EPILOG = f"""\ +environment variables: + {API_JWT_TOKEN_ENV} Bearer access token (required). Populate from get-bearer-token.py, e.g.: + export {API_JWT_TOKEN_ENV}="$(./test/local/get-bearer-token.py)" + {API_BASE_URL_ENV} API base URL (default: {DEFAULT_API_BASE}) +""" + + +def create_parser(description: str) -> argparse.ArgumentParser: + return argparse.ArgumentParser( + description=description, + epilog=ENV_HELP_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + +def require_api_jwt_token() -> str: + token = os.environ.get(API_JWT_TOKEN_ENV) + if not token: + raise SystemExit( + f"Set {API_JWT_TOKEN_ENV} to a bearer access token " + f'(see --help; e.g. export {API_JWT_TOKEN_ENV}="$(./test/local/get-bearer-token.py)").' + ) + return token + + +def get_api_base_url() -> str: + return os.environ.get(API_BASE_URL_ENV, DEFAULT_API_BASE) diff --git a/test/local/scripts/messages/send-message.py b/test/local/scripts/messages/send-message.py new file mode 100755 index 0000000..ec0bede --- /dev/null +++ b/test/local/scripts/messages/send-message.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Publish an example message event via POST /messages.""" + +from __future__ import annotations + +import json +import sys +import urllib.error +import urllib.request + +from messages_script_common import ( + create_parser, + get_api_base_url, + require_api_jwt_token, +) + + +def main() -> int: + parser = create_parser("Publish an example message event via POST /messages") + parser.add_argument( + "message", + nargs="?", + default="Hello from local test script", + help="Message text to publish (default: Hello from local test script)", + ) + args = parser.parse_args() + + token = require_api_jwt_token() + base_url = get_api_base_url() + url = f"{base_url.rstrip('/')}/messages" + body = json.dumps({"message": args.message}).encode("utf-8") + + request = urllib.request.Request( + url, + data=body, + method="POST", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + }, + ) + + try: + with urllib.request.urlopen(request, timeout=30) as response: + print(f"HTTP {response.status} {response.reason}") + except urllib.error.HTTPError as error: + body_text = error.read().decode("utf-8", errors="replace") + print(f"HTTP {error.code}: {body_text}", file=sys.stderr) + return 1 + + print(f"Published message for the authenticated user: {args.message!r}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 3898a176e00ef10ed63bbaa0e61c7afa58bcad55 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Fri, 4 Sep 2026 18:06:13 -0700 Subject: [PATCH 03/31] readme fmt --- test/local/README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/local/README.md b/test/local/README.md index 5f109f0..3c9bb07 100644 --- a/test/local/README.md +++ b/test/local/README.md @@ -390,13 +390,13 @@ The API publishes example message events to MiniStack's embedded IoT MQTT broker Local Compose points the API at MiniStack's anonymous MQTT-over-WebSocket endpoint (`ws://ministack:4566`). Username and password secret paths are intentionally `null` so the MQTT client connects without credentials. -| Variable | Local default | -|---|---| -| `CLIENT_EVENTS__MQTT__BROKER_URL` | `ws://ministack:4566` | -| `CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX` | `RedShirt.Example.Api` | -| `CLIENT_EVENTS__MQTT__USERNAME_SECRET_PATH` | `null` | -| `CLIENT_EVENTS__MQTT__PASSWORD_SECRET_PATH` | `null` | -| `CLIENT_EVENTS__MQTT__RETRY_COUNT` | `3` | +| Variable | Local default | +|---------------------------------------------|------------------------| +| `CLIENT_EVENTS__MQTT__BROKER_URL` | `ws://ministack:4566` | +| `CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX` | `RedShirt.Example.Api` | +| `CLIENT_EVENTS__MQTT__USERNAME_SECRET_PATH` | `null` | +| `CLIENT_EVENTS__MQTT__PASSWORD_SECRET_PATH` | `null` | +| `CLIENT_EVENTS__MQTT__RETRY_COUNT` | `3` | MiniStack also exposes native MQTT over TLS on port `8883` when needed; the compose file maps that port for host-side tooling. From 6094e570d78d603a4ba667e384298b0440ad64f4 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Fri, 4 Sep 2026 22:42:54 -0700 Subject: [PATCH 04/31] Hacky-smelling fixes to get local testing working. --- .../Extensions/ServiceCollectionExtensions.cs | 5 +- .../Factories/MqttClientFactory.cs | 210 +++++++++++++----- ...ntEvents.Library.MqttImplementation.csproj | 2 + test/local/README.md | 24 +- test/local/docker-compose.yaml | 11 +- 5 files changed, 186 insertions(+), 66 deletions(-) diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs index 1ec7aaf..a3c9fcd 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Amazon.IoT; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -5,6 +6,7 @@ using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Factories; using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services.Resilience; +using RedShirt.Example.Api.Common.Aws.Extensions; using RedShirt.Example.Api.Common.Extensions; namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Extensions; @@ -18,6 +20,7 @@ public static IServiceCollection AddMqttClientEvents(this IServiceCollection ser { services .AddCommonServices() + .AddAwsServiceWithLocalSupport() .Configure( configuration.GetSection(ConfigurationSectionName)) .Configure( @@ -31,4 +34,4 @@ public static IServiceCollection AddMqttClientEvents(this IServiceCollection ser return services; } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs index 45f26e9..752611a 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs @@ -1,9 +1,11 @@ +using Amazon.IoT; +using Amazon.IoT.Model; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MQTTnet; +using MQTTnet.Formatter; using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; using RedShirt.Example.Api.Common.SecretManagers.Core.Services; -using System.Net.Sockets; namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Factories; @@ -15,68 +17,57 @@ internal interface IMqttClientFactory internal sealed class ApiMqttClientFactory( IOptions configuration, ISecretManagerService secretManagerService, + IAmazonIoT amazonIoT, ILogger logger) : IMqttClientFactory { private const string DefaultClientIdPrefix = "RedShirt.Example.Api"; + private const string DefaultWebSocketPath = "/mqtt"; + private const string MqttWebSocketSubProtocol = "mqtt"; private static readonly string ServerHostname = string.IsNullOrWhiteSpace(Environment.MachineName) ? "unknown" : Environment.MachineName; - public async Task CreateConnectedClientAsync(CancellationToken cancellationToken) + private async Task ResolveBrokerTargetAsync(ConfigurationModel config, + CancellationToken cancellationToken) { - var config = configuration.Value; - if (string.IsNullOrWhiteSpace(config.BrokerUrl)) + if (!config.ResolveBrokerAddressFromDescribeEndpoint) { - throw new ApiClientEventsException("ClientEvents MQTT broker URL is not configured.") + return new MqttBrokerTarget { - CouldBeTransient = false, - IsHandled = false + BrokerUrl = config.BrokerUrl }; } - var mqttFactory = new MqttClientFactory(); - var client = mqttFactory.CreateMqttClient(); - var optionsBuilder = new MqttClientOptionsBuilder() - .WithClientId($"{config.EffectiveClientIdPrefix}/{ServerHostname}/{Guid.NewGuid():N}") - .WithCleanSession(); - - ApplyBrokerAddress(optionsBuilder, config.BrokerUrl); - - if (await TryResolveCredentialsAsync(config, cancellationToken) is { } credentials) + var describeResponse = await amazonIoT.DescribeEndpointAsync(new DescribeEndpointRequest { - optionsBuilder.WithCredentials(credentials.Username, credentials.Password); - } + EndpointType = "iot:Data-ATS" + }, cancellationToken); - try + if (string.IsNullOrWhiteSpace(describeResponse.EndpointAddress)) { - var connectResult = await client.ConnectAsync(optionsBuilder.Build(), cancellationToken); - if (connectResult.ResultCode != MqttClientConnectResultCode.Success) - { - throw new ApiClientEventsException( - $"MQTT broker connection failed with result code {connectResult.ResultCode}.") - { - CouldBeTransient = true, - IsHandled = false - }; - } - } - catch (ApiClientEventsException) - { - client.Dispose(); - throw; - } - catch (Exception exception) - { - client.Dispose(); - logger.LogError(exception, "Failed to connect to MQTT broker at {BrokerUrl}", config.BrokerUrl); - throw new ApiClientEventsException(exception) + throw new ApiClientEventsException("IoT DescribeEndpoint did not return an endpoint address.") { CouldBeTransient = true, IsHandled = false }; } - return client; + var endpointAddress = describeResponse.EndpointAddress; + var endpointUri = BuildUriFromHostPort(endpointAddress, DefaultWebSocketPath); + var connectHost = string.IsNullOrWhiteSpace(config.BrokerConnectHost) + ? endpointUri.Host + : config.BrokerConnectHost; + + var brokerUrl = new UriBuilder(endpointUri.Scheme, connectHost, endpointUri.Port, endpointUri.AbsolutePath) + .Uri + .ToString(); + + var needsHostHeader = !connectHost.Equals(endpointUri.Host, StringComparison.OrdinalIgnoreCase); + return new MqttBrokerTarget + { + BrokerUrl = brokerUrl, + WebSocketHostHeader = needsHostHeader ? endpointAddress : null + }; } /// @@ -84,36 +75,48 @@ public async Task CreateConnectedClientAsync(CancellationToken canc /// /// /// MQTTnet does not connect from a single opaque URL string. Each transport must be configured explicitly - /// ( for native MQTT, + /// ( for native MQTT, /// /// for MQTT-over-WebSocket). Deployment environments also use different schemes for the same broker /// (for example ws:// against MiniStack locally and mqtts:// in production), so this helper /// normalizes the configured URL into the builder call the transport requires. + /// MQTTnet WebSocket transport expects a full ws:// or wss:// URI (including the + /// /mqtt path and mqtt subprotocol). MiniStack (and AWS IoT) route upgrades by the IoT + /// endpoint hostname returned from DescribeEndpoint, not a bare gateway host name. /// - private static void ApplyBrokerAddress(MqttClientOptionsBuilder optionsBuilder, string brokerUrl) + private static void ApplyBrokerAddress( + MqttClientOptionsBuilder optionsBuilder, + string brokerUrl, + string? webSocketHostHeader) { - // Host-only values (no scheme) are treated as a TCP host name for plain MQTT. if (!Uri.TryCreate(brokerUrl, UriKind.Absolute, out var uri)) { optionsBuilder.WithTcpServer(brokerUrl); return; } - // WebSocket transports: MQTTnet expects "host[:port][/path]" without the ws/wss scheme prefix. if (uri.Scheme.Equals("ws", StringComparison.OrdinalIgnoreCase) || uri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase)) { - var webSocketUri = uri.IsDefaultPort - ? $"{uri.Host}{uri.PathAndQuery}" - : $"{uri.Host}:{uri.Port}{uri.PathAndQuery}"; - + var webSocketUri = BuildWebSocketUri(uri); optionsBuilder.WithWebSocketServer(webSocketBuilder => - webSocketBuilder.WithUri(webSocketUri)); + { + webSocketBuilder + .WithUri(webSocketUri) + .WithSubProtocols([MqttWebSocketSubProtocol]); + + if (!string.IsNullOrWhiteSpace(webSocketHostHeader)) + { + webSocketBuilder.WithRequestHeaders(new Dictionary + { + ["Host"] = webSocketHostHeader + }); + } + }); + return; } - // Native MQTT over TCP/TLS: map mqtt/mqtts/tcp URIs to host and port (default 1883 when omitted). - // ReSharper disable once InvertIf if (uri.Scheme.Equals("tcp", StringComparison.OrdinalIgnoreCase) || uri.Scheme.Equals("mqtt", StringComparison.OrdinalIgnoreCase) || uri.Scheme.Equals("mqtts", StringComparison.OrdinalIgnoreCase)) @@ -129,6 +132,29 @@ private static void ApplyBrokerAddress(MqttClientOptionsBuilder optionsBuilder, }; } + private static string BuildWebSocketUri(Uri uri) + { + var path = string.IsNullOrEmpty(uri.AbsolutePath) || uri.AbsolutePath == "/" + ? DefaultWebSocketPath + : uri.AbsolutePath; + + return new UriBuilder(uri.Scheme, uri.Host, uri.Port, path).Uri.ToString(); + } + + private static Uri BuildUriFromHostPort(string hostPort, string path) + { + if (Uri.TryCreate($"ws://{hostPort}{path}", UriKind.Absolute, out var uri)) + { + return uri; + } + + throw new ApiClientEventsException($"IoT endpoint address '{hostPort}' is not a valid host:port value.") + { + CouldBeTransient = false, + IsHandled = false + }; + } + private async Task TryResolveCredentialsAsync(ConfigurationModel config, CancellationToken cancellationToken) { @@ -159,6 +185,70 @@ private static void ApplyBrokerAddress(MqttClientOptionsBuilder optionsBuilder, }; } + public async Task CreateConnectedClientAsync(CancellationToken cancellationToken) + { + var config = configuration.Value; + var brokerTarget = await ResolveBrokerTargetAsync(config, cancellationToken); + if (string.IsNullOrWhiteSpace(brokerTarget.BrokerUrl)) + { + throw new ApiClientEventsException("ClientEvents MQTT broker URL is not configured.") + { + CouldBeTransient = false, + IsHandled = false + }; + } + + var mqttFactory = new MqttClientFactory(); + var client = mqttFactory.CreateMqttClient(); + var optionsBuilder = new MqttClientOptionsBuilder() + .WithClientId($"{config.EffectiveClientIdPrefix}/{ServerHostname}/{Guid.NewGuid():N}") + .WithCleanSession() + .WithProtocolVersion(MqttProtocolVersion.V311); + + ApplyBrokerAddress(optionsBuilder, brokerTarget.BrokerUrl, brokerTarget.WebSocketHostHeader); + + if (await TryResolveCredentialsAsync(config, cancellationToken) is { } credentials) + { + optionsBuilder.WithCredentials(credentials.Username, credentials.Password); + } + + try + { + var connectResult = await client.ConnectAsync(optionsBuilder.Build(), cancellationToken); + if (connectResult.ResultCode != MqttClientConnectResultCode.Success) + { + throw new ApiClientEventsException( + $"MQTT broker connection failed with result code {connectResult.ResultCode}.") + { + CouldBeTransient = true, + IsHandled = false + }; + } + } + catch (ApiClientEventsException) + { + throw; + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to connect to MQTT broker at {BrokerUrl}", brokerTarget.BrokerUrl); + throw new ApiClientEventsException(exception) + { + CouldBeTransient = true, + IsHandled = false + }; + } + + return client; + } + + private sealed class MqttBrokerTarget + { + public string? BrokerUrl { get; init; } + + public string? WebSocketHostHeader { get; init; } + } + private sealed class MqttBrokerCredentials { public required string Username { get; init; } @@ -176,6 +266,20 @@ internal sealed class ConfigurationModel public string? BrokerUrl { get; init; } + /// + /// When true, resolves the WebSocket broker address via IoT DescribeEndpoint instead of using + /// verbatim. Required for MiniStack and AWS IoT WebSocket clients. + /// + public bool ResolveBrokerAddressFromDescribeEndpoint { get; init; } + + /// + /// Optional TCP/WebSocket connect host when is enabled. + /// Use this in Docker when the IoT endpoint hostname (for example *.localhost) does not resolve to the + /// MiniStack container; the factory connects to this host and sends the DescribeEndpoint value as the HTTP + /// Host header. + /// + public string? BrokerConnectHost { get; init; } + public string? UsernameSecretPath { get; init; } public string? PasswordSecretPath { get; init; } @@ -183,4 +287,4 @@ internal sealed class ConfigurationModel public string EffectiveClientIdPrefix => string.IsNullOrWhiteSpace(ClientIdPrefix) ? DefaultClientIdPrefix : ClientIdPrefix; } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.csproj b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.csproj index 62a4db0..88514ef 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.csproj +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.csproj @@ -9,12 +9,14 @@ + + diff --git a/test/local/README.md b/test/local/README.md index 3c9bb07..5c6116b 100644 --- a/test/local/README.md +++ b/test/local/README.md @@ -388,17 +388,19 @@ This only updates in-memory WireMock stubs. Restarting `wiremock-bar` restores t The API publishes example message events to MiniStack's embedded IoT MQTT broker and streams them to clients over Server-Sent Events at `GET /messages/event-stream`. Each authenticated user receives events on the MQTT topic `example-message/user/{userId}` derived from the JWT subject. -Local Compose points the API at MiniStack's anonymous MQTT-over-WebSocket endpoint (`ws://ministack:4566`). Username and password secret paths are intentionally `null` so the MQTT client connects without credentials. - -| Variable | Local default | -|---------------------------------------------|------------------------| -| `CLIENT_EVENTS__MQTT__BROKER_URL` | `ws://ministack:4566` | -| `CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX` | `RedShirt.Example.Api` | -| `CLIENT_EVENTS__MQTT__USERNAME_SECRET_PATH` | `null` | -| `CLIENT_EVENTS__MQTT__PASSWORD_SECRET_PATH` | `null` | -| `CLIENT_EVENTS__MQTT__RETRY_COUNT` | `3` | - -MiniStack also exposes native MQTT over TLS on port `8883` when needed; the compose file maps that port for host-side tooling. +Local Compose resolves the MQTT broker address from IoT `DescribeEndpoint` (the same hostname AWS SDK clients use). MiniStack routes WebSocket upgrades by that IoT endpoint hostname, not by the bare gateway host `ministack:4566`; connecting without it returns HTTP `403` instead of `101 Switching Protocols`. The API connects to `ministack:4566` and sends the DescribeEndpoint value as the HTTP `Host` header. Username and password secret paths are intentionally `null` so the MQTT client connects without credentials. + +| Variable | Local default | +|-----------------------------------------------------------------------|------------------------| +| `CLIENT_EVENTS__MQTT__RESOLVE_BROKER_ADDRESS_FROM_DESCRIBE_ENDPOINT` | `true` | +| `CLIENT_EVENTS__MQTT__BROKER_CONNECT_HOST` | unset (connect to DescribeEndpoint host) | +| `CLIENT_EVENTS__MQTT__BROKER_URL` | unset (use DescribeEndpoint) | +| `CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX` | `RedShirt.Example.Api` | +| `CLIENT_EVENTS__MQTT__USERNAME_SECRET_PATH` | `null` | +| `CLIENT_EVENTS__MQTT__PASSWORD_SECRET_PATH` | `null` | +| `CLIENT_EVENTS__MQTT__RETRY_COUNT` | `3` | + +MiniStack advertises IoT endpoint hostnames under `MINISTACK_HOST` (set to `ministack` in compose). Docker maps that hostname to the MiniStack container via a network alias in `docker-compose.yaml`; if you reset MiniStack state, run `aws iot describe-endpoint` and update the alias to match. MiniStack also exposes native MQTT over TLS on port `8883` when needed; the compose file maps that port for host-side tooling. ### Scripts diff --git a/test/local/docker-compose.yaml b/test/local/docker-compose.yaml index 2079572..a723c99 100644 --- a/test/local/docker-compose.yaml +++ b/test/local/docker-compose.yaml @@ -4,6 +4,13 @@ services: ports: - 4566:4566 - 8883:8883 + environment: + MINISTACK_HOST: ministack + networks: + default: + aliases: + # Must match `aws iot describe-endpoint` (account-specific prefix persists in MiniStack state). + - f7b11509f4d675-ats.iot.us-east-1.ministack redis: image: redis:latest @@ -174,7 +181,9 @@ services: UPLOADS__PRESIGNED_URL_LIFETIME_MINUTES: "${UPLOADS__PRESIGNED_URL_LIFETIME_MINUTES-15}" # Client events (MiniStack IoT MQTT broker) ## - CLIENT_EVENTS__MQTT__BROKER_URL: "${CLIENT_EVENTS__MQTT__BROKER_URL-ws://ministack:4566}" + CLIENT_EVENTS__MQTT__RESOLVE_BROKER_ADDRESS_FROM_DESCRIBE_ENDPOINT: "${CLIENT_EVENTS__MQTT__RESOLVE_BROKER_ADDRESS_FROM_DESCRIBE_ENDPOINT-true}" + CLIENT_EVENTS__MQTT__BROKER_CONNECT_HOST: "${CLIENT_EVENTS__MQTT__BROKER_CONNECT_HOST-}" + CLIENT_EVENTS__MQTT__BROKER_URL: "${CLIENT_EVENTS__MQTT__BROKER_URL-}" CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX: "${CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX-RedShirt.Example.Api}" CLIENT_EVENTS__MQTT__USERNAME_SECRET_PATH: null CLIENT_EVENTS__MQTT__PASSWORD_SECRET_PATH: null From 01f377bf4d082879328ec2485fae92c39c8c456f Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Fri, 4 Sep 2026 23:00:46 -0700 Subject: [PATCH 05/31] implementation cleanup progress --- .../Factories/MqttClientFactory.cs | 88 +++++++++++-------- test/local/docker-compose.yaml | 2 + 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs index 752611a..90cafe7 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs @@ -23,6 +23,7 @@ internal sealed class ApiMqttClientFactory( private const string DefaultClientIdPrefix = "RedShirt.Example.Api"; private const string DefaultWebSocketPath = "/mqtt"; private const string MqttWebSocketSubProtocol = "mqtt"; + private const int DefaultMqttTcpPort = 1883; private static readonly string ServerHostname = string.IsNullOrWhiteSpace(Environment.MachineName) ? "unknown" : Environment.MachineName; @@ -71,32 +72,41 @@ private async Task ResolveBrokerTargetAsync(ConfigurationModel } /// - /// Maps a configured onto . + /// Builds a fully configured for connecting to the MQTT broker. /// /// /// MQTTnet does not connect from a single opaque URL string. Each transport must be configured explicitly /// ( for native MQTT, /// /// for MQTT-over-WebSocket). Deployment environments also use different schemes for the same broker - /// (for example ws:// against MiniStack locally and mqtts:// in production), so this helper - /// normalizes the configured URL into the builder call the transport requires. + /// (for example ws:// against MiniStack locally and mqtts:// in production), so broker URLs + /// are normalized into the builder call the transport requires. /// MQTTnet WebSocket transport expects a full ws:// or wss:// URI (including the /// /mqtt path and mqtt subprotocol). MiniStack (and AWS IoT) route upgrades by the IoT /// endpoint hostname returned from DescribeEndpoint, not a bare gateway host name. + /// MiniStack and AWS IoT data-plane brokers speak MQTT 3.1.1 only. /// - private static void ApplyBrokerAddress( - MqttClientOptionsBuilder optionsBuilder, - string brokerUrl, - string? webSocketHostHeader) + private static MqttClientOptionsBuilder GetOptionsBuilder( + ConfigurationModel config, + MqttBrokerTarget brokerTarget, + MqttBrokerCredentials? credentials) { - if (!Uri.TryCreate(brokerUrl, UriKind.Absolute, out var uri)) + var optionsBuilder = new MqttClientOptionsBuilder() + .WithClientId($"{config.EffectiveClientIdPrefix}/{ServerHostname}/{Guid.NewGuid():N}") + .WithCleanSession(); + + if (!string.IsNullOrWhiteSpace(config.ProtocolVersion) && + Enum.TryParse(config.ProtocolVersion, out var protocolVersion)) { - optionsBuilder.WithTcpServer(brokerUrl); - return; + optionsBuilder.WithProtocolVersion(protocolVersion); } - if (uri.Scheme.Equals("ws", StringComparison.OrdinalIgnoreCase) - || uri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase)) + if (!Uri.TryCreate(brokerTarget.BrokerUrl!, UriKind.Absolute, out var uri)) + { + optionsBuilder.WithTcpServer(brokerTarget.BrokerUrl!); + } + else if (uri.Scheme.Equals("ws", StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase)) { var webSocketUri = BuildWebSocketUri(uri); optionsBuilder.WithWebSocketServer(webSocketBuilder => @@ -105,31 +115,36 @@ private static void ApplyBrokerAddress( .WithUri(webSocketUri) .WithSubProtocols([MqttWebSocketSubProtocol]); - if (!string.IsNullOrWhiteSpace(webSocketHostHeader)) + if (!string.IsNullOrWhiteSpace(brokerTarget.WebSocketHostHeader)) { webSocketBuilder.WithRequestHeaders(new Dictionary { - ["Host"] = webSocketHostHeader + ["Host"] = brokerTarget.WebSocketHostHeader }); } }); - - return; } - - if (uri.Scheme.Equals("tcp", StringComparison.OrdinalIgnoreCase) - || uri.Scheme.Equals("mqtt", StringComparison.OrdinalIgnoreCase) - || uri.Scheme.Equals("mqtts", StringComparison.OrdinalIgnoreCase)) + else if (uri.Scheme.Equals("tcp", StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals("mqtt", StringComparison.OrdinalIgnoreCase) + || uri.Scheme.Equals("mqtts", StringComparison.OrdinalIgnoreCase)) { - optionsBuilder.WithTcpServer(uri.Host, uri.IsDefaultPort ? 1883 : uri.Port); - return; + optionsBuilder.WithTcpServer(uri.Host, uri.IsDefaultPort ? DefaultMqttTcpPort : uri.Port); + } + else + { + throw new ApiClientEventsException($"Unsupported MQTT broker URL scheme '{uri.Scheme}'.") + { + CouldBeTransient = false, + IsHandled = false + }; } - throw new ApiClientEventsException($"Unsupported MQTT broker URL scheme '{uri.Scheme}'.") + if (credentials is not null) { - CouldBeTransient = false, - IsHandled = false - }; + optionsBuilder.WithCredentials(credentials.Username, credentials.Password); + } + + return optionsBuilder; } private static string BuildWebSocketUri(Uri uri) @@ -200,21 +215,12 @@ public async Task CreateConnectedClientAsync(CancellationToken canc var mqttFactory = new MqttClientFactory(); var client = mqttFactory.CreateMqttClient(); - var optionsBuilder = new MqttClientOptionsBuilder() - .WithClientId($"{config.EffectiveClientIdPrefix}/{ServerHostname}/{Guid.NewGuid():N}") - .WithCleanSession() - .WithProtocolVersion(MqttProtocolVersion.V311); - - ApplyBrokerAddress(optionsBuilder, brokerTarget.BrokerUrl, brokerTarget.WebSocketHostHeader); - - if (await TryResolveCredentialsAsync(config, cancellationToken) is { } credentials) - { - optionsBuilder.WithCredentials(credentials.Username, credentials.Password); - } + var credentials = await TryResolveCredentialsAsync(config, cancellationToken); + var options = GetOptionsBuilder(config, brokerTarget, credentials).Build(); try { - var connectResult = await client.ConnectAsync(optionsBuilder.Build(), cancellationToken); + var connectResult = await client.ConnectAsync(options, cancellationToken); if (connectResult.ResultCode != MqttClientConnectResultCode.Success) { throw new ApiClientEventsException( @@ -280,6 +286,12 @@ internal sealed class ConfigurationModel /// public string? BrokerConnectHost { get; init; } + /// + /// MQTT protocol version to use when connecting. Expected to resolve to a + /// value (for example V311 or V500). + /// + public string? ProtocolVersion { get; init; } + public string? UsernameSecretPath { get; init; } public string? PasswordSecretPath { get; init; } diff --git a/test/local/docker-compose.yaml b/test/local/docker-compose.yaml index a723c99..2a2641c 100644 --- a/test/local/docker-compose.yaml +++ b/test/local/docker-compose.yaml @@ -185,6 +185,8 @@ services: CLIENT_EVENTS__MQTT__BROKER_CONNECT_HOST: "${CLIENT_EVENTS__MQTT__BROKER_CONNECT_HOST-}" CLIENT_EVENTS__MQTT__BROKER_URL: "${CLIENT_EVENTS__MQTT__BROKER_URL-}" CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX: "${CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX-RedShirt.Example.Api}" + # Set specific protocol version + CLIENT_EVENTS__MQTT__PROTOCOL_VERSION: "${CLIENT_EVENTS__MQTT__PROTOCOL_VERSION-V311}" CLIENT_EVENTS__MQTT__USERNAME_SECRET_PATH: null CLIENT_EVENTS__MQTT__PASSWORD_SECRET_PATH: null CLIENT_EVENTS__MQTT__RETRY_COUNT: "${CLIENT_EVENTS__MQTT__RETRY_COUNT-3}" From 136409f2ba8785a31ae5d2f65aca90b48905f74f Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Fri, 4 Sep 2026 23:04:47 -0700 Subject: [PATCH 06/31] implementation cleanup progress, again --- .../Factories/MqttClientFactory.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs index 90cafe7..b2cf1c5 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs @@ -6,6 +6,7 @@ using MQTTnet.Formatter; using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; using RedShirt.Example.Api.Common.SecretManagers.Core.Services; +using System.Net.Sockets; namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Factories; @@ -76,7 +77,7 @@ private async Task ResolveBrokerTargetAsync(ConfigurationModel /// /// /// MQTTnet does not connect from a single opaque URL string. Each transport must be configured explicitly - /// ( for native MQTT, + /// ( for native MQTT, /// /// for MQTT-over-WebSocket). Deployment environments also use different schemes for the same broker /// (for example ws:// against MiniStack locally and mqtts:// in production), so broker URLs From dcc9ea24af44e2c464b7f88e57998786a8b942fa Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Fri, 4 Sep 2026 23:28:31 -0700 Subject: [PATCH 07/31] starting to split off AWS IoT from the rest of the project --- .../Extensions/ServiceCollectionExtensions.cs | 3 + .../Factories/MqttClientFactory.cs | 135 ++++++++---------- .../Models/MqttBrokerTarget.cs | 8 ++ .../Services/IMqttBrokerUrlResolver.cs | 8 ++ .../Services/MqttBrokerUrlResolver.cs | 77 ++++++++++ 5 files changed, 156 insertions(+), 75 deletions(-) create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttBrokerTarget.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/IMqttBrokerUrlResolver.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttBrokerUrlResolver.cs diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs index a3c9fcd..b8a7e35 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs @@ -23,11 +23,14 @@ public static IServiceCollection AddMqttClientEvents(this IServiceCollection ser .AddAwsServiceWithLocalSupport() .Configure( configuration.GetSection(ConfigurationSectionName)) + .Configure( + configuration.GetSection(ConfigurationSectionName)) .Configure( configuration.GetSection(ConfigurationSectionName)); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(typeof(IApiClientEventSender<>), typeof(MqttApiClientEventSender<>)); services.TryAddSingleton(typeof(IApiClientEventReceiver<>), typeof(MqttApiClientEventReceiver<>)); diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs index b2cf1c5..76e851c 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs @@ -1,10 +1,10 @@ -using Amazon.IoT; -using Amazon.IoT.Model; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MQTTnet; using MQTTnet.Formatter; using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; +using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Models; +using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; using RedShirt.Example.Api.Common.SecretManagers.Core.Services; using System.Net.Sockets; @@ -18,7 +18,7 @@ internal interface IMqttClientFactory internal sealed class ApiMqttClientFactory( IOptions configuration, ISecretManagerService secretManagerService, - IAmazonIoT amazonIoT, + IMqttBrokerUrlResolver mqttBrokerUrlResolver, ILogger logger) : IMqttClientFactory { private const string DefaultClientIdPrefix = "RedShirt.Example.Api"; @@ -29,47 +29,55 @@ internal sealed class ApiMqttClientFactory( private static readonly string ServerHostname = string.IsNullOrWhiteSpace(Environment.MachineName) ? "unknown" : Environment.MachineName; + /// + /// Try to resolve broker URI and then validate. + /// + /// + /// Farms out the actual resolution to , the main thing that makes this + /// method special is centralized validation. + /// + /// + /// + /// + /// private async Task ResolveBrokerTargetAsync(ConfigurationModel config, CancellationToken cancellationToken) { - if (!config.ResolveBrokerAddressFromDescribeEndpoint) + var brokerTarget = await ResolveBrokerTargetInnerAsync(config, cancellationToken); + if (string.IsNullOrWhiteSpace(brokerTarget.BrokerUrl)) { - return new MqttBrokerTarget + throw new ApiClientEventsException("ClientEvents MQTT broker URL is not configured.") { - BrokerUrl = config.BrokerUrl + CouldBeTransient = false, + IsHandled = false }; } - var describeResponse = await amazonIoT.DescribeEndpointAsync(new DescribeEndpointRequest - { - EndpointType = "iot:Data-ATS" - }, cancellationToken); + return brokerTarget; + } - if (string.IsNullOrWhiteSpace(describeResponse.EndpointAddress)) + /// + /// Try to resolve broker URL. + /// If is false, then use configured broker URL. + /// If is true, then use + /// . + /// + /// + /// + /// + /// + private async Task ResolveBrokerTargetInnerAsync(ConfigurationModel config, + CancellationToken cancellationToken) + { + if (!config.ResolveBrokerAddressFromDescribeEndpoint) { - throw new ApiClientEventsException("IoT DescribeEndpoint did not return an endpoint address.") + return new MqttBrokerTarget { - CouldBeTransient = true, - IsHandled = false + BrokerUrl = config.BrokerUrl }; } - var endpointAddress = describeResponse.EndpointAddress; - var endpointUri = BuildUriFromHostPort(endpointAddress, DefaultWebSocketPath); - var connectHost = string.IsNullOrWhiteSpace(config.BrokerConnectHost) - ? endpointUri.Host - : config.BrokerConnectHost; - - var brokerUrl = new UriBuilder(endpointUri.Scheme, connectHost, endpointUri.Port, endpointUri.AbsolutePath) - .Uri - .ToString(); - - var needsHostHeader = !connectHost.Equals(endpointUri.Host, StringComparison.OrdinalIgnoreCase); - return new MqttBrokerTarget - { - BrokerUrl = brokerUrl, - WebSocketHostHeader = needsHostHeader ? endpointAddress : null - }; + return await mqttBrokerUrlResolver.ResolveBrokerUrlAsync(cancellationToken); } /// @@ -87,11 +95,11 @@ private async Task ResolveBrokerTargetAsync(ConfigurationModel /// endpoint hostname returned from DescribeEndpoint, not a bare gateway host name. /// MiniStack and AWS IoT data-plane brokers speak MQTT 3.1.1 only. /// - private static MqttClientOptionsBuilder GetOptionsBuilder( - ConfigurationModel config, - MqttBrokerTarget brokerTarget, - MqttBrokerCredentials? credentials) + private async Task GetOptionsBuilderAsync(ConfigurationModel config, + CancellationToken cancellationToken) { + var brokerTarget = await ResolveBrokerTargetAsync(config, cancellationToken); + var optionsBuilder = new MqttClientOptionsBuilder() .WithClientId($"{config.EffectiveClientIdPrefix}/{ServerHostname}/{Guid.NewGuid():N}") .WithCleanSession(); @@ -140,12 +148,16 @@ private static MqttClientOptionsBuilder GetOptionsBuilder( }; } - if (credentials is not null) + if (await TryResolveCredentialsAsync(config, cancellationToken) is { } credentials) { optionsBuilder.WithCredentials(credentials.Username, credentials.Password); } - return optionsBuilder; + return new MqttClientOptionsBuildResult + { + OptionsBuilder = optionsBuilder, + BrokerUrl = brokerTarget.BrokerUrl! + }; } private static string BuildWebSocketUri(Uri uri) @@ -157,20 +169,6 @@ private static string BuildWebSocketUri(Uri uri) return new UriBuilder(uri.Scheme, uri.Host, uri.Port, path).Uri.ToString(); } - private static Uri BuildUriFromHostPort(string hostPort, string path) - { - if (Uri.TryCreate($"ws://{hostPort}{path}", UriKind.Absolute, out var uri)) - { - return uri; - } - - throw new ApiClientEventsException($"IoT endpoint address '{hostPort}' is not a valid host:port value.") - { - CouldBeTransient = false, - IsHandled = false - }; - } - private async Task TryResolveCredentialsAsync(ConfigurationModel config, CancellationToken cancellationToken) { @@ -203,25 +201,14 @@ private static Uri BuildUriFromHostPort(string hostPort, string path) public async Task CreateConnectedClientAsync(CancellationToken cancellationToken) { - var config = configuration.Value; - var brokerTarget = await ResolveBrokerTargetAsync(config, cancellationToken); - if (string.IsNullOrWhiteSpace(brokerTarget.BrokerUrl)) - { - throw new ApiClientEventsException("ClientEvents MQTT broker URL is not configured.") - { - CouldBeTransient = false, - IsHandled = false - }; - } + var optionsBuild = await GetOptionsBuilderAsync(configuration.Value, cancellationToken); var mqttFactory = new MqttClientFactory(); var client = mqttFactory.CreateMqttClient(); - var credentials = await TryResolveCredentialsAsync(config, cancellationToken); - var options = GetOptionsBuilder(config, brokerTarget, credentials).Build(); try { - var connectResult = await client.ConnectAsync(options, cancellationToken); + var connectResult = await client.ConnectAsync(optionsBuild.OptionsBuilder.Build(), cancellationToken); if (connectResult.ResultCode != MqttClientConnectResultCode.Success) { throw new ApiClientEventsException( @@ -238,7 +225,7 @@ public async Task CreateConnectedClientAsync(CancellationToken canc } catch (Exception exception) { - logger.LogError(exception, "Failed to connect to MQTT broker at {BrokerUrl}", brokerTarget.BrokerUrl); + logger.LogError(exception, "Failed to connect to MQTT broker at {BrokerUrl}", optionsBuild.BrokerUrl); throw new ApiClientEventsException(exception) { CouldBeTransient = true, @@ -249,11 +236,17 @@ public async Task CreateConnectedClientAsync(CancellationToken canc return client; } - private sealed class MqttBrokerTarget + private sealed class MqttClientOptionsBuildResult { - public string? BrokerUrl { get; init; } + /// + /// Configured options object builder. + /// + public required MqttClientOptionsBuilder OptionsBuilder { get; init; } - public string? WebSocketHostHeader { get; init; } + /// + /// Also return BrokerUrl for logging purposes. + /// + public required string BrokerUrl { get; init; } } private sealed class MqttBrokerCredentials @@ -279,14 +272,6 @@ internal sealed class ConfigurationModel /// public bool ResolveBrokerAddressFromDescribeEndpoint { get; init; } - /// - /// Optional TCP/WebSocket connect host when is enabled. - /// Use this in Docker when the IoT endpoint hostname (for example *.localhost) does not resolve to the - /// MiniStack container; the factory connects to this host and sends the DescribeEndpoint value as the HTTP - /// Host header. - /// - public string? BrokerConnectHost { get; init; } - /// /// MQTT protocol version to use when connecting. Expected to resolve to a /// value (for example V311 or V500). diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttBrokerTarget.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttBrokerTarget.cs new file mode 100644 index 0000000..68e0e8c --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttBrokerTarget.cs @@ -0,0 +1,8 @@ +namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Models; + +internal sealed class MqttBrokerTarget +{ + public string? BrokerUrl { get; init; } + + public string? WebSocketHostHeader { get; init; } +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/IMqttBrokerUrlResolver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/IMqttBrokerUrlResolver.cs new file mode 100644 index 0000000..83bddc8 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/IMqttBrokerUrlResolver.cs @@ -0,0 +1,8 @@ +using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Models; + +namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; + +internal interface IMqttBrokerUrlResolver +{ + Task ResolveBrokerUrlAsync(CancellationToken cancellationToken); +} diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttBrokerUrlResolver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttBrokerUrlResolver.cs new file mode 100644 index 0000000..f5de67b --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttBrokerUrlResolver.cs @@ -0,0 +1,77 @@ +using Amazon.IoT; +using Amazon.IoT.Model; +using Microsoft.Extensions.Options; +using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; +using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Models; + +namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; + +/// +/// Resolves an MQTT broker connection target from AWS IoT Core DescribeEndpoint. +/// +internal sealed class MqttBrokerUrlResolver( + IAmazonIoT amazonIoT, + IOptions configuration) : IMqttBrokerUrlResolver +{ + private const string DefaultWebSocketPath = "/mqtt"; + + public async Task ResolveBrokerUrlAsync(CancellationToken cancellationToken) + { + var describeResponse = await amazonIoT.DescribeEndpointAsync(new DescribeEndpointRequest + { + EndpointType = "iot:Data-ATS" + }, cancellationToken); + + if (string.IsNullOrWhiteSpace(describeResponse.EndpointAddress)) + { + throw new ApiClientEventsException("IoT DescribeEndpoint did not return an endpoint address.") + { + CouldBeTransient = true, + IsHandled = false + }; + } + + var endpointAddress = describeResponse.EndpointAddress; + var endpointUri = BuildUriFromHostPort(endpointAddress, DefaultWebSocketPath); + var brokerConnectHost = configuration.Value.BrokerConnectHost; + var connectHost = string.IsNullOrWhiteSpace(brokerConnectHost) + ? endpointUri.Host + : brokerConnectHost; + + var brokerUrl = new UriBuilder(endpointUri.Scheme, connectHost, endpointUri.Port, endpointUri.AbsolutePath) + .Uri + .ToString(); + + var needsHostHeader = !connectHost.Equals(endpointUri.Host, StringComparison.OrdinalIgnoreCase); + return new MqttBrokerTarget + { + BrokerUrl = brokerUrl, + WebSocketHostHeader = needsHostHeader ? endpointAddress : null + }; + } + + private static Uri BuildUriFromHostPort(string hostPort, string path) + { + if (Uri.TryCreate($"ws://{hostPort}{path}", UriKind.Absolute, out var uri)) + { + return uri; + } + + throw new ApiClientEventsException($"IoT endpoint address '{hostPort}' is not a valid host:port value.") + { + CouldBeTransient = false, + IsHandled = false + }; + } + + internal sealed class ConfigurationModel + { + /// + /// Optional TCP/WebSocket connect host when resolving via IoT DescribeEndpoint. + /// Use this in Docker when the IoT endpoint hostname (for example *.localhost) does not resolve to the + /// MiniStack container; the resolver connects to this host and sends the DescribeEndpoint value as the HTTP + /// Host header. + /// + public string? BrokerConnectHost { get; init; } + } +} From 3d7c6d272e92f2a068d2786ff16a95a51cb1f9c9 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Fri, 4 Sep 2026 23:36:12 -0700 Subject: [PATCH 08/31] mostly generated documentation --- .../Services/IMqttBrokerUrlResolver.cs | 13 +++++ .../Services/MqttBrokerUrlResolver.cs | 58 +++++++++++++------ 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/IMqttBrokerUrlResolver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/IMqttBrokerUrlResolver.cs index 83bddc8..03b4829 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/IMqttBrokerUrlResolver.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/IMqttBrokerUrlResolver.cs @@ -2,7 +2,20 @@ namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; +/// +/// Resolves an MQTT broker connection target when a configured BrokerUrl is not sufficient on its own. +/// +/// +/// Most environments can connect using a static broker URL from configuration. This resolver exists for the special +/// case where the broker address must be discovered at runtime. +/// See for environment-specific behaviour. +/// internal interface IMqttBrokerUrlResolver { + /// + /// Resolves the broker URL and any transport-specific connection hints needed to reach it. + /// + /// Cancellation token. + /// A broker target suitable for passing to the MQTT client options builder. Task ResolveBrokerUrlAsync(CancellationToken cancellationToken); } diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttBrokerUrlResolver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttBrokerUrlResolver.cs index f5de67b..3969af7 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttBrokerUrlResolver.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttBrokerUrlResolver.cs @@ -9,12 +9,44 @@ namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; /// /// Resolves an MQTT broker connection target from AWS IoT Core DescribeEndpoint. /// +/// +/// +/// Reusable in real AWS: calling IoT DescribeEndpoint (iot:Data-ATS) to obtain the +/// account-specific data-plane hostname and building a WebSocket broker URL from it. With +/// unset, the resolver connects directly to that hostname, +/// which is the normal production path (or equivalent to configuring the endpoint from IaC output). +/// +/// +/// Local Docker / MiniStack only: and the optional +/// HTTP Host header override. Inside Docker Compose the DescribeEndpoint hostname often does not resolve to +/// the LocalStack/MiniStack container, so TCP connects to a configured host (for example ministack) while +/// the upgrade still advertises the IoT endpoint hostname. +/// +/// +/// Production AWS WebSocket clients also require wss:// and SigV4 signing on the upgrade; this resolver +/// covers endpoint discovery and URL assembly only. +/// +/// internal sealed class MqttBrokerUrlResolver( IAmazonIoT amazonIoT, IOptions configuration) : IMqttBrokerUrlResolver { private const string DefaultWebSocketPath = "/mqtt"; + private static Uri BuildUriFromHostPort(string hostPort, string path) + { + if (Uri.TryCreate($"ws://{hostPort}{path}", UriKind.Absolute, out var uri)) + { + return uri; + } + + throw new ApiClientEventsException($"IoT endpoint address '{hostPort}' is not a valid host:port value.") + { + CouldBeTransient = false, + IsHandled = false + }; + } + public async Task ResolveBrokerUrlAsync(CancellationToken cancellationToken) { var describeResponse = await amazonIoT.DescribeEndpointAsync(new DescribeEndpointRequest @@ -50,28 +82,18 @@ public async Task ResolveBrokerUrlAsync(CancellationToken canc }; } - private static Uri BuildUriFromHostPort(string hostPort, string path) - { - if (Uri.TryCreate($"ws://{hostPort}{path}", UriKind.Absolute, out var uri)) - { - return uri; - } - - throw new ApiClientEventsException($"IoT endpoint address '{hostPort}' is not a valid host:port value.") - { - CouldBeTransient = false, - IsHandled = false - }; - } - internal sealed class ConfigurationModel { /// /// Optional TCP/WebSocket connect host when resolving via IoT DescribeEndpoint. - /// Use this in Docker when the IoT endpoint hostname (for example *.localhost) does not resolve to the - /// MiniStack container; the resolver connects to this host and sends the DescribeEndpoint value as the HTTP - /// Host header. /// + /// + /// Intended for local Docker / MiniStack testing only. Leave unset in real AWS, where the DescribeEndpoint + /// hostname is the connection target. When set, the resolver TCP-connects to this host and sends the + /// DescribeEndpoint address as the HTTP Host header (for example when + /// *.localhost or *.ministack does not resolve to the MiniStack container from the API + /// container). + /// public string? BrokerConnectHost { get; init; } } -} +} \ No newline at end of file From f44b251b9320c180814c525cad0c1b288f8863c6 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Fri, 4 Sep 2026 23:37:09 -0700 Subject: [PATCH 09/31] Rename MqttImplementation project to RedShirt.Example.Api.ClientEvents.Library.Mqtt --- RedShirt.Example.Api.slnx | 186 +++++++----------- .../Extensions/ServiceCollectionExtensions.cs | 8 +- .../Factories/MqttClientFactory.cs | 6 +- .../Models/MqttBrokerTarget.cs | 2 +- .../MqttClientEventsExceptionArbiterReport.cs | 2 +- ...mple.Api.ClientEvents.Library.Mqtt.csproj} | 0 .../Services/IMqttBrokerUrlResolver.cs | 4 +- .../Services/MqttApiClientEventReceiver.cs | 6 +- .../Services/MqttApiClientEventSender.cs | 6 +- .../Services/MqttBrokerUrlResolver.cs | 4 +- ...MqttClientEventsExceptionArbiterService.cs | 4 +- .../MqttClientEventsRetryWrapperService.cs | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../RedShirt.Example.Api.csproj | 2 +- 14 files changed, 95 insertions(+), 139 deletions(-) rename src/{RedShirt.Example.Api.ClientEvents.Library.MqttImplementation => RedShirt.Example.Api.ClientEvents.Library.Mqtt}/Extensions/ServiceCollectionExtensions.cs (84%) rename src/{RedShirt.Example.Api.ClientEvents.Library.MqttImplementation => RedShirt.Example.Api.ClientEvents.Library.Mqtt}/Factories/MqttClientFactory.cs (97%) rename src/{RedShirt.Example.Api.ClientEvents.Library.MqttImplementation => RedShirt.Example.Api.ClientEvents.Library.Mqtt}/Models/MqttBrokerTarget.cs (64%) rename src/{RedShirt.Example.Api.ClientEvents.Library.MqttImplementation => RedShirt.Example.Api.ClientEvents.Library.Mqtt}/Models/MqttClientEventsExceptionArbiterReport.cs (74%) rename src/{RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.csproj => RedShirt.Example.Api.ClientEvents.Library.Mqtt/RedShirt.Example.Api.ClientEvents.Library.Mqtt.csproj} (100%) rename src/{RedShirt.Example.Api.ClientEvents.Library.MqttImplementation => RedShirt.Example.Api.ClientEvents.Library.Mqtt}/Services/IMqttBrokerUrlResolver.cs (85%) rename src/{RedShirt.Example.Api.ClientEvents.Library.MqttImplementation => RedShirt.Example.Api.ClientEvents.Library.Mqtt}/Services/MqttApiClientEventReceiver.cs (95%) rename src/{RedShirt.Example.Api.ClientEvents.Library.MqttImplementation => RedShirt.Example.Api.ClientEvents.Library.Mqtt}/Services/MqttApiClientEventSender.cs (93%) rename src/{RedShirt.Example.Api.ClientEvents.Library.MqttImplementation => RedShirt.Example.Api.ClientEvents.Library.Mqtt}/Services/MqttBrokerUrlResolver.cs (96%) rename src/{RedShirt.Example.Api.ClientEvents.Library.MqttImplementation => RedShirt.Example.Api.ClientEvents.Library.Mqtt}/Services/Resilience/MqttClientEventsExceptionArbiterService.cs (94%) rename src/{RedShirt.Example.Api.ClientEvents.Library.MqttImplementation => RedShirt.Example.Api.ClientEvents.Library.Mqtt}/Services/Resilience/MqttClientEventsRetryWrapperService.cs (98%) diff --git a/RedShirt.Example.Api.slnx b/RedShirt.Example.Api.slnx index 3216efb..ff4c600 100644 --- a/RedShirt.Example.Api.slnx +++ b/RedShirt.Example.Api.slnx @@ -1,157 +1,113 @@ - - + + - + - - - + + + - - + + - - - - - - - + + + + + + + - - - + + + - - + + - - - - + + + + - + - - - - - - - - - + + + + + + + + + - - + + - - + + - + - - + + - + - - - + + + - - - - - - - - - - - + + + + + + + + + + + - - - - + + + + - + - - - - - - - - + + + + + + + + - + - + diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Extensions/ServiceCollectionExtensions.cs similarity index 84% rename from src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs rename to src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Extensions/ServiceCollectionExtensions.cs index b8a7e35..39fd397 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Extensions/ServiceCollectionExtensions.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Extensions/ServiceCollectionExtensions.cs @@ -3,13 +3,13 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using RedShirt.Example.Api.ClientEvents.Library.Core.Services; -using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Factories; -using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; -using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services.Resilience; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Factories; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services.Resilience; using RedShirt.Example.Api.Common.Aws.Extensions; using RedShirt.Example.Api.Common.Extensions; -namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Extensions; +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Extensions; public static class ServiceCollectionExtensions { diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Factories/MqttClientFactory.cs similarity index 97% rename from src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs rename to src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Factories/MqttClientFactory.cs index 76e851c..6890332 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Factories/MqttClientFactory.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Factories/MqttClientFactory.cs @@ -3,12 +3,12 @@ using MQTTnet; using MQTTnet.Formatter; using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; -using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Models; -using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Models; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; using RedShirt.Example.Api.Common.SecretManagers.Core.Services; using System.Net.Sockets; -namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Factories; +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Factories; internal interface IMqttClientFactory { diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttBrokerTarget.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttBrokerTarget.cs similarity index 64% rename from src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttBrokerTarget.cs rename to src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttBrokerTarget.cs index 68e0e8c..4055744 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttBrokerTarget.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttBrokerTarget.cs @@ -1,4 +1,4 @@ -namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Models; +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Models; internal sealed class MqttBrokerTarget { diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttClientEventsExceptionArbiterReport.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttClientEventsExceptionArbiterReport.cs similarity index 74% rename from src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttClientEventsExceptionArbiterReport.cs rename to src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttClientEventsExceptionArbiterReport.cs index 18cbbe7..6445bc7 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Models/MqttClientEventsExceptionArbiterReport.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttClientEventsExceptionArbiterReport.cs @@ -1,4 +1,4 @@ -namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Models; +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Models; internal sealed class MqttClientEventsExceptionArbiterReport { diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.csproj b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/RedShirt.Example.Api.ClientEvents.Library.Mqtt.csproj similarity index 100% rename from src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.csproj rename to src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/RedShirt.Example.Api.ClientEvents.Library.Mqtt.csproj diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/IMqttBrokerUrlResolver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/IMqttBrokerUrlResolver.cs similarity index 85% rename from src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/IMqttBrokerUrlResolver.cs rename to src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/IMqttBrokerUrlResolver.cs index 03b4829..0e5a35e 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/IMqttBrokerUrlResolver.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/IMqttBrokerUrlResolver.cs @@ -1,6 +1,6 @@ -using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Models; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Models; -namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; /// /// Resolves an MQTT broker connection target when a configured BrokerUrl is not sufficient on its own. diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventReceiver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventReceiver.cs similarity index 95% rename from src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventReceiver.cs rename to src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventReceiver.cs index cd7c50f..d95089f 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventReceiver.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventReceiver.cs @@ -4,13 +4,13 @@ using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; using RedShirt.Example.Api.ClientEvents.Library.Core.Models; using RedShirt.Example.Api.ClientEvents.Library.Core.Services; -using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Factories; -using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services.Resilience; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Factories; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services.Resilience; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading.Channels; -namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; internal sealed class MqttApiClientEventReceiver( IMqttClientFactory mqttClientFactory, diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventSender.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventSender.cs similarity index 93% rename from src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventSender.cs rename to src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventSender.cs index 706ba0b..ea5dbc0 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttApiClientEventSender.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventSender.cs @@ -4,11 +4,11 @@ using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; using RedShirt.Example.Api.ClientEvents.Library.Core.Models; using RedShirt.Example.Api.ClientEvents.Library.Core.Services; -using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Factories; -using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services.Resilience; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Factories; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services.Resilience; using System.Text.Json; -namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; internal sealed class MqttApiClientEventSender( IMqttClientFactory mqttClientFactory, diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttBrokerUrlResolver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttBrokerUrlResolver.cs similarity index 96% rename from src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttBrokerUrlResolver.cs rename to src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttBrokerUrlResolver.cs index 3969af7..bedcf4f 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/MqttBrokerUrlResolver.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttBrokerUrlResolver.cs @@ -2,9 +2,9 @@ using Amazon.IoT.Model; using Microsoft.Extensions.Options; using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; -using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Models; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Models; -namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services; +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; /// /// Resolves an MQTT broker connection target from AWS IoT Core DescribeEndpoint. diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsExceptionArbiterService.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsExceptionArbiterService.cs similarity index 94% rename from src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsExceptionArbiterService.cs rename to src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsExceptionArbiterService.cs index 69347f4..0f587cd 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsExceptionArbiterService.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsExceptionArbiterService.cs @@ -1,10 +1,10 @@ using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; -using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Models; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Models; using RedShirt.Example.Api.Common.SecretManagers.Core.Exceptions; using System.Net.Sockets; using System.Text.Json; -namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services.Resilience; +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services.Resilience; /// /// Classifies MQTT client-events exceptions for retry decisions. diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsRetryWrapperService.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsRetryWrapperService.cs similarity index 98% rename from src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsRetryWrapperService.cs rename to src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsRetryWrapperService.cs index 285fa30..4dc5c4c 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.MqttImplementation/Services/Resilience/MqttClientEventsRetryWrapperService.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsRetryWrapperService.cs @@ -5,7 +5,7 @@ using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; using RedShirt.Example.Api.Common.Services.Utility; -namespace RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Services.Resilience; +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services.Resilience; /// /// Retries MQTT client-events operations that fail with expected transient exceptions, diff --git a/src/RedShirt.Example.Api/Extensions/ServiceCollectionExtensions.cs b/src/RedShirt.Example.Api/Extensions/ServiceCollectionExtensions.cs index 6424c1b..1d6f58b 100644 --- a/src/RedShirt.Example.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/RedShirt.Example.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,5 @@ using RedShirt.Example.Api.ClientEvents.Domains.Example.Extensions; -using RedShirt.Example.Api.ClientEvents.Library.MqttImplementation.Extensions; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Extensions; using RedShirt.Example.Api.Common.Aws.SsmSecretManager.Extensions; using RedShirt.Example.Api.Common.Distributed.Extensions; using RedShirt.Example.Api.Common.Extensions; diff --git a/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj b/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj index bbfd6b4..911e780 100644 --- a/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj +++ b/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj @@ -33,7 +33,7 @@ - + From 40cb6ee81b60e1619ab74e1c9338bfca75e16406 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 00:00:31 -0700 Subject: [PATCH 10/31] Create Mqtt.Aws subproject, started on documentation in docs/ explaining the decision. --- RedShirt.Example.Api.slnx | 189 +++++++++++------- docs/patterns/server-sent-events.md | 19 ++ .../Extensions/ServiceCollectionExtensions.cs | 28 +++ ...e.Api.ClientEvents.Library.Mqtt.Aws.csproj | 20 ++ .../Services/MqttBrokerUrlResolver.cs | 40 ++-- .../Extensions/ServiceCollectionExtensions.cs | 6 - .../Globals.cs | 4 + ...ample.Api.ClientEvents.Library.Mqtt.csproj | 2 - .../Services/IMqttBrokerUrlResolver.cs | 6 +- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../RedShirt.Example.Api.csproj | 3 +- test/local/README.md | 2 +- test/local/docker-compose.yaml | 2 +- 13 files changed, 226 insertions(+), 97 deletions(-) create mode 100644 docs/patterns/server-sent-events.md create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.csproj rename src/{RedShirt.Example.Api.ClientEvents.Library.Mqtt => RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws}/Services/MqttBrokerUrlResolver.cs (56%) create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Globals.cs diff --git a/RedShirt.Example.Api.slnx b/RedShirt.Example.Api.slnx index ff4c600..67c955f 100644 --- a/RedShirt.Example.Api.slnx +++ b/RedShirt.Example.Api.slnx @@ -1,113 +1,160 @@ - - + + - + - - - + + + + - - + + - - - - - - - + + + + + + + - - - + + + - - + + - - - - + + + + - + - - - - - - - - - + + + + + + + + + - - + + - - + + - + - - + + + - + - - - + + + - - - - - - - - - - - + + + + + + + + + + + - - - - + + + + - + - - - - - - - - + + + + + + + + - + - + diff --git a/docs/patterns/server-sent-events.md b/docs/patterns/server-sent-events.md new file mode 100644 index 0000000..8b9b3a7 --- /dev/null +++ b/docs/patterns/server-sent-events.md @@ -0,0 +1,19 @@ +# Client Events / Server-Sent Events + +This document talks a bit about this template's usage of Server-Sent Events to send live updates to connected clients. + +# Special Cases + +## AWS IoT Endpoint Resolution + +During local testing, it was discovered that pointing the Broker URL didn't cut it for connecting to MiniStack in the +local Docker Compose stack. + +This problem is in part a product of our choices to use MiniStack's IoT emulator for local testing. However, I am +choosing to keep this feature in the template because it's possible (though not required) to resolve a broker URL using +the AWSSDK.IoT client. It will return an IoT data-plane hostname that is stable for a given account and region. That +said, I'm also acknowledging that AWS deployments are not everyone's end goal. In the same vein as the rest of this +template, the AWSSDK.IoT operations have been quarantined in a separate project, +`RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws` in order to limit the scope of the removal. + +If you want to remove the use of the `AWSSDK.IoT` altogether, then {END OF SESSION, WILL FINISH LATER} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Extensions/ServiceCollectionExtensions.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..6f4c21f --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using Amazon.IoT; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.Services; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; +using RedShirt.Example.Api.Common.Aws.Extensions; + +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.Extensions; + +public static class ServiceCollectionExtensions +{ + private const string ConfigurationSectionName = + $"{Mqtt.Extensions.ServiceCollectionExtensions.ConfigurationSectionName}:AWS"; + + public static IServiceCollection AddAwsMqttClientEvents(this IServiceCollection services, + IConfiguration configuration) + { + services + .AddAwsServiceWithLocalSupport() + .Configure( + configuration.GetSection(ConfigurationSectionName)); + + services.TryAddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.csproj b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.csproj new file mode 100644 index 0000000..13b6433 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttBrokerUrlResolver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs similarity index 56% rename from src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttBrokerUrlResolver.cs rename to src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs index bedcf4f..a7a8b03 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttBrokerUrlResolver.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs @@ -3,24 +3,38 @@ using Microsoft.Extensions.Options; using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Models; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; -namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.Services; /// -/// Resolves an MQTT broker connection target from AWS IoT Core DescribeEndpoint. +/// AWS IoT implementation of that discovers the broker connection target +/// from IoT Core DescribeEndpoint at runtime. /// /// /// -/// Reusable in real AWS: calling IoT DescribeEndpoint (iot:Data-ATS) to obtain the -/// account-specific data-plane hostname and building a WebSocket broker URL from it. With +/// Runtime resolution is optional. The MQTT client factory uses this resolver only when +/// ClientEvents:Mqtt:ResolveBrokerAddressFromDescribeEndpoint is true. In a deployed AWS +/// environment you can set that flag to false and supply the IoT data-plane endpoint as +/// ClientEvents:Mqtt:BrokerUrl instead (for example from IaC output, SSM, or an environment variable). +/// The hostname returned by DescribeEndpoint (iot:Data-ATS) is stable for a given account and +/// region, so a configured BrokerUrl is equivalent to what this resolver would discover on startup. +/// Runtime resolution is a convenience that avoids plumbing the endpoint through configuration; it is not +/// required for production connectivity when DNS resolves to IoT Core directly. +/// +/// +/// When resolution is enabled in real AWS: calling IoT DescribeEndpoint obtains the +/// account-specific data-plane hostname and builds a WebSocket broker URL from it. With /// unset, the resolver connects directly to that hostname, -/// which is the normal production path (or equivalent to configuring the endpoint from IaC output). +/// which matches the normal production path described above. /// /// /// Local Docker / MiniStack only: and the optional /// HTTP Host header override. Inside Docker Compose the DescribeEndpoint hostname often does not resolve to -/// the LocalStack/MiniStack container, so TCP connects to a configured host (for example ministack) while -/// the upgrade still advertises the IoT endpoint hostname. +/// the LocalStack/MiniStack container, so a static BrokerUrl alone is insufficient — TCP must connect to a +/// configured host (for example ministack) while the upgrade still advertises the IoT endpoint hostname. +/// This split connect/host behaviour is what makes runtime resolution (or equivalent manual wiring) necessary +/// locally but not in AWS. /// /// /// Production AWS WebSocket clients also require wss:// and SigV4 signing on the upgrade; this resolver @@ -88,11 +102,13 @@ internal sealed class ConfigurationModel /// Optional TCP/WebSocket connect host when resolving via IoT DescribeEndpoint. /// /// - /// Intended for local Docker / MiniStack testing only. Leave unset in real AWS, where the DescribeEndpoint - /// hostname is the connection target. When set, the resolver TCP-connects to this host and sends the - /// DescribeEndpoint address as the HTTP Host header (for example when - /// *.localhost or *.ministack does not resolve to the MiniStack container from the API - /// container). + /// Intended for local Docker / MiniStack testing only, when + /// ResolveBrokerAddressFromDescribeEndpoint is true. Leave unset in real AWS, where the + /// DescribeEndpoint hostname is both the advertised and connectable target. When set, the resolver + /// TCP-connects to this host and sends the DescribeEndpoint address as the HTTP Host header (for + /// example when *.localhost or *.ministack does not resolve to the MiniStack container from + /// the API container). Has no effect when runtime resolution is disabled and + /// ClientEvents:Mqtt:BrokerUrl is used instead. /// public string? BrokerConnectHost { get; init; } } diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Extensions/ServiceCollectionExtensions.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Extensions/ServiceCollectionExtensions.cs index 39fd397..f3f3063 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Extensions/ServiceCollectionExtensions.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,3 @@ -using Amazon.IoT; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -6,7 +5,6 @@ using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Factories; using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services.Resilience; -using RedShirt.Example.Api.Common.Aws.Extensions; using RedShirt.Example.Api.Common.Extensions; namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Extensions; @@ -20,17 +18,13 @@ public static IServiceCollection AddMqttClientEvents(this IServiceCollection ser { services .AddCommonServices() - .AddAwsServiceWithLocalSupport() .Configure( configuration.GetSection(ConfigurationSectionName)) - .Configure( - configuration.GetSection(ConfigurationSectionName)) .Configure( configuration.GetSection(ConfigurationSectionName)); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(typeof(IApiClientEventSender<>), typeof(MqttApiClientEventSender<>)); services.TryAddSingleton(typeof(IApiClientEventReceiver<>), typeof(MqttApiClientEventReceiver<>)); diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Globals.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Globals.cs new file mode 100644 index 0000000..0f94766 --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Globals.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] +[assembly: InternalsVisibleTo("RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws")] \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/RedShirt.Example.Api.ClientEvents.Library.Mqtt.csproj b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/RedShirt.Example.Api.ClientEvents.Library.Mqtt.csproj index 88514ef..62a4db0 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/RedShirt.Example.Api.ClientEvents.Library.Mqtt.csproj +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/RedShirt.Example.Api.ClientEvents.Library.Mqtt.csproj @@ -9,14 +9,12 @@ - - diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/IMqttBrokerUrlResolver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/IMqttBrokerUrlResolver.cs index 0e5a35e..4bb7c88 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/IMqttBrokerUrlResolver.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/IMqttBrokerUrlResolver.cs @@ -7,8 +7,8 @@ namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; /// /// /// Most environments can connect using a static broker URL from configuration. This resolver exists for the special -/// case where the broker address must be discovered at runtime. -/// See for environment-specific behaviour. +/// case where the broker address must be discovered at runtime — for example via AWS IoT +/// DescribeEndpoint in RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws. /// internal interface IMqttBrokerUrlResolver { @@ -18,4 +18,4 @@ internal interface IMqttBrokerUrlResolver /// Cancellation token. /// A broker target suitable for passing to the MQTT client options builder. Task ResolveBrokerUrlAsync(CancellationToken cancellationToken); -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api/Extensions/ServiceCollectionExtensions.cs b/src/RedShirt.Example.Api/Extensions/ServiceCollectionExtensions.cs index 1d6f58b..31388d5 100644 --- a/src/RedShirt.Example.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/RedShirt.Example.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using RedShirt.Example.Api.ClientEvents.Domains.Example.Extensions; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.Extensions; using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Extensions; using RedShirt.Example.Api.Common.Aws.SsmSecretManager.Extensions; using RedShirt.Example.Api.Common.Distributed.Extensions; @@ -62,6 +63,7 @@ internal static IServiceCollection ConfigureApiServices(this IServiceCollection .AddProducts(configuration) .AddUploads(configuration) .AddMqttClientEvents(configuration) + .AddAwsMqttClientEvents(configuration) .AddExampleClientEventsDomain(); } } \ No newline at end of file diff --git a/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj b/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj index 911e780..0baef87 100644 --- a/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj +++ b/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj @@ -33,7 +33,8 @@ - + + diff --git a/test/local/README.md b/test/local/README.md index 5c6116b..f3df35d 100644 --- a/test/local/README.md +++ b/test/local/README.md @@ -393,7 +393,7 @@ Local Compose resolves the MQTT broker address from IoT `DescribeEndpoint` (the | Variable | Local default | |-----------------------------------------------------------------------|------------------------| | `CLIENT_EVENTS__MQTT__RESOLVE_BROKER_ADDRESS_FROM_DESCRIBE_ENDPOINT` | `true` | -| `CLIENT_EVENTS__MQTT__BROKER_CONNECT_HOST` | unset (connect to DescribeEndpoint host) | +| `CLIENT_EVENTS__MQTT__AWS__BROKER_CONNECT_HOST` | unset (connect to DescribeEndpoint host) | | `CLIENT_EVENTS__MQTT__BROKER_URL` | unset (use DescribeEndpoint) | | `CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX` | `RedShirt.Example.Api` | | `CLIENT_EVENTS__MQTT__USERNAME_SECRET_PATH` | `null` | diff --git a/test/local/docker-compose.yaml b/test/local/docker-compose.yaml index 2a2641c..667279f 100644 --- a/test/local/docker-compose.yaml +++ b/test/local/docker-compose.yaml @@ -182,7 +182,7 @@ services: # Client events (MiniStack IoT MQTT broker) ## CLIENT_EVENTS__MQTT__RESOLVE_BROKER_ADDRESS_FROM_DESCRIBE_ENDPOINT: "${CLIENT_EVENTS__MQTT__RESOLVE_BROKER_ADDRESS_FROM_DESCRIBE_ENDPOINT-true}" - CLIENT_EVENTS__MQTT__BROKER_CONNECT_HOST: "${CLIENT_EVENTS__MQTT__BROKER_CONNECT_HOST-}" + CLIENT_EVENTS__MQTT__AWS__BROKER_CONNECT_HOST: "${CLIENT_EVENTS__MQTT__AWS__BROKER_CONNECT_HOST-}" CLIENT_EVENTS__MQTT__BROKER_URL: "${CLIENT_EVENTS__MQTT__BROKER_URL-}" CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX: "${CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX-RedShirt.Example.Api}" # Set specific protocol version From 1860b161adeaf89a93a1a7086dfe406070dbc1df Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 10:46:16 -0700 Subject: [PATCH 11/31] documentation updates --- docs/patterns/server-sent-events.md | 55 +++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/docs/patterns/server-sent-events.md b/docs/patterns/server-sent-events.md index 8b9b3a7..9ac6665 100644 --- a/docs/patterns/server-sent-events.md +++ b/docs/patterns/server-sent-events.md @@ -2,6 +2,24 @@ This document talks a bit about this template's usage of Server-Sent Events to send live updates to connected clients. +# Overview + +Server-Sent Events (SSE) send a stream of event messages to a client as opposed to having them poll for new information. + +This template uses the MQTT to coordinate messages between clients. Using an external service is necessary for a couple +of reasons: + +* There could be more than one instance of an API running in an environment. An in-process coordination system would not + have awareness of other instances. +* There could be other sources writing events directly to the external service (though this is not recommended for + single-responsibility reasons). + +# Client Examples + +## JavaScript + +STUB + # Special Cases ## AWS IoT Endpoint Resolution @@ -9,11 +27,34 @@ This document talks a bit about this template's usage of Server-Sent Events to s During local testing, it was discovered that pointing the Broker URL didn't cut it for connecting to MiniStack in the local Docker Compose stack. -This problem is in part a product of our choices to use MiniStack's IoT emulator for local testing. However, I am -choosing to keep this feature in the template because it's possible (though not required) to resolve a broker URL using -the AWSSDK.IoT client. It will return an IoT data-plane hostname that is stable for a given account and region. That -said, I'm also acknowledging that AWS deployments are not everyone's end goal. In the same vein as the rest of this -template, the AWSSDK.IoT operations have been quarantined in a separate project, -`RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws` in order to limit the scope of the removal. +This issue is in part a product of my choice to use MiniStack's IoT emulator for local testing. However, I am choosing +to keep this feature in the template because it's possible (though not required) to resolve a broker URL using the +AWSSDK.IoT client if one is working in an AWS environment. It will return an IoT data-plane hostname that is stable for +a given account and region. That said, I'm also acknowledging that AWS deployments are not everyone's end goal. In the +same vein as the rest of this template, the AWSSDK.IoT operations have been quarantined in a separate +`RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws` project in order to limit the complexity of the removal. + +If you want to remove the use of the `AWSSDK.IoT` altogether, then the following steps are advised: -If you want to remove the use of the `AWSSDK.IoT` altogether, then {END OF SESSION, WILL FINISH LATER} \ No newline at end of file +1. Delete the `RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws` project. Off a cliff, it goes. +2. Remove the dependency injection setup issue that pops up in the root `RedShirt.Example.Api` project. +3. Do one of the following in the `RedShirt.Example.Api.ClientEvents.Library.Mqtt` project to address the need for an + implementation of `IMqttBrokerUrlResolver` in `MqttClientFactory`: + * Delete the `IMqttBrokerUrlResolver` altogether, and resolve any build errors that come up from it. I would + recommend this path, as apparently there's no other system outside of AWS that uses a resolution endpoint (except + perhaps if you insisted on also storing the broker URL in a secret manager?). + * If you decide to take this path, then in `` you could probably just fold `ResolveBrokerTargetInnerAsync` into + ResolveBrokerTargetAsync. + * Add in a quick stub implementation of the `IMqttBrokerUrlResolver` interface. + * The implementation can just return an object with a null URL. + * Be sure to set configuration so that this path is never accessed. +4. The `AWSSDK.IoT` library was required to locally test MQTT using MiniStack, so you will need to adjust your local + compose stack to use a different server. The following are some potential options (these haven't been explored in + detail, MiniStack was deemed good enough for the time being): + * Mosquitto: `eclipse-mosquitto` image + * According to Google, this has the lowest resource footprint of the immediate options. + * EMQX: `emqx/emqx` image + * HiveMQ (Community Edition): `hivemq/hivemq-ce` image + * RabbitMQ (not enabled by default, needs to be explicitly enabled) +5. Update configuration in local `test/local/docker-compose.yaml` file to point to the new server solution. + * You may need to also set credentials in secret managers. \ No newline at end of file From 3ec369bab07da5b4b8cb77fd0a6f9fec6b6c31d2 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 10:49:02 -0700 Subject: [PATCH 12/31] Documentation: Generate an example Javascript client. --- docs/patterns/server-sent-events.md | 77 ++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/docs/patterns/server-sent-events.md b/docs/patterns/server-sent-events.md index 9ac6665..f8639c2 100644 --- a/docs/patterns/server-sent-events.md +++ b/docs/patterns/server-sent-events.md @@ -14,12 +14,6 @@ of reasons: * There could be other sources writing events directly to the external service (though this is not recommended for single-responsibility reasons). -# Client Examples - -## JavaScript - -STUB - # Special Cases ## AWS IoT Endpoint Resolution @@ -57,4 +51,73 @@ If you want to remove the use of the `AWSSDK.IoT` altogether, then the following * HiveMQ (Community Edition): `hivemq/hivemq-ce` image * RabbitMQ (not enabled by default, needs to be explicitly enabled) 5. Update configuration in local `test/local/docker-compose.yaml` file to point to the new server solution. - * You may need to also set credentials in secret managers. \ No newline at end of file + * You may need to also set credentials in secret managers. + +# Client Examples + +## JavaScript + +The example stream lives at `GET /messages/event-stream`. Each event uses the SSE event name `message`, and the +`data` field is the plain message text (not JSON). The endpoint requires a bearer token with the `api:read` scope. + +Browsers cannot set custom headers on the built-in `EventSource` API, so the example below uses `fetch` and reads the +response body as a stream. This works in modern browsers and in Node.js 18+. + +```javascript +async function listenToMessageStream(apiBaseUrl, accessToken) { + const url = `${apiBaseUrl.replace(/\/$/, "")}/messages/event-stream`; + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "text/event-stream", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${await response.text()}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + console.log(`Listening on ${url} (Ctrl+C to stop)...`); + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + + let eventBoundary; + while ((eventBoundary = buffer.indexOf("\n\n")) >= 0) { + const block = buffer.slice(0, eventBoundary); + buffer = buffer.slice(eventBoundary + 2); + + let eventName = "message"; + const dataLines = []; + + for (const line of block.split("\n")) { + if (line.startsWith("event:")) { + eventName = line.slice("event:".length).trim(); + } else if (line.startsWith("data:")) { + dataLines.push(line.slice("data:".length).trimStart()); + } + } + + if (dataLines.length > 0) { + console.log(`[${eventName}] ${dataLines.join("\n")}`); + } + } + } +} + +// Example: +// listenToMessageStream("http://localhost:8080", process.env.API_JWT_TOKEN); +``` + +To exercise the stream locally, open a terminal running the listener above, then publish a message with +`POST /messages` (requires `api:write`). The listener should print lines like `[message] hello from mqtt`. \ No newline at end of file From 0e4489387d7967117978fb0c4366bb0f5198de44 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 10:51:05 -0700 Subject: [PATCH 13/31] Documentation: Other Resources section --- docs/patterns/server-sent-events.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/patterns/server-sent-events.md b/docs/patterns/server-sent-events.md index f8639c2..3340704 100644 --- a/docs/patterns/server-sent-events.md +++ b/docs/patterns/server-sent-events.md @@ -120,4 +120,13 @@ async function listenToMessageStream(apiBaseUrl, accessToken) { ``` To exercise the stream locally, open a terminal running the listener above, then publish a message with -`POST /messages` (requires `api:write`). The listener should print lines like `[message] hello from mqtt`. \ No newline at end of file +`POST /messages` (requires `api:write`). The listener should print lines like `[message] hello from mqtt`. + +# Other Resources + +The following links have useful reading material relating to Server-Sent Events and MQTT: + +* https://milanjovanovic.tech/blog/server-sent-events-in-aspnetcore-and-dotnet-10 +* https://docs.aws.amazon.com/whitepapers/latest/designing-mqtt-topics-aws-iot-core/mqtt-design-best-practices.html +* https://github.com/dotnet/MQTTnet/wiki/Tips-and-Tricks +* https://www.hivemq.com/blog/mqtt-essentials-part-5-mqtt-topics-best-practices/ \ No newline at end of file From 3cffbdc3e7e1b2c31139f3b389b5c1ff748e8de6 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 10:57:19 -0700 Subject: [PATCH 14/31] Documentation: Describe endpoints --- docs/patterns/server-sent-events.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/patterns/server-sent-events.md b/docs/patterns/server-sent-events.md index 3340704..8ca13b8 100644 --- a/docs/patterns/server-sent-events.md +++ b/docs/patterns/server-sent-events.md @@ -14,6 +14,19 @@ of reasons: * There could be other sources writing events directly to the external service (though this is not recommended for single-responsibility reasons). +## Template Example + +This template has a simple messaging example: + +* The POST endpoint at `/messages` can be used by a client to send events that will be read by any instance of the + client's user connected to the event-stream. +* The GET endpoint at `/messages/event-stream` can be used by a client to receive events related to their user. Users + will not receive messages intended for other users. + +# Configuration Options + +STUB + # Special Cases ## AWS IoT Endpoint Resolution From 51e4eb065b91a6732edc9e0e7f9cdc48279bc1a1 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 10:59:04 -0700 Subject: [PATCH 15/31] generalize --- .../Services/MqttBrokerUrlResolver.cs | 4 ++-- .../Factories/MqttClientFactory.cs | 24 ++++++++++++------- test/local/README.md | 2 +- test/local/docker-compose.yaml | 2 +- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs index a7a8b03..1556b05 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs @@ -14,7 +14,7 @@ namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.Services; /// /// /// Runtime resolution is optional. The MQTT client factory uses this resolver only when -/// ClientEvents:Mqtt:ResolveBrokerAddressFromDescribeEndpoint is true. In a deployed AWS +/// ClientEvents:Mqtt:ResolveBrokerAddressExternally is true. In a deployed AWS /// environment you can set that flag to false and supply the IoT data-plane endpoint as /// ClientEvents:Mqtt:BrokerUrl instead (for example from IaC output, SSM, or an environment variable). /// The hostname returned by DescribeEndpoint (iot:Data-ATS) is stable for a given account and @@ -103,7 +103,7 @@ internal sealed class ConfigurationModel /// /// /// Intended for local Docker / MiniStack testing only, when - /// ResolveBrokerAddressFromDescribeEndpoint is true. Leave unset in real AWS, where the + /// ResolveBrokerAddressExternally is true. Leave unset in real AWS, where the /// DescribeEndpoint hostname is both the advertised and connectable target. When set, the resolver /// TCP-connects to this host and sends the DescribeEndpoint address as the HTTP Host header (for /// example when *.localhost or *.ministack does not resolve to the MiniStack container from diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Factories/MqttClientFactory.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Factories/MqttClientFactory.cs index 6890332..7079881 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Factories/MqttClientFactory.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Factories/MqttClientFactory.cs @@ -57,11 +57,14 @@ private async Task ResolveBrokerTargetAsync(ConfigurationModel } /// - /// Try to resolve broker URL. - /// If is false, then use configured broker URL. - /// If is true, then use - /// . + /// Resolves the broker connection target from configuration or an external resolver. /// + /// + /// When is false, returns + /// as-is. When true, delegates to + /// (for example the AWS IoT implementation in + /// RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws). + /// /// /// /// @@ -69,7 +72,7 @@ private async Task ResolveBrokerTargetAsync(ConfigurationModel private async Task ResolveBrokerTargetInnerAsync(ConfigurationModel config, CancellationToken cancellationToken) { - if (!config.ResolveBrokerAddressFromDescribeEndpoint) + if (!config.ResolveBrokerAddressExternally) { return new MqttBrokerTarget { @@ -267,10 +270,15 @@ internal sealed class ConfigurationModel public string? BrokerUrl { get; init; } /// - /// When true, resolves the WebSocket broker address via IoT DescribeEndpoint instead of using - /// verbatim. Required for MiniStack and AWS IoT WebSocket clients. + /// When true, resolves the broker address through instead of using + /// verbatim. /// - public bool ResolveBrokerAddressFromDescribeEndpoint { get; init; } + /// + /// Use this when the broker URL cannot be known up front or when the registered resolver supplies + /// transport-specific connection metadata (for example a WebSocket Host header). When false, + /// must be set to a complete broker address. + /// + public bool ResolveBrokerAddressExternally { get; init; } /// /// MQTT protocol version to use when connecting. Expected to resolve to a diff --git a/test/local/README.md b/test/local/README.md index f3df35d..7cfb986 100644 --- a/test/local/README.md +++ b/test/local/README.md @@ -392,7 +392,7 @@ Local Compose resolves the MQTT broker address from IoT `DescribeEndpoint` (the | Variable | Local default | |-----------------------------------------------------------------------|------------------------| -| `CLIENT_EVENTS__MQTT__RESOLVE_BROKER_ADDRESS_FROM_DESCRIBE_ENDPOINT` | `true` | +| `CLIENT_EVENTS__MQTT__RESOLVE_BROKER_ADDRESS_EXTERNALLY` | `true` | | `CLIENT_EVENTS__MQTT__AWS__BROKER_CONNECT_HOST` | unset (connect to DescribeEndpoint host) | | `CLIENT_EVENTS__MQTT__BROKER_URL` | unset (use DescribeEndpoint) | | `CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX` | `RedShirt.Example.Api` | diff --git a/test/local/docker-compose.yaml b/test/local/docker-compose.yaml index 667279f..8d57d92 100644 --- a/test/local/docker-compose.yaml +++ b/test/local/docker-compose.yaml @@ -181,7 +181,7 @@ services: UPLOADS__PRESIGNED_URL_LIFETIME_MINUTES: "${UPLOADS__PRESIGNED_URL_LIFETIME_MINUTES-15}" # Client events (MiniStack IoT MQTT broker) ## - CLIENT_EVENTS__MQTT__RESOLVE_BROKER_ADDRESS_FROM_DESCRIBE_ENDPOINT: "${CLIENT_EVENTS__MQTT__RESOLVE_BROKER_ADDRESS_FROM_DESCRIBE_ENDPOINT-true}" + CLIENT_EVENTS__MQTT__RESOLVE_BROKER_ADDRESS_EXTERNALLY: "${CLIENT_EVENTS__MQTT__RESOLVE_BROKER_ADDRESS_EXTERNALLY-true}" CLIENT_EVENTS__MQTT__AWS__BROKER_CONNECT_HOST: "${CLIENT_EVENTS__MQTT__AWS__BROKER_CONNECT_HOST-}" CLIENT_EVENTS__MQTT__BROKER_URL: "${CLIENT_EVENTS__MQTT__BROKER_URL-}" CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX: "${CLIENT_EVENTS__MQTT__CLIENT_ID_PREFIX-RedShirt.Example.Api}" From 702f1bd5abca61b6d64279df79f968adf277c91b Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 11:06:13 -0700 Subject: [PATCH 16/31] phrasing --- .../Services/MqttBrokerUrlResolver.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs index 1556b05..673c378 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs @@ -31,7 +31,7 @@ namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.Services; /// /// Local Docker / MiniStack only: and the optional /// HTTP Host header override. Inside Docker Compose the DescribeEndpoint hostname often does not resolve to -/// the LocalStack/MiniStack container, so a static BrokerUrl alone is insufficient — TCP must connect to a +/// the LocalStack/MiniStack container, so a static BrokerUrl alone is insufficient. TCP must connect to a /// configured host (for example ministack) while the upgrade still advertises the IoT endpoint hostname. /// This split connect/host behaviour is what makes runtime resolution (or equivalent manual wiring) necessary /// locally but not in AWS. From d9e2bcdd791f990f55890ab3d53c7785615a3da2 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 11:11:03 -0700 Subject: [PATCH 17/31] unit tests --- RedShirt.Example.Api.slnx | 7 +- .../Globals.cs | 4 + .../Globals.cs | 4 +- ...ntEvents.Library.Mqtt.Aws.UnitTests.csproj | 37 +++++ .../Support/TestUtilities.cs | 38 ++++++ .../ServiceCollectionExtensionsTests.cs | 48 +++++++ .../Services/MqttBrokerUrlResolverTests.cs | 107 +++++++++++++++ ...ClientEvents.Library.Mqtt.UnitTests.csproj | 36 +++++ .../Factories/ApiMqttClientFactoryTests.cs | 126 ++++++++++++++++++ .../Services/MqttApiClientEventSenderTests.cs | 73 ++++++++++ ...lientEventsExceptionArbiterServiceTests.cs | 122 +++++++++++++++++ ...qttClientEventsRetryWrapperServiceTests.cs | 111 +++++++++++++++ 12 files changed, 711 insertions(+), 2 deletions(-) create mode 100644 src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Globals.cs create mode 100644 test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests.csproj create mode 100644 test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Support/TestUtilities.cs create mode 100644 test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Extensions/ServiceCollectionExtensionsTests.cs create mode 100644 test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Services/MqttBrokerUrlResolverTests.cs create mode 100644 test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests.csproj create mode 100644 test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Factories/ApiMqttClientFactoryTests.cs create mode 100644 test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Services/MqttApiClientEventSenderTests.cs create mode 100644 test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Services/Resilience/MqttClientEventsExceptionArbiterServiceTests.cs create mode 100644 test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Services/Resilience/MqttClientEventsRetryWrapperServiceTests.cs diff --git a/RedShirt.Example.Api.slnx b/RedShirt.Example.Api.slnx index 67c955f..66a9b31 100644 --- a/RedShirt.Example.Api.slnx +++ b/RedShirt.Example.Api.slnx @@ -156,5 +156,10 @@ - + + + + diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Globals.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Globals.cs new file mode 100644 index 0000000..005a7da --- /dev/null +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Globals.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] +[assembly: InternalsVisibleTo("RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests")] \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Globals.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Globals.cs index 0f94766..4682326 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Globals.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Globals.cs @@ -1,4 +1,6 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] -[assembly: InternalsVisibleTo("RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws")] \ No newline at end of file +[assembly: InternalsVisibleTo("RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws")] +[assembly: InternalsVisibleTo("RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests")] +[assembly: InternalsVisibleTo("RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests")] \ No newline at end of file diff --git a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests.csproj b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests.csproj new file mode 100644 index 0000000..f921156 --- /dev/null +++ b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + enable + enable + + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Support/TestUtilities.cs b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Support/TestUtilities.cs new file mode 100644 index 0000000..396d0a1 --- /dev/null +++ b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Support/TestUtilities.cs @@ -0,0 +1,38 @@ +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests.Support; + +internal static class TestUtilities +{ + internal static void WrapEnvironment(IReadOnlyDictionary values, Action action) + { + var previous = new Dictionary(StringComparer.Ordinal); + + foreach (var (key, value) in values) + { + previous[key] = Environment.GetEnvironmentVariable(key); + Environment.SetEnvironmentVariable(key, value); + } + + try + { + action(); + } + finally + { + foreach (var (key, value) in previous) + { + Environment.SetEnvironmentVariable(key, value); + } + } + } + + internal static void WrapLocalAwsEnvironment(Action action) + { + WrapEnvironment(new Dictionary + { + ["AWS_SERVICE_URL"] = "http://localhost:4566", + ["AWS_ACCESS_KEY_ID"] = "foo", + ["AWS_SECRET_ACCESS_KEY"] = "bar", + ["AWS_REGION"] = "us-east-1" + }, action); + } +} \ No newline at end of file diff --git a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Extensions/ServiceCollectionExtensionsTests.cs b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Extensions/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..37c9c10 --- /dev/null +++ b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Extensions/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,48 @@ +using Amazon.IoT; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.Extensions; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.Services; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests.Support; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; + +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests.Tests.Extensions; + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddAwsMqttClientEvents_RegistersAmazonIoTClient() + { + var configuration = new ConfigurationBuilder().Build(); + + TestUtilities.WrapLocalAwsEnvironment(() => + { + var provider = new ServiceCollection() + .AddAwsMqttClientEvents(configuration) + .BuildServiceProvider(); + + Assert.NotNull(provider.GetService()); + }); + } + + [Fact] + public void AddAwsMqttClientEvents_RegistersBrokerUrlResolver() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ClientEvents:Mqtt:AWS:BrokerConnectHost"] = "ministack" + }) + .Build(); + + TestUtilities.WrapLocalAwsEnvironment(() => + { + var provider = new ServiceCollection() + .AddAwsMqttClientEvents(configuration) + .BuildServiceProvider(); + + var resolver = provider.GetRequiredService(); + Assert.IsType(resolver); + }); + } +} \ No newline at end of file diff --git a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Services/MqttBrokerUrlResolverTests.cs b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Services/MqttBrokerUrlResolverTests.cs new file mode 100644 index 0000000..c58dd45 --- /dev/null +++ b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Services/MqttBrokerUrlResolverTests.cs @@ -0,0 +1,107 @@ +using Amazon.IoT; +using Amazon.IoT.Model; +using Microsoft.Extensions.Options; +using Moq; +using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.Services; + +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests.Tests.Services; + +public class MqttBrokerUrlResolverTests +{ + private static MqttBrokerUrlResolver CreateSut( + Mock amazonIoT, + MqttBrokerUrlResolver.ConfigurationModel? configuration = null) + { + return new MqttBrokerUrlResolver( + amazonIoT.Object, + Options.Create(configuration ?? new MqttBrokerUrlResolver.ConfigurationModel())); + } + + [Fact] + public async Task ResolveBrokerUrlAsync_ReturnsConnectHostAndHostHeader_WhenBrokerConnectHostSet() + { + const string endpointAddress = "abc123-ats.iot.us-east-1.ministack:4566"; + var amazonIoT = new Mock(MockBehavior.Strict); + amazonIoT + .Setup(i => i.DescribeEndpointAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new DescribeEndpointResponse + { + EndpointAddress = endpointAddress + }); + + var sut = CreateSut(amazonIoT, new MqttBrokerUrlResolver.ConfigurationModel + { + BrokerConnectHost = "ministack" + }); + + var target = await sut.ResolveBrokerUrlAsync(TestContext.Current.CancellationToken); + + Assert.Equal("ws://ministack:4566/mqtt", target.BrokerUrl); + Assert.Equal(endpointAddress, target.WebSocketHostHeader); + amazonIoT.VerifyAll(); + } + + [Fact] + public async Task ResolveBrokerUrlAsync_ReturnsDirectEndpoint_WhenBrokerConnectHostUnset() + { + const string endpointAddress = "abc123-ats.iot.us-east-1.amazonaws.com"; + var amazonIoT = new Mock(MockBehavior.Strict); + amazonIoT + .Setup(i => i.DescribeEndpointAsync( + It.Is(request => request.EndpointType == "iot:Data-ATS"), + It.IsAny())) + .ReturnsAsync(new DescribeEndpointResponse + { + EndpointAddress = endpointAddress + }); + + var sut = CreateSut(amazonIoT); + + var target = await sut.ResolveBrokerUrlAsync(TestContext.Current.CancellationToken); + + Assert.Equal($"ws://{endpointAddress}/mqtt", target.BrokerUrl); + Assert.Null(target.WebSocketHostHeader); + amazonIoT.VerifyAll(); + } + + [Fact] + public async Task ResolveBrokerUrlAsync_Throws_WhenEndpointAddressInvalid() + { + var amazonIoT = new Mock(MockBehavior.Strict); + amazonIoT + .Setup(i => i.DescribeEndpointAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new DescribeEndpointResponse + { + EndpointAddress = "://not-a-valid-host" + }); + + var sut = CreateSut(amazonIoT); + + var exception = await Assert.ThrowsAsync(() => + sut.ResolveBrokerUrlAsync(TestContext.Current.CancellationToken)); + + Assert.False(exception.CouldBeTransient); + amazonIoT.VerifyAll(); + } + + [Fact] + public async Task ResolveBrokerUrlAsync_Throws_WhenEndpointAddressMissing() + { + var amazonIoT = new Mock(MockBehavior.Strict); + amazonIoT + .Setup(i => i.DescribeEndpointAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new DescribeEndpointResponse + { + EndpointAddress = null + }); + + var sut = CreateSut(amazonIoT); + + var exception = await Assert.ThrowsAsync(() => + sut.ResolveBrokerUrlAsync(TestContext.Current.CancellationToken)); + + Assert.True(exception.CouldBeTransient); + amazonIoT.VerifyAll(); + } +} \ No newline at end of file diff --git a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests.csproj b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests.csproj new file mode 100644 index 0000000..94058f1 --- /dev/null +++ b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + enable + enable + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Factories/ApiMqttClientFactoryTests.cs b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Factories/ApiMqttClientFactoryTests.cs new file mode 100644 index 0000000..d46e2ee --- /dev/null +++ b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Factories/ApiMqttClientFactoryTests.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Factories; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Models; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; +using RedShirt.Example.Api.Common.SecretManagers.Core.Services; + +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests.Tests.Factories; + +public class ApiMqttClientFactoryTests +{ + private static ApiMqttClientFactory CreateSut( + ApiMqttClientFactory.ConfigurationModel configuration, + Mock? brokerUrlResolver = null, + Mock? secretManager = null) + { + brokerUrlResolver ??= new Mock(MockBehavior.Strict); + secretManager ??= new Mock(MockBehavior.Strict); + + return new ApiMqttClientFactory( + Options.Create(configuration), + secretManager.Object, + brokerUrlResolver.Object, + NullLogger.Instance); + } + + [Fact] + public async Task CreateConnectedClientAsync_WhenBrokerUrlMissing_ThrowsBeforeConnecting() + { + var brokerUrlResolver = new Mock(MockBehavior.Strict); + var sut = CreateSut( + new ApiMqttClientFactory.ConfigurationModel + { + ResolveBrokerAddressExternally = false, + BrokerUrl = null + }, + brokerUrlResolver); + + var exception = await Assert.ThrowsAsync(() => + sut.CreateConnectedClientAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("broker URL", exception.Message, StringComparison.OrdinalIgnoreCase); + brokerUrlResolver.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CreateConnectedClientAsync_WhenCredentialsConfigured_ResolvesSecretsBeforeConnecting() + { + var brokerUrlResolver = new Mock(MockBehavior.Strict); + var secretManager = new Mock(MockBehavior.Strict); + secretManager + .Setup(s => s.GetSecretsAsync( + It.Is>(paths => + paths.Contains("/mqtt/user") && paths.Contains("/mqtt/pass")), + It.IsAny())) + .ReturnsAsync(new Dictionary + { + ["/mqtt/user"] = "mqtt-user", + ["/mqtt/pass"] = "mqtt-pass" + }); + + var sut = CreateSut( + new ApiMqttClientFactory.ConfigurationModel + { + ResolveBrokerAddressExternally = false, + BrokerUrl = "mqtt://127.0.0.1:1", + UsernameSecretPath = "/mqtt/user", + PasswordSecretPath = "/mqtt/pass" + }, + brokerUrlResolver, + secretManager); + + await Assert.ThrowsAnyAsync(() => + sut.CreateConnectedClientAsync(TestContext.Current.CancellationToken)); + + secretManager.Verify( + s => s.GetSecretsAsync(It.IsAny>(), It.IsAny()), + Times.Once); + brokerUrlResolver.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CreateConnectedClientAsync_WhenResolveExternallyFalse_DoesNotCallResolver() + { + var brokerUrlResolver = new Mock(MockBehavior.Strict); + var sut = CreateSut( + new ApiMqttClientFactory.ConfigurationModel + { + ResolveBrokerAddressExternally = false, + BrokerUrl = "mqtt://127.0.0.1:1" + }, + brokerUrlResolver); + + await Assert.ThrowsAnyAsync(() => + sut.CreateConnectedClientAsync(TestContext.Current.CancellationToken)); + + brokerUrlResolver.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CreateConnectedClientAsync_WhenResolveExternallyTrue_CallsResolver() + { + var brokerUrlResolver = new Mock(MockBehavior.Strict); + brokerUrlResolver + .Setup(r => r.ResolveBrokerUrlAsync(It.IsAny())) + .ReturnsAsync(new MqttBrokerTarget + { + BrokerUrl = "mqtt://127.0.0.1:1" + }); + + var sut = CreateSut( + new ApiMqttClientFactory.ConfigurationModel + { + ResolveBrokerAddressExternally = true + }, + brokerUrlResolver); + + await Assert.ThrowsAnyAsync(() => + sut.CreateConnectedClientAsync(TestContext.Current.CancellationToken)); + + brokerUrlResolver.Verify(r => r.ResolveBrokerUrlAsync(It.IsAny()), Times.Once); + brokerUrlResolver.VerifyNoOtherCalls(); + } +} \ No newline at end of file diff --git a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Services/MqttApiClientEventSenderTests.cs b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Services/MqttApiClientEventSenderTests.cs new file mode 100644 index 0000000..d856e8f --- /dev/null +++ b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Services/MqttApiClientEventSenderTests.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; +using RedShirt.Example.Api.ClientEvents.Library.Core.Models; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Factories; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services.Resilience; + +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests.Tests.Services; + +public class MqttApiClientEventSenderTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task SendAsync_WhenTopicMissing_ThrowsWithoutCallingDependencies(string? topic) + { + var mqttClientFactory = new Mock(MockBehavior.Strict); + var retryWrapper = new Mock(MockBehavior.Strict); + + var sut = new MqttApiClientEventSender( + mqttClientFactory.Object, + retryWrapper.Object, + NullLogger>.Instance); + + var exception = await Assert.ThrowsAsync(() => + sut.SendAsync(new ApiClientEventSendRequest + { + Topic = topic, + Payload = "hello" + }, TestContext.Current.CancellationToken)); + + Assert.Contains("topic", exception.Message, StringComparison.OrdinalIgnoreCase); + mqttClientFactory.VerifyNoOtherCalls(); + retryWrapper.VerifyNoOtherCalls(); + } + + [Fact] + public async Task SendAsync_WhenTopicPresent_DelegatesToRetryWrapper() + { + var mqttClientFactory = new Mock(MockBehavior.Strict); + var retryWrapper = new Mock(MockBehavior.Strict); + retryWrapper + .Setup(r => r.RunAsync(It.IsAny>(), It.IsAny())) + .Returns, CancellationToken>((func, token) => func(token)); + + mqttClientFactory + .Setup(f => f.CreateConnectedClientAsync(It.IsAny())) + .ThrowsAsync(new ApiClientEventsException("connect failed") + { + CouldBeTransient = true, + IsHandled = false + }); + + var sut = new MqttApiClientEventSender( + mqttClientFactory.Object, + retryWrapper.Object, + NullLogger>.Instance); + + await Assert.ThrowsAsync(() => + sut.SendAsync(new ApiClientEventSendRequest + { + Topic = "example-message/user/test", + Payload = "hello" + }, TestContext.Current.CancellationToken)); + + retryWrapper.Verify( + r => r.RunAsync(It.IsAny>(), It.IsAny()), + Times.Once); + mqttClientFactory.Verify(f => f.CreateConnectedClientAsync(It.IsAny()), Times.Once); + } +} \ No newline at end of file diff --git a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Services/Resilience/MqttClientEventsExceptionArbiterServiceTests.cs b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Services/Resilience/MqttClientEventsExceptionArbiterServiceTests.cs new file mode 100644 index 0000000..b6ff0be --- /dev/null +++ b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Services/Resilience/MqttClientEventsExceptionArbiterServiceTests.cs @@ -0,0 +1,122 @@ +using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services.Resilience; +using RedShirt.Example.Api.Common.SecretManagers.Core.Exceptions; +using System.Net.Sockets; +using System.Text.Json; + +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests.Tests.Services.Resilience; + +public class MqttClientEventsExceptionArbiterServiceTests +{ + private readonly MqttClientEventsExceptionArbiterService _sut = new(); + + [Fact] + public void GetReport_ApiClientEventsException_IsAlreadyHandled() + { + var report = _sut.GetReport(new ApiClientEventsException("done") + { + IsHandled = true, + CouldBeTransient = true + }); + + Assert.True(report.AlreadyHandled); + Assert.True(report.IsExpected); + Assert.True(report.CouldBeTransient); + } + + [Fact] + public void GetReport_ApiClientEventsException_NonTransient_IsExpected() + { + var report = _sut.GetReport(new ApiClientEventsException("config") + { + IsHandled = false, + CouldBeTransient = false + }); + + Assert.False(report.AlreadyHandled); + Assert.True(report.IsExpected); + Assert.False(report.CouldBeTransient); + } + + [Fact] + public void GetReport_ApiClientEventsException_Transient_IsExpected() + { + var report = _sut.GetReport(new ApiClientEventsException("timeout") + { + IsHandled = false, + CouldBeTransient = true + }); + + Assert.False(report.AlreadyHandled); + Assert.True(report.IsExpected); + Assert.True(report.CouldBeTransient); + } + + [Fact] + public void GetReport_ApiSecretManagerException_IsAlreadyHandled() + { + var report = _sut.GetReport(new ApiSecretManagerException(new InvalidOperationException("ssm")) + { + IsHandled = true, + CouldBeTransient = true, + CouldBeExternallySolvable = true + }); + + Assert.True(report.AlreadyHandled); + Assert.True(report.IsExpected); + } + + [Fact] + public void GetReport_JsonException_IsExpectedNonTransient() + { + var report = _sut.GetReport(new JsonException("bad payload")); + + Assert.True(report.IsExpected); + Assert.False(report.CouldBeTransient); + } + + [Fact] + public void GetReport_OperationCanceledException_IsNotTransient() + { + var report = _sut.GetReport(new OperationCanceledException()); + + Assert.True(report.IsExpected); + Assert.False(report.CouldBeTransient); + } + + [Fact] + public void GetReport_SocketException_IsTransient() + { + var report = _sut.GetReport(new SocketException()); + + Assert.True(report.IsExpected); + Assert.True(report.CouldBeTransient); + } + + [Fact] + public void GetReport_TaskCanceledException_IsTransient() + { + var report = _sut.GetReport(new TaskCanceledException()); + + Assert.True(report.IsExpected); + Assert.True(report.CouldBeTransient); + } + + [Fact] + public void GetReport_UnknownException_IsUnexpected() + { + var report = _sut.GetReport(new InvalidOperationException("surprise")); + + Assert.False(report.IsExpected); + Assert.False(report.CouldBeTransient); + } + + [Fact] + public void GetReport_UnwrapsSingleAggregateException() + { + var report = _sut.GetReport(new AggregateException(new SocketException())); + + Assert.True(report.IsExpected); + Assert.True(report.CouldBeTransient); + } +} \ No newline at end of file diff --git a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Services/Resilience/MqttClientEventsRetryWrapperServiceTests.cs b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Services/Resilience/MqttClientEventsRetryWrapperServiceTests.cs new file mode 100644 index 0000000..b54f849 --- /dev/null +++ b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Services/Resilience/MqttClientEventsRetryWrapperServiceTests.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; +using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services.Resilience; +using RedShirt.Example.Api.Common.Services.Utility; +using System.Net.Sockets; +using System.Text.Json; + +namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests.Tests.Services.Resilience; + +public class MqttClientEventsRetryWrapperServiceTests +{ + private static MqttClientEventsRetryWrapperService CreateSut( + IMqttClientEventsExceptionArbiterService? arbiter = null, + ISleepService? sleep = null, + int? retryCount = 3) + { + var sleepMock = new Mock(); + sleepMock + .Setup(s => s.DelayAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + return new MqttClientEventsRetryWrapperService( + arbiter ?? new MqttClientEventsExceptionArbiterService(), + NullLogger.Instance, + sleep ?? sleepMock.Object, + Options.Create(new MqttClientEventsRetryWrapperService.ConfigurationModel + { + RetryCount = retryCount + })); + } + + [Fact] + public async Task RunAsync_DoesNotWrap_UnexpectedExceptions() + { + var sut = CreateSut(); + + await Assert.ThrowsAsync(() => + sut.RunAsync(_ => throw new InvalidOperationException("surprise"), + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RunAsync_PassesThrough_HandledApiClientEventsException() + { + var sut = CreateSut(); + + var original = new ApiClientEventsException("done") + { + IsHandled = true, + CouldBeTransient = true + }; + + var thrown = await Assert.ThrowsAsync(() => + sut.RunAsync(_ => throw original, TestContext.Current.CancellationToken)); + + Assert.Same(original, thrown); + } + + [Fact] + public async Task RunAsync_RetriesTransientFailures_ThenSucceeds() + { + var attempts = 0; + var sleep = new Mock(MockBehavior.Strict); + sleep + .Setup(s => s.DelayAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var sut = CreateSut(sleep: sleep.Object); + + var result = await sut.RunAsync(_ => + { + attempts++; + if (attempts < 3) + { + throw new SocketException(); + } + + return Task.FromResult("ok"); + }, TestContext.Current.CancellationToken); + + Assert.Equal("ok", result); + Assert.Equal(3, attempts); + sleep.Verify(s => s.DelayAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task RunAsync_ReturnsResult_WhenFuncSucceeds() + { + var sut = CreateSut(); + + var result = await sut.RunAsync(_ => Task.FromResult(42), TestContext.Current.CancellationToken); + + Assert.Equal(42, result); + } + + [Fact] + public async Task RunAsync_WrapsExpectedNonTransient_AsApiClientEventsException() + { + var sut = CreateSut(); + + var wrapped = await Assert.ThrowsAsync(() => + sut.RunAsync(_ => throw new JsonException("bad payload"), + TestContext.Current.CancellationToken)); + + Assert.True(wrapped.IsHandled); + Assert.False(wrapped.CouldBeTransient); + Assert.IsType(wrapped.InnerException); + } +} \ No newline at end of file From 0950a68af51488d3aee2dd699062dc3db820f5c2 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 14:50:03 -0700 Subject: [PATCH 18/31] rename url resolver to be more indicative --- .../Extensions/ServiceCollectionExtensions.cs | 4 ++-- ...UrlResolver.cs => AwsIotMqttBrokerUrlResolver.cs} | 4 ++-- .../Extensions/ServiceCollectionExtensionsTests.cs | 2 +- ...rTests.cs => AwsIotMqttBrokerUrlResolverTests.cs} | 12 ++++++------ 4 files changed, 11 insertions(+), 11 deletions(-) rename src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/{MqttBrokerUrlResolver.cs => AwsIotMqttBrokerUrlResolver.cs} (97%) rename test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Services/{MqttBrokerUrlResolverTests.cs => AwsIotMqttBrokerUrlResolverTests.cs} (89%) diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Extensions/ServiceCollectionExtensions.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Extensions/ServiceCollectionExtensions.cs index 6f4c21f..23608a9 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Extensions/ServiceCollectionExtensions.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Extensions/ServiceCollectionExtensions.cs @@ -18,10 +18,10 @@ public static IServiceCollection AddAwsMqttClientEvents(this IServiceCollection { services .AddAwsServiceWithLocalSupport() - .Configure( + .Configure( configuration.GetSection(ConfigurationSectionName)); - services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/AwsIotMqttBrokerUrlResolver.cs similarity index 97% rename from src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs rename to src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/AwsIotMqttBrokerUrlResolver.cs index 673c378..064c589 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/MqttBrokerUrlResolver.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws/Services/AwsIotMqttBrokerUrlResolver.cs @@ -41,9 +41,9 @@ namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.Services; /// covers endpoint discovery and URL assembly only. /// /// -internal sealed class MqttBrokerUrlResolver( +internal sealed class AwsIotMqttBrokerUrlResolver( IAmazonIoT amazonIoT, - IOptions configuration) : IMqttBrokerUrlResolver + IOptions configuration) : IMqttBrokerUrlResolver { private const string DefaultWebSocketPath = "/mqtt"; diff --git a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Extensions/ServiceCollectionExtensionsTests.cs b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Extensions/ServiceCollectionExtensionsTests.cs index 37c9c10..8b48d05 100644 --- a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Extensions/ServiceCollectionExtensionsTests.cs @@ -42,7 +42,7 @@ public void AddAwsMqttClientEvents_RegistersBrokerUrlResolver() .BuildServiceProvider(); var resolver = provider.GetRequiredService(); - Assert.IsType(resolver); + Assert.IsType(resolver); }); } } \ No newline at end of file diff --git a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Services/MqttBrokerUrlResolverTests.cs b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Services/AwsIotMqttBrokerUrlResolverTests.cs similarity index 89% rename from test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Services/MqttBrokerUrlResolverTests.cs rename to test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Services/AwsIotMqttBrokerUrlResolverTests.cs index c58dd45..a5dee68 100644 --- a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Services/MqttBrokerUrlResolverTests.cs +++ b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests/Tests/Services/AwsIotMqttBrokerUrlResolverTests.cs @@ -7,15 +7,15 @@ namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.Aws.UnitTests.Tests.Services; -public class MqttBrokerUrlResolverTests +public class AwsIotMqttBrokerUrlResolverTests { - private static MqttBrokerUrlResolver CreateSut( + private static AwsIotMqttBrokerUrlResolver CreateSut( Mock amazonIoT, - MqttBrokerUrlResolver.ConfigurationModel? configuration = null) + AwsIotMqttBrokerUrlResolver.ConfigurationModel? configuration = null) { - return new MqttBrokerUrlResolver( + return new AwsIotMqttBrokerUrlResolver( amazonIoT.Object, - Options.Create(configuration ?? new MqttBrokerUrlResolver.ConfigurationModel())); + Options.Create(configuration ?? new AwsIotMqttBrokerUrlResolver.ConfigurationModel())); } [Fact] @@ -30,7 +30,7 @@ public async Task ResolveBrokerUrlAsync_ReturnsConnectHostAndHostHeader_WhenBrok EndpointAddress = endpointAddress }); - var sut = CreateSut(amazonIoT, new MqttBrokerUrlResolver.ConfigurationModel + var sut = CreateSut(amazonIoT, new AwsIotMqttBrokerUrlResolver.ConfigurationModel { BrokerConnectHost = "ministack" }); From 3ba409fac9bafa362392d79b235c407154c7e409 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 15:00:34 -0700 Subject: [PATCH 19/31] Documentation: Talk briefly about SignalR. --- docs/patterns/server-sent-events.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/patterns/server-sent-events.md b/docs/patterns/server-sent-events.md index 8ca13b8..fb9847b 100644 --- a/docs/patterns/server-sent-events.md +++ b/docs/patterns/server-sent-events.md @@ -66,6 +66,23 @@ If you want to remove the use of the `AWSSDK.IoT` altogether, then the following 5. Update configuration in local `test/local/docker-compose.yaml` file to point to the new server solution. * You may need to also set credentials in secret managers. +# Other Technologies + +Server-Sent Events at the API level are a new feature in .NET 10. However, it was not the first iteration of the overall +pattern of streaming events. + +## SignalR + +SignalR is a historically-significant library for pushing event data to connected clients in real time. + +I chose not to use SignalR in this template for the following reasons: + +* SignalR supports bidirectional communication, which seemed like more complexity than I needed for my basic goal of + reading events. +* SignalR apparently has more of a resource footprint. + +With these reasons in mind, I would only pivot to SignalR if I had a specific case that required it. + # Client Examples ## JavaScript @@ -135,6 +152,11 @@ async function listenToMessageStream(apiBaseUrl, accessToken) { To exercise the stream locally, open a terminal running the listener above, then publish a message with `POST /messages` (requires `api:write`). The listener should print lines like `[message] hello from mqtt`. +## Python + +The local-testing scripts in the `test/local/scripts/messages/` directory has a Python-based example of listening for +events. + # Other Resources The following links have useful reading material relating to Server-Sent Events and MQTT: From 8ee45603718b97ba0e9bf549f45023fee86cebf0 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 15:03:45 -0700 Subject: [PATCH 20/31] Link in root readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 3b4f5ab..340cd0f 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Repo features in more detail: manager from being continually polled. * Configuration is based on environment variables. * File upload and tracking examples. +* Server-sent events over MQTT. + * For more information, see [`docs/patterns/server-sent-events.md`](docs/patterns/server-sent-events.md) * Documentation on common data handling practices: * [Event Sourcing](docs/patterns/event-sourcing.md) From 1290fe2518f5e455535e9b2fdfd0098eb197d97a Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 15:14:43 -0700 Subject: [PATCH 21/31] additional test coverage --- .../Factories/MqttClientFactory.cs | 13 +- .../Factories/ApiMqttClientFactoryTests.cs | 148 +++++++++++++++++- 2 files changed, 156 insertions(+), 5 deletions(-) diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Factories/MqttClientFactory.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Factories/MqttClientFactory.cs index 7079881..42a7d5f 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Factories/MqttClientFactory.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Factories/MqttClientFactory.cs @@ -107,10 +107,17 @@ private async Task GetOptionsBuilderAsync(Configur .WithClientId($"{config.EffectiveClientIdPrefix}/{ServerHostname}/{Guid.NewGuid():N}") .WithCleanSession(); - if (!string.IsNullOrWhiteSpace(config.ProtocolVersion) && - Enum.TryParse(config.ProtocolVersion, out var protocolVersion)) + if (!string.IsNullOrWhiteSpace(config.ProtocolVersion)) { - optionsBuilder.WithProtocolVersion(protocolVersion); + if (Enum.TryParse(config.ProtocolVersion, out var protocolVersion)) + { + optionsBuilder.WithProtocolVersion(protocolVersion); + } + else + { + logger.LogWarning("Failed to parse MQTT protocol version {ProtocolVersion} to {EnumName}", + config.ProtocolVersion, typeof(MqttProtocolVersion).FullName); + } } if (!Uri.TryCreate(brokerTarget.BrokerUrl!, UriKind.Absolute, out var uri)) diff --git a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Factories/ApiMqttClientFactoryTests.cs b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Factories/ApiMqttClientFactoryTests.cs index d46e2ee..417c9e9 100644 --- a/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Factories/ApiMqttClientFactoryTests.cs +++ b/test/RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests/Tests/Factories/ApiMqttClientFactoryTests.cs @@ -1,11 +1,15 @@ +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; +using MQTTnet; +using MQTTnet.Formatter; using RedShirt.Example.Api.ClientEvents.Library.Core.Exceptions; using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Factories; using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Models; using RedShirt.Example.Api.ClientEvents.Library.Mqtt.Services; using RedShirt.Example.Api.Common.SecretManagers.Core.Services; +using System.Reflection; namespace RedShirt.Example.Api.ClientEvents.Library.Mqtt.UnitTests.Tests.Factories; @@ -14,7 +18,8 @@ public class ApiMqttClientFactoryTests private static ApiMqttClientFactory CreateSut( ApiMqttClientFactory.ConfigurationModel configuration, Mock? brokerUrlResolver = null, - Mock? secretManager = null) + Mock? secretManager = null, + ILogger? logger = null) { brokerUrlResolver ??= new Mock(MockBehavior.Strict); secretManager ??= new Mock(MockBehavior.Strict); @@ -23,7 +28,53 @@ private static ApiMqttClientFactory CreateSut( Options.Create(configuration), secretManager.Object, brokerUrlResolver.Object, - NullLogger.Instance); + logger ?? NullLogger.Instance); + } + + private static void VerifyLogWarningContaining( + Mock> logger, + string expectedSubstring, + Times times) + { + logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((state, _) => + state.ToString()!.Contains(expectedSubstring, StringComparison.Ordinal)), + It.IsAny(), + It.IsAny>()), + times); + } + + private static async Task InvokeGetOptionsBuilderAsync( + ApiMqttClientFactory factory, + ApiMqttClientFactory.ConfigurationModel sutConfiguration) + { + var method = typeof(ApiMqttClientFactory).GetMethod( + "GetOptionsBuilderAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + var invokeResult = method.Invoke(factory, [sutConfiguration, CancellationToken.None]); + Assert.NotNull(invokeResult); + + var resultTask = (Task) invokeResult; + await resultTask.ConfigureAwait(false); + + var result = resultTask.GetType().GetProperty("Result")!.GetValue(resultTask); + Assert.NotNull(result); + + var optionsBuilder = result.GetType().GetProperty("OptionsBuilder")!.GetValue(result); + Assert.NotNull(optionsBuilder); + + var buildMethod = optionsBuilder.GetType().GetMethod("Build"); + Assert.NotNull(buildMethod); + + var options = buildMethod.Invoke(optionsBuilder, null); + Assert.IsType(options); + + return (MqttClientOptions) options; } [Fact] @@ -81,6 +132,45 @@ await Assert.ThrowsAnyAsync(() => brokerUrlResolver.VerifyNoOtherCalls(); } + [Fact] + public async Task CreateConnectedClientAsync_WhenProtocolVersionInvalid_LogsWarningAndStillAttemptsConnection() + { + var logger = new Mock>(); + var sut = CreateSut( + new ApiMqttClientFactory.ConfigurationModel + { + ResolveBrokerAddressExternally = false, + BrokerUrl = "mqtt://127.0.0.1:1", + ProtocolVersion = "not-a-version" + }, + logger: logger.Object); + + var exception = await Assert.ThrowsAsync(() => + sut.CreateConnectedClientAsync(TestContext.Current.CancellationToken)); + + Assert.NotEqual("not-a-version", exception.Message, StringComparer.Ordinal); + VerifyLogWarningContaining(logger, "Failed to parse MQTT protocol version", Times.Once()); + } + + [Fact] + public async Task CreateConnectedClientAsync_WhenProtocolVersionValid_DoesNotLogParseWarning() + { + var logger = new Mock>(); + var sut = CreateSut( + new ApiMqttClientFactory.ConfigurationModel + { + ResolveBrokerAddressExternally = false, + BrokerUrl = "mqtt://127.0.0.1:1", + ProtocolVersion = "V311" + }, + logger: logger.Object); + + await Assert.ThrowsAsync(() => + sut.CreateConnectedClientAsync(TestContext.Current.CancellationToken)); + + VerifyLogWarningContaining(logger, "Failed to parse MQTT protocol version", Times.Never()); + } + [Fact] public async Task CreateConnectedClientAsync_WhenResolveExternallyFalse_DoesNotCallResolver() { @@ -123,4 +213,58 @@ await Assert.ThrowsAnyAsync(() => brokerUrlResolver.Verify(r => r.ResolveBrokerUrlAsync(It.IsAny()), Times.Once); brokerUrlResolver.VerifyNoOtherCalls(); } + + [Theory] + [InlineData(null, "RedShirt.Example.Api")] + [InlineData("", "RedShirt.Example.Api")] + [InlineData(" ", "RedShirt.Example.Api")] + [InlineData("My.Custom.Prefix", "My.Custom.Prefix")] + public async Task GetOptionsBuilderAsync_WhenClientIdPrefixConfigured_UsesExpectedClientIdPrefix( + string? clientIdPrefix, + string expectedPrefix) + { + var config = new ApiMqttClientFactory.ConfigurationModel + { + ResolveBrokerAddressExternally = false, + BrokerUrl = "mqtt://127.0.0.1:1", + ClientIdPrefix = clientIdPrefix + }; + + var sut = CreateSut(config); + var options = await InvokeGetOptionsBuilderAsync(sut, config); + + var serverHostname = string.IsNullOrWhiteSpace(Environment.MachineName) + ? "unknown" + : Environment.MachineName; + + Assert.StartsWith($"{expectedPrefix}/{serverHostname}/", options.ClientId); + + var clientIdSegments = options.ClientId.Split('/'); + Assert.Equal(3, clientIdSegments.Length); + Assert.Matches("^[0-9a-f]{32}$", clientIdSegments[2]); + } + + [Theory] + [InlineData(null, MqttProtocolVersion.V500)] + [InlineData("", MqttProtocolVersion.V500)] + [InlineData(" ", MqttProtocolVersion.V500)] + [InlineData("V310", MqttProtocolVersion.V310)] + [InlineData("V311", MqttProtocolVersion.V311)] + [InlineData("V500", MqttProtocolVersion.V500)] + public async Task GetOptionsBuilderAsync_WhenProtocolVersionConfigured_AppliesExpectedMqttProtocolVersion( + string? protocolVersion, + MqttProtocolVersion expectedProtocolVersion) + { + var config = new ApiMqttClientFactory.ConfigurationModel + { + ResolveBrokerAddressExternally = false, + BrokerUrl = "mqtt://127.0.0.1:1", + ProtocolVersion = protocolVersion + }; + + var sut = CreateSut(config); + var options = await InvokeGetOptionsBuilderAsync(sut, config); + + Assert.Equal(expectedProtocolVersion, options.ProtocolVersion); + } } \ No newline at end of file From bb0573085cd1bea7d6fa55fb98e4b31ad3403ffb Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 15:19:33 -0700 Subject: [PATCH 22/31] solution cleanup sweep --- .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../Models/ExampleMessageModel.cs | 2 +- .../Services/ExampleMessageReceiveService.cs | 7 +-- .../Services/ExampleMessageSendService.cs | 4 +- .../Utilities/ExampleMessageTopicNames.cs | 7 ++- .../Exceptions/ApiClientEventsException.cs | 2 +- .../Models/ApiClientEventReceived.cs | 2 +- .../Models/ApiClientEventSendRequest.cs | 2 +- .../Services/IApiClientEventReceiver.cs | 2 +- .../Services/IApiClientEventSender.cs | 2 +- .../Models/MqttBrokerTarget.cs | 2 +- .../MqttClientEventsExceptionArbiterReport.cs | 2 +- .../Services/MqttApiClientEventReceiver.cs | 53 ++++++++++--------- .../Services/MqttApiClientEventSender.cs | 44 +++++++-------- ...MqttClientEventsExceptionArbiterService.cs | 2 +- .../MqttClientEventsRetryWrapperService.cs | 2 +- .../Send/SendExampleMessageCommand.cs | 2 +- .../Send/SendExampleMessageCommandHandler.cs | 2 +- .../SendExampleMessageCommandValidator.cs | 2 +- .../Stream/StreamExampleMessagesQuery.cs | 2 +- .../StreamExampleMessagesQueryHandler.cs | 6 ++- .../StreamExampleMessagesQueryValidator.cs | 2 +- .../Controllers/MessageController.cs | 49 ++++++++--------- .../Messages/ExampleMessagePostRequest.cs | 2 +- 24 files changed, 106 insertions(+), 98 deletions(-) diff --git a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Extensions/ServiceCollectionExtensions.cs b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Extensions/ServiceCollectionExtensions.cs index 318784f..507447f 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Extensions/ServiceCollectionExtensions.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Extensions/ServiceCollectionExtensions.cs @@ -11,4 +11,4 @@ public static IServiceCollection AddExampleClientEventsDomain(this IServiceColle .AddSingleton() .AddSingleton(); } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Models/ExampleMessageModel.cs b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Models/ExampleMessageModel.cs index c7fb5c5..1fb04c3 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Models/ExampleMessageModel.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Models/ExampleMessageModel.cs @@ -3,4 +3,4 @@ namespace RedShirt.Example.Api.ClientEvents.Domains.Example.Models; public sealed class ExampleMessageModel { public required string Message { get; init; } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageReceiveService.cs b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageReceiveService.cs index 7ae5f9c..15ded81 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageReceiveService.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageReceiveService.cs @@ -1,6 +1,7 @@ using RedShirt.Example.Api.ClientEvents.Domains.Example.Models; -using RedShirt.Example.Api.ClientEvents.Library.Core.Services; using RedShirt.Example.Api.ClientEvents.Domains.Example.Utilities; +using RedShirt.Example.Api.ClientEvents.Library.Core.Services; +using System.Runtime.CompilerServices; namespace RedShirt.Example.Api.ClientEvents.Domains.Example.Services; @@ -14,7 +15,7 @@ internal sealed class ExampleMessageReceiveService(IApiClientEventReceiver ReceiveForUserAsync(string userId, - [System.Runtime.CompilerServices.EnumeratorCancellation] + [EnumeratorCancellation] CancellationToken cancellationToken = default) { var topic = ExampleMessageTopicNames.ForUser(userId); @@ -23,4 +24,4 @@ public async IAsyncEnumerable ReceiveForUserAsync(string us yield return received.Payload; } } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageSendService.cs b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageSendService.cs index 21888a7..52cd6b1 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageSendService.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Services/ExampleMessageSendService.cs @@ -1,7 +1,7 @@ using RedShirt.Example.Api.ClientEvents.Domains.Example.Models; +using RedShirt.Example.Api.ClientEvents.Domains.Example.Utilities; using RedShirt.Example.Api.ClientEvents.Library.Core.Models; using RedShirt.Example.Api.ClientEvents.Library.Core.Services; -using RedShirt.Example.Api.ClientEvents.Domains.Example.Utilities; namespace RedShirt.Example.Api.ClientEvents.Domains.Example.Services; @@ -22,4 +22,4 @@ public Task SendToUserAsync(string userId, ExampleMessageModel message, Topic = ExampleMessageTopicNames.ForUser(userId) }, cancellationToken); } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Utilities/ExampleMessageTopicNames.cs b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Utilities/ExampleMessageTopicNames.cs index ac89579..32711bd 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Utilities/ExampleMessageTopicNames.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Domains.Example/Utilities/ExampleMessageTopicNames.cs @@ -2,5 +2,8 @@ namespace RedShirt.Example.Api.ClientEvents.Domains.Example.Utilities; internal static class ExampleMessageTopicNames { - public static string ForUser(string userId) => $"example-message/user/{userId}"; -} + public static string ForUser(string userId) + { + return $"example-message/user/{userId}"; + } +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Exceptions/ApiClientEventsException.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Exceptions/ApiClientEventsException.cs index 3a5af7e..70ff03c 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Exceptions/ApiClientEventsException.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Exceptions/ApiClientEventsException.cs @@ -24,4 +24,4 @@ public ApiClientEventsException(Exception innerException) : base(innerException. public ApiClientEventsException(string message) : base(message) { } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventReceived.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventReceived.cs index 732173f..a2d3f1e 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventReceived.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventReceived.cs @@ -9,4 +9,4 @@ public sealed class ApiClientEventReceived public required string Topic { get; init; } public required TPayload Payload { get; init; } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventSendRequest.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventSendRequest.cs index c2acff7..56e26b8 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventSendRequest.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Models/ApiClientEventSendRequest.cs @@ -12,4 +12,4 @@ public sealed class ApiClientEventSendRequest /// Optional MQTT topic. When omitted, the implementation applies its default routing. /// public string? Topic { get; init; } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventReceiver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventReceiver.cs index f459006..9b15d35 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventReceiver.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventReceiver.cs @@ -18,4 +18,4 @@ public interface IApiClientEventReceiver IAsyncEnumerable> ReceiveAsync( IReadOnlyList? topics = null, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventSender.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventSender.cs index 645d38a..ba444fd 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventSender.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Core/Services/IApiClientEventSender.cs @@ -14,4 +14,4 @@ public interface IApiClientEventSender /// it should be an MQTT-shaped topic understood by the implementation. /// Task SendAsync(ApiClientEventSendRequest request, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttBrokerTarget.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttBrokerTarget.cs index 4055744..6425524 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttBrokerTarget.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttBrokerTarget.cs @@ -5,4 +5,4 @@ internal sealed class MqttBrokerTarget public string? BrokerUrl { get; init; } public string? WebSocketHostHeader { get; init; } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttClientEventsExceptionArbiterReport.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttClientEventsExceptionArbiterReport.cs index 6445bc7..607d03c 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttClientEventsExceptionArbiterReport.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Models/MqttClientEventsExceptionArbiterReport.cs @@ -7,4 +7,4 @@ internal sealed class MqttClientEventsExceptionArbiterReport public required bool IsExpected { get; init; } public required bool CouldBeTransient { get; init; } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventReceiver.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventReceiver.cs index d95089f..7519fff 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventReceiver.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventReceiver.cs @@ -17,9 +17,31 @@ internal sealed class MqttApiClientEventReceiver( IMqttClientEventsRetryWrapperService retryWrapperService, ILogger> logger) : IApiClientEventReceiver { + private static async Task DisconnectSafelyAsync(IMqttClient client, CancellationToken cancellationToken) + { + if (!client.IsConnected) + { + return; + } + + try + { + await client.DisconnectAsync(cancellationToken: cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // Best-effort disconnect during cleanup. + } + } + public async IAsyncEnumerable> ReceiveAsync( IReadOnlyList? topics = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + [EnumeratorCancellation] + CancellationToken cancellationToken = default) { IMqttClient? client = null; Channel>? channel = null; @@ -31,7 +53,7 @@ public async IAsyncEnumerable> ReceiveAsync( cancellationToken); channel = Channel.CreateUnbounded>( - new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + new UnboundedChannelOptions {SingleReader = true, SingleWriter = false}); // MQTT pushes messages on this callback thread; ReceiveAsync exposes an IAsyncEnumerable to callers. // The channel bridges those models: WriteAsync enqueues from the handler, and then the ReadAllAsync invocation @@ -64,7 +86,7 @@ await channel.Writer.WriteAsync(new ApiClientEventReceived }; var subscribeBuilder = new MqttClientSubscribeOptionsBuilder(); - if (topics is { Count: > 0 }) + if (topics is {Count: > 0}) { foreach (var topic in topics.Where(topic => !string.IsNullOrWhiteSpace(topic))) { @@ -75,7 +97,7 @@ await channel.Writer.WriteAsync(new ApiClientEventReceived { subscribeBuilder.WithTopicFilter("#", MqttQualityOfServiceLevel.AtLeastOnce); } - + await retryWrapperService.RunAsync(async token => { var subscribeResult = await client.SubscribeAsync(subscribeBuilder.Build(), token); @@ -108,25 +130,4 @@ or MqttClientSubscribeResultCode.NotAuthorized } } } - - private static async Task DisconnectSafelyAsync(IMqttClient client, CancellationToken cancellationToken) - { - if (!client.IsConnected) - { - return; - } - - try - { - await client.DisconnectAsync(cancellationToken: cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - // Best-effort disconnect during cleanup. - } - } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventSender.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventSender.cs index ea5dbc0..1d5fdf7 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventSender.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/MqttApiClientEventSender.cs @@ -15,6 +15,27 @@ internal sealed class MqttApiClientEventSender( IMqttClientEventsRetryWrapperService retryWrapperService, ILogger> logger) : IApiClientEventSender { + private static async Task DisconnectSafelyAsync(IMqttClient client, CancellationToken cancellationToken) + { + if (!client.IsConnected) + { + return; + } + + try + { + await client.DisconnectAsync(cancellationToken: cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // Best-effort disconnect during cleanup. + } + } + public Task SendAsync(ApiClientEventSendRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.Topic)) @@ -74,25 +95,4 @@ public Task SendAsync(ApiClientEventSendRequest request, CancellationT } }, cancellationToken); } - - private static async Task DisconnectSafelyAsync(IMqttClient client, CancellationToken cancellationToken) - { - if (!client.IsConnected) - { - return; - } - - try - { - await client.DisconnectAsync(cancellationToken: cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - // Best-effort disconnect during cleanup. - } - } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsExceptionArbiterService.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsExceptionArbiterService.cs index 0f587cd..169338c 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsExceptionArbiterService.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsExceptionArbiterService.cs @@ -79,4 +79,4 @@ public MqttClientEventsExceptionArbiterReport GetReport(Exception exception) _ => Fresh(false, false) }; } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsRetryWrapperService.cs b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsRetryWrapperService.cs index 4dc5c4c..bcb8cf0 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsRetryWrapperService.cs +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/Services/Resilience/MqttClientEventsRetryWrapperService.cs @@ -162,4 +162,4 @@ internal sealed class ConfigurationModel /// public int EffectiveRetryCount => Math.Max(0, RetryCount ?? DefaultRetryCount); } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommand.cs b/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommand.cs index dd32fa7..e71c9ae 100644 --- a/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommand.cs +++ b/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommand.cs @@ -1,3 +1,3 @@ namespace RedShirt.Example.Api.Core.UseCases.Messages.Commands.Send; -public sealed record SendExampleMessageCommand(string UserId, string Message); +public sealed record SendExampleMessageCommand(string UserId, string Message); \ No newline at end of file diff --git a/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandHandler.cs b/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandHandler.cs index 9277acc..4307645 100644 --- a/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandHandler.cs +++ b/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandHandler.cs @@ -18,4 +18,4 @@ public async Task Handle(SendExampleMessageCommand command, CancellationToken ca Message = command.Message }, cancellationToken); } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandValidator.cs b/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandValidator.cs index 79ffc5b..6f71cc0 100644 --- a/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandValidator.cs +++ b/src/RedShirt.Example.Api.Core/UseCases/Messages/Commands/Send/SendExampleMessageCommandValidator.cs @@ -14,4 +14,4 @@ public SendExampleMessageCommandValidator() .Must(message => !string.IsNullOrWhiteSpace(message)) .WithMessage("Message is required"); } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQuery.cs b/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQuery.cs index 3c589ea..046390c 100644 --- a/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQuery.cs +++ b/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQuery.cs @@ -1,3 +1,3 @@ namespace RedShirt.Example.Api.Core.UseCases.Messages.Queries.Stream; -public sealed record StreamExampleMessagesQuery(string UserId); +public sealed record StreamExampleMessagesQuery(string UserId); \ No newline at end of file diff --git a/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryHandler.cs b/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryHandler.cs index 07fa22c..50d641e 100644 --- a/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryHandler.cs +++ b/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryHandler.cs @@ -4,7 +4,9 @@ namespace RedShirt.Example.Api.Core.UseCases.Messages.Queries.Stream; -public interface IStreamExampleMessagesQueryHandler : ICqrsHandler>; +public interface + IStreamExampleMessagesQueryHandler : ICqrsHandler>; internal sealed class StreamExampleMessagesQueryHandler( IExampleMessageReceiveService exampleMessageReceiveService, @@ -16,4 +18,4 @@ public async Task> Handle(StreamExampleMes await coreRequestValidator.ValidateAsync(query, cancellationToken); return exampleMessageReceiveService.ReceiveForUserAsync(query.UserId, cancellationToken); } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryValidator.cs b/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryValidator.cs index aef32c8..1cfe655 100644 --- a/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryValidator.cs +++ b/src/RedShirt.Example.Api.Core/UseCases/Messages/Queries/Stream/StreamExampleMessagesQueryValidator.cs @@ -10,4 +10,4 @@ public StreamExampleMessagesQueryValidator() .Must(userId => !string.IsNullOrWhiteSpace(userId)) .WithMessage("UserId is required"); } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api/Controllers/MessageController.cs b/src/RedShirt.Example.Api/Controllers/MessageController.cs index dd7fb5c..05efaf6 100644 --- a/src/RedShirt.Example.Api/Controllers/MessageController.cs +++ b/src/RedShirt.Example.Api/Controllers/MessageController.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using RedShirt.Example.Api.Attributes.Authorization; -using RedShirt.Example.Api.ClientEvents.Domains.Example.Models; using RedShirt.Example.Api.Authorization.Extensions; +using RedShirt.Example.Api.ClientEvents.Domains.Example.Models; using RedShirt.Example.Api.Constants; using RedShirt.Example.Api.Core.UseCases.Messages.Commands.Send; using RedShirt.Example.Api.Core.UseCases.Messages.Queries.Stream; @@ -16,26 +16,15 @@ namespace RedShirt.Example.Api.Controllers; [Route("messages")] public class MessageController : ControllerBase { - [HttpPost] - [ProducesResponseType(StatusCodes.Status202Accepted)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status502BadGateway)] - public async Task Post( - [FromBody] - ExampleMessagePostRequest request, - [FromServices] - ISendExampleMessageCommandHandler sendExampleMessageCommandHandler, + private static async IAsyncEnumerable ToServerSentEvents( + IAsyncEnumerable messages, + [EnumeratorCancellation] CancellationToken cancellationToken) { - if (!User.TryGetUserId(out var userId)) + await foreach (var message in messages.WithCancellation(cancellationToken)) { - return BadRequest("Authenticated user id is required."); + yield return message.Message; } - - await sendExampleMessageCommandHandler.Handle(new SendExampleMessageCommand(userId, request.Message), - cancellationToken); - - return Accepted(); } [HttpGet("event-stream")] @@ -59,16 +48,28 @@ public async Task GetEventStream( return TypedResults.ServerSentEvents( ToServerSentEvents(messageStream, cancellationToken), - eventType: "message"); + "message"); } - private static async IAsyncEnumerable ToServerSentEvents( - IAsyncEnumerable messages, - [EnumeratorCancellation] CancellationToken cancellationToken) + [HttpPost] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task Post( + [FromBody] + ExampleMessagePostRequest request, + [FromServices] + ISendExampleMessageCommandHandler sendExampleMessageCommandHandler, + CancellationToken cancellationToken) { - await foreach (var message in messages.WithCancellation(cancellationToken)) + if (!User.TryGetUserId(out var userId)) { - yield return message.Message; + return BadRequest("Authenticated user id is required."); } + + await sendExampleMessageCommandHandler.Handle(new SendExampleMessageCommand(userId, request.Message), + cancellationToken); + + return Accepted(); } -} +} \ No newline at end of file diff --git a/src/RedShirt.Example.Api/Models/Messages/ExampleMessagePostRequest.cs b/src/RedShirt.Example.Api/Models/Messages/ExampleMessagePostRequest.cs index 5826c67..1b68397 100644 --- a/src/RedShirt.Example.Api/Models/Messages/ExampleMessagePostRequest.cs +++ b/src/RedShirt.Example.Api/Models/Messages/ExampleMessagePostRequest.cs @@ -3,4 +3,4 @@ namespace RedShirt.Example.Api.Models.Messages; public sealed class ExampleMessagePostRequest { public required string Message { get; init; } -} +} \ No newline at end of file From e4a589d261d18c36cd684ab34ea9ae55b9e6860c Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 15:20:49 -0700 Subject: [PATCH 23/31] swagger --- .../RedShirt.Example.Api.Interop/swagger.json | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/swagger/RedShirt.Example.Api.Interop/swagger.json b/swagger/RedShirt.Example.Api.Interop/swagger.json index 7c9bb6d..eff36ba 100644 --- a/swagger/RedShirt.Example.Api.Interop/swagger.json +++ b/swagger/RedShirt.Example.Api.Interop/swagger.json @@ -830,26 +830,14 @@ } } }, - "/messages": { - "post": { + "/messages/event-stream": { + "get": { "tags": [ "Message" ], - "operationId": "Message_Post", - "requestBody": { - "x-name": "request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExampleMessagePostRequest" - } - } - }, - "required": true, - "x-position": 1 - }, + "operationId": "Message_GetEventStream", "responses": { - "202": { + "200": { "description": "" }, "400": { @@ -868,14 +856,26 @@ } } }, - "/messages/event-stream": { - "get": { + "/messages": { + "post": { "tags": [ "Message" ], - "operationId": "Message_GetEventStream", + "operationId": "Message_Post", + "requestBody": { + "x-name": "request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExampleMessagePostRequest" + } + } + }, + "required": true, + "x-position": 1 + }, "responses": { - "200": { + "202": { "description": "" }, "400": { From 3788471eab8db164642e16b7a363dc0dd6c7467b Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 16:22:18 -0700 Subject: [PATCH 24/31] Swagger funtimes. Add EventStreamListener sample. --- README.md | 11 +- RedShirt.Example.Api.slnx | 1 + docs/patterns/server-sent-events.md | 25 ++ .../Controllers/MessageController.cs | 6 + .../RedShirt.Example.Api.csproj | 2 + .../EventStreamListener.cs | 418 ++++++++++++++++++ .../RedShirt.Example.Api.Interop/swagger.json | 2 + ...Shirt.Example.Api.Interop.UnitTests.csproj | 34 ++ .../Tests/EventStreamListenerTests.cs | 406 +++++++++++++++++ .../Tests/MessagesEventStreamListenerTests.cs | 146 ++++++ 10 files changed, 1050 insertions(+), 1 deletion(-) create mode 100644 swagger/RedShirt.Example.Api.Interop/EventStreamListener.cs create mode 100644 test/RedShirt.Example.Api.Interop.UnitTests/RedShirt.Example.Api.Interop.UnitTests.csproj create mode 100644 test/RedShirt.Example.Api.Interop.UnitTests/Tests/EventStreamListenerTests.cs create mode 100644 test/RedShirt.Example.Api.Interop.UnitTests/Tests/MessagesEventStreamListenerTests.cs diff --git a/README.md b/README.md index 340cd0f..bdba2c2 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,16 @@ Below are the recommended steps for using this as a template: 2. Write database accessors and/or service connectors based on your application's needs. 3. Prune database accessors and/or service connectors that your application does not need. -4. Consider revising/pruning the Markdown files such as this README or those located in the `docs/` directory. They +4. The example of Server-Sent Events needed a bespoke handler, the standard NSwag clients did not know how to support + event streams. This handler can be found in the `RedShirt.Example.Api.Interop` project, defined in + `EventStreamListener.cs`. + * The `EventStreamListener.cs` contains the abstract `EventStreamListener` and the applied + `MessagesEventStreamListener`, which sets some defaults. + * If you are implementing Server-Sent Events but not as `/messages` as this example does, then I would recommend + adjusting the comments described in `MessagesEventStreamListener`. + * If you are not implementing Server-Side Events in your applied application, then I would recommend deleting + `EventStreamListener.cs` outright. +5. Consider revising/pruning the Markdown files such as this README or those located in the `docs/` directory. They heavily assume that they are speaking for a general template and not for an applied application. ## Secret Manager diff --git a/RedShirt.Example.Api.slnx b/RedShirt.Example.Api.slnx index 66a9b31..07d7cbf 100644 --- a/RedShirt.Example.Api.slnx +++ b/RedShirt.Example.Api.slnx @@ -91,6 +91,7 @@ + diff --git a/docs/patterns/server-sent-events.md b/docs/patterns/server-sent-events.md index fb9847b..500aefd 100644 --- a/docs/patterns/server-sent-events.md +++ b/docs/patterns/server-sent-events.md @@ -85,6 +85,27 @@ With these reasons in mind, I would only pivot to SignalR if I had a specific ca # Client Examples +## C# + +### Interop + +A sample listener has been written for the `RedShirt.Example.Api.Interop` project, called `EventStreamListener`. + +Example usage: + +```csharp +using var httpClient = new HttpClient(); +httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", accessToken); + +var listener = new MessagesEventStreamListener("https://api.example.com", httpClient); + +await listener.StreamEventsAsync( + // Can use an Action or Func + message => Console.WriteLine($"[message] {message}"), + cancellationToken); +``` + ## JavaScript The example stream lives at `GET /messages/event-stream`. Each event uses the SSE event name `message`, and the @@ -157,6 +178,10 @@ To exercise the stream locally, open a terminal running the listener above, then The local-testing scripts in the `test/local/scripts/messages/` directory has a Python-based example of listening for events. +## TypeScript + +STUB + # Other Resources The following links have useful reading material relating to Server-Sent Events and MQTT: diff --git a/src/RedShirt.Example.Api/Controllers/MessageController.cs b/src/RedShirt.Example.Api/Controllers/MessageController.cs index 05efaf6..2aee967 100644 --- a/src/RedShirt.Example.Api/Controllers/MessageController.cs +++ b/src/RedShirt.Example.Api/Controllers/MessageController.cs @@ -27,6 +27,12 @@ private static async IAsyncEnumerable ToServerSentEvents( } } + /// Event stream + /// + /// This GET /messages/event-stream endpoint is not directly supported by OpenAPI. + /// The generated NSwag MessageClient.GetEventStreamAsync method is intended for connectivity checks only. + /// Use MessagesEventStreamListener in RedShirt.Example.Api.Interop project to receive message events. + /// [HttpGet("event-stream")] [ApproveReadOnly] [Produces("text/event-stream")] diff --git a/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj b/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj index 0baef87..e696445 100644 --- a/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj +++ b/src/RedShirt.Example.Api/RedShirt.Example.Api.csproj @@ -4,6 +4,8 @@ net10.0 enable enable + true + $(NoWarn);1591 diff --git a/swagger/RedShirt.Example.Api.Interop/EventStreamListener.cs b/swagger/RedShirt.Example.Api.Interop/EventStreamListener.cs new file mode 100644 index 0000000..f3ef014 --- /dev/null +++ b/swagger/RedShirt.Example.Api.Interop/EventStreamListener.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace RedShirt.Example.Api.Interop; + +public abstract class EventStreamListener +{ + private static readonly Lazy DefaultJsonSerializerSettings = + new(CreateJsonSerializerSettings, true); + + private readonly HttpClient _httpClient; + + protected EventStreamListener(string baseUrl, HttpClient httpClient, string eventEndpointPath) + { + if (eventEndpointPath == null) + { + throw new ArgumentNullException(nameof(eventEndpointPath)); + } + + BaseUrl = NormalizeBaseUrl(baseUrl); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + EventEndpointPath = NormalizeEventEndpointPath(eventEndpointPath); + } + + // ReSharper disable once MemberCanBePrivate.Global + protected string BaseUrl { get; } + + // ReSharper disable once MemberCanBePrivate.Global + protected string EventEndpointPath { get; } + + protected virtual string DefaultEventName => "event"; + + protected virtual JsonSerializerOptions JsonSerializerSettings => DefaultJsonSerializerSettings.Value; + + protected virtual void PrepareRequest(HttpClient client, HttpRequestMessage request, string url) + { + } + + protected virtual void ProcessResponse(HttpClient client, HttpResponseMessage response) + { + } + + protected Task StreamEventsAsync( + Action onEvent, + CancellationToken cancellationToken = default) + { + if (onEvent == null) + { + throw new ArgumentNullException(nameof(onEvent)); + } + + return StreamEventsAsync( + (data, _) => + { + onEvent(data); + return Task.CompletedTask; + }, + cancellationToken); + } + + protected Task StreamEventsAsync( + Func onEvent, + CancellationToken cancellationToken = default) + { + if (onEvent == null) + { + throw new ArgumentNullException(nameof(onEvent)); + } + + return StreamEventsCoreAsync(onEvent, cancellationToken); + } + + protected abstract Task StreamEventsCoreAsync( + Func onEvent, + CancellationToken cancellationToken); + + protected async Task OpenEventStreamAsync( + Func onEvent, + string? expectedEventName, + CancellationToken cancellationToken) + { + var client = _httpClient; + var disposeClient = false; + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, BuildRequestUri()); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/event-stream")); + + PrepareRequest(client, request, request.RequestUri!.ToString()); + + using var response = await client + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + var headers = CopyResponseHeaders(response); + ProcessResponse(client, response); + + var status = (int) response.StatusCode; + + /* + * The below is organized as a series of if statements and not a switch statement + * for consistency with the generated NSwag client code. + */ + + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (status == 200) + { + if (response.Content == null) + { + return; + } + + await using var stream = await ReadAsStreamAsync(response.Content, cancellationToken) + .ConfigureAwait(false); + await ReadServerSentEventsAsync( + stream, + onEvent, + expectedEventName, + DefaultEventName, + cancellationToken) + .ConfigureAwait(false); + return; + } + + if (status == 400) + { + var objectResponse = await ReadObjectResponseAsync(response, headers, cancellationToken) + .ConfigureAwait(false); + if (objectResponse.Object == null) + { + throw new SwaggerException( + "Response was null which was not expected.", + status, + objectResponse.Text, + headers, + null); + } + + throw new SwaggerException( + "A server side error occurred.", + status, + objectResponse.Text, + headers, + objectResponse.Object, + null); + } + + if (status == 502) + { + var responseText = response.Content == null + ? string.Empty + : await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + throw new SwaggerException("A server side error occurred.", status, responseText, headers, null); + } + + var responseData = response.Content == null + ? null + : await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + throw new SwaggerException( + "The HTTP status code of the response was not expected (" + status + ").", + status, + responseData, + headers, + null); + } + finally + { + if (disposeClient) + { + client.Dispose(); + } + } + } + + private Uri BuildRequestUri() + { + var urlBuilder = new StringBuilder(); + if (!string.IsNullOrEmpty(BaseUrl)) + { + urlBuilder.Append(BaseUrl); + } + + urlBuilder.Append(EventEndpointPath); + return new Uri(urlBuilder.ToString(), UriKind.RelativeOrAbsolute); + } + + private static string NormalizeBaseUrl(string baseUrl) + { + if (string.IsNullOrEmpty(baseUrl)) + { + return baseUrl; + } + + return baseUrl.EndsWith("/", StringComparison.Ordinal) ? baseUrl : baseUrl + "/"; + } + + private static string NormalizeEventEndpointPath(string eventEndpointPath) + { + return eventEndpointPath.TrimStart('/'); + } + + private static Dictionary> CopyResponseHeaders(HttpResponseMessage response) + { + var headers = new Dictionary>(); + foreach (var item in response.Headers) + { + headers[item.Key] = item.Value; + } + + // ReSharper disable once InvertIf + if (response.Content?.Headers != null) + { + foreach (var item in response.Content.Headers) + { + headers[item.Key] = item.Value; + } + } + + return headers; + } + + private static async Task ReadServerSentEventsAsync( + Stream stream, + Func onEvent, + string? expectedEventName, + string defaultEventName, + CancellationToken cancellationToken) + { + using var reader = new StreamReader(stream); + string? eventName = null; + var dataLines = new List(); + + while (!cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync().ConfigureAwait(false); + if (line == null) + { + break; + } + + if (line.Length == 0) + { + if (dataLines.Count > 0) + { + var resolvedEventName = eventName ?? defaultEventName; + if (expectedEventName == null || + string.Equals(resolvedEventName, expectedEventName, StringComparison.Ordinal)) + { + await onEvent(string.Join("\n", dataLines), cancellationToken).ConfigureAwait(false); + } + } + + eventName = null; + dataLines.Clear(); + continue; + } + + if (line.StartsWith(":", StringComparison.Ordinal)) + { + continue; + } + + if (line.StartsWith("event:", StringComparison.Ordinal)) + { + eventName = line.Substring("event:".Length).Trim(); + continue; + } + + if (line.StartsWith("data:", StringComparison.Ordinal)) + { + dataLines.Add(line.Substring("data:".Length).TrimStart()); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + } + + private async Task> ReadObjectResponseAsync( + HttpResponseMessage response, + IReadOnlyDictionary> headers, + CancellationToken cancellationToken) + { + if (response.Content == null) + { + return new ObjectResponseResult(default!, string.Empty); + } + + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody!, responseText); + } + catch (JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new SwaggerException(message, (int) response.StatusCode, responseText, headers, exception); + } + } + + private static JsonSerializerOptions CreateJsonSerializerSettings() + { + return new JsonSerializerOptions(); + } + +#if NET5_0_OR_GREATER + private static Task ReadAsStringAsync(HttpContent content, CancellationToken cancellationToken) => + content.ReadAsStringAsync(cancellationToken); + + private static Task ReadAsStreamAsync(HttpContent content, CancellationToken cancellationToken) => + content.ReadAsStreamAsync(cancellationToken); +#else + // ReSharper disable UnusedParameter.Local + private static Task ReadAsStringAsync(HttpContent content, CancellationToken cancellationToken) + { + return content.ReadAsStringAsync(); + } + + private static Task ReadAsStreamAsync(HttpContent content, CancellationToken cancellationToken) + { + return content.ReadAsStreamAsync(); + } + // ReSharper restore UnusedParameter.Local +#endif + + // ReSharper disable once MemberCanBePrivate.Global + protected readonly struct ObjectResponseResult + { + // ReSharper disable once ConvertToPrimaryConstructor + public ObjectResponseResult(T responseObject, string responseText) + { + Object = responseObject; + Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } +} + +public interface IMessagesEventStreamListener +{ + /// Callback method + /// + /// A cancellation token that can be used by other objects or threads to receive notice of + /// cancellation. + /// + /// A server side error occurred. + Task StreamEventsAsync( + Action onEvent, + CancellationToken cancellationToken = default); + + /// Callback method + /// + /// A cancellation token that can be used by other objects or threads to receive notice of + /// cancellation. + /// + /// A server side error occurred. + Task StreamEventsAsync( + Func onEvent, + CancellationToken cancellationToken = default); +} + +public sealed class MessagesEventStreamListener : EventStreamListener, IMessagesEventStreamListener +{ + // ReSharper disable once ConvertToPrimaryConstructor + public MessagesEventStreamListener( + string baseUrl, + HttpClient httpClient, + string eventEndpointPath = "/messages/event-stream") + : base(baseUrl, httpClient, eventEndpointPath) + { + } + + protected override string DefaultEventName => "message"; + + /// Callback function + /// + /// A cancellation token that can be used by other objects or threads to receive notice of + /// cancellation. + /// + /// A server side error occurred. + public new Task StreamEventsAsync( + Action onEvent, + CancellationToken cancellationToken = default) + { + return base.StreamEventsAsync(onEvent, cancellationToken); + } + + /// Callback function + /// + /// A cancellation token that can be used by other objects or threads to receive notice of + /// cancellation. + /// + /// A server side error occurred. + public new Task StreamEventsAsync( + Func onEvent, + CancellationToken cancellationToken = default) + { + return base.StreamEventsAsync(onEvent, cancellationToken); + } + + protected override Task StreamEventsCoreAsync( + Func onEvent, + CancellationToken cancellationToken) + { + return OpenEventStreamAsync(onEvent, DefaultEventName, cancellationToken); + } +} \ No newline at end of file diff --git a/swagger/RedShirt.Example.Api.Interop/swagger.json b/swagger/RedShirt.Example.Api.Interop/swagger.json index eff36ba..d70478c 100644 --- a/swagger/RedShirt.Example.Api.Interop/swagger.json +++ b/swagger/RedShirt.Example.Api.Interop/swagger.json @@ -835,6 +835,8 @@ "tags": [ "Message" ], + "summary": "Event stream", + "description": "This GET /messages/event-stream endpoint is not directly supported by OpenAPI.\nThe generated NSwag MessageClient.GetEventStreamAsync method is intended for connectivity checks only.\nUse MessagesEventStreamListener in RedShirt.Example.Api.Interop project to receive message events.", "operationId": "Message_GetEventStream", "responses": { "200": { diff --git a/test/RedShirt.Example.Api.Interop.UnitTests/RedShirt.Example.Api.Interop.UnitTests.csproj b/test/RedShirt.Example.Api.Interop.UnitTests/RedShirt.Example.Api.Interop.UnitTests.csproj new file mode 100644 index 0000000..7ef1a61 --- /dev/null +++ b/test/RedShirt.Example.Api.Interop.UnitTests/RedShirt.Example.Api.Interop.UnitTests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/test/RedShirt.Example.Api.Interop.UnitTests/Tests/EventStreamListenerTests.cs b/test/RedShirt.Example.Api.Interop.UnitTests/Tests/EventStreamListenerTests.cs new file mode 100644 index 0000000..147ea09 --- /dev/null +++ b/test/RedShirt.Example.Api.Interop.UnitTests/Tests/EventStreamListenerTests.cs @@ -0,0 +1,406 @@ +using Moq; +using System.Net; +using System.Text; + +namespace RedShirt.Example.Api.Interop.UnitTests.Tests; + +public class EventStreamListenerTests +{ + private const string BaseUrl = "https://api.example.test/"; + + private static HttpResponseMessage EmptyOkResponse() + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty, Encoding.UTF8, "text/event-stream") + }; + } + + private static HttpResponseMessage SseOkResponse(string sseBody) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(sseBody, Encoding.UTF8, "text/event-stream") + }; + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenEventEndpointPathIsNull() + { + using var httpClient = new HttpClient(); + + var exception = Assert.Throws(() => + new TestEventStreamListener(BaseUrl, httpClient, null!)); + + Assert.Equal("eventEndpointPath", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenHttpClientIsNull() + { + var exception = Assert.Throws(() => + new TestEventStreamListener(BaseUrl, null!, "events/stream")); + + Assert.Equal("httpClient", exception.ParamName); + } + + [Fact] + public async Task StreamEventsAsync_ActionOverload_DeliversEventData() + { + var handler = new StubHandler(_ => SseOkResponse(SseBuilder.Event("message", "hello"))); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream") + { + ExpectedEventName = "message" + }; + var received = new List(); + + await listener.StreamEventsAsyncAction(received.Add, TestContext.Current.CancellationToken); + + Assert.Equal(["hello"], received); + } + + [Fact] + public async Task StreamEventsAsync_CompletesWithoutCallbacks_WhenResponseContentIsNull() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream"); + var callbackCount = 0; + + await listener.StreamAsync( + (_, _) => + { + callbackCount++; + return Task.CompletedTask; + }, + TestContext.Current.CancellationToken); + + Assert.Equal(0, callbackCount); + } + + [Fact] + public async Task StreamEventsAsync_FiltersEvents_WhenExpectedEventNameIsSet() + { + var sseBody = SseBuilder.Events( + ("other", "ignored"), + ("message", "kept")); + var handler = new StubHandler(_ => SseOkResponse(sseBody)); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream") + { + ExpectedEventName = "message" + }; + var received = new List(); + + await listener.StreamAsync( + (data, _) => + { + received.Add(data); + return Task.CompletedTask; + }, + TestContext.Current.CancellationToken); + + Assert.Equal(["kept"], received); + } + + [Fact] + public async Task StreamEventsAsync_FuncOverload_PassesCancellationTokenToCallback() + { + var handler = new StubHandler(_ => SseOkResponse(SseBuilder.Data("payload"))); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream"); + CancellationToken? callbackToken = null; + + await listener.StreamAsync( + (_, cancellationToken) => + { + callbackToken = cancellationToken; + return Task.CompletedTask; + }, + TestContext.Current.CancellationToken); + + Assert.Equal(TestContext.Current.CancellationToken, callbackToken); + } + + [Fact] + public async Task StreamEventsAsync_IgnoresCommentLines() + { + var sseBody = ": keep-alive\n\n" + SseBuilder.Data("hello"); + var handler = new StubHandler(_ => SseOkResponse(sseBody)); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream"); + var received = new List(); + + await listener.StreamAsync( + (data, _) => + { + received.Add(data); + return Task.CompletedTask; + }, + TestContext.Current.CancellationToken); + + Assert.Equal(["hello"], received); + } + + [Fact] + public async Task StreamEventsAsync_InvokesPrepareRequestAndProcessResponseHooks() + { + var prepareRequest = new Mock>(); + var processResponse = new Mock>(); + var handler = new StubHandler(_ => EmptyOkResponse()); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream") + { + PrepareRequestHook = prepareRequest.Object, + ProcessResponseHook = processResponse.Object + }; + + await listener.StreamAsync((_, _) => Task.CompletedTask, TestContext.Current.CancellationToken); + + prepareRequest.Verify( + hook => hook(httpClient, handler.LastRequest!, handler.LastRequest!.RequestUri!.ToString()), + Times.Once); + processResponse.Verify( + hook => hook(httpClient, It.Is(response => response.StatusCode == HttpStatusCode.OK)), + Times.Once); + } + + [Fact] + public async Task StreamEventsAsync_NormalizesBaseUrlAndEventEndpointPath() + { + var handler = new StubHandler(_ => EmptyOkResponse()); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener("https://api.example.test", httpClient, "/messages/event-stream"); + + await listener.StreamAsync((_, _) => Task.CompletedTask, TestContext.Current.CancellationToken); + + Assert.Equal( + "https://api.example.test/messages/event-stream", + handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task StreamEventsAsync_ParsesMultipleEventsAndMultilineData() + { + var sseBody = + "event: message\ndata: line-one\ndata: line-two\n\n" + + SseBuilder.Event("message", "second"); + var handler = new StubHandler(_ => SseOkResponse(sseBody)); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream") + { + ExpectedEventName = "message" + }; + var received = new List(); + + await listener.StreamAsync( + (data, _) => + { + received.Add(data); + return Task.CompletedTask; + }, + TestContext.Current.CancellationToken); + + Assert.Equal(["line-one\nline-two", "second"], received); + } + + [Fact] + public async Task StreamEventsAsync_PropagatesCancellation_BeforeRequestIsSent() + { + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + var handler = new StubHandler(_ => EmptyOkResponse()); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream"); + + await Assert.ThrowsAnyAsync(() => + listener.StreamAsync((_, _) => Task.CompletedTask, cancellationTokenSource.Token)); + + Assert.Null(handler.LastRequest); + } + + [Fact] + public async Task StreamEventsAsync_SendsGetRequest_WithExpectedUrlAndAcceptHeader() + { + var handler = new StubHandler(_ => EmptyOkResponse()); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "/messages/event-stream"); + + await listener.StreamAsync((_, _) => Task.CompletedTask, TestContext.Current.CancellationToken); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method); + Assert.Equal( + "https://api.example.test/messages/event-stream", + handler.LastRequest.RequestUri!.ToString()); + Assert.Contains( + handler.LastRequest.Headers.Accept, + value => value.MediaType == "text/event-stream"); + } + + [Fact] + public async Task StreamEventsAsync_ThrowsArgumentNullException_WhenActionCallbackIsNull() + { + using var httpClient = new HttpClient(new StubHandler(_ => EmptyOkResponse())); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream"); + + var exception = await Assert.ThrowsAsync(() => + listener.StreamEventsAsyncAction(null!, TestContext.Current.CancellationToken)); + + Assert.Equal("onEvent", exception.ParamName); + } + + [Fact] + public async Task StreamEventsAsync_ThrowsArgumentNullException_WhenFuncCallbackIsNull() + { + using var httpClient = new HttpClient(new StubHandler(_ => EmptyOkResponse())); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream"); + + var exception = await Assert.ThrowsAsync(() => + listener.StreamAsync(null!, TestContext.Current.CancellationToken)); + + Assert.Equal("onEvent", exception.ParamName); + } + + [Fact] + public async Task StreamEventsAsync_ThrowsSwaggerExceptionWithProblemDetails_On400() + { + const string problemJson = """ + { + "title": "Bad Request", + "status": 400, + "detail": "Authenticated user id is required." + } + """; + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(problemJson, Encoding.UTF8, "application/json") + }); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream"); + + var exception = await Assert.ThrowsAsync>(() => + listener.StreamAsync((_, _) => Task.CompletedTask, TestContext.Current.CancellationToken)); + + Assert.Equal(400, exception.StatusCode); + Assert.Equal("Bad Request", exception.Result.Title); + Assert.Equal("Authenticated user id is required.", exception.Result.Detail); + } + + [Fact] + public async Task StreamEventsAsync_ThrowsSwaggerException_On502() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent("upstream unavailable", Encoding.UTF8, "text/plain") + }); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream"); + + var exception = await Assert.ThrowsAsync(() => + listener.StreamAsync((_, _) => Task.CompletedTask, TestContext.Current.CancellationToken)); + + Assert.Equal(502, exception.StatusCode); + Assert.Equal("upstream unavailable", exception.Response); + } + + [Fact] + public async Task StreamEventsAsync_ThrowsSwaggerException_OnUnexpectedStatusCode() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("missing", Encoding.UTF8, "text/plain") + }); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream"); + + var exception = await Assert.ThrowsAsync(() => + listener.StreamAsync((_, _) => Task.CompletedTask, TestContext.Current.CancellationToken)); + + Assert.Equal(404, exception.StatusCode); + Assert.Contains("was not expected (404)", exception.Message); + Assert.Equal("missing", exception.Response); + } + + [Fact] + public async Task StreamEventsAsync_UsesDefaultEventName_WhenEventLineIsMissing() + { + var handler = new StubHandler(_ => SseOkResponse(SseBuilder.Data("implicit-message"))); + using var httpClient = new HttpClient(handler); + var listener = new TestEventStreamListener(BaseUrl, httpClient, "events/stream") + { + ExpectedEventName = "event" + }; + var received = new List(); + + await listener.StreamAsync( + (data, _) => + { + received.Add(data); + return Task.CompletedTask; + }, + TestContext.Current.CancellationToken); + + Assert.Equal(["implicit-message"], received); + } + + private sealed class TestEventStreamListener : EventStreamListener + { + public string? ExpectedEventName { get; init; } + + public Action? PrepareRequestHook { get; init; } + + public Action? ProcessResponseHook { get; init; } + + public TestEventStreamListener(string baseUrl, HttpClient httpClient, string eventEndpointPath) + : base(baseUrl, httpClient, eventEndpointPath) + { + } + + protected override void PrepareRequest(HttpClient client, HttpRequestMessage request, string url) + { + PrepareRequestHook?.Invoke(client, request, url); + } + + protected override void ProcessResponse(HttpClient client, HttpResponseMessage response) + { + ProcessResponseHook?.Invoke(client, response); + } + + public Task StreamAsync( + Func onEvent, + CancellationToken cancellationToken = default) + { + return StreamEventsAsync(onEvent, cancellationToken); + } + + public Task StreamEventsAsyncAction( + Action onEvent, + CancellationToken cancellationToken = default) + { + return StreamEventsAsync(onEvent, cancellationToken); + } + + protected override Task StreamEventsCoreAsync( + Func onEvent, + CancellationToken cancellationToken) + { + return OpenEventStreamAsync(onEvent, ExpectedEventName, cancellationToken); + } + } + + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + LastRequest = request; + return Task.FromResult(responder(request)); + } + } +} \ No newline at end of file diff --git a/test/RedShirt.Example.Api.Interop.UnitTests/Tests/MessagesEventStreamListenerTests.cs b/test/RedShirt.Example.Api.Interop.UnitTests/Tests/MessagesEventStreamListenerTests.cs new file mode 100644 index 0000000..64e32d1 --- /dev/null +++ b/test/RedShirt.Example.Api.Interop.UnitTests/Tests/MessagesEventStreamListenerTests.cs @@ -0,0 +1,146 @@ +using System.Net; +using System.Text; + +namespace RedShirt.Example.Api.Interop.UnitTests.Tests; + +public class MessagesEventStreamListenerTests +{ + private static HttpResponseMessage EmptyOkResponse() + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty, Encoding.UTF8, "text/event-stream") + }; + } + + private static HttpResponseMessage SseOkResponse(string sseBody) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(sseBody, Encoding.UTF8, "text/event-stream") + }; + } + + [Fact] + public async Task StreamEventsAsync_ActionOverload_DeliversMessageEvents() + { + var handler = new StubHandler(_ => SseOkResponse(SseBuilder.Data("hello"))); + using var httpClient = new HttpClient(handler); + var listener = new MessagesEventStreamListener("https://api.example.test/", httpClient); + var received = new List(); + + await listener.StreamEventsAsync(received.Add, TestContext.Current.CancellationToken); + + Assert.Equal(["hello"], received); + } + + [Fact] + public async Task StreamEventsAsync_AllowsCustomEventEndpointPath() + { + var handler = new StubHandler(_ => EmptyOkResponse()); + using var httpClient = new HttpClient(handler); + var listener = new MessagesEventStreamListener( + "https://api.example.test/", + httpClient, + "/custom/event-stream"); + + await listener.StreamEventsAsync((_, _) => Task.CompletedTask, TestContext.Current.CancellationToken); + + Assert.Equal( + "https://api.example.test/custom/event-stream", + handler.LastRequest!.RequestUri!.ToString()); + } + + [Fact] + public async Task StreamEventsAsync_CanBeUsedThroughInterface() + { + var handler = new StubHandler(_ => SseOkResponse(SseBuilder.Data("via-interface"))); + using var httpClient = new HttpClient(handler); + IMessagesEventStreamListener listener = + new MessagesEventStreamListener("https://api.example.test/", httpClient); + var received = new List(); + + await listener.StreamEventsAsync( + (data, _) => + { + received.Add(data); + return Task.CompletedTask; + }, + TestContext.Current.CancellationToken); + + Assert.Equal(["via-interface"], received); + } + + [Fact] + public async Task StreamEventsAsync_DeliversOnlyMessageEvents() + { + var sseBody = SseBuilder.Events( + ("heartbeat", "ignored"), + ("message", "hello")); + var handler = new StubHandler(_ => SseOkResponse(sseBody)); + using var httpClient = new HttpClient(handler); + var listener = new MessagesEventStreamListener("https://api.example.test/", httpClient); + var received = new List(); + + await listener.StreamEventsAsync( + (data, _) => + { + received.Add(data); + return Task.CompletedTask; + }, + TestContext.Current.CancellationToken); + + Assert.Equal(["hello"], received); + } + + [Fact] + public async Task StreamEventsAsync_UsesDefaultEventEndpointPath_InRequestUrl() + { + var handler = new StubHandler(_ => EmptyOkResponse()); + using var httpClient = new HttpClient(handler); + var listener = new MessagesEventStreamListener("https://api.example.test/", httpClient); + + await listener.StreamEventsAsync((_, _) => Task.CompletedTask, TestContext.Current.CancellationToken); + + Assert.Equal( + "https://api.example.test/messages/event-stream", + handler.LastRequest!.RequestUri!.ToString()); + } + + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(responder(request)); + } + } +} + +internal static class SseBuilder +{ + internal static string Data(string data) + { + return $"data: {data}\n\n"; + } + + internal static string Event(string eventName, string data) + { + return $"event: {eventName}\ndata: {data}\n\n"; + } + + internal static string Events(params (string EventName, string Data)[] events) + { + var builder = new StringBuilder(); + foreach (var (eventName, data) in events) + { + builder.Append(Event(eventName, data)); + } + + return builder.ToString(); + } +} \ No newline at end of file From c92e362a2191eba6b0ae2d9ee3360ddf64d72d45 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 16:25:29 -0700 Subject: [PATCH 25/31] refine JavaScript/TypeScript examples --- docs/patterns/server-sent-events.md | 80 +++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/docs/patterns/server-sent-events.md b/docs/patterns/server-sent-events.md index 500aefd..9c3516a 100644 --- a/docs/patterns/server-sent-events.md +++ b/docs/patterns/server-sent-events.md @@ -115,7 +115,13 @@ Browsers cannot set custom headers on the built-in `EventSource` API, so the exa response body as a stream. This works in modern browsers and in Node.js 18+. ```javascript -async function listenToMessageStream(apiBaseUrl, accessToken) { +async function listenToMessageStream( + apiBaseUrl, + accessToken, + onEvent = (eventName, data) => { + console.log(`[${eventName}] ${data}`); + }, +) { const url = `${apiBaseUrl.replace(/\/$/, "")}/messages/event-stream`; const response = await fetch(url, { @@ -160,7 +166,7 @@ async function listenToMessageStream(apiBaseUrl, accessToken) { } if (dataLines.length > 0) { - console.log(`[${eventName}] ${dataLines.join("\n")}`); + onEvent(eventName, dataLines.join("\n")); } } } @@ -180,7 +186,75 @@ events. ## TypeScript -STUB +The TypeScript example uses the same `fetch`-and-stream parsing approach as the JavaScript example above, with typed +parameters and a stricter null check on `response.body`. It runs in Node.js 18+ (with DOM lib types) or in a browser +build that provides `fetch`. + +```typescript +async function listenToMessageStream( + apiBaseUrl: string, + accessToken: string, + onEvent: (eventName: string, data: string) => void = (eventName, data) => { + console.log(`[${eventName}] ${data}`); + }, +): Promise { + const url = `${apiBaseUrl.replace(/\/$/, "")}/messages/event-stream`; + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "text/event-stream", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${await response.text()}`); + } + + if (!response.body) { + throw new Error("Response body is null."); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + console.log(`Listening on ${url} (Ctrl+C to stop)...`); + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + + let eventBoundary: number; + while ((eventBoundary = buffer.indexOf("\n\n")) >= 0) { + const block = buffer.slice(0, eventBoundary); + buffer = buffer.slice(eventBoundary + 2); + + let eventName = "message"; + const dataLines: string[] = []; + + for (const line of block.split("\n")) { + if (line.startsWith("event:")) { + eventName = line.slice("event:".length).trim(); + } else if (line.startsWith("data:")) { + dataLines.push(line.slice("data:".length).trimStart()); + } + } + + if (dataLines.length > 0) { + onEvent(eventName, dataLines.join("\n")); + } + } + } +} + +// Example: +// listenToMessageStream("http://localhost:8080", process.env.API_JWT_TOKEN!); +``` # Other Resources From 5857b3a4a45701fcf388184f9affaa6a106ddd49 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 16:27:46 -0700 Subject: [PATCH 26/31] standalone C# Example --- docs/patterns/server-sent-events.md | 99 +++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/docs/patterns/server-sent-events.md b/docs/patterns/server-sent-events.md index 9c3516a..6749210 100644 --- a/docs/patterns/server-sent-events.md +++ b/docs/patterns/server-sent-events.md @@ -106,6 +106,105 @@ await listener.StreamEventsAsync( cancellationToken); ``` +### Standalone + +Use this approach when you do not want a dependency on `RedShirt.Example.Api.Interop`. It uses `HttpClient` to open +`GET /messages/event-stream`, reads the response as a stream, and parses SSE blocks manually (same event name and plain +text `data` payload as the JavaScript example). + +```csharp +using System.Net.Http.Headers; +using System.Text; + +static async Task ListenToMessageStreamAsync( + string apiBaseUrl, + string accessToken, + Action? onEvent = null, + CancellationToken cancellationToken = default) +{ + onEvent ??= message => Console.WriteLine($"[message] {message}"); + + var baseUrl = apiBaseUrl.TrimEnd('/'); + using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/messages/event-stream"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + using var httpClient = new HttpClient(); + using var response = await httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var errorBody = response.Content == null + ? string.Empty + : await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException( + $"HTTP {(int)response.StatusCode}: {errorBody}", + null, + response.StatusCode); + } + + if (response.Content == null) + { + return; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var reader = new StreamReader(stream, Encoding.UTF8); + + string? eventName = null; + var dataLines = new List(); + + while (!cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(cancellationToken); + if (line == null) + { + break; + } + + if (line.Length == 0) + { + if (dataLines.Count > 0) + { + var resolvedEventName = eventName ?? "message"; + if (string.Equals(resolvedEventName, "message", StringComparison.Ordinal)) + { + onEvent(string.Join('\n', dataLines)); + } + } + + eventName = null; + dataLines.Clear(); + continue; + } + + if (line.StartsWith(':')) + { + continue; + } + + if (line.StartsWith("event:", StringComparison.Ordinal)) + { + eventName = line["event:".Length..].Trim(); + continue; + } + + if (line.StartsWith("data:", StringComparison.Ordinal)) + { + dataLines.Add(line["data:".Length..].TrimStart()); + } + } + + cancellationToken.ThrowIfCancellationRequested(); +} + +// Example: +// await ListenToMessageStreamAsync("http://localhost:8080", accessToken); +``` + ## JavaScript The example stream lives at `GET /messages/event-stream`. Each event uses the SSE event name `message`, and the From b1717e2b0c1d2b19c1bba1565296039c92b93cdf Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 16:30:35 -0700 Subject: [PATCH 27/31] header in documentation section --- docs/patterns/server-sent-events.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/patterns/server-sent-events.md b/docs/patterns/server-sent-events.md index 6749210..29daa8d 100644 --- a/docs/patterns/server-sent-events.md +++ b/docs/patterns/server-sent-events.md @@ -85,6 +85,9 @@ With these reasons in mind, I would only pivot to SignalR if I had a specific ca # Client Examples +Reading from event stream is a different challenge then a flat HTTP request, so here are several generated code samples +for interacting with them. + ## C# ### Interop From 18e75dfdc6fc67b3908d2f61f7221f346121e1da Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 16:31:44 -0700 Subject: [PATCH 28/31] plural --- docs/patterns/server-sent-events.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/patterns/server-sent-events.md b/docs/patterns/server-sent-events.md index 29daa8d..eb1de4d 100644 --- a/docs/patterns/server-sent-events.md +++ b/docs/patterns/server-sent-events.md @@ -85,8 +85,8 @@ With these reasons in mind, I would only pivot to SignalR if I had a specific ca # Client Examples -Reading from event stream is a different challenge then a flat HTTP request, so here are several generated code samples -for interacting with them. +Reading from an event stream is a different challenge then a flat HTTP request, so here are several generated code +samples for interacting with them. ## C# From 047f86d164f1ca6a4aac5347a2d1d92082d77a87 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 16:59:31 -0700 Subject: [PATCH 29/31] Address chicken-and-egg problem problem on cold builds --- README.md | 5 ++ .../EventStreamListener.cs | 1 + .../Exceptions/ProblemDetails.cs | 29 ++++++++++ .../Exceptions/SwaggerException.cs | 58 +++++++++++++++++++ .../RedShirt.Example.Api.Interop.csproj | 8 ++- .../RedShirt.Example.Api.Interop/nswag.json | 6 +- .../Tests/EventStreamListenerTests.cs | 1 + 7 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 swagger/RedShirt.Example.Api.Interop/Exceptions/ProblemDetails.cs create mode 100644 swagger/RedShirt.Example.Api.Interop/Exceptions/SwaggerException.cs diff --git a/README.md b/README.md index bdba2c2..093fdec 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,11 @@ Below are the recommended steps for using this as a template: adjusting the comments described in `MessagesEventStreamListener`. * If you are not implementing Server-Side Events in your applied application, then I would recommend deleting `EventStreamListener.cs` outright. + * The pattern of emulating the generated swagger clients by throwing a `SwaggerException` required the manual + declaration of `SwaggerException` and `ProblemDetails` to avoid a chicken-and-egg problem on build where a + cold build without generated code failed because the manual code was expecting it. If you are removing + `EventStreamListener`, then you might also want to consider removing these manual classes and adjusting + `nswag.json` to generate them again. 5. Consider revising/pruning the Markdown files such as this README or those located in the `docs/` directory. They heavily assume that they are speaking for a general template and not for an applied application. diff --git a/swagger/RedShirt.Example.Api.Interop/EventStreamListener.cs b/swagger/RedShirt.Example.Api.Interop/EventStreamListener.cs index f3ef014..8766952 100644 --- a/swagger/RedShirt.Example.Api.Interop/EventStreamListener.cs +++ b/swagger/RedShirt.Example.Api.Interop/EventStreamListener.cs @@ -1,3 +1,4 @@ +using RedShirt.Example.Api.Interop.Exceptions; using System; using System.Collections.Generic; using System.IO; diff --git a/swagger/RedShirt.Example.Api.Interop/Exceptions/ProblemDetails.cs b/swagger/RedShirt.Example.Api.Interop/Exceptions/ProblemDetails.cs new file mode 100644 index 0000000..40c0912 --- /dev/null +++ b/swagger/RedShirt.Example.Api.Interop/Exceptions/ProblemDetails.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace RedShirt.Example.Api.Interop.Exceptions; + +// Hand-maintained DTO listed in nswag.json excludedTypeNames so NSwag does not emit a duplicate. +// Same cold-build constraint as Exceptions/SwaggerException.cs. +// EventStreamListener references this type before Client.generated.cs exists. +// It must live outside the generated file even though OpenAPI client methods also use it. +public class ProblemDetails +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public int? Status { get; set; } + + [JsonPropertyName("detail")] + public string Detail { get; set; } = string.Empty; + + [JsonPropertyName("instance")] + public string Instance { get; set; } = string.Empty; + + [JsonExtensionData] + public IDictionary? AdditionalProperties { get; set; } +} \ No newline at end of file diff --git a/swagger/RedShirt.Example.Api.Interop/Exceptions/SwaggerException.cs b/swagger/RedShirt.Example.Api.Interop/Exceptions/SwaggerException.cs new file mode 100644 index 0000000..7705e84 --- /dev/null +++ b/swagger/RedShirt.Example.Api.Interop/Exceptions/SwaggerException.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; + +namespace RedShirt.Example.Api.Interop.Exceptions; + +// Hand-maintained counterpart to NSwag exceptionClass output (see nswag.json, generateExceptionClasses=false). +// EventStreamListener.cs is compiled in the same MSBuild pass as Client.generated.cs. +// Compile inputs are resolved before the NSwag CoreCompile target runs. +// These types must already exist on disk for a cold build from before Client.generated.cs to succeed. +// Do not remove in favour of generated copies without fixing that ordering. +// BeforeTargets=CoreCompile is not enough on its own. +public class SwaggerException : Exception +{ + public int StatusCode { get; } + + public string? Response { get; } + + public IReadOnlyDictionary> Headers { get; } + + // ReSharper disable once ConvertToPrimaryConstructor + public SwaggerException( + string message, + int statusCode, + string? response, + IReadOnlyDictionary> headers, + Exception? innerException) + : base( + message + "\n\nStatus: " + statusCode + "\nResponse: \n" + + (response == null ? "(null)" : response.Substring(0, response.Length >= 512 ? 512 : response.Length)), + innerException) + { + StatusCode = statusCode; + Response = response; + Headers = headers; + } + + public override string ToString() + { + return string.Format("HTTP Response: \n\n{0}\n\n{1}", Response, base.ToString()); + } +} + +public class SwaggerException : SwaggerException +{ + public TResult Result { get; } + + public SwaggerException( + string message, + int statusCode, + string? response, + IReadOnlyDictionary> headers, + TResult result, + Exception? innerException) + : base(message, statusCode, response, headers, innerException) + { + Result = result; + } +} \ No newline at end of file diff --git a/swagger/RedShirt.Example.Api.Interop/RedShirt.Example.Api.Interop.csproj b/swagger/RedShirt.Example.Api.Interop/RedShirt.Example.Api.Interop.csproj index 05505a0..491aba0 100644 --- a/swagger/RedShirt.Example.Api.Interop/RedShirt.Example.Api.Interop.csproj +++ b/swagger/RedShirt.Example.Api.Interop/RedShirt.Example.Api.Interop.csproj @@ -15,8 +15,12 @@ - - + + diff --git a/swagger/RedShirt.Example.Api.Interop/nswag.json b/swagger/RedShirt.Example.Api.Interop/nswag.json index 02ac53a..5088afa 100644 --- a/swagger/RedShirt.Example.Api.Interop/nswag.json +++ b/swagger/RedShirt.Example.Api.Interop/nswag.json @@ -13,7 +13,7 @@ "generateDtoTypes": true, "injectHttpClient": true, "disposeHttpClient": true, - "generateExceptionClasses": true, + "generateExceptionClasses": false, "exceptionClass": "SwaggerException", "wrapDtoExceptions": true, "useHttpClientCreationMethod": false, @@ -54,7 +54,9 @@ "classStyle": "Poco", "generateDefaultValues": true, "generateDataAnnotations": true, - "excludedTypeNames": [], + "excludedTypeNames": [ + "ProblemDetails" + ], "handleReferences": false, "generateImmutableArrayProperties": false, "generateImmutableDictionaryProperties": false, diff --git a/test/RedShirt.Example.Api.Interop.UnitTests/Tests/EventStreamListenerTests.cs b/test/RedShirt.Example.Api.Interop.UnitTests/Tests/EventStreamListenerTests.cs index 147ea09..98974df 100644 --- a/test/RedShirt.Example.Api.Interop.UnitTests/Tests/EventStreamListenerTests.cs +++ b/test/RedShirt.Example.Api.Interop.UnitTests/Tests/EventStreamListenerTests.cs @@ -1,4 +1,5 @@ using Moq; +using RedShirt.Example.Api.Interop.Exceptions; using System.Net; using System.Text; From 6195253b9b04be60e6f047daeb67dbcafc525a94 Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 17:04:41 -0700 Subject: [PATCH 30/31] Move exceptions back to root. Not thrilled, but consistent with old NuGet pattern --- swagger/RedShirt.Example.Api.Interop/EventStreamListener.cs | 1 - .../{Exceptions => }/ProblemDetails.cs | 6 +++--- .../{Exceptions => }/SwaggerException.cs | 4 ++-- .../Tests/EventStreamListenerTests.cs | 1 - 4 files changed, 5 insertions(+), 7 deletions(-) rename swagger/RedShirt.Example.Api.Interop/{Exceptions => }/ProblemDetails.cs (88%) rename swagger/RedShirt.Example.Api.Interop/{Exceptions => }/SwaggerException.cs (97%) diff --git a/swagger/RedShirt.Example.Api.Interop/EventStreamListener.cs b/swagger/RedShirt.Example.Api.Interop/EventStreamListener.cs index 8766952..f3ef014 100644 --- a/swagger/RedShirt.Example.Api.Interop/EventStreamListener.cs +++ b/swagger/RedShirt.Example.Api.Interop/EventStreamListener.cs @@ -1,4 +1,3 @@ -using RedShirt.Example.Api.Interop.Exceptions; using System; using System.Collections.Generic; using System.IO; diff --git a/swagger/RedShirt.Example.Api.Interop/Exceptions/ProblemDetails.cs b/swagger/RedShirt.Example.Api.Interop/ProblemDetails.cs similarity index 88% rename from swagger/RedShirt.Example.Api.Interop/Exceptions/ProblemDetails.cs rename to swagger/RedShirt.Example.Api.Interop/ProblemDetails.cs index 40c0912..65cbe75 100644 --- a/swagger/RedShirt.Example.Api.Interop/Exceptions/ProblemDetails.cs +++ b/swagger/RedShirt.Example.Api.Interop/ProblemDetails.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -namespace RedShirt.Example.Api.Interop.Exceptions; +namespace RedShirt.Example.Api.Interop; // Hand-maintained DTO listed in nswag.json excludedTypeNames so NSwag does not emit a duplicate. -// Same cold-build constraint as Exceptions/SwaggerException.cs. +// Same cold-build constraint as SwaggerException.cs. // EventStreamListener references this type before Client.generated.cs exists. // It must live outside the generated file even though OpenAPI client methods also use it. public class ProblemDetails @@ -26,4 +26,4 @@ public class ProblemDetails [JsonExtensionData] public IDictionary? AdditionalProperties { get; set; } -} \ No newline at end of file +} diff --git a/swagger/RedShirt.Example.Api.Interop/Exceptions/SwaggerException.cs b/swagger/RedShirt.Example.Api.Interop/SwaggerException.cs similarity index 97% rename from swagger/RedShirt.Example.Api.Interop/Exceptions/SwaggerException.cs rename to swagger/RedShirt.Example.Api.Interop/SwaggerException.cs index 7705e84..0e2509c 100644 --- a/swagger/RedShirt.Example.Api.Interop/Exceptions/SwaggerException.cs +++ b/swagger/RedShirt.Example.Api.Interop/SwaggerException.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace RedShirt.Example.Api.Interop.Exceptions; +namespace RedShirt.Example.Api.Interop; // Hand-maintained counterpart to NSwag exceptionClass output (see nswag.json, generateExceptionClasses=false). // EventStreamListener.cs is compiled in the same MSBuild pass as Client.generated.cs. @@ -55,4 +55,4 @@ public SwaggerException( { Result = result; } -} \ No newline at end of file +} diff --git a/test/RedShirt.Example.Api.Interop.UnitTests/Tests/EventStreamListenerTests.cs b/test/RedShirt.Example.Api.Interop.UnitTests/Tests/EventStreamListenerTests.cs index 98974df..147ea09 100644 --- a/test/RedShirt.Example.Api.Interop.UnitTests/Tests/EventStreamListenerTests.cs +++ b/test/RedShirt.Example.Api.Interop.UnitTests/Tests/EventStreamListenerTests.cs @@ -1,5 +1,4 @@ using Moq; -using RedShirt.Example.Api.Interop.Exceptions; using System.Net; using System.Text; From 1fe72682fd0dbbc345eacf8078e3e599190965fd Mon Sep 17 00:00:00 2001 From: Alan Deutscher Date: Sat, 5 Sep 2026 21:54:33 -0700 Subject: [PATCH 31/31] mqtt nuget --- .../RedShirt.Example.Api.ClientEvents.Library.Mqtt.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/RedShirt.Example.Api.ClientEvents.Library.Mqtt.csproj b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/RedShirt.Example.Api.ClientEvents.Library.Mqtt.csproj index 62a4db0..1409a56 100644 --- a/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/RedShirt.Example.Api.ClientEvents.Library.Mqtt.csproj +++ b/src/RedShirt.Example.Api.ClientEvents.Library.Mqtt/RedShirt.Example.Api.ClientEvents.Library.Mqtt.csproj @@ -9,7 +9,7 @@ - +