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