diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml
index 8dd34cb..b749361 100644
--- a/.github/workflows/commit-lint.yml
+++ b/.github/workflows/commit-lint.yml
@@ -1,5 +1,8 @@
name: Commit Lint
-
+permissions:
+ contents: read
+ pull-requests: write
+
on:
pull_request:
branches:
diff --git a/README.md b/README.md
index f986971..190eb6b 100644
--- a/README.md
+++ b/README.md
@@ -5,12 +5,33 @@

[](https://github.com/engineering87/WART/issues)
[](https://github.com/engineering87/WART)
+[](https://github.com/sponsors/engineering87)
-WART is a C# .NET library that enables you to extend any Web API controller and forward incoming calls directly to a SignalR hub. This hub then broadcasts notifications containing detailed information about the calls, including both the request and the response. Additionally, WART supports JWT authentication for secure communication with SignalR.
-
-## Features
+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**.
+Supports **JWT** and **Cookie Authentication** for secure communication.
+
+## 📑 Table of Contents
+- [Features](#-features)
+- [Installation](#-installation)
+- [How It Works](#️-how-it-works)
+- [Usage](#-usage)
+ - [Basic Setup](#basic-setup)
+ - [Using JWT Authentication](#using-jwt-authentication)
+ - [Custom Hub Names](#custom-hub-names)
+ - [Multiple Hubs](#multiple-hubs)
+ - [Client Example](#client-example)
+- [Supported Authentication Modes](#-supported-authentication-modes)
+- [Excluding APIs from Event Propagation](#-excluding-apis-from-event-propagation)
+- [Group-based Event Dispatching](#-group-based-event-dispatching)
+- [NuGet](#-nuget)
+- [Contributing](#-contributing)
+- [License](#-license)
+- [Contact](#-contact)
+
+## ✨ Features
- Converts REST API calls into SignalR events, enabling real-time communication.
- Provides controllers (`WartController`, `WartControllerJwt`, `WartControllerCookie`) for automatic SignalR event broadcasting.
- Supports JWT authentication for SignalR hub connections.
@@ -18,19 +39,23 @@ WART is a C# .NET library that enables you to extend any Web API controller and
- Enables group-specific event dispatching with `[GroupWart("group_name")]`.
- Configurable middleware (`AddWartMiddleware`) for flexible integration.
-## Installation
-You can install the library via the NuGet package manager with the following command:
+## 📦 Installation
+Install from **NuGet**
```bash
dotnet add package WART-Core
```
-### How it works
-WART implements a custom controller which overrides the `OnActionExecuting` and `OnActionExecuted` methods to retrieve the request and the response and encapsulates them in a **WartEvent** object which will be sent via SignalR on the **WartHub**.
-
-### How to use it
+### ⚙️ How it works
+WART overrides `OnActionExecuting` and `OnActionExecuted` in a custom base controller.
+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.
-To use the WART library, each WebApi controller must extend the **WartController** controller:
+## 🚀 Usage
+### Basic Setup
+Extend your API controllers from `WartController`:
```csharp
using WART_Core.Controllers;
@@ -39,104 +64,99 @@ using WART_Core.Hubs;
[ApiController]
[Route("api/[controller]")]
public class TestController : WartController
-```
-
-each controller must implement the following constructor, for example:
-
-```csharp
-public TestController(IHubContext messageHubContext,
-ILogger logger) : base(messageHubContext, logger)
{
+ public TestController(IHubContext hubContext, ILogger logger)
+ : base(hubContext, logger) { }
}
```
-WART support JWT bearer authentication on SignalR hub, if you want to use JWT authentication use the following controller extension:
-
-```csharp
-using WART_Core.Controllers;
-using WART_Core.Hubs;
-
-[ApiController]
-[Route("api/[controller]")]
-public class TestController : WartControllerJwt
-```
-
-You also need to enable SignalR in the WebAPI solution and map the **WartHub**.
-To do this, add the following configurations in the Startup.cs class:
+Register WART in `Startup.cs`:
```csharp
using WART_Core.Middleware;
-```
-In the ConfigureServices section add following:
+public void ConfigureServices(IServiceCollection services)
+{
+ services.AddWartMiddleware(); // No authentication
+}
-```csharp
-services.AddWartMiddleware();
+public void Configure(IApplicationBuilder app)
+{
+ app.UseWartMiddleware();
+}
```
-or by specifying JWT authentication:
-
+### Using JWT Authentication
```csharp
-services.AddWartMiddleware(hubType:HubType.JwtAuthentication, tokenKey:"password_here");
+services.AddWartMiddleware(hubType: HubType.JwtAuthentication, tokenKey: "your_secret_key");
+app.UseWartMiddleware(HubType.JwtAuthentication);
```
-In the Configure section add the following:
+Extend from `WartControllerJwt`:
```csharp
-app.UseWartMiddleware();
+public class TestController : WartControllerJwt
+{
+ public TestController(IHubContext hubContext, ILogger logger)
+ : base(hubContext, logger) { }
+}
```
-or by specifying JWT authentication:
+### Custom Hub Names
+You can specify custom hub routes:
```csharp
-app.UseWartMiddleware(HubType.JwtAuthentication);
+app.UseWartMiddleware("customhub");
```
-Alternatively, it is possible to specify a custom hub name:
+### Multiple Hubs
+You can configure multiple hubs at once by passing a list of hub names:
```csharp
-app.UseWartMiddleware("hubname");
+var hubs = new[] { "orders", "products", "notifications" };
+
+app.UseWartMiddleware(hubs);
```
-at this point it will be sufficient to connect via SignalR to the WartHub to receive notifications in real time of any call on the controller endpoints.
-For example:
+This is useful for separating traffic by domain.
+
+### Client Example
+#### Without authentication:
```csharp
var hubConnection = new HubConnectionBuilder()
- .WithUrl("http://localhost:52086/warthub")
+ .WithUrl("http://localhost:5000/warthub")
.Build();
-
-hubConnection.On("Send", (data) =>
+
+hubConnection.On("Send", data =>
{
- // data is the WartEvent JSON
+ // 'data' is a WartEvent JSON
});
+
+await hubConnection.StartAsync();
```
-or with JWT authentication:
+#### With JWT authentication:
```csharp
var hubConnection = new HubConnectionBuilder()
- .WithUrl($"http://localhost:51392/warthub", options =>
+ .WithUrl("http://localhost:5000/warthub", options =>
{
- options.SkipNegotiation = true;
- options.Transports = HttpTransportType.WebSockets;
options.AccessTokenProvider = () => Task.FromResult(GenerateToken());
})
.WithAutomaticReconnect()
.Build();
-
-hubConnection.On("Send", (data) =>
+
+hubConnection.On("Send", data =>
{
- // data is the WartEvent JSON
+ // Handle WartEvent JSON
});
-```
-
-In the source code you can find a simple test client and WebApi project.
-## Supported Authentication Modes
+await hubConnection.StartAsync();
+```
-The project supports three authentication modes for accessing the SignalR Hub:
+## 🔐 Supported Authentication Modes
| Mode | Description | Hub Class | Required Middleware |
|--------------------------|---------------------------------------------------------------------------|----------------------|---------------------------|
@@ -146,7 +166,7 @@ The project supports three authentication modes for accessing the SignalR Hub:
> ⚙️ Authentication mode is selected through the `HubType` configuration in the application startup.
-### Excluding APIs from Event Propagation
+### 🚫 Excluding APIs from Event Propagation
There might be scenarios where you want to exclude specific APIs from propagating events to connected clients. This can be particularly useful when certain endpoints should not trigger updates, notifications, or other real-time messages through SignalR. To achieve this, you can use a custom filter called `ExcludeWartAttribute`. By decorating the desired API endpoints with this attribute, you can prevent them from being included in the SignalR event propagation logic, for example:
```csharp
@@ -163,7 +183,7 @@ public ActionResult Get(int id)
}
```
-### SignalR Event Dispatching for Specific Groups
+### 👥 Group-based Event Dispatching
WART enables sending API events to specific groups in SignalR by specifying the group name in the query string. This approach allows for flexible and targeted event broadcasting, ensuring that only the intended group of clients receives the event.
By decorating an API method with `[GroupWart("group_name")]`, it is possible to specify the SignalR group name to which the dispatch of specific events for that API is restricted. This ensures that only the clients subscribed to the specified group ("SampleGroupName") will receive the related events, allowing for targeted, group-based communication in a SignalR environment.
@@ -179,24 +199,20 @@ public ActionResult Post([FromBody] TestEntity entity)
By appending `?WartGroup=group_name` to the URL, the library enables dispatching events from individual APIs to a specific SignalR group, identified by `group_name`. This allows for granular control over which clients receive the event, leveraging SignalR’s built-in group functionality.
-### NuGet
-
-The library is available on NuGet packetmanager.
-
-https://www.nuget.org/packages/WART-Core/
-
-### Contributing
-Thank you for considering to help out with the source code!
-If you'd like to contribute, please fork, fix, commit and send a pull request for the maintainers to review and merge into the main code base.
+### 📦 NuGet
+The library is available on [NuGet](https://www.nuget.org/packages/WART-Core/).
-**Getting started with Git and GitHub**
+### 🤝 Contributing
+Contributions are welcome!
+Steps to get started:
* [Setting up Git](https://docs.github.com/en/get-started/getting-started-with-git/set-up-git)
* [Fork the repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo)
* [Open an issue](https://github.com/engineering87/WART/issues) if you encounter a bug or have a suggestion for improvements/features
+ * Submit a Pull Request.
-### Licensee
+### 📄 License
WART source code is available under MIT License, see license in the source.
-### Contact
+### 📬 Contact
Please contact at francesco.delre[at]protonmail.com for any details.
diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj
index b211044..2c84c39 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/Authentication/Cookie/CookieServiceCollectionExtension.cs b/src/WART-Core/Authentication/Cookie/CookieServiceCollectionExtension.cs
index df778ca..a1e7b85 100644
--- a/src/WART-Core/Authentication/Cookie/CookieServiceCollectionExtension.cs
+++ b/src/WART-Core/Authentication/Cookie/CookieServiceCollectionExtension.cs
@@ -1,14 +1,17 @@
// (c) 2025 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
-using System;
-using System.IO;
-using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
using WART_Core.Hubs;
using WART_Core.Services;
@@ -58,10 +61,10 @@ public static IServiceCollection AddCookieMiddleware(
});
// Register WART event queue service
- services.AddSingleton();
+ services.TryAddSingleton();
// Register the WART event worker for the cookie-authenticated hub
- services.AddHostedService>();
+ services.TryAddEnumerable(ServiceDescriptor.Singleton>());
// SignalR configuration
services.AddSignalR(options =>
@@ -75,9 +78,13 @@ public static IServiceCollection AddCookieMiddleware(
// Compression for SignalR WebSocket/Binary transport
services.AddResponseCompression(opts =>
{
- opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
- new[] { "application/octet-stream" });
+ opts.EnableForHttps = true;
+ opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]);
+ opts.Providers.Add();
+ opts.Providers.Add();
});
+ services.Configure(o => o.Level = CompressionLevel.Fastest);
+ services.Configure(o => o.Level = CompressionLevel.Fastest);
return services;
}
diff --git a/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs b/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs
index 2686146..f1526e1 100755
--- a/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs
+++ b/src/WART-Core/Authentication/JWT/JwtServiceCollectionExtension.cs
@@ -1,16 +1,19 @@
// (c) 2021 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
-using System;
-using System.Text;
-using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
+using System;
+using System.IO.Compression;
using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
using WART_Core.Hubs;
using WART_Core.Services;
@@ -31,7 +34,7 @@ public static IServiceCollection AddJwtMiddleware(this IServiceCollection servic
// Validate that the token key is provided
if (string.IsNullOrEmpty(tokenKey))
{
- throw new ArgumentNullException("Invalid token key");
+ throw new ArgumentException("Invalid token key");
}
// Configure forwarded headers (to support proxy scenarios, e.g., when behind a load balancer)
@@ -65,6 +68,7 @@ public static IServiceCollection AddJwtMiddleware(this IServiceCollection servic
ValidateIssuer = false,
ValidateActor = false,
ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
IssuerSigningKey = securityKey
};
options.Events = new JwtBearerEvents
@@ -82,10 +86,10 @@ public static IServiceCollection AddJwtMiddleware(this IServiceCollection servic
});
// Register WART event queue as a singleton service.
- services.AddSingleton();
+ services.TryAddSingleton();
// Register the WART event worker as a hosted service.
- services.AddHostedService>();
+ services.TryAddEnumerable(ServiceDescriptor.Singleton>());
// Configure SignalR options, including error handling and timeouts
services.AddSignalR(options =>
@@ -99,9 +103,13 @@ public static IServiceCollection AddJwtMiddleware(this IServiceCollection servic
// Configure response compression to support additional MIME types
services.AddResponseCompression(opts =>
{
- opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
- new[] { "application/octet-stream" });
+ opts.EnableForHttps = true;
+ opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]);
+ opts.Providers.Add();
+ opts.Providers.Add();
});
+ services.Configure(o => o.Level = CompressionLevel.Fastest);
+ services.Configure(o => o.Level = CompressionLevel.Fastest);
return services;
}
diff --git a/src/WART-Core/Controllers/WartBaseController.cs b/src/WART-Core/Controllers/WartBaseController.cs
index 26439f9..1f2160f 100644
--- a/src/WART-Core/Controllers/WartBaseController.cs
+++ b/src/WART-Core/Controllers/WartBaseController.cs
@@ -1,13 +1,15 @@
// (c) 2024 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
-using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
using System.Linq;
using WART_Core.Entity;
using WART_Core.Filters;
-using Microsoft.Extensions.DependencyInjection;
using WART_Core.Services;
namespace WART_Core.Controllers
@@ -16,9 +18,10 @@ public abstract class WartBaseController : Controller where THub : Hub
{
private readonly ILogger _logger;
private readonly IHubContext _hubContext;
- private const string RouteDataKey = "REQUEST";
- private WartEventQueueService _eventQueue;
+ // Strongly-typed, collision-free key for HttpContext.Items
+ private sealed class RequestSnapshotKey { }
+ private static readonly object ItemsRequestKey = new RequestSnapshotKey();
protected WartBaseController(IHubContext hubContext, ILogger logger)
{
@@ -27,12 +30,19 @@ protected WartBaseController(IHubContext hubContext, ILogger logger)
}
///
- /// Adds the request objects to RouteData.
+ /// Stores a snapshot of action arguments in
+ /// under a collision-free typed key.
///
- /// The action executing context.
public override void OnActionExecuting(ActionExecutingContext context)
{
- context?.RouteData.Values.Add(RouteDataKey, context.ActionArguments);
+ if (context is not null)
+ {
+ var snapshot = new ReadOnlyDictionary(
+ new Dictionary(context.ActionArguments)
+ );
+ context.HttpContext.Items[ItemsRequestKey] = snapshot;
+ }
+
base.OnActionExecuting(context);
}
@@ -44,22 +54,27 @@ public override void OnActionExecuted(ActionExecutedContext context)
{
if (context?.Result is ObjectResult objectResult)
{
- var exclusion = context.Filters.Any(f => f.GetType().Name == nameof(ExcludeWartAttribute));
- if (!exclusion && context.RouteData.Values.TryGetValue(RouteDataKey, out var request))
+ // Opt-out if ExcludeWartAttribute is present
+ var excluded = context.Filters.OfType().Any();
+
+ if (!excluded && context.HttpContext.Items.TryGetValue(ItemsRequestKey, out var snapshotObj))
{
- var httpMethod = context.HttpContext?.Request.Method;
- var httpPath = context.HttpContext?.Request.Path;
- var remoteAddress = context.HttpContext?.Connection.RemoteIpAddress?.ToString();
- var response = objectResult.Value;
+ var http = context.HttpContext;
- var wartEvent = new WartEvent(request, response, httpMethod, httpPath, remoteAddress);
+ var wartEvent = new WartEvent(
+ request: snapshotObj,
+ response: objectResult.Value,
+ httpMethod: http?.Request.Method,
+ httpPath: http?.Request.Path,
+ remoteAddress: http?.Connection.RemoteIpAddress?.ToString()
+ );
- _eventQueue = context.HttpContext?.RequestServices.GetService();
- _eventQueue?.Enqueue(new WartEventWithFilters(wartEvent, [.. context.Filters]));
+ var queue = context.HttpContext?.RequestServices.GetService();
+ queue?.Enqueue(new WartEventWithFilters(wartEvent, [.. context.Filters]));
}
}
base.OnActionExecuted(context);
}
}
-}
+}
\ No newline at end of file
diff --git a/src/WART-Core/Entity/WartEvent.cs b/src/WART-Core/Entity/WartEvent.cs
index 5f83199..7170051 100755
--- a/src/WART-Core/Entity/WartEvent.cs
+++ b/src/WART-Core/Entity/WartEvent.cs
@@ -16,17 +16,55 @@ namespace WART_Core.Entity
[Serializable]
public class WartEvent
{
+ ///
+ /// Unique identifier for this event instance.
+ ///
public Guid EventId { get; set; }
+
+ ///
+ /// Local timestamp when the event was created. Intended to mirror
+ /// converted to the local time zone.
+ ///
public DateTime TimeStamp { get; set; }
+
+ ///
+ /// UTC timestamp when the event was created. Preferred for storage and comparisons.
+ ///
public DateTime UtcTimeStamp { get; set; }
- public string HttpMethod { get; set; }
- public string HttpPath { get; set; }
- public string RemoteAddress { get; set; }
+
+ ///
+ /// HTTP verb used by the request (e.g., GET, POST, PUT, DELETE).
+ ///
+ public string HttpMethod { get; set; } = string.Empty;
+
+ ///
+ /// HTTP path or route template of the request (e.g., "/api/orders/42").
+ ///
+ public string HttpPath { get; set; } = string.Empty;
+
+ ///
+ /// Remote client address (e.g., IP or "host:port") that originated the request.
+ ///
+ public string RemoteAddress { get; set; } = string.Empty;
+
+ ///
+ /// Request payload serialized as JSON text. May contain either a JSON object or array.
+ /// Left as raw string to preserve the original body.
+ ///
[JsonConverter(typeof(JsonArrayOrObjectStringConverter))]
- public string JsonRequestPayload { get; set; }
+ public string JsonRequestPayload { get; set; } = string.Empty;
+
+ ///
+ /// Response payload serialized as JSON text. May contain either a JSON object or array.
+ /// Left as raw string to preserve the original body.
+ ///
[JsonConverter(typeof(JsonArrayOrObjectStringConverter))]
- public string JsonResponsePayload { get; set; }
- public string ExtraInfo { get; set; }
+ public string JsonResponsePayload { get; set; } = string.Empty;
+
+ ///
+ /// Optional, free-form additional information (tags, notes, etc.).
+ ///
+ public string ExtraInfo { get; set; } = string.Empty;
///
/// Private constructor used for JSON deserialization.
@@ -40,14 +78,20 @@ private WartEvent() { }
/// The HTTP method (e.g., GET, POST).
/// The path of the HTTP request.
/// The remote address (IP) from which the request originated.
+ ///
+ /// Initializes a new instance with method, path and remote address.
+ ///
public WartEvent(string httpMethod, string httpPath, string remoteAddress)
{
- this.EventId = Guid.NewGuid();
- this.TimeStamp = DateTime.Now;
- this.UtcTimeStamp = DateTime.UtcNow;
- this.HttpMethod = httpMethod;
- this.HttpPath = httpPath;
- this.RemoteAddress = remoteAddress;
+ EventId = Guid.NewGuid();
+
+ var utcNow = DateTime.UtcNow;
+ UtcTimeStamp = utcNow;
+ TimeStamp = utcNow.ToLocalTime();
+
+ HttpMethod = httpMethod ?? string.Empty;
+ HttpPath = httpPath ?? string.Empty;
+ RemoteAddress = remoteAddress ?? string.Empty;
}
///
@@ -61,14 +105,18 @@ public WartEvent(string httpMethod, string httpPath, string remoteAddress)
/// The remote address (IP) from which the request originated.
public WartEvent(object request, object response, string httpMethod, string httpPath, string remoteAddress)
{
- this.EventId = Guid.NewGuid();
- this.TimeStamp = DateTime.Now;
- this.UtcTimeStamp = DateTime.UtcNow;
- this.HttpMethod = httpMethod;
- this.HttpPath = httpPath;
- this.RemoteAddress = remoteAddress;
- this.JsonRequestPayload = SerializationHelper.Serialize(request);
- this.JsonResponsePayload = SerializationHelper.Serialize(response);
+ EventId = Guid.NewGuid();
+
+ var utcNow = DateTime.UtcNow;
+ UtcTimeStamp = utcNow;
+ TimeStamp = utcNow.ToLocalTime();
+
+ HttpMethod = httpMethod ?? string.Empty;
+ HttpPath = httpPath ?? string.Empty;
+ RemoteAddress = remoteAddress ?? string.Empty;
+
+ JsonRequestPayload = SerializationHelper.Serialize(request);
+ JsonResponsePayload = SerializationHelper.Serialize(response);
}
///
diff --git a/src/WART-Core/Filters/ExcludeWartAttribute.cs b/src/WART-Core/Filters/ExcludeWartAttribute.cs
index 6c2052b..d0cfbdb 100644
--- a/src/WART-Core/Filters/ExcludeWartAttribute.cs
+++ b/src/WART-Core/Filters/ExcludeWartAttribute.cs
@@ -1,6 +1,7 @@
// (c) 2019 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
using Microsoft.AspNetCore.Mvc.Filters;
+using System;
namespace WART_Core.Filters
{
@@ -9,7 +10,8 @@ namespace WART_Core.Filters
/// When applied to an action, this filter ensures that no SignalR events are triggered or broadcasted
/// as a result of the execution of the action.
///
- public class ExcludeWartAttribute : ActionFilterAttribute
+ [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
+ public sealed class ExcludeWartAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
diff --git a/src/WART-Core/Filters/GroupWartAttribute.cs b/src/WART-Core/Filters/GroupWartAttribute.cs
index 01358ef..52288b9 100644
--- a/src/WART-Core/Filters/GroupWartAttribute.cs
+++ b/src/WART-Core/Filters/GroupWartAttribute.cs
@@ -1,7 +1,10 @@
// (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;
+using System.Collections.ObjectModel;
+using System.Linq;
namespace WART_Core.Filters
{
@@ -10,18 +13,30 @@ namespace WART_Core.Filters
/// This attribute allows specifying a list of group names, which can be used to target SignalR events
/// to one or more SignalR groups during the execution of an action.
///
- public class GroupWartAttribute : ActionFilterAttribute
+ [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
+ public sealed class GroupWartAttribute : ActionFilterAttribute
{
public IReadOnlyList GroupNames { get; }
// Constructor accepting a list of group names
public GroupWartAttribute(params string[] groupNames)
{
- GroupNames = new List(groupNames);
+ var cleaned = (groupNames ?? [])
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .Select(s => s.Trim())
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ GroupNames = new ReadOnlyCollection(cleaned);
}
public override void OnActionExecuting(ActionExecutingContext context)
{
+ if (GroupNames.Count > 0)
+ {
+ context.HttpContext.Items["WartGroups"] = GroupNames;
+ }
+
base.OnActionExecuting(context);
}
diff --git a/src/WART-Core/Helpers/SerializationHelper.cs b/src/WART-Core/Helpers/SerializationHelper.cs
index 6c2c157..c235564 100644
--- a/src/WART-Core/Helpers/SerializationHelper.cs
+++ b/src/WART-Core/Helpers/SerializationHelper.cs
@@ -49,7 +49,7 @@ public static T Deserialize(string jsonString)
}
catch (Exception)
{
- return default(T);
+ return default;
}
}
}
diff --git a/src/WART-Core/Hubs/WartHub.cs b/src/WART-Core/Hubs/WartHub.cs
index 4e785ae..f069ca6 100755
--- a/src/WART-Core/Hubs/WartHub.cs
+++ b/src/WART-Core/Hubs/WartHub.cs
@@ -1,7 +1,7 @@
// (c) 2019 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
-using Microsoft.Extensions.Logging;
-
+using Microsoft.Extensions.Logging;
+
namespace WART_Core.Hubs
{
///
diff --git a/src/WART-Core/Hubs/WartHubBase.cs b/src/WART-Core/Hubs/WartHubBase.cs
index a1a76b3..c8c384f 100644
--- a/src/WART-Core/Hubs/WartHubBase.cs
+++ b/src/WART-Core/Hubs/WartHubBase.cs
@@ -1,10 +1,12 @@
// (c) 2024 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
+using System.Linq;
using System.Threading.Tasks;
-using Microsoft.AspNetCore.SignalR;
-using Microsoft.Extensions.Logging;
+using WART_Core.Utilities;
namespace WART_Core.Hubs
{
@@ -15,9 +17,10 @@ namespace WART_Core.Hubs
public abstract class WartHubBase : Hub
{
///
- /// Stores active connections with their respective identifiers.
+ /// Stores active connections for each hub, with their respective identifiers.
///
- private static readonly ConcurrentDictionary _connections = new ConcurrentDictionary();
+ private static readonly ConcurrentDictionary> _connectionsByHub
+ = new();
///
/// Logger instance for logging hub activities.
@@ -41,17 +44,19 @@ protected WartHubBase(ILogger logger)
public override async Task OnConnectedAsync()
{
var httpContext = Context.GetHttpContext();
- var wartGroup = httpContext.Request.Query["WartGroup"].ToString();
- var userName = Context.User?.Identity?.Name ?? "Anonymous";
+ var wartGroup = httpContext?.Request?.Query["WartGroup"].ToString();
+ var userName = Context.User?.Identity?.Name ?? Context.UserIdentifier ?? "Anonymous";
- _connections.TryAdd(Context.ConnectionId, userName);
+ var bucket = _connectionsByHub.GetOrAdd(GetType(), _ => new ConcurrentDictionary());
+ bucket[Context.ConnectionId] = userName;
if (!string.IsNullOrEmpty(wartGroup))
{
await AddToGroup(wartGroup);
}
- _logger?.LogInformation($"OnConnect: ConnectionId={Context.ConnectionId}, User={userName}");
+ _logger?.LogInformation("OnConnected: ConnectionId={ConnectionId}, User={User}",
+ Context.ConnectionId, LogSanitizer.Sanitize(userName));
await base.OnConnectedAsync();
}
@@ -64,15 +69,19 @@ public override async Task OnConnectedAsync()
/// A task that represents the asynchronous operation.
public override Task OnDisconnectedAsync(Exception exception)
{
- _connections.TryRemove(Context.ConnectionId, out _);
+ if (_connectionsByHub.TryGetValue(GetType(), out var dict))
+ {
+ dict.TryRemove(Context.ConnectionId, out _);
+ if (dict.IsEmpty) _connectionsByHub.TryRemove(GetType(), out _);
+ }
if (exception != null)
{
- _logger?.LogWarning(exception, $"OnDisconnect with error: ConnectionId={Context.ConnectionId}");
+ _logger?.LogWarning(exception, "OnDisconnect with error: ConnectionId={ConnectionId}", Context.ConnectionId);
}
else
{
- _logger?.LogInformation($"OnDisconnect: ConnectionId={Context.ConnectionId}");
+ _logger?.LogInformation("OnDisconnect: ConnectionId={ConnectionId}", Context.ConnectionId);
}
return base.OnDisconnectedAsync(exception);
@@ -83,7 +92,7 @@ public override Task OnDisconnectedAsync(Exception exception)
///
/// The name of the SignalR group to add the connection to.
/// A task that represents the asynchronous operation.
- public async Task AddToGroup(string groupName)
+ protected async Task AddToGroup(string groupName)
{
if (string.IsNullOrWhiteSpace(groupName))
{
@@ -93,7 +102,8 @@ public async Task AddToGroup(string groupName)
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
- _logger?.LogInformation($"Connection {Context.ConnectionId} added to group {groupName}");
+ _logger?.LogInformation("Connection {ConnectionId} added to group {GroupName}",
+ Context.ConnectionId, LogSanitizer.Sanitize(groupName));
}
///
@@ -101,7 +111,7 @@ public async Task AddToGroup(string groupName)
///
/// The name of the SignalR group to remove the connection from.
/// A task that represents the asynchronous operation.
- public async Task RemoveFromGroup(string groupName)
+ protected async Task RemoveFromGroup(string groupName)
{
if (string.IsNullOrWhiteSpace(groupName))
{
@@ -111,21 +121,20 @@ public async Task RemoveFromGroup(string groupName)
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
- _logger?.LogInformation($"Connection {Context.ConnectionId} removed from group {groupName}");
+ _logger?.LogInformation("Connection {ConnectionId} removed from group {GroupName}",
+ Context.ConnectionId, LogSanitizer.Sanitize(groupName));
}
- ///
- /// Gets the current number of active connections.
- ///
- /// The count of active connections.
public static int GetConnectionsCount()
- {
- return _connections.Count;
- }
+ => _connectionsByHub.Values.Sum(d => d.Count);
- ///
- /// Returns a value indicating whether there are connected clients.
- ///
- public static bool HasConnectedClients => !_connections.IsEmpty;
+ public static bool HasConnectedClients
+ => _connectionsByHub.Values.Any(d => !d.IsEmpty);
+
+ public static int GetConnectionsCountFor() where THub : Hub
+ => _connectionsByHub.TryGetValue(typeof(THub), out var d) ? d.Count : 0;
+
+ public static bool HasConnectedClientsFor() where THub : Hub
+ => GetConnectionsCountFor() > 0;
}
}
\ No newline at end of file
diff --git a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs
index d5fa39b..a570715 100755
--- a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs
+++ b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs
@@ -15,6 +15,16 @@ public static class WartApplicationBuilderExtension
{
private const string DefaultHubName = "warthub";
+ private static string NormalizeHubPath(string name)
+ => "/" + (name ?? string.Empty).Trim().Trim('/');
+
+ private static IReadOnlyList GetDistinctPaths(IEnumerable hubNameList)
+ => (hubNameList ?? [])
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .Select(NormalizeHubPath)
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+
///
/// Configures and adds the WART middleware to the IApplicationBuilder.
/// This method sets up the default SignalR hub (warthub) without authentication.
@@ -23,16 +33,16 @@ public static class WartApplicationBuilderExtension
/// The updated IApplicationBuilder to continue configuration.
public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app)
{
+ app.UseForwardedHeaders();
+ app.UseResponseCompression();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
- endpoints.MapHub($"/{DefaultHubName}");
+ endpoints.MapHub(NormalizeHubPath(DefaultHubName));
});
- app.UseForwardedHeaders();
-
return app;
}
@@ -46,6 +56,8 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
/// The updated IApplicationBuilder to continue configuration.
public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, HubType hubType)
{
+ app.UseForwardedHeaders();
+ app.UseResponseCompression();
app.UseRouting();
switch(hubType)
@@ -56,7 +68,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
- endpoints.MapHub($"/{DefaultHubName}");
+ endpoints.MapHub(NormalizeHubPath(DefaultHubName));
});
break;
}
@@ -66,7 +78,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
- endpoints.MapHub($"/{DefaultHubName}");
+ endpoints.MapHub(NormalizeHubPath(DefaultHubName));
});
break;
}
@@ -76,14 +88,12 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
- endpoints.MapHub($"/{DefaultHubName}");
+ endpoints.MapHub(NormalizeHubPath(DefaultHubName));
});
break;
}
}
- app.UseForwardedHeaders();
-
return app;
}
@@ -94,22 +104,22 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
/// The IApplicationBuilder to configure the middleware pipeline.
/// The custom SignalR hub name (URL path).
/// The updated IApplicationBuilder to continue configuration.
- /// Thrown when the hub name is null or empty.
+ /// Thrown when the hub name is null or empty.
public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, string hubName)
{
if (string.IsNullOrEmpty(hubName))
- throw new ArgumentNullException("Invalid hub name");
+ throw new ArgumentException("Invalid hub name");
+ app.UseForwardedHeaders();
+ app.UseResponseCompression();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
- endpoints.MapHub($"/{hubName.Trim()}");
+ endpoints.MapHub(NormalizeHubPath(hubName));
});
- app.UseForwardedHeaders();
-
return app;
}
@@ -121,23 +131,27 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
/// The IApplicationBuilder to configure the middleware pipeline.
/// The list of custom SignalR hub names (URL paths).
/// The updated IApplicationBuilder to continue configuration.
- /// Thrown when the hub name list is null.
+ /// Thrown when the hub name list is null.
public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, IEnumerable hubNameList)
{
ArgumentNullException.ThrowIfNull(hubNameList);
+ app.UseForwardedHeaders();
+ app.UseResponseCompression();
app.UseRouting();
- foreach (var hubName in hubNameList.Distinct())
- {
- app.UseEndpoints(endpoints =>
- {
- endpoints.MapControllers();
- endpoints.MapHub($"/{hubName.Trim()}");
- });
- }
+ var unique = hubNameList
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .Select(NormalizeHubPath)
+ .Distinct()
+ .ToList();
- app.UseForwardedHeaders();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ foreach (var path in unique)
+ endpoints.MapHub(path);
+ });
return app;
}
@@ -150,12 +164,14 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
/// The custom SignalR hub name (URL path).
/// The type of SignalR hub to configure, determining if authentication is required.
/// The updated IApplicationBuilder to continue configuration.
- /// Thrown when the hub name is null or empty.
+ /// Thrown when the hub name is null or empty.
public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, string hubName, HubType hubType)
{
if (string.IsNullOrEmpty(hubName))
- throw new ArgumentNullException("Invalid hub name");
+ throw new ArgumentException("Invalid hub name");
+ app.UseForwardedHeaders();
+ app.UseResponseCompression();
app.UseRouting();
switch (hubType)
@@ -166,7 +182,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
- endpoints.MapHub($"/{hubName.Trim()}");
+ endpoints.MapHub(NormalizeHubPath(hubName));
});
break;
}
@@ -176,7 +192,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
- endpoints.MapHub($"/{hubName.Trim()}");
+ endpoints.MapHub(NormalizeHubPath(hubName));
});
break;
}
@@ -186,14 +202,12 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
- endpoints.MapHub($"/{hubName.Trim()}");
+ endpoints.MapHub(NormalizeHubPath(hubName));
});
break;
}
}
- app.UseForwardedHeaders();
-
return app;
}
@@ -207,51 +221,49 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
/// The list of custom SignalR hub names (URL paths).
/// The type of SignalR hub to configure, determining if authentication is required.
/// The updated IApplicationBuilder to continue configuration.
- /// Thrown when the hub name list is null.
+ /// Thrown when the hub name list is null.
public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, IEnumerable hubNameList, HubType hubType)
{
ArgumentNullException.ThrowIfNull(hubNameList);
+ var paths = GetDistinctPaths(hubNameList);
+
+ app.UseForwardedHeaders();
+ app.UseResponseCompression();
app.UseRouting();
- foreach (var hubName in hubNameList.Distinct())
+ switch (hubType)
{
- switch (hubType)
- {
- default:
- case HubType.NoAuthentication:
- {
- app.UseEndpoints(endpoints =>
- {
- endpoints.MapControllers();
- endpoints.MapHub($"/{hubName.Trim()}");
- });
- break;
- }
- case HubType.JwtAuthentication:
- {
- app.UseJwtMiddleware();
- app.UseEndpoints(endpoints =>
- {
- endpoints.MapControllers();
- endpoints.MapHub($"/{hubName.Trim()}");
- });
- break;
- }
- case HubType.CookieAuthentication:
- {
- app.UseCookieMiddleware();
- app.UseEndpoints(endpoints =>
- {
- endpoints.MapControllers();
- endpoints.MapHub($"/{hubName.Trim()}");
- });
- break;
- }
- }
- }
+ default:
+ case HubType.NoAuthentication:
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ foreach (var path in paths)
+ endpoints.MapHub(path);
+ });
+ break;
- app.UseForwardedHeaders();
+ case HubType.JwtAuthentication:
+ app.UseJwtMiddleware();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ foreach (var path in paths)
+ endpoints.MapHub(path);
+ });
+ break;
+
+ case HubType.CookieAuthentication:
+ app.UseCookieMiddleware();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ foreach (var path in paths)
+ endpoints.MapHub(path);
+ });
+ break;
+ }
return app;
}
diff --git a/src/WART-Core/Middleware/WartServiceCollectionExtension.cs b/src/WART-Core/Middleware/WartServiceCollectionExtension.cs
index 4886c4b..1dfd14f 100755
--- a/src/WART-Core/Middleware/WartServiceCollectionExtension.cs
+++ b/src/WART-Core/Middleware/WartServiceCollectionExtension.cs
@@ -4,8 +4,11 @@
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
+using System.IO.Compression;
using System.Linq;
using WART_Core.Authentication.Cookie;
using WART_Core.Authentication.JWT;
@@ -36,10 +39,10 @@ public static IServiceCollection AddWartMiddleware(this IServiceCollection servi
services.AddLogging(configure => configure.AddConsole());
// Register WART event queue as a singleton service.
- services.AddSingleton();
+ services.TryAddSingleton();
// Register the WART event worker as a hosted service.
- services.AddHostedService>();
+ services.TryAddEnumerable(ServiceDescriptor.Singleton>());
// Configure SignalR with custom options.
services.AddSignalR(options =>
@@ -53,9 +56,13 @@ public static IServiceCollection AddWartMiddleware(this IServiceCollection servi
// Configure response compression for specific MIME types.
services.AddResponseCompression(opts =>
{
- opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
- new[] { "application/octet-stream" });
+ opts.EnableForHttps = true;
+ opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]);
+ opts.Providers.Add();
+ opts.Providers.Add();
});
+ services.Configure(o => o.Level = CompressionLevel.Fastest);
+ services.Configure(o => o.Level = CompressionLevel.Fastest);
return services;
}
diff --git a/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs b/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs
index c2c8092..a729a12 100644
--- a/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs
+++ b/src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs
@@ -6,45 +6,50 @@
namespace WART_Core.Serialization
{
+ ///
+ /// A custom JSON converter that allows a string property to accept JSON objects or arrays
+ /// as raw JSON text. This is useful when the schema is flexible, and a value could be a
+ /// primitive string or an entire JSON structure (array or object).
+ ///
public class JsonArrayOrObjectStringConverter : JsonConverter
{
+ ///
+ /// Reads JSON tokens and returns them as a raw JSON string.
+ /// If the token is a string, returns its value.
+ /// If it's an object or array, serializes it to a JSON string.
+ ///
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
- return reader.GetString();
+ return reader.TokenType switch
+ {
+ JsonTokenType.String => reader.GetString(),
+ JsonTokenType.StartObject or JsonTokenType.StartArray => JsonDocument.ParseValue(ref reader).RootElement.GetRawText(),
+ JsonTokenType.Null => null,
+ _ => reader.GetString()
+ };
}
+ ///
+ /// Writes a string to the JSON output.
+ /// If the string contains valid JSON (object or array), it writes it as raw JSON.
+ /// Otherwise, it writes it as a simple string value.
+ ///
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
{
- if (string.IsNullOrEmpty(value))
+ if (string.IsNullOrWhiteSpace(value))
{
writer.WriteStringValue(value);
return;
}
- using (JsonDocument doc = JsonDocument.Parse(value))
+ try
+ {
+ using var doc = JsonDocument.Parse(value);
+ doc.RootElement.WriteTo(writer);
+ }
+ catch
{
- if (doc.RootElement.ValueKind == JsonValueKind.Array)
- {
- writer.WriteStartArray();
- foreach (var element in doc.RootElement.EnumerateArray())
- {
- element.WriteTo(writer);
- }
- writer.WriteEndArray();
- }
- else if (doc.RootElement.ValueKind == JsonValueKind.Object)
- {
- writer.WriteStartObject();
- foreach (var property in doc.RootElement.EnumerateObject())
- {
- property.WriteTo(writer);
- }
- writer.WriteEndObject();
- }
- else
- {
- writer.WriteStringValue(value);
- }
+ writer.WriteStringValue(value);
}
}
}
diff --git a/src/WART-Core/Services/WartEventQueueService.cs b/src/WART-Core/Services/WartEventQueueService.cs
index a13bbcb..4d71ce4 100644
--- a/src/WART-Core/Services/WartEventQueueService.cs
+++ b/src/WART-Core/Services/WartEventQueueService.cs
@@ -12,7 +12,7 @@ namespace WART_Core.Services
public class WartEventQueueService
{
// A thread-safe queue to hold WartEvent objects along with their associated filters.
- private readonly ConcurrentQueue _queue = new ConcurrentQueue();
+ private readonly ConcurrentQueue _queue = new();
///
/// Enqueues a WartEventWithFilters object to the queue.
@@ -20,8 +20,11 @@ public class WartEventQueueService
/// The WartEventWithFilters object to enqueue.
public void Enqueue(WartEventWithFilters wartEventWithFilters)
{
- // Adds the event with filters to the concurrent queue.
- _queue.Enqueue(wartEventWithFilters);
+ if (wartEventWithFilters != null)
+ {
+ // Adds the event with filters to the concurrent queue.
+ _queue.Enqueue(wartEventWithFilters);
+ }
}
///
@@ -29,15 +32,21 @@ public void Enqueue(WartEventWithFilters wartEventWithFilters)
///
/// The dequeued WartEventWithFilters object.
/// True if an event was dequeued; otherwise, false.
- public bool TryDequeue(out WartEventWithFilters wartEventWithFilters)
- {
- // Attempts to remove and return the event with filters from the queue.
- return _queue.TryDequeue(out wartEventWithFilters);
- }
+ public bool TryDequeue(out WartEventWithFilters item) => _queue.TryDequeue(out item);
+
+ ///
+ /// Attempts to peek at the next item without removing it.
+ ///
+ public bool TryPeek(out WartEventWithFilters item) => _queue.TryPeek(out item);
///
/// Gets the current count of events in the queue.
///
public int Count => _queue.Count;
+
+ ///
+ /// Check if the queue is empty
+ ///
+ public bool IsEmpty => _queue.IsEmpty;
}
}
\ No newline at end of file
diff --git a/src/WART-Core/Services/WartEventWorker.cs b/src/WART-Core/Services/WartEventWorker.cs
index 5004965..c565157 100644
--- a/src/WART-Core/Services/WartEventWorker.cs
+++ b/src/WART-Core/Services/WartEventWorker.cs
@@ -24,6 +24,9 @@ public class WartEventWorker : BackgroundService where THub : Hub
private readonly IHubContext _hubContext;
private readonly ILogger> _logger;
+ private const int NoClientsDelayMs = 500;
+ private const int IdleDelayMs = 200;
+
///
/// Constructor that initializes the worker with the event queue, hub context, and logger.
///
@@ -45,9 +48,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
while (!stoppingToken.IsCancellationRequested)
{
// Check if there are any connected clients.
- if (!WartHubBase.HasConnectedClients)
- {
- await Task.Delay(500, stoppingToken);
+ if (!WartHubBase.HasConnectedClientsFor())
+ {
+ await Task.Delay(NoClientsDelayMs, stoppingToken);
continue;
}
@@ -58,10 +61,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Extract the event and filters.
var wartEvent = wartEventWithFilters.WartEvent;
- var filters = wartEventWithFilters.Filters;
+ var filters = wartEventWithFilters.Filters ?? [];
// Send the event to the SignalR hub.
- await SendToHub(wartEvent, filters);
+ await SendToHub(wartEvent, filters, stoppingToken);
_logger.LogInformation("Event sent: {Event}", wartEvent);
}
@@ -77,7 +80,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
// Wait for 200 ms before checking for new events in the queue.
- await Task.Delay(200, stoppingToken);
+ await Task.Delay(IdleDelayMs, stoppingToken);
}
_logger.LogInformation("WartEventWorker stopped.");
@@ -87,8 +90,13 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
/// Sends the current event to the SignalR hub.
/// This method determines if the event should be sent to specific groups or all clients.
///
- private async Task SendToHub(WartEvent wartEvent, List filters)
+ private async Task SendToHub(WartEvent wartEvent, List filters, CancellationToken cancellationToken)
{
+ if (filters?.OfType().Any() == true)
+ {
+ return;
+ }
+
try
{
// Retrieve the target groups based on the filters.
@@ -97,13 +105,13 @@ private async Task SendToHub(WartEvent wartEvent, List filters)
// If specific groups are defined, send the event to each group in parallel.
if (groups.Count != 0)
{
- var tasks = groups.Select(group => SendEventToGroup(wartEvent, group));
+ var tasks = groups.Select(group => SendEventToGroup(wartEvent, group, cancellationToken));
await Task.WhenAll(tasks);
}
else
{
// If no groups are defined, send the event to all clients.
- await SendEventToAllClients(wartEvent);
+ await SendEventToAllClients(wartEvent, cancellationToken);
}
}
catch (Exception ex)
@@ -120,48 +128,44 @@ private async Task SendToHub(WartEvent wartEvent, List filters)
///
/// The list of filters that may contain group-related information.
/// A list of group names to send the WartEvent to.
- private List GetTargetGroups(List filters)
+ private static List GetTargetGroups(List filters)
{
- var groups = new List();
-
- // Check if there is a GroupWartAttribute filter indicating the groups.
- if (filters.Any(f => f.GetType().Name == nameof(GroupWartAttribute)))
- {
- var wartGroup = filters.FirstOrDefault(f => f.GetType() == typeof(GroupWartAttribute)) as GroupWartAttribute;
- if (wartGroup != null)
- {
- groups.AddRange(wartGroup.GroupNames);
- }
- }
-
- return groups;
+ var attr = filters?.OfType().FirstOrDefault();
+ return attr?.GroupNames?
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .Select(s => s.Trim())
+ .Distinct(StringComparer.Ordinal)
+ .ToList()
+ ?? [];
}
///
/// Sends the WartEvent to a specific group of clients.
///
- private async Task SendEventToGroup(WartEvent wartEvent, string group)
+ private async Task SendEventToGroup(WartEvent wartEvent, string group, CancellationToken cancellationToken)
{
// Send the event to the group using SignalR.
- await _hubContext?.Clients
+ await _hubContext.Clients
.Group(group)
- .SendAsync("Send", wartEvent.ToString());
+ .SendAsync("Send", wartEvent.ToString(), cancellationToken);
// Log the event sent to the group.
- _logger?.LogInformation($"Group: {group}, WartEvent: {wartEvent}");
+ _logger?.LogInformation("Group: {group}, WartEvent: {wartEvent}",
+ group, wartEvent.ToString());
}
///
/// Sends the WartEvent to all connected clients.
///
- private async Task SendEventToAllClients(WartEvent wartEvent)
+ private async Task SendEventToAllClients(WartEvent wartEvent, CancellationToken cancellationToken)
{
// Send the event to all clients using SignalR.
- await _hubContext?.Clients.All
- .SendAsync("Send", wartEvent.ToString());
+ await _hubContext.Clients.All
+ .SendAsync("Send", wartEvent.ToString(), cancellationToken);
// Log the event sent to all clients.
- _logger?.LogInformation("Event: {EventName}, Details: {EventDetails}", nameof(WartEvent), wartEvent.ToString());
+ _logger?.LogInformation("Event: {EventName}, Details: {EventDetails}",
+ nameof(WartEvent), wartEvent.ToString());
}
}
}
\ No newline at end of file
diff --git a/src/WART-Core/Utilities/LogSanitizer.cs b/src/WART-Core/Utilities/LogSanitizer.cs
new file mode 100644
index 0000000..95c93c2
--- /dev/null
+++ b/src/WART-Core/Utilities/LogSanitizer.cs
@@ -0,0 +1,23 @@
+// (c) 2024 Francesco Del Re
+// This code is licensed under MIT license (see LICENSE.txt for details)
+using System.Linq;
+
+namespace WART_Core.Utilities
+{
+ public static class LogSanitizer
+ {
+ ///
+ /// Sanitizes a string to make it safe for logging by removing control characters.
+ ///
+ /// The input string to sanitize.
+ /// The sanitized string with control characters removed.
+ public static string Sanitize(string input)
+ {
+ if (string.IsNullOrEmpty(input)) return input;
+
+ return new string(input
+ .Where(c => !char.IsControl(c))
+ .ToArray());
+ }
+ }
+}
diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj
index 4c3cbf6..17026ae 100644
--- a/src/WART-Core/WART-Core.csproj
+++ b/src/WART-Core/WART-Core.csproj
@@ -14,7 +14,7 @@
4.0.0.0
4.0.0.0
- 5.4.0
+ 6.0.0
icon.png
README.md
@@ -22,8 +22,8 @@
-
-
+
+
diff --git a/src/WART-Tests/Controllers/WartBaseControllerTests.cs b/src/WART-Tests/Controllers/WartBaseControllerTests.cs
new file mode 100644
index 0000000..b5f73b7
--- /dev/null
+++ b/src/WART-Tests/Controllers/WartBaseControllerTests.cs
@@ -0,0 +1,92 @@
+// (c) 2025 Francesco Del Re
+// This code is licensed under MIT license (see LICENSE.txt for details)
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Moq;
+using WART_Core.Controllers;
+using WART_Core.Filters;
+using WART_Core.Services;
+
+namespace WART_Tests.Controllers
+{
+ public class WartBaseControllerTests
+ {
+ public class TestHub : Hub { }
+
+ private class SutController : WartBaseController
+ {
+ public SutController(IHubContext hubContext, ILogger logger) : base(hubContext, logger) { }
+ public IActionResult OkResult() => new OkObjectResult(new { ok = true });
+ }
+
+ private (SutController ctrl, ActionExecutingContext aex, ActionExecutedContext aed, WartEventQueueService queue) Arrange(ObjectResult result, IList filters)
+ {
+ var services = new ServiceCollection();
+ var queue = new WartEventQueueService();
+ services.AddSingleton(queue);
+ var sp = services.BuildServiceProvider();
+
+ var http = new DefaultHttpContext { RequestServices = sp };
+ http.Request.Method = "GET";
+ http.Request.Path = "/api/test";
+
+ var actionCtx = new ActionContext(http, new Microsoft.AspNetCore.Routing.RouteData(),
+ new ControllerActionDescriptor(), modelState: new Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary());
+
+ var aex = new ActionExecutingContext(actionCtx, new List(), new Dictionary { { "id", 1 } }, controller: null);
+ var aed = new ActionExecutedContext(actionCtx, filters, controller: null) { Result = result };
+
+ var hubCtx = new Mock>().Object;
+ var logger = new Mock().Object;
+ var ctrl = new SutController(hubCtx, logger);
+
+ return (ctrl, aex, aed, queue);
+ }
+
+ [Fact]
+ public void Enqueues_On_ObjectResult_Without_Exclude()
+ {
+ var filters = new List();
+ var (ctrl, aex, aed, queue) = Arrange(new OkObjectResult(new { x = 1 }), filters);
+
+ ctrl.OnActionExecuting(aex);
+ ctrl.OnActionExecuted(aed);
+
+ Assert.Equal(1, queue.Count);
+ Assert.True(queue.TryPeek(out var item));
+ Assert.NotNull(item.WartEvent);
+ Assert.Equal("GET", item.WartEvent.HttpMethod);
+ Assert.Equal("/api/test", item.WartEvent.HttpPath);
+ }
+
+ [Fact]
+ public void DoesNotEnqueue_When_Excluded()
+ {
+ var filters = new List { new ExcludeWartAttribute() };
+ var (ctrl, aex, aed, queue) = Arrange(new OkObjectResult(new { x = 1 }), filters);
+
+ ctrl.OnActionExecuting(aex);
+ ctrl.OnActionExecuted(aed);
+
+ Assert.True(queue.IsEmpty);
+ }
+
+ [Fact]
+ public void Preserves_Filters_For_Worker_GroupUsage()
+ {
+ var filters = new List { new GroupWartAttribute("g1", "g2") };
+ var (ctrl, aex, aed, queue) = Arrange(new OkObjectResult(new { x = 1 }), filters);
+
+ ctrl.OnActionExecuting(aex);
+ ctrl.OnActionExecuted(aed);
+
+ Assert.True(queue.TryPeek(out var item));
+ Assert.Contains(item.Filters, f => f is GroupWartAttribute);
+ }
+ }
+}
diff --git a/src/WART-Tests/Entity/WartEventTests.cs b/src/WART-Tests/Entity/WartEventTests.cs
index 4fba799..3d2c951 100644
--- a/src/WART-Tests/Entity/WartEventTests.cs
+++ b/src/WART-Tests/Entity/WartEventTests.cs
@@ -1,6 +1,8 @@
// (c) 2024 Francesco Del Re
// This code is licensed under MIT license (see LICENSE.txt for details)
-namespace WART_Core.Entity.Tests
+using WART_Core.Entity;
+
+namespace WART_Tests.Entity
{
public class WartEventTests
{
@@ -17,14 +19,14 @@ public void WartEvent_ConstructorWithParameters_ShouldSetProperties()
// Assert
Assert.NotEqual(Guid.Empty, wartEvent.EventId);
- Assert.NotEqual(default(DateTime), wartEvent.TimeStamp);
- Assert.NotEqual(default(DateTime), wartEvent.UtcTimeStamp);
+ Assert.NotEqual(default, wartEvent.TimeStamp);
+ Assert.NotEqual(default, wartEvent.UtcTimeStamp);
Assert.Equal(httpMethod, wartEvent.HttpMethod);
Assert.Equal(httpPath, wartEvent.HttpPath);
Assert.Equal(remoteAddress, wartEvent.RemoteAddress);
- Assert.Null(wartEvent.JsonRequestPayload);
- Assert.Null(wartEvent.JsonResponsePayload);
- Assert.Null(wartEvent.ExtraInfo);
+ Assert.True(string.IsNullOrEmpty(wartEvent.JsonRequestPayload));
+ Assert.True(string.IsNullOrEmpty(wartEvent.JsonResponsePayload));
+ Assert.True(string.IsNullOrEmpty(wartEvent.ExtraInfo));
}
[Fact]
@@ -42,14 +44,14 @@ public void WartEvent_ConstructorWithRequestAndResponse_ShouldSetProperties()
// Assert
Assert.NotEqual(Guid.Empty, wartEvent.EventId);
- Assert.NotEqual(default(DateTime), wartEvent.TimeStamp);
- Assert.NotEqual(default(DateTime), wartEvent.UtcTimeStamp);
+ Assert.NotEqual(default, wartEvent.TimeStamp);
+ Assert.NotEqual(default, wartEvent.UtcTimeStamp);
Assert.Equal(httpMethod, wartEvent.HttpMethod);
Assert.Equal(httpPath, wartEvent.HttpPath);
Assert.Equal(remoteAddress, wartEvent.RemoteAddress);
- Assert.NotNull(wartEvent.JsonRequestPayload);
- Assert.NotNull(wartEvent.JsonResponsePayload);
- Assert.Null(wartEvent.ExtraInfo);
+ Assert.False(string.IsNullOrEmpty(wartEvent.JsonRequestPayload));
+ Assert.False(string.IsNullOrEmpty(wartEvent.JsonResponsePayload));
+ Assert.True(string.IsNullOrEmpty(wartEvent.ExtraInfo));
}
[Fact]
diff --git a/src/WART-Tests/Serialization/JsonArrayOrObjectStringConverterTests.cs b/src/WART-Tests/Serialization/JsonArrayOrObjectStringConverterTests.cs
new file mode 100644
index 0000000..5530bac
--- /dev/null
+++ b/src/WART-Tests/Serialization/JsonArrayOrObjectStringConverterTests.cs
@@ -0,0 +1,66 @@
+// (c) 2025 Francesco Del Re
+// This code is licensed under MIT license (see LICENSE.txt for details)
+using System.Text.Json;
+using WART_Core.Entity;
+using WART_Core.Serialization;
+
+namespace WART_Tests.Serialization
+{
+ public class JsonArrayOrObjectStringConverterTests
+ {
+ private readonly JsonSerializerOptions _opts = new()
+ {
+ Converters = { new JsonArrayOrObjectStringConverter() }
+ };
+
+ private class Wrapper { public string Payload { get; set; } }
+
+ [Fact]
+ public void Write_String_RemainsString()
+ {
+ var obj = new Wrapper { Payload = "hello" };
+ var json = JsonSerializer.Serialize(obj, _opts);
+ Assert.Contains("\"Payload\":\"hello\"", json);
+ }
+
+ [Fact]
+ public void Write_ObjectString_WritesAsObject()
+ {
+ var obj = new Wrapper { Payload = "{\"a\":1}" };
+ var json = JsonSerializer.Serialize(obj, _opts);
+ Assert.Contains("\"Payload\":{\"a\":1}", json);
+ }
+
+ [Fact]
+ public void Write_ArrayString_WritesAsArray()
+ {
+ var obj = new Wrapper { Payload = "[1,2,3]" };
+ var json = JsonSerializer.Serialize(obj, _opts);
+ Assert.Contains("\"Payload\":[1,2,3]", json);
+ }
+
+ [Fact]
+ public void Write_InvalidJson_FallsBackToString()
+ {
+ var obj = new Wrapper { Payload = "{not json}" };
+ var json = JsonSerializer.Serialize(obj, _opts);
+ Assert.Contains("\"Payload\":\"{not json}\"", json);
+ }
+
+ [Fact]
+ public void Read_ObjectToken_ReturnsRawJson()
+ {
+ var json = "{\"Payload\": {\"x\":42}}";
+ var wrapper = JsonSerializer.Deserialize(json, _opts);
+ Assert.Equal("{\"x\":42}", wrapper.Payload);
+ }
+
+ [Fact]
+ public void WartEvent_ToString_IsValidJson()
+ {
+ var e = new WartEvent("GET", "/api/test", "127.0.0.1") { ExtraInfo = "ok" };
+ var json = e.ToString();
+ using var _ = JsonDocument.Parse(json);
+ }
+ }
+}
diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj
index c9673cb..2042972 100644
--- a/src/WART-Tests/WART-Tests.csproj
+++ b/src/WART-Tests/WART-Tests.csproj
@@ -15,11 +15,11 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/src/WART-WebApiRealTime/Startup.cs b/src/WART-WebApiRealTime/Startup.cs
index 8a84660..43fd915 100755
--- a/src/WART-WebApiRealTime/Startup.cs
+++ b/src/WART-WebApiRealTime/Startup.cs
@@ -33,10 +33,10 @@ public void ConfigureServices(IServiceCollection services)
//services.AddWartMiddleware();
// with JWT authentication
- //services.AddWartMiddleware(hubType: HubType.JwtAuthentication, tokenKey: "dn3341fmcscscwe28419brhwbwgbss4t");
+ services.AddWartMiddleware(hubType: HubType.JwtAuthentication, tokenKey: "dn3341fmcscscwe28419brhwbwgbss4t");
// with Cookie authentication
- services.AddWartMiddleware(hubType: HubType.CookieAuthentication);
+ //services.AddWartMiddleware(hubType: HubType.CookieAuthentication);
// Register the Swagger generator, defining 1 or more Swagger documents
services.AddSwaggerGen(c =>
@@ -75,10 +75,10 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
//app.UseWartMiddleware();
// with JWT authentication
- //app.UseWartMiddleware(HubType.JwtAuthentication);
+ app.UseWartMiddleware(HubType.JwtAuthentication);
// with Cookie authentication
- app.UseWartMiddleware(HubType.CookieAuthentication);
+ //app.UseWartMiddleware(HubType.CookieAuthentication);
// multiple hub with authentication
//var hubNameList = new List
diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj
index 792f9b1..0e8df5a 100755
--- a/src/WART-WebApiRealTime/WART-Api.csproj
+++ b/src/WART-WebApiRealTime/WART-Api.csproj
@@ -8,7 +8,7 @@
-
+