Skip to content

Commit c851815

Browse files
committed
Refactor WebSocket routing and error handling logic
Replaces `Dictionary` with `ConcurrentDictionary` for thread-safe WebSocket route management and improves error logging with added debug assertions. Also fixes duplicate registrations, enhances dependency injection, updates package references, and adjusts WebSocket attribute structure for better extensibility and usage.
1 parent 59ddda6 commit c851815

10 files changed

Lines changed: 131 additions & 61 deletions

File tree

Examples/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ public static void ConfigureServices(IServiceCollection services)
4646
services.AddScoped<IConfiguration>(_ => new ConfigurationBuilder()
4747
.AddJsonFile("appsettings.json", true)
4848
.Build());
49+
50+
4951
}
5052

5153
public static void Configure(IApplicationBuilder app)

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@
186186
same "printed page" as the copyright notice for easier
187187
identification within third-party archives.
188188

189-
Copyright [2025] [Dmitrii Shimanskii]
189+
Copyright [2025] [Dmitri Shimanski]
190190

191191
Licensed under the Apache License, Version 2.0 (the "License");
192192
you may not use this file except in compliance with the License.

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,26 @@ services.AddSingleton(new WebSocketConfig()
129129
})
130130
```
131131

132+
## Work with c=connected users from any point at your code!
133+
```csharp
134+
public class MyCoolService
135+
{
136+
private IWebSocketManager _manager;
137+
public MyCoolService(IWebSocketManager manager)
138+
{
139+
_manager = manager;
140+
}
141+
142+
public async Task DoSomething()
143+
{
144+
await _manager.Broadcast(k => k.Path == "/my/cool/endpoint", "Hello!");
145+
}
146+
}
147+
148+
// DependencyInjection should provide IWebSocketManager to builder
149+
services.AddSingleton<MyCoolService>();
150+
```
151+
132152

133153
## Lifecycle Management
134154
1. **Connection** - Automatically handled by middleware

yawaflua.WebSockets.Tests/Core/WebSocketManagerTest.cs

Lines changed: 38 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
using yawaflua.WebSockets.Core;
22
using System;
3+
using System.Diagnostics;
34
using System.Net.WebSockets;
45
using System.Threading;
56
using System.Threading.Tasks;
67
using JetBrains.Annotations;
78
using Microsoft.AspNetCore.Http;
9+
using Microsoft.Extensions.Configuration;
810
using Microsoft.Extensions.DependencyInjection;
911
using Microsoft.Extensions.Logging;
1012
using Moq;
@@ -25,40 +27,60 @@ public class WebSocketRouterTests
2527
private readonly Mock<IServiceProvider> _serviceProviderMock = new();
2628
private readonly Mock<ILogger<WebSocketRouter>> _loggerMock = new();
2729
private IServiceCollection _services;
30+
private static WebSocketRouter _router;
2831

2932
public WebSocketRouterTests()
3033
{
3134
_services = new ServiceCollection();
35+
_services.AddSingleton(_ => new ConfigurationBuilder().Build() as IConfiguration);
3236
_serviceProviderMock.Setup(k => k.GetService(typeof(IServiceScopeFactory)))
3337
.Returns(_services.BuildServiceProvider().CreateScope());
38+
39+
_services.AddTransient<TestHandler>();
40+
_services.AddSingleton(_ => _loggerMock.Object);
41+
_services.SettingUpWebSockets(new WebSocketConfig());
42+
_router ??= _services.BuildServiceProvider().GetService<WebSocketRouter>();
3443
}
3544
[yawaflua.WebSockets.Attributes.WebSocket("/test")]
3645
public class TestHandler : WebSocketController
3746
{
47+
[CanBeNull] internal static IConfiguration Configuration { get; set; }
48+
public TestHandler(IConfiguration configuration)
49+
{
50+
Configuration ??= configuration;
51+
Debug.WriteLine("Hi!");
52+
}
3853
[yawaflua.WebSockets.Attributes.WebSocket("/static")]
3954
public static Task StaticHandler(IWebSocket ws, HttpContext context) => Task.CompletedTask;
4055

4156
[yawaflua.WebSockets.Attributes.WebSocket("/instance")]
4257
public Task InstanceHandler(IWebSocket ws, HttpContext context) => Task.CompletedTask;
4358
}
4459

60+
[Fact]
61+
public void DiscoverHandlers_ShouldRegisterStaticVars()
62+
{
63+
// Assert
64+
Assert.NotNull(TestHandler.Configuration);
65+
}
66+
67+
[Fact]
68+
public void DiscoverHandlers_ShouldRegisterWebSocketManager()
69+
{
70+
// Assert
71+
Assert.NotNull(_services.BuildServiceProvider().GetService<IWebSocketManager>());
72+
}
73+
4574
[Fact]
4675
public void DiscoverHandlers_ShouldRegisterStaticAndInstanceMethods()
4776
{
48-
// Arrange
49-
_services.AddTransient<TestHandler>();
50-
_serviceProviderMock.Setup(x => x.GetService(typeof(TestHandler)))
51-
.Returns(new TestHandler());
52-
// Act
53-
var router = new WebSocketRouter(_serviceProviderMock.Object, _loggerMock.Object);
54-
5577
// Assert
5678
Assert.True(WebSocketRouter.Routes.ContainsKey("/test/static"));
5779
Assert.True(WebSocketRouter.Routes.ContainsKey("/test/instance"));
5880
}
5981

6082
[Fact]
61-
public async Task HandleRequest_ShouldAcceptWebSocketAndAddClient()
83+
public async Task HandleRequest_ShouldAcceptWebSocketAndRemoveClientOnClose()
6284
{
6385
// Arrange
6486
var webSocketMock = new Mock<System.Net.WebSockets.WebSocket>();
@@ -69,18 +91,17 @@ public async Task HandleRequest_ShouldAcceptWebSocketAndAddClient()
6991
.ReturnsAsync(webSocketMock.Object);
7092
webSocketManagerMock.Setup(m => m.IsWebSocketRequest)
7193
.Returns(true);
94+
contextMock.SetupGet(c => c.Connection.RemoteIpAddress).Returns(new System.Net.IPAddress(new byte[] { 127, 0, 0, 1 }));
7295
contextMock.SetupGet(c => c.WebSockets).Returns(webSocketManagerMock.Object);
7396
contextMock.SetupGet(c => c.Request.Path).Returns(new PathString("/test/static"));
7497
contextMock.Setup(c => c.RequestServices)
75-
.Returns(_serviceProviderMock.Object);
76-
77-
var router = new WebSocketRouter(_services.BuildServiceProvider(), _loggerMock.Object);
98+
.Returns(_services.BuildServiceProvider());
7899

79100
// Act
80-
await router.HandleRequest(contextMock.Object);
101+
await _router.HandleRequest(contextMock.Object);
81102

82103
// Assert
83-
Assert.Single(WebSocketRouter.Clients);
104+
Assert.Empty(WebSocketRouter.Clients); // Because clients should be clean after exit
84105
}
85106

86107
[Fact]
@@ -92,40 +113,18 @@ public async Task HandleRequest_ShouldReturn404ForUnknownPath()
92113
var webSocketManagerMock = new Mock<WebSocketManager>();
93114

94115
webSocketManagerMock.Setup(m => m.IsWebSocketRequest).Returns(true);
116+
contextMock.SetupGet(c => c.Connection.RemoteIpAddress).Returns(new System.Net.IPAddress(new byte[] { 127, 0, 0, 1 }));
95117
contextMock.SetupGet(c => c.WebSockets).Returns(webSocketManagerMock.Object);
96118
contextMock.SetupGet(c => c.Request.Path).Returns(new PathString("/unknown"));
97119
contextMock.SetupGet(c => c.Response).Returns(responseMock.Object);
98120

99-
var router = new WebSocketRouter(_services.BuildServiceProvider(), _loggerMock.Object);
100-
101121
// Act
102-
await router.HandleRequest(contextMock.Object);
122+
await _router.HandleRequest(contextMock.Object);
103123

104124
// Assert
105125
responseMock.VerifySet(r => r.StatusCode = 404);
106126
}
107-
108-
[Fact]
109-
public void DiscoverHandlers_ShouldLogErrorOnInvalidHandler()
110-
{
111-
// Arrange
112-
var invalidHandlerType = typeof(InvalidHandler);
113-
_serviceProviderMock.Setup(x => x.GetService(invalidHandlerType))
114-
.Throws(new InvalidOperationException());
115-
116-
// Act
117-
var router = new WebSocketRouter(_serviceProviderMock.Object, _loggerMock.Object);
118-
119-
// Assert
120-
_loggerMock.Verify(
121-
x => x.Log(
122-
LogLevel.Critical,
123-
It.IsAny<EventId>(),
124-
It.IsAny<It.IsAnyType>(),
125-
It.IsAny<Exception>(),
126-
(Func<It.IsAnyType, Exception, string>)It.IsAny<object>()),
127-
Times.AtLeastOnce);
128-
}
127+
129128

130129
[WebSocket("/invalid")]
131130
public class InvalidHandler : WebSocketController
@@ -151,10 +150,8 @@ public async Task Client_ShouldBeRemovedOnConnectionClose()
151150
contextMock.SetupGet(c => c.Request.Path).Returns(new PathString("/test/static"));
152151
contextMock.Setup(c => c.RequestServices).Returns(_serviceProviderMock.Object);
153152

154-
var router = new WebSocketRouter(_serviceProviderMock.Object, _loggerMock.Object);
155-
156153
// Act
157-
await router.HandleRequest(contextMock.Object);
154+
await _router.HandleRequest(contextMock.Object);
158155
await Task.Delay(100); // Allow background task to complete
159156

160157
// Assert

yawaflua.WebSockets.Tests/yawaflua.WebSockets.Tests.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
</PropertyGroup>
88

99
<ItemGroup>
10+
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />
11+
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.5" />
1012
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
1113
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
1214
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />

yawaflua.WebSockets/Attributes/WebSocketAttribute.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ namespace yawaflua.WebSockets.Attributes;
2121
/// </remarks>
2222
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
2323
[ApiExplorerSettings(IgnoreApi = true)]
24-
public class WebSocketAttribute : RouteAttribute, IRouteTemplateProvider, IApiDescriptionVisibilityProvider
24+
public class WebSocketAttribute : Attribute, IApiDescriptionVisibilityProvider
2525
{
2626
/// <summary>
2727
/// Original route template specified in attribute
@@ -39,7 +39,7 @@ public class WebSocketAttribute : RouteAttribute, IRouteTemplateProvider, IApiDe
3939
/// - Parameters: "/user/{id}"
4040
/// - Constraints: "/file/{name:alpha}"
4141
/// - Optional: "/feed/{category?}"</param>
42-
public WebSocketAttribute([RouteTemplate]string path) : base(path)
42+
public WebSocketAttribute(string path)
4343
{
4444
Template = path;
4545
Name = path;

yawaflua.WebSockets/Core/WebSocketRouter.cs

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
using System.Diagnostics.CodeAnalysis;
1+
using System.Collections.Concurrent;
2+
using System.Diagnostics;
3+
using System.Diagnostics.CodeAnalysis;
24
using System.Net.WebSockets;
35
using System.Reflection;
46
using System.Text;
@@ -15,7 +17,7 @@ namespace yawaflua.WebSockets.Core;
1517
[SuppressMessage("ReSharper", "AsyncVoidLambda")]
1618
public class WebSocketRouter
1719
{
18-
internal static readonly Dictionary<string, Func<WebSocket, HttpContext, Task>> Routes = new();
20+
internal static readonly ConcurrentDictionary<string, Func<WebSocket, HttpContext, Task>> Routes = new();
1921
internal static readonly List<IWebSocketClient> Clients = new();
2022
private readonly IServiceProvider _serviceProvider;
2123
private readonly ILogger<WebSocketRouter> _logger;
@@ -69,6 +71,7 @@ internal void DiscoverHandlers()
6971
parameters[1].ParameterType != typeof(HttpContext) ||
7072
func.ReturnType != typeof(Task))
7173
{
74+
_logger.LogCritical($"Invalid handler signature in {type.Name}.{func.Name}");
7275
throw new InvalidOperationException(
7376
$"Invalid handler signature in {type.Name}.{func.Name}");
7477
}
@@ -79,15 +82,25 @@ internal void DiscoverHandlers()
7982
typeof(Func<WebSocket, HttpContext, Task>),
8083
func
8184
);
82-
Routes.Add(parentAttributeTemplate, delegateFunc);
85+
if (!Routes.TryAdd(parentAttributeTemplate, delegateFunc))
86+
{
87+
_logger.LogCritical($"Error registered whilest adds new route: {parentAttributeTemplate}");
88+
throw new InvalidOperationException(
89+
$"Error registered whilest adds new route: {parentAttributeTemplate}");
90+
}
8391
}
8492
else
8593
{
86-
Routes.Add(parentAttributeTemplate, async (ws, context) =>
94+
if (!Routes.TryAdd(parentAttributeTemplate, async (ws, context) =>
95+
{
96+
var instance = context.RequestServices.GetRequiredService(type);
97+
await (Task)func.Invoke(instance, new object[] { ws, context })!;
98+
}))
8799
{
88-
var instance = context.RequestServices.GetRequiredService(type);
89-
await (Task)func.Invoke(instance, new object[] { ws, context })!;
90-
});
100+
_logger.LogCritical($"Error registered whilest adds new route: {parentAttributeTemplate}");
101+
throw new InvalidOperationException(
102+
$"Error registered whilest adds new route: {parentAttributeTemplate}");
103+
}
91104
}
92105
}
93106
else
@@ -96,21 +109,42 @@ internal void DiscoverHandlers()
96109
{
97110
var attribute =
98111
(WebSocketAttribute)method.GetCustomAttributes(typeof(WebSocketAttribute), false).First();
112+
113+
var key = parentAttributeTemplate+attribute.Template;
114+
115+
if (Routes.ContainsKey(key))
116+
{
117+
Debug.WriteLine(Routes);
118+
_logger.LogCritical($"Duplicate route error: {key}");
119+
throw new InvalidOperationException(
120+
$"Duplicate route error: {key}");
121+
}
122+
99123
if (method.IsStatic)
100124
{
101125
var delegateFunc = (Func<WebSocket, HttpContext, Task>)Delegate.CreateDelegate(
102126
typeof(Func<WebSocket, HttpContext, Task>),
103127
method
104128
);
105-
Routes.Add(parentAttributeTemplate+attribute.Template, delegateFunc);
129+
if (!Routes.TryAdd(key, delegateFunc))
130+
{
131+
_logger.LogCritical($"Error registered whilest adds new route: {key}");
132+
throw new InvalidOperationException(
133+
$"Error registered whilest adds new route: {key}");
134+
}
106135
}
107136
else
108137
{
109-
Routes.Add(parentAttributeTemplate+attribute.Template, async (ws, context) =>
138+
if (!Routes.TryAdd(key, async (ws, context) =>
139+
{
140+
var instance = context.RequestServices.GetRequiredService(type);
141+
await (Task)method.Invoke(instance, new object[] { ws, context })!;
142+
}))
110143
{
111-
var instance = context.RequestServices.GetRequiredService(type);
112-
await (Task)method.Invoke(instance, new object[] { ws, context })!;
113-
});
144+
_logger.LogCritical($"Error registered whilest adds new route: {key}");
145+
throw new InvalidOperationException(
146+
$"Error registered whilest adds new route: {key}");
147+
}
114148
}
115149
}
116150
}
@@ -127,8 +161,19 @@ internal void DiscoverHandlers()
127161
}
128162
catch (Exception ex)
129163
{
130-
_logger.LogCritical(message:"Error when parsing attributes from assemblies: ", exception:ex);
164+
_logger.LogCritical("Error when parsing attributes from assemblies: {ex}", ex);
165+
Debug.WriteLine(ex);
166+
Debug.WriteLine(Routes);
167+
throw new Exception("Error when parsing attributes from assemblies", ex);
131168
}
169+
170+
#if DEBUG
171+
_logger.LogDebug("Routes:");
172+
foreach (var route in Routes)
173+
{
174+
_logger.LogDebug("Key:FuncName => {k}:{f}", route.Key, route.Value.Method.Name);
175+
}
176+
#endif
132177
}
133178

134179
internal async Task HandleRequest(HttpContext context, CancellationToken cts = default)
@@ -179,12 +224,13 @@ await handler(
179224
}
180225
catch (Exception ex)
181226
{
182-
_logger.LogError(message:"Error with handling request: ",exception: ex);
227+
_logger.LogError("Error with handling request: {ex}", ex);
183228
await Task.Run(async () =>
184229
{
185230
if (_webSocketConfig?.OnErrorHandler != null)
186231
await _webSocketConfig.OnErrorHandler(ex, new WebSocket(webSocket, client, webSocketManager), context);
187232
}, cts);
233+
188234
}
189235

190236
}, cts);
@@ -197,7 +243,7 @@ await Task.Run(async () =>
197243
}
198244
catch (Exception ex)
199245
{
200-
_logger.LogError(ex, $"Error when handle request {context.Connection.Id}: ");
246+
_logger.LogError($"Error when handle request {context.Connection.RemoteIpAddress}: {ex}");
201247
if (_webSocketConfig!.OnConnectionErrorHandler != null)
202248
await _webSocketConfig.OnConnectionErrorHandler(ex, context);
203249
}

yawaflua.WebSockets/Models/Abstracts/WebSocketController.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
namespace yawaflua.WebSockets.Models.Abstracts;
77

8-
public abstract class WebSocketController : IWebSocketController
8+
public abstract class WebSocketController : IWebSocketController
99
{
1010
/// <summary>
1111
/// WebsocketManager provides work with all clients

yawaflua.WebSockets/ServiceBindings.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public static IServiceCollection SettingUpWebSockets(this IServiceCollection isc
1616
if (isc.All(k => k.ServiceType != typeof(WebSocketConfig)))
1717
isc.AddSingleton(new WebSocketConfig());
1818
isc.AddScoped<IWebSocketManager, WebSocketManager>();
19+
isc.AddSingleton<IWebSocketManager, WebSocketManager>();
20+
isc.AddTransient<IWebSocketManager, WebSocketManager>();
1921
isc.AddSingleton<WebSocketMiddleware>();
2022
return isc;
2123
}

0 commit comments

Comments
 (0)