From 8f49f7eaa39246ff5fb24b83d508f796fc147a06 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Tue, 18 Nov 2025 14:18:26 +0100 Subject: [PATCH 01/54] chore: migrate solution to .slnx format --- Atc.Rest.MinimalApi.sln | 94 ---------------------------------------- Atc.Rest.MinimalApi.slnx | 22 ++++++++++ 2 files changed, 22 insertions(+), 94 deletions(-) delete mode 100644 Atc.Rest.MinimalApi.sln create mode 100644 Atc.Rest.MinimalApi.slnx diff --git a/Atc.Rest.MinimalApi.sln b/Atc.Rest.MinimalApi.sln deleted file mode 100644 index f090183..0000000 --- a/Atc.Rest.MinimalApi.sln +++ /dev/null @@ -1,94 +0,0 @@ -๏ปฟ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 15.0.26124.0 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{69C84246-AA75-43E8-94B2-66FD555B18B0}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{DAC1423F-D386-4838-AEA0-8BFFBB449ED2}" - ProjectSection(SolutionItems) = preProject - README.md = README.md - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Atc.Rest.MinimalApi", "src\Atc.Rest.MinimalApi\Atc.Rest.MinimalApi.csproj", "{48083BEB-5AB3-432F-B2E5-059D3FB4E620}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{58BC6728-197C-484C-A8EC-38BC2F0B58C4}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Atc.Rest.MinimalApi.Tests", "test\Atc.Rest.MinimalApi.Tests\Atc.Rest.MinimalApi.Tests.csproj", "{7280EDF9-17CB-478F-BE69-4906C84E04D5}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{F4A6EF5F-063B-476C-ADFB-F4A459A76074}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EAF16541-8350-4047-B661-AF8E6E05E1E6}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{7A940B2C-5FEC-406F-9309-63103E0AA88C}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo.Api", "sample\src\Demo.Api\Demo.Api.csproj", "{79B9B626-FAB4-4867-9747-7AEC65F10960}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo.Api.Contracts", "sample\src\Demo.Api.Contracts\Demo.Api.Contracts.csproj", "{E4C67482-08F0-48FC-A89F-EB348A069579}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo.Domain", "sample\src\Demo.Domain\Demo.Domain.csproj", "{5398791E-2B23-42EA-AB34-83D32867A870}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo.Api.Contracts.Tests", "sample\test\Demo.Api.Contracts.Tests\Demo.Api.Contracts.Tests.csproj", "{5F5D4952-D66C-413B-970A-3086DF94C239}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo.Api.IntegrationTests", "sample\test\Demo.Api.IntegrationTests\Demo.Api.IntegrationTests.csproj", "{9232F7EA-1B04-4BAB-8CF4-C1454D9436CA}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo.Domain.Tests", "sample\test\Demo.Domain.Tests\Demo.Domain.Tests.csproj", "{3E74A657-BC60-4432-97A7-B0D33D36C62B}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {48083BEB-5AB3-432F-B2E5-059D3FB4E620}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {48083BEB-5AB3-432F-B2E5-059D3FB4E620}.Debug|Any CPU.Build.0 = Debug|Any CPU - {48083BEB-5AB3-432F-B2E5-059D3FB4E620}.Release|Any CPU.ActiveCfg = Release|Any CPU - {48083BEB-5AB3-432F-B2E5-059D3FB4E620}.Release|Any CPU.Build.0 = Release|Any CPU - {7280EDF9-17CB-478F-BE69-4906C84E04D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7280EDF9-17CB-478F-BE69-4906C84E04D5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7280EDF9-17CB-478F-BE69-4906C84E04D5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7280EDF9-17CB-478F-BE69-4906C84E04D5}.Release|Any CPU.Build.0 = Release|Any CPU - {79B9B626-FAB4-4867-9747-7AEC65F10960}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {79B9B626-FAB4-4867-9747-7AEC65F10960}.Debug|Any CPU.Build.0 = Debug|Any CPU - {79B9B626-FAB4-4867-9747-7AEC65F10960}.Release|Any CPU.ActiveCfg = Release|Any CPU - {79B9B626-FAB4-4867-9747-7AEC65F10960}.Release|Any CPU.Build.0 = Release|Any CPU - {E4C67482-08F0-48FC-A89F-EB348A069579}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E4C67482-08F0-48FC-A89F-EB348A069579}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E4C67482-08F0-48FC-A89F-EB348A069579}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E4C67482-08F0-48FC-A89F-EB348A069579}.Release|Any CPU.Build.0 = Release|Any CPU - {5398791E-2B23-42EA-AB34-83D32867A870}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5398791E-2B23-42EA-AB34-83D32867A870}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5398791E-2B23-42EA-AB34-83D32867A870}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5398791E-2B23-42EA-AB34-83D32867A870}.Release|Any CPU.Build.0 = Release|Any CPU - {5F5D4952-D66C-413B-970A-3086DF94C239}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5F5D4952-D66C-413B-970A-3086DF94C239}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5F5D4952-D66C-413B-970A-3086DF94C239}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5F5D4952-D66C-413B-970A-3086DF94C239}.Release|Any CPU.Build.0 = Release|Any CPU - {9232F7EA-1B04-4BAB-8CF4-C1454D9436CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9232F7EA-1B04-4BAB-8CF4-C1454D9436CA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9232F7EA-1B04-4BAB-8CF4-C1454D9436CA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9232F7EA-1B04-4BAB-8CF4-C1454D9436CA}.Release|Any CPU.Build.0 = Release|Any CPU - {3E74A657-BC60-4432-97A7-B0D33D36C62B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3E74A657-BC60-4432-97A7-B0D33D36C62B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3E74A657-BC60-4432-97A7-B0D33D36C62B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3E74A657-BC60-4432-97A7-B0D33D36C62B}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {48083BEB-5AB3-432F-B2E5-059D3FB4E620} = {69C84246-AA75-43E8-94B2-66FD555B18B0} - {7280EDF9-17CB-478F-BE69-4906C84E04D5} = {58BC6728-197C-484C-A8EC-38BC2F0B58C4} - {EAF16541-8350-4047-B661-AF8E6E05E1E6} = {F4A6EF5F-063B-476C-ADFB-F4A459A76074} - {7A940B2C-5FEC-406F-9309-63103E0AA88C} = {F4A6EF5F-063B-476C-ADFB-F4A459A76074} - {79B9B626-FAB4-4867-9747-7AEC65F10960} = {EAF16541-8350-4047-B661-AF8E6E05E1E6} - {E4C67482-08F0-48FC-A89F-EB348A069579} = {EAF16541-8350-4047-B661-AF8E6E05E1E6} - {5398791E-2B23-42EA-AB34-83D32867A870} = {EAF16541-8350-4047-B661-AF8E6E05E1E6} - {5F5D4952-D66C-413B-970A-3086DF94C239} = {7A940B2C-5FEC-406F-9309-63103E0AA88C} - {9232F7EA-1B04-4BAB-8CF4-C1454D9436CA} = {7A940B2C-5FEC-406F-9309-63103E0AA88C} - {3E74A657-BC60-4432-97A7-B0D33D36C62B} = {7A940B2C-5FEC-406F-9309-63103E0AA88C} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {04120463-05C5-417B-8D7A-2D7D35B71A07} - EndGlobalSection -EndGlobal diff --git a/Atc.Rest.MinimalApi.slnx b/Atc.Rest.MinimalApi.slnx new file mode 100644 index 0000000..cb23f63 --- /dev/null +++ b/Atc.Rest.MinimalApi.slnx @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + From 31c4c7f81a35bfeca3167117c1c471cc561b384b Mon Sep 17 00:00:00 2001 From: Per Kops Date: Tue, 18 Nov 2025 14:19:23 +0100 Subject: [PATCH 02/54] chore(deps): upgrade nuget packages, analyzers, coding rules and enable Atc.Analyzer --- .editorconfig | 13 +++++++++++++ Directory.Build.props | 3 ++- src/Directory.Build.props | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.editorconfig b/.editorconfig index 2e75fd2..39a809b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -464,11 +464,13 @@ dotnet_diagnostic.MA0048.severity = error # https://github.com/atc-net dotnet_diagnostic.CA1014.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA1014.md dotnet_diagnostic.CA1068.severity = error # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA1068.md dotnet_diagnostic.CA1305.severity = error +dotnet_diagnostic.CA1308.severity = suggestion # Normalize strings to uppercase dotnet_diagnostic.CA1510.severity = suggestion # Use ArgumentNullException throw helper dotnet_diagnostic.CA1511.severity = suggestion # Use ArgumentException throw helper dotnet_diagnostic.CA1512.severity = suggestion # Use ArgumentOutOfRangeException throw helper dotnet_diagnostic.CA1513.severity = suggestion # Use ObjectDisposedException throw helper dotnet_diagnostic.CA1514.severity = error # Avoid redundant length argument +dotnet_diagnostic.CA1515.severity = suggestion # Because an application's API isn't typically referenced from outside the assembly, types can be made internal (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1515) dotnet_diagnostic.CA1707.severity = error # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA1707.md dotnet_diagnostic.CA1812.severity = none dotnet_diagnostic.CA1822.severity = suggestion @@ -504,7 +506,16 @@ dotnet_diagnostic.CA2259.severity = error # Ensure ThreadStatic is onl dotnet_diagnostic.CA2260.severity = error # Implement generic math interfaces correctly dotnet_diagnostic.CA2261.severity = error # Do not use ConfigureAwaitOptions.SuppressThrowing with Task dotnet_diagnostic.IDE0005.severity = warning # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/IDE0005.md +dotnet_diagnostic.IDE0010.severity = suggestion # Populate switch +dotnet_diagnostic.IDE0028.severity = suggestion # Collection initialization can be simplified +dotnet_diagnostic.IDE0021.severity = suggestion # Use expression body for constructor +dotnet_diagnostic.IDE0055.severity = none # Fix formatting dotnet_diagnostic.IDE0058.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/IDE0058.md +dotnet_diagnostic.IDE0061.severity = suggestion # Use expression body for local function +dotnet_diagnostic.IDE0130.severity = suggestion # Namespace does not match folder structure +dotnet_diagnostic.IDE0290.severity = none # Use primary constructor +dotnet_diagnostic.IDE0301.severity = suggestion # Use collection expression for empty +dotnet_diagnostic.IDE0305.severity = suggestion # Collection initialization can be simplified # Microsoft - Compiler Errors @@ -519,6 +530,7 @@ dotnet_diagnostic.CS4014.severity = error # https://github.com/atc-net/ # StyleCop # https://github.com/DotNetAnalyzers/StyleCopAnalyzers dotnet_diagnostic.SA1009.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1009.md +dotnet_diagnostic.SA1010.severity = none # False positive when using collection initializers dotnet_diagnostic.SA1101.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1101.md dotnet_diagnostic.SA1122.severity = error # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1122.md dotnet_diagnostic.SA1133.severity = error # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1133.md @@ -541,6 +553,7 @@ dotnet_diagnostic.SA1649.severity = error # https://github.com/atc-net # https://rules.sonarsource.com/csharp dotnet_diagnostic.S1135.severity = suggestion # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/SonarAnalyzerCSharp/S1135.md dotnet_diagnostic.S2629.severity = none # Don't use string interpolation in logging message templates. +dotnet_diagnostic.S3358.severity = none # Extract this nested ternary operation into an independent statement. dotnet_diagnostic.S6602.severity = none # "Find" method should be used instead of the "FirstOrDefault" dotnet_diagnostic.S6603.severity = none # The collection-specific "TrueForAll" method should be used instead of the "All" dotnet_diagnostic.S6605.severity = none # Collection-specific "Exists" method should be used instead of the "Any" diff --git a/Directory.Build.props b/Directory.Build.props index a0b1760..251850e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -45,9 +45,10 @@ + - + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 96a2d6a..bc52816 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -54,7 +54,7 @@ - + \ No newline at end of file From c5ec5e4e1641d9ac38ec9a087276525b525c279b Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 20 Nov 2025 07:29:01 +0100 Subject: [PATCH 03/54] ci: upgrade pipelines --- .github/workflows/post-integration.yml | 25 +++---------------------- .github/workflows/pre-integration.yml | 12 ++++++------ .github/workflows/release.yml | 6 +++--- 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/.github/workflows/post-integration.yml b/.github/workflows/post-integration.yml index 0a0b1bb..f0115f4 100644 --- a/.github/workflows/post-integration.yml +++ b/.github/workflows/post-integration.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: ๐Ÿ›’ Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 token: ${{ secrets.PAT_WORKFLOWS }} @@ -30,16 +30,10 @@ jobs: with: setAllVars: true - - name: โš™๏ธ Setup dotnet 8.0.x + - name: โš™๏ธ Setup dotnet 10.0.x uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' - - - name: โš™๏ธ Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: 17 - distribution: 'zulu' + dotnet-version: '10.0.x' - name: ๐Ÿงน Clean run: dotnet clean -c Release && dotnet nuget locals all --clear @@ -53,19 +47,6 @@ jobs: - name: ๐Ÿงช Run unit tests run: dotnet test -c Release --no-build --filter "Category!=Integration" - - name: ๐ŸŒฉ๏ธ SonarCloud install scanner - run: dotnet tool install --global dotnet-sonarscanner - - - name: ๐ŸŒฉ๏ธ SonarCloud analyze - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - shell: pwsh - run: | - dotnet sonarscanner begin /k:"atc-net_atc-rest-minimalapi" /o:"atc-net" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" - dotnet build -c Release /p:UseSourceLink=true --no-restore - dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" - - name: โฉ Merge to stable-branch run: | git config --local user.email ${{ env.ATC_EMAIL }} diff --git a/.github/workflows/pre-integration.yml b/.github/workflows/pre-integration.yml index 2536549..115283d 100644 --- a/.github/workflows/pre-integration.yml +++ b/.github/workflows/pre-integration.yml @@ -15,14 +15,14 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: ๐Ÿ›’ Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - - name: โš™๏ธ Setup dotnet 8.0.x + - name: โš™๏ธ Setup dotnet 10.0.x uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' - name: ๐Ÿงน Clean run: dotnet clean -c Release && dotnet nuget locals all --clear @@ -39,14 +39,14 @@ jobs: - dotnet-build steps: - name: ๐Ÿ›’ Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - - name: โš™๏ธ Setup dotnet 8.0.x + - name: โš™๏ธ Setup dotnet 10.0.x uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' - name: ๐Ÿ” Restore packages run: dotnet restore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1caaa60..7c17e07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: ๐Ÿ›’ Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 token: ${{ secrets.PAT_WORKFLOWS }} @@ -27,10 +27,10 @@ jobs: with: setAllVars: true - - name: โš™๏ธ Setup dotnet 8.0.x + - name: โš™๏ธ Setup dotnet 10.0.x uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' - name: ๐Ÿงน Clean run: dotnet clean -c Release && dotnet nuget locals all --clear From 3763d8afadf9e1e9edca999021cc3a9d2705ae95 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 20 Nov 2025 07:31:20 +0100 Subject: [PATCH 04/54] chore: update LangVersion to 14.0 and default TargetFramework to net10 --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 251850e..73f7b0a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,9 +16,9 @@ enable - 12.0 + 14.0 enable - net8.0 + net10.0 true 1573,1591,1712,CA1014,NU5104 From 674494c173071e6791c523373a68c13dd459564e Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 3 Dec 2025 11:52:40 +0100 Subject: [PATCH 05/54] style: consolidate method signatures and adopt primary constructors --- .../Extensions/WebApplicationBuilderExtensions.cs | 3 +-- .../Demo.Api/Extensions/WebApplicationExtensions.cs | 3 +-- .../src/Demo.Api/Options/ConfigureSwaggerOptions.cs | 6 ++---- .../Extensions/ApplicationBuilderExtensions.cs | 3 +-- .../Extensions/ServiceCollectionExtensions.cs | 3 +-- .../Demo.Domain/Users/Handlers/CreateUserHandler.cs | 10 +--------- .../Endpoints/UsersEndpointDefinitionTests.cs | 11 +++-------- .../Abstractions/IEndpointAndServiceDefinition.cs | 3 +-- .../Abstractions/IEndpointDefinition.cs | 3 +-- .../Extensions/EndpointDefinitionExtensions.cs | 3 +-- .../Extensions/HttpContextExtensions.cs | 6 ++---- .../Extensions/ValidationProblemExtensions.cs | 3 +-- .../Filters/Endpoints/ValidationFilter.cs | 4 +--- .../Middleware/GlobalErrorHandlingMiddleware.cs | 6 ++---- 14 files changed, 19 insertions(+), 48 deletions(-) diff --git a/sample/src/Demo.Api/Extensions/WebApplicationBuilderExtensions.cs b/sample/src/Demo.Api/Extensions/WebApplicationBuilderExtensions.cs index e9395b8..3c8746c 100644 --- a/sample/src/Demo.Api/Extensions/WebApplicationBuilderExtensions.cs +++ b/sample/src/Demo.Api/Extensions/WebApplicationBuilderExtensions.cs @@ -2,8 +2,7 @@ namespace Demo.Api.Extensions; public static class WebApplicationBuilderExtensions { - public static void ConfigureLogging( - this WebApplicationBuilder builder) + public static void ConfigureLogging(this WebApplicationBuilder builder) { builder.Services.AddLogging(logging => { diff --git a/sample/src/Demo.Api/Extensions/WebApplicationExtensions.cs b/sample/src/Demo.Api/Extensions/WebApplicationExtensions.cs index 6096c48..89f1beb 100644 --- a/sample/src/Demo.Api/Extensions/WebApplicationExtensions.cs +++ b/sample/src/Demo.Api/Extensions/WebApplicationExtensions.cs @@ -14,8 +14,7 @@ public static RouteHandlerBuilder MapPatch( PatchHttpMethods, handler); - public static IApplicationBuilder AddGlobalErrorHandler( - this WebApplication app) + public static IApplicationBuilder AddGlobalErrorHandler(this WebApplication app) => app.UseMiddleware(); public static IApplicationBuilder ConfigureSwaggerUI( diff --git a/sample/src/Demo.Api/Options/ConfigureSwaggerOptions.cs b/sample/src/Demo.Api/Options/ConfigureSwaggerOptions.cs index da6e0f0..6545f57 100644 --- a/sample/src/Demo.Api/Options/ConfigureSwaggerOptions.cs +++ b/sample/src/Demo.Api/Options/ConfigureSwaggerOptions.cs @@ -24,8 +24,7 @@ public ConfigureSwaggerOptions( } /// - public void Configure( - SwaggerGenOptions options) + public void Configure(SwaggerGenOptions options) { // Add a swagger document for each discovered API version // note: you might choose to skip or document deprecated API versions differently @@ -35,8 +34,7 @@ public void Configure( } } - private OpenApiInfo CreateInfoForApiVersion( - ApiVersionDescription description) + private OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description) { var text = new StringBuilder("An example API to showcase minimal api implementation using the Atc.Rest.MinimalApi Nuget package."); var info = new OpenApiInfo diff --git a/sample/src/Demo.Domain/Extensions/ApplicationBuilderExtensions.cs b/sample/src/Demo.Domain/Extensions/ApplicationBuilderExtensions.cs index 714d66a..807e7dc 100644 --- a/sample/src/Demo.Domain/Extensions/ApplicationBuilderExtensions.cs +++ b/sample/src/Demo.Domain/Extensions/ApplicationBuilderExtensions.cs @@ -2,8 +2,7 @@ namespace Demo.Domain.Extensions; public static class ApplicationBuilderExtensions { - public static IApplicationBuilder InitializeDatabase( - this IApplicationBuilder app) + public static IApplicationBuilder InitializeDatabase(this IApplicationBuilder app) { using var serviceScope = app.ApplicationServices.CreateScope(); var serviceProvider = serviceScope.ServiceProvider; diff --git a/sample/src/Demo.Domain/Extensions/ServiceCollectionExtensions.cs b/sample/src/Demo.Domain/Extensions/ServiceCollectionExtensions.cs index bb9f4d7..1f7b2eb 100644 --- a/sample/src/Demo.Domain/Extensions/ServiceCollectionExtensions.cs +++ b/sample/src/Demo.Domain/Extensions/ServiceCollectionExtensions.cs @@ -17,8 +17,7 @@ public static IServiceCollection ConfigureDomainServices( return services; } - public static void DefineHandlersAndServices( - this IServiceCollection services) + public static void DefineHandlersAndServices(this IServiceCollection services) { services.AddSingleton(); services.AddSingleton(); diff --git a/sample/src/Demo.Domain/Users/Handlers/CreateUserHandler.cs b/sample/src/Demo.Domain/Users/Handlers/CreateUserHandler.cs index b412c83..419836c 100644 --- a/sample/src/Demo.Domain/Users/Handlers/CreateUserHandler.cs +++ b/sample/src/Demo.Domain/Users/Handlers/CreateUserHandler.cs @@ -1,15 +1,7 @@ namespace Demo.Domain.Users.Handlers; -public sealed class CreateUserHandler : ICreateUserHandler +public sealed class CreateUserHandler(DemoDbContext dbContext) : ICreateUserHandler { - private readonly DemoDbContext dbContext; - - public CreateUserHandler( - DemoDbContext dbContext) - { - this.dbContext = dbContext; - } - public async Task, Conflict>> ExecuteAsync( CreateUserParameters parameters, CancellationToken cancellationToken = default) diff --git a/sample/test/Demo.Api.IntegrationTests/Endpoints/UsersEndpointDefinitionTests.cs b/sample/test/Demo.Api.IntegrationTests/Endpoints/UsersEndpointDefinitionTests.cs index f0c6090..0728526 100644 --- a/sample/test/Demo.Api.IntegrationTests/Endpoints/UsersEndpointDefinitionTests.cs +++ b/sample/test/Demo.Api.IntegrationTests/Endpoints/UsersEndpointDefinitionTests.cs @@ -1,14 +1,9 @@ namespace Demo.Api.IntegrationTests.Endpoints; -public class UsersEndpointDefinitionTests : IClassFixture> +public class UsersEndpointDefinitionTests(TestWebApplicationFactory factory) + : IClassFixture> { - private readonly HttpClient httpClient; - - public UsersEndpointDefinitionTests( - TestWebApplicationFactory factory) - { - httpClient = factory.CreateClient(); - } + private readonly HttpClient httpClient = factory.CreateClient(); [Fact] public async Task GetAllUsers_ReturnsOkResultOfIEnumerableUser() diff --git a/src/Atc.Rest.MinimalApi/Abstractions/IEndpointAndServiceDefinition.cs b/src/Atc.Rest.MinimalApi/Abstractions/IEndpointAndServiceDefinition.cs index e690857..37f0e8a 100644 --- a/src/Atc.Rest.MinimalApi/Abstractions/IEndpointAndServiceDefinition.cs +++ b/src/Atc.Rest.MinimalApi/Abstractions/IEndpointAndServiceDefinition.cs @@ -2,6 +2,5 @@ namespace Atc.Rest.MinimalApi.Abstractions; public interface IEndpointAndServiceDefinition : IEndpointDefinition { - void DefineServices( - IServiceCollection services); + void DefineServices(IServiceCollection services); } \ No newline at end of file diff --git a/src/Atc.Rest.MinimalApi/Abstractions/IEndpointDefinition.cs b/src/Atc.Rest.MinimalApi/Abstractions/IEndpointDefinition.cs index 83b3509..f454158 100644 --- a/src/Atc.Rest.MinimalApi/Abstractions/IEndpointDefinition.cs +++ b/src/Atc.Rest.MinimalApi/Abstractions/IEndpointDefinition.cs @@ -2,6 +2,5 @@ namespace Atc.Rest.MinimalApi.Abstractions; public interface IEndpointDefinition { - void DefineEndpoints( - WebApplication app); + void DefineEndpoints(WebApplication app); } \ No newline at end of file diff --git a/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs b/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs index 6e73af1..ae13eff 100644 --- a/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs +++ b/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs @@ -75,8 +75,7 @@ public static void AddEndpointAndServiceDefinitions( /// /// The to which the endpoint definitions are applied. /// Thrown if is null. - public static void UseEndpointDefinitions( - this WebApplication app) + public static void UseEndpointDefinitions(this WebApplication app) { ArgumentNullException.ThrowIfNull(app); diff --git a/src/Atc.Rest.MinimalApi/Extensions/HttpContextExtensions.cs b/src/Atc.Rest.MinimalApi/Extensions/HttpContextExtensions.cs index c7d397f..aae62e6 100644 --- a/src/Atc.Rest.MinimalApi/Extensions/HttpContextExtensions.cs +++ b/src/Atc.Rest.MinimalApi/Extensions/HttpContextExtensions.cs @@ -3,16 +3,14 @@ namespace Microsoft.AspNetCore.Http; public static class HttpContextExtensions { - public static string? GetCorrelationId( - this HttpContext context) + public static string? GetCorrelationId(this HttpContext context) => context.Request.Headers.TryGetValue( WellKnownHttpHeaders.CorrelationId, out var header) ? header.FirstOrDefault() : null; - public static string? GetRequestId( - this HttpContext context) + public static string? GetRequestId(this HttpContext context) => context.Request.Headers.TryGetValue( WellKnownHttpHeaders.RequestId, out var header) diff --git a/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs b/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs index 776e967..0b5058e 100644 --- a/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs +++ b/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs @@ -179,8 +179,7 @@ private static void FormatAndAddValidationErrors( } [SuppressMessage("Warning", "MA0009", Justification = "Regex is safe")] - private static string RemoveCollectionIndexer( - string errorName) + private static string RemoveCollectionIndexer(string errorName) => Regex.Replace(errorName, @"\[.*\]", string.Empty); private static void ReplaceSerializationTypeName( diff --git a/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs b/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs index 297bbbf..16dc006 100644 --- a/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs +++ b/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs @@ -19,9 +19,7 @@ public class ValidationFilter : IEndpointFilter /// An optional object containing options to configure the validation behavior. public ValidationFilter( ValidationFilterOptions? validationFilterOptions = null) - { - this.validationFilterOptions = validationFilterOptions; - } + => this.validationFilterOptions = validationFilterOptions; /// /// Asynchronously invokes the validation filter. diff --git a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs index 2f3fe7f..cc38f39 100644 --- a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs +++ b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs @@ -28,8 +28,7 @@ public GlobalErrorHandlingMiddleware( /// The for the current request. /// A representing the asynchronous operation. [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "OK.")] - public async Task Invoke( - HttpContext context) + public async Task Invoke(HttpContext context) { try { @@ -215,8 +214,7 @@ private static void SetExtensionFields( /// /// if a simple message should be used for the specified exception types; otherwise, . /// - private bool UseSimpleMessage( - Exception exception) + private bool UseSimpleMessage(Exception exception) { if (!options.IncludeException) { From 5b386bee431267f3d02a54b96e947939a3507648 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 3 Dec 2025 11:58:19 +0100 Subject: [PATCH 06/54] chore: migrate sample solution from .sln to .slnx format, upgrade nuget packages and target .NET 10, update coding rules --- .editorconfig | 12 +++- Directory.Build.props | 4 +- atc-coding-rules-updater.json | 2 +- sample/.editorconfig | 1 + sample/Demo.sln | 67 ------------------- sample/Demo.slnx | 19 ++++++ sample/Directory.Build.props | 4 +- .../Demo.Api.Contracts.csproj | 7 +- sample/src/Demo.Api/Demo.Api.csproj | 4 +- sample/src/Demo.Domain/Demo.Domain.csproj | 17 +++-- .../Demo.Api.Contracts.Tests.csproj | 6 +- .../Demo.Api.IntegrationTests.csproj | 8 +-- .../Demo.Domain.Tests.csproj | 6 +- src/.editorconfig | 4 +- .../Atc.Rest.MinimalApi.csproj | 10 +-- test/.editorconfig | 5 +- .../Atc.Rest.MinimalApi.Tests.csproj | 8 +-- 17 files changed, 71 insertions(+), 113 deletions(-) delete mode 100644 sample/Demo.sln create mode 100644 sample/Demo.slnx diff --git a/.editorconfig b/.editorconfig index 39a809b..28513a0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,8 +1,8 @@ # ATC coding rules - https://github.com/atc-net/atc-coding-rules -# Version: 1.0.1 -# Updated: 03-06-2024 +# Version: 1.0.0 +# Updated: 01-03-2025 # Location: Root -# Distribution: DotNet8 +# Distribution: DotNet9 # Inspired by: https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code-style-rule-options ########################################## @@ -492,6 +492,8 @@ dotnet_diagnostic.CA1867.severity = suggestion # Use char overload dotnet_diagnostic.CA1868.severity = suggestion # Unnecessary call to 'Contains(item)' dotnet_diagnostic.CA1869.severity = suggestion # Cache and reuse 'JsonSerializerOptions' instances dotnet_diagnostic.CA1870.severity = suggestion # Use a cached 'SearchValues' instance +dotnet_diagnostic.CA1871.severity = suggestion # Do not pass a nullable struct to 'ArgumentNullException.ThrowIfNull' +dotnet_diagnostic.CA1872.severity = suggestion # Prefer 'Convert.ToHexString' and 'Convert.ToHexStringLower' over call chains based on 'BitConverter.ToString' dotnet_diagnostic.CA2007.severity = suggestion # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA2007.md dotnet_diagnostic.CA2017.severity = error # Parameter count mismatch dotnet_diagnostic.CA2018.severity = error # The count argument to Buffer.BlockCopy should specify the number of bytes to copy @@ -505,6 +507,9 @@ dotnet_diagnostic.CA2255.severity = suggestion # The ModuleInitializer attr dotnet_diagnostic.CA2259.severity = error # Ensure ThreadStatic is only used with static fields dotnet_diagnostic.CA2260.severity = error # Implement generic math interfaces correctly dotnet_diagnostic.CA2261.severity = error # Do not use ConfigureAwaitOptions.SuppressThrowing with Task +dotnet_diagnostic.CA2262.severity = suggestion # Set 'MaxResponseHeadersLength' properly +dotnet_diagnostic.CA2263.severity = suggestion # Prefer generic overload when type is known +dotnet_diagnostic.CA2264.severity = error # Do not pass a non-nullable value to 'ArgumentNullException.ThrowIfNull' dotnet_diagnostic.IDE0005.severity = warning # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/IDE0005.md dotnet_diagnostic.IDE0010.severity = suggestion # Populate switch dotnet_diagnostic.IDE0028.severity = suggestion # Collection initialization can be simplified @@ -557,6 +562,7 @@ dotnet_diagnostic.S3358.severity = none # Extract this nested ternary dotnet_diagnostic.S6602.severity = none # "Find" method should be used instead of the "FirstOrDefault" dotnet_diagnostic.S6603.severity = none # The collection-specific "TrueForAll" method should be used instead of the "All" dotnet_diagnostic.S6605.severity = none # Collection-specific "Exists" method should be used instead of the "Any" +dotnet_diagnostic.S6964.severity = none # Value type property used as input in a controller action should be nullable, required or annotated with the JsonRequiredAttribute to avoid under-posting. ########################################## diff --git a/Directory.Build.props b/Directory.Build.props index 73f7b0a..1ce7663 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -48,10 +48,10 @@ - + - + \ No newline at end of file diff --git a/atc-coding-rules-updater.json b/atc-coding-rules-updater.json index 65e3cb3..6e57f36 100644 --- a/atc-coding-rules-updater.json +++ b/atc-coding-rules-updater.json @@ -1,5 +1,5 @@ { - "projectTarget": "DotNet8", + "projectTarget": "DotNet9", "useLatestMinorNugetVersion": true, "useTemporarySuppressions": false, "temporarySuppressionAsExcel": false, diff --git a/sample/.editorconfig b/sample/.editorconfig index a5eb5c3..4bc50b2 100644 --- a/sample/.editorconfig +++ b/sample/.editorconfig @@ -511,6 +511,7 @@ dotnet_diagnostic.S1135.severity = suggestion # https://github.com/atc-net ########################################## [*.{cs,csx,cake}] +dotnet_diagnostic.CA1515.severity = none # Consider making public types internal https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1515 dotnet_diagnostic.CA1859.severity = none # https://learn.microsoft.com/da-dk/dotnet/fundamentals/code-analysis/quality-rules/ca1859 dotnet_diagnostic.CA2007.severity = none # Consider calling ConfigureAwait. dotnet_diagnostic.CA2016.severity = none # Forward the CancellationToken parameter to methods that take one - False-Positive diff --git a/sample/Demo.sln b/sample/Demo.sln deleted file mode 100644 index 001e2a8..0000000 --- a/sample/Demo.sln +++ /dev/null @@ -1,67 +0,0 @@ -๏ปฟ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.3.32901.215 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3F58D3C2-990A-4EB3-A3A2-4B256AFDBE3C}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo.Api", "src\Demo.Api\Demo.Api.csproj", "{3967D71B-7A07-4903-BA68-647F9CE1E90C}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{E97F957E-1D56-4271-BDB6-45F6F0F1B095}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo.Domain", "src\Demo.Domain\Demo.Domain.csproj", "{6B4AC0A1-F950-406E-B9C2-90C887D848C8}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo.Api.IntegrationTests", "test\Demo.Api.IntegrationTests\Demo.Api.IntegrationTests.csproj", "{22AEF181-536D-41E8-8987-C506E37C41C5}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo.Domain.Tests", "test\Demo.Domain.Tests\Demo.Domain.Tests.csproj", "{4B615C02-572A-4DA4-B9C9-EBE37A736550}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo.Api.Contracts", "src\Demo.Api.Contracts\Demo.Api.Contracts.csproj", "{8263EAD4-3E86-4671-9408-DF8D03D5972E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Demo.Api.Contracts.Tests", "test\Demo.Api.Contracts.Tests\Demo.Api.Contracts.Tests.csproj", "{74CFD0C2-DE1E-40E8-87A0-669578069B17}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {3967D71B-7A07-4903-BA68-647F9CE1E90C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3967D71B-7A07-4903-BA68-647F9CE1E90C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3967D71B-7A07-4903-BA68-647F9CE1E90C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3967D71B-7A07-4903-BA68-647F9CE1E90C}.Release|Any CPU.Build.0 = Release|Any CPU - {6B4AC0A1-F950-406E-B9C2-90C887D848C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6B4AC0A1-F950-406E-B9C2-90C887D848C8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6B4AC0A1-F950-406E-B9C2-90C887D848C8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6B4AC0A1-F950-406E-B9C2-90C887D848C8}.Release|Any CPU.Build.0 = Release|Any CPU - {22AEF181-536D-41E8-8987-C506E37C41C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {22AEF181-536D-41E8-8987-C506E37C41C5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {22AEF181-536D-41E8-8987-C506E37C41C5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {22AEF181-536D-41E8-8987-C506E37C41C5}.Release|Any CPU.Build.0 = Release|Any CPU - {4B615C02-572A-4DA4-B9C9-EBE37A736550}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4B615C02-572A-4DA4-B9C9-EBE37A736550}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4B615C02-572A-4DA4-B9C9-EBE37A736550}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4B615C02-572A-4DA4-B9C9-EBE37A736550}.Release|Any CPU.Build.0 = Release|Any CPU - {8263EAD4-3E86-4671-9408-DF8D03D5972E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8263EAD4-3E86-4671-9408-DF8D03D5972E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8263EAD4-3E86-4671-9408-DF8D03D5972E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8263EAD4-3E86-4671-9408-DF8D03D5972E}.Release|Any CPU.Build.0 = Release|Any CPU - {74CFD0C2-DE1E-40E8-87A0-669578069B17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {74CFD0C2-DE1E-40E8-87A0-669578069B17}.Debug|Any CPU.Build.0 = Debug|Any CPU - {74CFD0C2-DE1E-40E8-87A0-669578069B17}.Release|Any CPU.ActiveCfg = Release|Any CPU - {74CFD0C2-DE1E-40E8-87A0-669578069B17}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {3967D71B-7A07-4903-BA68-647F9CE1E90C} = {3F58D3C2-990A-4EB3-A3A2-4B256AFDBE3C} - {6B4AC0A1-F950-406E-B9C2-90C887D848C8} = {3F58D3C2-990A-4EB3-A3A2-4B256AFDBE3C} - {22AEF181-536D-41E8-8987-C506E37C41C5} = {E97F957E-1D56-4271-BDB6-45F6F0F1B095} - {4B615C02-572A-4DA4-B9C9-EBE37A736550} = {E97F957E-1D56-4271-BDB6-45F6F0F1B095} - {8263EAD4-3E86-4671-9408-DF8D03D5972E} = {3F58D3C2-990A-4EB3-A3A2-4B256AFDBE3C} - {74CFD0C2-DE1E-40E8-87A0-669578069B17} = {E97F957E-1D56-4271-BDB6-45F6F0F1B095} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {834D65C6-6E3F-411C-B742-5C51CE293436} - EndGlobalSection -EndGlobal diff --git a/sample/Demo.slnx b/sample/Demo.slnx new file mode 100644 index 0000000..478d75b --- /dev/null +++ b/sample/Demo.slnx @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/sample/Directory.Build.props b/sample/Directory.Build.props index 6d00aa6..b4e1ecf 100644 --- a/sample/Directory.Build.props +++ b/sample/Directory.Build.props @@ -16,9 +16,9 @@ enable - 12.0 + 14.0 enable - net8.0 + net10.0 true 1573,1591,1712,CA1014 diff --git a/sample/src/Demo.Api.Contracts/Demo.Api.Contracts.csproj b/sample/src/Demo.Api.Contracts/Demo.Api.Contracts.csproj index f6c7737..76c7cc4 100644 --- a/sample/src/Demo.Api.Contracts/Demo.Api.Contracts.csproj +++ b/sample/src/Demo.Api.Contracts/Demo.Api.Contracts.csproj @@ -1,7 +1,6 @@ - net8.0 false @@ -13,9 +12,9 @@ - - - + + + diff --git a/sample/src/Demo.Api/Demo.Api.csproj b/sample/src/Demo.Api/Demo.Api.csproj index 28717d3..ad37eff 100644 --- a/sample/src/Demo.Api/Demo.Api.csproj +++ b/sample/src/Demo.Api/Demo.Api.csproj @@ -6,9 +6,9 @@ - + - + diff --git a/sample/src/Demo.Domain/Demo.Domain.csproj b/sample/src/Demo.Domain/Demo.Domain.csproj index 9ea16ac..f4d872c 100644 --- a/sample/src/Demo.Domain/Demo.Domain.csproj +++ b/sample/src/Demo.Domain/Demo.Domain.csproj @@ -1,23 +1,22 @@ - +๏ปฟ - net8.0 false - - - - + + + + - - - + + + diff --git a/sample/test/Demo.Api.Contracts.Tests/Demo.Api.Contracts.Tests.csproj b/sample/test/Demo.Api.Contracts.Tests/Demo.Api.Contracts.Tests.csproj index b7ac04c..8b6fe5c 100644 --- a/sample/test/Demo.Api.Contracts.Tests/Demo.Api.Contracts.Tests.csproj +++ b/sample/test/Demo.Api.Contracts.Tests/Demo.Api.Contracts.Tests.csproj @@ -1,19 +1,19 @@ - net8.0 + net10.0 false true - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/sample/test/Demo.Api.IntegrationTests/Demo.Api.IntegrationTests.csproj b/sample/test/Demo.Api.IntegrationTests/Demo.Api.IntegrationTests.csproj index 379f59e..e5b1eb2 100644 --- a/sample/test/Demo.Api.IntegrationTests/Demo.Api.IntegrationTests.csproj +++ b/sample/test/Demo.Api.IntegrationTests/Demo.Api.IntegrationTests.csproj @@ -1,20 +1,20 @@ - net8.0 + net10.0 false true - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/sample/test/Demo.Domain.Tests/Demo.Domain.Tests.csproj b/sample/test/Demo.Domain.Tests/Demo.Domain.Tests.csproj index a5dbb7d..8758e8d 100644 --- a/sample/test/Demo.Domain.Tests/Demo.Domain.Tests.csproj +++ b/sample/test/Demo.Domain.Tests/Demo.Domain.Tests.csproj @@ -1,19 +1,19 @@ - net8.0 + net10.0 false true - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/.editorconfig b/src/.editorconfig index 9cded84..3ad9104 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -1,8 +1,8 @@ # ATC coding rules - https://github.com/atc-net/atc-coding-rules # Version: 1.0.0 -# Updated: 25-09-2023 +# Updated: 03-06-2024 # Location: src -# Distribution: DotNet8 +# Distribution: DotNet9 # Inspired by: https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code-style-rule-options ########################################## diff --git a/src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj b/src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj index d81738f..69ad7b5 100644 --- a/src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj +++ b/src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj @@ -1,7 +1,7 @@ - net8.0 + net8.0;net9.0;net10.0 Atc.Rest.MinimalApi asp-net;minimal-api;minimal;rest Atc.Rest.MinimalApi is a collection of various components related to minimal apis, e.g EndpointDefinitions, Extensions, ValidationFilters, Errorhandling, Swagger Filters, versioning etc. @@ -14,10 +14,10 @@ - - - - + + + + diff --git a/test/.editorconfig b/test/.editorconfig index a2bd082..bb67100 100644 --- a/test/.editorconfig +++ b/test/.editorconfig @@ -1,8 +1,8 @@ # ATC coding rules - https://github.com/atc-net/atc-coding-rules # Version: 1.0.0 -# Updated: 25-09-2023 +# Updated: 09-01-2025 # Location: test -# Distribution: DotNet8 +# Distribution: DotNet9 # Inspired by: https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code-style-rule-options ########################################## @@ -24,6 +24,7 @@ dotnet_diagnostic.MA0004.severity = none # https://github.com/atc-net dotnet_diagnostic.MA0016.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/Meziantou/MA0016.md dotnet_diagnostic.MA0051.severity = none # Method Length + # Microsoft - Code Analysis # https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ dotnet_diagnostic.CA1068.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA1068.md diff --git a/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj b/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj index 2dc0605..1b73270 100644 --- a/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj +++ b/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj @@ -1,22 +1,22 @@ - net8.0 + net10.0 false true 1591;9057 - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all From af65a47d5f27c54765a43b3063418fa7792dad9f Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 3 Dec 2025 12:05:00 +0100 Subject: [PATCH 07/54] fix(sample): improve validation and fix nullable handling in request models --- .../Models/Requests/UsersRequestRecords.cs | 6 +++--- .../Validators/CreateUserRequestValidator.cs | 21 +++++++++++-------- .../Validators/UpdateUserRequestValidator.cs | 14 +++++++++---- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs b/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs index d49972f..007e139 100644 --- a/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs +++ b/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs @@ -2,11 +2,11 @@ namespace Demo.Api.Contracts.Contracts.Users.Models.Requests; public sealed record CreateUserRequest( GenderType Gender, - string FirstName, + [property: Required, JsonPropertyName("firstName")] string FirstName, string LastName, [property: EmailAddress] string Email, string Telephone, - [property: Uri] string HomePage, + [property: Url] string HomePage, Address? HomeAddress, Address WorkAddress); @@ -15,4 +15,4 @@ public sealed record UpdateUserRequest( [property: Required, JsonPropertyName("firstName")] string FirstName, [property: Required] string LastName, [property: Required, EmailAddress] string Email, - [property: Required, JsonPropertyName("address")] Address WorkAddress); \ No newline at end of file + [property: Required, JsonPropertyName("address")] Address? WorkAddress); \ No newline at end of file diff --git a/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs b/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs index 241dbf4..a879bbb 100644 --- a/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs +++ b/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs @@ -7,25 +7,28 @@ public CreateUserRequestValidator() RuleFor(x => x.FirstName) .NotNull() .Length(2, 10) - .Matches(@"^[A-Z]") + .Matches("^[A-Z]") .WithMessage(x => $"{nameof(x.FirstName)} has to start with an uppercase letter."); RuleFor(x => x.LastName) .NotNull() .Length(2, 30) - .Matches(@"^[A-Z]") + .Matches("^[A-Z]") .WithMessage(x => $"{nameof(x.LastName)} has to start with an uppercase letter.") .NotEqual(x => x.FirstName) .WithMessage("FirstName must not be equal to LastName."); RuleFor(x => x.WorkAddress).NotNull(); - RuleFor(x => x.WorkAddress.CityName) - .NotEmpty() - .WithMessage("WorkAddress.CityName is required.") - .MinimumLength(3) - .WithMessage("WorkAddress.CityName must be min 3 characters long.") - .MaximumLength(20) - .WithMessage("WorkAddress.CityName must be max 20 characters long."); + When(x => x.WorkAddress is not null, () => + { + RuleFor(x => x.WorkAddress.CityName) + .NotEmpty() + .WithMessage("WorkAddress.CityName is required.") + .MinimumLength(3) + .WithMessage("WorkAddress.CityName must be min 3 characters long.") + .MaximumLength(20) + .WithMessage("WorkAddress.CityName must be max 20 characters long."); + }); } } \ No newline at end of file diff --git a/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs b/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs index 3ba26dc..44fb04a 100644 --- a/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs +++ b/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs @@ -28,10 +28,16 @@ public UpdateUserRequestValidator() RuleFor(x => x.Request.WorkAddress) .NotNull(); - RuleFor(x => x.Request.WorkAddress.CityName) - .NotNull() - .Length(2, 10) - .WithMessage("CityName is too short."); + When(x => x.Request.WorkAddress is not null, () => + { + RuleFor(x => x.Request.WorkAddress!.CityName) + .NotEmpty() + .WithMessage("WorkAddress.CityName is required.") + .MinimumLength(3) + .WithMessage("WorkAddress.CityName must be min 3 characters long.") + .MaximumLength(50) + .WithMessage("WorkAddress.CityName must be max 50 characters long."); + }); } [GeneratedRegex("^[A-Z]", RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)] From 08af2fecaf622438ad61a5a38830749cbd21eb73 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 3 Dec 2025 12:09:48 +0100 Subject: [PATCH 08/54] chore(sample): remove unused Azure options configuration --- sample/src/Demo.Api/GlobalUsings.cs | 3 --- sample/src/Demo.Api/appsettings.Development.json | 14 +------------- sample/src/Demo.Domain/Demo.Domain.csproj | 1 - .../Extensions/ServiceCollectionExtensions.cs | 14 +------------- sample/src/Demo.Domain/GlobalUsings.cs | 2 -- 5 files changed, 2 insertions(+), 32 deletions(-) diff --git a/sample/src/Demo.Api/GlobalUsings.cs b/sample/src/Demo.Api/GlobalUsings.cs index a3c6c9f..cbc5f7f 100644 --- a/sample/src/Demo.Api/GlobalUsings.cs +++ b/sample/src/Demo.Api/GlobalUsings.cs @@ -8,15 +8,12 @@ global using Atc.Rest.MinimalApi.Middleware; global using Atc.Rest.MinimalApi.Versioning; global using Atc.Serialization; - global using Demo.Api.Contracts; global using Demo.Api.Extensions; global using Demo.Api.Options; global using Demo.Domain; global using Demo.Domain.Extensions; - global using FluentValidation; - global using Microsoft.AspNetCore.HttpLogging; global using Microsoft.AspNetCore.Mvc; global using Microsoft.Extensions.Logging.ApplicationInsights; diff --git a/sample/src/Demo.Api/appsettings.Development.json b/sample/src/Demo.Api/appsettings.Development.json index 6fb6f24..90e9f7d 100644 --- a/sample/src/Demo.Api/appsettings.Development.json +++ b/sample/src/Demo.Api/appsettings.Development.json @@ -11,19 +11,7 @@ } } }, - "EnvironmentOptions": { - "EnvironmentType": "Local", - "EnvironmentName": "dev" - }, - "ServiceOptions": { - "TenantId": "" - }, - "NamingOptions": { - "CompanyAbbreviation": "", - "SystemAbbreviation": "", - "ServiceAbbreviation": "" - }, "CosmosOptions": { "CosmosDbKey": "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" } -} +} \ No newline at end of file diff --git a/sample/src/Demo.Domain/Demo.Domain.csproj b/sample/src/Demo.Domain/Demo.Domain.csproj index f4d872c..e8bffb3 100644 --- a/sample/src/Demo.Domain/Demo.Domain.csproj +++ b/sample/src/Demo.Domain/Demo.Domain.csproj @@ -7,7 +7,6 @@ - diff --git a/sample/src/Demo.Domain/Extensions/ServiceCollectionExtensions.cs b/sample/src/Demo.Domain/Extensions/ServiceCollectionExtensions.cs index 1f7b2eb..975ab6c 100644 --- a/sample/src/Demo.Domain/Extensions/ServiceCollectionExtensions.cs +++ b/sample/src/Demo.Domain/Extensions/ServiceCollectionExtensions.cs @@ -8,8 +8,6 @@ public static IServiceCollection ConfigureDomainServices( { MapsterConfig.Register(); - services.ConfigureOptions(configuration); - services.DefineHandlersAndServices(); services.SetupStorage(); @@ -27,17 +25,7 @@ public static void DefineHandlersAndServices(this IServiceCollection services) services.AddSingleton(); } - private static void ConfigureOptions( - this IServiceCollection services, - IConfiguration configuration) - { - services.Configure(options => configuration.GetRequiredSection(nameof(ServiceOptions)).Bind(options)); - services.Configure(options => configuration.GetRequiredSection(nameof(EnvironmentOptions)).Bind(options)); - services.Configure(options => configuration.GetRequiredSection(nameof(NamingOptions)).Bind(options)); - } - - private static void SetupStorage( - this IServiceCollection services) + private static void SetupStorage(this IServiceCollection services) { // Add DbContext with In-Memory Database services.AddDbContext( diff --git a/sample/src/Demo.Domain/GlobalUsings.cs b/sample/src/Demo.Domain/GlobalUsings.cs index 8032d6d..abc6efe 100644 --- a/sample/src/Demo.Domain/GlobalUsings.cs +++ b/sample/src/Demo.Domain/GlobalUsings.cs @@ -1,7 +1,5 @@ global using System.Diagnostics.CodeAnalysis; global using System.Text.RegularExpressions; -global using Atc.Azure.Options; -global using Atc.Azure.Options.Environment; global using Bogus; global using Demo.Api.Contracts.Contracts; global using Demo.Api.Contracts.Contracts.Users.Interfaces; From 85134dae837ef9b1d2b42cf67e4830e3655fd3bd Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 3 Dec 2025 12:12:22 +0100 Subject: [PATCH 09/54] feat(sample): add Aspire AppHost for orchestration incl. Blazor Web UI project with MudBlazor --- Atc.Rest.MinimalApi.slnx | 2 + sample/src/Demo.AppHost/Demo.AppHost.csproj | 15 ++ sample/src/Demo.AppHost/Program.cs | 52 ++++ .../Properties/launchSettings.json | 29 ++ sample/src/Demo.AppHost/appsettings.json | 9 + sample/src/Demo.Web/Components/App.razor | 21 ++ .../Components/Layout/MainLayout.razor | 56 ++++ .../Demo.Web/Components/Layout/NavMenu.razor | 4 + .../src/Demo.Web/Components/Pages/Home.razor | 40 +++ .../Components/Pages/UserEditForm.razor | 153 +++++++++++ .../Demo.Web/Components/Pages/UserForm.razor | 255 ++++++++++++++++++ .../src/Demo.Web/Components/Pages/Users.razor | 252 +++++++++++++++++ .../Pages/ValidationErrorsAlert.razor | 60 +++++ sample/src/Demo.Web/Components/Routes.razor | 6 + sample/src/Demo.Web/Components/_Imports.razor | 12 + sample/src/Demo.Web/Demo.Web.csproj | 18 ++ sample/src/Demo.Web/GlobalUsings.cs | 4 + sample/src/Demo.Web/Models/Address.cs | 8 + .../Models/ApiValidationProblemDetails.cs | 28 ++ sample/src/Demo.Web/Models/Country.cs | 6 + .../src/Demo.Web/Models/CreateUserRequest.cs | 21 ++ sample/src/Demo.Web/Models/GenderType.cs | 10 + .../src/Demo.Web/Models/UpdateUserRequest.cs | 16 ++ sample/src/Demo.Web/Models/User.cs | 12 + sample/src/Demo.Web/Program.cs | 70 +++++ .../Demo.Web/Properties/launchSettings.json | 23 ++ sample/src/Demo.Web/Services/IUserService.cs | 19 ++ sample/src/Demo.Web/Services/UserService.cs | 166 ++++++++++++ sample/src/Demo.Web/appsettings.json | 9 + 29 files changed, 1376 insertions(+) create mode 100644 sample/src/Demo.AppHost/Demo.AppHost.csproj create mode 100644 sample/src/Demo.AppHost/Program.cs create mode 100644 sample/src/Demo.AppHost/Properties/launchSettings.json create mode 100644 sample/src/Demo.AppHost/appsettings.json create mode 100644 sample/src/Demo.Web/Components/App.razor create mode 100644 sample/src/Demo.Web/Components/Layout/MainLayout.razor create mode 100644 sample/src/Demo.Web/Components/Layout/NavMenu.razor create mode 100644 sample/src/Demo.Web/Components/Pages/Home.razor create mode 100644 sample/src/Demo.Web/Components/Pages/UserEditForm.razor create mode 100644 sample/src/Demo.Web/Components/Pages/UserForm.razor create mode 100644 sample/src/Demo.Web/Components/Pages/Users.razor create mode 100644 sample/src/Demo.Web/Components/Pages/ValidationErrorsAlert.razor create mode 100644 sample/src/Demo.Web/Components/Routes.razor create mode 100644 sample/src/Demo.Web/Components/_Imports.razor create mode 100644 sample/src/Demo.Web/Demo.Web.csproj create mode 100644 sample/src/Demo.Web/GlobalUsings.cs create mode 100644 sample/src/Demo.Web/Models/Address.cs create mode 100644 sample/src/Demo.Web/Models/ApiValidationProblemDetails.cs create mode 100644 sample/src/Demo.Web/Models/Country.cs create mode 100644 sample/src/Demo.Web/Models/CreateUserRequest.cs create mode 100644 sample/src/Demo.Web/Models/GenderType.cs create mode 100644 sample/src/Demo.Web/Models/UpdateUserRequest.cs create mode 100644 sample/src/Demo.Web/Models/User.cs create mode 100644 sample/src/Demo.Web/Program.cs create mode 100644 sample/src/Demo.Web/Properties/launchSettings.json create mode 100644 sample/src/Demo.Web/Services/IUserService.cs create mode 100644 sample/src/Demo.Web/Services/UserService.cs create mode 100644 sample/src/Demo.Web/appsettings.json diff --git a/Atc.Rest.MinimalApi.slnx b/Atc.Rest.MinimalApi.slnx index cb23f63..257b263 100644 --- a/Atc.Rest.MinimalApi.slnx +++ b/Atc.Rest.MinimalApi.slnx @@ -6,7 +6,9 @@ + + diff --git a/sample/src/Demo.AppHost/Demo.AppHost.csproj b/sample/src/Demo.AppHost/Demo.AppHost.csproj new file mode 100644 index 0000000..e04f7c5 --- /dev/null +++ b/sample/src/Demo.AppHost/Demo.AppHost.csproj @@ -0,0 +1,15 @@ + + + + Exe + true + false + demo-apphost-secrets + + + + + + + + diff --git a/sample/src/Demo.AppHost/Program.cs b/sample/src/Demo.AppHost/Program.cs new file mode 100644 index 0000000..0caa20d --- /dev/null +++ b/sample/src/Demo.AppHost/Program.cs @@ -0,0 +1,52 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var api = builder + .AddProject("api") + .WithHttpHealthCheck("/health") + .WithUrls(context => + { + foreach (var url in context.Urls) + { + url.DisplayLocation = UrlDisplayLocation.DetailsOnly; + } + + var endpointReference = context.GetEndpoint("https"); + + ////context.Urls.Add(new ResourceUrlAnnotation + ////{ + //// Url = string.Empty, + //// DisplayText = "Scalar", + //// Endpoint = endpointReference, + ////}); + + context.Urls.Add(new ResourceUrlAnnotation + { + Url = $"{endpointReference!.Url}/swagger", + DisplayText = "Swagger", + Endpoint = endpointReference, + }); + }); + +builder + .AddProject("web") + .WithExternalHttpEndpoints() + .WithReference(api) + .WaitFor(api) + .WithUrls(context => + { + foreach (var url in context.Urls) + { + url.DisplayLocation = UrlDisplayLocation.DetailsOnly; + } + + context.Urls.Add(new ResourceUrlAnnotation + { + Url = "/", + DisplayText = "Web", + Endpoint = context.GetEndpoint("https"), + }); + }); + +await builder + .Build() + .RunAsync(); \ No newline at end of file diff --git a/sample/src/Demo.AppHost/Properties/launchSettings.json b/sample/src/Demo.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..23b7d33 --- /dev/null +++ b/sample/src/Demo.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17100;http://localhost:15100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21100", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22100" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15100", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19100", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20100" + } + } + } +} diff --git a/sample/src/Demo.AppHost/appsettings.json b/sample/src/Demo.AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/sample/src/Demo.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/sample/src/Demo.Web/Components/App.razor b/sample/src/Demo.Web/Components/App.razor new file mode 100644 index 0000000..23617b6 --- /dev/null +++ b/sample/src/Demo.Web/Components/App.razor @@ -0,0 +1,21 @@ +@using Microsoft.AspNetCore.Components.Web + + + + + + + + + + + + + + + + + + + + diff --git a/sample/src/Demo.Web/Components/Layout/MainLayout.razor b/sample/src/Demo.Web/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..6fe6854 --- /dev/null +++ b/sample/src/Demo.Web/Components/Layout/MainLayout.razor @@ -0,0 +1,56 @@ +@inherits LayoutComponentBase + + + + + + + + + + Demo.Web + + + + + + + + + @Body + + + + +@code { + private bool _drawerOpen = true; + private bool _isDarkMode = true; + + private readonly MudTheme _theme = new() + { + PaletteLight = new PaletteLight + { + Primary = Colors.Blue.Default, + Secondary = Colors.Green.Accent4, + AppbarBackground = Colors.Blue.Default, + }, + PaletteDark = new PaletteDark + { + Primary = Colors.Blue.Lighten1, + Secondary = Colors.Green.Accent4, + AppbarBackground = Colors.Blue.Darken4, + } + }; + + private void ToggleDrawer() + { + _drawerOpen = !_drawerOpen; + } + + private void ToggleDarkMode() + { + _isDarkMode = !_isDarkMode; + } +} diff --git a/sample/src/Demo.Web/Components/Layout/NavMenu.razor b/sample/src/Demo.Web/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..5006ffe --- /dev/null +++ b/sample/src/Demo.Web/Components/Layout/NavMenu.razor @@ -0,0 +1,4 @@ + + Home + Users + diff --git a/sample/src/Demo.Web/Components/Pages/Home.razor b/sample/src/Demo.Web/Components/Pages/Home.razor new file mode 100644 index 0000000..4809ce5 --- /dev/null +++ b/sample/src/Demo.Web/Components/Pages/Home.razor @@ -0,0 +1,40 @@ +@page "/" + +Home + +Welcome to Demo.Web + + This Blazor application demonstrates CRUD operations with the Demo.Api, + showcasing model binding validation errors from FluentValidation and DataAnnotations. + + + + Navigate to the Users page to see the demo in action. + + + + + + Features + + + + + + Full CRUD operations (Create, Read, Update, Delete) + + + Server-side validation error display from API + + + FluentValidation + DataAnnotations combined + + + MudBlazor UI components + + + .NET Aspire orchestration + + + + diff --git a/sample/src/Demo.Web/Components/Pages/UserEditForm.razor b/sample/src/Demo.Web/Components/Pages/UserEditForm.razor new file mode 100644 index 0000000..c86e5c8 --- /dev/null +++ b/sample/src/Demo.Web/Components/Pages/UserEditForm.razor @@ -0,0 +1,153 @@ + + + + @foreach (GenderType gender in Enum.GetValues()) + { + @gender + } + + + + + + + + + + + + + + Work Address + + + + + + + + + + + + + + + + + + + + + + + + + + +@code { + [Parameter] + public UpdateUserRequest Model { get; set; } = new(); + + [Parameter] + public IDictionary? Errors { get; set; } + + private void UpdateWorkAddress(Func update) + { + var current = Model.WorkAddress ?? new Address(null, null, null, null, null); + Model.WorkAddress = update(current); + } + + private void UpdateWorkAddressCountry(Func update) + { + var currentAddress = Model.WorkAddress ?? new Address(null, null, null, null, null); + var currentCountry = currentAddress.Country ?? new Country(null, null, null); + Model.WorkAddress = currentAddress with { Country = update(currentCountry) }; + } + + private bool HasError(string fieldName) + { + if (Errors is null) + { + return false; + } + + return Errors.Keys.Any(k => k.Equals(fieldName, StringComparison.OrdinalIgnoreCase)); + } + + private string GetErrorText(string fieldName) + { + if (Errors is null) + { + return string.Empty; + } + + var key = Errors.Keys.FirstOrDefault(k => k.Equals(fieldName, StringComparison.OrdinalIgnoreCase)); + if (key is null) + { + return string.Empty; + } + + return string.Join("; ", Errors[key]); + } +} diff --git a/sample/src/Demo.Web/Components/Pages/UserForm.razor b/sample/src/Demo.Web/Components/Pages/UserForm.razor new file mode 100644 index 0000000..8321ad3 --- /dev/null +++ b/sample/src/Demo.Web/Components/Pages/UserForm.razor @@ -0,0 +1,255 @@ + + + + @foreach (GenderType gender in Enum.GetValues()) + { + @gender + } + + + + + + + + + + + + + + + + + + + @* Home Address Section *@ + + Home Address + + + + + + + + + + + + + + + + + + + + + + + + + + @* Work Address Section *@ + + Work Address + + + + + + + + + + + + + + + + + + + + + + + + + + +@code { + [Parameter] + public CreateUserRequest Model { get; set; } = new(); + + [Parameter] + public IDictionary? Errors { get; set; } + + private void UpdateHomeAddress(Func update) + { + var current = Model.HomeAddress ?? new Address(null, null, null, null, null); + Model.HomeAddress = update(current); + } + + private void UpdateHomeAddressCountry(Func update) + { + var currentAddress = Model.HomeAddress ?? new Address(null, null, null, null, null); + var currentCountry = currentAddress.Country ?? new Country(null, null, null); + Model.HomeAddress = currentAddress with { Country = update(currentCountry) }; + } + + private void UpdateWorkAddress(Func update) + { + var current = Model.WorkAddress ?? new Address(null, null, null, null, null); + Model.WorkAddress = update(current); + } + + private void UpdateWorkAddressCountry(Func update) + { + var currentAddress = Model.WorkAddress ?? new Address(null, null, null, null, null); + var currentCountry = currentAddress.Country ?? new Country(null, null, null); + Model.WorkAddress = currentAddress with { Country = update(currentCountry) }; + } + + private bool HasError(string fieldName) + { + if (Errors is null) + { + return false; + } + + return Errors.Keys.Any(k => k.Equals(fieldName, StringComparison.OrdinalIgnoreCase)); + } + + private string GetErrorText(string fieldName) + { + if (Errors is null) + { + return string.Empty; + } + + var key = Errors.Keys.FirstOrDefault(k => k.Equals(fieldName, StringComparison.OrdinalIgnoreCase)); + if (key is null) + { + return string.Empty; + } + + return string.Join("; ", Errors[key]); + } +} diff --git a/sample/src/Demo.Web/Components/Pages/Users.razor b/sample/src/Demo.Web/Components/Pages/Users.razor new file mode 100644 index 0000000..79c64a1 --- /dev/null +++ b/sample/src/Demo.Web/Components/Pages/Users.razor @@ -0,0 +1,252 @@ +@page "/users" +@inject IUserService UserService +@inject ISnackbar Snackbar + +Users + +Users Management + + Demonstrates CRUD operations with server-side validation error display. + + + + Create User + + +@if (_loading) +{ + +} + + + + Name + Email + Gender + Telephone + Actions + + + @context.FirstName @context.LastName + @context.Email + @context.Gender + @context.Telephone + + + + + + + No users found. Create one to get started! + + + +@* Create User Dialog *@ + + + + + Create User + + + + + + + + Cancel + + @if (_saving) + { + + } + Create + + + + +@* Edit User Dialog *@ + + + + + Edit User + + + + + + + + Cancel + + @if (_saving) + { + + } + Update + + + + +@code { + private List _users = []; + private bool _loading = true; + private bool _saving; + + private bool _createDialogVisible; + private bool _editDialogVisible; + private CreateUserRequest _createModel = new(); + private UpdateUserRequest _editModel = new(); + private Guid _editUserId; + private IDictionary? _validationErrors; + private int _validationVersion; + + private readonly DialogOptions _dialogOptions = new() + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseOnEscapeKey = true + }; + + protected override async Task OnInitializedAsync() + { + await LoadUsers(); + } + + private async Task LoadUsers() + { + _loading = true; + _users = (await UserService.GetAllUsersAsync()).ToList(); + _loading = false; + } + + private void OpenCreateDialog() + { + _createModel = new CreateUserRequest + { + Gender = GenderType.None, + HomeAddress = new Address(null, null, null, null, new Country(null, null, null)), + WorkAddress = new Address(null, null, null, null, new Country(null, null, null)) + }; + _validationErrors = null; + _createDialogVisible = true; + } + + private void CloseCreateDialog() + { + _createDialogVisible = false; + _validationErrors = null; + } + + private void OpenEditDialog(User user) + { + _editUserId = user.Id; + _editModel = new UpdateUserRequest + { + Gender = user.Gender, + FirstName = user.FirstName, + LastName = user.LastName, + Email = user.Email, + WorkAddress = user.WorkAddress ?? new Address(null, null, null, null, new Country(null, null, null)) + }; + _validationErrors = null; + _validationVersion++; + _editDialogVisible = true; + } + + private void CloseEditDialog() + { + _editDialogVisible = false; + _validationErrors = null; + } + + private async Task CreateUser() + { + _saving = true; + _validationErrors = null; + _validationVersion++; + + var (success, errors) = await UserService.CreateUserAsync(_createModel); + + if (success) + { + Snackbar.Add("User created successfully!", Severity.Success); + CloseCreateDialog(); + await LoadUsers(); + } + else if (errors?.Errors is not null && errors.Errors.Count > 0) + { + _validationErrors = errors.Errors; + _validationVersion++; + } + else if (!success) + { + Snackbar.Add(errors?.Detail ?? "Failed to create user", Severity.Error); + } + + _saving = false; + } + + private async Task UpdateUser() + { + _saving = true; + _validationErrors = null; + _validationVersion++; + + var (success, _, errors) = await UserService.UpdateUserAsync(_editUserId, _editModel); + + if (success) + { + Snackbar.Add("User updated successfully!", Severity.Success); + CloseEditDialog(); + await LoadUsers(); + } + else if (errors?.Errors is not null && errors.Errors.Count > 0) + { + _validationErrors = errors.Errors; + _validationVersion++; + } + else if (!success) + { + Snackbar.Add(errors?.Detail ?? "Failed to update user", Severity.Error); + } + + _saving = false; + } + + private async Task DeleteUser(User user) + { + var success = await UserService.DeleteUserAsync(user.Id); + + if (success) + { + Snackbar.Add("User deleted successfully!", Severity.Success); + await LoadUsers(); + } + else + { + Snackbar.Add("Failed to delete user", Severity.Error); + } + } +} diff --git a/sample/src/Demo.Web/Components/Pages/ValidationErrorsAlert.razor b/sample/src/Demo.Web/Components/Pages/ValidationErrorsAlert.razor new file mode 100644 index 0000000..277d2dc --- /dev/null +++ b/sample/src/Demo.Web/Components/Pages/ValidationErrorsAlert.razor @@ -0,0 +1,60 @@ +@if (Errors is not null && Errors.Count > 0) +{ + + Validation Errors: + + @foreach (var error in Errors) + { + @foreach (var message in error.Value) + { + + + @FormatFieldName(error.Key): @message + + + } + } + + +} + +@code { + [Parameter] + public IDictionary? Errors { get; set; } + + private static string FormatFieldName(string fieldName) + { + // Handle nested properties like "workAddress.cityName" + if (fieldName.Contains('.')) + { + var parts = fieldName.Split('.'); + return string.Join(" > ", parts.Select(FormatSingleField)); + } + + return FormatSingleField(fieldName); + } + + private static string FormatSingleField(string fieldName) + { + // Convert camelCase to Title Case with spaces + if (string.IsNullOrEmpty(fieldName)) + { + return fieldName; + } + + var result = new System.Text.StringBuilder(); + result.Append(char.ToUpper(fieldName[0])); + + for (var i = 1; i < fieldName.Length; i++) + { + if (char.IsUpper(fieldName[i])) + { + result.Append(' '); + } + + result.Append(fieldName[i]); + } + + return result.ToString(); + } +} diff --git a/sample/src/Demo.Web/Components/Routes.razor b/sample/src/Demo.Web/Components/Routes.razor new file mode 100644 index 0000000..faa2a8c --- /dev/null +++ b/sample/src/Demo.Web/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/sample/src/Demo.Web/Components/_Imports.razor b/sample/src/Demo.Web/Components/_Imports.razor new file mode 100644 index 0000000..9baaa52 --- /dev/null +++ b/sample/src/Demo.Web/Components/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Demo.Web +@using Demo.Web.Components +@using Demo.Web.Models +@using Demo.Web.Services +@using MudBlazor diff --git a/sample/src/Demo.Web/Demo.Web.csproj b/sample/src/Demo.Web/Demo.Web.csproj new file mode 100644 index 0000000..873d69d --- /dev/null +++ b/sample/src/Demo.Web/Demo.Web.csproj @@ -0,0 +1,18 @@ + + + + false + + + + + + + + + + + + + + diff --git a/sample/src/Demo.Web/GlobalUsings.cs b/sample/src/Demo.Web/GlobalUsings.cs new file mode 100644 index 0000000..484e032 --- /dev/null +++ b/sample/src/Demo.Web/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using System.Net; +global using System.Text.Json; +global using System.Text.Json.Serialization; +global using Demo.Web.Models; \ No newline at end of file diff --git a/sample/src/Demo.Web/Models/Address.cs b/sample/src/Demo.Web/Models/Address.cs new file mode 100644 index 0000000..af2466f --- /dev/null +++ b/sample/src/Demo.Web/Models/Address.cs @@ -0,0 +1,8 @@ +namespace Demo.Web.Models; + +public record Address( + string? StreetName, + string? StreetNumber, + string? PostalCode, + [property: JsonPropertyName("cityName")] string? CityName, + Country? Country); diff --git a/sample/src/Demo.Web/Models/ApiValidationProblemDetails.cs b/sample/src/Demo.Web/Models/ApiValidationProblemDetails.cs new file mode 100644 index 0000000..7f4149b --- /dev/null +++ b/sample/src/Demo.Web/Models/ApiValidationProblemDetails.cs @@ -0,0 +1,28 @@ +namespace Demo.Web.Models; + +/// +/// Represents validation problem details from the API. +/// This is a simple DTO for deserializing RFC 7807 problem details responses. +/// +/// +/// We use a custom class instead of HttpValidationProblemDetails because +/// the built-in class has a read-only Errors property that cannot be +/// populated by System.Text.Json during deserialization. +/// +public sealed class ApiValidationProblemDetails +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("status")] + public int? Status { get; set; } + + [JsonPropertyName("detail")] + public string? Detail { get; set; } + + [JsonPropertyName("errors")] + public IDictionary? Errors { get; set; } +} diff --git a/sample/src/Demo.Web/Models/Country.cs b/sample/src/Demo.Web/Models/Country.cs new file mode 100644 index 0000000..1c4009f --- /dev/null +++ b/sample/src/Demo.Web/Models/Country.cs @@ -0,0 +1,6 @@ +namespace Demo.Web.Models; + +public record Country( + string? Name, + string? Alpha2Code, + string? Alpha3Code); diff --git a/sample/src/Demo.Web/Models/CreateUserRequest.cs b/sample/src/Demo.Web/Models/CreateUserRequest.cs new file mode 100644 index 0000000..b07935e --- /dev/null +++ b/sample/src/Demo.Web/Models/CreateUserRequest.cs @@ -0,0 +1,21 @@ +namespace Demo.Web.Models; + +public class CreateUserRequest +{ + public GenderType Gender { get; set; } + + [JsonPropertyName("firstName")] + public string FirstName { get; set; } = string.Empty; + + public string LastName { get; set; } = string.Empty; + + public string Email { get; set; } = string.Empty; + + public string? Telephone { get; set; } + + public string? HomePage { get; set; } + + public Address? HomeAddress { get; set; } + + public Address? WorkAddress { get; set; } +} \ No newline at end of file diff --git a/sample/src/Demo.Web/Models/GenderType.cs b/sample/src/Demo.Web/Models/GenderType.cs new file mode 100644 index 0000000..631f9ac --- /dev/null +++ b/sample/src/Demo.Web/Models/GenderType.cs @@ -0,0 +1,10 @@ +namespace Demo.Web.Models; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum GenderType +{ + None, + NonBinary, + Male, + Female, +} \ No newline at end of file diff --git a/sample/src/Demo.Web/Models/UpdateUserRequest.cs b/sample/src/Demo.Web/Models/UpdateUserRequest.cs new file mode 100644 index 0000000..0a85027 --- /dev/null +++ b/sample/src/Demo.Web/Models/UpdateUserRequest.cs @@ -0,0 +1,16 @@ +namespace Demo.Web.Models; + +public class UpdateUserRequest +{ + public GenderType Gender { get; set; } + + [JsonPropertyName("firstName")] + public string FirstName { get; set; } = string.Empty; + + public string LastName { get; set; } = string.Empty; + + public string Email { get; set; } = string.Empty; + + [JsonPropertyName("address")] + public Address? WorkAddress { get; set; } +} \ No newline at end of file diff --git a/sample/src/Demo.Web/Models/User.cs b/sample/src/Demo.Web/Models/User.cs new file mode 100644 index 0000000..6798a0b --- /dev/null +++ b/sample/src/Demo.Web/Models/User.cs @@ -0,0 +1,12 @@ +namespace Demo.Web.Models; + +public record User( + Guid Id, + GenderType Gender, + string FirstName, + string LastName, + string Email, + string? Telephone, + string? HomePage, + Address? HomeAddress, + Address? WorkAddress); \ No newline at end of file diff --git a/sample/src/Demo.Web/Program.cs b/sample/src/Demo.Web/Program.cs new file mode 100644 index 0000000..8cdafef --- /dev/null +++ b/sample/src/Demo.Web/Program.cs @@ -0,0 +1,70 @@ +using Demo.Web.Components; +using Demo.Web.Services; +using MudBlazor.Services; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +var builder = WebApplication.CreateBuilder(args); + +// Add MudBlazor services +builder.Services.AddMudServices(); + +// Add Razor components +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +// Configure HttpClient for API with service discovery +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https+http://api"); +}) +.AddServiceDiscovery() +.AddStandardResilienceHandler(); + +// Add service discovery +builder.Services.AddServiceDiscovery(); + +// Configure OpenTelemetry +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + }); + +// Add OTLP exporters if configured +var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; +if (!string.IsNullOrWhiteSpace(otlpEndpoint)) +{ + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => metrics.AddOtlpExporter()) + .WithTracing(tracing => tracing.AddOtlpExporter()); + + builder.Logging.AddOpenTelemetry(logging => logging.AddOtlpExporter()); +} + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/sample/src/Demo.Web/Properties/launchSettings.json b/sample/src/Demo.Web/Properties/launchSettings.json new file mode 100644 index 0000000..ef393a4 --- /dev/null +++ b/sample/src/Demo.Web/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7200;http://localhost:5200", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5200", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/sample/src/Demo.Web/Services/IUserService.cs b/sample/src/Demo.Web/Services/IUserService.cs new file mode 100644 index 0000000..c5a357a --- /dev/null +++ b/sample/src/Demo.Web/Services/IUserService.cs @@ -0,0 +1,19 @@ +namespace Demo.Web.Services; + +public interface IUserService +{ + Task> GetAllUsersAsync(CancellationToken cancellationToken = default); + + Task GetUserByIdAsync(Guid userId, CancellationToken cancellationToken = default); + + Task<(bool Success, ApiValidationProblemDetails? Errors)> CreateUserAsync( + CreateUserRequest request, + CancellationToken cancellationToken = default); + + Task<(bool Success, User? User, ApiValidationProblemDetails? Errors)> UpdateUserAsync( + Guid userId, + UpdateUserRequest request, + CancellationToken cancellationToken = default); + + Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/sample/src/Demo.Web/Services/UserService.cs b/sample/src/Demo.Web/Services/UserService.cs new file mode 100644 index 0000000..284219a --- /dev/null +++ b/sample/src/Demo.Web/Services/UserService.cs @@ -0,0 +1,166 @@ +namespace Demo.Web.Services; + +public class UserService : IUserService +{ + private readonly HttpClient httpClient; + private readonly ILogger logger; + + public UserService( + HttpClient httpClient, + ILogger logger) + { + this.httpClient = httpClient; + this.logger = logger; + } + + public async Task> GetAllUsersAsync(CancellationToken cancellationToken = default) + { + try + { + var users = await httpClient.GetFromJsonAsync>( + "/api/users", + cancellationToken); + + return users ?? []; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get all users"); + return []; + } + } + + public async Task GetUserByIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + try + { + return await httpClient.GetFromJsonAsync( + $"/api/users/{userId}", + cancellationToken); + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get user {UserId}", userId); + return null; + } + } + + public async Task<(bool Success, ApiValidationProblemDetails? Errors)> CreateUserAsync( + CreateUserRequest request, + CancellationToken cancellationToken = default) + { + try + { + var response = await httpClient.PostAsJsonAsync( + "/api/users", + request, + cancellationToken); + + if (response.IsSuccessStatusCode) + { + return (true, null); + } + + if (response.StatusCode == HttpStatusCode.BadRequest) + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + var problemDetails = await response.Content.ReadFromJsonAsync( + options, + cancellationToken); + + return (false, problemDetails); + } + + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogError("Create user failed with status {StatusCode}: {Content}", response.StatusCode, errorContent); + + return (false, new ApiValidationProblemDetails + { + Title = "Error", + Status = (int)response.StatusCode, + Detail = errorContent, + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create user"); + return (false, new ApiValidationProblemDetails + { + Title = "Error", + Status = 500, + Detail = ex.Message, + }); + } + } + + public async Task<(bool Success, User? User, ApiValidationProblemDetails? Errors)> UpdateUserAsync( + Guid userId, + UpdateUserRequest request, + CancellationToken cancellationToken = default) + { + try + { + var response = await httpClient.PutAsJsonAsync( + $"/api/users/{userId}", + request, + cancellationToken); + + if (response.IsSuccessStatusCode) + { + var user = await response.Content.ReadFromJsonAsync(cancellationToken); + return (true, user, null); + } + + if (response.StatusCode == HttpStatusCode.BadRequest) + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + var problemDetails = await response.Content.ReadFromJsonAsync( + options, + cancellationToken); + + return (false, null, problemDetails); + } + + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogError("Update user failed with status {StatusCode}: {Content}", response.StatusCode, errorContent); + + return (false, null, new ApiValidationProblemDetails + { + Title = "Error", + Status = (int)response.StatusCode, + Detail = errorContent, + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update user {UserId}", userId); + return (false, null, new ApiValidationProblemDetails + { + Title = "Error", + Status = 500, + Detail = ex.Message, + }); + } + } + + public async Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken = default) + { + try + { + var response = await httpClient.DeleteAsync( + $"/api/users/{userId}", + cancellationToken); + + return response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NoContent; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete user {UserId}", userId); + return false; + } + } +} diff --git a/sample/src/Demo.Web/appsettings.json b/sample/src/Demo.Web/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/sample/src/Demo.Web/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From afe78f84b778a8c29f5ab418ec5137484fc46bbb Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 3 Dec 2025 12:21:29 +0100 Subject: [PATCH 10/54] chore(test): migrate to Microsoft.Testing.Platform and xUnit v3 --- .github/workflows/post-integration.yml | 4 ++-- .github/workflows/pre-integration.yml | 6 ++--- .github/workflows/release.yml | 2 +- global.json | 3 +++ .../Atc.Rest.MinimalApi.Tests.csproj | 16 ------------- test/Directory.Build.props | 23 ++++++++++++++++--- 6 files changed, 29 insertions(+), 25 deletions(-) diff --git a/.github/workflows/post-integration.yml b/.github/workflows/post-integration.yml index f0115f4..1ff459b 100644 --- a/.github/workflows/post-integration.yml +++ b/.github/workflows/post-integration.yml @@ -31,7 +31,7 @@ jobs: setAllVars: true - name: โš™๏ธ Setup dotnet 10.0.x - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' @@ -45,7 +45,7 @@ jobs: run: dotnet build -c Release --no-restore /p:UseSourceLink=true - name: ๐Ÿงช Run unit tests - run: dotnet test -c Release --no-build --filter "Category!=Integration" + run: dotnet test -c Release --no-build --filter-query "/[category!=integration]" --ignore-exit-code 8 - name: โฉ Merge to stable-branch run: | diff --git a/.github/workflows/pre-integration.yml b/.github/workflows/pre-integration.yml index 115283d..077223c 100644 --- a/.github/workflows/pre-integration.yml +++ b/.github/workflows/pre-integration.yml @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 - name: โš™๏ธ Setup dotnet 10.0.x - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' @@ -44,7 +44,7 @@ jobs: fetch-depth: 0 - name: โš™๏ธ Setup dotnet 10.0.x - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' @@ -55,4 +55,4 @@ jobs: run: dotnet build -c Release --no-restore /p:UseSourceLink=true - name: ๐Ÿงช Run unit tests - run: dotnet test -c Release --no-build --filter "Category!=Integration" \ No newline at end of file + run: dotnet test -c Release --no-build --filter-query "/[category!=integration]" --ignore-exit-code 8 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c17e07..d1a443f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: setAllVars: true - name: โš™๏ธ Setup dotnet 10.0.x - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' diff --git a/global.json b/global.json index 86298f9..7d7ff8a 100644 --- a/global.json +++ b/global.json @@ -2,5 +2,8 @@ "sdk": { "rollForward": "latestMajor", "allowPrerelease": false + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } \ No newline at end of file diff --git a/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj b/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj index 1b73270..b90df6c 100644 --- a/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj +++ b/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj @@ -1,27 +1,11 @@ - net10.0 false true 1591;9057 - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 3e568d2..7bb8c7e 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -6,19 +6,36 @@ --> + + true + true + true + + annotations - - + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - + From 01a191e53830059d54b9e4a776b52759846fc47c Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 3 Dec 2025 12:22:35 +0100 Subject: [PATCH 11/54] feat(sample): add health check endpoint --- sample/src/Demo.Api/Program.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sample/src/Demo.Api/Program.cs b/sample/src/Demo.Api/Program.cs index 3bfab59..dff1de7 100644 --- a/sample/src/Demo.Api/Program.cs +++ b/sample/src/Demo.Api/Program.cs @@ -38,8 +38,12 @@ SkipFirstLevelOnValidationKeys = true, }); +services.AddHealthChecks(); + var app = builder.Build(); +app.MapHealthChecks("/health"); + app.UseEndpointDefinitions(); app.AddGlobalErrorHandler(); From b8200f808821f84bb3289ec5cfd1a149f3ea82b1 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 4 Dec 2025 15:05:14 +0100 Subject: [PATCH 12/54] chore(deps): upgrade aspire to 13.0.2 --- sample/src/Demo.AppHost/Demo.AppHost.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/src/Demo.AppHost/Demo.AppHost.csproj b/sample/src/Demo.AppHost/Demo.AppHost.csproj index e04f7c5..84ac403 100644 --- a/sample/src/Demo.AppHost/Demo.AppHost.csproj +++ b/sample/src/Demo.AppHost/Demo.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe From 55ca145532d1e29cfee72a20233b415c23c9c7e0 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 4 Dec 2025 15:06:09 +0100 Subject: [PATCH 13/54] fix(sample): ensure proper serialization in demo web --- sample/src/Demo.Web/Demo.Web.csproj | 1 + sample/src/Demo.Web/GlobalUsings.cs | 8 +++++++- sample/src/Demo.Web/Models/GenderType.cs | 1 - sample/src/Demo.Web/Program.cs | 15 +++++---------- sample/src/Demo.Web/Services/UserService.cs | 15 ++++++++++----- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/sample/src/Demo.Web/Demo.Web.csproj b/sample/src/Demo.Web/Demo.Web.csproj index 873d69d..350ef32 100644 --- a/sample/src/Demo.Web/Demo.Web.csproj +++ b/sample/src/Demo.Web/Demo.Web.csproj @@ -5,6 +5,7 @@ + diff --git a/sample/src/Demo.Web/GlobalUsings.cs b/sample/src/Demo.Web/GlobalUsings.cs index 484e032..97fef47 100644 --- a/sample/src/Demo.Web/GlobalUsings.cs +++ b/sample/src/Demo.Web/GlobalUsings.cs @@ -1,4 +1,10 @@ global using System.Net; global using System.Text.Json; global using System.Text.Json.Serialization; -global using Demo.Web.Models; \ No newline at end of file +global using Demo.Web.Components; +global using Demo.Web.Models; +global using Demo.Web.Services; +global using MudBlazor.Services; +global using OpenTelemetry.Logs; +global using OpenTelemetry.Metrics; +global using OpenTelemetry.Trace; \ No newline at end of file diff --git a/sample/src/Demo.Web/Models/GenderType.cs b/sample/src/Demo.Web/Models/GenderType.cs index 631f9ac..48f0d52 100644 --- a/sample/src/Demo.Web/Models/GenderType.cs +++ b/sample/src/Demo.Web/Models/GenderType.cs @@ -1,6 +1,5 @@ namespace Demo.Web.Models; -[JsonConverter(typeof(JsonStringEnumConverter))] public enum GenderType { None, diff --git a/sample/src/Demo.Web/Program.cs b/sample/src/Demo.Web/Program.cs index 8cdafef..dd6ed95 100644 --- a/sample/src/Demo.Web/Program.cs +++ b/sample/src/Demo.Web/Program.cs @@ -1,17 +1,13 @@ -using Demo.Web.Components; -using Demo.Web.Services; -using MudBlazor.Services; -using OpenTelemetry.Logs; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; - var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSingleton(Atc.Serialization.JsonSerializerOptionsFactory.Create()); + // Add MudBlazor services builder.Services.AddMudServices(); // Add Razor components -builder.Services.AddRazorComponents() +builder.Services + .AddRazorComponents() .AddInteractiveServerComponents(); // Configure HttpClient for API with service discovery @@ -41,7 +37,6 @@ .AddHttpClientInstrumentation(); }); -// Add OTLP exporters if configured var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; if (!string.IsNullOrWhiteSpace(otlpEndpoint)) { @@ -67,4 +62,4 @@ app.MapRazorComponents() .AddInteractiveServerRenderMode(); -app.Run(); +await app.RunAsync(); \ No newline at end of file diff --git a/sample/src/Demo.Web/Services/UserService.cs b/sample/src/Demo.Web/Services/UserService.cs index 284219a..7ed9435 100644 --- a/sample/src/Demo.Web/Services/UserService.cs +++ b/sample/src/Demo.Web/Services/UserService.cs @@ -3,13 +3,16 @@ namespace Demo.Web.Services; public class UserService : IUserService { private readonly HttpClient httpClient; + private readonly JsonSerializerOptions jsonSerializerOptions; private readonly ILogger logger; public UserService( HttpClient httpClient, + JsonSerializerOptions jsonSerializerOptions, ILogger logger) { this.httpClient = httpClient; + this.jsonSerializerOptions = jsonSerializerOptions; this.logger = logger; } @@ -19,6 +22,7 @@ public async Task> GetAllUsersAsync(CancellationToken cancella { var users = await httpClient.GetFromJsonAsync>( "/api/users", + jsonSerializerOptions, cancellationToken); return users ?? []; @@ -36,6 +40,7 @@ public async Task> GetAllUsersAsync(CancellationToken cancella { return await httpClient.GetFromJsonAsync( $"/api/users/{userId}", + jsonSerializerOptions, cancellationToken); } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) @@ -58,6 +63,7 @@ public async Task> GetAllUsersAsync(CancellationToken cancella var response = await httpClient.PostAsJsonAsync( "/api/users", request, + jsonSerializerOptions, cancellationToken); if (response.IsSuccessStatusCode) @@ -67,9 +73,8 @@ public async Task> GetAllUsersAsync(CancellationToken cancella if (response.StatusCode == HttpStatusCode.BadRequest) { - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); var problemDetails = await response.Content.ReadFromJsonAsync( - options, + jsonSerializerOptions, cancellationToken); return (false, problemDetails); @@ -107,19 +112,19 @@ public async Task> GetAllUsersAsync(CancellationToken cancella var response = await httpClient.PutAsJsonAsync( $"/api/users/{userId}", request, + jsonSerializerOptions, cancellationToken); if (response.IsSuccessStatusCode) { - var user = await response.Content.ReadFromJsonAsync(cancellationToken); + var user = await response.Content.ReadFromJsonAsync(jsonSerializerOptions, cancellationToken); return (true, user, null); } if (response.StatusCode == HttpStatusCode.BadRequest) { - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); var problemDetails = await response.Content.ReadFromJsonAsync( - options, + jsonSerializerOptions, cancellationToken); return (false, null, problemDetails); From 36a0a016b6226ee63f83b2a77a464fd307b71c90 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 4 Dec 2025 15:07:20 +0100 Subject: [PATCH 14/54] feat(validation): add nested [FromBody] property validation and tests --- .../Filters/Endpoints/ValidationFilter.cs | 154 ++++++++++- .../Endpoints/ValidationFilterTests.cs | 253 ++++++++++++++++++ 2 files changed, 396 insertions(+), 11 deletions(-) create mode 100644 test/Atc.Rest.MinimalApi.Tests/Filters/Endpoints/ValidationFilterTests.cs diff --git a/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs b/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs index 16dc006..50a8ff7 100644 --- a/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs +++ b/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs @@ -3,10 +3,41 @@ namespace Atc.Rest.MinimalApi.Filters.Endpoints; /// /// Represents a validation filter that integrates with Minimal API endpoints. /// This class provides the following functionality: -/// 1. Utilizes both DataAnnotations validation and FluentValidation to validate the incoming request against a specific model. -/// 2. Merges validation errors from both methods, ensuring that no duplicates exist, and returns a validation problem if errors are detected. -/// 3. Automatically resolves the proper serialization names for the validation keys/values using the provided object's properties (e.g., JsonPropertyName attributes). +/// +/// Utilizes both DataAnnotations validation (via MiniValidation) and FluentValidation to validate the incoming request against a specific model. +/// Merges validation errors from both methods, ensuring that no duplicates exist, and returns a validation problem if errors are detected. +/// Automatically resolves the proper serialization names for the validation keys/values using the provided object's properties (e.g., JsonPropertyName attributes). +/// Supports nested validation for [FromBody] properties - validators registered for nested types will be automatically discovered and executed. +/// /// +/// +/// +/// This filter uses MiniValidation for DataAnnotations validation across all .NET versions (8, 9, and 10) to ensure consistent behavior and enable error merging. +/// +/// +/// Key Features: +/// +/// Unified error responses (merges DataAnnotations + FluentValidation errors) +/// Serialization name resolution (respects JsonPropertyName attributes) +/// Consistent validation behavior across .NET 8/9/10 +/// No additional configuration required (unlike .NET 10's native validation which requires InterceptorsNamespaces) +/// Nested [FromBody] validator discovery - use ValidationFilter<CreateUserParameters> with IValidator<CreateUserRequest> +/// +/// +/// +/// Example - Nested Validation: +/// +/// // Parameters wrapper with [FromBody] property +/// public record CreateUserParameters([property: FromBody] CreateUserRequest Request); +/// +/// // Validator for the nested request type +/// public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest> { ... } +/// +/// // The filter will automatically find and execute CreateUserRequestValidator +/// .AddEndpointFilter<ValidationFilter<CreateUserParameters>>() +/// +/// +/// /// The type of object to validate. public class ValidationFilter : IEndpointFilter where T : class @@ -63,23 +94,124 @@ private static async Task> ValidateUsingFluentValid { var result = new Dictionary(StringComparer.Ordinal); + // First, try to validate the main object (existing behavior) var validator = context.HttpContext.RequestServices.GetService>(); - if (validator is null) + if (validator is not null) { - return result; + var validationResult = await validator.ValidateAsync( + (T)objectToValidate, + context.HttpContext.RequestAborted); + + if (!validationResult.IsValid) + { + foreach (var error in validationResult.ToDictionary()) + { + result.Add(error.Key, error.Value); + } + } } - var validationResult = await validator.ValidateAsync((T)objectToValidate, context.HttpContext.RequestAborted); - if (validationResult.IsValid) + // Then, look for [FromBody] properties and validate them with their own validators + await ValidateNestedFromBodyProperties(context, objectToValidate, result); + + return result; + } + + private static async Task ValidateNestedFromBodyProperties( + EndpointFilterInvocationContext context, + object objectToValidate, + Dictionary errors) + { + var properties = typeof(T) + .GetProperties() + .Where(p => p.GetCustomAttribute() is not null); + + foreach (var property in properties) { - return result; + var propertyValue = property.GetValue(objectToValidate); + if (propertyValue is null) + { + continue; + } + + var propertyType = property.PropertyType; + var validatorType = typeof(IValidator<>).MakeGenericType(propertyType); + + // Cast to non-generic IValidator interface + if (context.HttpContext.RequestServices.GetService(validatorType) is not IValidator validator) + { + continue; + } + + // Create ValidationContext dynamically + var contextType = typeof(ValidationContext<>).MakeGenericType(propertyType); + var validationContext = (FluentValidation.IValidationContext)Activator.CreateInstance(contextType, propertyValue)!; + + // Use the non-generic IValidator.ValidateAsync directly + var validationResult = await validator.ValidateAsync( + validationContext, + context.HttpContext.RequestAborted); + + if (validationResult.IsValid) + { + continue; + } + + // Resolve JSON property names and prefix with [FromBody] property name + // This ensures proper first-level stripping when SkipFirstLevelOnValidationKeys=true + foreach (var error in validationResult.ToDictionary()) + { + var resolvedKey = ResolveJsonPropertyName(propertyType, error.Key); + + // Prefix with the [FromBody] property name for proper first-level stripping + // e.g., "WorkAddress.cityName" becomes "Request.WorkAddress.cityName" + var prefixedKey = $"{property.Name}.{resolvedKey}"; + + // Avoid duplicate keys - first error wins + if (!errors.ContainsKey(prefixedKey)) + { + errors.Add(prefixedKey, error.Value); + } + } + } + } + + private static string ResolveJsonPropertyName( + Type type, + string propertyName) + { + // Handle nested properties like "WorkAddress.CityName" + if (propertyName.Contains('.', StringComparison.Ordinal)) + { + var parts = propertyName.Split('.'); + var resolvedParts = new List(); + var currentType = type; + + foreach (var part in parts) + { + var propInfo = currentType.GetProperty(part); + if (propInfo is null) + { + resolvedParts.Add(part); + continue; + } + + var jsonAttr = propInfo.GetCustomAttribute(); + resolvedParts.Add(jsonAttr?.Name ?? part); + currentType = propInfo.PropertyType; + } + + return string.Join('.', resolvedParts); } - foreach (var error in validationResult.ToDictionary()) + // Simple property name + var property = type.GetProperty(propertyName); + if (property is null) { - result.Add(error.Key, error.Value); + return propertyName; } - return result; + var jsonPropertyName = property.GetCustomAttribute(); + return jsonPropertyName?.Name ?? propertyName; } } \ No newline at end of file diff --git a/test/Atc.Rest.MinimalApi.Tests/Filters/Endpoints/ValidationFilterTests.cs b/test/Atc.Rest.MinimalApi.Tests/Filters/Endpoints/ValidationFilterTests.cs new file mode 100644 index 0000000..f6d0678 --- /dev/null +++ b/test/Atc.Rest.MinimalApi.Tests/Filters/Endpoints/ValidationFilterTests.cs @@ -0,0 +1,253 @@ +namespace Atc.Rest.MinimalApi.Tests.Filters.Endpoints; + +public sealed class ValidationFilterTests +{ + [Fact] + public async Task InvokeAsync_WithValidModel_CallsNext() + { + // Arrange + var model = new CreateLocationRequestWithJsonPropertyNames( + new AddressWithJsonPropertyNames("DNK", "Place", "Street", "PostalCode", "City"), + "12345678"); + + var (filter, context, nextWasCalled) = CreateTestContext( + model, + new CreateLocationRequestWithJsonPropertyNamesValidator()); + + // Act + var result = await filter.InvokeAsync(context, _ => + { + nextWasCalled.Value = true; + return ValueTask.FromResult("Success"); + }); + + // Assert + Assert.True(nextWasCalled.Value, "Next delegate should have been called for valid model"); + Assert.Equal("Success", result); + } + + [Fact] + public async Task InvokeAsync_WithInvalidDataAnnotations_ReturnsValidationProblem() + { + // Arrange - Address.CountryCodeA3 has [MinLength(3)] and [MaxLength(3)] attributes + var model = new CreateLocationRequestWithJsonPropertyNames( + new AddressWithJsonPropertyNames("DK", "Place", "Street", "PostalCode", "City"), // DK is only 2 chars, violates MinLength(3) + "12345678"); + + var (filter, context, nextWasCalled) = CreateTestContext(model); + + // Act + var result = await filter.InvokeAsync(context, _ => + { + nextWasCalled.Value = true; + return ValueTask.FromResult("Success"); + }); + + // Assert + Assert.False(nextWasCalled.Value, "Next delegate should NOT have been called when DataAnnotations validation fails"); + var validationProblem = Assert.IsType(result); + Assert.NotEmpty(validationProblem.ProblemDetails.Errors); + } + + [Fact] + public async Task InvokeAsync_WithInvalidFluentValidation_ReturnsValidationProblem() + { + // Arrange - Telephone must not be empty according to FluentValidation + var model = new CreateLocationRequestWithJsonPropertyNames( + new AddressWithJsonPropertyNames("DNK", "Place", "Street", "PostalCode", "City"), + ""); // Empty telephone violates FluentValidation rule + + var (filter, context, nextWasCalled) = CreateTestContext( + model, + new CreateLocationRequestWithJsonPropertyNamesValidator()); + + // Act + var result = await filter.InvokeAsync(context, _ => + { + nextWasCalled.Value = true; + return ValueTask.FromResult("Success"); + }); + + // Assert + Assert.False(nextWasCalled.Value, "Next delegate should NOT have been called when FluentValidation fails"); + var validationProblem = Assert.IsType(result); + Assert.NotEmpty(validationProblem.ProblemDetails.Errors); + Assert.Contains(validationProblem.ProblemDetails.Errors, e => + e.Value.Any(v => v.Contains("Telephone", StringComparison.OrdinalIgnoreCase))); + } + + [Fact] + public async Task InvokeAsync_WithBothValidationErrors_MergesErrors() + { + // Arrange - Both DataAnnotations (CountryCodeA3 too short) and FluentValidation (Telephone empty) fail + var model = new CreateLocationRequestWithJsonPropertyNames( + new AddressWithJsonPropertyNames("DK", "Place", "Street", "PostalCode", "City"), // DK violates MinLength(3) + ""); // Empty violates FluentValidation + + var (filter, context, nextWasCalled) = CreateTestContext( + model, + new CreateLocationRequestWithJsonPropertyNamesValidator()); + + // Act + var result = await filter.InvokeAsync(context, _ => + { + nextWasCalled.Value = true; + return ValueTask.FromResult("Success"); + }); + + // Assert + Assert.False(nextWasCalled.Value); + var validationProblem = Assert.IsType(result); + + // Should have errors from both validation sources + Assert.True( + validationProblem.ProblemDetails.Errors.Count >= 2, + "Should have errors from both DataAnnotations and FluentValidation"); + } + + [Fact] + public async Task InvokeAsync_WithMissingArgument_ReturnsBadRequest() + { + // Arrange - Create context without the expected argument type + var services = new ServiceCollection().BuildServiceProvider(); + var httpContext = new DefaultHttpContext { RequestServices = services }; + + var context = new DefaultEndpointFilterInvocationContext( + httpContext, + "some string argument"); // Wrong type - filter expects CreateLocationRequestWithJsonPropertyNames + + var filter = new ValidationFilter(); + + // Act + var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("Success")); + + // Assert + var badRequest = Assert.IsType>(result); + Assert.Contains("Could not find argument to validate", badRequest.Value, StringComparison.Ordinal); + } + + [Fact] + public async Task InvokeAsync_WithNestedFromBodyProperty_ValidatesNestedType() + { + // Arrange - CreateLocationRequestWithRequest has [FromBody] on Request property + var model = new CreateLocationRequestWithRequest( + "LocationId", + new CreateLocationRequestWithJsonPropertyNames( + new AddressWithJsonPropertyNames("DNK", "Place", "Street", "PostalCode", "City"), + "")); // Empty telephone should trigger nested validation + + // Register validator for the nested type (CreateLocationRequestWithJsonPropertyNames) + var (filter, context, nextWasCalled) = CreateTestContext( + model, + new CreateLocationRequestWithJsonPropertyNamesValidator()); // Validator for the [FromBody] property type + + // Act + var result = await filter.InvokeAsync(context, _ => + { + nextWasCalled.Value = true; + return ValueTask.FromResult("Success"); + }); + + // Assert - Should fail because nested [FromBody] property validation should run + Assert.False(nextWasCalled.Value, "Next delegate should NOT have been called when nested validation fails"); + var validationProblem = Assert.IsType(result); + Assert.NotEmpty(validationProblem.ProblemDetails.Errors); + } + + [Fact] + public async Task InvokeAsync_WithJsonPropertyNames_ResolvesSerializationNames() + { + // Arrange - AddressWithJsonPropertyNames has [JsonPropertyName("country_code_a3")] attribute + var model = new CreateLocationRequestWithJsonPropertyNames( + new AddressWithJsonPropertyNames("DK", "Place", "Street", "PostalCode", "City"), // DK is too short + "12345678"); + + var (filter, context, _) = CreateTestContext( + model, + new CreateLocationRequestWithJsonPropertyNamesValidator()); + + // Act + var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("Success")); + + // Assert - Error key should use the JSON property name + var validationProblem = Assert.IsType(result); + + // The key should be resolved to use JSON property names like "address.country_code_a3" + Assert.Contains(validationProblem.ProblemDetails.Errors, e => + e.Key.Contains("country_code_a3", StringComparison.Ordinal) || + e.Key.Contains("CountryCodeA3", StringComparison.Ordinal)); + } + + [Fact] + public async Task InvokeAsync_WithSkipFirstLevel_StripsPrefix() + { + // Arrange - Model with Request wrapper (simulating parameter binding) + var model = new CreateLocationRequestWithRequest( + "LocationId", + new CreateLocationRequestWithJsonPropertyNames( + new AddressWithJsonPropertyNames("DNK", "Place", "Street", "PostalCode", "C"), // City too short + "1")); // Telephone too short + + var options = new ValidationFilterOptions { SkipFirstLevelOnValidationKeys = true }; + var (filter, context, _) = CreateTestContext( + model, + options, + new CreateLocationRequestWithRequestValidator()); + + // Act + var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("Success")); + + // Assert - Error keys should have first level stripped (no "Request." prefix) + var validationProblem = Assert.IsType(result); + Assert.NotEmpty(validationProblem.ProblemDetails.Errors); + + // Keys should NOT start with "Request." + foreach (var (key, _) in validationProblem.ProblemDetails.Errors) + { + Assert.False( + key.StartsWith("Request.", StringComparison.Ordinal), + $"Key '{key}' should not start with 'Request.' when SkipFirstLevelOnValidationKeys is true"); + } + } + + private static (ValidationFilter Filter, EndpointFilterInvocationContext Context, StrongBox NextWasCalled) + CreateTestContext( + T argument, + params IValidator[] validators) + where T : class + => CreateTestContext(argument, validationFilterOptions: null, validators); + + private static (ValidationFilter Filter, EndpointFilterInvocationContext Context, StrongBox NextWasCalled) + CreateTestContext( + T argument, + ValidationFilterOptions? validationFilterOptions, + params IValidator[] validators) + where T : class + { + var services = new ServiceCollection(); + + foreach (var validator in validators) + { + var validatorType = validator.GetType(); + var interfaces = validatorType + .GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValidator<>)); + + foreach (var @interface in interfaces) + { + services.AddSingleton(@interface, validator); + } + } + + var httpContext = new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider(), + }; + + var context = new DefaultEndpointFilterInvocationContext(httpContext, argument); + var filter = new ValidationFilter(validationFilterOptions); + var nextWasCalled = new StrongBox(value: false); + + return (filter, context, nextWasCalled); + } +} \ No newline at end of file From 0660b25fb6f7694f6e6cd6a7e9bbff56ebcb8aeb Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 4 Dec 2025 15:08:07 +0100 Subject: [PATCH 15/54] fix(validation): prevent duplicate key errors and add merge tests --- .../Extensions/ValidationProblemExtensions.cs | 54 +++++++-- .../ValidationProblemExtensionsTests.cs | 113 +++++++++++++++++- .../Atc.Rest.MinimalApi.Tests/GlobalUsings.cs | 4 + 3 files changed, 154 insertions(+), 17 deletions(-) diff --git a/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs b/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs index 0b5058e..4e0a3f6 100644 --- a/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs +++ b/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs @@ -54,7 +54,7 @@ public static ValidationProblem ResolveSerializationTypeNames( var propertyInfo = propertyInfos.FirstOrDefault(x => x.Name.Equals(key, StringComparison.Ordinal)); if (propertyInfo is null) { - newErrors.Add(key, values); + AddOrMergeErrors(newErrors, key, values); continue; } @@ -63,7 +63,7 @@ public static ValidationProblem ResolveSerializationTypeNames( .FirstOrDefault() as JsonPropertyNameAttribute; var newKey = jsonPropertyNameAttribute?.Name ?? key; - newErrors.Add(newKey, values); + AddOrMergeErrors(newErrors, newKey, values); if (key != newKey) { @@ -87,17 +87,25 @@ private static void DeepResolveSerializationNames( var newKey = new StringBuilder(); string? name = null; + var hasContent = false; while (depth.Any()) { var errorName = depth[0]; var propertyName = RemoveCollectionIndexer(errorName); - var propertyInfo = subType.GetProperty(propertyName)!; + var propertyInfo = subType.GetProperty(propertyName); if (propertyInfo is null) { - depth = depth.Skip(1).ToArray(); + depth = [.. depth.Skip(1)]; + + if (hasContent) + { + newKey.Append('.'); + } + newKey.Append(propertyName); + hasContent = true; continue; } @@ -109,9 +117,16 @@ private static void DeepResolveSerializationNames( .GetCustomAttributes(typeof(JsonPropertyNameAttribute), inherit: false) .FirstOrDefault() as JsonPropertyNameAttribute; - depth = depth.Skip(1).ToArray(); + depth = [.. depth.Skip(1)]; name = jsonPropertyNameAttribute?.Name ?? propertyName; + + if (hasContent) + { + newKey.Append('.'); + } + newKey.Append(name); + hasContent = true; if (errorName.Contains('[', StringComparison.Ordinal) && errorName.Contains(']', StringComparison.Ordinal)) @@ -120,11 +135,6 @@ private static void DeepResolveSerializationNames( var indexOf = errorName.IndexOf(']', StringComparison.Ordinal); newKey.Append(errorName.AsSpan(startIndex, indexOf - startIndex + 1)); } - - if (depth.Any()) - { - newKey.Append('.'); - } } FormatAndAddValidationErrors( @@ -168,13 +178,33 @@ private static void FormatAndAddValidationErrors( : value); } - newErrors.Add( + AddOrMergeErrors( + newErrors, key[(key.IndexOf('.', StringComparison.Ordinal) + 1)..], newValues.ToArray()); } else { - newErrors.Add(key, values); + AddOrMergeErrors(newErrors, key, values); + } + } + + private static void AddOrMergeErrors( + IDictionary errors, + string key, + string[] values) + { + if (errors.TryGetValue(key, out var existingValues)) + { + var merged = existingValues + .Union(values, StringComparer.Ordinal) + .ToArray(); + + errors[key] = merged; + } + else + { + errors.Add(key, values); } } diff --git a/test/Atc.Rest.MinimalApi.Tests/Extensions/ValidationProblemExtensionsTests.cs b/test/Atc.Rest.MinimalApi.Tests/Extensions/ValidationProblemExtensionsTests.cs index 87a25a9..d9440bd 100644 --- a/test/Atc.Rest.MinimalApi.Tests/Extensions/ValidationProblemExtensionsTests.cs +++ b/test/Atc.Rest.MinimalApi.Tests/Extensions/ValidationProblemExtensionsTests.cs @@ -13,7 +13,7 @@ public async Task Should_Not_Throw_NullReferenceException_For_Incorrect_Type_Spe "Telephone"); var (_, miniValidatorErrors) = await MiniValidator.TryValidateAsync(elementToValidate); - var fluentValidatorResults = await fluentValidator.ValidateAsync(elementToValidate); + var fluentValidatorResults = await fluentValidator.ValidateAsync(elementToValidate, TestContext.Current.CancellationToken); // Act var exception = await Record.ExceptionAsync(() => @@ -42,7 +42,7 @@ public async Task Should_Validate_Correctly_With_Proper_Casing_For_Type_Without_ "Telephone"); var (_, miniValidatorErrors) = await MiniValidator.TryValidateAsync(elementToValidate); - var fluentValidatorResults = await fluentValidator.TestValidateAsync(elementToValidate); + var fluentValidatorResults = await fluentValidator.TestValidateAsync(elementToValidate, cancellationToken: TestContext.Current.CancellationToken); // Act var problems = TypedResults @@ -86,7 +86,7 @@ public async Task Should_Validate_Correctly_With_Proper_Casing_For_Type_With_Jso " "); var (_, miniValidatorErrors) = await MiniValidator.TryValidateAsync(elementToValidate); - var fluentValidatorResults = await fluentValidator.TestValidateAsync(elementToValidate); + var fluentValidatorResults = await fluentValidator.TestValidateAsync(elementToValidate, cancellationToken: TestContext.Current.CancellationToken); // Act var problems = TypedResults @@ -142,7 +142,7 @@ public async Task Should_Validate_Correctly_With_Proper_Casing_For_Request_When_ "1")); var (_, miniValidatorErrors) = await MiniValidator.TryValidateAsync(elementToValidate); - var fluentValidatorResults = await fluentValidator.TestValidateAsync(elementToValidate); + var fluentValidatorResults = await fluentValidator.TestValidateAsync(elementToValidate, cancellationToken: TestContext.Current.CancellationToken); // Act var problems = TypedResults @@ -195,7 +195,7 @@ public async Task Should_Validate_Correctly_With_Proper_Casing_For_Request_When_ "1")); var (_, miniValidatorErrors) = await MiniValidator.TryValidateAsync(elementToValidate); - var fluentValidatorResults = await fluentValidator.TestValidateAsync(elementToValidate); + var fluentValidatorResults = await fluentValidator.TestValidateAsync(elementToValidate, cancellationToken: TestContext.Current.CancellationToken); // Act var problems = TypedResults @@ -234,4 +234,107 @@ public async Task Should_Validate_Correctly_With_Proper_Casing_For_Request_When_ errorValues2.Contains("CityName is too short.", StringComparer.Ordinal), "Missing validation error from FluentValidation."); } + + [Fact] + public void AddOrMergeErrors_WithDuplicateMessages_DeduplicatesUsingUnion() + { + // Arrange - Create TWO error dictionaries with the same key and same message + // The deduplication happens during MergeErrors (which uses Union internally) + var dataAnnotationErrors = new Dictionary(StringComparer.Ordinal) + { + ["Name"] = ["Name is required."], + }; + + var fluentValidationErrors = new Dictionary(StringComparer.Ordinal) + { + ["Name"] = ["Name is required."], // Same error message + }; + + // Act - Merge errors (this is where Union deduplication happens) + var mergedErrors = dataAnnotationErrors.MergeErrors(fluentValidationErrors); + var problems = mergedErrors.ResolveSerializationTypeNames(); + + // Assert - Duplicate messages should be deduplicated by Union + Assert.Single(problems.ProblemDetails.Errors); + var (key, values) = problems.ProblemDetails.Errors.First(); + Assert.Equal("Name", key); + Assert.Single(values); // Union should deduplicate identical messages + Assert.Equal("Name is required.", values[0]); + } + + [Fact] + public void AddOrMergeErrors_UsesOrdinalComparison_CaseSensitive() + { + // Arrange - Create TWO error dictionaries with same key but different case messages + // This tests that Union uses Ordinal (case-sensitive) comparison + var dataAnnotationErrors = new Dictionary(StringComparer.Ordinal) + { + ["Name"] = ["Name is required."], + }; + + var fluentValidationErrors = new Dictionary(StringComparer.Ordinal) + { + ["Name"] = ["name is required."], // Same message, different case + }; + + // Act - Merge errors + var mergedErrors = dataAnnotationErrors.MergeErrors(fluentValidationErrors); + var problems = mergedErrors.ResolveSerializationTypeNames(); + + // Assert - Different case messages should NOT be deduplicated (Ordinal comparison) + Assert.Single(problems.ProblemDetails.Errors); + var (key, values) = problems.ProblemDetails.Errors.First(); + Assert.Equal("Name", key); + Assert.Equal(2, values.Length); // Both messages should remain + Assert.Contains("Name is required.", values); + Assert.Contains("name is required.", values); + } + + [Fact] + public void AddOrMergeErrors_WithNewKey_AddsToErrors() + { + // Arrange - Single key with single value + var errors = new Dictionary(StringComparer.Ordinal) + { + ["Email"] = ["Email is invalid."], + }; + + // Act + var problems = errors.ResolveSerializationTypeNames(); + + // Assert + Assert.Single(problems.ProblemDetails.Errors); + var (key, values) = problems.ProblemDetails.Errors.First(); + Assert.Equal("Email", key); + Assert.Single(values); + Assert.Equal("Email is invalid.", values[0]); + } + + [Fact] + public void AddOrMergeErrors_WithExistingKey_MergesValues() + { + // Arrange - This test uses the merge behavior where DataAnnotations and FluentValidation + // both return errors for the same property, testing the actual merge path + var dataAnnotationErrors = new Dictionary(StringComparer.Ordinal) + { + ["Name"] = ["DataAnnotation error for Name."], + }; + + var fluentValidationErrors = new Dictionary(StringComparer.Ordinal) + { + ["Name"] = ["FluentValidation error for Name."], + }; + + // Act - Merge errors like the actual code does + var mergedErrors = dataAnnotationErrors.MergeErrors(fluentValidationErrors); + var problems = mergedErrors.ResolveSerializationTypeNames(); + + // Assert - Both error sources should be merged + Assert.Single(problems.ProblemDetails.Errors); + var (key, values) = problems.ProblemDetails.Errors.First(); + Assert.Equal("Name", key); + Assert.Equal(2, values.Length); + Assert.Contains("DataAnnotation error for Name.", values); + Assert.Contains("FluentValidation error for Name.", values); + } } \ No newline at end of file diff --git a/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs b/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs index 972ea28..2a061eb 100644 --- a/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs +++ b/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs @@ -1,12 +1,16 @@ global using System.ComponentModel.DataAnnotations; +global using System.Runtime.CompilerServices; global using System.Text.Json.Serialization; global using Atc.Rest.MinimalApi.Extensions; global using Atc.Rest.MinimalApi.Extensions.Internal; +global using Atc.Rest.MinimalApi.Filters.Endpoints; global using Atc.Rest.MinimalApi.Tests.Models; global using Atc.Rest.MinimalApi.Tests.Validators; global using FluentAssertions.Execution; global using FluentValidation; global using FluentValidation.TestHelper; global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Http.HttpResults; global using Microsoft.AspNetCore.Mvc; +global using Microsoft.Extensions.DependencyInjection; global using MiniValidation; \ No newline at end of file From 732caeffcce4c0b64721c1a91844dd77d62b29ad Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 4 Dec 2025 15:10:56 +0100 Subject: [PATCH 16/54] feat(sample): add Scalar API reference alongside Swagger UI --- sample/src/Demo.Api/Demo.Api.csproj | 7 +++++- .../Extensions/WebApplicationExtensions.cs | 23 ++++++++++++++++++- sample/src/Demo.Api/GlobalUsings.cs | 4 ++-- sample/src/Demo.Api/Program.cs | 8 +++---- .../Demo.Api/Properties/launchSettings.json | 2 +- sample/src/Demo.AppHost/Program.cs | 20 ++++++++-------- 6 files changed, 45 insertions(+), 19 deletions(-) diff --git a/sample/src/Demo.Api/Demo.Api.csproj b/sample/src/Demo.Api/Demo.Api.csproj index ad37eff..c110a98 100644 --- a/sample/src/Demo.Api/Demo.Api.csproj +++ b/sample/src/Demo.Api/Demo.Api.csproj @@ -1,13 +1,18 @@ - net8.0 false + + + + + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated + diff --git a/sample/src/Demo.Api/Extensions/WebApplicationExtensions.cs b/sample/src/Demo.Api/Extensions/WebApplicationExtensions.cs index 89f1beb..86779d8 100644 --- a/sample/src/Demo.Api/Extensions/WebApplicationExtensions.cs +++ b/sample/src/Demo.Api/Extensions/WebApplicationExtensions.cs @@ -21,7 +21,11 @@ public static IApplicationBuilder ConfigureSwaggerUI( this WebApplication app, string applicationName) { - app.UseSwagger(); + app.UseSwagger(options => + { + options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; + }); + app.UseSwaggerUI(options => { options.EnableTryItOutByDefault(); @@ -40,4 +44,21 @@ public static IApplicationBuilder ConfigureSwaggerUI( return app; } + + public static IApplicationBuilder ConfigureScalarUI(this WebApplication app) + { + // Root endpoint redirects to Scalar API reference + app + .MapGet("/", () => Results.Redirect("/scalar/v1")) + .ExcludeFromDescription(); + + // Scalar uses native .NET OpenAPI + app.MapOpenApi(); + app.MapScalarApiReference(options => + { + options.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient); + }); + + return app; + } } \ No newline at end of file diff --git a/sample/src/Demo.Api/GlobalUsings.cs b/sample/src/Demo.Api/GlobalUsings.cs index cbc5f7f..24601dc 100644 --- a/sample/src/Demo.Api/GlobalUsings.cs +++ b/sample/src/Demo.Api/GlobalUsings.cs @@ -18,6 +18,6 @@ global using Microsoft.AspNetCore.Mvc; global using Microsoft.Extensions.Logging.ApplicationInsights; global using Microsoft.Extensions.Options; -global using Microsoft.OpenApi.Models; - +global using Microsoft.OpenApi; +global using Scalar.AspNetCore; global using Swashbuckle.AspNetCore.SwaggerGen; \ No newline at end of file diff --git a/sample/src/Demo.Api/Program.cs b/sample/src/Demo.Api/Program.cs index dff1de7..b6e81ab 100644 --- a/sample/src/Demo.Api/Program.cs +++ b/sample/src/Demo.Api/Program.cs @@ -6,6 +6,8 @@ var services = builder.Services; +services.AddOpenApi(); + services.AddMemoryCache(); services.ConfigureDomainServices(builder.Configuration); @@ -57,6 +59,7 @@ if (app.Environment.IsDevelopment()) { app.ConfigureSwaggerUI(builder.Environment.ApplicationName); + app.ConfigureScalarUI(); } app.InitializeDatabase(); @@ -66,10 +69,7 @@ app.UseCors("DemoCorsPolicy"); -////app.UseAuthentication(); -////app.UseAuthorization(); - -app.Run(); +await app.RunAsync(); // Make the implicit Program class public so test projects can access it public partial class Program diff --git a/sample/src/Demo.Api/Properties/launchSettings.json b/sample/src/Demo.Api/Properties/launchSettings.json index 389275f..20bffa9 100644 --- a/sample/src/Demo.Api/Properties/launchSettings.json +++ b/sample/src/Demo.Api/Properties/launchSettings.json @@ -13,7 +13,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "", "applicationUrl": "https://localhost:7138;http://localhost:5138", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/sample/src/Demo.AppHost/Program.cs b/sample/src/Demo.AppHost/Program.cs index 0caa20d..a715e81 100644 --- a/sample/src/Demo.AppHost/Program.cs +++ b/sample/src/Demo.AppHost/Program.cs @@ -10,20 +10,20 @@ url.DisplayLocation = UrlDisplayLocation.DetailsOnly; } - var endpointReference = context.GetEndpoint("https"); + var endpoint = context.GetEndpoint("https"); - ////context.Urls.Add(new ResourceUrlAnnotation - ////{ - //// Url = string.Empty, - //// DisplayText = "Scalar", - //// Endpoint = endpointReference, - ////}); + context.Urls.Add(new ResourceUrlAnnotation + { + Url = $"{endpoint.Url}/scalar/v1", + DisplayText = "Scalar", + Endpoint = endpoint, + }); context.Urls.Add(new ResourceUrlAnnotation { - Url = $"{endpointReference!.Url}/swagger", + Url = $"{endpoint.Url}/swagger", DisplayText = "Swagger", - Endpoint = endpointReference, + Endpoint = endpoint, }); }); @@ -49,4 +49,4 @@ await builder .Build() - .RunAsync(); \ No newline at end of file + .RunAsync(); From 354014427646404ee4985a7df3435672014aa5fd Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 4 Dec 2025 15:19:18 +0100 Subject: [PATCH 17/54] refactor(swagger): update filters for Microsoft.OpenApi v2.x compatibility --- .../Filters/Swagger/SwaggerDefaultValues.cs | 64 ++++++++++++------ .../SwaggerEnumDescriptionsDocumentFilter.cs | 67 ++++++++++++------- src/Atc.Rest.MinimalApi/GlobalUsings.cs | 6 +- 3 files changed, 88 insertions(+), 49 deletions(-) diff --git a/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerDefaultValues.cs b/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerDefaultValues.cs index 2fd7f1d..2a25551 100644 --- a/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerDefaultValues.cs +++ b/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerDefaultValues.cs @@ -33,24 +33,35 @@ public void Apply( var metadata = apiDescription.ActionDescriptor.EndpointMetadata; - operation.Description ??= metadata.OfType().FirstOrDefault()?.Description; - operation.Summary ??= metadata.OfType().FirstOrDefault()?.Summary; + operation.Description ??= metadata + .OfType() + .FirstOrDefault()?.Description; + operation.Summary ??= metadata + .OfType() + .FirstOrDefault()?.Summary; // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1752#issue-663991077 - foreach (var responseType in context.ApiDescription.SupportedResponseTypes) + if (operation.Responses is not null) { - // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/b7cf75e7905050305b115dd96640ddd6e74c7ac9/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs#L383-L387 - var responseKey = responseType.IsDefaultResponse - ? "default" - : responseType.StatusCode.ToString(GlobalizationConstants.EnglishCultureInfo); + foreach (var responseType in context.ApiDescription.SupportedResponseTypes) + { + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/b7cf75e7905050305b115dd96640ddd6e74c7ac9/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs#L383-L387 + var responseKey = responseType.IsDefaultResponse + ? "default" + : responseType.StatusCode.ToString(GlobalizationConstants.EnglishCultureInfo); - var response = operation.Responses[responseKey]; + if (!operation.Responses.TryGetValue(responseKey, out var response) || + response.Content is null) + { + continue; + } - foreach (var contentType in response.Content.Keys) - { - if (responseType.ApiResponseFormats.All(x => x.MediaType != contentType)) + foreach (var contentType in response.Content.Keys) { - response.Content.Remove(contentType); + if (responseType.ApiResponseFormats.All(x => x.MediaType != contentType)) + { + response.Content.Remove(contentType); + } } } } @@ -64,21 +75,34 @@ public void Apply( // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/413 foreach (var parameter in operation.Parameters) { - var description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name); + var description = apiDescription.ParameterDescriptions.FirstOrDefault(p => p.Name == parameter.Name); + if (description is null) + { + continue; + } - parameter.Description ??= description.ModelMetadata.Description; + parameter.Description ??= description.ModelMetadata?.Description; - if (parameter.Schema.Default == null && - description.DefaultValue != null && - description.DefaultValue is not DBNull && - description.ModelMetadata is { } modelMetadata) + // Cast to concrete types to modify properties in Microsoft.OpenApi v2.x + if (parameter.Schema is OpenApiSchema schema && + schema.Default == null && + description.DefaultValue != null && + description.DefaultValue is not DBNull && + description.ModelMetadata is { } modelMetadata) { // REF: https://github.com/Microsoft/aspnet-api-versioning/issues/429#issuecomment-605402330 var json = JsonSerializer.Serialize(description.DefaultValue, modelMetadata.ModelType); - parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson(json); + var defaultValue = JsonNode.Parse(json); + if (defaultValue is not null) + { + schema.Default = defaultValue; + } } - parameter.Required |= description.IsRequired; + if (parameter is OpenApiParameter concreteParam) + { + concreteParam.Required |= description.IsRequired; + } } } } \ No newline at end of file diff --git a/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilter.cs b/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilter.cs index 3ecd512..f2dca1e 100644 --- a/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilter.cs +++ b/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilter.cs @@ -24,6 +24,11 @@ public void Apply( ArgumentNullException.ThrowIfNull(context); // Add enum descriptions to result models + if (swaggerDoc.Components?.Schemas is null) + { + return; + } + foreach (var item in swaggerDoc.Components.Schemas.Where(x => x.Value?.Enum?.Count > 0)) { var propertyEnums = item.Value.Enum; @@ -49,7 +54,7 @@ public void Apply( /// The path of the operations. [SuppressMessage("Minor Code Smell", "S1643:Strings should not be concatenated using '+' in a loop", Justification = "OK.")] private static void DescribeEnumParameters( - IDictionary? operations, + IDictionary? operations, OpenApiDocument document, IEnumerable apiDescriptions, string path) @@ -61,10 +66,18 @@ private static void DescribeEnumParameters( path = path.Trim('/'); - var pathDescriptions = apiDescriptions.Where(a => string.Equals(a.RelativePath, path, StringComparison.Ordinal)).ToList(); + var pathDescriptions = apiDescriptions + .Where(a => string.Equals(a.RelativePath, path, StringComparison.Ordinal)) + .ToList(); + foreach (var operation in operations) { - var operationDescription = pathDescriptions.Find(a => a.HttpMethod!.Equals(operation.Key.ToString(), StringComparison.OrdinalIgnoreCase)); + if (operation.Value.Parameters is null) + { + continue; + } + + var operationDescription = pathDescriptions.Find(a => a.HttpMethod is not null && a.HttpMethod.Equals(operation.Key.Method, StringComparison.OrdinalIgnoreCase)); foreach (var param in operation.Value.Parameters) { var parameterDescription = operationDescription?.ParameterDescriptions.FirstOrDefault(a => string.Equals(a.Name, param.Name, StringComparison.Ordinal)); @@ -79,8 +92,13 @@ private static void DescribeEnumParameters( continue; } + if (document.Components?.Schemas is null) + { + continue; + } + var paramEnum = document.Components.Schemas.FirstOrDefault(x => string.Equals(x.Key, enumType.Name, StringComparison.Ordinal)); - if (paramEnum.Value is not null) + if (paramEnum.Value?.Enum is not null) { param.Description += DescribeEnum(paramEnum.Value.Enum, paramEnum.Key); } @@ -95,32 +113,28 @@ private static void DescribeEnumParameters( /// The name of the property type containing the enum. /// A formatted string describing the enum values and names. private static string DescribeEnum( - IEnumerable enums, + IEnumerable enums, string propertyTypeName) { var enumDescriptions = new List(); var enumType = GetEnumTypeByName(propertyTypeName); if (enumType is null) { - return null!; + return string.Empty; } foreach (var item in enums) { - switch (item) + if (item is JsonValue jsonValue) { - case OpenApiInteger intItem: + if (jsonValue.TryGetValue(out var intValue)) { - var enumInt = intItem.Value; - enumDescriptions.Add($"{enumInt} = {Enum.GetName(enumType, enumInt)}"); - break; + enumDescriptions.Add($"{intValue} = {Enum.GetName(enumType, intValue)}"); } - - case OpenApiString stringItem: + else if (jsonValue.TryGetValue(out var stringValue)) { - var enumInt = (int)Enum.Parse(enumType, stringItem.Value); - enumDescriptions.Add($"{enumInt} = {stringItem.Value}"); - break; + var enumInt = (int)Enum.Parse(enumType, stringValue); + enumDescriptions.Add($"{enumInt} = {stringValue}"); } } } @@ -133,16 +147,17 @@ private static string DescribeEnum( /// /// The name of the enum type to find. /// The enum type, if found; otherwise, null. - private static Type? GetEnumTypeByName( - string enumTypeName) + private static Type? GetEnumTypeByName(string enumTypeName) => AppDomain.CurrentDomain - .GetAssemblies() - .SelectMany(x => x.GetTypes().Where(t => t.IsEnum)) - .Where(x => string.Equals(x.Name, enumTypeName, StringComparison.Ordinal)) - .ToArray() + .GetAssemblies() + .SelectMany(x => x + .GetTypes() + .Where(t => t.IsEnum)) + .Where(x => string.Equals(x.Name, enumTypeName, StringComparison.Ordinal)) + .ToArray() switch - { - { Length: 1 } a => a[0], - _ => null, - }; + { + { Length: 1 } a => a[0], + _ => null, + }; } \ No newline at end of file diff --git a/src/Atc.Rest.MinimalApi/GlobalUsings.cs b/src/Atc.Rest.MinimalApi/GlobalUsings.cs index a355773..9184ba3 100644 --- a/src/Atc.Rest.MinimalApi/GlobalUsings.cs +++ b/src/Atc.Rest.MinimalApi/GlobalUsings.cs @@ -1,9 +1,11 @@ global using System.Diagnostics.CodeAnalysis; global using System.Net; global using System.Net.Mime; +global using System.Reflection; global using System.Runtime.CompilerServices; global using System.Text; global using System.Text.Json; +global using System.Text.Json.Nodes; global using System.Text.Json.Serialization; global using System.Text.RegularExpressions; @@ -21,9 +23,7 @@ global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.Mvc.ApiExplorer; global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.OpenApi.Any; -global using Microsoft.OpenApi.Models; - +global using Microsoft.OpenApi; global using MiniValidation; global using Swashbuckle.AspNetCore.SwaggerGen; \ No newline at end of file From 58e83ac38e4552371512d60568adff6e2444534b Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 4 Dec 2025 15:20:18 +0100 Subject: [PATCH 18/54] chore(deps): add Scalar.AspNetCore package reference --- src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj b/src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj index 69ad7b5..2d6afc2 100644 --- a/src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj +++ b/src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj @@ -17,6 +17,7 @@ + From 1b5abbbd400c85e88a8801c9ef9dc0c010de50ba Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 4 Dec 2025 15:21:59 +0100 Subject: [PATCH 19/54] docs: expand README with validation, Scalar, and .NET 10 guidance --- README.md | 245 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 225 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 0fa0413..1e00b3c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![NuGet Version](https://img.shields.io/nuget/v/atc.rest.minimalapi.svg?logo=nuget&style=for-the-badge)](https://www.nuget.org/packages/atc.rest.minimalapi) -# Atc.Rest.MinimalApi +# ๐Ÿš€ Atc.Rest.MinimalApi Modern development demands efficiency, clarity, and flexibility, especially when it comes to building RESTful APIs. The Atc.Rest.MinimalApi library is crafted with these principles in mind, offering a comprehensive collection of components specifically designed to streamline API development. @@ -8,22 +8,27 @@ From EndpointDefinitions that standardize and automate endpoint registration to Whether you're building a brand-new project or seeking to enhance an existing one, Atc.Rest.MinimalApi can significantly simplify development, allowing you to focus on what truly matters: delivering value to your users. -# Table of Contents -- [Atc.Rest.MinimalApi](#atcrestminimalapi) -- [Table of Contents](#table-of-contents) -- [Automatic endpoint discovery and registration](#automatic-endpoint-discovery-and-registration) -- [Automatic endpoint discovery and registration with services](#automatic-endpoint-discovery-and-registration-with-services) -- [SwaggerFilters](#swaggerfilters) +# ๐Ÿ“‘ Table of Contents +- [๐Ÿš€ Atc.Rest.MinimalApi](#-atcrestminimalapi) +- [๐Ÿ“‘ Table of Contents](#-table-of-contents) +- [๐Ÿ”Œ Automatic endpoint discovery and registration](#-automatic-endpoint-discovery-and-registration) +- [๐Ÿ”Œ Automatic endpoint discovery and registration with services](#-automatic-endpoint-discovery-and-registration-with-services) +- [๐Ÿ“ SwaggerFilters](#-swaggerfilters) - [`SwaggerDefaultValues`](#swaggerdefaultvalues) - [`SwaggerEnumDescriptionsDocumentFilter`](#swaggerenumdescriptionsdocumentfilter) -- [Validation](#validation) -- [Middleware](#middleware) +- [โœ… Validation](#-validation) + - [Validation Approaches](#validation-approaches) + - [Nested [FromBody] Validator Support ๐ŸŽฏ](#nested-frombody-validator-support-) +- [๐Ÿ“š API Documentation](#-api-documentation) + - [Swagger UI](#swagger-ui) + - [Scalar](#scalar) +- [๐Ÿ›ก๏ธ Middleware](#๏ธ-middleware) - [GlobalErrorHandlingMiddleware](#globalerrorhandlingmiddleware) -- [Sample Project](#sample-project) -- [Requirements](#requirements) -- [How to contribute](#how-to-contribute) +- [๐Ÿ’ก Sample Project](#-sample-project) +- [๐Ÿ“‹ Requirements](#-requirements) +- [๐Ÿค How to contribute](#-how-to-contribute) -# Automatic endpoint discovery and registration +# ๐Ÿ”Œ Automatic endpoint discovery and registration In modern API development, maintaining consistency and automation in endpoint registration is paramount. Utilizing an interface like `IEndpointDefinition` can automate the process, seamlessly incorporating all endpoints within the API into the Dependency Container. By inheriting from this interface in your endpoints, you enable a systematic orchestration for automatic registration, as illustrated in the subsequent examples. @@ -84,7 +89,7 @@ public sealed class UsersEndpointDefinition : IEndpointDefinition } ``` -# Automatic endpoint discovery and registration with services +# ๐Ÿ”Œ Automatic endpoint discovery and registration with services An alternative approach is using the interface `IEndpointAndServiceDefinition`. @@ -150,7 +155,7 @@ public sealed class UsersEndpointDefinition : IEndpointAndServiceDefinition } ``` -# SwaggerFilters +# ๐Ÿ“ SwaggerFilters In the development of RESTful APIs, filters play an essential role in shaping the output and behavior of the system. Whether it's providing detailed descriptions for enumerations or handling default values and response formats, filters like those in the Atc.Rest.MinimalApi.Filters.Swagger namespace enhance the API's functionality and documentation, aiding in both development and integration. @@ -186,10 +191,88 @@ services.AddSwaggerGen(options => }); ``` -# Validation +# โœ… Validation Enhance your Minimal API with powerful validation using the `ValidationFilter` class. This filter integrates both DataAnnotations validation and FluentValidation, combining and deduplicating errors into a cohesive validation response. +## Validation Approaches + +When building APIs with .NET 8, 9, or 10, you have two options for validation: + +### Option 1: Atc.Rest.MinimalApi ValidationFilter (Recommended) โญ + +This library provides a `ValidationFilter` that works consistently across all .NET versions. + +**โœ… Pros:** +- **Unified error responses** - Merges DataAnnotations and FluentValidation errors into a single response +- **Smart error keys** - Respects `[JsonPropertyName]` attributes for serialization-aware error keys +- **Consistent behavior** - Same validation logic across .NET 8, 9, and 10 +- **No additional configuration** - Works out of the box without project file changes +- **Error deduplication** - Automatically removes duplicate validation errors +- **Custom validation attributes** - Full support for custom `ValidationAttribute` implementations +- **Nested validator discovery** - Automatically finds validators for `[FromBody]` properties + +**Usage:** +```csharp +usersV1 + .MapPost("/", CreateUser) + .WithName("CreateUser") + .AddEndpointFilter>() + .ProducesValidationProblem(); +``` + +### Option 2: .NET 10 Native Validation + +.NET 10 introduces built-in validation via `services.AddValidation()` with source generators. + +**โœ… Pros:** +- Built into the framework (no additional packages) +- Source generator approach (compile-time validation discovery) + +**โš ๏ธ Cons:** +- Requires `InterceptorsNamespaces` configuration in your project file +- Short-circuits on first validation failure (cannot merge with FluentValidation errors) +- Only available on .NET 10+ +- Less control over error key formatting + +**Configuration required in .csproj:** +```xml + + $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated + +``` + +**Usage:** +```csharp +builder.Services.AddValidation(); +``` + +### Custom Validation Attributes + +Both approaches support custom validation attributes. Here's an example: + +```csharp +public class EvenNumberAttribute : ValidationAttribute +{ + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + if (value is int number && number % 2 != 0) + { + return new ValidationResult($"The field {validationContext.DisplayName} must be an even number."); + } + return ValidationResult.Success; + } +} + +public class MyRequest +{ + [EvenNumber] + public int Quantity { get; set; } +} +``` + +## Basic Usage + Out of the box, the filter can be applied as shown below: ```csharp @@ -234,7 +317,125 @@ usersV1 .ProducesValidationProblem(); ``` -# Middleware +## Nested [FromBody] Validator Support ๐ŸŽฏ + +When using the common pattern of wrapping request parameters, the `ValidationFilter` automatically discovers validators for nested `[FromBody]` properties: + +```csharp +// Your Parameters wrapper +public record CreateUserParameters([property: FromBody] CreateUserRequest Request); + +// Your validator - registered for the nested type +public class CreateUserRequestValidator : AbstractValidator +{ + public CreateUserRequestValidator() + { + RuleFor(x => x.FirstName).NotEmpty().MaximumLength(50); + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + } +} + +// The filter automatically finds CreateUserRequestValidator! +.AddEndpointFilter>() +``` + +This means you can: +- โœ… Keep validators focused on the actual request model (not the parameters wrapper) +- โœ… Reuse validators across different endpoints +- โœ… Register validators with `AddValidatorsFromAssemblyContaining()` as usual +- โœ… Both `IValidator` and `IValidator` will run if both exist + +# ๐Ÿ“š API Documentation + +This library includes support for both Swagger UI and Scalar for API documentation, with OpenAPI 3.1 support. + +## Swagger UI + +Configure Swagger with OpenAPI 3.1 and the provided filters: + +```csharp +// In Program.cs or your service configuration +builder.Services.AddSwaggerGen(options => +{ + options.OperationFilter(); + options.DocumentFilter(); +}); + +// In your app configuration +app.UseSwagger(options => +{ + options.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_1; +}); + +app.UseSwaggerUI(options => +{ + options.EnableTryItOutByDefault(); +}); +``` + +## Scalar + +[Scalar](https://github.com/scalar/scalar) provides a modern, beautiful API reference documentation UI. This library includes Scalar.AspNetCore for easy integration: + +```csharp +using Scalar.AspNetCore; + +// Configure Scalar with C# HttpClient code generation +app.MapScalarApiReference(options => +{ + options.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient); +}); +``` + +### Available Endpoints + +When configured, your API will have the following documentation endpoints: + +| Endpoint | Description | +|----------|-------------| +| `/` | Redirects to Scalar API reference | +| `/swagger` | Swagger UI interface | +| `/swagger/v1/swagger.json` | OpenAPI spec for Swagger UI | +| `/scalar/v1` | Scalar API reference | +| `/openapi/v1.json` | OpenAPI spec for Scalar | + +Example configuration with separate OpenAPI paths: + +```csharp +// Configure Swagger with OpenAPI 3.1 (default path for Swagger UI) +app.UseSwagger(options => +{ + options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; +}); + +// Also serve at /openapi path for Scalar +app.UseSwagger(options => +{ + options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; + options.RouteTemplate = "openapi/{documentName}.json"; +}); + +// Configure Swagger UI +app.UseSwaggerUI(options => +{ + var descriptions = app.DescribeApiVersions(); + foreach (var description in descriptions) + { + var url = $"/swagger/{description.GroupName}/swagger.json"; + options.SwaggerEndpoint(url, $"MyApi {description.GroupName.ToUpperInvariant()}"); + } +}); + +// Configure Scalar with separate OpenAPI path +app.MapScalarApiReference(options => +{ + options + .WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient) + .WithOpenApiRoutePattern("/openapi/{documentName}.json"); +}); +``` + +# ๐Ÿ›ก๏ธ Middleware ## GlobalErrorHandlingMiddleware @@ -276,17 +477,21 @@ app.UseGlobalErrorHandler(options => }); ``` -# Sample Project +# ๐Ÿ’ก Sample Project The sample project `Demo.Api` located in the [sample](/sample/) folder within the repository illustrates a practical implementation of the Atc.Rest.MinimalApi package, showcasing all the features and best practices detailed in this documentation. It's a comprehensive starting point for those looking to get a hands-on understanding of how to effectively utilize the library in real-world applications. The Demo.Api project also leverages the `Asp.Versioning.Http` Nuget package to establish a proper versioning scheme. It's an example implementation of how API versioning can be easily managed and maintained. -# Requirements +# ๐Ÿ“‹ Requirements + +This library supports multiple .NET versions: * [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) +* [.NET 9 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) +* [.NET 10 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) (Preview) -# How to contribute +# ๐Ÿค How to contribute [Contribution Guidelines](https://atc-net.github.io/introduction/about-atc#how-to-contribute) From db7ae76c9fac2521ac7a3214f3a00894c0f7099b Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 4 Dec 2025 15:24:54 +0100 Subject: [PATCH 20/54] chore(sample): use project reference instead of NuGet package --- sample/src/Demo.Domain/Demo.Domain.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/src/Demo.Domain/Demo.Domain.csproj b/sample/src/Demo.Domain/Demo.Domain.csproj index e8bffb3..cd87a38 100644 --- a/sample/src/Demo.Domain/Demo.Domain.csproj +++ b/sample/src/Demo.Domain/Demo.Domain.csproj @@ -7,7 +7,6 @@ - @@ -20,6 +19,7 @@ + From 10c1e9dc2306579dc6bbc1ada220af1a6a77ae35 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 4 Dec 2025 15:49:03 +0100 Subject: [PATCH 21/54] chore(sample): suppress analyzer warnings and fix file endings --- sample/.editorconfig | 9 ++++++++- sample/src/Demo.Api/Demo.Api.csproj | 1 + sample/src/Demo.AppHost/Program.cs | 4 ++-- sample/src/Demo.Web/Models/Address.cs | 2 +- .../src/Demo.Web/Models/ApiValidationProblemDetails.cs | 2 +- sample/src/Demo.Web/Models/Country.cs | 2 +- sample/src/Demo.Web/Services/UserService.cs | 6 ++++-- 7 files changed, 18 insertions(+), 8 deletions(-) diff --git a/sample/.editorconfig b/sample/.editorconfig index 4bc50b2..ae5e8cd 100644 --- a/sample/.editorconfig +++ b/sample/.editorconfig @@ -511,14 +511,21 @@ dotnet_diagnostic.S1135.severity = suggestion # https://github.com/atc-net ########################################## [*.{cs,csx,cake}] +dotnet_diagnostic.CA1031.severity = none # https://learn.microsoft.com/da-dk/dotnet/fundamentals/code-analysis/quality-rules/ca1031 dotnet_diagnostic.CA1515.severity = none # Consider making public types internal https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1515 +dotnet_diagnostic.CA1848.severity = none # https://learn.microsoft.com/da-dk/dotnet/fundamentals/code-analysis/quality-rules/ca1848 dotnet_diagnostic.CA1859.severity = none # https://learn.microsoft.com/da-dk/dotnet/fundamentals/code-analysis/quality-rules/ca1859 dotnet_diagnostic.CA2007.severity = none # Consider calling ConfigureAwait. dotnet_diagnostic.CA2016.severity = none # Forward the CancellationToken parameter to methods that take one - False-Positive +dotnet_diagnostic.CA2227.severity = none # Read-only by removing the property setter (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2227) +dotnet_diagnostic.CA2234.severity = none # call 'HttpClient.DeleteAsync(Uri, CancellationToken)' instead of 'HttpClient.DeleteAsync(string, CancellationToken)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2234) dotnet_diagnostic.MA0004.severity = none # Consider calling ConfigureAwait. dotnet_diagnostic.MA0040.severity = none # Forward the CancellationToken parameter to methods that take one - False-Positive +dotnet_diagnostic.SA1010.severity = none # Opening square brackets should not be preceded by a space (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1010.md) dotnet_diagnostic.SA1402.severity = none # File may only contains a single type - not relevant for multi records in single file by design -dotnet_diagnostic.S3267.severity = none # Loop could be simplified. LINQ Expression. \ No newline at end of file +dotnet_diagnostic.S1075.severity = none # Refactor your code not to use hardcoded absolute paths or URIs. (https://rules.sonarsource.com/csharp/RSPEC-1075) +dotnet_diagnostic.S3267.severity = none # Loop could be simplified. LINQ Expression. + diff --git a/sample/src/Demo.Api/Demo.Api.csproj b/sample/src/Demo.Api/Demo.Api.csproj index c110a98..d8f3b40 100644 --- a/sample/src/Demo.Api/Demo.Api.csproj +++ b/sample/src/Demo.Api/Demo.Api.csproj @@ -11,6 +11,7 @@ + diff --git a/sample/src/Demo.AppHost/Program.cs b/sample/src/Demo.AppHost/Program.cs index a715e81..0197745 100644 --- a/sample/src/Demo.AppHost/Program.cs +++ b/sample/src/Demo.AppHost/Program.cs @@ -14,7 +14,7 @@ context.Urls.Add(new ResourceUrlAnnotation { - Url = $"{endpoint.Url}/scalar/v1", + Url = $"{endpoint!.Url}/scalar/v1", DisplayText = "Scalar", Endpoint = endpoint, }); @@ -49,4 +49,4 @@ await builder .Build() - .RunAsync(); + .RunAsync(); \ No newline at end of file diff --git a/sample/src/Demo.Web/Models/Address.cs b/sample/src/Demo.Web/Models/Address.cs index af2466f..ccc3560 100644 --- a/sample/src/Demo.Web/Models/Address.cs +++ b/sample/src/Demo.Web/Models/Address.cs @@ -5,4 +5,4 @@ public record Address( string? StreetNumber, string? PostalCode, [property: JsonPropertyName("cityName")] string? CityName, - Country? Country); + Country? Country); \ No newline at end of file diff --git a/sample/src/Demo.Web/Models/ApiValidationProblemDetails.cs b/sample/src/Demo.Web/Models/ApiValidationProblemDetails.cs index 7f4149b..a50ef36 100644 --- a/sample/src/Demo.Web/Models/ApiValidationProblemDetails.cs +++ b/sample/src/Demo.Web/Models/ApiValidationProblemDetails.cs @@ -25,4 +25,4 @@ public sealed class ApiValidationProblemDetails [JsonPropertyName("errors")] public IDictionary? Errors { get; set; } -} +} \ No newline at end of file diff --git a/sample/src/Demo.Web/Models/Country.cs b/sample/src/Demo.Web/Models/Country.cs index 1c4009f..1caaf51 100644 --- a/sample/src/Demo.Web/Models/Country.cs +++ b/sample/src/Demo.Web/Models/Country.cs @@ -3,4 +3,4 @@ namespace Demo.Web.Models; public record Country( string? Name, string? Alpha2Code, - string? Alpha3Code); + string? Alpha3Code); \ No newline at end of file diff --git a/sample/src/Demo.Web/Services/UserService.cs b/sample/src/Demo.Web/Services/UserService.cs index 7ed9435..d55c578 100644 --- a/sample/src/Demo.Web/Services/UserService.cs +++ b/sample/src/Demo.Web/Services/UserService.cs @@ -152,7 +152,9 @@ public async Task> GetAllUsersAsync(CancellationToken cancella } } - public async Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken = default) + public async Task DeleteUserAsync( + Guid userId, + CancellationToken cancellationToken = default) { try { @@ -168,4 +170,4 @@ public async Task DeleteUserAsync(Guid userId, CancellationToken cancellat return false; } } -} +} \ No newline at end of file From 5c876c78bea88ff348e87572a04995d1b09911d4 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 4 Dec 2025 16:24:08 +0100 Subject: [PATCH 22/54] chore(deps): upgrade packages and consolidate test infrastructure --- .editorconfig | 2 +- Directory.Build.props | 6 ++--- sample/.editorconfig | 2 +- sample/Directory.Build.props | 4 ++-- sample/global.json | 3 +++ .../Demo.Api.Contracts.csproj | 10 ++------ sample/src/Demo.Api/Demo.Api.csproj | 4 ++-- sample/src/Demo.Api/GlobalUsings.cs | 1 + .../Options/ConfigureSwaggerOptions.cs | 2 +- sample/src/Demo.Domain/Demo.Domain.csproj | 2 +- sample/src/Demo.Web/Demo.Web.csproj | 16 ++++++------- .../Demo.Api.Contracts.Tests.csproj | 13 ----------- .../Demo.Api.IntegrationTests.csproj | 14 ----------- .../Endpoints/UsersEndpointDefinitionTests.cs | 3 ++- .../Demo.Domain.Tests.csproj | 13 ----------- sample/test/Directory.Build.props | 23 ++++++++++++++++--- src/.editorconfig | 2 +- .../Atc.Rest.MinimalApi.csproj | 6 ++--- test/.editorconfig | 2 +- test/Directory.Build.props | 4 ++-- 20 files changed, 54 insertions(+), 78 deletions(-) diff --git a/.editorconfig b/.editorconfig index 28513a0..394fede 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,7 @@ # Version: 1.0.0 # Updated: 01-03-2025 # Location: Root -# Distribution: DotNet9 +# Distribution: DotNet10 # Inspired by: https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code-style-rule-options ########################################## diff --git a/Directory.Build.props b/Directory.Build.props index 1ce7663..60d6ad8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -45,13 +45,13 @@ - + - + - + \ No newline at end of file diff --git a/sample/.editorconfig b/sample/.editorconfig index ae5e8cd..11266e4 100644 --- a/sample/.editorconfig +++ b/sample/.editorconfig @@ -527,5 +527,5 @@ dotnet_diagnostic.SA1010.severity = none # Opening square brackets sh dotnet_diagnostic.SA1402.severity = none # File may only contains a single type - not relevant for multi records in single file by design dotnet_diagnostic.S1075.severity = none # Refactor your code not to use hardcoded absolute paths or URIs. (https://rules.sonarsource.com/csharp/RSPEC-1075) +dotnet_diagnostic.S2325.severity = none # https://rules.sonarsource.com/csharp/RSPEC-2325/ dotnet_diagnostic.S3267.severity = none # Loop could be simplified. LINQ Expression. - diff --git a/sample/Directory.Build.props b/sample/Directory.Build.props index b4e1ecf..10042ab 100644 --- a/sample/Directory.Build.props +++ b/sample/Directory.Build.props @@ -47,10 +47,10 @@ - + - + \ No newline at end of file diff --git a/sample/global.json b/sample/global.json index 86298f9..7d7ff8a 100644 --- a/sample/global.json +++ b/sample/global.json @@ -2,5 +2,8 @@ "sdk": { "rollForward": "latestMajor", "allowPrerelease": false + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } \ No newline at end of file diff --git a/sample/src/Demo.Api.Contracts/Demo.Api.Contracts.csproj b/sample/src/Demo.Api.Contracts/Demo.Api.Contracts.csproj index 76c7cc4..d63ca62 100644 --- a/sample/src/Demo.Api.Contracts/Demo.Api.Contracts.csproj +++ b/sample/src/Demo.Api.Contracts/Demo.Api.Contracts.csproj @@ -1,18 +1,12 @@ - +๏ปฟ false - - - - - - - + diff --git a/sample/src/Demo.Api/Demo.Api.csproj b/sample/src/Demo.Api/Demo.Api.csproj index d8f3b40..eafb5ce 100644 --- a/sample/src/Demo.Api/Demo.Api.csproj +++ b/sample/src/Demo.Api/Demo.Api.csproj @@ -1,4 +1,4 @@ - +๏ปฟ false @@ -10,7 +10,7 @@ - + diff --git a/sample/src/Demo.Api/GlobalUsings.cs b/sample/src/Demo.Api/GlobalUsings.cs index 24601dc..8c924b2 100644 --- a/sample/src/Demo.Api/GlobalUsings.cs +++ b/sample/src/Demo.Api/GlobalUsings.cs @@ -1,3 +1,4 @@ +global using System.Globalization; global using System.Text; global using System.Text.Json; diff --git a/sample/src/Demo.Api/Options/ConfigureSwaggerOptions.cs b/sample/src/Demo.Api/Options/ConfigureSwaggerOptions.cs index 6545f57..c8f931b 100644 --- a/sample/src/Demo.Api/Options/ConfigureSwaggerOptions.cs +++ b/sample/src/Demo.Api/Options/ConfigureSwaggerOptions.cs @@ -40,7 +40,7 @@ private OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description) var info = new OpenApiInfo { Title = $"{environment.ApplicationName} {description.GroupName.ToUpperInvariant()}", - Version = description.ApiVersion.ToString(), + Version = description.ApiVersion.ToString(format: null, CultureInfo.InvariantCulture), Contact = new OpenApiContact { Name = "atc-net", Email = "atcnet.org@gmail.com" }, }; diff --git a/sample/src/Demo.Domain/Demo.Domain.csproj b/sample/src/Demo.Domain/Demo.Domain.csproj index cd87a38..1702fa1 100644 --- a/sample/src/Demo.Domain/Demo.Domain.csproj +++ b/sample/src/Demo.Domain/Demo.Domain.csproj @@ -7,7 +7,7 @@ - + diff --git a/sample/src/Demo.Web/Demo.Web.csproj b/sample/src/Demo.Web/Demo.Web.csproj index 350ef32..6ea4355 100644 --- a/sample/src/Demo.Web/Demo.Web.csproj +++ b/sample/src/Demo.Web/Demo.Web.csproj @@ -6,14 +6,14 @@ - - - - - - - - + + + + + + + + diff --git a/sample/test/Demo.Api.Contracts.Tests/Demo.Api.Contracts.Tests.csproj b/sample/test/Demo.Api.Contracts.Tests/Demo.Api.Contracts.Tests.csproj index 8b6fe5c..5f1ae56 100644 --- a/sample/test/Demo.Api.Contracts.Tests/Demo.Api.Contracts.Tests.csproj +++ b/sample/test/Demo.Api.Contracts.Tests/Demo.Api.Contracts.Tests.csproj @@ -6,19 +6,6 @@ true - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - diff --git a/sample/test/Demo.Api.IntegrationTests/Demo.Api.IntegrationTests.csproj b/sample/test/Demo.Api.IntegrationTests/Demo.Api.IntegrationTests.csproj index e5b1eb2..5f1ae56 100644 --- a/sample/test/Demo.Api.IntegrationTests/Demo.Api.IntegrationTests.csproj +++ b/sample/test/Demo.Api.IntegrationTests/Demo.Api.IntegrationTests.csproj @@ -6,20 +6,6 @@ true - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - diff --git a/sample/test/Demo.Api.IntegrationTests/Endpoints/UsersEndpointDefinitionTests.cs b/sample/test/Demo.Api.IntegrationTests/Endpoints/UsersEndpointDefinitionTests.cs index 0728526..9f7d282 100644 --- a/sample/test/Demo.Api.IntegrationTests/Endpoints/UsersEndpointDefinitionTests.cs +++ b/sample/test/Demo.Api.IntegrationTests/Endpoints/UsersEndpointDefinitionTests.cs @@ -13,7 +13,8 @@ public async Task GetAllUsers_ReturnsOkResultOfIEnumerableUser() // Act var response = await httpClient.GetFromJsonAsync>( UsersEndpointDefinition.ApiRouteBase, - JsonSerializerOptionsFactory.Create()); + JsonSerializerOptionsFactory.Create(), + TestContext.Current.CancellationToken); // Assert Assert.NotNull(response); diff --git a/sample/test/Demo.Domain.Tests/Demo.Domain.Tests.csproj b/sample/test/Demo.Domain.Tests/Demo.Domain.Tests.csproj index 8758e8d..2b96447 100644 --- a/sample/test/Demo.Domain.Tests/Demo.Domain.Tests.csproj +++ b/sample/test/Demo.Domain.Tests/Demo.Domain.Tests.csproj @@ -6,19 +6,6 @@ true - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - diff --git a/sample/test/Directory.Build.props b/sample/test/Directory.Build.props index 586e8e3..2570c13 100644 --- a/sample/test/Directory.Build.props +++ b/sample/test/Directory.Build.props @@ -6,19 +6,36 @@ --> + + true + true + true + + annotations - - + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - + diff --git a/src/.editorconfig b/src/.editorconfig index 3ad9104..2950137 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -2,7 +2,7 @@ # Version: 1.0.0 # Updated: 03-06-2024 # Location: src -# Distribution: DotNet9 +# Distribution: DotNet10 # Inspired by: https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code-style-rule-options ########################################## diff --git a/src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj b/src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj index 2d6afc2..8803705 100644 --- a/src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj +++ b/src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj @@ -1,7 +1,7 @@ - +๏ปฟ - net8.0;net9.0;net10.0 + net10.0 Atc.Rest.MinimalApi asp-net;minimal-api;minimal;rest Atc.Rest.MinimalApi is a collection of various components related to minimal apis, e.g EndpointDefinitions, Extensions, ValidationFilters, Errorhandling, Swagger Filters, versioning etc. @@ -14,7 +14,7 @@ - + diff --git a/test/.editorconfig b/test/.editorconfig index bb67100..8af5c12 100644 --- a/test/.editorconfig +++ b/test/.editorconfig @@ -2,7 +2,7 @@ # Version: 1.0.0 # Updated: 09-01-2025 # Location: test -# Distribution: DotNet9 +# Distribution: DotNet10 # Inspired by: https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code-style-rule-options ########################################## diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 7bb8c7e..2e68270 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -17,7 +17,7 @@ - + @@ -25,7 +25,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive From 99b6d3ce7c9c8646adf20e4978d7624020662ede Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 4 Dec 2025 16:25:09 +0100 Subject: [PATCH 23/54] chore: bump version to 2.0 --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 9f7c0ed..ff60197 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.0", + "version": "2.0", "cloudBuild": { "buildNumber": { "enabled": true From d70434932a908e02e7dc91a0d24742d3d4ec5c98 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Thu, 4 Dec 2025 17:30:16 +0100 Subject: [PATCH 24/54] docs: update readme --- README.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1e00b3c..3cf2045 100644 --- a/README.md +++ b/README.md @@ -197,16 +197,16 @@ Enhance your Minimal API with powerful validation using the `ValidationFilter ## Validation Approaches -When building APIs with .NET 8, 9, or 10, you have two options for validation: +When building APIs with .NET 10, you have two options for validation: ### Option 1: Atc.Rest.MinimalApi ValidationFilter (Recommended) โญ -This library provides a `ValidationFilter` that works consistently across all .NET versions. +This library provides a `ValidationFilter` that works on .NET 10. **โœ… Pros:** - **Unified error responses** - Merges DataAnnotations and FluentValidation errors into a single response - **Smart error keys** - Respects `[JsonPropertyName]` attributes for serialization-aware error keys -- **Consistent behavior** - Same validation logic across .NET 8, 9, and 10 +- **Consistent behavior** - Reliable validation logic on .NET 10 - **No additional configuration** - Works out of the box without project file changes - **Error deduplication** - Automatically removes duplicate validation errors - **Custom validation attributes** - Full support for custom `ValidationAttribute` implementations @@ -485,11 +485,7 @@ The Demo.Api project also leverages the `Asp.Versioning.Http` Nuget package to e # ๐Ÿ“‹ Requirements -This library supports multiple .NET versions: - -* [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) -* [.NET 9 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) -* [.NET 10 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) (Preview) +* [.NET 10 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) # ๐Ÿค How to contribute From 223988fbe91c1249eced9a3ba5d139be5e935fff Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 09:43:39 +0100 Subject: [PATCH 25/54] fix(swagger): resolve enum type lookup for duplicate names across assemblies Use DocumentFilterContext to build schema-to-type mapping instead of AppDomain scanning, enabling correct enum description generation when multiple assemblies define enums with identical names. --- .../SwaggerEnumDescriptionsDocumentFilter.cs | 143 ++++++++++++++++-- 1 file changed, 131 insertions(+), 12 deletions(-) diff --git a/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilter.cs b/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilter.cs index f2dca1e..3d08205 100644 --- a/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilter.cs +++ b/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilter.cs @@ -7,6 +7,13 @@ namespace Atc.Rest.MinimalApi.Filters.Swagger; /// public class SwaggerEnumDescriptionsDocumentFilter : IDocumentFilter { + /// + /// Thread-local mapping from schema ID to CLR Type, built from DocumentFilterContext. + /// This provides accurate type resolution even when multiple assemblies define enums with the same name. + /// + [ThreadStatic] + private static Dictionary? schemaIdToType; + /// /// Applies the filter to the specified Swagger document, enhancing enum descriptions in the schema components and operations. /// @@ -23,6 +30,9 @@ public void Apply( ArgumentNullException.ThrowIfNull(swaggerDoc); ArgumentNullException.ThrowIfNull(context); + // Build schema-to-type mapping from DocumentFilterContext for accurate type resolution + BuildSchemaToTypeMapping(context); + // Add enum descriptions to result models if (swaggerDoc.Components?.Schemas is null) { @@ -106,6 +116,96 @@ private static void DescribeEnumParameters( } } + /// + /// Builds a mapping from schema IDs to CLR Types by analyzing the API descriptions. + /// This approach ensures accurate type resolution by using the actual types Swashbuckle registered. + /// + /// The document filter context containing API descriptions and schema repository. + private static void BuildSchemaToTypeMapping(DocumentFilterContext context) + { + schemaIdToType = new Dictionary(StringComparer.Ordinal); + var visitedTypes = new HashSet(); + + foreach (var apiDescription in context.ApiDescriptions) + { + foreach (var responseType in apiDescription.SupportedResponseTypes) + { + if (responseType.Type is not null) + { + RegisterTypeAndNestedTypes(responseType.Type, context, visitedTypes); + } + } + + foreach (var parameter in apiDescription.ParameterDescriptions) + { + if (parameter.Type is not null) + { + RegisterTypeAndNestedTypes(parameter.Type, context, visitedTypes); + } + } + } + } + + /// + /// Recursively registers a type and its nested types (properties, generic arguments) in the schema-to-type mapping. + /// + /// The type to register. + /// The document filter context. + /// Set of already processed types to prevent infinite recursion. + private static void RegisterTypeAndNestedTypes( + Type type, + DocumentFilterContext context, + HashSet visitedTypes) + { + // Handle nullable types + var underlyingType = Nullable.GetUnderlyingType(type) ?? type; + + // Prevent infinite recursion for circular type references + if (!visitedTypes.Add(underlyingType)) + { + return; + } + + // Handle generic types (List, Dictionary, etc.) + if (underlyingType.IsGenericType) + { + foreach (var genericArg in underlyingType.GetGenericArguments()) + { + RegisterTypeAndNestedTypes(genericArg, context, visitedTypes); + } + } + + // Handle array types + if (underlyingType.IsArray && underlyingType.GetElementType() is { } elementType) + { + RegisterTypeAndNestedTypes(elementType, context, visitedTypes); + } + + // Register enum types - use type name as schema ID (Swashbuckle's default behavior) + // TryLookupByType may fail if the type was registered with a different instance, + // so we fall back to using the simple type name as the schema ID + if (underlyingType.IsEnum) + { + var schemaId = underlyingType.Name; + if (context.SchemaRepository.TryLookupByType(underlyingType, out var reference) && + reference.Id is not null) + { + schemaId = reference.Id; + } + + schemaIdToType!.TryAdd(schemaId, underlyingType); + } + + // Register properties of complex types (for nested enums in DTOs) + if (underlyingType.IsClass && underlyingType != typeof(string)) + { + foreach (var property in underlyingType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + RegisterTypeAndNestedTypes(property.PropertyType, context, visitedTypes); + } + } + } + /// /// Constructs a description for a given enum, enumerating its values and names. /// @@ -143,21 +243,40 @@ private static string DescribeEnum( } /// - /// Retrieves the enum type by its name from the current application domain. + /// Retrieves the enum type by its name (schema ID), using the schema-to-type mapping + /// built from the DocumentFilterContext for accurate resolution. /// - /// The name of the enum type to find. + /// The name of the enum type (schema ID) to find. /// The enum type, if found; otherwise, null. + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "GetTypes() can throw various exceptions for dynamic assemblies")] private static Type? GetEnumTypeByName(string enumTypeName) - => AppDomain.CurrentDomain + { + // Primary: Use schema-to-type mapping (most accurate) + if (schemaIdToType is not null && schemaIdToType.TryGetValue(enumTypeName, out var mappedType)) + { + return mappedType; + } + + // Fallback: AppDomain scan for edge cases (e.g., enums not in API descriptions) + var enumTypes = AppDomain.CurrentDomain .GetAssemblies() - .SelectMany(x => x - .GetTypes() - .Where(t => t.IsEnum)) - .Where(x => string.Equals(x.Name, enumTypeName, StringComparison.Ordinal)) - .ToArray() - switch + .SelectMany(assembly => { - { Length: 1 } a => a[0], - _ => null, - }; + try + { + return assembly + .GetTypes() + .Where(t => t.IsEnum); + } + catch (ReflectionTypeLoadException) + { + return []; + } + }) + .Where(x => string.Equals(x.Name, enumTypeName, StringComparison.Ordinal)) + .ToArray(); + + // Only return if exactly one match (to avoid ambiguity) + return enumTypes.Length == 1 ? enumTypes[0] : null; + } } \ No newline at end of file From a53071d72e33f4cec20aa9c88f04ecb69d340c9e Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 14:55:33 +0100 Subject: [PATCH 26/54] docs: update readme --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 3cf2045..d37da76 100644 --- a/README.md +++ b/README.md @@ -412,7 +412,6 @@ app.UseSwagger(options => app.UseSwagger(options => { options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; - options.RouteTemplate = "openapi/{documentName}.json"; }); // Configure Swagger UI From b98c8c9a1d2578aec5a7a42ca9536be5dd78d4b9 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 15:17:43 +0100 Subject: [PATCH 27/54] fix(middleware): prevent header modification after response started --- .../Middleware/GlobalErrorHandlingMiddleware.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs index cc38f39..02beffb 100644 --- a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs +++ b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs @@ -50,6 +50,12 @@ private Task HandleExceptionAsync( HttpContext context, Exception exception) { + // Can't modify response if it has already started + if (context.Response.HasStarted) + { + return Task.CompletedTask; + } + var statusCode = GetHttpStatusCodeByExceptionType(exception); context.Response.ContentType = MediaTypeNames.Application.Json; context.Response.StatusCode = (int)statusCode; From e56ee18e27c4b1d0ec2ae73386168afbfe08b5e1 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 15:30:05 +0100 Subject: [PATCH 28/54] docs(readme): fix incorrect GlobalErrorHandlingOptions class name --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d37da76..5027f3b 100644 --- a/README.md +++ b/README.md @@ -455,7 +455,7 @@ An example of how to configure the middleware with options. ```csharp var app = builder.Build(); -var options = new GlobalErrorHandlingMiddlewareOptions +var options = new GlobalErrorHandlingOptions { IncludeException = true, UseProblemDetailsAsResponseBody = false, From 301c93eba918b8d5a046adb0aafd42f2597c22be Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 15:31:42 +0100 Subject: [PATCH 29/54] docs(readme): remove duplicate UseSwagger call in Scalar example --- README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/README.md b/README.md index 5027f3b..3446d7d 100644 --- a/README.md +++ b/README.md @@ -402,13 +402,7 @@ When configured, your API will have the following documentation endpoints: Example configuration with separate OpenAPI paths: ```csharp -// Configure Swagger with OpenAPI 3.1 (default path for Swagger UI) -app.UseSwagger(options => -{ - options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; -}); - -// Also serve at /openapi path for Scalar +// Configure Swagger with OpenAPI 3.1 app.UseSwagger(options => { options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; From cbb7b306b92db6c2a3ed5a9fc586e2c3c923b688 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 15:49:28 +0100 Subject: [PATCH 30/54] refactor(middleware): use pattern matching for exception type checking --- .../GlobalErrorHandlingMiddleware.cs | 48 +++++-------------- 1 file changed, 12 insertions(+), 36 deletions(-) diff --git a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs index 02beffb..210fea6 100644 --- a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs +++ b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs @@ -74,31 +74,16 @@ private Task HandleExceptionAsync( /// The corresponding HTTP status code. private static HttpStatusCode GetHttpStatusCodeByExceptionType( Exception exception) - { - var statusCode = HttpStatusCode.InternalServerError; - - var exceptionType = exception.GetType(); - if (exceptionType == typeof(FluentValidation.ValidationException) || - exceptionType == typeof(System.ComponentModel.DataAnnotations.ValidationException) || - exceptionType == typeof(BadHttpRequestException)) - { - statusCode = HttpStatusCode.BadRequest; - } - else if (exceptionType == typeof(UnauthorizedAccessException)) - { - statusCode = HttpStatusCode.Unauthorized; - } - else if (exceptionType == typeof(InvalidOperationException)) - { - statusCode = HttpStatusCode.Conflict; - } - else if (exceptionType == typeof(NotImplementedException)) - { - statusCode = HttpStatusCode.NotImplemented; - } - - return statusCode; - } + => exception switch + { + FluentValidation.ValidationException => HttpStatusCode.BadRequest, + System.ComponentModel.DataAnnotations.ValidationException => HttpStatusCode.BadRequest, + BadHttpRequestException => HttpStatusCode.BadRequest, + UnauthorizedAccessException => HttpStatusCode.Unauthorized, + InvalidOperationException => HttpStatusCode.Conflict, + NotImplementedException => HttpStatusCode.NotImplemented, + _ => HttpStatusCode.InternalServerError, + }; /// /// Creates a problem details object to include in the error response. @@ -221,15 +206,6 @@ private static void SetExtensionFields( /// if a simple message should be used for the specified exception types; otherwise, . /// private bool UseSimpleMessage(Exception exception) - { - if (!options.IncludeException) - { - return true; - } - - var exceptionType = exception.GetType(); - return exceptionType == typeof(BadHttpRequestException) || - exceptionType == typeof(UnauthorizedAccessException) || - exceptionType == typeof(NotImplementedException); - } + => !options.IncludeException || + exception is BadHttpRequestException or UnauthorizedAccessException or NotImplementedException; } \ No newline at end of file From d175852f5c457e42423a433367753e1221f3d419 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 15:54:10 +0100 Subject: [PATCH 31/54] feat(middleware): add exception mappings for ArgumentException and TimeoutException --- .../Middleware/GlobalErrorHandlingMiddleware.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs index 210fea6..47aa38e 100644 --- a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs +++ b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs @@ -79,9 +79,11 @@ private static HttpStatusCode GetHttpStatusCodeByExceptionType( FluentValidation.ValidationException => HttpStatusCode.BadRequest, System.ComponentModel.DataAnnotations.ValidationException => HttpStatusCode.BadRequest, BadHttpRequestException => HttpStatusCode.BadRequest, + ArgumentException => HttpStatusCode.BadRequest, UnauthorizedAccessException => HttpStatusCode.Unauthorized, InvalidOperationException => HttpStatusCode.Conflict, NotImplementedException => HttpStatusCode.NotImplemented, + TimeoutException => HttpStatusCode.GatewayTimeout, _ => HttpStatusCode.InternalServerError, }; From 553ada99b0f3f7251a0da65001943fe2e2a23c47 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 15:56:37 +0100 Subject: [PATCH 32/54] fix(middleware): skip error response for canceled requests --- .../Middleware/GlobalErrorHandlingMiddleware.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs index 47aa38e..42b06c7 100644 --- a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs +++ b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs @@ -34,6 +34,10 @@ public async Task Invoke(HttpContext context) { await next(context); } + catch (OperationCanceledException) + { + // Client disconnected or request was canceled - no error response needed + } catch (Exception ex) { await HandleExceptionAsync(context, ex); From 09c698d152edd1f892a6263a4e63535faa7e05dc Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 16:12:32 +0100 Subject: [PATCH 33/54] fix(middleware): produce valid JSON in CreateMessage output --- .../GlobalErrorHandlingMiddleware.cs | 36 +++--- test/.editorconfig | 2 + .../Atc.Rest.MinimalApi.Tests/GlobalUsings.cs | 3 + .../GlobalErrorHandlingMiddlewareTests.cs | 110 ++++++++++++++++++ 4 files changed, 130 insertions(+), 21 deletions(-) create mode 100644 test/Atc.Rest.MinimalApi.Tests/Middleware/GlobalErrorHandlingMiddlewareTests.cs diff --git a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs index 42b06c7..aa006df 100644 --- a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs +++ b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs @@ -127,52 +127,46 @@ private ProblemDetails CreateProblemDetails( /// The for the current request. /// The exception to include in the problem details. /// The HTTP status code for the response. - /// A object representing the error details. + /// A JSON string representing the error details. private string CreateMessage( HttpContext context, Exception exception, HttpStatusCode statusCode) { - var sb = new StringBuilder(); + var message = new Dictionary(StringComparer.Ordinal) + { + ["status"] = (int)statusCode, + ["title"] = statusCode.ToNormalizedString(), + }; - sb.AppendLine("{"); - sb.Append(2, "status: "); - sb.AppendLine(((int)statusCode).ToString(GlobalizationConstants.EnglishCultureInfo)); - sb.Append(2, "title: "); - sb.AppendLine(statusCode.ToNormalizedString()); + var detail = UseSimpleMessage(exception) + ? exception.GetMessage() + : exception.GetMessage(includeInnerMessage: true, includeExceptionName: true); - if (exception is not null) + if (!string.IsNullOrEmpty(detail)) { - sb.Append(2, "detail: "); - sb.AppendLine(UseSimpleMessage(exception) - ? exception.GetMessage() - : exception.GetMessage(includeInnerMessage: true, includeExceptionName: true)); + message["detail"] = detail; } var correlationId = context.GetCorrelationId(); if (!string.IsNullOrEmpty(correlationId)) { - sb.Append(2, "correlationId: "); - sb.AppendLine(correlationId); + message["correlationId"] = correlationId; } var requestId = context.GetRequestId(); if (!string.IsNullOrEmpty(requestId)) { - sb.Append(2, "requestId: "); - sb.AppendLine(requestId); + message["requestId"] = requestId; } var traceId = context.TraceIdentifier; if (!string.IsNullOrEmpty(traceId)) { - sb.Append(2, "traceId: "); - sb.AppendLine(traceId); + message["traceId"] = traceId; } - sb.Append('}'); - - return sb.ToString(); + return JsonSerializer.Serialize(message); } /// diff --git a/test/.editorconfig b/test/.editorconfig index 8af5c12..f8bdd4e 100644 --- a/test/.editorconfig +++ b/test/.editorconfig @@ -55,8 +55,10 @@ dotnet_diagnostic.SA1133.severity = none # https://github.com/atc-net # Custom - Code Analyzers Rules ########################################## dotnet_diagnostic.CA1062.severity = none # https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1062 +dotnet_diagnostic.CA2000.severity = none # https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2000 dotnet_diagnostic.SA1402.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1402.md dotnet_diagnostic.SA1649.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1649.md +dotnet_diagnostic.MA0015.severity = none # dotnet_diagnostic.MA0048.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/Meziantou/MA0048.md \ No newline at end of file diff --git a/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs b/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs index 2a061eb..2bc0797 100644 --- a/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs +++ b/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs @@ -1,9 +1,12 @@ global using System.ComponentModel.DataAnnotations; global using System.Runtime.CompilerServices; +global using System.Text.Json; global using System.Text.Json.Serialization; global using Atc.Rest.MinimalApi.Extensions; global using Atc.Rest.MinimalApi.Extensions.Internal; global using Atc.Rest.MinimalApi.Filters.Endpoints; +global using Atc.Rest.MinimalApi.Middleware; +global using Atc.Rest.MinimalApi.Options; global using Atc.Rest.MinimalApi.Tests.Models; global using Atc.Rest.MinimalApi.Tests.Validators; global using FluentAssertions.Execution; diff --git a/test/Atc.Rest.MinimalApi.Tests/Middleware/GlobalErrorHandlingMiddlewareTests.cs b/test/Atc.Rest.MinimalApi.Tests/Middleware/GlobalErrorHandlingMiddlewareTests.cs new file mode 100644 index 0000000..d848758 --- /dev/null +++ b/test/Atc.Rest.MinimalApi.Tests/Middleware/GlobalErrorHandlingMiddlewareTests.cs @@ -0,0 +1,110 @@ +namespace Atc.Rest.MinimalApi.Tests.Middleware; + +public sealed class GlobalErrorHandlingMiddlewareTests +{ + [Fact] + public async Task Invoke_WhenExceptionThrown_ReturnsValidJson() + { + // Arrange + var options = new GlobalErrorHandlingOptions + { + UseProblemDetailsAsResponseBody = false, + IncludeException = true, + }; + + var context = new DefaultHttpContext { Response = { Body = new MemoryStream() } }; + + var middleware = new GlobalErrorHandlingMiddleware( + next: _ => throw new InvalidOperationException("Test error message"), + options: options); + + // Act + await middleware.Invoke(context); + + // Assert + context.Response.Body.Seek(0, SeekOrigin.Begin); + var responseBody = await new StreamReader(context.Response.Body).ReadToEndAsync(TestContext.Current.CancellationToken); + + // This should be valid JSON - currently it fails because the output is not valid JSON + var parseAction = () => JsonDocument.Parse(responseBody); + parseAction.Should().NotThrow( + because: "the error response should be valid JSON, but got: {0}", responseBody); + } + + [Fact] + public async Task Invoke_WhenExceptionThrown_WithProblemDetails_ReturnsValidJson() + { + // Arrange + var options = new GlobalErrorHandlingOptions + { + UseProblemDetailsAsResponseBody = true, + IncludeException = true, + }; + + var context = new DefaultHttpContext { Response = { Body = new MemoryStream() } }; + + var middleware = new GlobalErrorHandlingMiddleware( + next: _ => throw new ArgumentException("Invalid argument"), + options: options); + + // Act + await middleware.Invoke(context); + + // Assert + context.Response.Body.Seek(0, SeekOrigin.Begin); + var responseBody = await new StreamReader(context.Response.Body).ReadToEndAsync(TestContext.Current.CancellationToken); + + // ProblemDetails path - this should work as it uses JsonSerializer + var parseAction = () => JsonDocument.Parse(responseBody); + parseAction.Should().NotThrow( + because: "the ProblemDetails response should be valid JSON"); + } + + [Fact] + public async Task Invoke_WhenExceptionThrown_ResponseContainsExpectedFields() + { + // Arrange + var options = new GlobalErrorHandlingOptions + { + UseProblemDetailsAsResponseBody = false, + IncludeException = true, + }; + + var context = new DefaultHttpContext { Response = { Body = new MemoryStream() } }; + + var middleware = new GlobalErrorHandlingMiddleware( + next: _ => throw new InvalidOperationException("Test error"), + options: options); + + // Act + await middleware.Invoke(context); + + // Assert + context.Response.Body.Seek(0, SeekOrigin.Begin); + var responseBody = await new StreamReader(context.Response.Body).ReadToEndAsync(TestContext.Current.CancellationToken); + + // Parse as JSON and verify fields exist + using var doc = JsonDocument.Parse(responseBody); + var root = doc.RootElement; + + root + .TryGetProperty("status", out var status) + .Should() + .BeTrue(); + + status + .GetInt32() + .Should() + .Be(409); // InvalidOperationException -> Conflict + + root + .TryGetProperty("title", out var title) + .Should() + .BeTrue(); + + title + .GetString() + .Should() + .Be("Conflict"); + } +} \ No newline at end of file From 22c73bd6d9e75fe8b71aecf0461df1a8fe3190a3 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 16:15:21 +0100 Subject: [PATCH 34/54] refactor(middleware): remove unnecessary null checks for exception parameter --- .../Middleware/GlobalErrorHandlingMiddleware.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs index aa006df..e24b94d 100644 --- a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs +++ b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs @@ -107,14 +107,10 @@ private ProblemDetails CreateProblemDetails( { Status = (int)statusCode, Title = statusCode.ToNormalizedString(), - }; - - if (exception is not null) - { - result.Detail = UseSimpleMessage(exception) + Detail = UseSimpleMessage(exception) ? exception.GetMessage() - : exception.GetMessage(includeInnerMessage: true, includeExceptionName: true); - } + : exception.GetMessage(includeInnerMessage: true, includeExceptionName: true), + }; SetExtensionFields(result, context); From 4c8fa658bb9a4dd31931522285156c46b8792b17 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 16:17:04 +0100 Subject: [PATCH 35/54] feat(middleware): add request path as Instance in ProblemDetails --- .../Middleware/GlobalErrorHandlingMiddleware.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs index e24b94d..7ca4285 100644 --- a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs +++ b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs @@ -110,6 +110,7 @@ private ProblemDetails CreateProblemDetails( Detail = UseSimpleMessage(exception) ? exception.GetMessage() : exception.GetMessage(includeInnerMessage: true, includeExceptionName: true), + Instance = context.Request.Path, }; SetExtensionFields(result, context); From 4f24d4abb05e293da9b2ce83a61aee09863aff79 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 16:22:54 +0100 Subject: [PATCH 36/54] refactor(middleware): unify extension fields logic and add instance to CreateMessage --- .../GlobalErrorHandlingMiddleware.cs | 42 ++++++++----------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs index 7ca4285..4d58003 100644 --- a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs +++ b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs @@ -130,10 +130,11 @@ private string CreateMessage( Exception exception, HttpStatusCode statusCode) { - var message = new Dictionary(StringComparer.Ordinal) + var message = new Dictionary(StringComparer.Ordinal) { ["status"] = (int)statusCode, ["title"] = statusCode.ToNormalizedString(), + ["instance"] = context.Request.Path.ToString(), }; var detail = UseSimpleMessage(exception) @@ -145,23 +146,7 @@ private string CreateMessage( message["detail"] = detail; } - var correlationId = context.GetCorrelationId(); - if (!string.IsNullOrEmpty(correlationId)) - { - message["correlationId"] = correlationId; - } - - var requestId = context.GetRequestId(); - if (!string.IsNullOrEmpty(requestId)) - { - message["requestId"] = requestId; - } - - var traceId = context.TraceIdentifier; - if (!string.IsNullOrEmpty(traceId)) - { - message["traceId"] = traceId; - } + AddExtensionFields(message, context); return JsonSerializer.Serialize(message); } @@ -174,24 +159,33 @@ private string CreateMessage( private static void SetExtensionFields( ProblemDetails problemDetails, HttpContext context) + => AddExtensionFields(problemDetails.Extensions, context); + + /// + /// Adds extension fields (correlationId, requestId, traceId) to a dictionary. + /// + /// The dictionary to add fields to. + /// The for the current request. + private static void AddExtensionFields( + IDictionary dictionary, + HttpContext context) { var correlationId = context.GetCorrelationId(); - var requestId = context.GetRequestId(); - var traceId = context.TraceIdentifier; - if (!string.IsNullOrEmpty(correlationId)) { - problemDetails.Extensions["correlationId"] = correlationId; + dictionary["correlationId"] = correlationId; } + var requestId = context.GetRequestId(); if (!string.IsNullOrEmpty(requestId)) { - problemDetails.Extensions["requestId"] = requestId; + dictionary["requestId"] = requestId; } + var traceId = context.TraceIdentifier; if (!string.IsNullOrEmpty(traceId)) { - problemDetails.Extensions["traceId"] = traceId; + dictionary["traceId"] = traceId; } } From a6e3d4ab07c88ed02a64775c38965035486277e2 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 16:48:57 +0100 Subject: [PATCH 37/54] fix(endpoints): filter null instances from Activator.CreateInstance --- .../Extensions/EndpointDefinitionExtensions.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs b/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs index ae13eff..7116c0a 100644 --- a/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs +++ b/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs @@ -27,7 +27,9 @@ public static void AddEndpointDefinitions( marker.Assembly.ExportedTypes .Where(x => typeof(IEndpointDefinition).IsAssignableFrom(x) && x is { IsInterface: false, IsAbstract: false }) - .Select(Activator.CreateInstance).Cast()); + .Select(Activator.CreateInstance) + .Where(x => x is not null) + .Cast()); } services.AddSingleton(endpointDefinitions as IReadOnlyCollection); @@ -59,7 +61,9 @@ public static void AddEndpointAndServiceDefinitions( marker.Assembly.ExportedTypes .Where(x => typeof(IEndpointAndServiceDefinition).IsAssignableFrom(x) && x is { IsInterface: false, IsAbstract: false }) - .Select(Activator.CreateInstance).Cast()); + .Select(Activator.CreateInstance) + .Where(x => x is not null) + .Cast()); } foreach (var endpointDefinition in endpointDefinitions) From 96f4ce77a9d15f05e02a4eac405f47bab53a0523 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 16:49:57 +0100 Subject: [PATCH 38/54] fix(swagger): use TryParse for enum string parsing --- .../Filters/Swagger/SwaggerEnumDescriptionsDocumentFilter.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilter.cs b/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilter.cs index 3d08205..82fcf10 100644 --- a/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilter.cs +++ b/src/Atc.Rest.MinimalApi/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilter.cs @@ -231,9 +231,10 @@ private static string DescribeEnum( { enumDescriptions.Add($"{intValue} = {Enum.GetName(enumType, intValue)}"); } - else if (jsonValue.TryGetValue(out var stringValue)) + else if (jsonValue.TryGetValue(out var stringValue) && + Enum.TryParse(enumType, stringValue, ignoreCase: false, out var enumValue)) { - var enumInt = (int)Enum.Parse(enumType, stringValue); + var enumInt = (int)enumValue; enumDescriptions.Add($"{enumInt} = {stringValue}"); } } From 1c3a37ac5f44e51c63e7a73269ce765db944a764 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 16:54:57 +0100 Subject: [PATCH 39/54] fix(validation): add bounds check for GenericTypeArguments access --- .../Extensions/ValidationProblemExtensions.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs b/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs index 4e0a3f6..20d04b0 100644 --- a/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs +++ b/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs @@ -109,7 +109,8 @@ private static void DeepResolveSerializationNames( continue; } - subType = propertyInfo.PropertyType.IsGenericType + subType = propertyInfo.PropertyType.IsGenericType && + propertyInfo.PropertyType.GenericTypeArguments.Length > 0 ? propertyInfo.PropertyType.GenericTypeArguments[0] : propertyInfo.PropertyType; From e1f7c8872079b01e2e26a491fd603309a0a537a2 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 18:36:08 +0100 Subject: [PATCH 40/54] fix(sample): handle nullable fields and improve update handler --- .../Models/Requests/UsersRequestRecords.cs | 6 +- sample/src/Demo.Domain/MapsterConfig.cs | 2 +- .../src/Demo.Domain/Storage/AddressEntity.cs | 8 +- .../src/Demo.Domain/Storage/CountryEntity.cs | 6 +- sample/src/Demo.Domain/Storage/UserEntity.cs | 2 +- .../Users/Handlers/UpdateUserByIdHandler.cs | 79 ++++++++++++++++++- .../Validators/CreateUserRequestValidator.cs | 13 +-- .../Validators/UpdateUserRequestValidator.cs | 2 +- 8 files changed, 97 insertions(+), 21 deletions(-) diff --git a/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs b/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs index 007e139..c58fb1a 100644 --- a/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs +++ b/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs @@ -5,10 +5,10 @@ public sealed record CreateUserRequest( [property: Required, JsonPropertyName("firstName")] string FirstName, string LastName, [property: EmailAddress] string Email, - string Telephone, - [property: Url] string HomePage, + string? Telephone, + [property: Url] string? HomePage, Address? HomeAddress, - Address WorkAddress); + Address? WorkAddress); public sealed record UpdateUserRequest( [property: Required] GenderType Gender, diff --git a/sample/src/Demo.Domain/MapsterConfig.cs b/sample/src/Demo.Domain/MapsterConfig.cs index c3a14c7..e7fc062 100644 --- a/sample/src/Demo.Domain/MapsterConfig.cs +++ b/sample/src/Demo.Domain/MapsterConfig.cs @@ -6,7 +6,7 @@ public static void Register() { TypeAdapterConfig .NewConfig() - .Map(dest => dest.HomePage, src => new Uri(src.HomePage)); + .Map(dest => dest.HomePage, src => src.HomePage != null ? new Uri(src.HomePage) : null); TypeAdapterConfig.GlobalSettings.Compile(); } diff --git a/sample/src/Demo.Domain/Storage/AddressEntity.cs b/sample/src/Demo.Domain/Storage/AddressEntity.cs index 2a717c4..f7a41b7 100644 --- a/sample/src/Demo.Domain/Storage/AddressEntity.cs +++ b/sample/src/Demo.Domain/Storage/AddressEntity.cs @@ -2,13 +2,13 @@ namespace Demo.Domain.Storage; public class AddressEntity { - public string StreetName { get; set; } = string.Empty; + public string? StreetName { get; set; } - public string StreetNumber { get; set; } = string.Empty; + public string? StreetNumber { get; set; } - public string PostalCode { get; set; } = string.Empty; + public string? PostalCode { get; set; } - public string CityName { get; set; } = string.Empty; + public string? CityName { get; set; } public CountryEntity? Country { get; set; } diff --git a/sample/src/Demo.Domain/Storage/CountryEntity.cs b/sample/src/Demo.Domain/Storage/CountryEntity.cs index 570fd9b..2a49f7d 100644 --- a/sample/src/Demo.Domain/Storage/CountryEntity.cs +++ b/sample/src/Demo.Domain/Storage/CountryEntity.cs @@ -2,11 +2,11 @@ namespace Demo.Domain.Storage; public class CountryEntity { - public string Name { get; set; } = string.Empty; + public string? Name { get; set; } - public string Alpha2Code { get; set; } = string.Empty; + public string? Alpha2Code { get; set; } - public string Alpha3Code { get; set; } = string.Empty; + public string? Alpha3Code { get; set; } public override string ToString() => $"{nameof(Name)}: {Name}, {nameof(Alpha2Code)}: {Alpha2Code}, {nameof(Alpha3Code)}: {Alpha3Code}"; diff --git a/sample/src/Demo.Domain/Storage/UserEntity.cs b/sample/src/Demo.Domain/Storage/UserEntity.cs index a4c7429..2bde417 100644 --- a/sample/src/Demo.Domain/Storage/UserEntity.cs +++ b/sample/src/Demo.Domain/Storage/UserEntity.cs @@ -12,7 +12,7 @@ public class UserEntity public string Email { get; set; } = string.Empty; - public string Telephone { get; set; } = string.Empty; + public string? Telephone { get; set; } public Uri? HomePage { get; set; } diff --git a/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs b/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs index 237bda3..2a450ac 100644 --- a/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs +++ b/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs @@ -25,7 +25,8 @@ public async Task, BadRequest, NotFound, Conflict x.Email.Equals(parameters.Request.Email, StringComparison.OrdinalIgnoreCase), + x => x.Email.Equals(parameters.Request.Email, StringComparison.OrdinalIgnoreCase) && + x.Id != parameters.UserId, cancellationToken); if (existingUserByEmail is not null) @@ -42,6 +43,7 @@ public async Task, BadRequest, NotFound, Conflict !request.FirstName.Equals(user.FirstName, StringComparison.Ordinal) || !request.LastName.Equals(user.LastName, StringComparison.Ordinal) || request.Gender != user.Gender || - !request.Email.Equals(user.Email, StringComparison.Ordinal); + !request.Email.Equals(user.Email, StringComparison.Ordinal) || + IsAddressModified(request.WorkAddress, user.WorkAddress); + + private static bool IsAddressModified( + Address? request, + AddressEntity? entity) + { + if (request is null && entity is null) + { + return false; + } + + if (request is null || entity is null) + { + return true; + } + + return !string.Equals(request.StreetName, entity.StreetName, StringComparison.Ordinal) || + !string.Equals(request.StreetNumber, entity.StreetNumber, StringComparison.Ordinal) || + !string.Equals(request.PostalCode, entity.PostalCode, StringComparison.Ordinal) || + !string.Equals(request.CityName, entity.CityName, StringComparison.Ordinal) || + IsCountryModified(request.Country, entity.Country); + } + + private static bool IsCountryModified( + Country? request, + CountryEntity? entity) + { + if (request is null && entity is null) + { + return false; + } + + if (request is null || entity is null) + { + return true; + } + + return !string.Equals(request.Name, entity.Name, StringComparison.Ordinal) || + !string.Equals(request.Alpha2Code, entity.Alpha2Code, StringComparison.Ordinal) || + !string.Equals(request.Alpha3Code, entity.Alpha3Code, StringComparison.Ordinal); + } + + private static AddressEntity? MapAddress(Address? address) + { + if (address is null) + { + return null; + } + + return new AddressEntity + { + StreetName = address.StreetName, + StreetNumber = address.StreetNumber, + PostalCode = address.PostalCode, + CityName = address.CityName, + Country = MapCountry(address.Country), + }; + } + + private static CountryEntity? MapCountry(Country? country) + { + if (country is null) + { + return null; + } + + return new CountryEntity + { + Name = country.Name, + Alpha2Code = country.Alpha2Code, + Alpha3Code = country.Alpha3Code, + }; + } } \ No newline at end of file diff --git a/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs b/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs index a879bbb..1e9975e 100644 --- a/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs +++ b/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs @@ -1,28 +1,26 @@ namespace Demo.Domain.Validators; -public sealed class CreateUserRequestValidator : AbstractValidator +public sealed partial class CreateUserRequestValidator : AbstractValidator { public CreateUserRequestValidator() { RuleFor(x => x.FirstName) .NotNull() .Length(2, 10) - .Matches("^[A-Z]") + .Matches(EnsureFirstCharacterUpperCase()) .WithMessage(x => $"{nameof(x.FirstName)} has to start with an uppercase letter."); RuleFor(x => x.LastName) .NotNull() .Length(2, 30) - .Matches("^[A-Z]") + .Matches(EnsureFirstCharacterUpperCase()) .WithMessage(x => $"{nameof(x.LastName)} has to start with an uppercase letter.") .NotEqual(x => x.FirstName) .WithMessage("FirstName must not be equal to LastName."); - RuleFor(x => x.WorkAddress).NotNull(); - When(x => x.WorkAddress is not null, () => { - RuleFor(x => x.WorkAddress.CityName) + RuleFor(x => x.WorkAddress!.CityName) .NotEmpty() .WithMessage("WorkAddress.CityName is required.") .MinimumLength(3) @@ -31,4 +29,7 @@ public CreateUserRequestValidator() .WithMessage("WorkAddress.CityName must be max 20 characters long."); }); } + + [GeneratedRegex("^[A-Z]", RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)] + private static partial Regex EnsureFirstCharacterUpperCase(); } \ No newline at end of file diff --git a/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs b/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs index 44fb04a..b88631e 100644 --- a/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs +++ b/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs @@ -19,7 +19,7 @@ public UpdateUserRequestValidator() RuleFor(x => x.Request.LastName) .NotNull() - .Length(2, 10) + .Length(2, 30) .Matches(EnsureFirstCharacterUpperCase()) .WithMessage(x => $"{nameof(x.Request.LastName)} has to start with an uppercase letter.") .NotEqual(x => x.Request.FirstName) From 0946d76d59a849b2b81b95895749cc6b23eda7d3 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 19:14:36 +0100 Subject: [PATCH 41/54] test: add unit tests for Swagger filters and EndpointDefinitionExtensions --- .../Atc.Rest.MinimalApi.Tests.csproj | 4 + .../EndpointDefinitionExtensionsTests.cs | 165 ++++++++++++++++++ .../Swagger/SwaggerDefaultValuesTests.cs | 157 +++++++++++++++++ ...ggerEnumDescriptionsDocumentFilterTests.cs | 151 ++++++++++++++++ .../Atc.Rest.MinimalApi.Tests/GlobalUsings.cs | 11 +- 5 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 test/Atc.Rest.MinimalApi.Tests/Extensions/EndpointDefinitionExtensionsTests.cs create mode 100644 test/Atc.Rest.MinimalApi.Tests/Filters/Swagger/SwaggerDefaultValuesTests.cs create mode 100644 test/Atc.Rest.MinimalApi.Tests/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilterTests.cs diff --git a/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj b/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj index b90df6c..b09c6a7 100644 --- a/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj +++ b/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj @@ -10,4 +10,8 @@ + + + + diff --git a/test/Atc.Rest.MinimalApi.Tests/Extensions/EndpointDefinitionExtensionsTests.cs b/test/Atc.Rest.MinimalApi.Tests/Extensions/EndpointDefinitionExtensionsTests.cs new file mode 100644 index 0000000..c8ffb28 --- /dev/null +++ b/test/Atc.Rest.MinimalApi.Tests/Extensions/EndpointDefinitionExtensionsTests.cs @@ -0,0 +1,165 @@ +namespace Atc.Rest.MinimalApi.Tests.Extensions; + +public sealed class EndpointDefinitionExtensionsTests +{ + [Fact] + public void AddEndpointDefinitions_WhenScanMarkersIsNull_ThrowsArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + + // Act & Assert + Assert.Throws(() => services.AddEndpointDefinitions(null!)); + } + + [Fact] + public void AddEndpointDefinitions_WhenScanMarkersIsEmpty_AddsEmptyCollection() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddEndpointDefinitions(); + + // Assert + var provider = services.BuildServiceProvider(); + var definitions = provider.GetService>(); + Assert.NotNull(definitions); + Assert.Empty(definitions); + } + + [Fact] + public void AddEndpointDefinitions_WithValidMarker_RegistersEndpointDefinitions() + { + // Arrange + var services = new ServiceCollection(); + + // Act - Use the test class type as marker, which will scan this assembly + services.AddEndpointDefinitions(typeof(EndpointDefinitionExtensionsTests)); + + // Assert - Should find all public IEndpointDefinition implementations in this assembly + var provider = services.BuildServiceProvider(); + var definitions = provider.GetService>(); + Assert.NotNull(definitions); + + // We expect to find TestEndpointDefinition and TestEndpointAndServiceDefinition + Assert.True(definitions.Count >= 2); + Assert.Contains(definitions, x => x is TestEndpointDefinition); + Assert.Contains(definitions, x => x is TestEndpointAndServiceDefinition); + } + + [Fact] + public void AddEndpointAndServiceDefinitions_WhenScanMarkersIsNull_ThrowsArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + + // Act & Assert + Assert.Throws(() => services.AddEndpointAndServiceDefinitions(null!)); + } + + [Fact] + public void AddEndpointAndServiceDefinitions_WithValidMarker_RegistersDefinitionsAndCallsDefineServices() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddEndpointAndServiceDefinitions(typeof(TestEndpointAndServiceDefinition)); + + // Assert + var provider = services.BuildServiceProvider(); + var definitions = provider.GetService>(); + Assert.NotNull(definitions); + + // Verify DefineServices was called by checking registered service + var testService = provider.GetService(); + Assert.NotNull(testService); + } + + [Fact] + public void UseEndpointDefinitions_WhenAppIsNull_ThrowsArgumentNullException() + { + // Arrange + WebApplication? app = null; + + // Act & Assert + Assert.Throws(() => app!.UseEndpointDefinitions()); + } + + [Fact] + public void AddEndpointDefinitions_FiltersAbstractAndInterfaceTypes() + { + // Arrange + var services = new ServiceCollection(); + + // Act - Scan an assembly containing abstract types + services.AddEndpointDefinitions(typeof(AbstractEndpointDefinition)); + + // Assert - Should not include abstract types + var provider = services.BuildServiceProvider(); + var definitions = provider.GetService>(); + Assert.NotNull(definitions); + + // No abstract types should be included + Assert.DoesNotContain(definitions, x => x.GetType().IsAbstract); + } + + [Fact] + public void AddEndpointDefinitions_FiltersPotentialNullFromActivatorCreateInstance() + { + // Arrange + var services = new ServiceCollection(); + + // Act - This test ensures that if Activator.CreateInstance returns null, + // it doesn't cause issues (the Where filter handles it) + services.AddEndpointDefinitions(typeof(EndpointDefinitionExtensionsTests)); + + // Assert - Should complete without throwing + var provider = services.BuildServiceProvider(); + var definitions = provider.GetService>(); + Assert.NotNull(definitions); + } +} + +//// Test types defined outside the test class to have predictable behavior + +#pragma warning disable CA1034 // Nested types should not be visible +#pragma warning disable CA1040 // Avoid empty interfaces + +public sealed class TestEndpointDefinition : IEndpointDefinition +{ + public void DefineEndpoints(WebApplication app) + { + // Test implementation + } +} + +public interface ITestService +{ +} + +internal sealed class TestService : ITestService +{ +} + +public sealed class TestEndpointAndServiceDefinition : IEndpointAndServiceDefinition +{ + public void DefineEndpoints(WebApplication app) + { + // Test implementation + } + + public void DefineServices(IServiceCollection services) + { + services.AddSingleton(); + } +} + +public abstract class AbstractEndpointDefinition : IEndpointDefinition +{ + public abstract void DefineEndpoints(WebApplication app); +} + +#pragma warning restore CA1040 // Avoid empty interfaces +#pragma warning restore CA1034 // Nested types should not be visible \ No newline at end of file diff --git a/test/Atc.Rest.MinimalApi.Tests/Filters/Swagger/SwaggerDefaultValuesTests.cs b/test/Atc.Rest.MinimalApi.Tests/Filters/Swagger/SwaggerDefaultValuesTests.cs new file mode 100644 index 0000000..73b02eb --- /dev/null +++ b/test/Atc.Rest.MinimalApi.Tests/Filters/Swagger/SwaggerDefaultValuesTests.cs @@ -0,0 +1,157 @@ +namespace Atc.Rest.MinimalApi.Tests.Filters.Swagger; + +public sealed class SwaggerDefaultValuesTests +{ + [Fact] + public void Apply_WhenOperationParametersIsNull_DoesNotThrow() + { + // Arrange + var filter = new SwaggerDefaultValues(); + var operation = new OpenApiOperation + { + Parameters = null, + }; + + var apiDescription = CreateApiDescription(); + var context = CreateOperationFilterContext(apiDescription); + + // Act + var exception = Record.Exception(() => filter.Apply(operation, context)); + + // Assert + Assert.Null(exception); + } + + [Fact] + public void Apply_WhenOperationResponsesIsNull_DoesNotThrow() + { + // Arrange + var filter = new SwaggerDefaultValues(); + var operation = new OpenApiOperation + { + Responses = null, + }; + + var apiDescription = CreateApiDescription(); + var context = CreateOperationFilterContext(apiDescription); + + // Act + var exception = Record.Exception(() => filter.Apply(operation, context)); + + // Assert + Assert.Null(exception); + } + + [Fact] + public void Apply_WhenResponseContentIsNull_DoesNotThrow() + { + // Arrange + var filter = new SwaggerDefaultValues(); + var operation = new OpenApiOperation + { + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Content = null, + }, + }, + }; + + var apiDescription = CreateApiDescription(); + var context = CreateOperationFilterContext(apiDescription); + + // Act + var exception = Record.Exception(() => filter.Apply(operation, context)); + + // Assert + Assert.Null(exception); + } + + [Fact] + public void Apply_SetsParameterRequiredFromDescription() + { + // Arrange + var filter = new SwaggerDefaultValues(); + var operation = new OpenApiOperation + { + Parameters = + [ + new OpenApiParameter + { + Name = "testParam", + Required = false, + }, + ], + }; + + var apiDescription = CreateApiDescription(); + apiDescription.ParameterDescriptions.Add(new ApiParameterDescription + { + Name = "testParam", + IsRequired = true, + }); + + var context = CreateOperationFilterContext(apiDescription); + + // Act + filter.Apply(operation, context); + + // Assert + Assert.True(operation.Parameters[0].Required); + } + + [Fact] + public void Apply_WhenParameterDescriptionNotFound_DoesNotThrow() + { + // Arrange + var filter = new SwaggerDefaultValues(); + var operation = new OpenApiOperation + { + Parameters = + [ + new OpenApiParameter + { + Name = "unknownParam", + Required = false, + }, + ], + }; + + var apiDescription = CreateApiDescription(); + var context = CreateOperationFilterContext(apiDescription); + + // Act + var exception = Record.Exception(() => filter.Apply(operation, context)); + + // Assert + Assert.Null(exception); + } + + private static ApiDescription CreateApiDescription() + { + var apiDescription = new ApiDescription + { + ActionDescriptor = new ActionDescriptor + { + EndpointMetadata = [], + RouteValues = new Dictionary(StringComparer.Ordinal), + }, + }; + + return apiDescription; + } + + /// + /// Creates a test OperationFilterContext with minimal required dependencies. + /// The MethodInfo parameter is required by the constructor but not used in these tests. + /// + private static OperationFilterContext CreateOperationFilterContext( + ApiDescription apiDescription) + => new( + apiDescription, + Substitute.For(), + new SchemaRepository(), + new OpenApiDocument(), + typeof(SwaggerDefaultValuesTests).GetMethod(nameof(CreateApiDescription))!); +} \ No newline at end of file diff --git a/test/Atc.Rest.MinimalApi.Tests/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilterTests.cs b/test/Atc.Rest.MinimalApi.Tests/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilterTests.cs new file mode 100644 index 0000000..70fdb86 --- /dev/null +++ b/test/Atc.Rest.MinimalApi.Tests/Filters/Swagger/SwaggerEnumDescriptionsDocumentFilterTests.cs @@ -0,0 +1,151 @@ +namespace Atc.Rest.MinimalApi.Tests.Filters.Swagger; + +public sealed class SwaggerEnumDescriptionsDocumentFilterTests +{ + [Fact] + public void Apply_WhenSwaggerDocIsNull_ThrowsArgumentNullException() + { + // Arrange + var filter = new SwaggerEnumDescriptionsDocumentFilter(); + var context = CreateDocumentFilterContext(); + + // Act & Assert + Assert.Throws(() => filter.Apply(null!, context)); + } + + [Fact] + public void Apply_WhenContextIsNull_ThrowsArgumentNullException() + { + // Arrange + var filter = new SwaggerEnumDescriptionsDocumentFilter(); + var swaggerDoc = new OpenApiDocument(); + + // Act & Assert + Assert.Throws(() => filter.Apply(swaggerDoc, null!)); + } + + [Fact] + public void Apply_WhenComponentsSchemasIsNull_DoesNotThrow() + { + // Arrange + var filter = new SwaggerEnumDescriptionsDocumentFilter(); + var swaggerDoc = new OpenApiDocument + { + Components = null, + }; + var context = CreateDocumentFilterContext(); + + // Act + var exception = Record.Exception(() => filter.Apply(swaggerDoc, context)); + + // Assert + Assert.Null(exception); + } + + [Fact] + public void Apply_WhenSchemasIsEmpty_DoesNotThrow() + { + // Arrange + var filter = new SwaggerEnumDescriptionsDocumentFilter(); + var swaggerDoc = new OpenApiDocument + { + Components = new OpenApiComponents(), + }; + var context = CreateDocumentFilterContext(); + + // Act + var exception = Record.Exception(() => filter.Apply(swaggerDoc, context)); + + // Assert + Assert.Null(exception); + } + + [Fact] + public void Apply_WithEnumSchema_DoesNotThrow() + { + // Arrange + var filter = new SwaggerEnumDescriptionsDocumentFilter(); + var swaggerDoc = new OpenApiDocument + { + Components = new OpenApiComponents + { + Schemas = new Dictionary(StringComparer.Ordinal) + { + ["TestEnum"] = new OpenApiSchema + { + Enum = + [ + JsonValue.Create(0), + JsonValue.Create(1), + ], + }, + }, + }, + Paths = new OpenApiPaths(), + }; + + var context = CreateDocumentFilterContext(); + + // Act + var exception = Record.Exception(() => filter.Apply(swaggerDoc, context)); + + // Assert + Assert.Null(exception); + } + + [Fact] + public void Apply_WithEmptyPaths_DoesNotThrow() + { + // Arrange + var filter = new SwaggerEnumDescriptionsDocumentFilter(); + var swaggerDoc = new OpenApiDocument + { + Components = new OpenApiComponents(), + Paths = new OpenApiPaths(), + }; + var context = CreateDocumentFilterContext(); + + // Act + var exception = Record.Exception(() => filter.Apply(swaggerDoc, context)); + + // Assert + Assert.Null(exception); + } + + [Fact] + public void Apply_WithPathOperationsHavingNullParameters_DoesNotThrow() + { + // Arrange + var filter = new SwaggerEnumDescriptionsDocumentFilter(); + var swaggerDoc = new OpenApiDocument + { + Components = new OpenApiComponents(), + Paths = new OpenApiPaths + { + ["/test"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Get] = new OpenApiOperation + { + Parameters = null, + }, + }, + }, + }, + }; + var context = CreateDocumentFilterContext(); + + // Act + var exception = Record.Exception(() => filter.Apply(swaggerDoc, context)); + + // Assert + Assert.Null(exception); + } + + private static DocumentFilterContext CreateDocumentFilterContext() + => new( + [], + Substitute.For(), + new SchemaRepository()); +} \ No newline at end of file diff --git a/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs b/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs index 2bc0797..af333ff 100644 --- a/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs +++ b/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs @@ -1,10 +1,13 @@ global using System.ComponentModel.DataAnnotations; global using System.Runtime.CompilerServices; global using System.Text.Json; +global using System.Text.Json.Nodes; global using System.Text.Json.Serialization; +global using Atc.Rest.MinimalApi.Abstractions; global using Atc.Rest.MinimalApi.Extensions; global using Atc.Rest.MinimalApi.Extensions.Internal; global using Atc.Rest.MinimalApi.Filters.Endpoints; +global using Atc.Rest.MinimalApi.Filters.Swagger; global using Atc.Rest.MinimalApi.Middleware; global using Atc.Rest.MinimalApi.Options; global using Atc.Rest.MinimalApi.Tests.Models; @@ -12,8 +15,14 @@ global using FluentAssertions.Execution; global using FluentValidation; global using FluentValidation.TestHelper; +global using Microsoft.AspNetCore.Builder; global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.Http.HttpResults; global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.Mvc.Abstractions; +global using Microsoft.AspNetCore.Mvc.ApiExplorer; global using Microsoft.Extensions.DependencyInjection; -global using MiniValidation; \ No newline at end of file +global using Microsoft.OpenApi; +global using MiniValidation; +global using NSubstitute; +global using Swashbuckle.AspNetCore.SwaggerGen; \ No newline at end of file From 2a2e20d4ea2a65d8d3da1e89821e17aaee52eab7 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 19:56:35 +0100 Subject: [PATCH 42/54] fix(sample): make User response fields nullable to match entity --- .../Users/Models/Responses/UsersResponseRecords.cs | 6 +++--- .../Demo.Domain/Validators/CreateUserRequestValidator.cs | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Responses/UsersResponseRecords.cs b/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Responses/UsersResponseRecords.cs index b657a43..9669485 100644 --- a/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Responses/UsersResponseRecords.cs +++ b/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Responses/UsersResponseRecords.cs @@ -6,7 +6,7 @@ public sealed record User( string FirstName, string LastName, string Email, - string Telephone, - Uri HomePage, + string? Telephone, + Uri? HomePage, Address? HomeAddress, - Address WorkAddress); \ No newline at end of file + Address? WorkAddress); \ No newline at end of file diff --git a/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs b/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs index 1e9975e..9ead18e 100644 --- a/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs +++ b/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs @@ -1,5 +1,8 @@ namespace Demo.Domain.Validators; +/// +/// The main CreateUserRequestValidator Validator. +/// public sealed partial class CreateUserRequestValidator : AbstractValidator { public CreateUserRequestValidator() From f772af305356538f28bd0aab0e2339d909dfb3b8 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 20:02:00 +0100 Subject: [PATCH 43/54] fix(validation): use FirstOrDefault to avoid exception with multiple matches --- src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs b/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs index 50a8ff7..80c3154 100644 --- a/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs +++ b/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs @@ -62,7 +62,7 @@ public ValidationFilter( EndpointFilterInvocationContext context, EndpointFilterDelegate next) { - var argToValidate = context.Arguments.SingleOrDefault(x => x?.GetType() == typeof(T)); + var argToValidate = context.Arguments.FirstOrDefault(x => x?.GetType() == typeof(T)); if (argToValidate is null) { return TypedResults.BadRequest("The request is invalid - Could not find argument to validate from EndpointFilterInvocationContext."); From 007c9ad65e7db21b8c125b8514d567ca246c284f Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 20:13:41 +0100 Subject: [PATCH 44/54] fix(validation): add null check for Activator.CreateInstance result --- .../Filters/Endpoints/ValidationFilter.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs b/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs index 80c3154..1e1cdf1 100644 --- a/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs +++ b/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs @@ -145,7 +145,10 @@ private static async Task ValidateNestedFromBodyProperties( // Create ValidationContext dynamically var contextType = typeof(ValidationContext<>).MakeGenericType(propertyType); - var validationContext = (FluentValidation.IValidationContext)Activator.CreateInstance(contextType, propertyValue)!; + if (Activator.CreateInstance(contextType, propertyValue) is not FluentValidation.IValidationContext validationContext) + { + continue; + } // Use the non-generic IValidator.ValidateAsync directly var validationResult = await validator.ValidateAsync( From d972f14d9d69c695ebf1d07042b7d797870062ea Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 20:18:09 +0100 Subject: [PATCH 45/54] fix(endpoints): handle Activator.CreateInstance exceptions gracefully --- .../EndpointDefinitionExtensions.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs b/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs index 7116c0a..2ffc2c1 100644 --- a/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs +++ b/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs @@ -27,7 +27,7 @@ public static void AddEndpointDefinitions( marker.Assembly.ExportedTypes .Where(x => typeof(IEndpointDefinition).IsAssignableFrom(x) && x is { IsInterface: false, IsAbstract: false }) - .Select(Activator.CreateInstance) + .Select(TryCreateInstance) .Where(x => x is not null) .Cast()); } @@ -35,6 +35,21 @@ public static void AddEndpointDefinitions( services.AddSingleton(endpointDefinitions as IReadOnlyCollection); } + /// + /// Attempts to create an instance of the specified type, returning null if instantiation fails. + /// + private static object? TryCreateInstance(Type type) + { + try + { + return Activator.CreateInstance(type); + } + catch (MissingMethodException) + { + return null; + } + } + /// /// Adds the endpoint definitions to the specified service collection by scanning the assemblies of the provided marker types. /// This method looks for types that implement the and @@ -61,7 +76,7 @@ public static void AddEndpointAndServiceDefinitions( marker.Assembly.ExportedTypes .Where(x => typeof(IEndpointAndServiceDefinition).IsAssignableFrom(x) && x is { IsInterface: false, IsAbstract: false }) - .Select(Activator.CreateInstance) + .Select(TryCreateInstance) .Where(x => x is not null) .Cast()); } From 7be6e3df943d55850c4b106dbd3efe914b698a1b Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 20:23:06 +0100 Subject: [PATCH 46/54] fix(sample): make Address and Country record fields nullable --- .../Contracts/_Shared/Models/CommonRecords.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/sample/src/Demo.Api.Contracts/Contracts/_Shared/Models/CommonRecords.cs b/sample/src/Demo.Api.Contracts/Contracts/_Shared/Models/CommonRecords.cs index 0200e81..ee9a528 100644 --- a/sample/src/Demo.Api.Contracts/Contracts/_Shared/Models/CommonRecords.cs +++ b/sample/src/Demo.Api.Contracts/Contracts/_Shared/Models/CommonRecords.cs @@ -2,13 +2,13 @@ namespace Demo.Api.Contracts.Contracts; public sealed record Address( - [property: StringLength(255)] string StreetName, - string StreetNumber, - string PostalCode, - [property: JsonPropertyName("cityName")] string CityName, - Country Country); + [property: StringLength(255)] string? StreetName, + string? StreetNumber, + string? PostalCode, + [property: JsonPropertyName("cityName")] string? CityName, + Country? Country); public record Country( - string Name, - [property: MinLength(2), MaxLength(2), RegularExpression("^[A-Za-z]{2}$")] string Alpha2Code, - [property: MinLength(3), MaxLength(3), RegularExpression("^[A-Za-z]{3}$")] string Alpha3Code); \ No newline at end of file + string? Name, + [property: MinLength(2), MaxLength(2), RegularExpression("^[A-Za-z]{2}$")] string? Alpha2Code, + [property: MinLength(3), MaxLength(3), RegularExpression("^[A-Za-z]{3}$")] string? Alpha3Code); \ No newline at end of file From 083a689010dbc6ede904dcecdaedfd7f2d1429fb Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 20:36:11 +0100 Subject: [PATCH 47/54] fix(samples): remove contradictory Required attribute from nullable WorkAddress --- .../Contracts/Users/Models/Requests/UsersRequestRecords.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs b/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs index c58fb1a..0292475 100644 --- a/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs +++ b/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs @@ -15,4 +15,4 @@ public sealed record UpdateUserRequest( [property: Required, JsonPropertyName("firstName")] string FirstName, [property: Required] string LastName, [property: Required, EmailAddress] string Email, - [property: Required, JsonPropertyName("address")] Address? WorkAddress); \ No newline at end of file + [property: JsonPropertyName("address")] Address? WorkAddress); \ No newline at end of file From 7279539c3f1c8e4fff0a133638a4b64f0038f66e Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 21:04:32 +0100 Subject: [PATCH 48/54] fix(extensions): add null guards to DictionaryExtensions.MergeErrors --- .../Extensions/Internal/DictionaryExtensions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Atc.Rest.MinimalApi/Extensions/Internal/DictionaryExtensions.cs b/src/Atc.Rest.MinimalApi/Extensions/Internal/DictionaryExtensions.cs index 3c41c75..cc6ebf9 100644 --- a/src/Atc.Rest.MinimalApi/Extensions/Internal/DictionaryExtensions.cs +++ b/src/Atc.Rest.MinimalApi/Extensions/Internal/DictionaryExtensions.cs @@ -15,6 +15,9 @@ internal static IDictionary MergeErrors( this IDictionary errorsA, IDictionary errorsB) { + ArgumentNullException.ThrowIfNull(errorsA); + ArgumentNullException.ThrowIfNull(errorsB); + if (errorsA.Count == 0 && errorsB.Count == 0) { From 6462682017e6c3362129c93a333e4ee45a6bc97a Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 21:06:24 +0100 Subject: [PATCH 49/54] fix(validators): remove NotNull requirement for optional WorkAddress --- .../src/Demo.Domain/Validators/UpdateUserRequestValidator.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs b/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs index b88631e..30e146a 100644 --- a/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs +++ b/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs @@ -25,9 +25,6 @@ public UpdateUserRequestValidator() .NotEqual(x => x.Request.FirstName) .WithMessage("LastName must not be equal to FirstName."); - RuleFor(x => x.Request.WorkAddress) - .NotNull(); - When(x => x.Request.WorkAddress is not null, () => { RuleFor(x => x.Request.WorkAddress!.CityName) From 70f328a55debbafc8186229f5c96f4381b078aec Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 21:20:06 +0100 Subject: [PATCH 50/54] feat(sample): implement partial updates and Gender validation --- .../Models/Requests/UsersRequestRecords.cs | 14 ++-- .../Users/Handlers/UpdateUserByIdHandler.cs | 64 +++++++++++++------ .../Validators/CreateUserRequestValidator.cs | 6 ++ .../Validators/UpdateUserRequestValidator.cs | 34 ++++++---- 4 files changed, 78 insertions(+), 40 deletions(-) diff --git a/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs b/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs index 0292475..fd64394 100644 --- a/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs +++ b/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs @@ -1,18 +1,18 @@ namespace Demo.Api.Contracts.Contracts.Users.Models.Requests; public sealed record CreateUserRequest( - GenderType Gender, + [property: Required] GenderType? Gender, [property: Required, JsonPropertyName("firstName")] string FirstName, - string LastName, - [property: EmailAddress] string Email, + [property: Required] string LastName, + [property: Required, EmailAddress] string Email, string? Telephone, [property: Url] string? HomePage, Address? HomeAddress, Address? WorkAddress); public sealed record UpdateUserRequest( - [property: Required] GenderType Gender, - [property: Required, JsonPropertyName("firstName")] string FirstName, - [property: Required] string LastName, - [property: Required, EmailAddress] string Email, + GenderType? Gender, + [property: JsonPropertyName("firstName")] string? FirstName, + string? LastName, + [property: EmailAddress] string? Email, [property: JsonPropertyName("address")] Address? WorkAddress); \ No newline at end of file diff --git a/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs b/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs index 2a450ac..498a8da 100644 --- a/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs +++ b/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs @@ -14,24 +14,26 @@ public async Task, BadRequest, NotFound, Conflict x.Email.Equals(parameters.Request.Email, StringComparison.OrdinalIgnoreCase) && - x.Id != parameters.UserId, - cancellationToken); - - if (existingUserByEmail is not null) + // Only check email conflict if email is being updated + if (parameters.Request.Email is not null) { - return TypedResults.Conflict($"A user already exists with the email '{parameters.Request.Email}'"); + var existingUserByEmail = await dbContext.Users + .FirstOrDefaultAsync( + x => x.Email.Equals(parameters.Request.Email, StringComparison.OrdinalIgnoreCase) && + x.Id != parameters.UserId, + cancellationToken); + + if (existingUserByEmail is not null) + { + return TypedResults.Conflict($"A user already exists with the email '{parameters.Request.Email}'"); + } } if (!IsModified(parameters.Request, user)) @@ -39,11 +41,31 @@ public async Task, BadRequest, NotFound, Conflict()); } - user.FirstName = parameters.Request.FirstName; - user.LastName = parameters.Request.LastName; - user.Gender = parameters.Request.Gender; - user.Email = parameters.Request.Email; - user.WorkAddress = MapAddress(parameters.Request.WorkAddress); + // Partial update: only update fields that are provided + if (parameters.Request.FirstName is not null) + { + user.FirstName = parameters.Request.FirstName; + } + + if (parameters.Request.LastName is not null) + { + user.LastName = parameters.Request.LastName; + } + + if (parameters.Request.Gender is not null) + { + user.Gender = parameters.Request.Gender.Value; + } + + if (parameters.Request.Email is not null) + { + user.Email = parameters.Request.Email; + } + + if (parameters.Request.WorkAddress is not null) + { + user.WorkAddress = MapAddress(parameters.Request.WorkAddress); + } var saveChangesResult = await dbContext.SaveChangesAsync(cancellationToken); @@ -55,11 +77,11 @@ public async Task, BadRequest, NotFound, Conflict !request.FirstName.Equals(user.FirstName, StringComparison.Ordinal) || - !request.LastName.Equals(user.LastName, StringComparison.Ordinal) || - request.Gender != user.Gender || - !request.Email.Equals(user.Email, StringComparison.Ordinal) || - IsAddressModified(request.WorkAddress, user.WorkAddress); + => (request.FirstName is not null && !request.FirstName.Equals(user.FirstName, StringComparison.Ordinal)) || + (request.LastName is not null && !request.LastName.Equals(user.LastName, StringComparison.Ordinal)) || + (request.Gender is not null && request.Gender != user.Gender) || + (request.Email is not null && !request.Email.Equals(user.Email, StringComparison.Ordinal)) || + (request.WorkAddress is not null && IsAddressModified(request.WorkAddress, user.WorkAddress)); private static bool IsAddressModified( Address? request, diff --git a/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs b/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs index 9ead18e..c35f4d1 100644 --- a/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs +++ b/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs @@ -7,6 +7,12 @@ public sealed partial class CreateUserRequestValidator : AbstractValidator x.Gender) + .NotNull() + .WithMessage("Gender is required.") + .NotEqual(GenderType.None) + .WithMessage("Gender must be specified (not None)."); + RuleFor(x => x.FirstName) .NotNull() .Length(2, 10) diff --git a/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs b/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs index 30e146a..95f83a2 100644 --- a/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs +++ b/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs @@ -11,19 +11,29 @@ public UpdateUserRequestValidator() .NotNull() .WithMessage("Request must not be null."); - RuleFor(x => x.Request.FirstName) - .NotNull() - .Length(2, 10) - .Matches(EnsureFirstCharacterUpperCase()) - .WithMessage(x => $"{nameof(x.Request.FirstName)} has to start with an uppercase letter."); + // Partial update: only validate fields when provided + When(x => x.Request.Gender is not null, () => + { + RuleFor(x => x.Request.Gender) + .NotEqual(GenderType.None) + .WithMessage("Gender must be specified (not None)."); + }); - RuleFor(x => x.Request.LastName) - .NotNull() - .Length(2, 30) - .Matches(EnsureFirstCharacterUpperCase()) - .WithMessage(x => $"{nameof(x.Request.LastName)} has to start with an uppercase letter.") - .NotEqual(x => x.Request.FirstName) - .WithMessage("LastName must not be equal to FirstName."); + When(x => x.Request.FirstName is not null, () => + { + RuleFor(x => x.Request.FirstName) + .Length(2, 10) + .Matches(EnsureFirstCharacterUpperCase()) + .WithMessage(x => $"{nameof(x.Request.FirstName)} has to start with an uppercase letter."); + }); + + When(x => x.Request.LastName is not null, () => + { + RuleFor(x => x.Request.LastName) + .Length(2, 30) + .Matches(EnsureFirstCharacterUpperCase()) + .WithMessage(x => $"{nameof(x.Request.LastName)} has to start with an uppercase letter."); + }); When(x => x.Request.WorkAddress is not null, () => { From 3a459c4c8096841004e12ace853fe0a546602e62 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 21:25:31 +0100 Subject: [PATCH 51/54] fix(sample): return BadRequest instead of throwing in DeleteUserByIdHandler --- .../Users/Interfaces/IDeleteUserByIdHandler.cs | 2 +- .../EndpointDefinitions/UsersEndpointDefinition.cs | 2 +- .../Users/Handlers/DeleteUserByIdHandler.cs | 10 ++++------ 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/sample/src/Demo.Api.Contracts/Contracts/Users/Interfaces/IDeleteUserByIdHandler.cs b/sample/src/Demo.Api.Contracts/Contracts/Users/Interfaces/IDeleteUserByIdHandler.cs index 03ce980..502cf80 100644 --- a/sample/src/Demo.Api.Contracts/Contracts/Users/Interfaces/IDeleteUserByIdHandler.cs +++ b/sample/src/Demo.Api.Contracts/Contracts/Users/Interfaces/IDeleteUserByIdHandler.cs @@ -2,7 +2,7 @@ namespace Demo.Api.Contracts.Contracts.Users.Interfaces; public interface IDeleteUserByIdHandler { - Task> ExecuteAsync( + Task, NotFound>> ExecuteAsync( DeleteUserByIdParameters parameters, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/sample/src/Demo.Api.Contracts/EndpointDefinitions/UsersEndpointDefinition.cs b/sample/src/Demo.Api.Contracts/EndpointDefinitions/UsersEndpointDefinition.cs index 93fd72f..c0bcac4 100644 --- a/sample/src/Demo.Api.Contracts/EndpointDefinitions/UsersEndpointDefinition.cs +++ b/sample/src/Demo.Api.Contracts/EndpointDefinitions/UsersEndpointDefinition.cs @@ -89,7 +89,7 @@ internal Task, BadRequest, NotFound, Conflict>> CancellationToken cancellationToken) => handler.ExecuteAsync(parameters, cancellationToken); - internal Task> DeleteUserById( + internal Task, NotFound>> DeleteUserById( [FromServices] IDeleteUserByIdHandler handler, [AsParameters] DeleteUserByIdParameters parameters, CancellationToken cancellationToken) diff --git a/sample/src/Demo.Domain/Users/Handlers/DeleteUserByIdHandler.cs b/sample/src/Demo.Domain/Users/Handlers/DeleteUserByIdHandler.cs index 8c7d560..c237ea4 100644 --- a/sample/src/Demo.Domain/Users/Handlers/DeleteUserByIdHandler.cs +++ b/sample/src/Demo.Domain/Users/Handlers/DeleteUserByIdHandler.cs @@ -10,7 +10,7 @@ public DeleteUserByIdHandler( this.dbContext = dbContext; } - public async Task> ExecuteAsync( + public async Task, NotFound>> ExecuteAsync( DeleteUserByIdParameters parameters, CancellationToken cancellationToken = default) { @@ -26,11 +26,9 @@ public async Task> ExecuteAsync( dbContext.Users.Remove(user); var saveChangesResult = await dbContext.SaveChangesAsync(cancellationToken); - if (saveChangesResult > 0) - { - return TypedResults.NoContent(); - } - throw new DbUpdateException($"Failed to delete user with id '{parameters.UserId}'"); + return saveChangesResult > 0 + ? TypedResults.NoContent() + : TypedResults.BadRequest($"Could not delete user with id '{parameters.UserId}'."); } } \ No newline at end of file From e2653a1a6c1b8cb4eb3998175b5f615b5f219119 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 21:29:36 +0100 Subject: [PATCH 52/54] fix(validation): handle Activator.CreateInstance exceptions in ValidationFilter --- .../Filters/Endpoints/ValidationFilter.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs b/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs index 1e1cdf1..81af0f3 100644 --- a/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs +++ b/src/Atc.Rest.MinimalApi/Filters/Endpoints/ValidationFilter.cs @@ -145,7 +145,18 @@ private static async Task ValidateNestedFromBodyProperties( // Create ValidationContext dynamically var contextType = typeof(ValidationContext<>).MakeGenericType(propertyType); - if (Activator.CreateInstance(contextType, propertyValue) is not FluentValidation.IValidationContext validationContext) + FluentValidation.IValidationContext? validationContext; + + try + { + validationContext = Activator.CreateInstance(contextType, propertyValue) as FluentValidation.IValidationContext; + } + catch (Exception ex) when (ex is MissingMethodException or TargetInvocationException or TypeInitializationException) + { + continue; + } + + if (validationContext is null) { continue; } From ffc75add885607ccb6e3f5d1e215617f20e8a424 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 22:07:14 +0100 Subject: [PATCH 53/54] fix(sample): critical DI, exception handling, and mapping fixes --- sample/.editorconfig | 2 ++ .../ApplicationBuilderExtensions.cs | 4 +--- .../Extensions/ServiceCollectionExtensions.cs | 20 ++++++++++-------- sample/src/Demo.Domain/MapsterConfig.cs | 21 ++++++++++++++++++- .../Users/Handlers/CreateUserHandler.cs | 15 +++++++++---- .../Users/Handlers/DeleteUserByIdHandler.cs | 15 +++++++++---- .../Users/Handlers/UpdateUserByIdHandler.cs | 19 +++++++++++++---- 7 files changed, 71 insertions(+), 25 deletions(-) diff --git a/sample/.editorconfig b/sample/.editorconfig index 11266e4..79016d2 100644 --- a/sample/.editorconfig +++ b/sample/.editorconfig @@ -511,6 +511,8 @@ dotnet_diagnostic.S1135.severity = suggestion # https://github.com/atc-net ########################################## [*.{cs,csx,cake}] +MA0051.maximum_lines_per_method = 100 + dotnet_diagnostic.CA1031.severity = none # https://learn.microsoft.com/da-dk/dotnet/fundamentals/code-analysis/quality-rules/ca1031 dotnet_diagnostic.CA1515.severity = none # Consider making public types internal https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1515 dotnet_diagnostic.CA1848.severity = none # https://learn.microsoft.com/da-dk/dotnet/fundamentals/code-analysis/quality-rules/ca1848 diff --git a/sample/src/Demo.Domain/Extensions/ApplicationBuilderExtensions.cs b/sample/src/Demo.Domain/Extensions/ApplicationBuilderExtensions.cs index 807e7dc..4c760d0 100644 --- a/sample/src/Demo.Domain/Extensions/ApplicationBuilderExtensions.cs +++ b/sample/src/Demo.Domain/Extensions/ApplicationBuilderExtensions.cs @@ -5,9 +5,7 @@ public static class ApplicationBuilderExtensions public static IApplicationBuilder InitializeDatabase(this IApplicationBuilder app) { using var serviceScope = app.ApplicationServices.CreateScope(); - var serviceProvider = serviceScope.ServiceProvider; - var options = serviceProvider.GetRequiredService>(); - using var context = new DemoDbContext(options); + var context = serviceScope.ServiceProvider.GetRequiredService(); if (context.Users.Any()) { diff --git a/sample/src/Demo.Domain/Extensions/ServiceCollectionExtensions.cs b/sample/src/Demo.Domain/Extensions/ServiceCollectionExtensions.cs index 975ab6c..c33c5b1 100644 --- a/sample/src/Demo.Domain/Extensions/ServiceCollectionExtensions.cs +++ b/sample/src/Demo.Domain/Extensions/ServiceCollectionExtensions.cs @@ -17,20 +17,22 @@ public static IServiceCollection ConfigureDomainServices( public static void DefineHandlersAndServices(this IServiceCollection services) { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void SetupStorage(this IServiceCollection services) { // Add DbContext with In-Memory Database + // Note: Using a fixed database name so data persists across requests + // contextLifetime: Scoped for thread-safety, optionsLifetime: Singleton for test compatibility services.AddDbContext( - options => options.UseInMemoryDatabase( - Guid.NewGuid().ToString()), - ServiceLifetime.Singleton); + options => options.UseInMemoryDatabase("DemoDatabase"), + contextLifetime: ServiceLifetime.Scoped, + optionsLifetime: ServiceLifetime.Singleton); } } \ No newline at end of file diff --git a/sample/src/Demo.Domain/MapsterConfig.cs b/sample/src/Demo.Domain/MapsterConfig.cs index e7fc062..b25728e 100644 --- a/sample/src/Demo.Domain/MapsterConfig.cs +++ b/sample/src/Demo.Domain/MapsterConfig.cs @@ -4,10 +4,29 @@ public static class MapsterConfig { public static void Register() { + // Map Country -> CountryEntity + TypeAdapterConfig + .NewConfig(); + + // Map Address -> AddressEntity + TypeAdapterConfig + .NewConfig(); + + // Map CreateUserRequest -> UserEntity with safe Uri parsing TypeAdapterConfig .NewConfig() - .Map(dest => dest.HomePage, src => src.HomePage != null ? new Uri(src.HomePage) : null); + .Map(dest => dest.HomePage, src => TryCreateUri(src.HomePage)); TypeAdapterConfig.GlobalSettings.Compile(); } + + private static Uri? TryCreateUri(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return null; + } + + return Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null; + } } \ No newline at end of file diff --git a/sample/src/Demo.Domain/Users/Handlers/CreateUserHandler.cs b/sample/src/Demo.Domain/Users/Handlers/CreateUserHandler.cs index 419836c..740f39b 100644 --- a/sample/src/Demo.Domain/Users/Handlers/CreateUserHandler.cs +++ b/sample/src/Demo.Domain/Users/Handlers/CreateUserHandler.cs @@ -22,10 +22,17 @@ public async Task, Conflict>> dbContext.Users.Add(userEntity); - var saveChangesResult = await dbContext.SaveChangesAsync(cancellationToken); + try + { + var saveChangesResult = await dbContext.SaveChangesAsync(cancellationToken); - return saveChangesResult > 0 - ? TypedResults.CreatedAtRoute(Api.Contracts.Names.UserDefinitionNames.GetUserById, new { userId }) - : TypedResults.BadRequest("Could not create user."); + return saveChangesResult > 0 + ? TypedResults.CreatedAtRoute(Api.Contracts.Names.UserDefinitionNames.GetUserById, new { userId }) + : TypedResults.BadRequest("Could not create user."); + } + catch (DbUpdateException) + { + return TypedResults.BadRequest("Could not create user due to a database error."); + } } } \ No newline at end of file diff --git a/sample/src/Demo.Domain/Users/Handlers/DeleteUserByIdHandler.cs b/sample/src/Demo.Domain/Users/Handlers/DeleteUserByIdHandler.cs index c237ea4..654b3f2 100644 --- a/sample/src/Demo.Domain/Users/Handlers/DeleteUserByIdHandler.cs +++ b/sample/src/Demo.Domain/Users/Handlers/DeleteUserByIdHandler.cs @@ -25,10 +25,17 @@ public async Task, NotFound>> ExecuteAsync dbContext.Users.Remove(user); - var saveChangesResult = await dbContext.SaveChangesAsync(cancellationToken); + try + { + var saveChangesResult = await dbContext.SaveChangesAsync(cancellationToken); - return saveChangesResult > 0 - ? TypedResults.NoContent() - : TypedResults.BadRequest($"Could not delete user with id '{parameters.UserId}'."); + return saveChangesResult > 0 + ? TypedResults.NoContent() + : TypedResults.BadRequest($"Could not delete user with id '{parameters.UserId}'."); + } + catch (DbUpdateException) + { + return TypedResults.BadRequest($"Could not delete user with id '{parameters.UserId}' due to a database error."); + } } } \ No newline at end of file diff --git a/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs b/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs index 498a8da..419bfbf 100644 --- a/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs +++ b/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs @@ -67,11 +67,22 @@ public async Task, BadRequest, NotFound, Conflict 0 - ? TypedResults.Ok(user.Adapt()) - : TypedResults.BadRequest("Could not update user."); + return saveChangesResult > 0 + ? TypedResults.Ok(user.Adapt()) + : TypedResults.BadRequest("Could not update user."); + } + catch (DbUpdateConcurrencyException) + { + return TypedResults.Conflict("The user was modified by another request. Please retry."); + } + catch (DbUpdateException) + { + return TypedResults.BadRequest("Could not update user due to a database error."); + } } private static bool IsModified( From 9984040de61c5d47b088aa67871d593b2b8be30b Mon Sep 17 00:00:00 2001 From: Per Kops Date: Fri, 5 Dec 2025 22:16:56 +0100 Subject: [PATCH 54/54] chore: upgrade to DotNet10 in atc-coding-rules-updater.json --- atc-coding-rules-updater.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/atc-coding-rules-updater.json b/atc-coding-rules-updater.json index 6e57f36..75f37c6 100644 --- a/atc-coding-rules-updater.json +++ b/atc-coding-rules-updater.json @@ -1,5 +1,5 @@ { - "projectTarget": "DotNet9", + "projectTarget": "DotNet10", "useLatestMinorNugetVersion": true, "useTemporarySuppressions": false, "temporarySuppressionAsExcel": false,