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-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index 2c84c39..43da9fe 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,10 +21,10 @@ - - - - + + + + diff --git a/src/WART-Client/WartTestClient.cs b/src/WART-Client/WartTestClient.cs index 5bfb666..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(new Random().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-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/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, 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/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/Filters/WartEndpointFilter.cs b/src/WART-Core/Filters/WartEndpointFilter.cs new file mode 100644 index 0000000..1e2e426 --- /dev/null +++ b/src/WART-Core/Filters/WartEndpointFilter.cs @@ -0,0 +1,71 @@ +// (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; + 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/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/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/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/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-Core/Serialization/JsonArrayOrObjectStringConverter.cs b/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs index a729a12..e1e4bb8 100644 --- a/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs +++ b/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs @@ -20,13 +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, - _ => reader.GetString() - }; + 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 c565157..32d1d1b 100644 --- a/src/WART-Core/Services/WartEventWorker.cs +++ b/src/WART-Core/Services/WartEventWorker.cs @@ -25,7 +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. @@ -42,7 +44,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) @@ -54,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)) { @@ -68,22 +73,39 @@ 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 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 + { + _logger.LogWarning("Event {EventId} dropped after {MaxRetries} retries.", + wartEventWithFilters.WartEvent?.EventId, MaxRetryCount); + } } } - - // Wait for 200 ms before checking for new events in the queue. - await Task.Delay(IdleDelayMs, stoppingToken); } - _logger.LogInformation("WartEventWorker stopped."); + _logger.LogDebug("WartEventWorker stopped."); } /// @@ -117,7 +139,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 +186,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()); } } diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index 17026ae..e48acaf 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 @@ -12,18 +12,15 @@ https://github.com/engineering87/WART LICENSE.txt - 4.0.0.0 - 4.0.0.0 - 6.0.0 + 7.0.0 icon.png README.md - - - + + 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-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/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(); }); 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..dc18bf1 --- /dev/null +++ b/src/WART-Tests/Services/WartEventQueueServiceTests.cs @@ -0,0 +1,174 @@ +// (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_ThrowsArgumentNullException() + { + var queue = new WartEventQueueService(); + + Assert.Throws(() => queue.Enqueue(null!)); + } + + [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); + } + + [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 new file mode 100644 index 0000000..6f0dde7 --- /dev/null +++ b/src/WART-Tests/Services/WartEventWorkerTests.cs @@ -0,0 +1,152 @@ +// (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); + } + + [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/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é ñ 日本語")); + } + } +} diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index 2042972..8e80022 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -1,28 +1,27 @@ - net9.0 + net10.0 WART_Tests enable enable - false true - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - - + all runtime; build; native; contentfiles; analyzers; buildtransitive + 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 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 0e8df5a..49aaaee 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 - +