From 23ecaf8bd610b9d2f3b065b00e36ed76a32a71d8 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Wed, 17 Sep 2025 17:07:52 +0200 Subject: [PATCH 01/20] chore(deps): update dependencies --- src/WART-Client/WART-Client.csproj | 6 +++--- src/WART-Core/WART-Core.csproj | 10 +++++----- src/WART-Tests/WART-Tests.csproj | 2 +- src/WART-WebApiRealTime/WART-Api.csproj | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index 2c84c39..5447a5f 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -21,9 +21,9 @@ - - - + + + diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index 17026ae..0dc86e2 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -12,9 +12,9 @@ https://github.com/engineering87/WART LICENSE.txt - 4.0.0.0 - 4.0.0.0 - 6.0.0 + 6.0.1 + 6.0.1 + 6.0.1 icon.png README.md @@ -22,8 +22,8 @@ - - + + diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index 2042972..3c62790 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -15,7 +15,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj index 0e8df5a..87a1ba6 100755 --- a/src/WART-WebApiRealTime/WART-Api.csproj +++ b/src/WART-WebApiRealTime/WART-Api.csproj @@ -8,7 +8,7 @@ - + From 56bd679ba768ef524a0769cfa0e5ed83dfa2d4f0 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sat, 4 Oct 2025 16:14:49 +0200 Subject: [PATCH 02/20] chore(deps): update dependencies to latest versions --- src/WART-Tests/WART-Tests.csproj | 4 ++-- src/WART-WebApiRealTime/WART-Api.csproj | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index 3c62790..f2e4616 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -16,10 +16,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj index 87a1ba6..7c54d4a 100755 --- a/src/WART-WebApiRealTime/WART-Api.csproj +++ b/src/WART-WebApiRealTime/WART-Api.csproj @@ -8,7 +8,7 @@ - + From ae055c5e0c803a24cff479856b1e8bea5ad0f1bc Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 20 Oct 2025 00:16:23 +0200 Subject: [PATCH 03/20] chore(deps): update dependencies to latest versions --- src/WART-Client/WART-Client.csproj | 6 +++--- src/WART-Core/WART-Core.csproj | 4 ++-- src/WART-Tests/WART-Tests.csproj | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index 5447a5f..b69f272 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -21,9 +21,9 @@ - - - + + + diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index 0dc86e2..9d35001 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -22,8 +22,8 @@ - - + + diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index f2e4616..8cac7d6 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -15,7 +15,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + From 8731d85f200f131992f743ce623f1efdf49ee76f Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 17 Nov 2025 00:20:34 +0100 Subject: [PATCH 04/20] chore: upgrade project to .NET 10 and update dependencies --- src/WART-Client/WART-Client.csproj | 8 ++++---- src/WART-Core/WART-Core.csproj | 9 ++++----- src/WART-Tests/WART-Tests.csproj | 7 +++---- src/WART-WebApiRealTime/Startup.cs | 4 +--- src/WART-WebApiRealTime/WART-Api.csproj | 4 ++-- 5 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index b69f272..16fde6e 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 WART_Client WART_Client.Program false @@ -21,9 +21,9 @@ - - - + + + diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index 9d35001..34d0399 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 WART_Core true Francesco Del Re @@ -14,16 +14,15 @@ 6.0.1 6.0.1 - 6.0.1 + 7.0.0 icon.png README.md - - - + + diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index 8cac7d6..67af462 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -1,11 +1,10 @@ - net9.0 + net10.0 WART_Tests enable enable - false true @@ -15,8 +14,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/src/WART-WebApiRealTime/Startup.cs b/src/WART-WebApiRealTime/Startup.cs index 43fd915..fcf3f98 100755 --- a/src/WART-WebApiRealTime/Startup.cs +++ b/src/WART-WebApiRealTime/Startup.cs @@ -5,9 +5,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.OpenApi.Models; -using System; -using System.Collections.Generic; +using Microsoft.OpenApi; using WART_Core.Enum; using WART_Core.Middleware; diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj index 7c54d4a..0015bf4 100755 --- a/src/WART-WebApiRealTime/WART-Api.csproj +++ b/src/WART-WebApiRealTime/WART-Api.csproj @@ -1,14 +1,14 @@ - net9.0 + net10.0 WART_Api False false - + From b360a53a6c6b9f0f2a164b61dfd588fbbf73ab38 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 17 Nov 2025 00:25:09 +0100 Subject: [PATCH 05/20] fix(jwt): prevent null reference in LifetimeValidator for tokens without exp --- .../Authentication/JWT/JwtServiceCollectionExtension.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs b/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs index f1526e1..0e77b41 100755 --- a/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs +++ b/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs @@ -63,7 +63,7 @@ public static IServiceCollection AddJwtMiddleware(this IServiceCollection servic options.TokenValidationParameters = new TokenValidationParameters { - LifetimeValidator = (before, expires, token, parameters) => expires > DateTime.UtcNow, + LifetimeValidator = (before, expires, token, parameters) => expires != null && expires > DateTime.UtcNow, ValidateAudience = false, ValidateIssuer = false, ValidateActor = false, From 4a5919e92c23f08c1c6e1fbcdc372920f10dc8a4 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 17 Nov 2025 01:32:23 +0100 Subject: [PATCH 06/20] chore: minor code cleanup --- src/WART-Core/Helpers/SerializationHelper.cs | 2 +- src/WART-Core/Services/WartEventWorker.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/WART-Core/Helpers/SerializationHelper.cs b/src/WART-Core/Helpers/SerializationHelper.cs index c235564..cdbc908 100644 --- a/src/WART-Core/Helpers/SerializationHelper.cs +++ b/src/WART-Core/Helpers/SerializationHelper.cs @@ -6,7 +6,7 @@ namespace WART_Core.Helpers { - public class SerializationHelper + public static class SerializationHelper { // Default JSON serializer options to be used for serialization and deserialization. private static readonly JsonSerializerOptions DefaultOptions = new JsonSerializerOptions diff --git a/src/WART-Core/Services/WartEventWorker.cs b/src/WART-Core/Services/WartEventWorker.cs index c565157..bf4eaaa 100644 --- a/src/WART-Core/Services/WartEventWorker.cs +++ b/src/WART-Core/Services/WartEventWorker.cs @@ -42,7 +42,7 @@ public WartEventWorker(WartEventQueueService eventQueue, IHubContext hubCo /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("WartEventWorker started."); + _logger.LogDebug("WartEventWorker started."); // The worker will keep running as long as the cancellation token is not triggered. while (!stoppingToken.IsCancellationRequested) @@ -83,7 +83,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) await Task.Delay(IdleDelayMs, stoppingToken); } - _logger.LogInformation("WartEventWorker stopped."); + _logger.LogDebug("WartEventWorker stopped."); } /// @@ -117,7 +117,7 @@ private async Task SendToHub(WartEvent wartEvent, List filters, catch (Exception ex) { // Log errors that occur while sending events to SignalR clients. - _logger?.LogError(ex, "Error sending WartEvent to clients"); + _logger?.LogError(ex, "Error while sending event {EventId}", wartEvent?.EventId); throw; } @@ -164,7 +164,7 @@ await _hubContext.Clients.All .SendAsync("Send", wartEvent.ToString(), cancellationToken); // Log the event sent to all clients. - _logger?.LogInformation("Event: {EventName}, Details: {EventDetails}", + _logger?.LogInformation("Event: {EventName}, Details: {EventDetails}", nameof(WartEvent), wartEvent.ToString()); } } From 9031041e8266e8a5c1ee282d5d21e4dff29d59ae Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Thu, 4 Dec 2025 08:26:24 +0100 Subject: [PATCH 07/20] chore(deps): update project dependencies --- src/WART-Client/WART-Client.csproj | 2 +- src/WART-Core/WART-Core.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index 16fde6e..1b0dc84 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -24,7 +24,7 @@ - + diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index 34d0399..1c1eb86 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -21,7 +21,7 @@ - + From bab8775239e6a10ae5a0e3ca02def7289b2fd4c8 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Thu, 11 Dec 2025 00:01:56 +0100 Subject: [PATCH 08/20] chore(deps): update project dependencies --- src/WART-Client/WART-Client.csproj | 6 +++--- src/WART-Core/WART-Core.csproj | 2 +- src/WART-Tests/WART-Tests.csproj | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index 1b0dc84..4d6a77d 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -21,9 +21,9 @@ - - - + + + diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index 1c1eb86..ae2611b 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -22,7 +22,7 @@ - + diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index 67af462..fcff545 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -14,7 +14,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + From 8ff7e55fd9078b4faadcb90bae6784f967b5abb8 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sun, 21 Dec 2025 15:23:58 +0100 Subject: [PATCH 09/20] chore(deps): update project dependencies --- src/WART-WebApiRealTime/WART-Api.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj index 0015bf4..ccd858a 100755 --- a/src/WART-WebApiRealTime/WART-Api.csproj +++ b/src/WART-WebApiRealTime/WART-Api.csproj @@ -8,7 +8,7 @@ - + From ef55b8123cf17f0c9afa45bab7f3542fd71eb995 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Wed, 14 Jan 2026 14:42:50 +0100 Subject: [PATCH 10/20] chore(deps): update dependencies --- src/WART-Client/WART-Client.csproj | 6 +++--- src/WART-Core/WART-Core.csproj | 2 +- src/WART-Tests/WART-Tests.csproj | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index 4d6a77d..afa9ddd 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -21,9 +21,9 @@ - - - + + + diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index ae2611b..9946ee1 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -22,7 +22,7 @@ - + diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index fcff545..4907501 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -14,7 +14,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + From 55659639a01ca4c8bb152191421437047141207a Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Thu, 15 Jan 2026 16:26:38 +0100 Subject: [PATCH 11/20] test(middleware): register response compression deps in WebApplicationFactory setup --- .../Middleware/WartApplicationBuilderExtensionTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/WART-Tests/Middleware/WartApplicationBuilderExtensionTests.cs b/src/WART-Tests/Middleware/WartApplicationBuilderExtensionTests.cs index e16d7e1..682dbaf 100644 --- a/src/WART-Tests/Middleware/WartApplicationBuilderExtensionTests.cs +++ b/src/WART-Tests/Middleware/WartApplicationBuilderExtensionTests.cs @@ -21,6 +21,7 @@ public async Task UseWartMiddleware_ShouldMapControllersAndHub() { configure.ConfigureServices(services => { + services.AddWartMiddleware(); services.AddControllers(); services.AddSignalR(); }); From 4e7573cb68e92712fba33aee5e68afc1b84ba2d1 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Wed, 4 Feb 2026 15:03:16 +0100 Subject: [PATCH 12/20] chore(deps): update dependencies --- src/WART-WebApiRealTime/WART-Api.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj index ccd858a..3e44962 100755 --- a/src/WART-WebApiRealTime/WART-Api.csproj +++ b/src/WART-WebApiRealTime/WART-Api.csproj @@ -8,7 +8,7 @@ - + From 73a3add26bb5b85f5ceb1dcf05c74fa6b0503672 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sat, 7 Feb 2026 15:49:51 +0100 Subject: [PATCH 13/20] chore(deps): update dependencies --- src/WART-WebApiRealTime/WART-Api.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj index 3e44962..7dcb6fa 100755 --- a/src/WART-WebApiRealTime/WART-Api.csproj +++ b/src/WART-WebApiRealTime/WART-Api.csproj @@ -8,7 +8,7 @@ - + From 5f02c119443865588a9ceba4a8318e593e4e2671 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sat, 28 Feb 2026 21:44:50 +0100 Subject: [PATCH 14/20] fix: harden event processing and edge case handling --- src/WART-Client/WartTestClient.cs | 2 +- src/WART-Client/WartTestClientCookie.cs | 4 +- src/WART-Client/WartTestClientJwt.cs | 2 +- src/WART-Client/appsettings.json | 2 +- src/WART-Core/Entity/WartEventWithFilters.cs | 8 ++ .../WartApplicationBuilderExtension.cs | 6 +- .../JsonArrayOrObjectStringConverter.cs | 4 +- src/WART-Core/Services/WartEventWorker.cs | 22 ++- src/WART-Tests/Entity/WartEventTests.cs | 70 ++++++++++ .../Entity/WartEventWithFiltersTests.cs | 60 ++++++++ .../JsonArrayOrObjectStringConverterTests.cs | 83 ++++++++++- .../Services/WartEventQueueServiceTests.cs | 94 +++++++++++++ .../Services/WartEventWorkerTests.cs | 130 ++++++++++++++++++ src/WART-Tests/Utilities/LogSanitizerTests.cs | 47 +++++++ 14 files changed, 521 insertions(+), 13 deletions(-) create mode 100644 src/WART-Tests/Entity/WartEventWithFiltersTests.cs create mode 100644 src/WART-Tests/Services/WartEventQueueServiceTests.cs create mode 100644 src/WART-Tests/Services/WartEventWorkerTests.cs create mode 100644 src/WART-Tests/Utilities/LogSanitizerTests.cs diff --git a/src/WART-Client/WartTestClient.cs b/src/WART-Client/WartTestClient.cs index 5bfb666..dbcd95a 100755 --- a/src/WART-Client/WartTestClient.cs +++ b/src/WART-Client/WartTestClient.cs @@ -38,7 +38,7 @@ public static async Task ConnectAsync(string wartHubUrl) { Console.WriteLine(exception); Console.WriteLine(Environment.NewLine); - await Task.Delay(new Random().Next(0, 5) * 1000); + await Task.Delay(Random.Shared.Next(0, 5) * 1000); await hubConnection.StartAsync(); }; diff --git a/src/WART-Client/WartTestClientCookie.cs b/src/WART-Client/WartTestClientCookie.cs index 9e692c6..b0bdde6 100644 --- a/src/WART-Client/WartTestClientCookie.cs +++ b/src/WART-Client/WartTestClientCookie.cs @@ -28,7 +28,7 @@ public static async Task ConnectAsync(string hubUrl) AllowAutoRedirect = true }; - using var httpClient = new HttpClient(handler); + using var httpClient = new HttpClient(handler, disposeHandler: false); var loginContent = new FormUrlEncodedContent(new[] { @@ -66,7 +66,7 @@ public static async Task ConnectAsync(string hubUrl) hubConnection.Closed += async (ex) => { Console.WriteLine($"Connection closed: {ex?.Message}"); - await Task.Delay(new Random().Next(0, 5) * 1000); + await Task.Delay(Random.Shared.Next(0, 5) * 1000); if (hubConnection != null) await hubConnection.StartAsync(); }; diff --git a/src/WART-Client/WartTestClientJwt.cs b/src/WART-Client/WartTestClientJwt.cs index 7dfdd73..302cf03 100755 --- a/src/WART-Client/WartTestClientJwt.cs +++ b/src/WART-Client/WartTestClientJwt.cs @@ -43,7 +43,7 @@ public static async Task ConnectAsync(string wartHubUrl, string key) { Console.WriteLine(exception); Console.WriteLine(Environment.NewLine); - await Task.Delay(new Random().Next(0, 5) * 1000); + await Task.Delay(Random.Shared.Next(0, 5) * 1000); await hubConnection.StartAsync(); }; diff --git a/src/WART-Client/appsettings.json b/src/WART-Client/appsettings.json index f990e0e..f29633e 100644 --- a/src/WART-Client/appsettings.json +++ b/src/WART-Client/appsettings.json @@ -1,7 +1,7 @@ { "Scheme": "https", "Host": "localhost", - "Port": "54644", + "Port": "62198", "Hubname": "warthub", "AuthenticationType": "JWT", "Key": "dn3341fmcscscwe28419brhwbwgbss4t", diff --git a/src/WART-Core/Entity/WartEventWithFilters.cs b/src/WART-Core/Entity/WartEventWithFilters.cs index 7e3ca99..c7d5d69 100644 --- a/src/WART-Core/Entity/WartEventWithFilters.cs +++ b/src/WART-Core/Entity/WartEventWithFilters.cs @@ -1,6 +1,7 @@ // (c) 2024 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using Microsoft.AspNetCore.Mvc.Filters; +using System; using System.Collections.Generic; namespace WART_Core.Entity @@ -20,6 +21,11 @@ public class WartEventWithFilters /// public List Filters { get; set; } + /// + /// The number of times this event has been retried. + /// + public int RetryCount { get; set; } + /// /// Initializes a new instance of the WartEventWithFilters class. /// @@ -27,6 +33,8 @@ public class WartEventWithFilters /// The list of filters applied to the event. public WartEventWithFilters(WartEvent wartEvent, List filters) { + ArgumentNullException.ThrowIfNull(wartEvent); + // Initialize the WartEvent and Filters properties WartEvent = wartEvent; Filters = filters; diff --git a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs index a570715..d1ffffa 100755 --- a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs +++ b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs @@ -107,7 +107,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app /// Thrown when the hub name is null or empty. public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, string hubName) { - if (string.IsNullOrEmpty(hubName)) + if (string.IsNullOrWhiteSpace(hubName)) throw new ArgumentException("Invalid hub name"); app.UseForwardedHeaders(); @@ -143,7 +143,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app var unique = hubNameList .Where(s => !string.IsNullOrWhiteSpace(s)) .Select(NormalizeHubPath) - .Distinct() + .Distinct(StringComparer.Ordinal) .ToList(); app.UseEndpoints(endpoints => @@ -167,7 +167,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app /// Thrown when the hub name is null or empty. public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, string hubName, HubType hubType) { - if (string.IsNullOrEmpty(hubName)) + if (string.IsNullOrWhiteSpace(hubName)) throw new ArgumentException("Invalid hub name"); app.UseForwardedHeaders(); diff --git a/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs b/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs index a729a12..c35135a 100644 --- a/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs +++ b/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs @@ -25,7 +25,9 @@ public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS JsonTokenType.String => reader.GetString(), JsonTokenType.StartObject or JsonTokenType.StartArray => JsonDocument.ParseValue(ref reader).RootElement.GetRawText(), JsonTokenType.Null => null, - _ => reader.GetString() + JsonTokenType.Number => JsonDocument.ParseValue(ref reader).RootElement.GetRawText(), + JsonTokenType.True or JsonTokenType.False => reader.GetBoolean().ToString(), + _ => throw new JsonException($"Unexpected token type: {reader.TokenType}") }; } diff --git a/src/WART-Core/Services/WartEventWorker.cs b/src/WART-Core/Services/WartEventWorker.cs index bf4eaaa..26dfd46 100644 --- a/src/WART-Core/Services/WartEventWorker.cs +++ b/src/WART-Core/Services/WartEventWorker.cs @@ -26,6 +26,7 @@ public class WartEventWorker : BackgroundService where THub : Hub private const int NoClientsDelayMs = 500; private const int IdleDelayMs = 200; + private const int MaxRetryCount = 5; /// /// Constructor that initializes the worker with the event queue, hub context, and logger. @@ -68,14 +69,29 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _logger.LogInformation("Event sent: {Event}", wartEvent); } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Shutting down — re-enqueue without logging an error so + // the event is not lost, then exit the loop. + _eventQueue.Enqueue(wartEventWithFilters); + break; + } catch (Exception ex) { // Log any errors that occur while sending the event. _logger.LogError(ex, "Error while sending event."); - // Re-enqueue the event for retry - // We lost the order of the events, but we can't lose the events - _eventQueue.Enqueue(wartEventWithFilters); + // Re-enqueue the event for retry up to the maximum retry count. + wartEventWithFilters.RetryCount++; + if (wartEventWithFilters.RetryCount <= MaxRetryCount) + { + _eventQueue.Enqueue(wartEventWithFilters); + } + else + { + _logger.LogWarning("Event {EventId} dropped after {MaxRetries} retries.", + wartEventWithFilters.WartEvent?.EventId, MaxRetryCount); + } } } diff --git a/src/WART-Tests/Entity/WartEventTests.cs b/src/WART-Tests/Entity/WartEventTests.cs index 3d2c951..020f6be 100644 --- a/src/WART-Tests/Entity/WartEventTests.cs +++ b/src/WART-Tests/Entity/WartEventTests.cs @@ -108,5 +108,75 @@ public void WartEvent_GetResponseObject_ShouldDeserializeJsonResponse() // Assert Assert.NotNull(deserializedResponse); } + + [Fact] + public void WartEvent_Constructor_NullParameters_DefaultsToEmpty() + { + // Act + var wartEvent = new WartEvent(null!, null!, null!); + + // Assert + Assert.Equal(string.Empty, wartEvent.HttpMethod); + Assert.Equal(string.Empty, wartEvent.HttpPath); + Assert.Equal(string.Empty, wartEvent.RemoteAddress); + } + + [Fact] + public void WartEvent_FullConstructor_NullRequestResponse_DoesNotThrow() + { + // Act + var wartEvent = new WartEvent(null, null, "PUT", "/api/items", "10.0.0.1"); + + // Assert + Assert.NotEqual(Guid.Empty, wartEvent.EventId); + Assert.Equal("PUT", wartEvent.HttpMethod); + } + + [Fact] + public void WartEvent_ToDictionary_ContainsAllKeys() + { + // Arrange + var wartEvent = new WartEvent("DELETE", "/api/items/1", "192.168.0.1") + { + ExtraInfo = "test-info" + }; + + // Act + var dict = wartEvent.ToDictionary(); + + // Assert + Assert.Equal(9, dict.Count); + Assert.Equal(wartEvent.EventId, dict["EventId"]); + Assert.Equal("DELETE", dict["HttpMethod"]); + Assert.Equal("/api/items/1", dict["HttpPath"]); + Assert.Equal("192.168.0.1", dict["RemoteAddress"]); + Assert.Equal("test-info", dict["ExtraInfo"]); + Assert.True(dict.ContainsKey("TimeStamp")); + Assert.True(dict.ContainsKey("UtcTimeStamp")); + Assert.True(dict.ContainsKey("JsonRequestPayload")); + Assert.True(dict.ContainsKey("JsonResponsePayload")); + } + + [Fact] + public void WartEvent_Timestamps_AreConsistent() + { + // Arrange & Act + var before = DateTime.UtcNow; + var wartEvent = new WartEvent("GET", "/", "::1"); + var after = DateTime.UtcNow; + + // Assert + Assert.InRange(wartEvent.UtcTimeStamp, before, after); + Assert.Equal(wartEvent.UtcTimeStamp.ToLocalTime(), wartEvent.TimeStamp); + } + + [Fact] + public void WartEvent_EventId_IsUnique() + { + var a = new WartEvent("GET", "/", "::1"); + var b = new WartEvent("GET", "/", "::1"); + + Assert.NotEqual(a.EventId, b.EventId); + } } } diff --git a/src/WART-Tests/Entity/WartEventWithFiltersTests.cs b/src/WART-Tests/Entity/WartEventWithFiltersTests.cs new file mode 100644 index 0000000..d25a91d --- /dev/null +++ b/src/WART-Tests/Entity/WartEventWithFiltersTests.cs @@ -0,0 +1,60 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using Microsoft.AspNetCore.Mvc.Filters; +using WART_Core.Entity; +using WART_Core.Filters; + +namespace WART_Tests.Entity +{ + public class WartEventWithFiltersTests + { + [Fact] + public void Constructor_SetsProperties() + { + var evt = new WartEvent("POST", "/api/items", "10.0.0.1"); + var filters = new List { new GroupWartAttribute("g1") }; + + var item = new WartEventWithFilters(evt, filters); + + Assert.Same(evt, item.WartEvent); + Assert.Same(filters, item.Filters); + Assert.Equal(0, item.RetryCount); + } + + [Fact] + public void Constructor_NullWartEvent_ThrowsArgumentNullException() + { + Assert.Throws(() => + new WartEventWithFilters(null!, [])); + } + + [Fact] + public void Constructor_NullFilters_DoesNotThrow() + { + var evt = new WartEvent("GET", "/", "::1"); + + var item = new WartEventWithFilters(evt, null!); + + Assert.Null(item.Filters); + } + + [Fact] + public void RetryCount_DefaultsToZero() + { + var item = new WartEventWithFilters(new WartEvent("GET", "/", "::1"), []); + + Assert.Equal(0, item.RetryCount); + } + + [Fact] + public void RetryCount_CanBeIncremented() + { + var item = new WartEventWithFilters(new WartEvent("GET", "/", "::1"), []); + + item.RetryCount++; + item.RetryCount++; + + Assert.Equal(2, item.RetryCount); + } + } +} diff --git a/src/WART-Tests/Serialization/JsonArrayOrObjectStringConverterTests.cs b/src/WART-Tests/Serialization/JsonArrayOrObjectStringConverterTests.cs index 5530bac..9b4d4a3 100644 --- a/src/WART-Tests/Serialization/JsonArrayOrObjectStringConverterTests.cs +++ b/src/WART-Tests/Serialization/JsonArrayOrObjectStringConverterTests.cs @@ -13,7 +13,7 @@ public class JsonArrayOrObjectStringConverterTests Converters = { new JsonArrayOrObjectStringConverter() } }; - private class Wrapper { public string Payload { get; set; } } + private class Wrapper { public string? Payload { get; set; } } [Fact] public void Write_String_RemainsString() @@ -62,5 +62,86 @@ public void WartEvent_ToString_IsValidJson() var json = e.ToString(); using var _ = JsonDocument.Parse(json); } + + [Fact] + public void Read_NullToken_ReturnsNull() + { + var json = "{\"Payload\": null}"; + var wrapper = JsonSerializer.Deserialize(json, _opts); + Assert.Null(wrapper!.Payload); + } + + [Fact] + public void Read_NumberToken_ReturnsStringRepresentation() + { + var json = "{\"Payload\": 42}"; + var wrapper = JsonSerializer.Deserialize(json, _opts); + Assert.Equal("42", wrapper!.Payload); + } + + [Fact] + public void Read_DecimalNumberToken_ReturnsStringRepresentation() + { + var json = "{\"Payload\": 3.14}"; + var wrapper = JsonSerializer.Deserialize(json, _opts); + Assert.Equal("3.14", wrapper!.Payload); + } + + [Fact] + public void Read_BooleanTrueToken_ReturnsString() + { + var json = "{\"Payload\": true}"; + var wrapper = JsonSerializer.Deserialize(json, _opts); + Assert.Equal("True", wrapper!.Payload); + } + + [Fact] + public void Read_BooleanFalseToken_ReturnsString() + { + var json = "{\"Payload\": false}"; + var wrapper = JsonSerializer.Deserialize(json, _opts); + Assert.Equal("False", wrapper!.Payload); + } + + [Fact] + public void Read_ArrayToken_ReturnsRawJson() + { + var json = "{\"Payload\": [1,2,3]}"; + var wrapper = JsonSerializer.Deserialize(json, _opts); + Assert.Equal("[1,2,3]", wrapper!.Payload); + } + + [Fact] + public void Write_NullValue_WritesNull() + { + var obj = new Wrapper { Payload = null! }; + var json = JsonSerializer.Serialize(obj, _opts); + Assert.Contains("\"Payload\":null", json); + } + + [Fact] + public void Write_EmptyString_WritesEmptyString() + { + var obj = new Wrapper { Payload = string.Empty }; + var json = JsonSerializer.Serialize(obj, _opts); + Assert.Contains("\"Payload\":\"\"", json); + } + + [Fact] + public void Read_StringToken_ReturnsString() + { + var json = "{\"Payload\": \"simple text\"}"; + var wrapper = JsonSerializer.Deserialize(json, _opts); + Assert.Equal("simple text", wrapper!.Payload); + } + + [Fact] + public void Roundtrip_ObjectPayload_PreservesStructure() + { + var obj = new Wrapper { Payload = "{\"key\":\"value\"}" }; + var json = JsonSerializer.Serialize(obj, _opts); + var deserialized = JsonSerializer.Deserialize(json, _opts); + Assert.Equal("{\"key\":\"value\"}", deserialized!.Payload); + } } } diff --git a/src/WART-Tests/Services/WartEventQueueServiceTests.cs b/src/WART-Tests/Services/WartEventQueueServiceTests.cs new file mode 100644 index 0000000..8e559b7 --- /dev/null +++ b/src/WART-Tests/Services/WartEventQueueServiceTests.cs @@ -0,0 +1,94 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using WART_Core.Entity; +using WART_Core.Services; + +namespace WART_Tests.Services +{ + public class WartEventQueueServiceTests + { + private static WartEventWithFilters MakeEvent() + { + return new WartEventWithFilters(new WartEvent("GET", "/api/test", "127.0.0.1"), []); + } + + [Fact] + public void NewQueue_IsEmpty() + { + var queue = new WartEventQueueService(); + + Assert.True(queue.IsEmpty); + Assert.Equal(0, queue.Count); + } + + [Fact] + public void Enqueue_IncreasesCount() + { + var queue = new WartEventQueueService(); + + queue.Enqueue(MakeEvent()); + queue.Enqueue(MakeEvent()); + + Assert.Equal(2, queue.Count); + Assert.False(queue.IsEmpty); + } + + [Fact] + public void Enqueue_Null_IsIgnored() + { + var queue = new WartEventQueueService(); + + queue.Enqueue(null!); + + Assert.True(queue.IsEmpty); + Assert.Equal(0, queue.Count); + } + + [Fact] + public void TryDequeue_ReturnsItemsInFifoOrder() + { + var queue = new WartEventQueueService(); + var first = MakeEvent(); + var second = MakeEvent(); + + queue.Enqueue(first); + queue.Enqueue(second); + + Assert.True(queue.TryDequeue(out var item1)); + Assert.Same(first, item1); + Assert.True(queue.TryDequeue(out var item2)); + Assert.Same(second, item2); + Assert.True(queue.IsEmpty); + } + + [Fact] + public void TryDequeue_EmptyQueue_ReturnsFalse() + { + var queue = new WartEventQueueService(); + + Assert.False(queue.TryDequeue(out var item)); + Assert.Null(item); + } + + [Fact] + public void TryPeek_ReturnsItemWithoutRemoving() + { + var queue = new WartEventQueueService(); + var evt = MakeEvent(); + queue.Enqueue(evt); + + Assert.True(queue.TryPeek(out var peeked)); + Assert.Same(evt, peeked); + Assert.Equal(1, queue.Count); + } + + [Fact] + public void TryPeek_EmptyQueue_ReturnsFalse() + { + var queue = new WartEventQueueService(); + + Assert.False(queue.TryPeek(out var item)); + Assert.Null(item); + } + } +} diff --git a/src/WART-Tests/Services/WartEventWorkerTests.cs b/src/WART-Tests/Services/WartEventWorkerTests.cs new file mode 100644 index 0000000..70198d5 --- /dev/null +++ b/src/WART-Tests/Services/WartEventWorkerTests.cs @@ -0,0 +1,130 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using Moq; +using WART_Core.Entity; +using WART_Core.Filters; +using WART_Core.Services; + +namespace WART_Tests.Services +{ + public class WartEventWorkerTests + { + public class TestHub : Hub { } + + /// + /// Exposes ExecuteAsync for testing. + /// + private class TestableWorker : WartEventWorker + { + public TestableWorker(WartEventQueueService queue, IHubContext hub, ILogger> logger) + : base(queue, hub, logger) { } + + public Task RunAsync(CancellationToken ct) => ExecuteAsync(ct); + } + + private static WartEventWithFilters MakeEvent(List? filters = null) + { + var evt = new WartEvent("GET", "/api/test", "127.0.0.1"); + return new WartEventWithFilters(evt, filters ?? []); + } + + private static (TestableWorker worker, WartEventQueueService queue, Mock> hubMock) CreateWorker() + { + var queue = new WartEventQueueService(); + var hubMock = new Mock>(); + var clientsMock = new Mock(); + var clientProxyMock = new Mock(); + + clientsMock.Setup(c => c.All).Returns(clientProxyMock.Object); + clientsMock.Setup(c => c.Group(It.IsAny())).Returns(clientProxyMock.Object); + hubMock.Setup(h => h.Clients).Returns(clientsMock.Object); + + var logger = new Mock>>(); + var worker = new TestableWorker(queue, hubMock.Object, logger.Object); + + return (worker, queue, hubMock); + } + + [Fact] + public async Task Stops_Gracefully_When_Cancelled() + { + var (worker, queue, _) = CreateWorker(); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + + // TaskCanceledException is expected — BackgroundService base class + // normally swallows it on shutdown. + await Assert.ThrowsAnyAsync(() => worker.RunAsync(cts.Token)); + } + + [Fact] + public async Task Processes_Event_And_Sends_To_All_Clients() + { + var (worker, queue, hubMock) = CreateWorker(); + var item = MakeEvent(); + queue.Enqueue(item); + + // With no connected clients the worker delays without dequeuing. + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(600)); + await Assert.ThrowsAnyAsync(() => worker.RunAsync(cts.Token)); + + // Event remains in the queue because HasConnectedClientsFor is false. + Assert.Equal(1, queue.Count); + } + + [Fact] + public async Task ExcludeWart_Filter_Skips_Event() + { + var (worker, queue, _) = CreateWorker(); + var item = MakeEvent([new ExcludeWartAttribute()]); + queue.Enqueue(item); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(600)); + await Assert.ThrowsAnyAsync(() => worker.RunAsync(cts.Token)); + + // Event stays in queue because no clients are connected + Assert.Equal(1, queue.Count); + } + + [Fact] + public void RetryCount_Increments_On_Event() + { + var item = MakeEvent(); + Assert.Equal(0, item.RetryCount); + + item.RetryCount++; + Assert.Equal(1, item.RetryCount); + } + + [Fact] + public void Event_Dropped_After_MaxRetries_Is_Not_Requeued() + { + var queue = new WartEventQueueService(); + var item = MakeEvent(); + + // Simulate reaching the max retry count (5) + item.RetryCount = 6; + + // At this point the worker logic would NOT re-enqueue because RetryCount > MaxRetryCount + bool shouldReenqueue = item.RetryCount <= 5; + Assert.False(shouldReenqueue); + } + + [Fact] + public void Event_Under_MaxRetries_Is_Requeued() + { + var queue = new WartEventQueueService(); + var item = MakeEvent(); + item.RetryCount = 3; + + bool shouldReenqueue = item.RetryCount <= 5; + Assert.True(shouldReenqueue); + + queue.Enqueue(item); + Assert.Equal(1, queue.Count); + } + } +} diff --git a/src/WART-Tests/Utilities/LogSanitizerTests.cs b/src/WART-Tests/Utilities/LogSanitizerTests.cs new file mode 100644 index 0000000..23f95ba --- /dev/null +++ b/src/WART-Tests/Utilities/LogSanitizerTests.cs @@ -0,0 +1,47 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using WART_Core.Utilities; + +namespace WART_Tests.Utilities +{ + public class LogSanitizerTests + { + [Fact] + public void Sanitize_Null_ReturnsNull() + { + Assert.Null(LogSanitizer.Sanitize(null!)); + } + + [Fact] + public void Sanitize_Empty_ReturnsEmpty() + { + Assert.Equal(string.Empty, LogSanitizer.Sanitize(string.Empty)); + } + + [Fact] + public void Sanitize_NormalString_ReturnsUnchanged() + { + Assert.Equal("hello world", LogSanitizer.Sanitize("hello world")); + } + + [Fact] + public void Sanitize_RemovesNewlines() + { + Assert.Equal("ab", LogSanitizer.Sanitize("a\nb")); + Assert.Equal("ab", LogSanitizer.Sanitize("a\r\nb")); + } + + [Fact] + public void Sanitize_RemovesTabsAndControlChars() + { + Assert.Equal("ab", LogSanitizer.Sanitize("a\tb")); + Assert.Equal("ab", LogSanitizer.Sanitize("a\0b")); + } + + [Fact] + public void Sanitize_PreservesUnicodeCharacters() + { + Assert.Equal("café ñ 日本語", LogSanitizer.Sanitize("café ñ 日本語")); + } + } +} From 7a295ebad1ee556526cda82ca47096b5657de870 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Wed, 25 Mar 2026 09:51:52 +0100 Subject: [PATCH 15/20] chore(deps): update project dependencies --- src/WART-Client/WART-Client.csproj | 8 ++++---- src/WART-Core/WART-Core.csproj | 4 ++-- src/WART-Tests/WART-Tests.csproj | 6 +++--- src/WART-WebApiRealTime/WART-Api.csproj | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index afa9ddd..67e81c8 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -21,10 +21,10 @@ - - - - + + + + diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index 9946ee1..090871e 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -21,8 +21,8 @@ - - + + diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index 4907501..572ea11 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -10,12 +10,12 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj index 7dcb6fa..f2d2b7b 100755 --- a/src/WART-WebApiRealTime/WART-Api.csproj +++ b/src/WART-WebApiRealTime/WART-Api.csproj @@ -8,7 +8,7 @@ - + From 5ad0ee1d18a8fc1c5972a84b191eebbb69c43a96 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Tue, 31 Mar 2026 15:40:25 +0200 Subject: [PATCH 16/20] chore(deps): update project dependencies --- src/WART-WebApiRealTime/WART-Api.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj index f2d2b7b..49aaaee 100755 --- a/src/WART-WebApiRealTime/WART-Api.csproj +++ b/src/WART-WebApiRealTime/WART-Api.csproj @@ -8,7 +8,7 @@ - + From ce231e95f67dc2ac37845db768e3023ea71be2a4 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Tue, 14 Apr 2026 00:13:10 +0200 Subject: [PATCH 17/20] chore(deps): update project dependencies --- src/WART-Tests/WART-Tests.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index 572ea11..b69b6e1 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -15,13 +15,13 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + - all runtime; build; native; contentfiles; analyzers; buildtransitive + From b708446fab33ae58518ae00c559c5a451f340160 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sat, 18 Apr 2026 16:14:13 +0200 Subject: [PATCH 18/20] chore(deps): update project dependencies --- src/WART-Client/WART-Client.csproj | 6 +++--- src/WART-Core/WART-Core.csproj | 2 +- src/WART-Tests/WART-Tests.csproj | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index 67e81c8..c3bd3fb 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -21,9 +21,9 @@ - - - + + + diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index 090871e..a6b70a1 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -22,7 +22,7 @@ - + diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index b69b6e1..ce15f24 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -10,11 +10,11 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + From e1c86b06cb51c9c4a77b5814205e236a3cc1d25f Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 20 Apr 2026 00:17:32 +0200 Subject: [PATCH 19/20] feat: add Minimal API support via endpoint filter --- README.md | 64 ++++++++++++- src/WART-Core/Filters/WartEndpointFilter.cs | 63 +++++++++++++ .../Middleware/WartEndpointExtensions.cs | 58 ++++++++++++ .../Endpoints/TestEndpoints.cs | 93 +++++++++++++++++++ src/WART-MinimalApi/Entity/TestEntity.cs | 14 +++ src/WART-MinimalApi/Program.cs | 58 ++++++++++++ src/WART-MinimalApi/WART-MinimalApi.csproj | 18 ++++ src/WART-WebApiRealTime.sln | 50 ++++++++++ 8 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 src/WART-Core/Filters/WartEndpointFilter.cs create mode 100644 src/WART-Core/Middleware/WartEndpointExtensions.cs create mode 100644 src/WART-MinimalApi/Endpoints/TestEndpoints.cs create mode 100644 src/WART-MinimalApi/Entity/TestEntity.cs create mode 100644 src/WART-MinimalApi/Program.cs create mode 100644 src/WART-MinimalApi/WART-MinimalApi.csproj diff --git a/README.md b/README.md index 190eb6b..693a255 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ -WART is a lightweight C# .NET library that extends your Web API controllers to forward incoming calls directly to a SignalR Hub. -The Hub broadcasts rich, structured events containing request and response details in **real-time**. +WART is a lightweight C# .NET library that forwards your Web API calls directly to a SignalR Hub. +It works with both **Controllers** and **Minimal APIs**, broadcasting rich, structured events containing request and response details in **real-time**. Supports **JWT** and **Cookie Authentication** for secure communication. ## 📑 Table of Contents @@ -24,6 +24,7 @@ Supports **JWT** and **Cookie Authentication** for secure communication. - [Multiple Hubs](#multiple-hubs) - [Client Example](#client-example) - [Supported Authentication Modes](#-supported-authentication-modes) +- [Minimal API Support](#-minimal-api-support) - [Excluding APIs from Event Propagation](#-excluding-apis-from-event-propagation) - [Group-based Event Dispatching](#-group-based-event-dispatching) - [NuGet](#-nuget) @@ -33,7 +34,9 @@ Supports **JWT** and **Cookie Authentication** for secure communication. ## ✨ Features - Converts REST API calls into SignalR events, enabling real-time communication. +- Works with both **Controllers** and **Minimal APIs**. - Provides controllers (`WartController`, `WartControllerJwt`, `WartControllerCookie`) for automatic SignalR event broadcasting. +- Provides `UseWart()` endpoint filter for Minimal API support. - Supports JWT authentication for SignalR hub connections. - Allows API exclusion from event broadcasting with `[ExcludeWart]` attribute. - Enables group-specific event dispatching with `[GroupWart("group_name")]`. @@ -47,8 +50,10 @@ dotnet add package WART-Core ``` ### ⚙️ How it works -WART overrides `OnActionExecuting` and `OnActionExecuted` in a custom base controller. -For every API request/response: +**Controllers:** WART overrides `OnActionExecuting` and `OnActionExecuted` in a custom base controller. +**Minimal APIs:** WART uses an `IEndpointFilter` (`WartEndpointFilter`) that intercepts the request pipeline. + +In both cases, for every API request/response: 1) Captures request and response data. 2) Wraps them in a `WartEvent`. 3) Publishes it through a SignalR Hub to all connected clients. @@ -156,6 +161,57 @@ hubConnection.On("Send", data => await hubConnection.StartAsync(); ``` +### 🔌 Minimal API Support +WART fully supports **Minimal APIs** via the `UseWart()` endpoint filter extension method. No base controller is needed. + +#### Basic usage + +```csharp +using WART_Core.Middleware; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddWartMiddleware(); + +var app = builder.Build(); +app.UseWartMiddleware(); + +app.MapGet("/api/items", () => new[] { "item1", "item2" }) + .UseWart(); + +app.MapPost("/api/items", (Item item) => item) + .UseWart(); + +app.Run(); +``` + +#### Applying to a route group + +You can apply WART to all endpoints in a group at once: + +```csharp +var group = app.MapGroup("/api/v2").UseWart(); +group.MapGet("/orders", () => GetOrders()); +group.MapPost("/orders", (Order o) => CreateOrder(o)); +``` + +#### Excluding endpoints + +```csharp +app.MapGet("/api/health", () => "ok") + .UseWart() + .ExcludeFromWart(); +``` + +#### Group-based dispatching + +```csharp +app.MapPost("/api/orders", (Order o) => CreateOrder(o)) + .UseWart() + .WartGroup("admin", "managers"); +``` + +> 💡 The `ExcludeWart` and `GroupWart` attributes work as endpoint metadata for Minimal APIs and as action filters for controllers — no breaking changes. + ## 🔐 Supported Authentication Modes | Mode | Description | Hub Class | Required Middleware | diff --git a/src/WART-Core/Filters/WartEndpointFilter.cs b/src/WART-Core/Filters/WartEndpointFilter.cs new file mode 100644 index 0000000..3507027 --- /dev/null +++ b/src/WART-Core/Filters/WartEndpointFilter.cs @@ -0,0 +1,63 @@ +// (c) 2024-2026 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Filters; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using WART_Core.Entity; +using WART_Core.Services; + +namespace WART_Core.Filters +{ + /// + /// An that captures Minimal API request/response data + /// and enqueues a for SignalR broadcast. + /// Respects and + /// when applied as endpoint metadata. + /// + public sealed class WartEndpointFilter : IEndpointFilter + { + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var endpoint = context.HttpContext.GetEndpoint(); + var metadata = endpoint?.Metadata; + + // If the endpoint is decorated with ExcludeWartAttribute, skip processing. + if (metadata?.GetMetadata() is not null) + { + return await next(context); + } + + // Capture request arguments as a dictionary. + var requestArgs = new Dictionary(); + for (int i = 0; i < context.Arguments.Count; i++) + { + requestArgs[$"arg{i}"] = context.Arguments[i]; + } + + // Invoke the next filter/handler. + var result = await next(context); + + // Build the WartEvent from request/response data. + var httpContext = context.HttpContext; + var wartEvent = new WartEvent( + request: requestArgs, + response: result, + httpMethod: httpContext.Request.Method, + httpPath: httpContext.Request.Path, + remoteAddress: httpContext.Connection.RemoteIpAddress?.ToString() + ); + + // Collect IFilterMetadata from endpoint metadata for group routing support. + var filters = metadata? + .OfType() + .ToList() ?? []; + + var queue = httpContext.RequestServices.GetService(typeof(WartEventQueueService)) as WartEventQueueService; + queue?.Enqueue(new WartEventWithFilters(wartEvent, filters)); + + return result; + } + } +} diff --git a/src/WART-Core/Middleware/WartEndpointExtensions.cs b/src/WART-Core/Middleware/WartEndpointExtensions.cs new file mode 100644 index 0000000..8853f80 --- /dev/null +++ b/src/WART-Core/Middleware/WartEndpointExtensions.cs @@ -0,0 +1,58 @@ +// (c) 2024-2026 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using WART_Core.Filters; + +namespace WART_Core.Middleware +{ + /// + /// Extension methods for adding WART support to Minimal API endpoints. + /// + public static class WartEndpointExtensions + { + /// + /// Adds the WART endpoint filter to a Minimal API route, enabling + /// real-time SignalR event broadcasting for the endpoint. + /// + /// The route handler builder. + /// The updated for chaining. + public static RouteHandlerBuilder UseWart(this RouteHandlerBuilder builder) + { + return builder.AddEndpointFilter(); + } + + /// + /// Adds the WART endpoint filter to all endpoints in a Minimal API route group, + /// enabling real-time SignalR event broadcasting for every endpoint in the group. + /// + /// The route group builder. + /// The updated for chaining. + public static RouteGroupBuilder UseWart(this RouteGroupBuilder builder) + { + return builder.AddEndpointFilter(); + } + + /// + /// Excludes a Minimal API endpoint from WART SignalR event broadcasting. + /// + /// The route handler builder. + /// The updated for chaining. + public static RouteHandlerBuilder ExcludeFromWart(this RouteHandlerBuilder builder) + { + return builder.WithMetadata(new ExcludeWartAttribute()); + } + + /// + /// Directs WART SignalR events for this Minimal API endpoint to specific groups. + /// + /// The route handler builder. + /// The SignalR group names to target. + /// The updated for chaining. + public static RouteHandlerBuilder WartGroup(this RouteHandlerBuilder builder, params string[] groupNames) + { + return builder.WithMetadata(new GroupWartAttribute(groupNames)); + } + } +} diff --git a/src/WART-MinimalApi/Endpoints/TestEndpoints.cs b/src/WART-MinimalApi/Endpoints/TestEndpoints.cs new file mode 100644 index 0000000..3536fc1 --- /dev/null +++ b/src/WART-MinimalApi/Endpoints/TestEndpoints.cs @@ -0,0 +1,93 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using WART_Core.Middleware; +using WART_MinimalApi.Entity; + +namespace WART_MinimalApi.Endpoints +{ + /// + /// Minimal API endpoint definitions equivalent to TestController in WART-Api. + /// Demonstrates WART integration using the UseWart() endpoint filter. + /// + public static class TestEndpoints + { + private static readonly List Items = + [ + new TestEntity { Id = 1, Param = "Item1" }, + new TestEntity { Id = 2, Param = "Item2" }, + new TestEntity { Id = 3, Param = "Item3" } + ]; + + public static void MapTestEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/test") + .WithTags("Test"); + + // GET /api/test — returns all items (WART enabled) + group.MapGet("/", () => Results.Ok(Items)) + .UseWart(); + + // GET /api/test/{id} — returns a single item (excluded from WART) + group.MapGet("/{id:int}", (int id) => + { + var item = Items.FirstOrDefault(x => x.Id == id); + return item is not null ? Results.Ok(item) : Results.NotFound(); + }) + .UseWart() + .ExcludeFromWart(); + + // POST /api/test — creates an item (WART with group-based dispatching) + group.MapPost("/", (TestEntity entity) => + { + Items.Add(entity); + return Results.Ok(entity); + }) + .UseWart() + .WartGroup("SampleGroupName"); + + // PATCH /api/test/{id} — partially updates an item + group.MapPatch("/{id:int}", (int id, TestEntity entity) => + { + var item = Items.FirstOrDefault(x => x.Id == id); + if (item is null) + { + return Results.NotFound(); + } + item.Param = entity.Param; + return Results.Ok(item); + }) + .UseWart(); + + // PUT /api/test/{id} — fully updates an item + group.MapPut("/{id:int}", (int id, TestEntity entity) => + { + var item = Items.FirstOrDefault(x => x.Id == id); + if (item is null) + { + return Results.NotFound(); + } + item.Param = entity.Param; + return Results.Ok(item); + }) + .UseWart(); + + // DELETE /api/test/{id} — deletes an item + group.MapDelete("/{id:int}", (int id) => + { + var item = Items.FirstOrDefault(x => x.Id == id); + if (item is null) + { + return Results.NotFound(); + } + Items.Remove(item); + return Results.Ok(item); + }) + .UseWart(); + } + } +} diff --git a/src/WART-MinimalApi/Entity/TestEntity.cs b/src/WART-MinimalApi/Entity/TestEntity.cs new file mode 100644 index 0000000..b9cb10b --- /dev/null +++ b/src/WART-MinimalApi/Entity/TestEntity.cs @@ -0,0 +1,14 @@ +// (c) 2019 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using System; + +namespace WART_MinimalApi.Entity +{ + [Serializable] + public class TestEntity + { + public int Id { get; set; } + public DateTime Date { get; set; } + public string Param { get; set; } + } +} diff --git a/src/WART-MinimalApi/Program.cs b/src/WART-MinimalApi/Program.cs new file mode 100644 index 0000000..fcbdc87 --- /dev/null +++ b/src/WART-MinimalApi/Program.cs @@ -0,0 +1,58 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi; +using WART_Core.Enum; +using WART_Core.Middleware; +using WART_MinimalApi.Endpoints; + +var builder = WebApplication.CreateBuilder(args); + +// Add the WART middleware service extension + +// Default without authentication +builder.Services.AddWartMiddleware(); + +// With JWT authentication +//builder.Services.AddWartMiddleware(hubType: HubType.JwtAuthentication, tokenKey: "dn3341fmcscscwe28419brhwbwgbss4t"); + +// With Cookie authentication +//builder.Services.AddWartMiddleware(hubType: HubType.CookieAuthentication); + +// Register the Swagger generator +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo { Title = "WART-MinimalApi", Version = "v1" }); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} + +// Enable Swagger +app.UseSwagger(); +app.UseSwaggerUI(c => +{ + c.SwaggerEndpoint("/swagger/v1/swagger.json", "WART-MinimalApi"); + c.RoutePrefix = string.Empty; +}); + +// Use the WART middleware builder extension +// Default without authentication +app.UseWartMiddleware(); + +// With JWT authentication +//app.UseWartMiddleware(HubType.JwtAuthentication); + +// With Cookie authentication +//app.UseWartMiddleware(HubType.CookieAuthentication); + +// Map all Minimal API endpoints +app.MapTestEndpoints(); + +app.Run(); diff --git a/src/WART-MinimalApi/WART-MinimalApi.csproj b/src/WART-MinimalApi/WART-MinimalApi.csproj new file mode 100644 index 0000000..b6c4782 --- /dev/null +++ b/src/WART-MinimalApi/WART-MinimalApi.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + WART_MinimalApi + False + false + + + + + + + + + + + diff --git a/src/WART-WebApiRealTime.sln b/src/WART-WebApiRealTime.sln index dfa0edc..c46386d 100755 --- a/src/WART-WebApiRealTime.sln +++ b/src/WART-WebApiRealTime.sln @@ -11,28 +11,78 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WART-Core", "WART-Core\WART EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WART-Tests", "WART-Tests\WART-Tests.csproj", "{A80F18C6-1FF2-4F13-A65D-9134EE44158A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WART-MinimalApi", "WART-MinimalApi\WART-MinimalApi.csproj", "{40FC3CC3-70D9-4A95-8BB0-F124D8DDBEB3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {2F57B8CA-6C41-485D-82D1-F9039FD11360}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2F57B8CA-6C41-485D-82D1-F9039FD11360}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F57B8CA-6C41-485D-82D1-F9039FD11360}.Debug|x64.ActiveCfg = Debug|Any CPU + {2F57B8CA-6C41-485D-82D1-F9039FD11360}.Debug|x64.Build.0 = Debug|Any CPU + {2F57B8CA-6C41-485D-82D1-F9039FD11360}.Debug|x86.ActiveCfg = Debug|Any CPU + {2F57B8CA-6C41-485D-82D1-F9039FD11360}.Debug|x86.Build.0 = Debug|Any CPU {2F57B8CA-6C41-485D-82D1-F9039FD11360}.Release|Any CPU.ActiveCfg = Release|Any CPU {2F57B8CA-6C41-485D-82D1-F9039FD11360}.Release|Any CPU.Build.0 = Release|Any CPU + {2F57B8CA-6C41-485D-82D1-F9039FD11360}.Release|x64.ActiveCfg = Release|Any CPU + {2F57B8CA-6C41-485D-82D1-F9039FD11360}.Release|x64.Build.0 = Release|Any CPU + {2F57B8CA-6C41-485D-82D1-F9039FD11360}.Release|x86.ActiveCfg = Release|Any CPU + {2F57B8CA-6C41-485D-82D1-F9039FD11360}.Release|x86.Build.0 = Release|Any CPU {57AD2261-F3CA-49C6-9BC0-FCB2DC5A86E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {57AD2261-F3CA-49C6-9BC0-FCB2DC5A86E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57AD2261-F3CA-49C6-9BC0-FCB2DC5A86E2}.Debug|x64.ActiveCfg = Debug|Any CPU + {57AD2261-F3CA-49C6-9BC0-FCB2DC5A86E2}.Debug|x64.Build.0 = Debug|Any CPU + {57AD2261-F3CA-49C6-9BC0-FCB2DC5A86E2}.Debug|x86.ActiveCfg = Debug|Any CPU + {57AD2261-F3CA-49C6-9BC0-FCB2DC5A86E2}.Debug|x86.Build.0 = Debug|Any CPU {57AD2261-F3CA-49C6-9BC0-FCB2DC5A86E2}.Release|Any CPU.ActiveCfg = Release|Any CPU {57AD2261-F3CA-49C6-9BC0-FCB2DC5A86E2}.Release|Any CPU.Build.0 = Release|Any CPU + {57AD2261-F3CA-49C6-9BC0-FCB2DC5A86E2}.Release|x64.ActiveCfg = Release|Any CPU + {57AD2261-F3CA-49C6-9BC0-FCB2DC5A86E2}.Release|x64.Build.0 = Release|Any CPU + {57AD2261-F3CA-49C6-9BC0-FCB2DC5A86E2}.Release|x86.ActiveCfg = Release|Any CPU + {57AD2261-F3CA-49C6-9BC0-FCB2DC5A86E2}.Release|x86.Build.0 = Release|Any CPU {7588AAE3-E882-468E-81A0-90B8F92D7B72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7588AAE3-E882-468E-81A0-90B8F92D7B72}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7588AAE3-E882-468E-81A0-90B8F92D7B72}.Debug|x64.ActiveCfg = Debug|Any CPU + {7588AAE3-E882-468E-81A0-90B8F92D7B72}.Debug|x64.Build.0 = Debug|Any CPU + {7588AAE3-E882-468E-81A0-90B8F92D7B72}.Debug|x86.ActiveCfg = Debug|Any CPU + {7588AAE3-E882-468E-81A0-90B8F92D7B72}.Debug|x86.Build.0 = Debug|Any CPU {7588AAE3-E882-468E-81A0-90B8F92D7B72}.Release|Any CPU.ActiveCfg = Release|Any CPU {7588AAE3-E882-468E-81A0-90B8F92D7B72}.Release|Any CPU.Build.0 = Release|Any CPU + {7588AAE3-E882-468E-81A0-90B8F92D7B72}.Release|x64.ActiveCfg = Release|Any CPU + {7588AAE3-E882-468E-81A0-90B8F92D7B72}.Release|x64.Build.0 = Release|Any CPU + {7588AAE3-E882-468E-81A0-90B8F92D7B72}.Release|x86.ActiveCfg = Release|Any CPU + {7588AAE3-E882-468E-81A0-90B8F92D7B72}.Release|x86.Build.0 = Release|Any CPU {A80F18C6-1FF2-4F13-A65D-9134EE44158A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A80F18C6-1FF2-4F13-A65D-9134EE44158A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A80F18C6-1FF2-4F13-A65D-9134EE44158A}.Debug|x64.ActiveCfg = Debug|Any CPU + {A80F18C6-1FF2-4F13-A65D-9134EE44158A}.Debug|x64.Build.0 = Debug|Any CPU + {A80F18C6-1FF2-4F13-A65D-9134EE44158A}.Debug|x86.ActiveCfg = Debug|Any CPU + {A80F18C6-1FF2-4F13-A65D-9134EE44158A}.Debug|x86.Build.0 = Debug|Any CPU {A80F18C6-1FF2-4F13-A65D-9134EE44158A}.Release|Any CPU.ActiveCfg = Release|Any CPU {A80F18C6-1FF2-4F13-A65D-9134EE44158A}.Release|Any CPU.Build.0 = Release|Any CPU + {A80F18C6-1FF2-4F13-A65D-9134EE44158A}.Release|x64.ActiveCfg = Release|Any CPU + {A80F18C6-1FF2-4F13-A65D-9134EE44158A}.Release|x64.Build.0 = Release|Any CPU + {A80F18C6-1FF2-4F13-A65D-9134EE44158A}.Release|x86.ActiveCfg = Release|Any CPU + {A80F18C6-1FF2-4F13-A65D-9134EE44158A}.Release|x86.Build.0 = Release|Any CPU + {40FC3CC3-70D9-4A95-8BB0-F124D8DDBEB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {40FC3CC3-70D9-4A95-8BB0-F124D8DDBEB3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {40FC3CC3-70D9-4A95-8BB0-F124D8DDBEB3}.Debug|x64.ActiveCfg = Debug|Any CPU + {40FC3CC3-70D9-4A95-8BB0-F124D8DDBEB3}.Debug|x64.Build.0 = Debug|Any CPU + {40FC3CC3-70D9-4A95-8BB0-F124D8DDBEB3}.Debug|x86.ActiveCfg = Debug|Any CPU + {40FC3CC3-70D9-4A95-8BB0-F124D8DDBEB3}.Debug|x86.Build.0 = Debug|Any CPU + {40FC3CC3-70D9-4A95-8BB0-F124D8DDBEB3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {40FC3CC3-70D9-4A95-8BB0-F124D8DDBEB3}.Release|Any CPU.Build.0 = Release|Any CPU + {40FC3CC3-70D9-4A95-8BB0-F124D8DDBEB3}.Release|x64.ActiveCfg = Release|Any CPU + {40FC3CC3-70D9-4A95-8BB0-F124D8DDBEB3}.Release|x64.Build.0 = Release|Any CPU + {40FC3CC3-70D9-4A95-8BB0-F124D8DDBEB3}.Release|x86.ActiveCfg = Release|Any CPU + {40FC3CC3-70D9-4A95-8BB0-F124D8DDBEB3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 1512faec1bcc591b12034aa2c9e6bbe2d3edca3c Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sat, 25 Apr 2026 23:51:41 +0200 Subject: [PATCH 20/20] fix: harden WartEventQueueService and improve error handling --- src/WART-Client/WART-Client.csproj | 6 +- src/WART-Client/WartTestClient.cs | 13 ++- src/WART-Core/Entity/WartEvent.cs | 1 - src/WART-Core/Filters/WartEndpointFilter.cs | 10 ++- src/WART-Core/Hubs/WartHubBase.cs | 1 - .../JsonArrayOrObjectStringConverter.cs | 23 +++-- .../Services/WartEventQueueService.cs | 74 +++++++++++---- src/WART-Core/Services/WartEventWorker.cs | 16 ++-- src/WART-Core/WART-Core.csproj | 6 +- .../Services/WartEventQueueServiceTests.cs | 90 +++++++++++++++++-- .../Services/WartEventWorkerTests.cs | 22 +++++ src/WART-Tests/WART-Tests.csproj | 2 +- 12 files changed, 213 insertions(+), 51 deletions(-) diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index c3bd3fb..43da9fe 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -21,9 +21,9 @@ - - - + + + diff --git a/src/WART-Client/WartTestClient.cs b/src/WART-Client/WartTestClient.cs index dbcd95a..3cf11a2 100755 --- a/src/WART-Client/WartTestClient.cs +++ b/src/WART-Client/WartTestClient.cs @@ -30,7 +30,7 @@ public static async Task ConnectAsync(string wartHubUrl) hubConnection.On("Send", (data) => { Console.WriteLine(data); - Console.WriteLine($"Message size: {Encoding.UTF8.GetBytes(data).Length} byte"); + Console.WriteLine($"Message size: {Encoding.UTF8.GetBytes(data ?? string.Empty).Length} byte"); Console.WriteLine(Environment.NewLine); }); @@ -38,8 +38,15 @@ public static async Task ConnectAsync(string wartHubUrl) { Console.WriteLine(exception); Console.WriteLine(Environment.NewLine); - await Task.Delay(Random.Shared.Next(0, 5) * 1000); - await hubConnection.StartAsync(); + try + { + await Task.Delay(Random.Shared.Next(0, 5) * 1000); + await hubConnection.StartAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"Reconnection failed: {ex.Message}"); + } }; hubConnection.On("ConnectionFailed", (exception) => diff --git a/src/WART-Core/Entity/WartEvent.cs b/src/WART-Core/Entity/WartEvent.cs index 7170051..5fae218 100755 --- a/src/WART-Core/Entity/WartEvent.cs +++ b/src/WART-Core/Entity/WartEvent.cs @@ -13,7 +13,6 @@ namespace WART_Core.Entity /// along with additional metadata such as timestamps and remote addresses. /// This class is serializable and designed to be used for logging or transmitting event data. /// - [Serializable] public class WartEvent { /// diff --git a/src/WART-Core/Filters/WartEndpointFilter.cs b/src/WART-Core/Filters/WartEndpointFilter.cs index 3507027..1e2e426 100644 --- a/src/WART-Core/Filters/WartEndpointFilter.cs +++ b/src/WART-Core/Filters/WartEndpointFilter.cs @@ -55,7 +55,15 @@ public async ValueTask InvokeAsync(EndpointFilterInvocationContext conte .ToList() ?? []; var queue = httpContext.RequestServices.GetService(typeof(WartEventQueueService)) as WartEventQueueService; - queue?.Enqueue(new WartEventWithFilters(wartEvent, filters)); + if (queue is not null) + { + queue.Enqueue(new WartEventWithFilters(wartEvent, filters)); + } + else + { + // WartEventQueueService not registered — event will be lost. + System.Diagnostics.Debug.WriteLine("WartEventQueueService is not registered. Event was not enqueued."); + } return result; } diff --git a/src/WART-Core/Hubs/WartHubBase.cs b/src/WART-Core/Hubs/WartHubBase.cs index c8c384f..2c638d3 100644 --- a/src/WART-Core/Hubs/WartHubBase.cs +++ b/src/WART-Core/Hubs/WartHubBase.cs @@ -72,7 +72,6 @@ public override Task OnDisconnectedAsync(Exception exception) if (_connectionsByHub.TryGetValue(GetType(), out var dict)) { dict.TryRemove(Context.ConnectionId, out _); - if (dict.IsEmpty) _connectionsByHub.TryRemove(GetType(), out _); } if (exception != null) diff --git a/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs b/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs index c35135a..e1e4bb8 100644 --- a/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs +++ b/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs @@ -20,15 +20,22 @@ public class JsonArrayOrObjectStringConverter : JsonConverter /// public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - return reader.TokenType switch + switch (reader.TokenType) { - JsonTokenType.String => reader.GetString(), - JsonTokenType.StartObject or JsonTokenType.StartArray => JsonDocument.ParseValue(ref reader).RootElement.GetRawText(), - JsonTokenType.Null => null, - JsonTokenType.Number => JsonDocument.ParseValue(ref reader).RootElement.GetRawText(), - JsonTokenType.True or JsonTokenType.False => reader.GetBoolean().ToString(), - _ => throw new JsonException($"Unexpected token type: {reader.TokenType}") - }; + case JsonTokenType.String: + return reader.GetString(); + case JsonTokenType.Null: + return null; + case JsonTokenType.True or JsonTokenType.False: + return reader.GetBoolean().ToString(); + case JsonTokenType.StartObject or JsonTokenType.StartArray or JsonTokenType.Number: + using (var doc = JsonDocument.ParseValue(ref reader)) + { + return doc.RootElement.GetRawText(); + } + default: + throw new JsonException($"Unexpected token type: {reader.TokenType}"); + } } /// diff --git a/src/WART-Core/Services/WartEventQueueService.cs b/src/WART-Core/Services/WartEventQueueService.cs index 4d71ce4..437f618 100644 --- a/src/WART-Core/Services/WartEventQueueService.cs +++ b/src/WART-Core/Services/WartEventQueueService.cs @@ -1,52 +1,88 @@ // (c) 2024 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) -using System.Collections.Concurrent; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; using WART_Core.Entity; namespace WART_Core.Services { /// - /// A service that manages a concurrent queue for WartEvent objects with filters. - /// This class provides methods for enqueuing and dequeuing events. + /// A service that manages a channel-based queue for WartEvent objects with filters. + /// Uses for efficient, non-polling async consumption. /// public class WartEventQueueService { - // A thread-safe queue to hold WartEvent objects along with their associated filters. - private readonly ConcurrentQueue _queue = new(); + private readonly Channel _channel = + Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = false, + SingleWriter = false + }); /// - /// Enqueues a WartEventWithFilters object to the queue. + /// Enqueues a WartEventWithFilters object to the channel. /// /// The WartEventWithFilters object to enqueue. - public void Enqueue(WartEventWithFilters wartEventWithFilters) + /// True if the event was successfully written; false if the channel has been completed. + /// Thrown when wartEventWithFilters is null. + public bool Enqueue(WartEventWithFilters wartEventWithFilters) { - if (wartEventWithFilters != null) + ArgumentNullException.ThrowIfNull(wartEventWithFilters); + + if (!_channel.Writer.TryWrite(wartEventWithFilters)) { - // Adds the event with filters to the concurrent queue. - _queue.Enqueue(wartEventWithFilters); + throw new InvalidOperationException("Unable to enqueue the event. The channel may have been completed."); } + + return true; } /// - /// Attempts to dequeue a WartEventWithFilters object from the queue. + /// Attempts to read a WartEventWithFilters object from the channel without waiting. /// - /// The dequeued WartEventWithFilters object. - /// True if an event was dequeued; otherwise, false. - public bool TryDequeue(out WartEventWithFilters item) => _queue.TryDequeue(out item); + /// The read WartEventWithFilters object. + /// True if an event was read; otherwise, false. + public bool TryDequeue(out WartEventWithFilters item) => _channel.Reader.TryRead(out item); /// /// Attempts to peek at the next item without removing it. /// - public bool TryPeek(out WartEventWithFilters item) => _queue.TryPeek(out item); + public bool TryPeek(out WartEventWithFilters item) => _channel.Reader.TryPeek(out item); + + /// + /// Gets the current count of events in the channel. + /// + public int Count => _channel.Reader.Count; + + /// + /// Check if the channel is empty. + /// Note: This is an approximate check and may be subject to race conditions in concurrent scenarios. + /// + public bool IsEmpty => !_channel.Reader.TryPeek(out _); + + /// + /// Waits asynchronously until data is available to read. + /// + /// The cancellation token. + /// True if data is available; false if the channel is completed. + public ValueTask WaitToReadAsync(CancellationToken cancellationToken = default) + => _channel.Reader.WaitToReadAsync(cancellationToken); /// - /// Gets the current count of events in the queue. + /// Returns an async enumerable that reads all items from the channel. /// - public int Count => _queue.Count; + /// The cancellation token. + /// An async enumerable of WartEventWithFilters. + public IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken = default) + => _channel.Reader.ReadAllAsync(cancellationToken); /// - /// Check if the queue is empty + /// Marks the channel as complete, signaling that no more items will be written. /// - public bool IsEmpty => _queue.IsEmpty; + /// An optional exception to propagate to consumers. + public void Complete(Exception? error = null) => _channel.Writer.TryComplete(error); } } \ No newline at end of file diff --git a/src/WART-Core/Services/WartEventWorker.cs b/src/WART-Core/Services/WartEventWorker.cs index 26dfd46..32d1d1b 100644 --- a/src/WART-Core/Services/WartEventWorker.cs +++ b/src/WART-Core/Services/WartEventWorker.cs @@ -25,8 +25,9 @@ public class WartEventWorker : BackgroundService where THub : Hub private readonly ILogger> _logger; private const int NoClientsDelayMs = 500; - private const int IdleDelayMs = 200; private const int MaxRetryCount = 5; + private const int BaseRetryDelayMs = 200; + private const int MaxRetryDelayMs = 5000; /// /// Constructor that initializes the worker with the event queue, hub context, and logger. @@ -55,6 +56,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) continue; } + // Wait asynchronously until an event is available (no polling). + await _eventQueue.WaitToReadAsync(stoppingToken); + // Dequeue events and process them. while (_eventQueue.TryDequeue(out var wartEventWithFilters)) { @@ -81,10 +85,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // Log any errors that occur while sending the event. _logger.LogError(ex, "Error while sending event."); - // Re-enqueue the event for retry up to the maximum retry count. + // Re-enqueue the event for retry with exponential backoff. wartEventWithFilters.RetryCount++; if (wartEventWithFilters.RetryCount <= MaxRetryCount) { + var shift = Math.Min(wartEventWithFilters.RetryCount - 1, 20); + var delayMs = Math.Min( + BaseRetryDelayMs * (1 << shift), + MaxRetryDelayMs); + await Task.Delay(delayMs, stoppingToken); _eventQueue.Enqueue(wartEventWithFilters); } else @@ -94,9 +103,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } } - - // Wait for 200 ms before checking for new events in the queue. - await Task.Delay(IdleDelayMs, stoppingToken); } _logger.LogDebug("WartEventWorker stopped."); diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index a6b70a1..e48acaf 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -12,8 +12,6 @@ https://github.com/engineering87/WART LICENSE.txt - 6.0.1 - 6.0.1 7.0.0 icon.png README.md @@ -22,7 +20,7 @@ - + diff --git a/src/WART-Tests/Services/WartEventQueueServiceTests.cs b/src/WART-Tests/Services/WartEventQueueServiceTests.cs index 8e559b7..dc18bf1 100644 --- a/src/WART-Tests/Services/WartEventQueueServiceTests.cs +++ b/src/WART-Tests/Services/WartEventQueueServiceTests.cs @@ -34,14 +34,11 @@ public void Enqueue_IncreasesCount() } [Fact] - public void Enqueue_Null_IsIgnored() + public void Enqueue_Null_ThrowsArgumentNullException() { var queue = new WartEventQueueService(); - queue.Enqueue(null!); - - Assert.True(queue.IsEmpty); - Assert.Equal(0, queue.Count); + Assert.Throws(() => queue.Enqueue(null!)); } [Fact] @@ -90,5 +87,88 @@ public void TryPeek_EmptyQueue_ReturnsFalse() Assert.False(queue.TryPeek(out var item)); Assert.Null(item); } + + [Fact] + public async Task WaitToReadAsync_CompletesImmediately_WhenDataAvailable() + { + var queue = new WartEventQueueService(); + queue.Enqueue(MakeEvent()); + + var result = await queue.WaitToReadAsync(CancellationToken.None); + + Assert.True(result); + } + + [Fact] + public async Task WaitToReadAsync_Unblocks_WhenItemEnqueued() + { + var queue = new WartEventQueueService(); + + var waitTask = queue.WaitToReadAsync(CancellationToken.None); + + // Should not be completed yet since the queue is empty. + Assert.False(waitTask.IsCompleted); + + // Enqueue an item to unblock. + queue.Enqueue(MakeEvent()); + + var result = await waitTask; + Assert.True(result); + } + + [Fact] + public async Task WaitToReadAsync_ThrowsOnCancellation() + { + var queue = new WartEventQueueService(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + () => queue.WaitToReadAsync(cts.Token).AsTask()); + } + + [Fact] + public void Complete_PreventsEnqueue() + { + var queue = new WartEventQueueService(); + queue.Complete(); + + Assert.Throws(() => queue.Enqueue(MakeEvent())); + } + + [Fact] + public async Task Complete_WithError_PropagatesExceptionToReaders() + { + var queue = new WartEventQueueService(); + var expected = new InvalidOperationException("test error"); + + queue.Complete(expected); + + var ex = await Assert.ThrowsAsync( + async () => await queue.ReadAllAsync().GetAsyncEnumerator().MoveNextAsync()); + Assert.Same(expected, ex); + } + + [Fact] + public async Task ReadAllAsync_ReturnsEnqueuedItems() + { + var queue = new WartEventQueueService(); + var evt1 = MakeEvent(); + var evt2 = MakeEvent(); + + queue.Enqueue(evt1); + queue.Enqueue(evt2); + queue.Complete(); + + var items = new List(); + await foreach (var item in queue.ReadAllAsync()) + { + items.Add(item); + } + + Assert.Equal(2, items.Count); + Assert.Same(evt1, items[0]); + Assert.Same(evt2, items[1]); + } } } diff --git a/src/WART-Tests/Services/WartEventWorkerTests.cs b/src/WART-Tests/Services/WartEventWorkerTests.cs index 70198d5..6f0dde7 100644 --- a/src/WART-Tests/Services/WartEventWorkerTests.cs +++ b/src/WART-Tests/Services/WartEventWorkerTests.cs @@ -126,5 +126,27 @@ public void Event_Under_MaxRetries_Is_Requeued() queue.Enqueue(item); Assert.Equal(1, queue.Count); } + + [Theory] + [InlineData(1, 200)] // 200 * 2^0 = 200 + [InlineData(2, 400)] // 200 * 2^1 = 400 + [InlineData(3, 800)] // 200 * 2^2 = 800 + [InlineData(4, 1600)] // 200 * 2^3 = 1600 + [InlineData(5, 3200)] // 200 * 2^4 = 3200 + [InlineData(10, 5000)] // Would be 200 * 2^9 = 102400, capped to 5000 + [InlineData(20, 5000)] // Extremely high retry, still capped to 5000 + [InlineData(25, 5000)] // Shift clamped to 20-bit max, still capped to 5000 + public void ExponentialBackoff_Delay_IsCappedAtMaxRetryDelayMs(int retryCount, int expectedDelayMs) + { + const int baseRetryDelayMs = 200; + const int maxRetryDelayMs = 5000; + + var shift = Math.Min(retryCount - 1, 20); + var delayMs = Math.Min( + baseRetryDelayMs * (1 << shift), + maxRetryDelayMs); + + Assert.Equal(expectedDelayMs, delayMs); + } } } diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index ce15f24..8e80022 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -14,7 +14,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - +