diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4ee2ed2..236e095 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup .NET Core - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: global-json-file: global.json diff --git a/.gitignore b/.gitignore index 45195a5..dbc2976 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +## Get latest from `dotnet new gitignore` -.idea +# dotenv files +.env # User-specific files *.rsuser @@ -31,7 +32,6 @@ x86/ bld/ [Bb]in/ [Oo]bj/ -[Oo]ut/ [Ll]og/ [Ll]ogs/ @@ -60,11 +60,14 @@ dlldata.c # Benchmark Results BenchmarkDotNet.Artifacts/ -# .NET Core +# .NET project.lock.json project.fragment.lock.json artifacts/ +# Tye +.tye/ + # ASP.NET Scaffolding ScaffoldingReadMe.txt @@ -93,6 +96,7 @@ StyleCopReport.xml *.tmp_proj *_wpftmp.csproj *.log +*.tlog *.vspscc *.vssscc .builds @@ -296,6 +300,17 @@ node_modules/ # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts @@ -352,6 +367,9 @@ ASALocalRun/ # Local History for Visual Studio .localhistory/ +# Visual Studio History (VSHistory) files +.vshistory/ + # BeatPulse healthcheck temp database healthchecksdb @@ -364,6 +382,107 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp + # Added by me logs LocalFileStorage diff --git a/SharedKernel.Demo/MessageHub.cs b/SharedKernel.Demo/MessageHub.cs index 1b7e5e0..610b0c1 100644 --- a/SharedKernel.Demo/MessageHub.cs +++ b/SharedKernel.Demo/MessageHub.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.SignalR; using ResponseCrafter.ExceptionHandlers.SignalR; +using System.Threading; namespace SharedKernel.Demo; diff --git a/SharedKernel.Demo/SharedKernel.Demo.csproj b/SharedKernel.Demo/SharedKernel.Demo.csproj index 2feee98..a135fe9 100644 --- a/SharedKernel.Demo/SharedKernel.Demo.csproj +++ b/SharedKernel.Demo/SharedKernel.Demo.csproj @@ -1,16 +1,16 @@ - net9.0 + net10.0 enable enable - - - + + + diff --git a/SharedKernel.sln b/SharedKernel.sln deleted file mode 100644 index 1974072..0000000 --- a/SharedKernel.sln +++ /dev/null @@ -1,46 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedKernel", "src\SharedKernel\SharedKernel.csproj", "{25001943-A870-4E17-A9B9-0D190CEC819B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedKernel.Tests", "test\SharedKernel.Tests\SharedKernel.Tests.csproj", "{0305E58F-1C47-454C-B10B-A223F2561A85}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{F8A6DCFE-8924-49A4-B3E9-2034593F54E5}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{FEE159A2-74A0-4469-9B93-52987CA1A3CA}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8FF54200-8B03-4828-AAAE-297E30E2A00A}" - ProjectSection(SolutionItems) = preProject - .github\workflows\main.yml = .github\workflows\main.yml - .gitignore = .gitignore - Readme.md = Readme.md - global.json = global.json - .editorconfig = .editorconfig - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedKernel.Demo", "SharedKernel.Demo\SharedKernel.Demo.csproj", "{1CD76A30-4A74-4F54-AC0C-AEDD92408553}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {25001943-A870-4E17-A9B9-0D190CEC819B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {25001943-A870-4E17-A9B9-0D190CEC819B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {25001943-A870-4E17-A9B9-0D190CEC819B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {25001943-A870-4E17-A9B9-0D190CEC819B}.Release|Any CPU.Build.0 = Release|Any CPU - {0305E58F-1C47-454C-B10B-A223F2561A85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0305E58F-1C47-454C-B10B-A223F2561A85}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0305E58F-1C47-454C-B10B-A223F2561A85}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0305E58F-1C47-454C-B10B-A223F2561A85}.Release|Any CPU.Build.0 = Release|Any CPU - {1CD76A30-4A74-4F54-AC0C-AEDD92408553}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1CD76A30-4A74-4F54-AC0C-AEDD92408553}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1CD76A30-4A74-4F54-AC0C-AEDD92408553}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1CD76A30-4A74-4F54-AC0C-AEDD92408553}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {25001943-A870-4E17-A9B9-0D190CEC819B} = {F8A6DCFE-8924-49A4-B3E9-2034593F54E5} - {0305E58F-1C47-454C-B10B-A223F2561A85} = {FEE159A2-74A0-4469-9B93-52987CA1A3CA} - {1CD76A30-4A74-4F54-AC0C-AEDD92408553} = {FEE159A2-74A0-4469-9B93-52987CA1A3CA} - EndGlobalSection -EndGlobal diff --git a/SharedKernel.slnx b/SharedKernel.slnx new file mode 100644 index 0000000..283a980 --- /dev/null +++ b/SharedKernel.slnx @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/global.json b/global.json index 72e9873..c783c4f 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.200", + "version": "10.0.101", "rollForward": "latestMinor" } } diff --git a/src/SharedKernel/Extensions/ConfigurationExtensions.cs b/src/SharedKernel/Extensions/ConfigurationExtensions.cs index 5163066..5a86339 100644 --- a/src/SharedKernel/Extensions/ConfigurationExtensions.cs +++ b/src/SharedKernel/Extensions/ConfigurationExtensions.cs @@ -9,47 +9,30 @@ internal static class ConfigurationExtensions private const string RepositoryNameConfigurationPath = "RepositoryName"; private const string TimeZoneConfigurationPath = "DefaultTimeZone"; - internal static string GetAllowedCorsOrigins(this IConfiguration configuration) + extension(IConfiguration configuration) { - var corsOrigins = configuration[CorsOriginsConfigurationPath]; - if (corsOrigins is null) + internal string GetAllowedCorsOrigins() { - throw new InvalidOperationException("Allowed CORS origins are not configured."); + var corsOrigins = configuration[CorsOriginsConfigurationPath]; + return corsOrigins ?? throw new InvalidOperationException("Allowed CORS origins are not configured."); } - return corsOrigins; - } - - internal static string GetRepositoryName(this IConfiguration configuration) - { - var repositoryName = configuration[RepositoryNameConfigurationPath]; - if (repositoryName is null) + internal string GetRepositoryName() { - throw new InvalidOperationException("Repository name is not configured."); + var repositoryName = configuration[RepositoryNameConfigurationPath]; + return repositoryName ?? throw new InvalidOperationException("Repository name is not configured."); } - return repositoryName; - } - - internal static string GetPersistentPath(this IConfiguration configuration) - { - var persistentPath = configuration.GetConnectionString(PersistentConfigurationPath); - if (persistentPath is null) + internal string GetPersistentPath() { - throw new InvalidOperationException("Persistent path is not configured."); + var persistentPath = configuration.GetConnectionString(PersistentConfigurationPath); + return persistentPath ?? throw new InvalidOperationException("Persistent path is not configured."); } - return persistentPath; - } - - public static string GetDefaultTimeZone(this IConfiguration configuration) - { - var timeZone = configuration[TimeZoneConfigurationPath]; - if (timeZone is null) + public string GetDefaultTimeZone() { - throw new InvalidOperationException("Default time zone is not configured."); + var timeZone = configuration[TimeZoneConfigurationPath]; + return timeZone ?? throw new InvalidOperationException("Default time zone is not configured."); } - - return timeZone; } } \ No newline at end of file diff --git a/src/SharedKernel/Extensions/HealthCheckExtensions.cs b/src/SharedKernel/Extensions/HealthCheckExtensions.cs index b4dbdc9..5e33094 100644 --- a/src/SharedKernel/Extensions/HealthCheckExtensions.cs +++ b/src/SharedKernel/Extensions/HealthCheckExtensions.cs @@ -41,8 +41,7 @@ public static WebApplication MapHealthCheckEndpoints(this WebApplication app) app .MapGet($"{EndpointConstants.BasePath}/ping", () => "pong") .Produces() - .WithTags(EndpointConstants.TagName) - .WithOpenApi(); + .WithTags(EndpointConstants.TagName); app.MapHealthChecks($"{EndpointConstants.BasePath}/health", new HealthCheckOptions diff --git a/src/SharedKernel/Helpers/ValidationHelper.cs b/src/SharedKernel/Helpers/ValidationHelper.cs index f1c277f..cf7ab46 100644 --- a/src/SharedKernel/Helpers/ValidationHelper.cs +++ b/src/SharedKernel/Helpers/ValidationHelper.cs @@ -10,10 +10,6 @@ public static class ValidationHelper { 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 Username = new(@"^[a-zA-Z0-9_]{5,15}$", @@ -26,14 +22,6 @@ public static class ValidationHelper 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})?$", @@ -167,4 +155,46 @@ public static bool IsJson(string json) return false; } } + + public static bool IsCreditCardNumber(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var len = value.Length; + if (len is < 13 or > 19) + { + return false; + } + + var sum = 0; + var doubleIt = false; + + for (var i = len - 1; i >= 0; i--) + { + var ch = value[i]; + if (ch is < '0' or > '9') + { + return false; + } + + var d = ch - '0'; + + if (doubleIt) + { + d *= 2; + if (d > 9) + { + d -= 9; + } + } + + sum += d; + doubleIt = !doubleIt; + } + + return sum % 10 == 0; + } } \ No newline at end of file diff --git a/src/SharedKernel/Logging/Middleware/HttpLogHelper.cs b/src/SharedKernel/Logging/Middleware/HttpLogHelper.cs index 0b40858..c9b0424 100644 --- a/src/SharedKernel/Logging/Middleware/HttpLogHelper.cs +++ b/src/SharedKernel/Logging/Middleware/HttpLogHelper.cs @@ -1,6 +1,7 @@ using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.Net.Http.Headers; +using System.Threading; namespace SharedKernel.Logging.Middleware; @@ -8,7 +9,8 @@ internal static class HttpLogHelper { public static async Task<(object Headers, object Body)> CaptureAsync(Stream bodyStream, IHeaderDictionary headers, - string? contentType) + string? contentType, + CancellationToken ct = default) { var redactedHeaders = RedactionHelper.RedactHeaders(headers); @@ -32,7 +34,7 @@ internal static class HttpLogHelper LoggingOptions.RequestResponseBodyMaxBytes)); } - var (raw, truncated) = await ReadLimitedAsync(bodyStream, LoggingOptions.RequestResponseBodyMaxBytes); + var (raw, truncated) = await ReadLimitedAsync(bodyStream, LoggingOptions.RequestResponseBodyMaxBytes, ct); if (truncated) { return (redactedHeaders, LogFormatting.Omitted( @@ -48,7 +50,8 @@ internal static class HttpLogHelper public static async Task<(object Headers, object Body)> CaptureAsync(Dictionary> headers, Func> rawReader, - string? contentType) + string? contentType, + CancellationToken ct = default) { var redactedHeaders = RedactionHelper.RedactHeaders(headers); @@ -101,13 +104,11 @@ public static Dictionary> CreateHeadersDictionary(Ht dict[h.Key] = h.Value; } - var ch = res.Content?.Headers; - if (ch != null) + var ch = res.Content.Headers; + + foreach (var h in ch) { - foreach (var h in ch) - { - dict[h.Key] = h.Value; - } + dict[h.Key] = h.Value; } return dict; @@ -129,7 +130,9 @@ internal static bool IsTextLike(string? mediaType) return null; } - private static async Task<(string text, bool truncated)> ReadLimitedAsync(Stream s, int maxBytes) + private static async Task<(string text, bool truncated)> ReadLimitedAsync(Stream s, + int maxBytes, + CancellationToken ct = default) { s.Seek(0, SeekOrigin.Begin); @@ -140,13 +143,13 @@ internal static bool IsTextLike(string? mediaType) while (total < maxBytes) { var toRead = Math.Min(buf.Length, maxBytes - total); - var read = await s.ReadAsync(buf.AsMemory(0, toRead)); + var read = await s.ReadAsync(buf.AsMemory(0, toRead), ct); if (read == 0) { break; } - await ms.WriteAsync(buf.AsMemory(0, read)); + await ms.WriteAsync(buf.AsMemory(0, read), ct); total += read; } @@ -154,7 +157,7 @@ internal static bool IsTextLike(string? mediaType) if (total == maxBytes) { var probe = new byte[1]; - var read = await s.ReadAsync(probe.AsMemory(0, 1)); + var read = await s.ReadAsync(probe.AsMemory(0, 1), ct); if (read > 0) { truncated = true; diff --git a/src/SharedKernel/Logging/Middleware/OutboundLoggingHandler.cs b/src/SharedKernel/Logging/Middleware/OutboundLoggingHandler.cs index 342a616..2fad126 100644 --- a/src/SharedKernel/Logging/Middleware/OutboundLoggingHandler.cs +++ b/src/SharedKernel/Logging/Middleware/OutboundLoggingHandler.cs @@ -130,12 +130,9 @@ protected override async Task SendAsync(HttpRequestMessage } else { - (reqHeaders, reqBody) = await HttpLogHelper.CaptureAsync( - reqHdrDict, - () => request.Content == null + (reqHeaders, reqBody) = await HttpLogHelper.CaptureAsync(reqHdrDict, () => request.Content == null ? Task.FromResult(string.Empty) - : request.Content.ReadAsStringAsync(cancellationToken), - reqMedia); + : request.Content.ReadAsStringAsync(cancellationToken), reqMedia, cancellationToken); } var response = await base.SendAsync(request, cancellationToken); @@ -166,10 +163,7 @@ protected override async Task SendAsync(HttpRequestMessage } else { - (resHeaders, resBody) = await HttpLogHelper.CaptureAsync( - resHdrDict, - () => response.Content.ReadAsStringAsync(cancellationToken), - resMedia); + (resHeaders, resBody) = await HttpLogHelper.CaptureAsync(resHdrDict, () => response.Content.ReadAsStringAsync(cancellationToken), resMedia, cancellationToken); } var hostPath = request.RequestUri is null ? "" : request.RequestUri.GetLeftPart(UriPartial.Path); diff --git a/src/SharedKernel/Logging/SerilogExtensions.cs b/src/SharedKernel/Logging/SerilogExtensions.cs index 0eba751..3ab6a04 100644 --- a/src/SharedKernel/Logging/SerilogExtensions.cs +++ b/src/SharedKernel/Logging/SerilogExtensions.cs @@ -60,122 +60,122 @@ public static WebApplicationBuilder AddSerilog(this WebApplicationBuilder builde } - private static LoggerConfiguration ConfigureDestinations(this LoggerConfiguration loggerConfig, - WebApplicationBuilder builder, - LogBackend logBackend, - bool asyncSinks) + extension(LoggerConfiguration loggerConfig) { - if (builder.Environment.IsLocal()) + private LoggerConfiguration ConfigureDestinations(WebApplicationBuilder builder, + LogBackend logBackend, + bool asyncSinks) { - loggerConfig.WriteToConsole(asyncSinks); - - return loggerConfig; - } + if (builder.Environment.IsLocal()) + { + loggerConfig.WriteToConsole(asyncSinks); - if (!builder.Environment.IsProduction()) - { - loggerConfig.WriteToConsole(asyncSinks); - } + return loggerConfig; + } - if (logBackend != LogBackend.None) - { - loggerConfig.WriteToFile(builder, logBackend, asyncSinks); - } + if (!builder.Environment.IsProduction()) + { + loggerConfig.WriteToConsole(asyncSinks); + } - return loggerConfig; - } + if (logBackend != LogBackend.None) + { + loggerConfig.WriteToFile(builder, logBackend, asyncSinks); + } - private static LoggerConfiguration WriteToConsole(this LoggerConfiguration loggerConfig, bool useAsync) - { - if (useAsync) - { - loggerConfig.WriteTo.Async(a => a.Console()); + return loggerConfig; } - else + + private LoggerConfiguration WriteToConsole(bool useAsync) { - loggerConfig.WriteTo.Console(); - } + if (useAsync) + { + loggerConfig.WriteTo.Async(a => a.Console()); + } + else + { + loggerConfig.WriteTo.Console(); + } - return loggerConfig; - } + return loggerConfig; + } - private static LoggerConfiguration WriteToFile(this LoggerConfiguration loggerConfig, - WebApplicationBuilder builder, - LogBackend logBackend, - bool useAsync) - { - var ecsConfig = new EcsTextFormatterConfiguration + private LoggerConfiguration WriteToFile(WebApplicationBuilder builder, + LogBackend logBackend, + bool useAsync) { - MessageFormatProvider = CultureInfo.InvariantCulture, - IncludeHost = false, - IncludeProcess = false, - IncludeUser = false, - - MapCustom = (doc, _) => + var ecsConfig = new EcsTextFormatterConfiguration { - doc.Agent = null; + MessageFormatProvider = CultureInfo.InvariantCulture, + IncludeHost = false, + IncludeProcess = false, + IncludeUser = false, - if (doc.Labels is { Count: > 0 }) + MapCustom = (doc, _) => { - doc.Labels.Remove("MessageTemplate"); - } + doc.Agent = null; - if (doc.Event != null) - { - var dur = doc.Event.Duration; - doc.Event = new Event + if (doc.Labels is { Count: > 0 }) { - Duration = dur - }; - } + doc.Labels.Remove("MessageTemplate"); + } - if (doc.Service != null) - { - doc.Service.Type = null; + if (doc.Event != null) + { + var dur = doc.Event.Duration; + doc.Event = new Event + { + Duration = dur + }; + } + + if (doc.Service != null) + { + doc.Service.Type = null; + } + + return doc; } + }; + ITextFormatter formatter = logBackend switch + { + LogBackend.ElasticSearch => new EcsTextFormatter(ecsConfig), + LogBackend.Loki => new LokiJsonTextFormatter(), + LogBackend.CompactJson => new CompactJsonFormatter(), + _ => new CompactJsonFormatter() // Fallback + }; - return doc; - } - }; - ITextFormatter formatter = logBackend switch - { - LogBackend.ElasticSearch => new EcsTextFormatter(ecsConfig), - LogBackend.Loki => new LokiJsonTextFormatter(), - LogBackend.CompactJson => new CompactJsonFormatter(), - _ => new CompactJsonFormatter() // Fallback - }; + var logPath = builder.GetLogsPath(); - var logPath = builder.GetLogsPath(); + if (useAsync) + { + return loggerConfig.WriteTo.Async(a => + a.File(formatter, + logPath, + rollingInterval: RollingInterval.Day), + blockWhenFull: true, + bufferSize: 10_000); + } - if (useAsync) - { - return loggerConfig.WriteTo.Async(a => - a.File(formatter, - logPath, - rollingInterval: RollingInterval.Day), - blockWhenFull: true, - bufferSize: 10_000); + return loggerConfig.WriteTo.File(formatter, logPath, rollingInterval: RollingInterval.Day); } - return loggerConfig.WriteTo.File(formatter, logPath, rollingInterval: RollingInterval.Day); - } - - private static LoggerConfiguration FilterOutUnwantedLogs(this LoggerConfiguration loggerConfig, - bool suppressAspNetExceptionHandler) - { - var filteredConfig = loggerConfig - .Filter - .ByExcluding(IsEfOutboxQuery) - .Filter - .ByExcluding(ShouldDropByPath); - if (suppressAspNetExceptionHandler) + private LoggerConfiguration FilterOutUnwantedLogs(bool suppressAspNetExceptionHandler) { - filteredConfig = filteredConfig - .Filter - .ByExcluding(ShouldDropExceptionHandlerLogs); - } + var filteredConfig = loggerConfig + .Filter + .ByExcluding(IsEfOutboxQuery) + .Filter + .ByExcluding(ShouldDropByPath); + if (suppressAspNetExceptionHandler) + { + filteredConfig = filteredConfig + .Filter + .ByExcluding(ShouldDropExceptionHandlerLogs); + } - return filteredConfig; + return filteredConfig; + } } private static bool ShouldDropExceptionHandlerLogs(LogEvent evt) diff --git a/src/SharedKernel/Maintenance/MaintenanceMiddleware.cs b/src/SharedKernel/Maintenance/MaintenanceMiddleware.cs index 147a2aa..7e22470 100644 --- a/src/SharedKernel/Maintenance/MaintenanceMiddleware.cs +++ b/src/SharedKernel/Maintenance/MaintenanceMiddleware.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; +using System.Threading; namespace SharedKernel.Maintenance; diff --git a/src/SharedKernel/OpenApi/EnumSchemaTransformer.cs b/src/SharedKernel/OpenApi/EnumSchemaTransformer.cs index 1b8ad4b..2a3848e 100644 --- a/src/SharedKernel/OpenApi/EnumSchemaTransformer.cs +++ b/src/SharedKernel/OpenApi/EnumSchemaTransformer.cs @@ -1,5 +1,5 @@ using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace SharedKernel.OpenApi; diff --git a/src/SharedKernel/OpenApi/OpenApiOptionsExtensions.cs b/src/SharedKernel/OpenApi/OpenApiOptionsExtensions.cs index 69155ee..393ba6b 100644 --- a/src/SharedKernel/OpenApi/OpenApiOptionsExtensions.cs +++ b/src/SharedKernel/OpenApi/OpenApiOptionsExtensions.cs @@ -1,55 +1,59 @@ using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using SharedKernel.OpenApi.Options; namespace SharedKernel.OpenApi; internal static class OpenApiOptionsExtensions { - internal static OpenApiOptions AddDocument(this OpenApiOptions options, - Document doc, - OpenApiConfig openApiConfigConfiguration) + extension(OpenApiOptions options) { - options.AddDocumentTransformer((document, _, _) => + internal OpenApiOptions AddDocument(Document doc, OpenApiConfig openApiConfig) { - document.Info = new OpenApiInfo + options.AddDocumentTransformer((document, _, _) => { - Title = doc.Title, - Description = doc.Description, - Version = doc.Version, - Contact = new OpenApiContact + document.Info = new OpenApiInfo { - Name = openApiConfigConfiguration.Contact.Name, - Url = new Uri(openApiConfigConfiguration.Contact.Url), - Email = openApiConfigConfiguration.Contact.Email - } - }; - return Task.CompletedTask; - }); + Title = doc.Title, + Description = doc.Description, + Version = doc.Version, + Contact = new OpenApiContact + { + Name = openApiConfig.Contact.Name, + Url = new Uri(openApiConfig.Contact.Url), + Email = openApiConfig.Contact.Email + } + }; - return options; - } + return Task.CompletedTask; + }); - internal static OpenApiOptions UseApiSecuritySchemes(this OpenApiOptions options, OpenApiConfig? configurations) - { - if (configurations is null) - { return options; } - foreach (var scheme in configurations.SecuritySchemes) + internal OpenApiOptions UseApiSecuritySchemes(OpenApiConfig? config) { - var securityScheme = new OpenApiSecurityScheme + if (config?.SecuritySchemes is not { Count: > 0 }) { - Description = scheme.Description, - Name = scheme.HeaderName, - In = ParameterLocation.Header - }; + return options; + } options.AddDocumentTransformer((document, _, _) => { document.Components ??= new OpenApiComponents(); - document.Components.SecuritySchemes.Add(scheme.HeaderName, securityScheme); + document.Components.SecuritySchemes ??= new Dictionary(StringComparer.Ordinal); + + foreach (var scheme in config.SecuritySchemes) + { + document.Components.SecuritySchemes[scheme.HeaderName] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Header, + Name = scheme.HeaderName, + Description = scheme.Description + }; + } + return Task.CompletedTask; }); @@ -57,26 +61,18 @@ internal static OpenApiOptions UseApiSecuritySchemes(this OpenApiOptions options { operation.Security ??= new List(); - var securityRequirement = new OpenApiSecurityRequirement + foreach (var scheme in config.SecuritySchemes) { + operation.Security.Add(new OpenApiSecurityRequirement { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = scheme.HeaderName - } - }, - Array.Empty() - } - }; + [new OpenApiSecuritySchemeReference(scheme.HeaderName)] = [] + }); + } - operation.Security.Add(securityRequirement); return Task.CompletedTask; }); - } - return options; + return options; + } } -} \ No newline at end of file +} diff --git a/src/SharedKernel/OpenApi/RemoveServersTransformer.cs b/src/SharedKernel/OpenApi/RemoveServersTransformer.cs index 40a53e8..bf0bc77 100644 --- a/src/SharedKernel/OpenApi/RemoveServersTransformer.cs +++ b/src/SharedKernel/OpenApi/RemoveServersTransformer.cs @@ -1,5 +1,5 @@ using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace SharedKernel.OpenApi; @@ -9,7 +9,7 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) { - document.Servers.Clear(); + document.Servers?.Clear(); return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/SharedKernel/OpenApi/TagOrderingTransformer.cs b/src/SharedKernel/OpenApi/TagOrderingTransformer.cs index acb4dbd..f26f9ef 100644 --- a/src/SharedKernel/OpenApi/TagOrderingTransformer.cs +++ b/src/SharedKernel/OpenApi/TagOrderingTransformer.cs @@ -1,5 +1,5 @@ using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace SharedKernel.OpenApi; @@ -9,41 +9,77 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) { - var tags = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var path in document.Paths.Values) + var tagsByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (document.Tags is not null) { - foreach (var op in path.Operations.Values) + foreach (var t in document.Tags) { - foreach (var t in op.Tags ?? []) + if (!string.IsNullOrWhiteSpace(t.Name)) { - tags.Add(t.Name); + tagsByName[t.Name] = t; // keep existing metadata if present } } } - var ordered = tags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase) - .ToList(); + foreach (var path in document.Paths.Values) + { + var operations = path.Operations; + if (operations is null) + { + continue; + } - document.Tags = ordered.Select(t => new OpenApiTag - { - Name = t - }) - .ToList(); + foreach (var name in from opTags in operations.Values + .Select(op => op.Tags) + .OfType>() + from tr in opTags + select tr.Name + into name + where !string.IsNullOrWhiteSpace(name) + where !tagsByName.ContainsKey(name) + select name) + { + tagsByName[name] = new OpenApiTag + { + Name = name + }; + } + } - var index = document.Tags - .Select((t, i) => (t.Name, i)) - .ToDictionary(x => x.Name, x => x.i, StringComparer.OrdinalIgnoreCase); + var orderedNames = tagsByName.Keys + .OrderBy(static x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); - foreach (var path in document.Paths.Values) + var index = orderedNames + .Select((name, i) => (name, i)) + .ToDictionary(x => x.name, x => x.i, StringComparer.OrdinalIgnoreCase); + + document.Tags ??= new HashSet(); + document.Tags.Clear(); + + foreach (var name in orderedNames) { - foreach (var op in path.Operations.Values) + document.Tags.Add(tagsByName[name]); + } + + foreach (var opTags in document.Paths + .Values + .Select(path => path.Operations) + .OfType>() + .SelectMany(operations => operations.Values + .Select(op => op.Tags) + .OfType>() + .Where(opTags => opTags.Count > 1))) + { + var ordered = opTags + .OrderBy(t => index.TryGetValue(t.Name ?? "", out var i) ? i : int.MaxValue) + .ToList(); + + opTags.Clear(); + foreach (var t in ordered) { - if (op.Tags is { Count: > 1 }) - { - op.Tags = op.Tags - .OrderBy(t => index[t.Name]) - .ToList(); - } + opTags.Add(t); } } diff --git a/src/SharedKernel/SharedKernel.csproj b/src/SharedKernel/SharedKernel.csproj index c7340aa..93c4a88 100644 --- a/src/SharedKernel/SharedKernel.csproj +++ b/src/SharedKernel/SharedKernel.csproj @@ -1,64 +1,81 @@  - net9.0 + net10.0 enable enable pandatech.png Readme.md Pandatech MIT - 1.9.0 + 2.0.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 - New json covnerters added to reduce code reduncancy across projects. + dotnet 10 upgrade + + + + false + + + true + + + false + - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/CreditCardNumberValidator.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/CreditCardNumberValidator.cs new file mode 100644 index 0000000..bfa743b --- /dev/null +++ b/src/SharedKernel/ValidatorAndMediatR/Validators/CreditCardNumberValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; +using FluentValidation.Validators; +using SharedKernel.Helpers; + +namespace SharedKernel.ValidatorAndMediatR.Validators; + +public sealed class CreditCardNumberValidator : PropertyValidator +{ + public override string Name => "CreditCardNumberValidator"; + + public override bool IsValid(ValidationContext context, string? value) + { + if (value is null) + { + return true; + } + + if (ValidationHelper.IsCreditCardNumber(value)) + { + return true; + } + + context.AddFailure("credit_card_number_format_is_not_valid"); + return false; + } + + protected override string GetDefaultMessageTemplate(string errorCode) + { + return "credit_card_number_format_is_not_valid"; + } +} \ No newline at end of file diff --git a/src/SharedKernel/ValidatorAndMediatR/Validators/ValidatorExtensions.cs b/src/SharedKernel/ValidatorAndMediatR/Validators/ValidatorExtensions.cs index b4c143f..ca63be4 100644 --- a/src/SharedKernel/ValidatorAndMediatR/Validators/ValidatorExtensions.cs +++ b/src/SharedKernel/ValidatorAndMediatR/Validators/ValidatorExtensions.cs @@ -8,79 +8,89 @@ namespace SharedKernel.ValidatorAndMediatR.Validators; public static class ValidatorExtensions { - public static IRuleBuilderOptions IsEmail(this IRuleBuilder ruleBuilder) + extension(IRuleBuilder ruleBuilder) { - return ruleBuilder.Must(x => x is null || ValidationHelper.IsEmail(x)) - .WithMessage("email_format_is_not_valid"); - } + public IRuleBuilderOptions IsEmail() + { + return ruleBuilder.Must(x => x is null || ValidationHelper.IsEmail(x)) + .WithMessage("email_format_is_not_valid"); + } - public static IRuleBuilderOptions IsPhoneNumber(this IRuleBuilder ruleBuilder) - { - return ruleBuilder.Must(x => x is null || ValidationHelper.IsPandaFormattedPhoneNumber(x)) - .WithMessage("phone_number_format_is_not_valid"); - } + public IRuleBuilderOptions IsPhoneNumber() + { + return ruleBuilder.Must(x => x is null || ValidationHelper.IsPandaFormattedPhoneNumber(x)) + .WithMessage("phone_number_format_is_not_valid"); + } - public static IRuleBuilderOptions IsEmailOrPhoneNumber(this IRuleBuilder ruleBuilder) - { - return ruleBuilder - .Must(x => x is null || ValidationHelper.IsPandaFormattedPhoneNumber(x) || ValidationHelper.IsEmail(x)) - .WithMessage("phone_number_or_email_format_is_not_valid"); - } + public IRuleBuilderOptions IsEmailOrPhoneNumber() + { + return ruleBuilder + .Must(x => x is null || ValidationHelper.IsPandaFormattedPhoneNumber(x) || ValidationHelper.IsEmail(x)) + .WithMessage("phone_number_or_email_format_is_not_valid"); + } - public static IRuleBuilderOptions IsPhoneNumberOrEmail(this IRuleBuilder ruleBuilder) - { - return ruleBuilder.IsEmailOrPhoneNumber(); - } + public IRuleBuilderOptions IsPhoneNumberOrEmail() + { + return ruleBuilder.IsEmailOrPhoneNumber(); + } - public static IRuleBuilderOptions IsValidJson(this IRuleBuilder ruleBuilder) - { - return ruleBuilder.SetValidator(new JsonValidator()); + public IRuleBuilderOptions IsCreditCardNumber() + { + return ruleBuilder.SetValidator(new CreditCardNumberValidator()); + } } - public static IRuleBuilderOptions IsXssSanitized(this IRuleBuilder ruleBuilder) + extension(IRuleBuilder ruleBuilder) { - return ruleBuilder.SetValidator(new XssSanitizationValidator()); + public IRuleBuilderOptions IsValidJson() + { + return ruleBuilder.SetValidator(new JsonValidator()); + } + + public IRuleBuilderOptions IsXssSanitized() + { + return ruleBuilder.SetValidator(new XssSanitizationValidator()); + } } // Single file - public static IRuleBuilderOptions HasMaxSizeMb(this IRuleBuilder rb, int maxMb) + extension(IRuleBuilder rb) { - return rb.SetValidator(new FileMaxSizeMbValidator(maxMb)); - } + public IRuleBuilderOptions HasMaxSizeMb(int maxMb) + { + return rb.SetValidator(new FileMaxSizeMbValidator(maxMb)); + } - public static IRuleBuilderOptions ExtensionIn(this IRuleBuilder rb, - params string[] allowedExts) - { - return rb.SetValidator(new FileExtensionValidator(allowedExts)); + public IRuleBuilderOptions ExtensionIn(params string[] allowedExts) + { + return rb.SetValidator(new FileExtensionValidator(allowedExts)); + } } // Collection files - public static IRuleBuilderOptions EachHasMaxSizeMb( - this IRuleBuilder rb, - int maxMb) + extension(IRuleBuilder rb) { - return rb.SetValidator(new FilesEachMaxSizeMbValidator(maxMb)); - } + public IRuleBuilderOptions EachHasMaxSizeMb(int maxMb) + { + return rb.SetValidator(new FilesEachMaxSizeMbValidator(maxMb)); + } - public static IRuleBuilderOptions EachExtensionIn( - this IRuleBuilder rb, - params string[] allowedExts) - { - return rb.SetValidator(new FilesEachExtensionValidator(allowedExts)); - } + public IRuleBuilderOptions EachExtensionIn(params string[] allowedExts) + { + return rb.SetValidator(new FilesEachExtensionValidator(allowedExts)); + } - public static IRuleBuilderOptions TotalSizeMaxMb( - this IRuleBuilder rb, - int maxMb) - { - return rb.SetValidator(new FilesTotalMaxSizeMbValidator(maxMb)); - } + public IRuleBuilderOptions TotalSizeMaxMb(int maxMb) + { + return rb.SetValidator(new FilesTotalMaxSizeMbValidator(maxMb)); + } - public static IRuleBuilderOptions MaxCount(this IRuleBuilder rb, - int maxCount) - { - return rb.SetValidator(new FilesMaxCountValidator(maxCount)); + public IRuleBuilderOptions MaxCount(int maxCount) + { + return rb.SetValidator(new FilesMaxCountValidator(maxCount)); + } } + public static IRuleBuilderOptions ValidateCommissionRule(this IRuleBuilder rule) { return rule.SetValidator(new CommissionRuleValidator()); diff --git a/test/SharedKernel.Tests/SharedKernel.Tests.csproj b/test/SharedKernel.Tests/SharedKernel.Tests.csproj index 0218235..c469aaf 100644 --- a/test/SharedKernel.Tests/SharedKernel.Tests.csproj +++ b/test/SharedKernel.Tests/SharedKernel.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable @@ -10,7 +10,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/SharedKernel.Tests/TimeZoneMappingTests.cs b/test/SharedKernel.Tests/TimeZoneMappingTests.cs new file mode 100644 index 0000000..a850dd3 --- /dev/null +++ b/test/SharedKernel.Tests/TimeZoneMappingTests.cs @@ -0,0 +1,41 @@ +namespace SharedKernel.Tests; + +public sealed class TimeZoneMappingTests +{ + [Fact] + public void Caucasus_and_AsiaYerevan_should_produce_same_local_time_for_same_utc_instants() + { + var caucasus = GetTimeZone("Caucasus Standard Time"); + var yerevan = GetTimeZone("Asia/Yerevan"); + + var instantsUtc = new[] + { + new DateTime(2024, 01, 15, 00, 00, 00, DateTimeKind.Utc), + new DateTime(2024, 07, 15, 12, 34, 56, DateTimeKind.Utc), + new DateTime(2025, 12, 18, 08, 00, 00, DateTimeKind.Utc), + }; + + foreach (var utc in instantsUtc) + { + var local1 = TimeZoneInfo.ConvertTimeFromUtc(utc, caucasus); + var local2 = TimeZoneInfo.ConvertTimeFromUtc(utc, yerevan); + + Assert.Equal(local1, local2); + Assert.Equal(caucasus.GetUtcOffset(utc), yerevan.GetUtcOffset(utc)); + } + } + + private static TimeZoneInfo GetTimeZone(string id) + { + if (TimeZoneInfo.TryFindSystemTimeZoneById(id, out var tz) || + TimeZoneInfo.TryConvertIanaIdToWindowsId(id, out var winId) && + TimeZoneInfo.TryFindSystemTimeZoneById(winId, out tz) || + TimeZoneInfo.TryConvertWindowsIdToIanaId(id, out var ianaId) && + TimeZoneInfo.TryFindSystemTimeZoneById(ianaId, out tz)) + { + return tz; + } + + throw new TimeZoneNotFoundException($"Could not resolve time zone id '{id}'."); + } +} \ No newline at end of file diff --git a/test/SharedKernel.Tests/ValidationHelperTests.cs b/test/SharedKernel.Tests/ValidationHelperTests.cs index f2a5275..4251b02 100644 --- a/test/SharedKernel.Tests/ValidationHelperTests.cs +++ b/test/SharedKernel.Tests/ValidationHelperTests.cs @@ -66,27 +66,28 @@ public void IsUsSocialSecurityNumberTests(string number, bool expected) Assert.Equal(expected, isValid); } - // [Theory] - // [InlineData("4111111111111111", true)] // Visa - // [InlineData("5555555555554444", true)] // MasterCard - // [InlineData("378282246310005", true)] // American Express - // [InlineData("6011111111111117", true)] // Discover - // [InlineData("3530111333300000", true)] // JCB - // [InlineData("411111111111111", false)] // Shortened Visa number - // [InlineData("55555555555544444", false)] // Extended MasterCard number - // [InlineData("37828224631005", false)] // Shortened American Express - // [InlineData("601111111111111", false)] // Shortened Discover number - // [InlineData("353011133330000", false)] // Shortened JCB number - // [InlineData("1234567890123456", false)] // Doesn't follow Luhn algorithm - // [InlineData("abcdefabcdefabc", false)] // Contains non-numeric characters - // [InlineData("4111-1111-1111-1111", false)] // Contains hyphens - // [InlineData("4111 1111 1111 1111", false)] // Contains spaces - // public void IsCreditCardNumberTests(string number, bool expected) - // { - // var isValid = ValidationHelper.IsCreditCardNumber(number); - // Assert.Equal(expected, isValid); - // } - + [Theory] + [InlineData("4111111111111111", true)] // Visa + [InlineData("5555555555554444", true)] // MasterCard + [InlineData("378282246310005", true)] // American Express + [InlineData("6011111111111117", true)] // Discover + [InlineData("3530111333300000", true)] // JCB + [InlineData("411111111111111", false)] // too short + [InlineData("55555555555544444", false)] // too long + [InlineData("37828224631005", false)] // too short + [InlineData("601111111111111", false)] // too short + [InlineData("353011133330000", false)] // too short + [InlineData("1234567890123456", false)] // fails Luhn + [InlineData("abcdefabcdefabc", false)] // non-numeric + [InlineData("4111-1111-1111-1111", false)] // separators not allowed + [InlineData("4111 1111 1111 1111", false)] // separators not allowed + [InlineData("4578900000014055", true)] + [InlineData("4578906000014055", false)] + + public void IsCreditCardNumberTests(string number, bool expected) + { + Assert.Equal(expected, ValidationHelper.IsCreditCardNumber(number)); + } [Theory] [InlineData("http://google.com", true)]