Add project files.

This commit is contained in:
2024-08-16 11:24:48 +02:00
parent 6e24754b23
commit 6e4501f373
22 changed files with 682 additions and 0 deletions
+25
View File
@@ -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
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>0.1</Version>
<Authors>Alexandru Macocian</Authors>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CorrelationVector" Version="1.0.42" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="SystemExtensions.NetCore" Version="1.0.1" />
<PackageReference Include="SystemExtensions.NetStandard" Version="1.6.3" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
namespace AspNetCore.Extensions.Attributes;
[AttributeUsage(AttributeTargets.Constructor)]
public class DoNotInjectAttribute : Attribute
{
}
@@ -0,0 +1,7 @@
namespace AspNetCore.Extensions.Attributes;
[AttributeUsage(AttributeTargets.Class)]
public sealed class OptionsNameAttribute : Attribute
{
public string? Name { get; set; }
}
@@ -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<X509Certificate2>
{
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);
}
}
@@ -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);
}
}
@@ -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;
}
}
+21
View File
@@ -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.
@@ -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<CorrelationVectorOptions>(new()))
{
}
public CorrelationVectorMiddleware(IOptions<CorrelationVectorOptions> 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);
}
}
@@ -0,0 +1,22 @@
using System.Core.Extensions;
using System.Extensions;
namespace AspNetCore.Extensions.Middleware;
public sealed class HeaderLoggingMiddleware : IMiddleware
{
private readonly ILogger<HeaderLoggingMiddleware> logger;
public HeaderLoggingMiddleware(
ILogger<HeaderLoggingMiddleware> 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<string>())}")));
await next(context);
}
}
@@ -0,0 +1,6 @@
namespace AspNetCore.Extensions.Options;
public sealed class CorrelationVectorOptions
{
public string Header { get; set; } = "X-Correlation-Vector";
}
@@ -0,0 +1,12 @@
{
"profiles": {
"AspNetCore.Extensions": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:54242;http://localhost:54243"
}
}
}
@@ -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<TOptions>(this WebApplicationBuilder builder)
where TOptions : class, new()
{
builder.ThrowIfNull()
.Services.Configure<TOptions>(builder.Configuration.GetSection(GetOptionsName<TOptions>()));
return builder;
}
public static IConfigurationSection GetRequiredSection<TOptions>(this ConfigurationManager configurationManager)
{
configurationManager.ThrowIfNull();
return configurationManager.GetRequiredSection(GetOptionsName<TOptions>());
}
private static string GetOptionsName<TOptions>()
{
var maybeAttribute = typeof(TOptions).GetCustomAttributes(false).OfType<OptionsNameAttribute>().FirstOrDefault();
if (maybeAttribute is not null &&
maybeAttribute.Name?.IsNullOrWhiteSpace() is false)
{
return maybeAttribute.Name;
}
return typeof(TOptions).Name;
}
}
@@ -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<ILogger<WebSocketRouteBase>>();
var route = GetRoute<TWebSocketRoute>(context);
var routeFilters = GetRouteFilters<TWebSocketRoute>(context).ToList();
var actionContext = new ActionContext(
context,
new RouteData(),
new ActionDescriptor());
var actionExecutingContext = new ActionExecutingContext(
actionContext,
routeFilters,
new Dictionary<string, object?>(),
route);
var actionExecutedContext = new ActionExecutedContext(
actionContext,
routeFilters,
route);
try
{
var processingTask = new Func<Task>(() => 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<Task> processWebSocket)
{
foreach (var filter in actionExecutingContext.Filters.OfType<IActionFilter>())
{
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<IAsyncActionFilter>())
{
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<byte>(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<WebSocketRouteBase>();
}
throw new InvalidOperationException($"Unable to resolve {typeof(TWebSocketRoute).Name}");
}
private static IEnumerable<IFilterMetadata> GetRouteFilters<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TWebSocketRoute>(HttpContext context)
where TWebSocketRoute : WebSocketRouteBase
{
foreach(var attribute in typeof(TWebSocketRoute).GetCustomAttributes(true).OfType<ServiceFilterAttribute>())
{
var filter = context.RequestServices.GetRequiredService(attribute.ServiceType);
yield return filter.Cast<IFilterMetadata>();
}
}
}
@@ -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<T> : WebSocketMessageConverter<T>
{
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<T>(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 };
}
}
@@ -0,0 +1,27 @@
using System.Text;
namespace AspNetCore.Extensions.Websockets.Converters;
public sealed class TextWebSocketMessageConverter : WebSocketMessageConverter<TextContent>
{
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)
};
}
}
@@ -0,0 +1,10 @@
using AspNetCore.Extensions.Websockets.Converters;
namespace AspNetCore.Extensions.Websockets;
[WebSocketConverter<TextWebSocketMessageConverter, TextContent>]
public sealed class TextContent
{
public string? Text { get; set; }
}
@@ -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<TConverter, TPayload> : WebSocketConverterAttributeBase
where TConverter : WebSocketMessageConverter<TPayload>, new()
{
public override sealed Type ConverterType => typeof(TConverter);
}
@@ -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; }
}
@@ -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; }
}
@@ -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<T> : WebSocketMessageConverterBase
{
public sealed override object ConvertToObject(WebSocketConverterRequest request)
{
return ConvertTo(request)!;
}
public sealed override WebSocketConverterResponse ConvertFromObject(object message)
{
return this.ConvertFrom(message.Cast<T>());
}
public abstract T ConvertTo(WebSocketConverterRequest request);
public abstract WebSocketConverterResponse ConvertFrom(T message);
}
@@ -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<TReceiveType> : WebSocketRouteBase
where TReceiveType : class, new()
{
private readonly Lazy<WebSocketMessageConverterBase> converter;
public WebSocketRouteBase()
{
this.converter = new Lazy<WebSocketMessageConverterBase>(() =>
{
var attribute = typeof(TReceiveType).GetCustomAttributes(true).First(a => a is WebSocketConverterAttributeBase).Cast<WebSocketConverterAttributeBase>();
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<TReceiveType>();
return this.ExecuteAsync(objData, cancellationToken);
}
catch (Exception ex)
{
var scoppedLogger = this.Context!.RequestServices.GetRequiredService<ILogger<WebSocketRouteBase<TReceiveType>>>().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<WebSocketMessageConverterBase>();
}
throw new InvalidOperationException($"Unable to resolve {converterType.Name}");
}
}
public abstract class WebSocketRouteBase<TReceiveType, TSendType> : WebSocketRouteBase<TReceiveType>
where TReceiveType : class, new()
{
private readonly Lazy<WebSocketMessageConverterBase> converter = new(() =>
{
var attribute = typeof(TSendType).GetCustomAttributes(true).First(a => a is WebSocketConverterAttributeBase).Cast<WebSocketConverterAttributeBase>();
var converter = Activator.CreateInstance(attribute.ConverterType)!.Cast<WebSocketMessageConverterBase>();
return converter;
});
public WebSocketRouteBase()
{
this.converter = new Lazy<WebSocketMessageConverterBase>(() =>
{
var attribute = typeof(TSendType).GetCustomAttributes(true).First(a => a is WebSocketConverterAttributeBase).Cast<WebSocketConverterAttributeBase>();
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<ILogger<WebSocketRouteBase<TReceiveType, TSendType>>>().CreateScopedLogger(nameof(this.SendMessage), string.Empty);
scoppedLogger.LogError(ex, "Failed to send data");
throw;
}
}
}