diff --git a/Readme.md b/Readme.md index b550146..0889bce 100644 --- a/Readme.md +++ b/Readme.md @@ -27,6 +27,8 @@ This package currently supports: - **OpenTelemetry**: Metrics, traces, and logs with Prometheus support. - **Health Checks**: Startup validation and endpoints for monitoring. - **ValidationHelper**: A collection of regex-based validators for common data formats. +- **Maintenance Mode**: Global switch with three modes (`Disabled`, `EnabledForClients`, `EnabledForAll`); clients = all + routes except `/api/admin/*`. - Various **Extensions and Utilities**, including enumerable, string, dictionary and queryable extensions. ## Prerequisites @@ -144,6 +146,7 @@ builder o.RedisConnectionString = "redis://localhost:6379"; o.ChannelPrefix = "app_name:"; }) + .AddMaintenanceMode() // Works only with DistributedCache .AddDistributedSignalR("redis://localhost:6379","app_name:") // or .AddSignalR() .AddCors() .AddHealthChecks(); @@ -153,6 +156,7 @@ var app = builder.Build(); app .UseRequestLogging() + .UseMaintenanceMode() //(place early) .UseResponseCrafter() .UseCors() .MapMinimalApis() @@ -633,6 +637,17 @@ Integrate OpenTelemetry for observability, including metrics, traces, and loggin - OTLP exporter - EF Core telemetry +## Maintenance Mode + +- **Modes** + - `Disabled`: normal operation. + - `EnabledForClients`: only `/api/admin/*` or `/hub/admin/*` allowed + - `EnabledForAll`: everything blocked except `/above-board/*` and `OPTIONS`. +- **Security**: use your own auth (recommended). If you don’t have auth yet, you can pass a shared secret to + `MapMaintenanceEndpoint(basePath, querySecret)`. + +> Currently, this feature requires a Pandatech.DistributedCache to work correctly. + ## HealthChecks - **Startup Validation:** `app.EnsureHealthy()` performs a health check at startup and terminates the application if it @@ -730,6 +745,7 @@ This package includes various extensions and utilities to aid development: retrieves DefaultTimeZone from `appsettings.json` and sets it as the default time zone. - **UrlBuilder:** A utility for building URLs with query parameters. - **Language ISO Code Helper:** Validate, query, and retrieve information about ISO language codes. +- **PhoneUtil class** Utility class for phone number formatting. ### Related NuGet Packages diff --git a/SharedKernel.Demo/Context/InMemoryContext.cs b/SharedKernel.Demo/Context/InMemoryContext.cs index c91a859..6f69caf 100644 --- a/SharedKernel.Demo/Context/InMemoryContext.cs +++ b/SharedKernel.Demo/Context/InMemoryContext.cs @@ -7,10 +7,12 @@ public class InMemoryContext(DbContextOptions options) : DbCont { public DbSet OutboxMessages { get; set; } - protected override void OnModelCreating(ModelBuilder b) => + protected override void OnModelCreating(ModelBuilder b) + { b.Entity() .ToTable("outbox_messages") .HasKey(x => x.Id); + } } public class OutboxMessage diff --git a/SharedKernel.Demo/LoggingTestEndpoints.cs b/SharedKernel.Demo/LoggingTestEndpoints.cs index 1811397..04ff788 100644 --- a/SharedKernel.Demo/LoggingTestEndpoints.cs +++ b/SharedKernel.Demo/LoggingTestEndpoints.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.Json; using FluentMinimalApiMapper; using Microsoft.AspNetCore.Mvc; @@ -83,7 +84,7 @@ public void AddRoutes(IEndpointRouteBuilder app) }); grp.MapGet("/no-content-type", - async (HttpContext ctx) => + async ctx => { await ctx.Response.Body.WriteAsync(Encoding.UTF8.GetBytes("raw body with no content-type")); }); @@ -104,12 +105,16 @@ public void AddRoutes(IEndpointRouteBuilder app) () => { var sb = new StringBuilder(); - for (var i = 0; i < 20_000; i++) sb.Append('x'); + for (var i = 0; i < 20_000; i++) + { + sb.Append('x'); + } + return Results.Text(sb.ToString(), "text/plain", Encoding.UTF8); }); grp.MapPost("/echo-with-headers", - ([FromBody] SharedKernel.Demo.TestTypes payload, HttpResponse res) => + ([FromBody] TestTypes payload, HttpResponse res) => { res.Headers["Custom-Header-Response"] = "CustomValue"; res.ContentType = "application/json; charset=utf-8"; @@ -117,10 +122,10 @@ public void AddRoutes(IEndpointRouteBuilder app) }); grp.MapGet("/ping", () => Results.Text("pong", "text/plain")); - + grp.MapGet("/invalid-json", - async (HttpContext ctx) => + async ctx => { ctx.Response.StatusCode = StatusCodes.Status200OK; ctx.Response.ContentType = "application/json"; @@ -134,27 +139,31 @@ public void AddRoutes(IEndpointRouteBuilder app) var httpClient = httpClientFactory.CreateClient("RandomApiClient"); httpClient.DefaultRequestHeaders.Add("auth", "hardcoded-auth-value"); - var body = new SharedKernel.Demo.TestTypes + var body = new TestTypes { AnimalType = AnimalType.Cat, JustText = "Hello from Get Data", JustNumber = 100 }; - var content = new StringContent(System.Text.Json.JsonSerializer.Serialize(body), - System.Text.Encoding.UTF8, + var content = new StringContent(JsonSerializer.Serialize(body), + Encoding.UTF8, "application/json"); var response = await httpClient.PostAsync("tests/echo-with-headers?barev=5", content); if (!response.IsSuccessStatusCode) + { throw new Exception("Something went wrong"); + } var responseBody = await response.Content.ReadAsStringAsync(); - var testTypes = System.Text.Json.JsonSerializer.Deserialize(responseBody); + var testTypes = JsonSerializer.Deserialize(responseBody); if (testTypes == null) + { throw new Exception("Failed to get data from external API"); + } return TypedResults.Ok(testTypes); }); diff --git a/SharedKernel.Demo/MaintenanceTestEndpoints.cs b/SharedKernel.Demo/MaintenanceTestEndpoints.cs new file mode 100644 index 0000000..5f61204 --- /dev/null +++ b/SharedKernel.Demo/MaintenanceTestEndpoints.cs @@ -0,0 +1,17 @@ +using FluentMinimalApiMapper; + +namespace SharedKernel.Demo; + +public class MaintenanceTestEndpoints : IEndpoint +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + var grp = app.MapGroup("/") + .WithTags("maintenance"); + + + grp.MapGet("/api/admin/v1/test", () => Results.Ok("ok")); + grp.MapGet("/api/admin/v2/test", () => Results.Ok("ok")); + grp.MapGet("/api/integration/v1/test", () => Results.Ok("ok")); + } +} \ No newline at end of file diff --git a/SharedKernel.Demo/MassTransitExtensions.cs b/SharedKernel.Demo/MassTransitExtensions.cs index 5433c6b..3da39c2 100644 --- a/SharedKernel.Demo/MassTransitExtensions.cs +++ b/SharedKernel.Demo/MassTransitExtensions.cs @@ -45,7 +45,7 @@ public static WebApplicationBuilder AddRmqHealthCheck(this WebApplicationBuilder var rmqConnectionString = builder.Configuration.GetConnectionString("RabbitMq")!; var factory = new ConnectionFactory { - Uri = new Uri(rmqConnectionString), + Uri = new Uri(rmqConnectionString) }; return factory.CreateConnectionAsync() .GetAwaiter() diff --git a/SharedKernel.Demo/MessageHub.cs b/SharedKernel.Demo/MessageHub.cs index 30f45ea..1b7e5e0 100644 --- a/SharedKernel.Demo/MessageHub.cs +++ b/SharedKernel.Demo/MessageHub.cs @@ -13,6 +13,6 @@ public async Task SendMessage(SendMessageRequest message) public class SendMessageRequest : IHubArgument { - public required string InvocationId { get; set; } public required string Message { get; set; } + public required string InvocationId { get; set; } } \ No newline at end of file diff --git a/SharedKernel.Demo/Program.cs b/SharedKernel.Demo/Program.cs index da0339c..4749f49 100644 --- a/SharedKernel.Demo/Program.cs +++ b/SharedKernel.Demo/Program.cs @@ -1,7 +1,5 @@ using DistributedCache.Extensions; using FluentMinimalApiMapper; -using FluentValidation; -using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using ResponseCrafter.Enums; @@ -12,6 +10,7 @@ using SharedKernel.Helpers; using SharedKernel.Logging; using SharedKernel.Logging.Middleware; +using SharedKernel.Maintenance; using SharedKernel.OpenApi; using SharedKernel.Resilience; using SharedKernel.ValidatorAndMediatR; @@ -26,16 +25,17 @@ .AddSerilog(LogBackend.ElasticSearch) .AddResponseCrafter(NamingConvention.ToSnakeCase) .AddOpenApi() + .AddMaintenanceMode() .AddOpenTelemetry() .AddMinimalApis(AssemblyRegistry.ToArray()) .AddControllers(AssemblyRegistry.ToArray()) .AddMediatrWithBehaviors(AssemblyRegistry.ToArray()) .AddResilienceDefaultPipeline() - .AddDistributedSignalR("localhost:6379", "app_name:") // or .AddSignalR() + .AddDistributedSignalR("localhost:6379", "app_name") // or .AddSignalR() .AddDistributedCache(o => { o.RedisConnectionString = "localhost:6379"; - o.ChannelPrefix = "app_name:"; + o.ChannelPrefix = "app_name"; }) .AddMassTransit(AssemblyRegistry.ToArray()) .MapDefaultTimeZone() @@ -66,6 +66,7 @@ app .UseRequestLogging() + .UseMaintenanceMode() .UseResponseCrafter() .UseCors() .MapMinimalApis() @@ -78,6 +79,8 @@ app.CreateInMemoryDb(); +app.MapMaintenanceEndpoint(); + app.MapGet("/outbox-count", async (InMemoryContext db) => diff --git a/SharedKernel.Demo/SharedKernel.Demo.csproj b/SharedKernel.Demo/SharedKernel.Demo.csproj index 0fe6381..575c194 100644 --- a/SharedKernel.Demo/SharedKernel.Demo.csproj +++ b/SharedKernel.Demo/SharedKernel.Demo.csproj @@ -7,23 +7,23 @@ - - - - + + + + - + - - Never - - - Never - + + Never + + + Never + diff --git a/src/SharedKernel/Extensions/ControllerExtensions.cs b/src/SharedKernel/Extensions/ControllerExtensions.cs index 373440d..4e3e851 100644 --- a/src/SharedKernel/Extensions/ControllerExtensions.cs +++ b/src/SharedKernel/Extensions/ControllerExtensions.cs @@ -10,7 +10,8 @@ public static class ControllerExtensions { public static WebApplicationBuilder AddControllers(this WebApplicationBuilder builder, Assembly[] assemblies) { - var mvcBuilder = builder.Services.AddControllers(options => options.Conventions.Add(new ToLowerNamingConvention())); + var mvcBuilder = + builder.Services.AddControllers(options => options.Conventions.Add(new ToLowerNamingConvention())); foreach (var assembly in assemblies) { mvcBuilder.AddApplicationPart(assembly); diff --git a/src/SharedKernel/Extensions/FusionCacheExtensions.cs b/src/SharedKernel/Extensions/FusionCacheExtensions.cs index 01aaec4..599185f 100644 --- a/src/SharedKernel/Extensions/FusionCacheExtensions.cs +++ b/src/SharedKernel/Extensions/FusionCacheExtensions.cs @@ -39,4 +39,5 @@ // AddBaseFusionCache(builder, instanceName); // return builder; // } -// } \ No newline at end of file +// } + diff --git a/src/SharedKernel/Extensions/HttpContextExtensions.cs b/src/SharedKernel/Extensions/HttpContextExtensions.cs index f128da9..b78f638 100644 --- a/src/SharedKernel/Extensions/HttpContextExtensions.cs +++ b/src/SharedKernel/Extensions/HttpContextExtensions.cs @@ -18,7 +18,7 @@ public static void MarkAsPrivateEndpoint(this IHttpContextAccessor contextAccess { contextAccessor.HttpContext?.Response.Headers.Append("X-Private-Endpoint", "1"); } - + public static void MarkAsPrivateEndpoint(this HttpContextAccessor contextAccessor) { contextAccessor.HttpContext?.Response.Headers.Append("X-Private-Endpoint", "1"); diff --git a/src/SharedKernel/Extensions/OpenTelemetryExtension.cs b/src/SharedKernel/Extensions/OpenTelemetryExtension.cs index 6583878..a0c11dc 100644 --- a/src/SharedKernel/Extensions/OpenTelemetryExtension.cs +++ b/src/SharedKernel/Extensions/OpenTelemetryExtension.cs @@ -27,7 +27,7 @@ public static WebApplicationBuilder AddOpenTelemetry(this WebApplicationBuilder .WithMetrics(metrics => { metrics.AddRuntimeInstrumentation() - // .AddFusionCacheInstrumentation() + // .AddFusionCacheInstrumentation() .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddPrometheusExporter(); @@ -35,7 +35,7 @@ public static WebApplicationBuilder AddOpenTelemetry(this WebApplicationBuilder .WithTracing(tracing => { tracing - // .AddFusionCacheInstrumentation() + // .AddFusionCacheInstrumentation() .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddEntityFrameworkCoreInstrumentation(); diff --git a/src/SharedKernel/Extensions/SignalRExtensions.cs b/src/SharedKernel/Extensions/SignalRExtensions.cs index 8a51ed7..6d36900 100644 --- a/src/SharedKernel/Extensions/SignalRExtensions.cs +++ b/src/SharedKernel/Extensions/SignalRExtensions.cs @@ -4,7 +4,6 @@ using ResponseCrafter.ExceptionHandlers.SignalR; using Serilog; using Serilog.Events; -using SharedKernel.Logging; using SharedKernel.Logging.Middleware; using StackExchange.Redis; diff --git a/src/SharedKernel/Helpers/MethodTimingStatistics.cs b/src/SharedKernel/Helpers/MethodTimingStatistics.cs index 5f25a35..4e7c11a 100644 --- a/src/SharedKernel/Helpers/MethodTimingStatistics.cs +++ b/src/SharedKernel/Helpers/MethodTimingStatistics.cs @@ -6,25 +6,27 @@ namespace SharedKernel.Helpers; /// -/// Tracks and logs timing statistics of method executions for benchmarking. +/// Tracks and logs timing statistics of method executions for benchmarking. /// /// -/// Not recommended for production usage. Use only for performance diagnostics or quick benchmarks. +/// Not recommended for production usage. Use only for performance diagnostics or quick benchmarks. /// public class MethodTimingStatistics { + private static readonly List CollectedStats = []; public required string MethodName { get; init; } private int InvocationCount { get; set; } private double TotalElapsedMilliseconds { get; set; } private double AverageElapsedMilliseconds { get; set; } - private static readonly List CollectedStats = []; - /// - /// Updates or adds statistics for a particular method based on the start timestamp. + /// Updates or adds statistics for a particular method based on the start timestamp. /// /// The name of the method being tracked. - /// A timestamp (via ) captured before the method execution. + /// + /// A timestamp (via ) captured before the method + /// execution. + /// public static void RecordExecution(string methodName, long startTimestamp) { var elapsedMs = Stopwatch.GetElapsedTime(startTimestamp) @@ -51,9 +53,9 @@ public static void RecordExecution(string methodName, long startTimestamp) } /// - /// Logs the accumulated statistics for all tracked methods. + /// Logs the accumulated statistics for all tracked methods. /// - /// An instance used for logging the statistics. + /// An instance used for logging the statistics. public static void LogAll(ILogger logger) { foreach (var stat in CollectedStats) @@ -71,7 +73,7 @@ public static void LogAll(ILogger logger) } /// - /// Clears the statistics for a specific method or for all methods if none is specified. + /// Clears the statistics for a specific method or for all methods if none is specified. /// /// Optional method name to clear; if null or empty, clears all statistics. public static void ClearStatistics(string? methodName = null) @@ -85,6 +87,7 @@ public static void ClearStatistics(string? methodName = null) CollectedStats.Clear(); } } + private static string FormatDuration(double milliseconds) { switch (milliseconds) diff --git a/src/SharedKernel/Helpers/ValidationHelper.cs b/src/SharedKernel/Helpers/ValidationHelper.cs index ea7d413..f1c277f 100644 --- a/src/SharedKernel/Helpers/ValidationHelper.cs +++ b/src/SharedKernel/Helpers/ValidationHelper.cs @@ -8,151 +8,163 @@ namespace SharedKernel.Helpers; public static class ValidationHelper { - private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(50); + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(50); - private static readonly Regex Email = - new(@"^[\w-_]+(\.[\w!#$%'*+\/=?\^`{|}]+)*@((([\-\w]+\.)+[a-zA-Z]{2,20})|(([0-9]{1,3}\.){3}[0-9]{1,3}))$", - RegexOptions.ExplicitCapture | RegexOptions.Compiled, RegexTimeout); + private static readonly Regex Email = + new(@"^[\w-_]+(\.[\w!#$%'*+\/=?\^`{|}]+)*@((([\-\w]+\.)+[a-zA-Z]{2,20})|(([0-9]{1,3}\.){3}[0-9]{1,3}))$", + RegexOptions.ExplicitCapture | RegexOptions.Compiled, + RegexTimeout); - private static readonly Regex Username = - new(@"^[a-zA-Z0-9_]{5,15}$", - RegexOptions.ExplicitCapture | RegexOptions.Compiled, RegexTimeout); + private static readonly Regex Username = + new(@"^[a-zA-Z0-9_]{5,15}$", + RegexOptions.ExplicitCapture | RegexOptions.Compiled, + RegexTimeout); - private static readonly Regex PandaFormattedPhoneNumber = - new(@"^\(\d{1,5}\)\d{4,15}$", - RegexOptions.ExplicitCapture | RegexOptions.Compiled, RegexTimeout); + private static readonly Regex PandaFormattedPhoneNumber = + new(@"^\(\d{1,5}\)\d{4,15}$", + RegexOptions.ExplicitCapture | RegexOptions.Compiled, + RegexTimeout); - //Credit card is commented out as 4 tests are not passing for an unknown reason! Via https://regex101.com/ everything passes. - // private static readonly Regex CreditCardNumber = - // new Regex( - // @"^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12}|35\d{14})$", - // RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture | RegexOptions.Compiled, RegexTimeout); - - - private static readonly Regex UsSocialSecurityNumber = - new( - @"^4[0-9]{12}(?:[0-9]{3})?$", - RegexOptions.ExplicitCapture | RegexOptions.Compiled, RegexTimeout); - - private static readonly Regex ArmeniaSocialSecurityNumber = - new(@"^(\d{10}|Տ\d{3}\/\d{5}|S\d{3}A\d{5})$", - RegexOptions.ExplicitCapture | RegexOptions.Compiled, RegexTimeout); - - private static readonly Regex ArmeniaIdCard = - new(@"^\d{9}$", - RegexOptions.ExplicitCapture | RegexOptions.Compiled, RegexTimeout); - - private static readonly Regex ArmeniaPassportNumber = - new(@"^([A-Z]{2}\d{7}|\d{9})$", - RegexOptions.ExplicitCapture | RegexOptions.Compiled, RegexTimeout); - - private static readonly Regex ArmeniaTaxCode = - new(@"^\d{8}$", - RegexOptions.ExplicitCapture | RegexOptions.Compiled, RegexTimeout); - - private static readonly Regex ArmeniaStateRegistryNumber = - new(@"^\d{3}\.\d{3}\.\d{5,10}$", - RegexOptions.ExplicitCapture | RegexOptions.Compiled, RegexTimeout); - - - public static bool IsUri(string uri, bool allowNonSecure = true) - - { - Uri.TryCreate(uri, UriKind.Absolute, out var parsedUri); - - if (parsedUri is null) - { - return false; - } - - if (!allowNonSecure && parsedUri.Scheme == Uri.UriSchemeHttp) - { - return false; - } - - return true; - } - - public static bool IsUsSocialSecurityNumber(string number) - { - return UsSocialSecurityNumber.IsMatch(number); - } - - - public static bool IsEmail(string email) - { - return MailAddress.TryCreate(email, out _); - } - - public static bool IsUsername(string userName) - { - return Username.IsMatch(userName); - } - - public static bool IsArmeniaSocialSecurityNumber(string socialCardNumber) - { - return ArmeniaSocialSecurityNumber.IsMatch(socialCardNumber); - } - - public static bool IsArmeniaIdCard(string idCard) - { - return ArmeniaIdCard.IsMatch(idCard); - } - - public static bool IsArmeniaPassportNumber(string passportNumber) - { - return ArmeniaPassportNumber.IsMatch(passportNumber); - } - - public static bool IsArmeniaTaxCode(string taxCode) - { - return ArmeniaTaxCode.IsMatch(taxCode); - } - - public static bool IsArmeniaStateRegistryNumber(string stateRegistryNumber) - { - return ArmeniaStateRegistryNumber.IsMatch(stateRegistryNumber); - } - - public static bool IsPandaFormattedPhoneNumber(string phoneNumber) - { - return PandaFormattedPhoneNumber.IsMatch(phoneNumber); - } - - public static bool IsGuid(string guid) - { - return Guid.TryParse(guid, out _); - } - - public static bool IsIPv4(string ipv4) - { - return IPAddress.TryParse(ipv4, out var address) && address.AddressFamily == AddressFamily.InterNetwork; - } - - public static bool IsIPv6(string ipv6) - { - return IPAddress.TryParse(ipv6, out var address) && address.AddressFamily == AddressFamily.InterNetworkV6; - } - - public static bool IsIpAddress(string ipAddress) - { - return IPAddress.TryParse(ipAddress, out _); - } - - public static bool IsJson(string json) - { - if (string.IsNullOrWhiteSpace(json)) - return false; - try - { - using var doc = JsonDocument.Parse(json); - return true; - } - catch (JsonException) - { - return false; - } - } + //Credit card is commented out as 4 tests are not passing for an unknown reason! Via https://regex101.com/ everything passes. + // private static readonly Regex CreditCardNumber = + // new Regex( + // @"^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12}|35\d{14})$", + // RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture | RegexOptions.Compiled, RegexTimeout); + + + private static readonly Regex UsSocialSecurityNumber = + new( + @"^4[0-9]{12}(?:[0-9]{3})?$", + RegexOptions.ExplicitCapture | RegexOptions.Compiled, + RegexTimeout); + + private static readonly Regex ArmeniaSocialSecurityNumber = + new(@"^(\d{10}|Տ\d{3}\/\d{5}|S\d{3}A\d{5})$", + RegexOptions.ExplicitCapture | RegexOptions.Compiled, + RegexTimeout); + + private static readonly Regex ArmeniaIdCard = + new(@"^\d{9}$", + RegexOptions.ExplicitCapture | RegexOptions.Compiled, + RegexTimeout); + + private static readonly Regex ArmeniaPassportNumber = + new(@"^([A-Z]{2}\d{7}|\d{9})$", + RegexOptions.ExplicitCapture | RegexOptions.Compiled, + RegexTimeout); + + private static readonly Regex ArmeniaTaxCode = + new(@"^\d{8}$", + RegexOptions.ExplicitCapture | RegexOptions.Compiled, + RegexTimeout); + + private static readonly Regex ArmeniaStateRegistryNumber = + new(@"^\d{3}\.\d{3}\.\d{5,10}$", + RegexOptions.ExplicitCapture | RegexOptions.Compiled, + RegexTimeout); + + + public static bool IsUri(string uri, bool allowNonSecure = true) + + { + Uri.TryCreate(uri, UriKind.Absolute, out var parsedUri); + + if (parsedUri is null) + { + return false; + } + + if (!allowNonSecure && parsedUri.Scheme == Uri.UriSchemeHttp) + { + return false; + } + + return true; + } + + public static bool IsUsSocialSecurityNumber(string number) + { + return UsSocialSecurityNumber.IsMatch(number); + } + + + public static bool IsEmail(string email) + { + return MailAddress.TryCreate(email, out _); + } + + public static bool IsUsername(string userName) + { + return Username.IsMatch(userName); + } + + public static bool IsArmeniaSocialSecurityNumber(string socialCardNumber) + { + return ArmeniaSocialSecurityNumber.IsMatch(socialCardNumber); + } + + public static bool IsArmeniaIdCard(string idCard) + { + return ArmeniaIdCard.IsMatch(idCard); + } + + public static bool IsArmeniaPassportNumber(string passportNumber) + { + return ArmeniaPassportNumber.IsMatch(passportNumber); + } + + public static bool IsArmeniaTaxCode(string taxCode) + { + return ArmeniaTaxCode.IsMatch(taxCode); + } + + public static bool IsArmeniaStateRegistryNumber(string stateRegistryNumber) + { + return ArmeniaStateRegistryNumber.IsMatch(stateRegistryNumber); + } + + public static bool IsPandaFormattedPhoneNumber(string phoneNumber) + { + return PandaFormattedPhoneNumber.IsMatch(phoneNumber); + } + + public static bool IsGuid(string guid) + { + return Guid.TryParse(guid, out _); + } + + public static bool IsIPv4(string ipv4) + { + return IPAddress.TryParse(ipv4, out var address) && address.AddressFamily == AddressFamily.InterNetwork; + } + + public static bool IsIPv6(string ipv6) + { + return IPAddress.TryParse(ipv6, out var address) && address.AddressFamily == AddressFamily.InterNetworkV6; + } + + public static bool IsIpAddress(string ipAddress) + { + return IPAddress.TryParse(ipAddress, out _); + } + + public static bool IsJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return false; + } + + try + { + using var doc = JsonDocument.Parse(json); + return true; + } + catch (JsonException) + { + return false; + } + } } \ No newline at end of file diff --git a/src/SharedKernel/Logging/Middleware/CappedResponseBodyStream.cs b/src/SharedKernel/Logging/Middleware/CappedResponseBodyStream.cs index 493ccc4..d6ecdfb 100644 --- a/src/SharedKernel/Logging/Middleware/CappedResponseBodyStream.cs +++ b/src/SharedKernel/Logging/Middleware/CappedResponseBodyStream.cs @@ -4,11 +4,10 @@ namespace SharedKernel.Logging.Middleware; internal sealed class CappedResponseBodyStream : Stream { - private readonly Stream _inner; - private readonly int _capBytes; private readonly byte[] _buf; + private readonly int _capBytes; + private readonly Stream _inner; private int _bufLen; - private long _totalWritten; public CappedResponseBodyStream(Stream inner, int capBytes) { @@ -16,17 +15,25 @@ public CappedResponseBodyStream(Stream inner, int capBytes) _capBytes = capBytes; _buf = ArrayPool.Shared.Rent(_capBytes); _bufLen = 0; - _totalWritten = 0; + TotalWritten = 0; } - public long TotalWritten - { - get { return _totalWritten; } - } + public long TotalWritten { get; private set; } + + public ReadOnlyMemory Captured => new(_buf, 0, _bufLen); - public ReadOnlyMemory Captured + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => throw new NotSupportedException(); + + public override long Position { - get { return new ReadOnlyMemory(_buf, 0, _bufLen); } + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); } private void Capture(ReadOnlySpan src) @@ -50,21 +57,21 @@ private void Capture(ReadOnlySpan src) public override void Write(byte[] buffer, int offset, int count) { _inner.Write(buffer, offset, count); - _totalWritten += count; + TotalWritten += count; Capture(new ReadOnlySpan(buffer, offset, count)); } public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { await _inner.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); - _totalWritten += count; + TotalWritten += count; Capture(new ReadOnlySpan(buffer, offset, count)); } public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { var vt = _inner.WriteAsync(buffer, cancellationToken); - _totalWritten += buffer.Length; + TotalWritten += buffer.Length; Capture(buffer.Span); return vt; } @@ -91,32 +98,6 @@ protected override void Dispose(bool disposing) } } - public override bool CanRead - { - get { return false; } - } - - public override bool CanSeek - { - get { return false; } - } - - public override bool CanWrite - { - get { return true; } - } - - public override long Length - { - get { throw new NotSupportedException(); } - } - - public override long Position - { - get { throw new NotSupportedException(); } - set { throw new NotSupportedException(); } - } - public override int Read(byte[] buffer, int offset, int count) { throw new NotSupportedException(); diff --git a/src/SharedKernel/Logging/Middleware/HttpLogHelper.cs b/src/SharedKernel/Logging/Middleware/HttpLogHelper.cs index deba294..0b40858 100644 --- a/src/SharedKernel/Logging/Middleware/HttpLogHelper.cs +++ b/src/SharedKernel/Logging/Middleware/HttpLogHelper.cs @@ -26,20 +26,20 @@ internal static class HttpLogHelper if (!textLike) { return (redactedHeaders, LogFormatting.Omitted( - reason: "non-text", - lengthBytes: len, - mediaType: MediaTypeUtil.Normalize(contentType), - thresholdBytes: LoggingOptions.RequestResponseBodyMaxBytes)); + "non-text", + len, + MediaTypeUtil.Normalize(contentType), + LoggingOptions.RequestResponseBodyMaxBytes)); } var (raw, truncated) = await ReadLimitedAsync(bodyStream, LoggingOptions.RequestResponseBodyMaxBytes); if (truncated) { return (redactedHeaders, LogFormatting.Omitted( - reason: "exceeds-limit", - lengthBytes: LoggingOptions.RequestResponseBodyMaxBytes, - mediaType: MediaTypeUtil.Normalize(contentType), - thresholdBytes: LoggingOptions.RequestResponseBodyMaxBytes)); + "exceeds-limit", + LoggingOptions.RequestResponseBodyMaxBytes, + MediaTypeUtil.Normalize(contentType), + LoggingOptions.RequestResponseBodyMaxBytes)); } var body = RedactionHelper.RedactBody(contentType, raw); @@ -62,10 +62,10 @@ internal static class HttpLogHelper if (Utf8ByteCount(raw) > LoggingOptions.RequestResponseBodyMaxBytes) { return (redactedHeaders, LogFormatting.Omitted( - reason: "exceeds-limit", - lengthBytes: Utf8ByteCount(raw), - mediaType: MediaTypeUtil.Normalize(contentType), - thresholdBytes: LoggingOptions.RequestResponseBodyMaxBytes)); + "exceeds-limit", + Utf8ByteCount(raw), + MediaTypeUtil.Normalize(contentType), + LoggingOptions.RequestResponseBodyMaxBytes)); } var body = RedactionHelper.RedactBody(contentType, raw); @@ -96,7 +96,10 @@ public static Dictionary> CreateHeadersDictionary(Ht public static Dictionary> CreateHeadersDictionary(HttpResponseMessage res) { var dict = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var h in res.Headers) dict[h.Key] = h.Value; + foreach (var h in res.Headers) + { + dict[h.Key] = h.Value; + } var ch = res.Content?.Headers; if (ch != null) @@ -110,7 +113,10 @@ public static Dictionary> CreateHeadersDictionary(Ht return dict; } - internal static bool IsTextLike(string? mediaType) => MediaTypeUtil.IsTextLike(mediaType); + internal static bool IsTextLike(string? mediaType) + { + return MediaTypeUtil.IsTextLike(mediaType); + } private static long? GetContentLengthOrNull(IHeaderDictionary headers) { @@ -127,7 +133,7 @@ public static Dictionary> CreateHeadersDictionary(Ht { s.Seek(0, SeekOrigin.Begin); - using var ms = new MemoryStream(capacity: maxBytes); + using var ms = new MemoryStream(maxBytes); var buf = new byte[Math.Min(8192, maxBytes)]; var total = 0; @@ -135,7 +141,11 @@ public static Dictionary> CreateHeadersDictionary(Ht { var toRead = Math.Min(buf.Length, maxBytes - total); var read = await s.ReadAsync(buf.AsMemory(0, toRead)); - if (read == 0) break; + if (read == 0) + { + break; + } + await ms.WriteAsync(buf.AsMemory(0, read)); total += read; } @@ -148,7 +158,10 @@ public static Dictionary> CreateHeadersDictionary(Ht if (read > 0) { truncated = true; - if (s.CanSeek) s.Seek(-read, SeekOrigin.Current); + if (s.CanSeek) + { + s.Seek(-read, SeekOrigin.Current); + } } } @@ -156,5 +169,8 @@ public static Dictionary> CreateHeadersDictionary(Ht return (Encoding.UTF8.GetString(ms.ToArray()), truncated); } - private static int Utf8ByteCount(string s) => Encoding.UTF8.GetByteCount(s); + private static int Utf8ByteCount(string s) + { + return Encoding.UTF8.GetByteCount(s); + } } \ No newline at end of file diff --git a/src/SharedKernel/Logging/Middleware/ReductionHelper.cs b/src/SharedKernel/Logging/Middleware/ReductionHelper.cs index 94ecd57..e47f366 100644 --- a/src/SharedKernel/Logging/Middleware/ReductionHelper.cs +++ b/src/SharedKernel/Logging/Middleware/ReductionHelper.cs @@ -10,19 +10,23 @@ internal static class RedactionHelper { // ------- Headers ------- - public static Dictionary RedactHeaders(IHeaderDictionary headers) => - headers.ToDictionary( + public static Dictionary RedactHeaders(IHeaderDictionary headers) + { + return headers.ToDictionary( h => h.Key, h => LoggingOptions.SensitiveKeywords.Any(k => h.Key.Contains(k, StringComparison.OrdinalIgnoreCase)) ? "[REDACTED]" : h.Value.ToString()); + } - public static Dictionary RedactHeaders(Dictionary> headers) => - headers.ToDictionary( + public static Dictionary RedactHeaders(Dictionary> headers) + { + return headers.ToDictionary( kvp => kvp.Key, kvp => LoggingOptions.SensitiveKeywords.Any(k => kvp.Key.Contains(k, StringComparison.OrdinalIgnoreCase)) ? "[REDACTED]" : string.Join(";", kvp.Value)); + } // ------- Bodies (JSON, x-www-form-urlencoded, text fallback) ------- @@ -43,7 +47,10 @@ public static object RedactBody(string? contentType, string raw) } catch (JsonException) { - return new Dictionary { ["invalidJson"] = true }; + return new Dictionary + { + ["invalidJson"] = true + }; } } @@ -146,8 +153,9 @@ public static Dictionary RedactFormFields(IFormCollection form) // ------- Helpers ------- - private static object RedactElement(JsonElement el) => - el.ValueKind switch + private static object RedactElement(JsonElement el) + { + return el.ValueKind switch { JsonValueKind.Object => el.EnumerateObject() .ToDictionary( @@ -169,6 +177,7 @@ private static object RedactElement(JsonElement el) => JsonValueKind.Null => null!, _ => el.GetRawText() }; + } private static string RedactString(string value) { diff --git a/src/SharedKernel/Logging/SerilogExtensions.cs b/src/SharedKernel/Logging/SerilogExtensions.cs index a53a86b..67fae8e 100644 --- a/src/SharedKernel/Logging/SerilogExtensions.cs +++ b/src/SharedKernel/Logging/SerilogExtensions.cs @@ -201,8 +201,10 @@ sc is ScalarValue sv && sv.Value is string src && sql.Contains("outbox_messages", StringComparison.OrdinalIgnoreCase); // Grab the structured SQL (EF logs it as 'commandText'; some sinks rename to 'CommandText') - static string? Get(LogEvent e, string name) => - e.Properties.TryGetValue(name, out var v) && v is ScalarValue s && s.Value is string str ? str : null; + static string? Get(LogEvent e, string name) + { + return e.Properties.TryGetValue(name, out var v) && v is ScalarValue s && s.Value is string str ? str : null; + } } diff --git a/src/SharedKernel/Logging/StartupLoggerExtensions.cs b/src/SharedKernel/Logging/StartupLoggerExtensions.cs index 2f3f08c..8deb307 100644 --- a/src/SharedKernel/Logging/StartupLoggerExtensions.cs +++ b/src/SharedKernel/Logging/StartupLoggerExtensions.cs @@ -6,7 +6,7 @@ namespace SharedKernel.Logging; public static class StartupLoggerExtensions { - private static long? _startTimestamp = null; + private static long? _startTimestamp; public static WebApplicationBuilder LogStartAttempt(this WebApplicationBuilder builder) { diff --git a/src/SharedKernel/Maintenance/MaintenanceCacheEntity.cs b/src/SharedKernel/Maintenance/MaintenanceCacheEntity.cs new file mode 100644 index 0000000..cb39401 --- /dev/null +++ b/src/SharedKernel/Maintenance/MaintenanceCacheEntity.cs @@ -0,0 +1,10 @@ +using MessagePack; + +namespace SharedKernel.Maintenance; + +[MessagePackObject] +public sealed class MaintenanceCacheEntity +{ + [Key(0)] + public MaintenanceMode Mode { get; init; } +} \ No newline at end of file diff --git a/src/SharedKernel/Maintenance/MaintenanceCachePoller.cs b/src/SharedKernel/Maintenance/MaintenanceCachePoller.cs new file mode 100644 index 0000000..e3b1105 --- /dev/null +++ b/src/SharedKernel/Maintenance/MaintenanceCachePoller.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Hosting; + +namespace SharedKernel.Maintenance; + +internal class MaintenanceCachePoller(HybridCache hybridCache, MaintenanceState state) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + var maintenanceCacheEntity = await hybridCache.GetOrCreateAsync( + "maintenance-mode", + _ => ValueTask.FromResult(CreateCacheEntity()), + cancellationToken: stoppingToken); + + state.SetFromPoller(maintenanceCacheEntity.Mode); + } + catch + { + //ignore + } + finally + { + await Task.Delay(TimeSpan.FromSeconds(7), stoppingToken); + } + } + } + + private static MaintenanceCacheEntity CreateCacheEntity() + { + return new MaintenanceCacheEntity + { + Mode = MaintenanceMode.Disabled + }; + } +} \ No newline at end of file diff --git a/src/SharedKernel/Maintenance/MaintenanceMiddleware.cs b/src/SharedKernel/Maintenance/MaintenanceMiddleware.cs new file mode 100644 index 0000000..147a2aa --- /dev/null +++ b/src/SharedKernel/Maintenance/MaintenanceMiddleware.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; + +namespace SharedKernel.Maintenance; + +internal sealed class MaintenanceMiddleware(RequestDelegate next, MaintenanceState state) +{ + private static readonly PathString[] AdminPrefixes = ["/api/admin", "/hub/admin"]; // add "/admin" if needed + + public async Task InvokeAsync(HttpContext httpContext) + { + var path = httpContext.Request.Path; + + // always allow health/metrics/ping and CORS preflight + if (path.StartsWithSegments("/above-board", StringComparison.OrdinalIgnoreCase, out _) || + HttpMethods.IsOptions(httpContext.Request.Method)) + { + await next(httpContext); + return; + } + + var mode = state.Mode; + + if (mode == MaintenanceMode.EnabledForAll) + { + await Set503Async(httpContext); + return; + } + + var isAdminRoute = AdminPrefixes.Any(p => path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase, out _)); + + if (mode == MaintenanceMode.EnabledForClients && !isAdminRoute) + { + await Set503Async(httpContext); + return; + } + + await next(httpContext); + } + + private static async Task Set503Async(HttpContext ctx) + { + ctx.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + ctx.Response.Headers.RetryAfter = "60"; + ctx.Response.Headers.CacheControl = "no-store, no-cache, must-revalidate"; + ctx.Response.ContentType = "application/json; charset=utf-8"; + + if (!HttpMethods.IsHead(ctx.Request.Method)) + { + var payload = JsonSerializer.Serialize(new + { + message = "The service is under maintenance. Please try again later." + }); + await ctx.Response.WriteAsync(payload); + } + } +} \ No newline at end of file diff --git a/src/SharedKernel/Maintenance/MaintenanceMode.cs b/src/SharedKernel/Maintenance/MaintenanceMode.cs new file mode 100644 index 0000000..c6cefed --- /dev/null +++ b/src/SharedKernel/Maintenance/MaintenanceMode.cs @@ -0,0 +1,15 @@ +namespace SharedKernel.Maintenance; + +public enum MaintenanceMode +{ + Disabled = 0, + EnabledForClients = 1, + EnabledForAll = 2 +} + +//This is for local cache entity to poll the maintenance mode from distributed cache +//This should be removed then L1 + L2 cache is implemented in hybrid cache + +// This is a local cache entity to hold the maintenance mode in memory +// This should be removed then L1 + L2 cache is implemented in hybrid cache +// thread-safe local snapshot \ No newline at end of file diff --git a/src/SharedKernel/Maintenance/MaintenanceState.cs b/src/SharedKernel/Maintenance/MaintenanceState.cs new file mode 100644 index 0000000..ab02313 --- /dev/null +++ b/src/SharedKernel/Maintenance/MaintenanceState.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Caching.Hybrid; + +namespace SharedKernel.Maintenance; + +public sealed class MaintenanceState(HybridCache cache) +{ + private const string Key = "maintenance-mode"; + private int _mode = (int)MaintenanceMode.Disabled; + + public MaintenanceMode Mode + { + get => (MaintenanceMode)Volatile.Read(ref _mode); + private set => Volatile.Write(ref _mode, (int)value); + } + + // for admin/API to change mode (updates local immediately, then L2) + public async Task SetModeAsync(MaintenanceMode mode, CancellationToken ct = default) + { + Mode = mode; + await cache.SetAsync( + Key, + new MaintenanceCacheEntity + { + Mode = mode + }, + new HybridCacheEntryOptions + { + Expiration = TimeSpan.MaxValue, + LocalCacheExpiration = TimeSpan.MaxValue, + Flags = null + }, + cancellationToken: ct); + } + + // used by the poller only + internal void SetFromPoller(MaintenanceMode mode) + { + Mode = mode; + } +} \ No newline at end of file diff --git a/src/SharedKernel/Maintenance/WebAppExtensions.cs b/src/SharedKernel/Maintenance/WebAppExtensions.cs new file mode 100644 index 0000000..8d7babe --- /dev/null +++ b/src/SharedKernel/Maintenance/WebAppExtensions.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using SharedKernel.Constants; + +namespace SharedKernel.Maintenance; + +public static class WebAppExtensions +{ + private static bool _builderAdded; + private static bool _webAppAdded; + + public static WebApplicationBuilder AddMaintenanceMode(this WebApplicationBuilder builder) + { + if (_builderAdded) + { + return builder; + } + + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + + _builderAdded = true; + return builder; + } + + public static WebApplication UseMaintenanceMode(this WebApplication app) + { + if (_webAppAdded) + { + return app; + } + + if (!_builderAdded) + { + throw new InvalidOperationException( + "You must call AddMaintenanceMode on the WebApplicationBuilder before calling UseMaintenanceMode on the WebApplication."); + } + + app.UseMiddleware(); + + _webAppAdded = true; + + return app; + } + + /// + /// Maps PUT {EndpointConstants.BasePath}/maintenance. + /// Set to enable a shared-secret check; leave it null to use your own authorization + /// instead. + /// + public static RouteHandlerBuilder MapMaintenanceEndpoint(this IEndpointRouteBuilder app, + string? querySecret = null) + { + if (!_webAppAdded) + { + throw new InvalidOperationException("Call UseMaintenanceMode before MapMaintenanceEndpoint."); + } + + + if (string.IsNullOrWhiteSpace(querySecret)) + { + return app.MapPut(EndpointConstants.BasePath + "/maintenance", + async ([FromServices] MaintenanceState state, + [FromBody] MaintenanceModeRequest req, + CancellationToken ct) => + { + await state.SetModeAsync(req.Mode, ct); + return TypedResults.Ok(new MaintenanceModeResponse("Mode set to " + req.Mode, + DateTimeOffset.UtcNow)); + }) + .WithTags(EndpointConstants.TagName) + .WithSummary("Set maintenance mode"); + } + + return app.MapPut(EndpointConstants.BasePath + "/maintenance", + async ([FromServices] MaintenanceState state, + [FromBody] MaintenanceModeRequest req, + [FromQuery] string secret, + CancellationToken ct) => + { + if (!string.Equals(secret, querySecret, StringComparison.Ordinal)) + { + return Results.Unauthorized(); + } + + await state.SetModeAsync(req.Mode, ct); + return TypedResults.Ok(new MaintenanceModeResponse("Mode set to " + req.Mode, + DateTimeOffset.UtcNow)); + }) + .WithTags(EndpointConstants.TagName) + .WithSummary("Set maintenance mode") + .Produces(StatusCodes.Status401Unauthorized); + } +} + +public sealed record MaintenanceModeRequest(MaintenanceMode Mode); + +public sealed record MaintenanceModeResponse(string Message, DateTimeOffset UpdatedAt); \ No newline at end of file diff --git a/src/SharedKernel/OpenApi/EmbeddedFilesExtension.cs b/src/SharedKernel/OpenApi/EmbeddedFilesExtension.cs index a3aeded..befa68b 100644 --- a/src/SharedKernel/OpenApi/EmbeddedFilesExtension.cs +++ b/src/SharedKernel/OpenApi/EmbeddedFilesExtension.cs @@ -16,44 +16,48 @@ internal static class EmbeddedFilesExtension internal static WebApplication MapSwaggerUiAssetEndpoint(this WebApplication app) { - app.Map("/swagger-resources/{resourceName}", async (HttpContext context, string resourceName) => - { - if (!AllowedResources.Contains(resourceName)) - { - context.Response.StatusCode = StatusCodes.Status404NotFound; - await context.Response.WriteAsync($"Resource '{resourceName}' not found."); - return; - } + app.Map("/swagger-resources/{resourceName}", + async (HttpContext context, string resourceName) => + { + if (!AllowedResources.Contains(resourceName)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsync($"Resource '{resourceName}' not found."); + return; + } - var assembly = typeof(EmbeddedFilesExtension).Assembly; - var resourcePath = assembly.GetManifestResourceNames() - .FirstOrDefault(x => x.EndsWith(resourceName, StringComparison.OrdinalIgnoreCase)); + var assembly = typeof(EmbeddedFilesExtension).Assembly; + var resourcePath = assembly.GetManifestResourceNames() + .FirstOrDefault(x => + x.EndsWith(resourceName, StringComparison.OrdinalIgnoreCase)); - if (resourcePath == null) - { - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - await context.Response.WriteAsync($"Resource '{resourceName}' not found in assembly."); - return; - } + if (resourcePath == null) + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsync($"Resource '{resourceName}' not found in assembly."); + return; + } - await using var stream = assembly.GetManifestResourceStream(resourcePath); - if (stream == null) - { - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - await context.Response.WriteAsync($"Failed to load resource '{resourceName}'."); - return; - } + await using var stream = assembly.GetManifestResourceStream(resourcePath); + if (stream == null) + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsync($"Failed to load resource '{resourceName}'."); + return; + } - context.Response.ContentType = GetContentType(resourceName); - await stream.CopyToAsync(context.Response.Body); - }) - .WithGroupName("SwaggerUiAssetEndpoint"); + context.Response.ContentType = GetContentType(resourceName); + await stream.CopyToAsync(context.Response.Body); + }) + .WithGroupName("SwaggerUiAssetEndpoint"); return app; } - private static string GetContentType(string resourceName) => - Path.GetExtension(resourceName).ToLowerInvariant() switch + private static string GetContentType(string resourceName) + { + return Path.GetExtension(resourceName) + .ToLowerInvariant() switch { ".css" => "text/css", ".js" => "application/javascript", @@ -62,4 +66,5 @@ private static string GetContentType(string resourceName) => ".jpg" or ".jpeg" => "image/jpeg", _ => "application/octet-stream" }; + } } \ No newline at end of file diff --git a/src/SharedKernel/OpenApi/OpenApiExtensions.cs b/src/SharedKernel/OpenApi/OpenApiExtensions.cs index a2ead91..95e32a7 100644 --- a/src/SharedKernel/OpenApi/OpenApiExtensions.cs +++ b/src/SharedKernel/OpenApi/OpenApiExtensions.cs @@ -14,7 +14,6 @@ public static class OpenApiExtensions public static WebApplicationBuilder AddOpenApi(this WebApplicationBuilder builder, Action? configureOptions = null) { - var openApiConfiguration = builder.Configuration .GetSection("OpenApi") .Get(); diff --git a/src/SharedKernel/OpenApi/UiAssets/panda-style.js b/src/SharedKernel/OpenApi/UiAssets/panda-style.js index 601cf3f..4ac6858 100644 --- a/src/SharedKernel/OpenApi/UiAssets/panda-style.js +++ b/src/SharedKernel/OpenApi/UiAssets/panda-style.js @@ -1,7 +1,7 @@ document.addEventListener('DOMContentLoaded', function () { - + const faviconPath = "/swagger-resources/favicon.svg"; - + const existingLink = document.querySelector("link[rel*='icon']"); if (existingLink) { existingLink.href = faviconPath; diff --git a/src/SharedKernel/OpenApi/UiExtensions.cs b/src/SharedKernel/OpenApi/UiExtensions.cs index 7eda449..2b2cbda 100644 --- a/src/SharedKernel/OpenApi/UiExtensions.cs +++ b/src/SharedKernel/OpenApi/UiExtensions.cs @@ -40,7 +40,7 @@ internal static WebApplication MapScalarUi(this WebApplication app, OpenApiConfi { options.Theme = ScalarTheme.Kepler; options.Favicon = "/swagger-resources/favicon.svg"; - + foreach (var document in openApiConfigConfiguration.Documents) { options.AddDocument( diff --git a/src/SharedKernel/SharedKernel.csproj b/src/SharedKernel/SharedKernel.csproj index 362350d..29f594c 100644 --- a/src/SharedKernel/SharedKernel.csproj +++ b/src/SharedKernel/SharedKernel.csproj @@ -8,56 +8,56 @@ Readme.md Pandatech MIT - 1.6.6 + 1.7.0 Pandatech.SharedKernel Pandatech Shared Kernel Library Pandatech, shared kernel, library, OpenAPI, Swagger, utilities, scalar Pandatech.SharedKernel provides centralized configurations, utilities, and extensions for ASP.NET Core projects. For more information refere to readme.md document. https://github.com/PandaTechAM/be-lib-sharedkernel - Phone util added + Maintenance mode has been added - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehavior.cs b/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehavior.cs index 70ce625..2cf5d77 100644 --- a/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehavior.cs +++ b/src/SharedKernel/ValidatorAndMediatR/Behaviors/ValidationBehavior.cs @@ -4,21 +4,20 @@ namespace SharedKernel.ValidatorAndMediatR.Behaviors; -public sealed class ValidationBehavior( - IEnumerable> validators -) : IPipelineBehavior where TRequest : notnull +public sealed class ValidationBehavior(IEnumerable> validators) + : IPipelineBehavior where TRequest : notnull { private readonly IValidator[] _validators = validators as IValidator[] ?? validators.ToArray(); - public async Task Handle( - TRequest request, + public async Task Handle(TRequest request, RequestHandlerDelegate next, - CancellationToken cancellationToken - ) + CancellationToken cancellationToken) { if (_validators.Length == 0) + { return await next(cancellationToken); + } var context = new ValidationContext(request); var failures = (await Task.WhenAll( @@ -29,11 +28,16 @@ CancellationToken cancellationToken .ToList(); if (!failures.Any()) + { return await next(cancellationToken); + } var errorMap = failures .GroupBy(e => e.PropertyName) - .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).Distinct().First()); + .ToDictionary(g => g.Key, + g => g.Select(e => e.ErrorMessage) + .Distinct() + .First()); throw new BadRequestException(errorMap); } diff --git a/test/SharedKernel.Tests/SharedKernel.Tests.csproj b/test/SharedKernel.Tests/SharedKernel.Tests.csproj index 92e0527..add6b8a 100644 --- a/test/SharedKernel.Tests/SharedKernel.Tests.csproj +++ b/test/SharedKernel.Tests/SharedKernel.Tests.csproj @@ -10,8 +10,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -23,7 +23,7 @@ - +