Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
23ecaf8
chore(deps): update dependencies
engineering87 Sep 17, 2025
56bd679
chore(deps): update dependencies to latest versions
engineering87 Oct 4, 2025
ae055c5
chore(deps): update dependencies to latest versions
engineering87 Oct 19, 2025
8731d85
chore: upgrade project to .NET 10 and update dependencies
engineering87 Nov 16, 2025
b360a53
fix(jwt): prevent null reference in LifetimeValidator for tokens with…
engineering87 Nov 16, 2025
4a5919e
chore: minor code cleanup
engineering87 Nov 17, 2025
9031041
chore(deps): update project dependencies
engineering87 Dec 4, 2025
bab8775
chore(deps): update project dependencies
engineering87 Dec 10, 2025
8ff7e55
chore(deps): update project dependencies
engineering87 Dec 21, 2025
ef55b81
chore(deps): update dependencies
engineering87 Jan 14, 2026
5565963
test(middleware): register response compression deps in WebApplicatio…
engineering87 Jan 15, 2026
4e7573c
chore(deps): update dependencies
engineering87 Feb 4, 2026
73a3add
chore(deps): update dependencies
engineering87 Feb 7, 2026
5f02c11
fix: harden event processing and edge case handling
engineering87 Feb 28, 2026
7a295eb
chore(deps): update project dependencies
engineering87 Mar 25, 2026
5ad0ee1
chore(deps): update project dependencies
engineering87 Mar 31, 2026
ce231e9
chore(deps): update project dependencies
engineering87 Apr 13, 2026
b708446
chore(deps): update project dependencies
engineering87 Apr 18, 2026
e1c86b0
feat: add Minimal API support via endpoint filter
engineering87 Apr 19, 2026
1512fae
fix: harden WartEventQueueService and improve error handling
engineering87 Apr 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 60 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@

<img src="https://github.com/engineering87/WART/blob/develop/wart_logo.jpg" width="300">

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
Expand All @@ -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)
Expand All @@ -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")]`.
Expand All @@ -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.
Expand Down Expand Up @@ -156,6 +161,57 @@ hubConnection.On<string>("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 |
Expand Down
10 changes: 5 additions & 5 deletions src/WART-Client/WART-Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>WART_Client</RootNamespace>
<StartupObject>WART_Client.Program</StartupObject>
<IsPackable>false</IsPackable>
Expand All @@ -21,10 +21,10 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.8" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.17.0" />
</ItemGroup>

</Project>
13 changes: 10 additions & 3 deletions src/WART-Client/WartTestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,23 @@ public static async Task ConnectAsync(string wartHubUrl)
hubConnection.On<string>("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);
});

hubConnection.Closed += async (exception) =>
{
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<Exception>("ConnectionFailed", (exception) =>
Expand Down
4 changes: 2 additions & 2 deletions src/WART-Client/WartTestClientCookie.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
{
Expand Down Expand Up @@ -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();
};
Expand Down
2 changes: 1 addition & 1 deletion src/WART-Client/WartTestClientJwt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};

Expand Down
2 changes: 1 addition & 1 deletion src/WART-Client/appsettings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"Scheme": "https",
"Host": "localhost",
"Port": "54644",
"Port": "62198",
"Hubname": "warthub",
"AuthenticationType": "JWT",
"Key": "dn3341fmcscscwe28419brhwbwgbss4t",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion src/WART-Core/Entity/WartEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
[Serializable]
public class WartEvent
{
/// <summary>
Expand Down
8 changes: 8 additions & 0 deletions src/WART-Core/Entity/WartEventWithFilters.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// (c) 2024 Francesco Del Re <francesco.delre.87@gmail.com>
// 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
Expand All @@ -20,13 +21,20 @@ public class WartEventWithFilters
/// </summary>
public List<IFilterMetadata> Filters { get; set; }

/// <summary>
/// The number of times this event has been retried.
/// </summary>
public int RetryCount { get; set; }

/// <summary>
/// Initializes a new instance of the WartEventWithFilters class.
/// </summary>
/// <param name="wartEvent">The WartEvent to associate with the filters.</param>
/// <param name="filters">The list of filters applied to the event.</param>
public WartEventWithFilters(WartEvent wartEvent, List<IFilterMetadata> filters)
{
ArgumentNullException.ThrowIfNull(wartEvent);

// Initialize the WartEvent and Filters properties
WartEvent = wartEvent;
Filters = filters;
Expand Down
71 changes: 71 additions & 0 deletions src/WART-Core/Filters/WartEndpointFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// (c) 2024-2026 Francesco Del Re <francesco.delre.87@gmail.com>
// 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
{
/// <summary>
/// An <see cref="IEndpointFilter"/> that captures Minimal API request/response data
/// and enqueues a <see cref="WartEvent"/> for SignalR broadcast.
/// Respects <see cref="ExcludeWartAttribute"/> and <see cref="GroupWartAttribute"/>
/// when applied as endpoint metadata.
/// </summary>
public sealed class WartEndpointFilter : IEndpointFilter
{
public async ValueTask<object> 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<ExcludeWartAttribute>() is not null)
{
return await next(context);
}

// Capture request arguments as a dictionary.
var requestArgs = new Dictionary<string, object>();
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<IFilterMetadata>()
.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;
}
}
}
2 changes: 1 addition & 1 deletion src/WART-Core/Helpers/SerializationHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion src/WART-Core/Hubs/WartHubBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions src/WART-Core/Middleware/WartApplicationBuilderExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
/// <exception cref="ArgumentException">Thrown when the hub name is null or empty.</exception>
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();
Expand Down Expand Up @@ -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 =>
Expand All @@ -167,7 +167,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
/// <exception cref="ArgumentException">Thrown when the hub name is null or empty.</exception>
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();
Expand Down
Loading
Loading