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; + } + } +}