diff --git a/.editorconfig b/.editorconfig index 2e75fd2..394fede 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: DotNet10 # Inspired by: https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code-style-rule-options ########################################## @@ -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 @@ -490,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 @@ -503,8 +507,20 @@ 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 +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 +535,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,9 +558,11 @@ 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" +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/.github/workflows/post-integration.yml b/.github/workflows/post-integration.yml index 0a0b1bb..1ff459b 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 - uses: actions/setup-dotnet@v4 + - name: โš™๏ธ Setup dotnet 10.0.x + uses: actions/setup-dotnet@v5 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 @@ -51,20 +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" - - - 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 }}" + 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 2536549..077223c 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 - uses: actions/setup-dotnet@v4 + - name: โš™๏ธ Setup dotnet 10.0.x + uses: actions/setup-dotnet@v5 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 - uses: actions/setup-dotnet@v4 + - name: โš™๏ธ Setup dotnet 10.0.x + uses: actions/setup-dotnet@v5 with: - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' - name: ๐Ÿ” Restore packages run: dotnet restore @@ -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 1caaa60..d1a443f 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 - uses: actions/setup-dotnet@v4 + - name: โš™๏ธ Setup dotnet 10.0.x + uses: actions/setup-dotnet@v5 with: - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' - name: ๐Ÿงน Clean run: dotnet clean -c Release && dotnet nuget locals all --clear 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..257b263 --- /dev/null +++ b/Atc.Rest.MinimalApi.slnx @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props index a0b1760..60d6ad8 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 @@ -45,12 +45,13 @@ + - + - + \ No newline at end of file diff --git a/README.md b/README.md index 0fa0413..3446d7d 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 10, you have two options for validation: + +### Option 1: Atc.Rest.MinimalApi ValidationFilter (Recommended) โญ + +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** - 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 +- **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,118 @@ 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 +app.UseSwagger(options => +{ + options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; +}); + +// 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 @@ -255,7 +449,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, @@ -276,17 +470,17 @@ 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 -* [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) +* [.NET 10 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) -# How to contribute +# ๐Ÿค How to contribute [Contribution Guidelines](https://atc-net.github.io/introduction/about-atc#how-to-contribute) diff --git a/atc-coding-rules-updater.json b/atc-coding-rules-updater.json index 65e3cb3..75f37c6 100644 --- a/atc-coding-rules-updater.json +++ b/atc-coding-rules-updater.json @@ -1,5 +1,5 @@ { - "projectTarget": "DotNet8", + "projectTarget": "DotNet10", "useLatestMinorNugetVersion": true, "useTemporarySuppressions": false, "temporarySuppressionAsExcel": false, 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/sample/.editorconfig b/sample/.editorconfig index a5eb5c3..79016d2 100644 --- a/sample/.editorconfig +++ b/sample/.editorconfig @@ -511,13 +511,23 @@ 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 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.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/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..10042ab 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 @@ -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/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/Contracts/Users/Models/Requests/UsersRequestRecords.cs b/sample/src/Demo.Api.Contracts/Contracts/Users/Models/Requests/UsersRequestRecords.cs index d49972f..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, - string FirstName, - string LastName, - [property: EmailAddress] string Email, - string Telephone, - [property: Uri] string HomePage, - Address? HomeAddress, - Address WorkAddress); - -public sealed record UpdateUserRequest( - [property: Required] GenderType Gender, + [property: Required] GenderType? Gender, [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 + string? Telephone, + [property: Url] string? HomePage, + Address? HomeAddress, + Address? WorkAddress); + +public sealed record UpdateUserRequest( + 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.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.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 diff --git a/sample/src/Demo.Api.Contracts/Demo.Api.Contracts.csproj b/sample/src/Demo.Api.Contracts/Demo.Api.Contracts.csproj index f6c7737..d63ca62 100644 --- a/sample/src/Demo.Api.Contracts/Demo.Api.Contracts.csproj +++ b/sample/src/Demo.Api.Contracts/Demo.Api.Contracts.csproj @@ -1,21 +1,14 @@ - +๏ปฟ - net8.0 false - - - - - - - - - + + + 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.Api/Demo.Api.csproj b/sample/src/Demo.Api/Demo.Api.csproj index 28717d3..eafb5ce 100644 --- a/sample/src/Demo.Api/Demo.Api.csproj +++ b/sample/src/Demo.Api/Demo.Api.csproj @@ -1,14 +1,20 @@ - +๏ปฟ - net8.0 false + + + + + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated - + + - + + 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..86779d8 100644 --- a/sample/src/Demo.Api/Extensions/WebApplicationExtensions.cs +++ b/sample/src/Demo.Api/Extensions/WebApplicationExtensions.cs @@ -14,15 +14,18 @@ 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( this WebApplication app, string applicationName) { - app.UseSwagger(); + app.UseSwagger(options => + { + options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; + }); + app.UseSwaggerUI(options => { options.EnableTryItOutByDefault(); @@ -41,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 a3c6c9f..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; @@ -8,19 +9,16 @@ 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; 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/Options/ConfigureSwaggerOptions.cs b/sample/src/Demo.Api/Options/ConfigureSwaggerOptions.cs index da6e0f0..c8f931b 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,14 +34,13 @@ 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 { 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.Api/Program.cs b/sample/src/Demo.Api/Program.cs index 3bfab59..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); @@ -38,8 +40,12 @@ SkipFirstLevelOnValidationKeys = true, }); +services.AddHealthChecks(); + var app = builder.Build(); +app.MapHealthChecks("/health"); + app.UseEndpointDefinitions(); app.AddGlobalErrorHandler(); @@ -53,6 +59,7 @@ if (app.Environment.IsDevelopment()) { app.ConfigureSwaggerUI(builder.Environment.ApplicationName); + app.ConfigureScalarUI(); } app.InitializeDatabase(); @@ -62,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.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.AppHost/Demo.AppHost.csproj b/sample/src/Demo.AppHost/Demo.AppHost.csproj new file mode 100644 index 0000000..84ac403 --- /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..0197745 --- /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 endpoint = context.GetEndpoint("https"); + + context.Urls.Add(new ResourceUrlAnnotation + { + Url = $"{endpoint!.Url}/scalar/v1", + DisplayText = "Scalar", + Endpoint = endpoint, + }); + + context.Urls.Add(new ResourceUrlAnnotation + { + Url = $"{endpoint.Url}/swagger", + DisplayText = "Swagger", + Endpoint = endpoint, + }); + }); + +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.Domain/Demo.Domain.csproj b/sample/src/Demo.Domain/Demo.Domain.csproj index 9ea16ac..1702fa1 100644 --- a/sample/src/Demo.Domain/Demo.Domain.csproj +++ b/sample/src/Demo.Domain/Demo.Domain.csproj @@ -1,27 +1,25 @@ - +๏ปฟ - net8.0 false - - - - - - + + + + - - - + + + + diff --git a/sample/src/Demo.Domain/Extensions/ApplicationBuilderExtensions.cs b/sample/src/Demo.Domain/Extensions/ApplicationBuilderExtensions.cs index 714d66a..4c760d0 100644 --- a/sample/src/Demo.Domain/Extensions/ApplicationBuilderExtensions.cs +++ b/sample/src/Demo.Domain/Extensions/ApplicationBuilderExtensions.cs @@ -2,13 +2,10 @@ 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; - 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 bb9f4d7..c33c5b1 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(); @@ -17,33 +15,24 @@ public static IServiceCollection ConfigureDomainServices( return services; } - public static void DefineHandlersAndServices( - this IServiceCollection services) - { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - } - - private static void ConfigureOptions( - this IServiceCollection services, - IConfiguration configuration) + public static void DefineHandlersAndServices(this IServiceCollection services) { - 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)); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } - private static void SetupStorage( - this IServiceCollection services) + 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/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; diff --git a/sample/src/Demo.Domain/MapsterConfig.cs b/sample/src/Demo.Domain/MapsterConfig.cs index c3a14c7..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 => new Uri(src.HomePage)); + .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/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/CreateUserHandler.cs b/sample/src/Demo.Domain/Users/Handlers/CreateUserHandler.cs index b412c83..740f39b 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) @@ -30,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 8c7d560..654b3f2 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) { @@ -25,12 +25,17 @@ public async Task> ExecuteAsync( dbContext.Users.Remove(user); - var saveChangesResult = await dbContext.SaveChangesAsync(cancellationToken); - if (saveChangesResult > 0) + try { - return TypedResults.NoContent(); - } + var saveChangesResult = await dbContext.SaveChangesAsync(cancellationToken); - 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}'."); + } + 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 237bda3..419bfbf 100644 --- a/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs +++ b/sample/src/Demo.Domain/Users/Handlers/UpdateUserByIdHandler.cs @@ -14,23 +14,26 @@ public async Task, BadRequest, NotFound, Conflict x.Email.Equals(parameters.Request.Email, StringComparison.OrdinalIgnoreCase), - 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)) @@ -38,23 +41,128 @@ 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; + // 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; + } - var saveChangesResult = await dbContext.SaveChangesAsync(cancellationToken); + if (parameters.Request.Email is not null) + { + user.Email = parameters.Request.Email; + } - return saveChangesResult > 0 - ? TypedResults.Ok(user.Adapt()) - : TypedResults.BadRequest("Could not update user."); + if (parameters.Request.WorkAddress is not null) + { + user.WorkAddress = MapAddress(parameters.Request.WorkAddress); + } + + try + { + var saveChangesResult = await dbContext.SaveChangesAsync(cancellationToken); + + 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( UpdateUserRequest request, UserEntity user) - => !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.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, + 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 241dbf4..c35f4d1 100644 --- a/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs +++ b/sample/src/Demo.Domain/Validators/CreateUserRequestValidator.cs @@ -1,31 +1,44 @@ namespace Demo.Domain.Validators; -public sealed class CreateUserRequestValidator : AbstractValidator +/// +/// The main CreateUserRequestValidator Validator. +/// +public sealed partial class CreateUserRequestValidator : AbstractValidator { public CreateUserRequestValidator() { + RuleFor(x => 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) - .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(); - - 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."); + }); } + + [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 3ba26dc..95f83a2 100644 --- a/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs +++ b/sample/src/Demo.Domain/Validators/UpdateUserRequestValidator.cs @@ -11,27 +11,40 @@ 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, 10) - .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."); + }); - RuleFor(x => x.Request.WorkAddress) - .NotNull(); + 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."); + }); - 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)] 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..6ea4355 --- /dev/null +++ b/sample/src/Demo.Web/Demo.Web.csproj @@ -0,0 +1,19 @@ + + + + false + + + + + + + + + + + + + + + diff --git a/sample/src/Demo.Web/GlobalUsings.cs b/sample/src/Demo.Web/GlobalUsings.cs new file mode 100644 index 0000000..97fef47 --- /dev/null +++ b/sample/src/Demo.Web/GlobalUsings.cs @@ -0,0 +1,10 @@ +global using System.Net; +global using System.Text.Json; +global using System.Text.Json.Serialization; +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/Address.cs b/sample/src/Demo.Web/Models/Address.cs new file mode 100644 index 0000000..ccc3560 --- /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); \ No newline at end of file diff --git a/sample/src/Demo.Web/Models/ApiValidationProblemDetails.cs b/sample/src/Demo.Web/Models/ApiValidationProblemDetails.cs new file mode 100644 index 0000000..a50ef36 --- /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; } +} \ No newline at end of file diff --git a/sample/src/Demo.Web/Models/Country.cs b/sample/src/Demo.Web/Models/Country.cs new file mode 100644 index 0000000..1caaf51 --- /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); \ No newline at end of file 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..48f0d52 --- /dev/null +++ b/sample/src/Demo.Web/Models/GenderType.cs @@ -0,0 +1,9 @@ +namespace Demo.Web.Models; + +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..dd6ed95 --- /dev/null +++ b/sample/src/Demo.Web/Program.cs @@ -0,0 +1,65 @@ +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(Atc.Serialization.JsonSerializerOptionsFactory.Create()); + +// 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(); + }); + +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(); + +await app.RunAsync(); \ No newline at end of file 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..d55c578 --- /dev/null +++ b/sample/src/Demo.Web/Services/UserService.cs @@ -0,0 +1,173 @@ +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; + } + + public async Task> GetAllUsersAsync(CancellationToken cancellationToken = default) + { + try + { + var users = await httpClient.GetFromJsonAsync>( + "/api/users", + jsonSerializerOptions, + 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}", + jsonSerializerOptions, + 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, + jsonSerializerOptions, + cancellationToken); + + if (response.IsSuccessStatusCode) + { + return (true, null); + } + + if (response.StatusCode == HttpStatusCode.BadRequest) + { + var problemDetails = await response.Content.ReadFromJsonAsync( + jsonSerializerOptions, + 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, + jsonSerializerOptions, + cancellationToken); + + if (response.IsSuccessStatusCode) + { + var user = await response.Content.ReadFromJsonAsync(jsonSerializerOptions, cancellationToken); + return (true, user, null); + } + + if (response.StatusCode == HttpStatusCode.BadRequest) + { + var problemDetails = await response.Content.ReadFromJsonAsync( + jsonSerializerOptions, + 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; + } + } +} \ No newline at end of file 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": "*" +} 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..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 @@ -1,24 +1,11 @@ - 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..5f1ae56 100644 --- a/sample/test/Demo.Api.IntegrationTests/Demo.Api.IntegrationTests.csproj +++ b/sample/test/Demo.Api.IntegrationTests/Demo.Api.IntegrationTests.csproj @@ -1,25 +1,11 @@ - 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/Endpoints/UsersEndpointDefinitionTests.cs b/sample/test/Demo.Api.IntegrationTests/Endpoints/UsersEndpointDefinitionTests.cs index f0c6090..9f7d282 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() @@ -18,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 a5dbb7d..2b96447 100644 --- a/sample/test/Demo.Domain.Tests/Demo.Domain.Tests.csproj +++ b/sample/test/Demo.Domain.Tests/Demo.Domain.Tests.csproj @@ -1,24 +1,11 @@ - 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/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 9cded84..2950137 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: DotNet10 # Inspired by: https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code-style-rule-options ########################################## 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/Atc.Rest.MinimalApi.csproj b/src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj index d81738f..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 + 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,11 @@ - - - - + + + + + diff --git a/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs b/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs index 6e73af1..2ffc2c1 100644 --- a/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs +++ b/src/Atc.Rest.MinimalApi/Extensions/EndpointDefinitionExtensions.cs @@ -27,12 +27,29 @@ public static void AddEndpointDefinitions( marker.Assembly.ExportedTypes .Where(x => typeof(IEndpointDefinition).IsAssignableFrom(x) && x is { IsInterface: false, IsAbstract: false }) - .Select(Activator.CreateInstance).Cast()); + .Select(TryCreateInstance) + .Where(x => x is not null) + .Cast()); } 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 @@ -59,7 +76,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(TryCreateInstance) + .Where(x => x is not null) + .Cast()); } foreach (var endpointDefinition in endpointDefinitions) @@ -75,8 +94,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/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) { diff --git a/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs b/src/Atc.Rest.MinimalApi/Extensions/ValidationProblemExtensions.cs index 776e967..20d04b0 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,21 +87,30 @@ 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; } - subType = propertyInfo.PropertyType.IsGenericType + subType = propertyInfo.PropertyType.IsGenericType && + propertyInfo.PropertyType.GenericTypeArguments.Length > 0 ? propertyInfo.PropertyType.GenericTypeArguments[0] : propertyInfo.PropertyType; @@ -109,9 +118,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 +136,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,19 +179,38 @@ 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); } } [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..81af0f3 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 @@ -19,9 +50,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. @@ -33,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."); @@ -65,23 +94,138 @@ 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); + 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; + } + + // 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/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..82fcf10 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,7 +30,15 @@ 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) + { + return; + } + foreach (var item in swaggerDoc.Components.Schemas.Where(x => x.Value?.Enum?.Count > 0)) { var propertyEnums = item.Value.Enum; @@ -49,7 +64,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 +76,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 +102,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); } @@ -88,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. /// @@ -95,32 +213,29 @@ 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) && + Enum.TryParse(enumType, stringValue, ignoreCase: false, out var enumValue)) { - var enumInt = (int)Enum.Parse(enumType, stringItem.Value); - enumDescriptions.Add($"{enumInt} = {stringItem.Value}"); - break; + var enumInt = (int)enumValue; + enumDescriptions.Add($"{enumInt} = {stringValue}"); } } } @@ -129,20 +244,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. - 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() - switch + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "GetTypes() can throw various exceptions for dynamic assemblies")] + private static Type? GetEnumTypeByName(string enumTypeName) + { + // Primary: Use schema-to-type mapping (most accurate) + if (schemaIdToType is not null && schemaIdToType.TryGetValue(enumTypeName, out var mappedType)) { - { Length: 1 } a => a[0], - _ => null, - }; + return mappedType; + } + + // Fallback: AppDomain scan for edge cases (e.g., enums not in API descriptions) + var enumTypes = AppDomain.CurrentDomain + .GetAssemblies() + .SelectMany(assembly => + { + 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 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 diff --git a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs index 2f3fe7f..4d58003 100644 --- a/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs +++ b/src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs @@ -28,13 +28,16 @@ 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 { await next(context); } + catch (OperationCanceledException) + { + // Client disconnected or request was canceled - no error response needed + } catch (Exception ex) { await HandleExceptionAsync(context, ex); @@ -51,6 +54,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; @@ -69,31 +78,18 @@ 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, + ArgumentException => HttpStatusCode.BadRequest, + UnauthorizedAccessException => HttpStatusCode.Unauthorized, + InvalidOperationException => HttpStatusCode.Conflict, + NotImplementedException => HttpStatusCode.NotImplemented, + TimeoutException => HttpStatusCode.GatewayTimeout, + _ => HttpStatusCode.InternalServerError, + }; /// /// Creates a problem details object to include in the error response. @@ -111,14 +107,11 @@ 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), + Instance = context.Request.Path, + }; SetExtensionFields(result, context); @@ -131,52 +124,31 @@ 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(); - - sb.AppendLine("{"); - sb.Append(2, "status: "); - sb.AppendLine(((int)statusCode).ToString(GlobalizationConstants.EnglishCultureInfo)); - sb.Append(2, "title: "); - sb.AppendLine(statusCode.ToNormalizedString()); - - if (exception is not null) + var message = new Dictionary(StringComparer.Ordinal) { - sb.Append(2, "detail: "); - sb.AppendLine(UseSimpleMessage(exception) - ? exception.GetMessage() - : exception.GetMessage(includeInnerMessage: true, includeExceptionName: true)); - } - - var correlationId = context.GetCorrelationId(); - if (!string.IsNullOrEmpty(correlationId)) - { - sb.Append(2, "correlationId: "); - sb.AppendLine(correlationId); - } + ["status"] = (int)statusCode, + ["title"] = statusCode.ToNormalizedString(), + ["instance"] = context.Request.Path.ToString(), + }; - var requestId = context.GetRequestId(); - if (!string.IsNullOrEmpty(requestId)) - { - sb.Append(2, "requestId: "); - sb.AppendLine(requestId); - } + var detail = UseSimpleMessage(exception) + ? exception.GetMessage() + : exception.GetMessage(includeInnerMessage: true, includeExceptionName: true); - var traceId = context.TraceIdentifier; - if (!string.IsNullOrEmpty(traceId)) + if (!string.IsNullOrEmpty(detail)) { - sb.Append(2, "traceId: "); - sb.AppendLine(traceId); + message["detail"] = detail; } - sb.Append('}'); + AddExtensionFields(message, context); - return sb.ToString(); + return JsonSerializer.Serialize(message); } /// @@ -187,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; } } @@ -215,17 +196,7 @@ 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); - } + private bool UseSimpleMessage(Exception exception) + => !options.IncludeException || + exception is BadHttpRequestException or UnauthorizedAccessException or NotImplementedException; } \ No newline at end of file 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 diff --git a/test/.editorconfig b/test/.editorconfig index a2bd082..f8bdd4e 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: DotNet10 # 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 @@ -54,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/Atc.Rest.MinimalApi.Tests.csproj b/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj index 2dc0605..b09c6a7 100644 --- a/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj +++ b/test/Atc.Rest.MinimalApi.Tests/Atc.Rest.MinimalApi.Tests.csproj @@ -1,29 +1,17 @@ - net8.0 false true 1591;9057 - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + - + 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/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/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 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 972ea28..af333ff 100644 --- a/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs +++ b/test/Atc.Rest.MinimalApi.Tests/GlobalUsings.cs @@ -1,12 +1,28 @@ 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; global using Atc.Rest.MinimalApi.Tests.Validators; 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 MiniValidation; \ No newline at end of file +global using Microsoft.AspNetCore.Mvc.Abstractions; +global using Microsoft.AspNetCore.Mvc.ApiExplorer; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.OpenApi; +global using MiniValidation; +global using NSubstitute; +global using Swashbuckle.AspNetCore.SwaggerGen; \ No newline at end of file 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 diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 3e568d2..2e68270 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 + - + 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