diff --git a/AspNetCore.Extensions.sln b/AspNetCore.Extensions.sln
new file mode 100644
index 0000000..ab92dc4
--- /dev/null
+++ b/AspNetCore.Extensions.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.9.34714.143
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.Extensions", "AspNetCore.Extensions\AspNetCore.Extensions.csproj", "{D0570CEC-F740-4332-AC10-8491974D1827}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {D0570CEC-F740-4332-AC10-8491974D1827}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D0570CEC-F740-4332-AC10-8491974D1827}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D0570CEC-F740-4332-AC10-8491974D1827}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D0570CEC-F740-4332-AC10-8491974D1827}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {7BB65352-F868-424E-BC0A-FD7CB202F791}
+ EndGlobalSection
+EndGlobal
diff --git a/AspNetCore.Extensions/AspNetCore.Extensions.csproj b/AspNetCore.Extensions/AspNetCore.Extensions.csproj
new file mode 100644
index 0000000..8e6c86b
--- /dev/null
+++ b/AspNetCore.Extensions/AspNetCore.Extensions.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ enable
+ 0.1
+ Alexandru Macocian
+ LICENSE
+ true
+ latest
+
+
+
+
+
+
+
+
+
+
diff --git a/AspNetCore.Extensions/Attributes/DoNotInjectAttribute.cs b/AspNetCore.Extensions/Attributes/DoNotInjectAttribute.cs
new file mode 100644
index 0000000..25e09c1
--- /dev/null
+++ b/AspNetCore.Extensions/Attributes/DoNotInjectAttribute.cs
@@ -0,0 +1,6 @@
+namespace AspNetCore.Extensions.Attributes;
+
+[AttributeUsage(AttributeTargets.Constructor)]
+public class DoNotInjectAttribute : Attribute
+{
+}
diff --git a/AspNetCore.Extensions/Attributes/OptionsNameAttribute.cs b/AspNetCore.Extensions/Attributes/OptionsNameAttribute.cs
new file mode 100644
index 0000000..c77ebfd
--- /dev/null
+++ b/AspNetCore.Extensions/Attributes/OptionsNameAttribute.cs
@@ -0,0 +1,7 @@
+namespace AspNetCore.Extensions.Attributes;
+
+[AttributeUsage(AttributeTargets.Class)]
+public sealed class OptionsNameAttribute : Attribute
+{
+ public string? Name { get; set; }
+}
\ No newline at end of file
diff --git a/AspNetCore.Extensions/Converters/Base64ToCertificateConverter.cs b/AspNetCore.Extensions/Converters/Base64ToCertificateConverter.cs
new file mode 100644
index 0000000..8e0c6ac
--- /dev/null
+++ b/AspNetCore.Extensions/Converters/Base64ToCertificateConverter.cs
@@ -0,0 +1,25 @@
+using System.Security.Cryptography.X509Certificates;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace AspNetCore.Extensions.Websockets.Converters;
+
+public sealed class Base64ToCertificateConverter : JsonConverter
+{
+ public override X509Certificate2? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.GetString() is not string base64)
+ {
+ throw new InvalidOperationException($"Cannot deserialize {nameof(X509Certificate2)} from {reader.GetString()}");
+ }
+
+ var bytes = Convert.FromBase64String(base64);
+ return new X509Certificate2(bytes);
+ }
+
+ public override void Write(Utf8JsonWriter writer, X509Certificate2 value, JsonSerializerOptions options)
+ {
+ var base64 = Convert.ToBase64String(value!.GetRawCertData());
+ writer.WriteStringValue(base64);
+ }
+}
diff --git a/AspNetCore.Extensions/ForbiddenResponseActionResult.cs b/AspNetCore.Extensions/ForbiddenResponseActionResult.cs
new file mode 100644
index 0000000..28f7768
--- /dev/null
+++ b/AspNetCore.Extensions/ForbiddenResponseActionResult.cs
@@ -0,0 +1,19 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace AspNetCore.Extensions;
+
+public class ForbiddenResponseActionResult : IActionResult
+{
+ private readonly string reason;
+
+ public ForbiddenResponseActionResult(string reason)
+ {
+ this.reason = reason;
+ }
+
+ public Task ExecuteResultAsync(ActionContext context)
+ {
+ context.HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
+ return context.HttpContext.Response.WriteAsync(reason, context.HttpContext.RequestAborted);
+ }
+}
diff --git a/AspNetCore.Extensions/HttpContextExtensions.cs b/AspNetCore.Extensions/HttpContextExtensions.cs
new file mode 100644
index 0000000..10af23f
--- /dev/null
+++ b/AspNetCore.Extensions/HttpContextExtensions.cs
@@ -0,0 +1,27 @@
+using Microsoft.CorrelationVector;
+using System.Core.Extensions;
+
+namespace AspNetCore.Extensions;
+
+public static class HttpContextExtensions
+{
+ private const string CorrelationVectorKey = "CorrelationVector";
+
+ public static void SetCorrelationVector(this HttpContext context, CorrelationVector cv)
+ {
+ context.ThrowIfNull()
+ .Items.Add(CorrelationVectorKey, cv);
+ }
+
+ public static CorrelationVector GetCorrelationVector(this HttpContext context)
+ {
+ context.ThrowIfNull();
+ if (!context.Items.TryGetValue(CorrelationVectorKey, out var cvVal) ||
+ cvVal is not CorrelationVector cv)
+ {
+ throw new InvalidOperationException("Unable to extract API Key from context");
+ }
+
+ return cv;
+ }
+}
diff --git a/AspNetCore.Extensions/LICENSE b/AspNetCore.Extensions/LICENSE
new file mode 100644
index 0000000..99a4191
--- /dev/null
+++ b/AspNetCore.Extensions/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Macocian Alexandru Victor
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/AspNetCore.Extensions/Middleware/CorrelationVectorMiddleware.cs b/AspNetCore.Extensions/Middleware/CorrelationVectorMiddleware.cs
new file mode 100644
index 0000000..9c0e77c
--- /dev/null
+++ b/AspNetCore.Extensions/Middleware/CorrelationVectorMiddleware.cs
@@ -0,0 +1,34 @@
+using AspNetCore.Extensions.Options;
+using Microsoft.CorrelationVector;
+using Microsoft.Extensions.Options;
+using System.Core.Extensions;
+
+namespace AspNetCore.Extensions.Middleware;
+
+public sealed class CorrelationVectorMiddleware : IMiddleware
+{
+ private readonly CorrelationVectorOptions options;
+
+ public CorrelationVectorMiddleware() : this(Microsoft.Extensions.Options.Options.Create(new()))
+ {
+ }
+
+ public CorrelationVectorMiddleware(IOptions options)
+ {
+ this.options = options.ThrowIfNull().Value.ThrowIfNull();
+ }
+
+ public Task InvokeAsync(HttpContext context, RequestDelegate next)
+ {
+ var cv = new CorrelationVector();
+ if (context.Items.TryGetValue(this.options.Header, out var correlationVectorVal) &&
+ correlationVectorVal is string correlationVectorStr)
+ {
+ cv = CorrelationVector.Parse(correlationVectorStr);
+ cv.Increment();
+ }
+
+ context.SetCorrelationVector(cv);
+ return next(context);
+ }
+}
diff --git a/AspNetCore.Extensions/Middleware/HeaderLoggingMiddleware.cs b/AspNetCore.Extensions/Middleware/HeaderLoggingMiddleware.cs
new file mode 100644
index 0000000..43b3e2c
--- /dev/null
+++ b/AspNetCore.Extensions/Middleware/HeaderLoggingMiddleware.cs
@@ -0,0 +1,22 @@
+using System.Core.Extensions;
+using System.Extensions;
+
+namespace AspNetCore.Extensions.Middleware;
+
+public sealed class HeaderLoggingMiddleware : IMiddleware
+{
+ private readonly ILogger logger;
+
+ public HeaderLoggingMiddleware(
+ ILogger logger)
+ {
+ this.logger = logger.ThrowIfNull();
+ }
+
+ public async Task InvokeAsync(HttpContext context, RequestDelegate next)
+ {
+ var scopedLogger = this.logger.CreateScopedLogger(nameof(this.InvokeAsync), string.Empty);
+ scopedLogger.LogDebug(string.Join("\n", context.Request.Headers.Select(kvp => $"{kvp.Key}: {string.Join(",", kvp.Value.OfType())}")));
+ await next(context);
+ }
+}
diff --git a/AspNetCore.Extensions/Options/CorrelationVectorOptions.cs b/AspNetCore.Extensions/Options/CorrelationVectorOptions.cs
new file mode 100644
index 0000000..67a2f9d
--- /dev/null
+++ b/AspNetCore.Extensions/Options/CorrelationVectorOptions.cs
@@ -0,0 +1,6 @@
+namespace AspNetCore.Extensions.Options;
+
+public sealed class CorrelationVectorOptions
+{
+ public string Header { get; set; } = "X-Correlation-Vector";
+}
diff --git a/AspNetCore.Extensions/Properties/launchSettings.json b/AspNetCore.Extensions/Properties/launchSettings.json
new file mode 100644
index 0000000..3b641b0
--- /dev/null
+++ b/AspNetCore.Extensions/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "AspNetCore.Extensions": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:54242;http://localhost:54243"
+ }
+ }
+}
\ No newline at end of file
diff --git a/AspNetCore.Extensions/WebApplicationBuilderExtensions.cs b/AspNetCore.Extensions/WebApplicationBuilderExtensions.cs
new file mode 100644
index 0000000..bf25e28
--- /dev/null
+++ b/AspNetCore.Extensions/WebApplicationBuilderExtensions.cs
@@ -0,0 +1,34 @@
+using AspNetCore.Extensions.Attributes;
+using System.Core.Extensions;
+using System.Extensions;
+
+namespace AspNetCore.Extensions;
+
+public static class WebApplicationBuilderExtensions
+{
+ public static WebApplicationBuilder ConfigureExtended(this WebApplicationBuilder builder)
+ where TOptions : class, new()
+ {
+ builder.ThrowIfNull()
+ .Services.Configure(builder.Configuration.GetSection(GetOptionsName()));
+ return builder;
+ }
+
+ public static IConfigurationSection GetRequiredSection(this ConfigurationManager configurationManager)
+ {
+ configurationManager.ThrowIfNull();
+ return configurationManager.GetRequiredSection(GetOptionsName());
+ }
+
+ private static string GetOptionsName()
+ {
+ var maybeAttribute = typeof(TOptions).GetCustomAttributes(false).OfType().FirstOrDefault();
+ if (maybeAttribute is not null &&
+ maybeAttribute.Name?.IsNullOrWhiteSpace() is false)
+ {
+ return maybeAttribute.Name;
+ }
+
+ return typeof(TOptions).Name;
+ }
+}
diff --git a/AspNetCore.Extensions/WebApplicationExtensions.cs b/AspNetCore.Extensions/WebApplicationExtensions.cs
new file mode 100644
index 0000000..0cfab57
--- /dev/null
+++ b/AspNetCore.Extensions/WebApplicationExtensions.cs
@@ -0,0 +1,170 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Abstractions;
+using Microsoft.AspNetCore.Mvc.Filters;
+using System.Core.Extensions;
+using System.Diagnostics.CodeAnalysis;
+using System.Extensions;
+using System.Net.WebSockets;
+
+namespace AspNetCore.Extensions.Websockets.Extensions;
+
+public static class WebApplicationExtensions
+{
+ public static WebApplication MapWebSocket<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TWebSocketRoute>(this WebApplication app, string route)
+ where TWebSocketRoute : WebSocketRouteBase
+ {
+ app.ThrowIfNull();
+ app.Map(route, async context =>
+ {
+ if (context.WebSockets.IsWebSocketRequest)
+ {
+ var logger = app.Services.GetRequiredService>();
+ var route = GetRoute(context);
+ var routeFilters = GetRouteFilters(context).ToList();
+
+ var actionContext = new ActionContext(
+ context,
+ new RouteData(),
+ new ActionDescriptor());
+ var actionExecutingContext = new ActionExecutingContext(
+ actionContext,
+ routeFilters,
+ new Dictionary(),
+ route);
+ var actionExecutedContext = new ActionExecutedContext(
+ actionContext,
+ routeFilters,
+ route);
+ try
+ {
+ var processingTask = new Func(() => ProcessWebSocketRequest(route, context));
+ await BeginProcessingPipeline(actionExecutingContext, actionExecutedContext, processingTask);
+ }
+ catch(WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
+ {
+ logger.LogInformation("Websocket closed prematurely. Marking as closed");
+ }
+ catch(OperationCanceledException)
+ {
+ logger.LogInformation("Websocket closed prematurely. Marking as closed");
+ }
+ catch(Exception ex)
+ {
+ logger.LogError(ex, "Encountered exception while handling websocket. Closing");
+ }
+ finally
+ {
+ await route.SocketClosed();
+ }
+ }
+ else
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ }
+ });
+
+ return app;
+ }
+
+ private static async Task ProcessWebSocketRequest(WebSocketRouteBase route, HttpContext httpContext)
+ {
+ using var webSocket = await httpContext.WebSockets.AcceptWebSocketAsync();
+ route.WebSocket = webSocket;
+ route.Context = httpContext;
+ await route.SocketAccepted(httpContext.RequestAborted);
+ httpContext.Request.Scheme = httpContext.Request.Scheme == "https" ? "wss" : "ws";
+ await HandleWebSocket(webSocket, route, httpContext.RequestAborted);
+ await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", httpContext.RequestAborted);
+ }
+
+ private static async Task BeginProcessingPipeline(ActionExecutingContext actionExecutingContext, ActionExecutedContext actionExecutedContext, Func processWebSocket)
+ {
+ foreach (var filter in actionExecutingContext.Filters.OfType())
+ {
+ filter.OnActionExecuting(actionExecutingContext);
+ if (actionExecutingContext.Result is IActionResult result)
+ {
+ await result.ExecuteResultAsync(actionExecutedContext);
+ return;
+ }
+ }
+
+ ActionExecutionDelegate pipeline = async () =>
+ {
+ await processWebSocket();
+ return actionExecutedContext;
+ };
+
+ foreach (var filter in actionExecutingContext.Filters.OfType())
+ {
+ var next = pipeline;
+ pipeline = async () =>
+ {
+ if (actionExecutingContext.Result is IActionResult result)
+ {
+ await result.ExecuteResultAsync(actionExecutedContext);
+ return actionExecutedContext;
+ }
+
+ await filter.OnActionExecutionAsync(actionExecutingContext, next);
+ await (actionExecutingContext.Result?.ExecuteResultAsync(actionExecutingContext) ?? Task.CompletedTask);
+ return actionExecutedContext;
+ };
+ }
+
+ await pipeline();
+ }
+
+ private static async Task HandleWebSocket(WebSocket webSocket, WebSocketRouteBase route, CancellationToken cancellationToken)
+ {
+ var buffer = new byte[1024];
+ using var memoryStream = new MemoryStream(1024);
+ ValueWebSocketReceiveResult result;
+ while(webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
+ {
+ do
+ {
+ var memory = new Memory(buffer);
+ result = await webSocket.ReceiveAsync(memory, cancellationToken);
+ await memoryStream.WriteAsync(buffer, 0, result.Count, cancellationToken);
+ } while (!result.EndOfMessage);
+
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ return;
+ }
+
+ await route.ExecuteAsync(result.MessageType, memoryStream.ToArray(), cancellationToken);
+ memoryStream.SetLength(0);
+ }
+ }
+
+ private static WebSocketRouteBase GetRoute<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TWebSocketRoute>(HttpContext context)
+ where TWebSocketRoute : WebSocketRouteBase
+ {
+ var constructors = typeof(TWebSocketRoute).GetConstructors();
+ foreach(var constructor in constructors)
+ {
+ var dependencies = constructor.GetParameters().Select(param => context.RequestServices.GetService(param.ParameterType));
+ if (dependencies.Any(d => d is null))
+ {
+ continue;
+ }
+
+ var route = constructor.Invoke(dependencies.ToArray());
+ return route.Cast();
+ }
+
+ throw new InvalidOperationException($"Unable to resolve {typeof(TWebSocketRoute).Name}");
+ }
+
+ private static IEnumerable GetRouteFilters<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TWebSocketRoute>(HttpContext context)
+ where TWebSocketRoute : WebSocketRouteBase
+ {
+ foreach(var attribute in typeof(TWebSocketRoute).GetCustomAttributes(true).OfType())
+ {
+ var filter = context.RequestServices.GetRequiredService(attribute.ServiceType);
+ yield return filter.Cast();
+ }
+ }
+}
diff --git a/AspNetCore.Extensions/Websockets/Converters/JsonWebSocketMessageConverter.cs b/AspNetCore.Extensions/Websockets/Converters/JsonWebSocketMessageConverter.cs
new file mode 100644
index 0000000..298dc3e
--- /dev/null
+++ b/AspNetCore.Extensions/Websockets/Converters/JsonWebSocketMessageConverter.cs
@@ -0,0 +1,40 @@
+using AspNetCore.Extensions.Attributes;
+using System.Core.Extensions;
+using System.Text;
+using System.Text.Json;
+
+namespace AspNetCore.Extensions.Websockets.Converters;
+
+public class JsonWebSocketMessageConverter : WebSocketMessageConverter
+{
+ private readonly JsonSerializerOptions? jsonSerializerOptions;
+
+ [DoNotInject]
+ public JsonWebSocketMessageConverter()
+ {
+ }
+
+ public JsonWebSocketMessageConverter(JsonSerializerOptions options)
+ {
+ this.jsonSerializerOptions = options.ThrowIfNull();
+ }
+
+ public override T ConvertTo(WebSocketConverterRequest request)
+ {
+ if (request.Type != System.Net.WebSockets.WebSocketMessageType.Text)
+ {
+ throw new InvalidOperationException($"Unable to deserialize message. Message is not text");
+ }
+
+ var stringData = Encoding.UTF8.GetString(request.Payload!);
+ var objData = JsonSerializer.Deserialize(stringData, this.jsonSerializerOptions);
+ return objData ?? throw new InvalidOperationException($"Unable to deserialize message to {typeof(T).Name}");
+ }
+
+ public override WebSocketConverterResponse ConvertFrom(T message)
+ {
+ var serialized = JsonSerializer.Serialize(message, this.jsonSerializerOptions);
+ var data = Encoding.UTF8.GetBytes(serialized);
+ return new WebSocketConverterResponse { EndOfMessage = true, Type = System.Net.WebSockets.WebSocketMessageType.Text, Payload = data };
+ }
+}
diff --git a/AspNetCore.Extensions/Websockets/Converters/TextWebSocketMessageConverter.cs b/AspNetCore.Extensions/Websockets/Converters/TextWebSocketMessageConverter.cs
new file mode 100644
index 0000000..e3abd7e
--- /dev/null
+++ b/AspNetCore.Extensions/Websockets/Converters/TextWebSocketMessageConverter.cs
@@ -0,0 +1,27 @@
+using System.Text;
+
+namespace AspNetCore.Extensions.Websockets.Converters;
+
+public sealed class TextWebSocketMessageConverter : WebSocketMessageConverter
+{
+ public override TextContent ConvertTo(WebSocketConverterRequest request)
+ {
+ if (request.Type is not System.Net.WebSockets.WebSocketMessageType.Text)
+ {
+ throw new InvalidOperationException($"Cannot parse message of type {request.Type}");
+ }
+
+ var message = Encoding.UTF8.GetString(request.Payload!);
+ return new TextContent { Text = message };
+ }
+
+ public override WebSocketConverterResponse ConvertFrom(TextContent message)
+ {
+ return new WebSocketConverterResponse
+ {
+ Type = System.Net.WebSockets.WebSocketMessageType.Text,
+ EndOfMessage = true,
+ Payload = Encoding.UTF8.GetBytes(message.Text ?? string.Empty)
+ };
+ }
+}
diff --git a/AspNetCore.Extensions/Websockets/TextContent.cs b/AspNetCore.Extensions/Websockets/TextContent.cs
new file mode 100644
index 0000000..eb63d5d
--- /dev/null
+++ b/AspNetCore.Extensions/Websockets/TextContent.cs
@@ -0,0 +1,10 @@
+using AspNetCore.Extensions.Websockets.Converters;
+
+namespace AspNetCore.Extensions.Websockets;
+
+[WebSocketConverter]
+public sealed class TextContent
+{
+ public string? Text { get; set; }
+}
+
diff --git a/AspNetCore.Extensions/Websockets/WebSocketConverterAttribute.cs b/AspNetCore.Extensions/Websockets/WebSocketConverterAttribute.cs
new file mode 100644
index 0000000..17cc467
--- /dev/null
+++ b/AspNetCore.Extensions/Websockets/WebSocketConverterAttribute.cs
@@ -0,0 +1,13 @@
+namespace AspNetCore.Extensions.Websockets;
+
+public abstract class WebSocketConverterAttributeBase : Attribute
+{
+ public abstract Type ConverterType { get; }
+}
+
+[AttributeUsage(AttributeTargets.Class)]
+public sealed class WebSocketConverterAttribute : WebSocketConverterAttributeBase
+ where TConverter : WebSocketMessageConverter, new()
+{
+ public override sealed Type ConverterType => typeof(TConverter);
+}
diff --git a/AspNetCore.Extensions/Websockets/WebSocketConverterRequest.cs b/AspNetCore.Extensions/Websockets/WebSocketConverterRequest.cs
new file mode 100644
index 0000000..ee7d13f
--- /dev/null
+++ b/AspNetCore.Extensions/Websockets/WebSocketConverterRequest.cs
@@ -0,0 +1,10 @@
+using System.Net.WebSockets;
+
+namespace AspNetCore.Extensions.Websockets;
+
+public sealed class WebSocketConverterRequest
+{
+ public WebSocketMessageType Type { get; set; }
+ public byte[]? Payload { get; set; }
+}
+
diff --git a/AspNetCore.Extensions/Websockets/WebSocketConverterResponse.cs b/AspNetCore.Extensions/Websockets/WebSocketConverterResponse.cs
new file mode 100644
index 0000000..72135b3
--- /dev/null
+++ b/AspNetCore.Extensions/Websockets/WebSocketConverterResponse.cs
@@ -0,0 +1,10 @@
+using System.Net.WebSockets;
+
+namespace AspNetCore.Extensions.Websockets;
+
+public sealed class WebSocketConverterResponse
+{
+ public WebSocketMessageType Type { get; set; }
+ public byte[]? Payload { get; set; }
+ public bool EndOfMessage { get; set; }
+}
\ No newline at end of file
diff --git a/AspNetCore.Extensions/Websockets/WebSocketMessageConverterBase.cs b/AspNetCore.Extensions/Websockets/WebSocketMessageConverterBase.cs
new file mode 100644
index 0000000..d0f4120
--- /dev/null
+++ b/AspNetCore.Extensions/Websockets/WebSocketMessageConverterBase.cs
@@ -0,0 +1,24 @@
+using System.Extensions;
+
+namespace AspNetCore.Extensions.Websockets;
+
+public abstract class WebSocketMessageConverterBase
+{
+ public abstract object ConvertToObject(WebSocketConverterRequest request);
+ public abstract WebSocketConverterResponse ConvertFromObject(object message);
+}
+
+public abstract class WebSocketMessageConverter : WebSocketMessageConverterBase
+{
+ public sealed override object ConvertToObject(WebSocketConverterRequest request)
+ {
+ return ConvertTo(request)!;
+ }
+ public sealed override WebSocketConverterResponse ConvertFromObject(object message)
+ {
+ return this.ConvertFrom(message.Cast());
+ }
+
+ public abstract T ConvertTo(WebSocketConverterRequest request);
+ public abstract WebSocketConverterResponse ConvertFrom(T message);
+}
\ No newline at end of file
diff --git a/AspNetCore.Extensions/Websockets/WebSocketRouteBase.cs b/AspNetCore.Extensions/Websockets/WebSocketRouteBase.cs
new file mode 100644
index 0000000..62f303d
--- /dev/null
+++ b/AspNetCore.Extensions/Websockets/WebSocketRouteBase.cs
@@ -0,0 +1,119 @@
+using AspNetCore.Extensions.Attributes;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using System.Extensions;
+using System.Net.WebSockets;
+
+namespace AspNetCore.Extensions.Websockets;
+
+public abstract class WebSocketRouteBase
+{
+ public HttpContext? Context { get; internal set; }
+
+ public WebSocket? WebSocket { get; internal set; }
+
+ public virtual Task SocketAccepted(CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ public virtual Task SocketClosed()
+ {
+ return Task.CompletedTask;
+ }
+
+ public abstract Task ExecuteAsync(WebSocketMessageType type, byte[] data, CancellationToken cancellationToken);
+}
+
+public abstract class WebSocketRouteBase : WebSocketRouteBase
+ where TReceiveType : class, new()
+{
+ private readonly Lazy converter;
+
+ public WebSocketRouteBase()
+ {
+ this.converter = new Lazy(() =>
+ {
+ var attribute = typeof(TReceiveType).GetCustomAttributes(true).First(a => a is WebSocketConverterAttributeBase).Cast();
+ var parsedConverter = GetConverter(attribute.ConverterType, this.Context!);
+ return parsedConverter;
+ });
+ }
+
+ public sealed override Task ExecuteAsync(WebSocketMessageType type, byte[] data, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var objData = this.converter.Value.ConvertToObject(new WebSocketConverterRequest { Type = type, Payload = data }).Cast();
+ return this.ExecuteAsync(objData, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ var scoppedLogger = this.Context!.RequestServices.GetRequiredService>>().CreateScopedLogger(nameof(this.ExecuteAsync), string.Empty);
+ scoppedLogger.LogError(ex, "Failed to process data");
+ throw;
+ }
+ }
+
+ public abstract Task ExecuteAsync(TReceiveType? type, CancellationToken cancellationToken);
+
+ internal static WebSocketMessageConverterBase GetConverter(Type converterType, HttpContext context)
+ {
+ var constructors = converterType.GetConstructors();
+ foreach (var constructor in constructors)
+ {
+ if (constructor.GetCustomAttributes(false).Any(a => a is DoNotInjectAttribute))
+ {
+ continue;
+ }
+
+ var dependencies = constructor.GetParameters().Select(param => context.RequestServices.GetService(param.ParameterType));
+ if (dependencies.Any(d => d is null))
+ {
+ continue;
+ }
+
+ var route = constructor.Invoke(dependencies.ToArray());
+ return route.Cast();
+ }
+
+ throw new InvalidOperationException($"Unable to resolve {converterType.Name}");
+ }
+}
+
+public abstract class WebSocketRouteBase : WebSocketRouteBase
+ where TReceiveType : class, new()
+{
+ private readonly Lazy converter = new(() =>
+ {
+ var attribute = typeof(TSendType).GetCustomAttributes(true).First(a => a is WebSocketConverterAttributeBase).Cast();
+ var converter = Activator.CreateInstance(attribute.ConverterType)!.Cast();
+ return converter;
+ });
+
+ public WebSocketRouteBase()
+ {
+ this.converter = new Lazy(() =>
+ {
+ var attribute = typeof(TSendType).GetCustomAttributes(true).First(a => a is WebSocketConverterAttributeBase).Cast();
+ var parsedConverter = GetConverter(attribute.ConverterType, this.Context!);
+ return parsedConverter;
+ });
+ }
+
+ public Task SendMessage(TSendType sendType, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var response = this.converter.Value.ConvertFromObject(sendType!);
+ return this.WebSocket!.SendAsync(response.Payload!, response.Type, response.EndOfMessage, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ var scoppedLogger = this.Context!.RequestServices.GetRequiredService>>().CreateScopedLogger(nameof(this.SendMessage), string.Empty);
+ scoppedLogger.LogError(ex, "Failed to send data");
+ throw;
+ }
+ }
+}