diff --git a/.github/workflows/locker.yml b/.github/workflows/locker.yml index 052ca7f6fdc1..592b01424740 100644 --- a/.github/workflows/locker.yml +++ b/.github/workflows/locker.yml @@ -20,13 +20,14 @@ permissions: jobs: main: runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'dotnet' }} steps: - name: Checkout Actions uses: actions/checkout@v4 with: repository: "microsoft/vscode-github-triage-actions" path: ./actions - ref: 0e46b66330f7fbece9aacc65c31c38f434041050 # Pin to commit: https://github.com/microsoft/vscode-github-triage-actions/commit/0e46b66330f7fbece9aacc65c31c38f434041050 + ref: 066bee9cefa6f0b4bf306040ff36fc7d96a6d56d # Pin to commit: https://github.com/microsoft/vscode-github-triage-actions/commit/066bee9cefa6f0b4bf306040ff36fc7d96a6d56d - name: Install Actions run: npm install --production --prefix ./actions - name: Run Locker diff --git a/AspNetCore.sln b/AspNetCore.sln index 68269bb213ca..296a8be79925 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1788,6 +1788,18 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NotReferencedInWasmCodePack EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Components.WasmRemoteAuthentication", "src\Components\test\testassets\Components.WasmRemoteAuthentication\Components.WasmRemoteAuthentication.csproj", "{8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample", "src\OpenApi\sample\Sample.csproj", "{6DEC24A8-A166-432F-8E3B-58FFCDA92F52}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hybrid", "Hybrid", "{2D64CA23-6E81-488E-A7D3-9BDF87240098}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Caching.Hybrid", "src\Caching\Hybrid\src\Microsoft.Extensions.Caching.Hybrid.csproj", "{2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Caching.Hybrid.Tests", "src\Caching\Hybrid\test\Microsoft.Extensions.Caching.Hybrid.Tests.csproj", "{CF63C942-895A-4F6B-888A-7653D7C4991A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "perf", "perf", "{9DC6B242-457B-4767-A84B-C3D23B76C642}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OpenApi.Microbenchmarks", "src\OpenApi\perf\Microbenchmarks\Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj", "{D53F0EF7-0CDC-49B4-AA2D-229901B0A734}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -10789,6 +10801,70 @@ Global {8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}.Release|x64.Build.0 = Release|Any CPU {8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}.Release|x86.ActiveCfg = Release|Any CPU {8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}.Release|x86.Build.0 = Release|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|arm64.ActiveCfg = Debug|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|arm64.Build.0 = Debug|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|x64.ActiveCfg = Debug|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|x64.Build.0 = Debug|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|x86.ActiveCfg = Debug|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Debug|x86.Build.0 = Debug|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|Any CPU.Build.0 = Release|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|arm64.ActiveCfg = Release|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|arm64.Build.0 = Release|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|x64.ActiveCfg = Release|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|x64.Build.0 = Release|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|x86.ActiveCfg = Release|Any CPU + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52}.Release|x86.Build.0 = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|arm64.ActiveCfg = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|arm64.Build.0 = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|x64.Build.0 = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|x86.Build.0 = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|Any CPU.Build.0 = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|arm64.ActiveCfg = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|arm64.Build.0 = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|x64.ActiveCfg = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|x64.Build.0 = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|x86.ActiveCfg = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|x86.Build.0 = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|arm64.ActiveCfg = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|arm64.Build.0 = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|x64.ActiveCfg = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|x64.Build.0 = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|x86.ActiveCfg = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|x86.Build.0 = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|Any CPU.Build.0 = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|arm64.ActiveCfg = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|arm64.Build.0 = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x64.ActiveCfg = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x64.Build.0 = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x86.ActiveCfg = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x86.Build.0 = Release|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|arm64.ActiveCfg = Debug|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|arm64.Build.0 = Debug|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|x64.ActiveCfg = Debug|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|x64.Build.0 = Debug|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|x86.ActiveCfg = Debug|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Debug|x86.Build.0 = Debug|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|Any CPU.Build.0 = Release|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|arm64.ActiveCfg = Release|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|arm64.Build.0 = Release|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|x64.ActiveCfg = Release|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|x64.Build.0 = Release|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|x86.ActiveCfg = Release|Any CPU + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -11672,6 +11748,12 @@ Global {15D08EA7-8C63-45FB-8B4D-C5F8E43B433E} = {05A169C7-4F20-4516-B10A-B13C5649D346} {433F91E4-E39D-4EB0-B798-2998B3969A2C} = {6126DCE4-9692-4EE2-B240-C65743572995} {8A021D6D-7935-4AB3-BB47-38D4FF9B0D13} = {6126DCE4-9692-4EE2-B240-C65743572995} + {6DEC24A8-A166-432F-8E3B-58FFCDA92F52} = {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} + {2D64CA23-6E81-488E-A7D3-9BDF87240098} = {0F39820F-F4A5-41C6-9809-D79B68F032EF} + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9} = {2D64CA23-6E81-488E-A7D3-9BDF87240098} + {CF63C942-895A-4F6B-888A-7653D7C4991A} = {2D64CA23-6E81-488E-A7D3-9BDF87240098} + {9DC6B242-457B-4767-A84B-C3D23B76C642} = {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} + {D53F0EF7-0CDC-49B4-AA2D-229901B0A734} = {9DC6B242-457B-4767-A84B-C3D23B76C642} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/eng/Dependencies.props b/eng/Dependencies.props index a987f75730aa..2539beee1c9b 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -69,6 +69,7 @@ and are generated based on the last package release. + diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index 7686ce1e869c..caac54022a4d 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -5,6 +5,7 @@ --> + diff --git a/eng/ShippingAssemblies.props b/eng/ShippingAssemblies.props index bd06923b2454..d0cae638afbb 100644 --- a/eng/ShippingAssemblies.props +++ b/eng/ShippingAssemblies.props @@ -104,6 +104,7 @@ + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 82702c0cf10e..3f23e51b3708 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -9,321 +9,321 @@ --> - + https://github.com/dotnet/efcore - 45448efb1da8914489739bc4116f7a8f6c9374a2 + ef5f7a3d7208a5d6f3d82ceeb44da26b19446c83 - + https://github.com/dotnet/efcore - 45448efb1da8914489739bc4116f7a8f6c9374a2 + ef5f7a3d7208a5d6f3d82ceeb44da26b19446c83 - + https://github.com/dotnet/efcore - 45448efb1da8914489739bc4116f7a8f6c9374a2 + ef5f7a3d7208a5d6f3d82ceeb44da26b19446c83 - + https://github.com/dotnet/efcore - 45448efb1da8914489739bc4116f7a8f6c9374a2 + ef5f7a3d7208a5d6f3d82ceeb44da26b19446c83 - + https://github.com/dotnet/efcore - 45448efb1da8914489739bc4116f7a8f6c9374a2 + ef5f7a3d7208a5d6f3d82ceeb44da26b19446c83 - + https://github.com/dotnet/efcore - 45448efb1da8914489739bc4116f7a8f6c9374a2 + ef5f7a3d7208a5d6f3d82ceeb44da26b19446c83 - + https://github.com/dotnet/efcore - 45448efb1da8914489739bc4116f7a8f6c9374a2 + ef5f7a3d7208a5d6f3d82ceeb44da26b19446c83 - + https://github.com/dotnet/efcore - 45448efb1da8914489739bc4116f7a8f6c9374a2 + ef5f7a3d7208a5d6f3d82ceeb44da26b19446c83 - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae https://github.com/dotnet/xdt @@ -363,9 +363,9 @@ 1aa759af23d2a29043ea44fcef5bd6823dafa5d0 - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae @@ -376,9 +376,9 @@ - + https://github.com/dotnet/runtime - 9b57a265c7efd3732b035bade005561a04767128 + a12a726969c7da5c3e2b5cd72386bef142cdfdae https://github.com/dotnet/winforms diff --git a/eng/Versions.props b/eng/Versions.props index 6a93f118294f..02bb406d4207 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -62,91 +62,91 @@ --> - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 - 9.0.0-preview.4.24206.3 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 + 9.0.0-preview.4.24217.19 9.0.0-preview.4.24214.1 9.0.0-preview.4.24214.1 - 9.0.0-preview.4.24205.3 - 9.0.0-preview.4.24205.3 - 9.0.0-preview.4.24205.3 - 9.0.0-preview.4.24205.3 - 9.0.0-preview.4.24205.3 - 9.0.0-preview.4.24205.3 - 9.0.0-preview.4.24205.3 - 9.0.0-preview.4.24205.3 + 9.0.0-preview.4.24217.1 + 9.0.0-preview.4.24217.1 + 9.0.0-preview.4.24217.1 + 9.0.0-preview.4.24217.1 + 9.0.0-preview.4.24217.1 + 9.0.0-preview.4.24217.1 + 9.0.0-preview.4.24217.1 + 9.0.0-preview.4.24217.1 4.8.0-3.23518.7 4.8.0-3.23518.7 @@ -335,6 +335,7 @@ 4.0.5 6.0.0-preview.3.21167.1 1.6.13 + 1.6.13 6.0.322601 1.10.93 diff --git a/src/Caching/Caching.slnf b/src/Caching/Caching.slnf index dcecdb8a91c7..63610b8e28d5 100644 --- a/src/Caching/Caching.slnf +++ b/src/Caching/Caching.slnf @@ -2,6 +2,8 @@ "solution": { "path": "..\\..\\AspNetCore.sln", "projects": [ + "src\\Caching\\Hybrid\\src\\Microsoft.Extensions.Caching.Hybrid.csproj", + "src\\Caching\\Hybrid\\test\\Microsoft.Extensions.Caching.Hybrid.Tests.csproj", "src\\Caching\\SqlServer\\src\\Microsoft.Extensions.Caching.SqlServer.csproj", "src\\Caching\\SqlServer\\test\\Microsoft.Extensions.Caching.SqlServer.Tests.csproj", "src\\Caching\\StackExchangeRedis\\src\\Microsoft.Extensions.Caching.StackExchangeRedis.csproj", diff --git a/src/Caching/Hybrid/src/HybridCacheBuilderExtensions.cs b/src/Caching/Hybrid/src/HybridCacheBuilderExtensions.cs new file mode 100644 index 000000000000..a27240f66418 --- /dev/null +++ b/src/Caching/Hybrid/src/HybridCacheBuilderExtensions.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Configuration extension methods for / . +/// +public static class HybridCacheBuilderExtensions +{ + /// + /// Serialize values of type with the specified serializer from . + /// + public static IHybridCacheBuilder WithSerializer(this IHybridCacheBuilder builder, IHybridCacheSerializer serializer) + { + builder.Services.AddSingleton>(serializer); + return builder; + } + + /// + /// Serialize values of type with the serializer of type . + /// + public static IHybridCacheBuilder WithSerializer(this IHybridCacheBuilder builder) + where TImplementation : class, IHybridCacheSerializer + { + builder.Services.AddSingleton, TImplementation>(); + return builder; + } + + /// + /// Add as an additional serializer factory, which can provide serializers for multiple types. + /// + public static IHybridCacheBuilder WithSerializerFactory(this IHybridCacheBuilder builder, IHybridCacheSerializerFactory factory) + { + builder.Services.AddSingleton(factory); + return builder; + } + + /// + /// Add a factory of type as an additional serializer factory, which can provide serializers for multiple types. + /// + public static IHybridCacheBuilder WithSerializerFactory< +#if NET5_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + TImplementation>(this IHybridCacheBuilder builder) + where TImplementation : class, IHybridCacheSerializerFactory + { + builder.Services.AddSingleton(); + return builder; + } +} diff --git a/src/Caching/Hybrid/src/HybridCacheOptions.cs b/src/Caching/Hybrid/src/HybridCacheOptions.cs new file mode 100644 index 000000000000..62407b9bf6a9 --- /dev/null +++ b/src/Caching/Hybrid/src/HybridCacheOptions.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Options for configuring the default implementation. +/// +public class HybridCacheOptions +{ + /// + /// Default global options to be applied to operations; if options are + /// specified at the individual call level, the non-null values are merged (with the per-call + /// options being used in preference to the global options). If no value is specified for a given + /// option (globally or per-call), the implementation may choose a reasonable default. + /// + public HybridCacheEntryOptions? DefaultEntryOptions { get; set; } + + /// + /// Disallow compression for this instance. + /// + public bool DisableCompression { get; set; } + + /// + /// The maximum size of cache items; attempts to store values over this size will be logged + /// and the value will not be stored in cache. + /// + /// The default value is 1 MiB. + public long MaximumPayloadBytes { get; set; } = 1 << 20; // 1MiB + + /// + /// The maximum permitted length (in characters) of keys; attempts to use keys over this size will be logged. + /// + /// The default value is 1024 characters. + public int MaximumKeyLength { get; set; } = 1024; // characters + + /// + /// Use "tags" data as dimensions on metric reporting; if enabled, care should be used to ensure that + /// tags do not contain data that should not be visible in metrics systems. + /// + public bool ReportTagMetrics { get; set; } +} diff --git a/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs b/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs new file mode 100644 index 000000000000..bcbde7462a39 --- /dev/null +++ b/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Internal; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Configuration extension methods for . +/// +public static class HybridCacheServiceExtensions +{ + /// + /// Adds support for multi-tier caching services. + /// + /// A builder instance that allows further configuration of the system. + public static IHybridCacheBuilder AddHybridCache(this IServiceCollection services, Action setupAction) + { +#if NET7_0_OR_GREATER + ArgumentNullException.ThrowIfNull(setupAction); +#else + _ = setupAction ?? throw new ArgumentNullException(nameof(setupAction)); +#endif + AddHybridCache(services); + services.Configure(setupAction); + return new HybridCacheBuilder(services); + } + + /// + /// Adds support for multi-tier caching services. + /// + /// A builder instance that allows further configuration of the system. + public static IHybridCacheBuilder AddHybridCache(this IServiceCollection services) + { +#if NET7_0_OR_GREATER + ArgumentNullException.ThrowIfNull(services); +#else + _ = services ?? throw new ArgumentNullException(nameof(services)); +#endif + + services.TryAddSingleton(TimeProvider.System); + services.AddOptions(); + services.AddMemoryCache(); + services.AddDistributedMemoryCache(); // we need a backend; use in-proc by default + services.TryAddSingleton(); + services.TryAddSingleton>(InbuiltTypeSerializer.Instance); + services.TryAddSingleton>(InbuiltTypeSerializer.Instance); + services.TryAddSingleton(); + return new HybridCacheBuilder(services); + } +} diff --git a/src/Caching/Hybrid/src/IHybridCacheBuilder.cs b/src/Caching/Hybrid/src/IHybridCacheBuilder.cs new file mode 100644 index 000000000000..fae49c030fc3 --- /dev/null +++ b/src/Caching/Hybrid/src/IHybridCacheBuilder.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Helper API for configuring . +/// +public interface IHybridCacheBuilder +{ + /// + /// Gets the services collection associated with this instance. + /// + IServiceCollection Services { get; } +} + +internal sealed class HybridCacheBuilder(IServiceCollection services) : IHybridCacheBuilder +{ + public IServiceCollection Services { get; } = services; +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs new file mode 100644 index 000000000000..0ec73b682118 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +/// +/// The inbuilt ASP.NET implementation of . +/// +internal sealed class DefaultHybridCache : HybridCache +{ + private readonly IDistributedCache _backendCache; + private readonly IServiceProvider _services; + private readonly HybridCacheOptions _options; + + public DefaultHybridCache(IOptions options, IDistributedCache backendCache, IServiceProvider services) + { + _backendCache = backendCache ?? throw new ArgumentNullException(nameof(backendCache)); + _services = services ?? throw new ArgumentNullException(nameof(services)); + _options = options.Value; + } + + internal HybridCacheOptions Options => _options; + + public override ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) + => underlyingDataCallback(state, token); // pass-thru without caching for initial API pass + + public override ValueTask RemoveKeyAsync(string key, CancellationToken token = default) + => default; // no cache, nothing to remove + + public override ValueTask RemoveTagAsync(string tag, CancellationToken token = default) + => default; // no cache, nothing to remove + + public override ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) + => default; // no cache, nothing to set + + internal IHybridCacheSerializer GetSerializer() + { + // unused API, primarily intended to show configuration is working; + // the real version would memoize the result + var service = _services.GetService>(); + if (service is null) + { + foreach (var factory in _services.GetServices()) + { + if (factory.TryCreateSerializer(out var current)) + { + service = current; + } + } + } + return service ?? throw new InvalidOperationException("No serializer configured for type: " + typeof(T).Name); + } +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultJsonSerializerFactory.cs b/src/Caching/Hybrid/src/Internal/DefaultJsonSerializerFactory.cs new file mode 100644 index 000000000000..e925a033951f --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultJsonSerializerFactory.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +internal sealed class DefaultJsonSerializerFactory : IHybridCacheSerializerFactory +{ + public bool TryCreateSerializer([NotNullWhen(true)] out IHybridCacheSerializer? serializer) + { + // no restriction + serializer = new DefaultJsonSerializer(); + return true; + } + + internal sealed class DefaultJsonSerializer : IHybridCacheSerializer + { + T IHybridCacheSerializer.Deserialize(ReadOnlySequence source) + { + var reader = new Utf8JsonReader(source); +#pragma warning disable IL2026, IL3050 // AOT bits + return JsonSerializer.Deserialize(ref reader)!; +#pragma warning restore IL2026, IL3050 + } + + void IHybridCacheSerializer.Serialize(T value, IBufferWriter target) + { + using var writer = new Utf8JsonWriter(target); +#pragma warning disable IL2026, IL3050 // AOT bits + JsonSerializer.Serialize(writer, value, JsonSerializerOptions.Default); +#pragma warning restore IL2026, IL3050 + } + } + +} diff --git a/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs b/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs new file mode 100644 index 000000000000..a043fc1ca203 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Buffers; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +internal sealed class InbuiltTypeSerializer : IHybridCacheSerializer, IHybridCacheSerializer +{ + public static InbuiltTypeSerializer Instance { get; } = new(); + + string IHybridCacheSerializer.Deserialize(ReadOnlySequence source) + { +#if NET5_0_OR_GREATER + return Encoding.UTF8.GetString(source); +#else + if (source.IsSingleSegment && MemoryMarshal.TryGetArray(source.First, out var segment)) + { + // we can use the existing single chunk as-is + return Encoding.UTF8.GetString(segment.Array, segment.Offset, segment.Count); + } + + var length = checked((int)source.Length); + var oversized = ArrayPool.Shared.Rent(length); + source.CopyTo(oversized); + var s = Encoding.UTF8.GetString(oversized, 0, length); + ArrayPool.Shared.Return(oversized); + return s; +#endif + } + + void IHybridCacheSerializer.Serialize(string value, IBufferWriter target) + { +#if NET5_0_OR_GREATER + Encoding.UTF8.GetBytes(value, target); +#else + var length = Encoding.UTF8.GetByteCount(value); + var oversized = ArrayPool.Shared.Rent(length); + var actual = Encoding.UTF8.GetBytes(value, 0, value.Length, oversized, 0); + Debug.Assert(actual == length); + target.Write(new(oversized, 0, length)); + ArrayPool.Shared.Return(oversized); +#endif + } + + byte[] IHybridCacheSerializer.Deserialize(ReadOnlySequence source) + => source.ToArray(); + + void IHybridCacheSerializer.Serialize(byte[] value, IBufferWriter target) + => target.Write(value); +} diff --git a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj new file mode 100644 index 000000000000..49671f048347 --- /dev/null +++ b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj @@ -0,0 +1,30 @@ + + + + Multi-level caching implementation building on and extending IDistributedCache + $(DefaultNetCoreTargetFramework);$(DefaultNetFxTargetFramework);netstandard2.0 + true + cache;distributedcache;hybrid + true + false + true + true + + + + + + + + + + + + + + + + + + + diff --git a/src/Caching/Hybrid/src/PublicAPI.Shipped.txt b/src/Caching/Hybrid/src/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..ab058de62d44 --- /dev/null +++ b/src/Caching/Hybrid/src/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt b/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..47e46d6d30ce --- /dev/null +++ b/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt @@ -0,0 +1,60 @@ +#nullable enable +abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.GetOrCreateAsync(string! key, TState state, System.Func>! factory, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.IReadOnlyCollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveKeyAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveTagAsync(string! tag, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.SetAsync(string! key, T value, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.IReadOnlyCollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache +Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.Set(string! key, System.Buffers.ReadOnlySequence value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options) -> void +Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.SetAsync(string! key, System.Buffers.ReadOnlySequence value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.TryGet(string! key, System.Buffers.IBufferWriter! destination) -> bool +Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.TryGetAsync(string! key, System.Buffers.IBufferWriter! destination, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Microsoft.Extensions.Caching.Hybrid.HybridCache +Microsoft.Extensions.Caching.Hybrid.HybridCache.GetOrCreateAsync(string! key, System.Func>! factory, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.IReadOnlyCollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Microsoft.Extensions.Caching.Hybrid.HybridCache.HybridCache() -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableCompression = 32 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableDistributedCache = Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableDistributedCacheRead | Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableDistributedCacheWrite -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableDistributedCacheRead = 4 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableDistributedCacheWrite = 8 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCache = Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCacheRead | Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCacheWrite -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCacheRead = 1 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCacheWrite = 2 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableUnderlyingData = 16 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.None = 0 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.Expiration.get -> System.TimeSpan? +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.Expiration.init -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.Flags.get -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags? +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.Flags.init -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.HybridCacheEntryOptions() -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.LocalCacheExpiration.get -> System.TimeSpan? +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.LocalCacheExpiration.init -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DefaultEntryOptions.get -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DefaultEntryOptions.set -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DisableCompression.get -> bool +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DisableCompression.set -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.HybridCacheOptions() -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.MaximumKeyLength.get -> int +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.MaximumKeyLength.set -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.MaximumPayloadBytes.get -> long +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.MaximumPayloadBytes.set -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.ReportTagMetrics.get -> bool +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.ReportTagMetrics.set -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheServiceExtensions +Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder +Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder.Services.get -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializer +Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializer.Deserialize(System.Buffers.ReadOnlySequence source) -> T +Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializer.Serialize(T value, System.Buffers.IBufferWriter! target) -> void +Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializerFactory +Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializerFactory.TryCreateSerializer(out Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializer? serializer) -> bool +static Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions.WithSerializer(this Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! builder) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! +static Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions.WithSerializer(this Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! builder, Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializer! serializer) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! +static Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions.WithSerializerFactory(this Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! builder, Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializerFactory! factory) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! +static Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions.WithSerializerFactory(this Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! builder) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! +static Microsoft.Extensions.Caching.Hybrid.HybridCacheServiceExtensions.AddHybridCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! +static Microsoft.Extensions.Caching.Hybrid.HybridCacheServiceExtensions.AddHybridCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! setupAction) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! +virtual Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveKeysAsync(System.Collections.Generic.IEnumerable! keys, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +virtual Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveTagsAsync(System.Collections.Generic.IEnumerable! tags, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs new file mode 100644 index 000000000000..a2aaad2c0f26 --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -0,0 +1,124 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Provides multi-tier caching services building on backends. +/// +public abstract class HybridCache +{ + /// + /// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. + /// + /// The type of the data being considered. + /// The type of additional state required by . + /// The key of the entry to look for or create. + /// Provides the underlying data service is the data is not available in the cache. + /// Additional state required for . + /// Additional options for this cache entry. + /// The tags to associate with this cache item. + /// The used to propagate notifications that the operation should be canceled. + /// The data, either from cache or the underlying data service. + [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")] + public abstract ValueTask GetOrCreateAsync(string key, TState state, Func> factory, + HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default); + + /// + /// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. + /// + /// The type of the data being considered. + /// The key of the entry to look for or create. + /// Provides the underlying data service is the data is not available in the cache. + /// Additional options for this cache entry. + /// The tags to associate with this cache item. + /// The used to propagate notifications that the operation should be canceled. + /// The data, either from cache or the underlying data service. + [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")] + public ValueTask GetOrCreateAsync(string key, Func> factory, + HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) + => GetOrCreateAsync(key, factory, WrappedCallbackCache.Instance, options, tags, token); + + private static class WrappedCallbackCache // per-T memoized helper that allows GetOrCreateAsync and GetOrCreateAsync to share an implementation + { + // for the simple usage scenario (no TState), pack the original callback as the "state", and use a wrapper function that just unrolls and invokes from the state + public static readonly Func>, CancellationToken, ValueTask> Instance = static (callback, ct) => callback(ct); + } + + /// + /// Asynchronously sets or overwrites the value associated with the key. + /// + /// The type of the data being considered. + /// The key of the entry to create. + /// The value to assign for this cache entry. + /// Additional options for this cache entry. + /// The tags to associate with this cache entry. + /// The used to propagate notifications that the operation should be canceled. + public abstract ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default); + + /// + /// Asynchronously removes the value associated with the key if it exists. + /// + public abstract ValueTask RemoveKeyAsync(string key, CancellationToken token = default); + + /// + /// Asynchronously removes the value associated with the key if it exists. + /// + /// Implementors should treat null as empty + public virtual ValueTask RemoveKeysAsync(IEnumerable keys, CancellationToken token = default) + { + return keys switch + { + // for consistency with GetOrCreate/Set: interpret null as "none" + null or ICollection { Count: 0 } => default, + ICollection { Count: 1 } => RemoveTagAsync(keys.Single(), token), + _ => ForEachAsync(this, keys, token), + }; + + // default implementation is to call RemoveKeyAsync for each key in turn + static async ValueTask ForEachAsync(HybridCache @this, IEnumerable keys, CancellationToken token) + { + foreach (var key in keys) + { + await @this.RemoveKeyAsync(key, token).ConfigureAwait(false); + } + } + } + + /// + /// Asynchronously removes the value associated with the specified tags. + /// + /// Implementors should treat null as empty + public virtual ValueTask RemoveTagsAsync(IEnumerable tags, CancellationToken token = default) + { + return tags switch + { + // for consistency with GetOrCreate/Set: interpret null as "none" + null or ICollection { Count: 0 } => default, + ICollection { Count: 1 } => RemoveTagAsync(tags.Single(), token), + _ => ForEachAsync(this, tags, token), + }; + + // default implementation is to call RemoveTagAsync for each key in turn + static async ValueTask ForEachAsync(HybridCache @this, IEnumerable keys, CancellationToken token) + { + foreach (var key in keys) + { + await @this.RemoveTagAsync(key, token).ConfigureAwait(false); + } + } + } + + /// + /// Asynchronously removes the value associated with the specified tag. + /// + public abstract ValueTask RemoveTagAsync(string tag, CancellationToken token = default); +} diff --git a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs new file mode 100644 index 000000000000..b6a51b11691f --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Additional flags that apply to a operation. +/// +[Flags] +public enum HybridCacheEntryFlags +{ + /// + /// No additional flags. + /// + None = 0, + /// + /// Disables reading from the local in-process cache. + /// + DisableLocalCacheRead = 1 << 0, + /// + /// Disables writing to the local in-process cache. + /// + DisableLocalCacheWrite = 1 << 1, + /// + /// Disables both reading from and writing to the local in-process cache. + /// + DisableLocalCache = DisableLocalCacheRead | DisableLocalCacheWrite, + /// + /// Disables reading from the secondary distributed cache. + /// + DisableDistributedCacheRead = 1 << 2, + /// + /// Disables writing to the secondary distributed cache. + /// + DisableDistributedCacheWrite = 1 << 3, + /// + /// Disables both reading from and writing to the secondary distributed cache. + /// + DisableDistributedCache = DisableDistributedCacheRead | DisableDistributedCacheWrite, + /// + /// Only fetches the value from cache; does not attempt to access the underlying data store. + /// + DisableUnderlyingData = 1 << 4, + /// + /// Disables compression for this payload. + /// + DisableCompression = 1 << 5, +} diff --git a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs new file mode 100644 index 000000000000..a5416cce9692 --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Additional options (expiration, etc.) that apply to a operation. When options +/// can be specified at multiple levels (for example, globally and per-call), the values are composed; the +/// most granular non-null value is used, with null values being inherited. If no value is specified at +/// any level, the implementation may choose a reasonable default. +/// +public sealed class HybridCacheEntryOptions +{ + /// + /// Overall cache duration of this entry, passed to the backend distributed cache. + /// + public TimeSpan? Expiration { get; init; } // overall cache duration + + /// + /// Cache duration in local cache; when retrieving a cached value + /// from an external cache store, this value will be used to calculate the local + /// cache expiration, not exceeding the remaining overall cache lifetime. + /// + public TimeSpan? LocalCacheExpiration { get; init; } // TTL in L1 + + /// + /// Additional flags that apply to this usage. + /// + public HybridCacheEntryFlags? Flags { get; init; } +} diff --git a/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs b/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs new file mode 100644 index 000000000000..994d52766a9d --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Caching.Distributed; // intentional for parity with IDistributedCache + +/// +/// Represents a distributed cache of serialized values, with support for low allocation data transfer. +/// +public interface IBufferDistributedCache : IDistributedCache +{ + /// + /// Attempt to retrieve an existing cache item. + /// + /// The unique key for the cache item. + /// The target to write the cache contents on success. + /// true if the cache item is found, false otherwise. + /// This is functionally similar to , but avoids the array allocation. + bool TryGet(string key, IBufferWriter destination); + + /// + /// Asynchronously attempt to retrieve an existing cache entry. + /// + /// The unique key for the cache entry. + /// The target to write the cache contents on success. + /// The used to propagate notifications that the operation should be canceled. + /// true if the cache entry is found, false otherwise. + /// This is functionally similar to , but avoids the array allocation. + ValueTask TryGetAsync(string key, IBufferWriter destination, CancellationToken token = default); + + /// + /// Sets or overwrites a cache item. + /// + /// The key of the entry to create. + /// The value for this cache entry. + /// The cache options for the entry. + /// This is functionally similar to , but avoids the array allocation. + void Set(string key, ReadOnlySequence value, DistributedCacheEntryOptions options); + + /// + /// Asynchronously sets or overwrites a cache entry. + /// + /// The key of the entry to create. + /// The value for this cache entry. + /// The cache options for the value. + /// The used to propagate notifications that the operation should be canceled. + /// This is functionally similar to , but avoids the array allocation. + ValueTask SetAsync(string key, ReadOnlySequence value, DistributedCacheEntryOptions options, CancellationToken token = default); +} diff --git a/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializer.cs b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializer.cs new file mode 100644 index 000000000000..f5c869a71772 --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializer.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Per-type serialization/deserialization support for . +/// +/// The type being serialized/deserialized. +public interface IHybridCacheSerializer +{ + /// + /// Deserialize a value from the provided . + /// + T Deserialize(ReadOnlySequence source); + + /// + /// Serialize , writing to the provided . + /// + void Serialize(T value, IBufferWriter target); +} + diff --git a/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs new file mode 100644 index 000000000000..d500ddfb2ba9 --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Factory provider for per-type instances. +/// +public interface IHybridCacheSerializerFactory +{ + /// + /// Request a serializer for the provided type, if possible. + /// + /// The type being serialized/deserialized. + /// The serializer. + /// true if the factory supports this type, false otherwise. + bool TryCreateSerializer([NotNullWhen(true)] out IHybridCacheSerializer? serializer); +} diff --git a/src/Caching/Hybrid/src/Runtime/readme.md b/src/Caching/Hybrid/src/Runtime/readme.md new file mode 100644 index 000000000000..1e2289449f0b --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/readme.md @@ -0,0 +1,2 @@ +These types are intended to be added to be relocated to `Microsoft.Extensions.Caching.Abstractions`; their inclusion +here is a preview placeholder diff --git a/src/Caching/Hybrid/test/BasicConfig.json b/src/Caching/Hybrid/test/BasicConfig.json new file mode 100644 index 000000000000..374114fb1dba --- /dev/null +++ b/src/Caching/Hybrid/test/BasicConfig.json @@ -0,0 +1,12 @@ +{ + "no_entry_options": { + "MaximumKeyLength": 937 + }, + "with_entry_options": { + "MaximumKeyLength": 937, + "DefaultEntryOptions": { + "LocalCacheExpiration": "00:02:00", + "Flags": "DisableCompression,DisableLocalCacheRead" + } + } +} diff --git a/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj b/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj new file mode 100644 index 000000000000..c589f1499cc8 --- /dev/null +++ b/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj @@ -0,0 +1,20 @@ + + + + $(DefaultNetCoreTargetFramework);$(DefaultNetFxTargetFramework) + enable + enable + + + + + + + + + + PreserveNewest + + + + diff --git a/src/Caching/Hybrid/test/ServiceConstructionTests.cs b/src/Caching/Hybrid/test/ServiceConstructionTests.cs new file mode 100644 index 000000000000..d9515816f222 --- /dev/null +++ b/src/Caching/Hybrid/test/ServiceConstructionTests.cs @@ -0,0 +1,162 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Json; +using Microsoft.Extensions.DependencyInjection; + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously +#pragma warning disable CS8769 // Nullability of reference types in type of parameter doesn't match implemented member (possibly because of nullability attributes). + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; +public class ServiceConstructionTests +{ + [Fact] + public void CanCreateDefaultService() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + using var provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetService()); + } + + [Fact] + public void CanCreateServiceWithManualOptions() + { + var services = new ServiceCollection(); + services.AddHybridCache(options => + { + options.MaximumKeyLength = 937; + options.DefaultEntryOptions = new() { Expiration = TimeSpan.FromSeconds(120), Flags = HybridCacheEntryFlags.DisableLocalCacheRead }; + }); + using var provider = services.BuildServiceProvider(); + var obj = Assert.IsType(provider.GetService()); + var options = obj.Options; + Assert.Equal(937, options.MaximumKeyLength); + var defaults = options.DefaultEntryOptions; + Assert.NotNull(defaults); + Assert.Equal(TimeSpan.FromSeconds(120), defaults.Expiration); + Assert.Equal(HybridCacheEntryFlags.DisableLocalCacheRead, defaults.Flags); + Assert.Null(defaults.LocalCacheExpiration); // wasn't specified + } + + [Fact] + public void CanParseOptions_NoEntryOptions() + { + var source = new JsonConfigurationSource { Path = "BasicConfig.json" }; + var configBuilder = new ConfigurationBuilder { Sources = { source } }; + var config = configBuilder.Build(); + var options = new HybridCacheOptions(); + ConfigurationBinder.Bind(config, "no_entry_options", options); + + Assert.Equal(937, options.MaximumKeyLength); + Assert.Null(options.DefaultEntryOptions); + } + [Fact] + public void CanParseOptions_WithEntryOptions() // in particular, check we can parse the timespan and [Flags] enums + { + var source = new JsonConfigurationSource { Path = "BasicConfig.json" }; + var configBuilder = new ConfigurationBuilder { Sources = { source } }; + var config = configBuilder.Build(); + var options = new HybridCacheOptions(); + ConfigurationBinder.Bind(config, "with_entry_options", options); + + Assert.Equal(937, options.MaximumKeyLength); + var defaults = options.DefaultEntryOptions; + Assert.NotNull(defaults); + Assert.Equal(HybridCacheEntryFlags.DisableCompression | HybridCacheEntryFlags.DisableLocalCacheRead, defaults.Flags); + Assert.Equal(TimeSpan.FromSeconds(120), defaults.LocalCacheExpiration); + Assert.Null(defaults.Expiration); // wasn't specified + } + + [Fact] + public async Task BasicStatelessUsage() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + using var provider = services.BuildServiceProvider(); + var cache = provider.GetRequiredService(); + + var expected = Guid.NewGuid().ToString(); + var actual = await cache.GetOrCreateAsync(Me(), async _ => expected); + Assert.Equal(expected, actual); + } + + [Fact] + public async Task BasicStatefulUsage() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + using var provider = services.BuildServiceProvider(); + var cache = provider.GetRequiredService(); + + var expected = Guid.NewGuid().ToString(); + var actual = await cache.GetOrCreateAsync(Me(), expected, async (state, _) => state); + Assert.Equal(expected, actual); + } + + [Fact] + public void DefaultSerializerConfiguration() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + using var provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + Assert.IsType(cache.GetSerializer()); + Assert.IsType(cache.GetSerializer()); + Assert.IsType>(cache.GetSerializer()); + Assert.IsType>(cache.GetSerializer()); + } + + [Fact] + public void CustomSerializerConfiguration() + { + var services = new ServiceCollection(); + services.AddHybridCache().WithSerializer(); + using var provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + Assert.IsType(cache.GetSerializer()); + Assert.IsType>(cache.GetSerializer()); + } + + [Fact] + public void CustomSerializerFactoryConfiguration() + { + var services = new ServiceCollection(); + services.AddHybridCache().WithSerializerFactory(); + using var provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + Assert.IsType(cache.GetSerializer()); + Assert.IsType>(cache.GetSerializer()); + } + + class Customer { } + class Order { } + + class CustomerSerializer : IHybridCacheSerializer + { + Customer IHybridCacheSerializer.Deserialize(ReadOnlySequence source) => throw new NotImplementedException(); + void IHybridCacheSerializer.Serialize(Customer value, IBufferWriter target) => throw new NotImplementedException(); + } + + class CustomFactory : IHybridCacheSerializerFactory + { + bool IHybridCacheSerializerFactory.TryCreateSerializer(out IHybridCacheSerializer? serializer) + { + if (typeof(T) == typeof(Customer)) + { + serializer = (IHybridCacheSerializer)new CustomerSerializer(); + return true; + } + serializer = null; + return false; + } + } + private static string Me([CallerMemberName] string caller = "") => caller; +} diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs index dda21878323f..adc9de0999af 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs @@ -12,8 +12,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Diagnostics.Runtime.Interop; namespace Microsoft.AspNetCore.Http.Generators.Tests; diff --git a/src/Identity/Core/src/IdentityBuilderExtensions.cs b/src/Identity/Core/src/IdentityBuilderExtensions.cs index eec4e9d04ede..264a88a5c23a 100644 --- a/src/Identity/Core/src/IdentityBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityBuilderExtensions.cs @@ -106,12 +106,14 @@ public static IdentityBuilder AddApiEndpoints(this IdentityBuilder builder) // Set TimeProvider from DI on all options instances, if not already set by tests. private sealed class PostConfigureSecurityStampValidatorOptions : IPostConfigureOptions { - public PostConfigureSecurityStampValidatorOptions(TimeProvider timeProvider) + public PostConfigureSecurityStampValidatorOptions(TimeProvider? timeProvider = null) { + // We could assign this to "timeProvider ?? TimeProvider.System", but + // SecurityStampValidator already has system clock fallback logic. TimeProvider = timeProvider; } - private TimeProvider TimeProvider { get; } + private TimeProvider? TimeProvider { get; } public void PostConfigure(string? name, SecurityStampValidatorOptions options) { diff --git a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs index 8b547dc148cd..12034b3c8971 100644 --- a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs +++ b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs @@ -171,12 +171,14 @@ public static IServiceCollection ConfigureExternalCookie(this IServiceCollection private sealed class PostConfigureSecurityStampValidatorOptions : IPostConfigureOptions { - public PostConfigureSecurityStampValidatorOptions(TimeProvider timeProvider) + public PostConfigureSecurityStampValidatorOptions(TimeProvider? timeProvider = null) { + // We could assign this to "timeProvider ?? TimeProvider.System", but + // SecurityStampValidator already has system clock fallback logic. TimeProvider = timeProvider; } - private TimeProvider TimeProvider { get; } + private TimeProvider? TimeProvider { get; } public void PostConfigure(string? name, SecurityStampValidatorOptions options) { diff --git a/src/Identity/test/Identity.Test/IdentityBuilderTest.cs b/src/Identity/test/Identity.Test/IdentityBuilderTest.cs index faddd25e5c8b..642188f8f5d8 100644 --- a/src/Identity/test/Identity.Test/IdentityBuilderTest.cs +++ b/src/Identity/test/Identity.Test/IdentityBuilderTest.cs @@ -196,6 +196,45 @@ public void EnsureDefaultServices() Assert.IsType>(provider.GetRequiredService>()); } + [Fact] + public void EnsureDefaultSignInManagerDependenciesForIdentity() + { + var services = new ServiceCollection() + .AddSingleton(new ConfigurationBuilder().Build()); + services.AddLogging() + .AddIdentity() + .AddUserStore() + .AddRoleStore() + .AddSignInManager(); + + var provider = services.BuildServiceProvider(); + + Assert.IsType(provider.GetRequiredService>()); + Assert.IsType>(provider.GetRequiredService()); + Assert.IsType>(provider.GetRequiredService()); + Assert.NotNull(provider.GetService>()); + } + + [Fact] + public void EnsureDefaultSignInManagerDependenciesForIdentityCore() + { + var services = new ServiceCollection() + .AddSingleton(new ConfigurationBuilder().Build()); + services.AddLogging() + .AddIdentityCore() + .AddRoles() + .AddUserStore() + .AddRoleStore() + .AddSignInManager(); + + var provider = services.BuildServiceProvider(); + + Assert.IsType(provider.GetRequiredService>()); + Assert.IsType>(provider.GetRequiredService()); + Assert.IsType>(provider.GetRequiredService()); + Assert.NotNull(provider.GetService>()); + } + [Fact] public void EnsureDefaultTokenProviders() { diff --git a/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj b/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj index e32c4ee748ee..075d7b90a560 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj +++ b/src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj @@ -20,5 +20,6 @@ + diff --git a/src/OpenApi/OpenApi.slnf b/src/OpenApi/OpenApi.slnf index 0311c6b7ddcd..1f85792cc331 100644 --- a/src/OpenApi/OpenApi.slnf +++ b/src/OpenApi/OpenApi.slnf @@ -8,7 +8,9 @@ "src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj", "src\\Http\\Routing\\src\\Microsoft.AspNetCore.Routing.csproj", "src\\OpenApi\\src\\Microsoft.AspNetCore.OpenApi.csproj", - "src\\OpenApi\\test\\Microsoft.AspNetCore.OpenApi.Tests.csproj" + "src\\OpenApi\\test\\Microsoft.AspNetCore.OpenApi.Tests.csproj", + "src\\OpenApi\\sample\\Sample.csproj", + "src\\OpenApi\\perf\\Microbenchmarks\\Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj" ] } -} \ No newline at end of file +} diff --git a/src/OpenApi/perf/Microbenchmarks/AssemblyInfo.cs b/src/OpenApi/perf/Microbenchmarks/AssemblyInfo.cs new file mode 100644 index 000000000000..09f49228e9e6 --- /dev/null +++ b/src/OpenApi/perf/Microbenchmarks/AssemblyInfo.cs @@ -0,0 +1,4 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +[assembly: BenchmarkDotNet.Attributes.AspNetCoreBenchmark] diff --git a/src/OpenApi/perf/Microbenchmarks/GenerationBenchmarks.cs b/src/OpenApi/perf/Microbenchmarks/GenerationBenchmarks.cs new file mode 100644 index 000000000000..1c676b4545f8 --- /dev/null +++ b/src/OpenApi/perf/Microbenchmarks/GenerationBenchmarks.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.OpenApi.Microbenchmarks; + +/// +/// The following benchmarks are used to assess the performance of the +/// core OpenAPI document generation logic. The parameter under test here +/// is the number of endpoints/operations that are defined in the application. +/// +[MemoryDiagnoser] +public class GenerationBenchmarks : OpenApiDocumentServiceTestBase +{ + [Params(10, 100, 1000)] + public int EndpointCount { get; set; } + + private readonly IEndpointRouteBuilder _builder = CreateBuilder(); + private readonly OpenApiOptions _options = new OpenApiOptions(); + private OpenApiDocumentService _documentService; + + [GlobalSetup(Target = nameof(GenerateDocument))] + public void OperationTransformerAsDelegate_Setup() + { + _builder.MapGet("/", () => { }); + for (var i = 0; i <= EndpointCount; i++) + { + _builder.MapGet($"/{i}", (int i) => new Todo(1, "Write benchmarks", false, DateTime.Now)); + _builder.MapPost($"/{i}", (Todo todo) => Results.Ok()); + _builder.MapDelete($"/{i}", (string id) => Results.NoContent()); + } + _documentService = CreateDocumentService(_builder, _options); + } + + [Benchmark] + public async Task GenerateDocument() + { + await _documentService.GetOpenApiDocumentAsync(); + } +} diff --git a/src/OpenApi/perf/Microbenchmarks/Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj b/src/OpenApi/perf/Microbenchmarks/Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj new file mode 100644 index 000000000000..df6d36dd7dca --- /dev/null +++ b/src/OpenApi/perf/Microbenchmarks/Microsoft.AspNetCore.OpenApi.Microbenchmarks.csproj @@ -0,0 +1,23 @@ + + + + $(DefaultNetCoreTargetFramework) + Exe + true + + + + + + + + + + + + + + + + + diff --git a/src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs b/src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs new file mode 100644 index 000000000000..4dd02d9989d0 --- /dev/null +++ b/src/OpenApi/perf/Microbenchmarks/TransformersBenchmark.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi.Microbenchmarks; + +/// +/// The following benchmarks are used to assess the memory and performance +/// impact of different types of transformers. In particular, we want to +/// measure the impact of (a) context-object creation and caching and (b) +/// enumerator usage when processing operations in a given document. +/// +public class TransformersBenchmark : OpenApiDocumentServiceTestBase +{ + [Params(10, 100, 1000)] + public int TransformerCount { get; set; } + + private readonly IEndpointRouteBuilder _builder = CreateBuilder(); + private readonly OpenApiOptions _options = new OpenApiOptions(); + private OpenApiDocumentService _documentService; + + [GlobalSetup(Target = nameof(OperationTransformerAsDelegate))] + public void OperationTransformerAsDelegate_Setup() + { + _builder.MapGet("/", () => { }); + for (var i = 0; i <= TransformerCount; i++) + { + _options.UseOperationTransformer((operation, context, token) => + { + operation.Description = "New Description"; + return Task.CompletedTask; + }); + } + _documentService = CreateDocumentService(_builder, _options); + } + + [GlobalSetup(Target = nameof(ActivatedDocumentTransformer))] + public void ActivatedDocumentTransformer_Setup() + { + _builder.MapGet("/", () => { }); + for (var i = 0; i <= TransformerCount; i++) + { + _options.UseTransformer(); + } + _documentService = CreateDocumentService(_builder, _options); + } + + [GlobalSetup(Target = nameof(DocumentTransformerAsDelegate))] + public void DocumentTransformerAsDelegate_Delegate() + { + _builder.MapGet("/", () => { }); + for (var i = 0; i <= TransformerCount; i++) + { + _options.UseTransformer((document, context, token) => + { + document.Info.Description = "New Description"; + return Task.CompletedTask; + }); + } + _documentService = CreateDocumentService(_builder, _options); + } + + [Benchmark] + public async Task OperationTransformerAsDelegate() + { + await _documentService.GetOpenApiDocumentAsync(); + } + + [Benchmark] + public async Task ActivatedDocumentTransformer() + { + await _documentService.GetOpenApiDocumentAsync(); + } + + [Benchmark] + public async Task DocumentTransformerAsDelegate() + { + await _documentService.GetOpenApiDocumentAsync(); + } + + private class ActivatedTransformer : IOpenApiDocumentTransformer + { + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + document.Info.Description = "Info Description"; + return Task.CompletedTask; + } + } +} diff --git a/src/OpenApi/sample/EndpointRouteBuilderExtensions.cs b/src/OpenApi/sample/EndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000000..fd196d7fc101 --- /dev/null +++ b/src/OpenApi/sample/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +internal static class OpenApiEndpointRouteBuilderExtensions +{ + /// + /// Helper method to render Swagger UI view for testing. + /// + public static IEndpointConventionBuilder MapSwaggerUi(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/swagger/{documentName}", (string documentName) => Results.Content($$""" + + + + Codestin Search App + + + +
+ + + + + + + + """, "text/html")).ExcludeFromDescription(); + } +} diff --git a/src/OpenApi/sample/Program.cs b/src/OpenApi/sample/Program.cs new file mode 100644 index 000000000000..0ce2d85244ec --- /dev/null +++ b/src/OpenApi/sample/Program.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Models; +using Sample.Transformers; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAuthentication().AddJwtBearer(); + +builder.Services.AddOpenApi("v1", options => +{ + options.AddHeader("X-Version", "1.0"); + options.UseTransformer(); +}); +builder.Services.AddOpenApi("v2", options => { + options.UseTransformer(new AddContactTransformer()); + options.UseTransformer((document, context, token) => { + document.Info.License = new OpenApiLicense { Name = "MIT" }; + return Task.CompletedTask; + }); +}); +builder.Services.AddOpenApi("responses"); +builder.Services.AddOpenApi("forms"); + +var app = builder.Build(); + +app.MapOpenApi(); +if (app.Environment.IsDevelopment()) +{ + app.MapSwaggerUi(); +} + +var forms = app.MapGroup("forms") + .WithGroupName("forms"); + +if (app.Environment.IsDevelopment()) +{ + forms.DisableAntiforgery(); +} + +forms.MapPost("/form-file", (IFormFile resume) => Results.Ok(resume.FileName)); +forms.MapPost("/form-files", (IFormFileCollection files) => Results.Ok(files.Count)); +forms.MapPost("/form-todo", ([FromForm] Todo todo) => Results.Ok(todo)); + +var v1 = app.MapGroup("v1") + .WithGroupName("v1"); +var v2 = app.MapGroup("v2") + .WithGroupName("v2"); +var responses = app.MapGroup("responses") + .WithGroupName("responses"); + +v1.MapPost("/todos", (Todo todo) => Results.Created($"/todos/{todo.Id}", todo)) + .WithSummary("Creates a new todo item."); +v1.MapGet("/todos/{id}", (int id) => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now)) + .WithDescription("Returns a specific todo item."); + +v2.MapGet("/users", () => new [] { "alice", "bob" }) + .WithTags("users"); + +v2.MapPost("/users", () => Results.Created("/users/1", new { Id = 1, Name = "Test user" })); + +responses.MapGet("/200-add-xml", () => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now)) + .Produces(additionalContentTypes: "text/xml"); + +responses.MapGet("/200-only-xml", () => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now)) + .Produces(contentType: "text/xml"); + +app.Run(); diff --git a/src/OpenApi/sample/Properties/launchSettings.json b/src/OpenApi/sample/Properties/launchSettings.json new file mode 100644 index 000000000000..e7c91524954d --- /dev/null +++ b/src/OpenApi/sample/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:43164", + "sslPort": 44391 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5051", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7174;http://localhost:5051", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/OpenApi/sample/Sample.csproj b/src/OpenApi/sample/Sample.csproj new file mode 100644 index 000000000000..882dbbed211d --- /dev/null +++ b/src/OpenApi/sample/Sample.csproj @@ -0,0 +1,25 @@ + + + + $(DefaultNetCoreTargetFramework) + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenApi/sample/Transformers/AddBearerSecuritySchemeTransformer.cs b/src/OpenApi/sample/Transformers/AddBearerSecuritySchemeTransformer.cs new file mode 100644 index 000000000000..02bd033b7628 --- /dev/null +++ b/src/OpenApi/sample/Transformers/AddBearerSecuritySchemeTransformer.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +namespace Sample.Transformers; + +public sealed class BearerSecuritySchemeTransformer(IAuthenticationSchemeProvider authenticationSchemeProvider) : IOpenApiDocumentTransformer +{ + public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + var authenticationSchemes = await authenticationSchemeProvider.GetAllSchemesAsync(); + if (authenticationSchemes.Any(authScheme => authScheme.Name == "Bearer")) + { + var requirements = new Dictionary + { + ["Bearer"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", // "bearer" refers to the header name here + In = ParameterLocation.Header, + BearerFormat = "Json Web Token" + } + }; + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes = requirements; + } + } +} diff --git a/src/OpenApi/sample/Transformers/AddContactTransformer.cs b/src/OpenApi/sample/Transformers/AddContactTransformer.cs new file mode 100644 index 000000000000..5d9d35f1c7ad --- /dev/null +++ b/src/OpenApi/sample/Transformers/AddContactTransformer.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +namespace Sample.Transformers; + +public sealed class AddContactTransformer : IOpenApiDocumentTransformer +{ + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + document.Info.Contact = new OpenApiContact + { + Name = "OpenAPI Enthusiast", + Email = "iloveopenapi@example.com" + }; + return Task.CompletedTask; + } +} diff --git a/src/OpenApi/sample/Transformers/OperationTransformers.cs b/src/OpenApi/sample/Transformers/OperationTransformers.cs new file mode 100644 index 000000000000..71b2c9842951 --- /dev/null +++ b/src/OpenApi/sample/Transformers/OperationTransformers.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Extensions; + +namespace Sample.Transformers; + +public static class OperationTransformers +{ + public static OpenApiOptions AddHeader(this OpenApiOptions options, string headerName, string defaultValue) + { + return options.UseOperationTransformer((operation, context, cancellationToken) => + { + var schema = OpenApiTypeMapper.MapTypeToOpenApiPrimitiveType(typeof(string)); + schema.Default = new OpenApiString(defaultValue); + operation.Parameters.Add(new OpenApiParameter + { + Name = headerName, + In = ParameterLocation.Header, + Schema = schema + }); + return Task.CompletedTask; + }); + } +} diff --git a/src/OpenApi/sample/appsettings.Development.json b/src/OpenApi/sample/appsettings.Development.json new file mode 100644 index 000000000000..0c208ae9181e --- /dev/null +++ b/src/OpenApi/sample/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/OpenApi/sample/appsettings.json b/src/OpenApi/sample/appsettings.json new file mode 100644 index 000000000000..10f68b8c8b4f --- /dev/null +++ b/src/OpenApi/sample/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs new file mode 100644 index 000000000000..9e134604641a --- /dev/null +++ b/src/OpenApi/src/Extensions/ApiDescriptionExtensions.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.OpenApi.Models; + +internal static class ApiDescriptionExtensions +{ + /// + /// Maps the HTTP method of the ApiDescription to the OpenAPI . + /// + /// The ApiDescription to resolve an operation type from. + /// The associated with the given . + public static OperationType GetOperationType(this ApiDescription apiDescription) => + apiDescription.HttpMethod?.ToUpperInvariant() switch + { + "GET" => OperationType.Get, + "POST" => OperationType.Post, + "PUT" => OperationType.Put, + "DELETE" => OperationType.Delete, + "PATCH" => OperationType.Patch, + "HEAD" => OperationType.Head, + "OPTIONS" => OperationType.Options, + "TRACE" => OperationType.Trace, + _ => throw new InvalidOperationException($"Unsupported HTTP method: {apiDescription.HttpMethod}"), + }; + + /// + /// Maps the relative path included in the ApiDescription to the path + /// that should be included in the OpenApiDocument. This typically + /// consists of removing any constraints from route parameter parts + /// and retaining only the literals. + /// + /// The ApiDescription to resolve an item path from. + /// The resolved item path for the given . + public static string MapRelativePathToItemPath(this ApiDescription apiDescription) + { + Debug.Assert(apiDescription.RelativePath != null, "Relative path cannot be null."); + // "" -> "/" + if (string.IsNullOrEmpty(apiDescription.RelativePath)) + { + return "/"; + } + var strippedRoute = new StringBuilder(); + var routePattern = RoutePatternFactory.Parse(apiDescription.RelativePath); + for (var i = 0; i < routePattern.PathSegments.Count; i++) + { + strippedRoute.Append('/'); + var segment = routePattern.PathSegments[i]; + foreach (var part in segment.Parts) + { + if (part is RoutePatternLiteralPart literalPart) + { + strippedRoute.Append(literalPart.Content); + } + else if (part is RoutePatternParameterPart parameterPart) + { + strippedRoute.Append('{'); + strippedRoute.Append(parameterPart.Name); + strippedRoute.Append('}'); + } + else if (part is RoutePatternSeparatorPart separatorPart) + { + strippedRoute.Append(separatorPart.Content); + } + } + } + return strippedRoute.ToString(); + } + + /// + /// Determines if the given is a request body parameter. + /// + /// The to check. + /// Returns if the given parameter comes from the request body, otherwise. + public static bool IsRequestBodyParameter(this ApiParameterDescription apiParameterDescription) => + apiParameterDescription.Source == BindingSource.Body || + apiParameterDescription.Source == BindingSource.FormFile || + apiParameterDescription.Source == BindingSource.Form; + + /// + /// Retrieves the form parameters from the ApiDescription, if they exist. + /// + /// The ApiDescription to resolve form parameters from. + /// A list of associated with the form parameters. + /// if form parameters were found, otherwise. + public static bool TryGetFormParameters(this ApiDescription apiDescription, out IEnumerable formParameters) + { + formParameters = apiDescription.ParameterDescriptions.Where(parameter => parameter.Source == BindingSource.Form || parameter.Source == BindingSource.FormFile); + return formParameters.Any(); + } + + /// + /// Retrieves the body parameter from the ApiDescription, if it exists. + /// + /// The ApiDescription to resolve the body parameter from. + /// The associated with the body parameter. + /// if a single body parameter was found, otherwise. + public static bool TryGetBodyParameter(this ApiDescription apiDescription, [NotNullWhen(true)] out ApiParameterDescription? bodyParameter) + { + bodyParameter = null; + var bodyParameters = apiDescription.ParameterDescriptions.Where(parameter => parameter.Source == BindingSource.Body); + if (bodyParameters.Count() == 1) + { + bodyParameter = bodyParameters.Single(); + return true; + } + return false; + } +} diff --git a/src/OpenApi/src/OpenApiEndpointConventionBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointConventionBuilderExtensions.cs similarity index 100% rename from src/OpenApi/src/OpenApiEndpointConventionBuilderExtensions.cs rename to src/OpenApi/src/Extensions/OpenApiEndpointConventionBuilderExtensions.cs diff --git a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000000..7bae09542251 --- /dev/null +++ b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Internal; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Extensions; +using Microsoft.OpenApi.Writers; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// OpenAPI-related methods for . +/// +public static class OpenApiEndpointRouteBuilderExtensions +{ + /// + /// Register an endpoint onto the current application for resolving the OpenAPI document associated + /// with the current application. + /// + /// The . + /// The route to register the endpoint on. Must include the 'documentName' route parameter. + /// An that can be used to further customize the endpoint. + public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = OpenApiConstants.DefaultOpenApiRoute) + { + var options = endpoints.ServiceProvider.GetRequiredService>(); + return endpoints.MapGet(pattern, async (HttpContext context, string documentName = OpenApiConstants.DefaultDocumentName) => + { + // It would be ideal to use the `HttpResponseStreamWriter` to + // asynchronously write to the response stream here but Microsoft.OpenApi + // does not yet support async APIs on their writers. + // See https://github.com/microsoft/OpenAPI.NET/issues/421 for more info. + var documentService = context.RequestServices.GetKeyedService(documentName); + if (documentService is null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + context.Response.ContentType = "text/plain;charset=utf-8"; + await context.Response.WriteAsync($"No OpenAPI document with the name '{documentName}' was found."); + } + else + { + var document = await documentService.GetOpenApiDocumentAsync(context.RequestAborted); + var documentOptions = options.Get(documentName); + using var output = MemoryBufferWriter.Get(); + using var writer = Utf8BufferTextWriter.Get(output); + try + { + document.Serialize(new OpenApiJsonWriter(writer), documentOptions.OpenApiVersion); + await context.Response.BodyWriter.WriteAsync(output.ToArray()); + await context.Response.BodyWriter.FlushAsync(); + } + finally + { + MemoryBufferWriter.Return(output); + Utf8BufferTextWriter.Return(writer); + } + + } + }).ExcludeFromDescription(); + } +} diff --git a/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs new file mode 100644 index 000000000000..b7372551a0ac --- /dev/null +++ b/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.ApiDescriptions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// OpenAPI-related methods for . +/// +public static class OpenApiServiceCollectionExtensions +{ + /// + /// Adds OpenAPI services related to the given document name to the specified . + /// + /// The to register services onto. + /// The name of the OpenAPI document associated with registered services. + public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName) + { + ArgumentNullException.ThrowIfNull(services); + + return services.AddOpenApi(documentName, _ => { }); + } + + /// + /// Adds OpenAPI services related to the given document name to the specified with the specified options. + /// + /// The to register services onto. + /// The name of the OpenAPI document associated with registered services. + /// A delegate used to configure the target . + public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + services.AddOpenApiCore(documentName); + services.Configure(documentName, options => + { + options.DocumentName = documentName; + configureOptions(options); + }); + return services; + } + + /// + /// Adds OpenAPI services related to the default document to the specified with the specified options. + /// + /// The to register services onto. + /// A delegate used to configure the target . + public static IServiceCollection AddOpenApi(this IServiceCollection services, Action configureOptions) + => services.AddOpenApi(OpenApiConstants.DefaultDocumentName, configureOptions); + + /// + /// Adds OpenAPI services related to the default document to the specified . + /// + /// The to register services onto. + public static IServiceCollection AddOpenApi(this IServiceCollection services) + => services.AddOpenApi(OpenApiConstants.DefaultDocumentName); + + private static IServiceCollection AddOpenApiCore(this IServiceCollection services, string documentName) + { + services.AddEndpointsApiExplorer(); + services.AddKeyedSingleton(documentName); + services.AddKeyedSingleton(documentName); + // Required for build-time generation + services.AddSingleton(); + // Required to resolve document names for build-time generation + services.AddSingleton(new NamedService(documentName)); + return services; + } +} diff --git a/src/OpenApi/src/Helpers/OpenApiTagComparer.cs b/src/OpenApi/src/Helpers/OpenApiTagComparer.cs new file mode 100644 index 000000000000..d24d12e79768 --- /dev/null +++ b/src/OpenApi/src/Helpers/OpenApiTagComparer.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// This comparer is used to maintain a globally unique list of tags encountered +/// in a particular OpenAPI document. +/// +internal class OpenApiTagComparer : IEqualityComparer +{ + public static OpenApiTagComparer Instance { get; } = new OpenApiTagComparer(); + + public bool Equals(OpenApiTag? x, OpenApiTag? y) + { + if (x is null && y is null) + { + return true; + } + if (x is null || y is null) + { + return false; + } + // Tag comparisons are case-sensitive by default. Although the OpenAPI specification + // only outlines case sensitivity for property names, we extend this principle to + // property values for tag names as well. + // See https://spec.openapis.org/oas/v3.1.0#format. + return string.Equals(x.Name, y.Name, StringComparison.Ordinal); + } + + public int GetHashCode(OpenApiTag obj) => obj.Name.GetHashCode(); +} diff --git a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj index 34a81cb70466..39c3bd0e05ac 100644 --- a/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj +++ b/src/OpenApi/src/Microsoft.AspNetCore.OpenApi.csproj @@ -4,14 +4,18 @@ $(DefaultNetCoreTargetFramework) true aspnetcore;openapi + + true Provides APIs for annotating route handler endpoints in ASP.NET Core with OpenAPI annotations. + + @@ -24,6 +28,12 @@ + - \ No newline at end of file + + + + + + diff --git a/src/OpenApi/src/PublicAPI.Unshipped.txt b/src/OpenApi/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..f32c9da9334c 100644 --- a/src/OpenApi/src/PublicAPI.Unshipped.txt +++ b/src/OpenApi/src/PublicAPI.Unshipped.txt @@ -1 +1,37 @@ #nullable enable +Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions +Microsoft.AspNetCore.OpenApi.IOpenApiDocumentTransformer.TransformAsync(Microsoft.OpenApi.Models.OpenApiDocument! document, Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Description.get -> Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescription! +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.Description.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiOptions +Microsoft.AspNetCore.OpenApi.OpenApiOptions.DocumentName.get -> string! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.OpenApiOptions() -> void +Microsoft.AspNetCore.OpenApi.OpenApiOptions.OpenApiVersion.get -> Microsoft.OpenApi.OpenApiSpecVersion +Microsoft.AspNetCore.OpenApi.OpenApiOptions.OpenApiVersion.set -> void +Microsoft.AspNetCore.OpenApi.OpenApiOptions.ShouldInclude.get -> System.Func! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.ShouldInclude.set -> void +Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseOperationTransformer(System.Func! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseTransformer(Microsoft.AspNetCore.OpenApi.IOpenApiDocumentTransformer! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseTransformer(System.Func! transformer) -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.AspNetCore.OpenApi.OpenApiOptions.UseTransformer() -> Microsoft.AspNetCore.OpenApi.OpenApiOptions! +Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions +static Microsoft.AspNetCore.Builder.OpenApiEndpointRouteBuilderExtensions.MapOpenApi(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern = "/openapi/{documentName}.json") -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! +static Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions.AddOpenApi(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions.AddOpenApi(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! documentName) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions.AddOpenApi(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! documentName, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.Extensions.DependencyInjection.OpenApiServiceCollectionExtensions.AddOpenApi(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +Microsoft.AspNetCore.OpenApi.IOpenApiDocumentTransformer +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.ApplicationServices.get -> System.IServiceProvider! +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.ApplicationServices.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.DescriptionGroups.get -> System.Collections.Generic.IReadOnlyList! +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.DescriptionGroups.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.DocumentName.get -> string! +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.DocumentName.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiDocumentTransformerContext.OpenApiDocumentTransformerContext() -> void +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.ApplicationServices.get -> System.IServiceProvider! +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.ApplicationServices.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.DocumentName.get -> string! +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.DocumentName.init -> void +Microsoft.AspNetCore.OpenApi.OpenApiOperationTransformerContext.OpenApiOperationTransformerContext() -> void diff --git a/src/OpenApi/src/Services/IDocumentProvider.cs b/src/OpenApi/src/Services/IDocumentProvider.cs new file mode 100644 index 000000000000..61ef9dc560fe --- /dev/null +++ b/src/OpenApi/src/Services/IDocumentProvider.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ApiDescriptions; + +/// +/// Represents a provider for OpenAPI documents to support build-time generation. +/// +/// +/// The Microsoft.Extensions.ApiDescription.Server package and associated configuration +/// execute the `dotnet getdocument` command at build-time to support build-time +/// generation of documents. The `getdocument` tool launches the entry point assembly +/// and queries it for a service that implements the `IDocumentProvider` interface. For +/// historical reasons, the `IDocumentProvider` interface isn't exposed publicly from +/// the framework and the `getdocument` tool instead queries for it using the type name. +/// That means the `IDocumentProvider` interface must be declared under the namespace +/// that it expects. For more information, see https://github.com/dotnet/aspnetcore/blob/82c9b34d7206ba56ea1d641843e1f2fe6d2a0b1c/src/Tools/GetDocumentInsider/src/Commands/GetDocumentCommandWorker.cs#L25. +/// +internal interface IDocumentProvider +{ + IEnumerable GetDocumentNames(); + Task GenerateAsync(string documentName, TextWriter writer); +} diff --git a/src/OpenApi/src/Services/NamedService.cs b/src/OpenApi/src/Services/NamedService.cs new file mode 100644 index 000000000000..ca1545313a76 --- /dev/null +++ b/src/OpenApi/src/Services/NamedService.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Keyed services don't provide an accessible API for resolving +/// all the service keys associated with a given type. +/// See https:///github.com/dotnet/runtime/issues/100105 for more info. +/// This internal class is used to track the document names that have been registered +/// so that they can be resolved in the `IDocumentProvider` implementation. +/// This is inspired by the implementation used in Orleans. See +/// https:///github.com/dotnet/orleans/blob/005ab200bc91302245857cb75efaa436296a1aae/src/Orleans.Runtime/Hosting/NamedService.cs. +/// +internal sealed class NamedService(string name) +{ + public string Name { get; } = name; +} diff --git a/src/OpenApi/src/Services/OpenApiComponentService.cs b/src/OpenApi/src/Services/OpenApiComponentService.cs new file mode 100644 index 000000000000..e949dc6f8236 --- /dev/null +++ b/src/OpenApi/src/Services/OpenApiComponentService.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Http; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Supports managing elements that belong in the "components" section of +/// an OpenAPI document. In particular, this is the API that is used to +/// interact with the JSON schemas that are managed by a given OpenAPI document. +/// +internal sealed class OpenApiComponentService +{ + private readonly ConcurrentDictionary _schemas = new() + { + // Pre-populate OpenAPI schemas for well-defined types in ASP.NET Core. + [typeof(IFormFile)] = new OpenApiSchema { Type = "string", Format = "binary" }, + [typeof(IFormFileCollection)] = new OpenApiSchema + { + Type = "array", + Items = new OpenApiSchema { Type = "string", Format = "binary" } + }, + }; + + internal OpenApiSchema GetOrCreateSchema(Type type) + { + return _schemas.GetOrAdd(type, _ => CreateSchema()); + } + + // TODO: Implement this method to create a schema for a given type. + private static OpenApiSchema CreateSchema() + { + return new OpenApiSchema { Type = "string" }; + } +} diff --git a/src/OpenApi/src/Services/OpenApiConstants.cs b/src/OpenApi/src/Services/OpenApiConstants.cs new file mode 100644 index 000000000000..9ab82ba85470 --- /dev/null +++ b/src/OpenApi/src/Services/OpenApiConstants.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.OpenApi; + +internal static class OpenApiConstants +{ + internal const string DefaultDocumentName = "v1"; + internal const string DefaultOpenApiVersion = "1.0.0"; + internal const string DefaultOpenApiRoute = "/openapi/{documentName}.json"; + internal const string DescriptionId = "x-aspnetcore-id"; + internal const string DefaultOpenApiResponseKey = "default"; +} diff --git a/src/OpenApi/src/Services/OpenApiDocumentProvider.cs b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs new file mode 100644 index 000000000000..831475f8960a --- /dev/null +++ b/src/OpenApi/src/Services/OpenApiDocumentProvider.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.OpenApi.Writers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.Options; +using System.Linq; +using Microsoft.OpenApi.Extensions; + +namespace Microsoft.Extensions.ApiDescriptions; + +internal sealed class OpenApiDocumentProvider(IServiceProvider serviceProvider) : IDocumentProvider +{ + /// + /// Serializes the OpenAPI document associated with a given document name to + /// the provided writer. + /// + /// The name of the document to resolve. + /// A text writer associated with the document to write to. + public async Task GenerateAsync(string documentName, TextWriter writer) + { + // Microsoft.OpenAPI does not provide async APIs for writing the JSON + // document to a file. See https://github.com/microsoft/OpenAPI.NET/issues/421 for + // more info. + var targetDocumentService = serviceProvider.GetRequiredKeyedService(documentName); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + var document = await targetDocumentService.GetOpenApiDocumentAsync(); + var jsonWriter = new OpenApiJsonWriter(writer); + document.Serialize(jsonWriter, namedOption.OpenApiVersion); + } + + /// + /// Provides all document names that are currently managed in the application. + /// + public IEnumerable GetDocumentNames() + { + // Keyed services lack an API to resolve all registered keys. + // We use the service provider to resolve an internal type. + // This type tracks registered document names. + // See https://github.com/dotnet/runtime/issues/100105 for more info. + var documentServices = serviceProvider.GetServices>(); + return documentServices.Select(docService => docService.Name); + } +} diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs new file mode 100644 index 000000000000..d96ec88905c8 --- /dev/null +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -0,0 +1,337 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class OpenApiDocumentService( + [ServiceKey] string documentName, + IApiDescriptionGroupCollectionProvider apiDescriptionGroupCollectionProvider, + IHostEnvironment hostEnvironment, + IOptionsMonitor optionsMonitor, + IServiceProvider serviceProvider) +{ + private readonly OpenApiOptions _options = optionsMonitor.Get(documentName); + private readonly OpenApiComponentService _componentService = serviceProvider.GetRequiredKeyedService(documentName); + + private static readonly OpenApiEncoding _defaultFormEncoding = new OpenApiEncoding { Style = ParameterStyle.Form, Explode = true }; + + /// + /// Cache of instances keyed by the + /// `ApiDescription.ActionDescriptor.Id` of the associated operation. ActionDescriptor IDs + /// are unique within the lifetime of an application and serve as helpful associators between + /// operations, API descriptions, and their respective transformer contexts. + /// + private readonly ConcurrentDictionary _operationTransformerContextCache = new(); + private static readonly ApiResponseType _defaultApiResponseType = new ApiResponseType { StatusCode = StatusCodes.Status200OK }; + + internal bool TryGetCachedOperationTransformerContext(string descriptionId, [NotNullWhen(true)] out OpenApiOperationTransformerContext? context) + => _operationTransformerContextCache.TryGetValue(descriptionId, out context); + + public async Task GetOpenApiDocumentAsync(CancellationToken cancellationToken = default) + { + // For good hygiene, operation-level tags must also appear in the document-level + // tags collection. This set captures all tags that have been seen so far. + HashSet capturedTags = new(OpenApiTagComparer.Instance); + var document = new OpenApiDocument + { + Info = GetOpenApiInfo(), + Paths = GetOpenApiPaths(capturedTags), + Tags = [.. capturedTags] + }; + await ApplyTransformersAsync(document, cancellationToken); + return document; + } + + private async Task ApplyTransformersAsync(OpenApiDocument document, CancellationToken cancellationToken) + { + var documentTransformerContext = new OpenApiDocumentTransformerContext + { + DocumentName = documentName, + ApplicationServices = serviceProvider, + DescriptionGroups = apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items, + }; + // Use index-based for loop to avoid allocating an enumerator with a foreach. + for (var i = 0; i < _options.DocumentTransformers.Count; i++) + { + var transformer = _options.DocumentTransformers[i]; + await transformer.TransformAsync(document, documentTransformerContext, cancellationToken); + } + } + + // Note: Internal for testing. + internal OpenApiInfo GetOpenApiInfo() + { + return new OpenApiInfo + { + Title = $"{hostEnvironment.ApplicationName} | {documentName}", + Version = OpenApiConstants.DefaultOpenApiVersion + }; + } + + /// + /// Gets the OpenApiPaths for the document based on the ApiDescriptions. + /// + /// + /// At this point in the construction of the OpenAPI document, we run + /// each API description through the `ShouldInclude` delegate defined in + /// the object to support filtering each + /// description instance into its appropriate document. + /// + private OpenApiPaths GetOpenApiPaths(HashSet capturedTags) + { + var descriptionsByPath = apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items + .SelectMany(group => group.Items) + .Where(_options.ShouldInclude) + .GroupBy(apiDescription => apiDescription.MapRelativePathToItemPath()); + var paths = new OpenApiPaths(); + foreach (var descriptions in descriptionsByPath) + { + Debug.Assert(descriptions.Key != null, "Relative path mapped to OpenApiPath key cannot be null."); + paths.Add(descriptions.Key, new OpenApiPathItem { Operations = GetOperations(descriptions, capturedTags) }); + } + return paths; + } + + private Dictionary GetOperations(IGrouping descriptions, HashSet capturedTags) + { + var operations = new Dictionary(); + foreach (var description in descriptions) + { + var operation = GetOperation(description, capturedTags); + operation.Extensions.Add(OpenApiConstants.DescriptionId, new OpenApiString(description.ActionDescriptor.Id)); + _operationTransformerContextCache.TryAdd(description.ActionDescriptor.Id, new OpenApiOperationTransformerContext + { + DocumentName = documentName, + Description = description, + ApplicationServices = serviceProvider, + }); + operations[description.GetOperationType()] = operation; + } + return operations; + } + + private OpenApiOperation GetOperation(ApiDescription description, HashSet capturedTags) + { + var tags = GetTags(description); + if (tags != null) + { + foreach (var tag in tags) + { + capturedTags.Add(tag); + } + } + var operation = new OpenApiOperation + { + Summary = GetSummary(description), + Description = GetDescription(description), + Responses = GetResponses(description), + Parameters = GetParameters(description), + RequestBody = GetRequestBody(description), + Tags = tags, + }; + return operation; + } + + private static string? GetSummary(ApiDescription description) + => description.ActionDescriptor.EndpointMetadata.OfType().LastOrDefault()?.Summary; + + private static string? GetDescription(ApiDescription description) + => description.ActionDescriptor.EndpointMetadata.OfType().LastOrDefault()?.Description; + + private static List? GetTags(ApiDescription description) + { + var actionDescriptor = description.ActionDescriptor; + if (actionDescriptor.EndpointMetadata?.OfType().LastOrDefault() is { } tagsMetadata) + { + return tagsMetadata.Tags.Select(tag => new OpenApiTag { Name = tag }).ToList(); + } + // If no tags are specified, use the controller name as the tag. This effectively + // allows us to group endpoints by the "resource" concept (e.g. users, todos, etc.) + return [new OpenApiTag { Name = description.ActionDescriptor.RouteValues["controller"] }]; + } + + private static OpenApiResponses GetResponses(ApiDescription description) + { + // OpenAPI requires that each operation have a response, usually a successful one. + // if there are no response types defined, we assume a successful 200 OK response + // with no content by default. + if (description.SupportedResponseTypes.Count == 0) + { + return new OpenApiResponses + { + ["200"] = GetResponse(description, StatusCodes.Status200OK, _defaultApiResponseType) + }; + } + + var responses = new OpenApiResponses(); + foreach (var responseType in description.SupportedResponseTypes) + { + // The "default" response type is a special case in OpenAPI used to describe + // the response for all HTTP status codes that are not explicitly defined + // for a given operation. This is typically used to describe catch-all scenarios + // like error responses. + var responseKey = responseType.IsDefaultResponse + ? OpenApiConstants.DefaultOpenApiResponseKey + : responseType.StatusCode.ToString(CultureInfo.InvariantCulture); + responses.Add(responseKey, GetResponse(description, responseType.StatusCode, responseType)); + } + return responses; + } + + private static OpenApiResponse GetResponse(ApiDescription apiDescription, int statusCode, ApiResponseType apiResponseType) + { + var description = ReasonPhrases.GetReasonPhrase(statusCode); + var response = new OpenApiResponse + { + Description = description, + Content = new Dictionary() + }; + + // ApiResponseFormats aggregates information about the supported response content types + // from different types of Produces metadata. This is handled by ApiExplorer so looking + // up values in ApiResponseFormats should provide us a complete set of the information + // encoded in Produces metadata added via attributes or extension methods. + var apiResponseFormatContentTypes = apiResponseType.ApiResponseFormats + .Select(responseFormat => responseFormat.MediaType); + foreach (var contentType in apiResponseFormatContentTypes) + { + response.Content[contentType] = new OpenApiMediaType(); + } + + // MVC's `ProducesAttribute` doesn't implement the produces metadata that the ApiExplorer + // looks for when generating ApiResponseFormats above so we need to pull the content + // types defined there separately. + var explicitContentTypes = apiDescription.ActionDescriptor.EndpointMetadata + .OfType() + .SelectMany(attr => attr.ContentTypes); + foreach (var contentType in explicitContentTypes) + { + response.Content[contentType] = new OpenApiMediaType(); + } + + return response; + } + + private static List? GetParameters(ApiDescription description) + { + List? parameters = null; + foreach (var parameter in description.ParameterDescriptions) + { + // Parameters that should be in the request body should not be + // populated in the parameters list. + if (parameter.IsRequestBodyParameter()) + { + continue; + } + + var openApiParameter = new OpenApiParameter + { + Name = parameter.Name, + In = parameter.Source.Id switch + { + "Query" => ParameterLocation.Query, + "Header" => ParameterLocation.Header, + "Path" => ParameterLocation.Path, + _ => throw new InvalidOperationException($"Unsupported parameter source: {parameter.Source.Id}") + }, + // Per the OpenAPI specification, parameters that are sourced from the path + // are always required, regardless of the requiredness status of the parameter. + Required = parameter.Source == BindingSource.Path || parameter.IsRequired, + }; + parameters ??= []; + parameters.Add(openApiParameter); + } + return parameters; + } + + private OpenApiRequestBody? GetRequestBody(ApiDescription description) + { + // Only one parameter can be bound from the body in each request. + if (description.TryGetBodyParameter(out var bodyParameter)) + { + return GetJsonRequestBody(description.SupportedRequestFormats, bodyParameter); + } + // If there are no body parameters, check for form parameters. + // Note: Form parameters and body parameters cannot exist simultaneously + // in the same endpoint. + if (description.TryGetFormParameters(out var formParameters)) + { + return GetFormRequestBody(description.SupportedRequestFormats, formParameters); + } + return null; + } + + private OpenApiRequestBody GetFormRequestBody(IList supportedRequestFormats, IEnumerable formParameters) + { + if (supportedRequestFormats.Count == 0) + { + // Assume "application/x-www-form-urlencoded" as the default media type + // to match the default assumed in IFormFeature. + supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/x-www-form-urlencoded" }]; + } + + var requestBody = new OpenApiRequestBody + { + Required = formParameters.Any(parameter => parameter.IsRequired), + Content = new Dictionary() + }; + + // Forms are represented as objects with properties for each form field. + var schema = new OpenApiSchema { Type = "object", Properties = new Dictionary() }; + foreach (var parameter in formParameters) + { + schema.Properties[parameter.Name] = _componentService.GetOrCreateSchema(parameter.Type); + } + + foreach (var requestFormat in supportedRequestFormats) + { + var contentType = requestFormat.MediaType; + requestBody.Content[contentType] = new OpenApiMediaType + { + Schema = schema, + Encoding = new Dictionary() { [contentType] = _defaultFormEncoding } + }; + } + + return requestBody; + } + + private static OpenApiRequestBody GetJsonRequestBody(IList supportedRequestFormats, ApiParameterDescription bodyParameter) + { + if (supportedRequestFormats.Count == 0) + { + supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/json" }]; + } + + var requestBody = new OpenApiRequestBody + { + Required = bodyParameter.IsRequired, + Content = new Dictionary() + }; + + foreach (var requestForm in supportedRequestFormats) + { + var contentType = requestForm.MediaType; + requestBody.Content[contentType] = new OpenApiMediaType(); + } + + return requestBody; + } +} diff --git a/src/OpenApi/src/OpenApiGenerator.cs b/src/OpenApi/src/Services/OpenApiGenerator.cs similarity index 100% rename from src/OpenApi/src/OpenApiGenerator.cs rename to src/OpenApi/src/Services/OpenApiGenerator.cs diff --git a/src/OpenApi/src/Services/OpenApiOptions.cs b/src/OpenApi/src/Services/OpenApiOptions.cs new file mode 100644 index 000000000000..fba727315660 --- /dev/null +++ b/src/OpenApi/src/Services/OpenApiOptions.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Options to support the construction of OpenAPI documents. +/// +public sealed class OpenApiOptions +{ + internal readonly List DocumentTransformers = []; + + /// + /// Initializes a new instance of the class + /// with the default predicate. + /// + public OpenApiOptions() + { + ShouldInclude = (description) => description.GroupName == null || description.GroupName == DocumentName; + } + + /// + /// The version of the OpenAPI specification to use. Defaults to . + /// + public OpenApiSpecVersion OpenApiVersion { get; set; } = OpenApiSpecVersion.OpenApi3_0; + + /// + /// The name of the OpenAPI document this instance is associated with. + /// + public string DocumentName { get; internal set; } = OpenApiConstants.DefaultDocumentName; + + /// + /// A delegate to determine whether a given should be included in the given OpenAPI document. + /// + public Func ShouldInclude { get; set; } + + /// + /// Registers a new document transformer on the current instance. + /// + /// The type of the to instantiate. + /// The instance for further customization. + public OpenApiOptions UseTransformer<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTransformerType>() + where TTransformerType : IOpenApiDocumentTransformer + { + DocumentTransformers.Add(new TypeBasedOpenApiDocumentTransformer(typeof(TTransformerType))); + return this; + } + + /// + /// Registers a given instance of on the current instance. + /// + /// The instance to use. + /// The instance for further customization. + public OpenApiOptions UseTransformer(IOpenApiDocumentTransformer transformer) + { + ArgumentNullException.ThrowIfNull(transformer, nameof(transformer)); + + DocumentTransformers.Add(transformer); + return this; + } + + /// + /// Registers a given delegate as a document transformer on the current instance. + /// + /// The delegate representing the document transformer. + /// The instance for further customization. + public OpenApiOptions UseTransformer(Func transformer) + { + ArgumentNullException.ThrowIfNull(transformer, nameof(transformer)); + + DocumentTransformers.Add(new DelegateOpenApiDocumentTransformer(transformer)); + return this; + } + + /// + /// Registers a given delegate as an operation transformer on the current instance. + /// + /// The delegate representing the operation transformer. + /// The instance for further customization. + public OpenApiOptions UseOperationTransformer(Func transformer) + { + ArgumentNullException.ThrowIfNull(transformer, nameof(transformer)); + + DocumentTransformers.Add(new DelegateOpenApiDocumentTransformer(transformer)); + return this; + } +} diff --git a/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs new file mode 100644 index 000000000000..63db26908d40 --- /dev/null +++ b/src/OpenApi/src/Transformers/DelegateOpenApiDocumentTransformer.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class DelegateOpenApiDocumentTransformer : IOpenApiDocumentTransformer +{ + // Since there's a finite set of operation types that can be included in a given + // OpenApiPaths, we can pre-allocate an array of these types and use a direct + // lookup on the OpenApiPaths dictionary to avoid allocating an enumerator + // over the KeyValuePairs in OpenApiPaths. + private static readonly OperationType[] _operationTypes = [ + OperationType.Get, + OperationType.Post, + OperationType.Put, + OperationType.Delete, + OperationType.Options, + OperationType.Head, + OperationType.Patch, + OperationType.Trace + ]; + private readonly Func? _documentTransformer; + private readonly Func? _operationTransformer; + + public DelegateOpenApiDocumentTransformer(Func transformer) + { + _documentTransformer = transformer; + } + + public DelegateOpenApiDocumentTransformer(Func transformer) + { + _operationTransformer = transformer; + } + + public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + if (_documentTransformer != null) + { + await _documentTransformer(document, context, cancellationToken); + } + + if (_operationTransformer != null) + { + var documentService = context.ApplicationServices.GetRequiredKeyedService(context.DocumentName); + foreach (var pathItem in document.Paths.Values) + { + for (var i = 0; i < _operationTypes.Length; i++) + { + var operationType = _operationTypes[i]; + if (!pathItem.Operations.TryGetValue(operationType, out var operation)) + { + continue; + } + + if (operation.Extensions.TryGetValue(OpenApiConstants.DescriptionId, out var descriptionIdExtension) && + descriptionIdExtension is OpenApiString { Value: var descriptionId } && + documentService.TryGetCachedOperationTransformerContext(descriptionId, out var operationContext)) + { + await _operationTransformer(operation, operationContext, cancellationToken); + } + else + { + // If the cached operation transformer context was not found, throw an exception. + // This can occur if the `x-aspnetcore-id` extension attribute was removed by the + // user in another operation transformer or if the lookup for operation transformer + // context resulted in a cache miss. As an alternative here, we could just to implement + // the "slow-path" and look up the ApiDescription associated with the OpenApiOperation + // using the OperationType and given path, but we'll avoid this for now. + throw new InvalidOperationException("Cached operation transformer context not found. Please ensure that the operation contains the `x-aspnetcore-id` extension attribute."); + } + } + } + } + } +} diff --git a/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs new file mode 100644 index 000000000000..c12f249a1939 --- /dev/null +++ b/src/OpenApi/src/Transformers/IOpenApiDocumentTransformer.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Represents a transformer that can be used to modify an OpenAPI document. +/// +public interface IOpenApiDocumentTransformer +{ + /// + /// Transforms the specified OpenAPI document. + /// + /// The to modify. + /// The associated with the . + /// The cancellation token to use. + /// The task object representing the asynchronous operation. + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken); +} diff --git a/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs b/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs new file mode 100644 index 000000000000..c47638bedf93 --- /dev/null +++ b/src/OpenApi/src/Transformers/OpenApiDocumentTransformerContext.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc.ApiExplorer; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Represents the context in which an OpenAPI document transformer is executed. +/// +public sealed class OpenApiDocumentTransformerContext +{ + /// + /// Gets the name of the associated OpenAPI document. + /// + public required string DocumentName { get; init; } + + /// + /// Gets the API description groups associated with current document. + /// + public required IReadOnlyList DescriptionGroups { get; init; } + + /// + /// Gets the application services associated with current document. + /// + public required IServiceProvider ApplicationServices { get; init; } +} diff --git a/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs b/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs new file mode 100644 index 000000000000..49d76a0191e6 --- /dev/null +++ b/src/OpenApi/src/Transformers/OpenApiOperationTransformerContext.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc.ApiExplorer; + +namespace Microsoft.AspNetCore.OpenApi; + +/// +/// Represents the context in which an OpenAPI operation transformer is executed. +/// +public sealed class OpenApiOperationTransformerContext +{ + /// + /// Gets the name of the associated OpenAPI document. + /// + public required string DocumentName { get; init; } + + /// + /// Gets the API description associated with target operation. + /// + public required ApiDescription Description { get; init; } + + /// + /// Gets the application services associated with the current document the target operation is in. + /// + public required IServiceProvider ApplicationServices { get; init; } +} diff --git a/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs b/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs new file mode 100644 index 000000000000..4883b38bce76 --- /dev/null +++ b/src/OpenApi/src/Transformers/TypeBasedOpenApiDocumentTransformer.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal sealed class TypeBasedOpenApiDocumentTransformer(Type transformerType) : IOpenApiDocumentTransformer +{ + private readonly ObjectFactory _transformerFactory = ActivatorUtilities.CreateFactory(transformerType, []); + + public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + var transformer = _transformerFactory.Invoke(context.ApplicationServices, []) as IOpenApiDocumentTransformer; + Debug.Assert(transformer != null, $"The type {transformerType} does not implement {nameof(IOpenApiDocumentTransformer)}."); + try + { + await transformer.TransformAsync(document, context, cancellationToken); + } + finally + { + if (transformer is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else if (transformer is IDisposable disposable) + { + disposable.Dispose(); + } + } + } +} diff --git a/src/OpenApi/test/Extensions/ApiDescriptionExtensionsTests.cs b/src/OpenApi/test/Extensions/ApiDescriptionExtensionsTests.cs new file mode 100644 index 000000000000..cc0d4e872c9a --- /dev/null +++ b/src/OpenApi/test/Extensions/ApiDescriptionExtensionsTests.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OpenApi.Models; + +public class ApiDescriptionExtensionsTests +{ + [Theory] + [InlineData("api/todos", "/api/todos")] + [InlineData("api/todos/{id}", "/api/todos/{id}")] + [InlineData("api/todos/{id:int:min(10)}", "/api/todos/{id}")] + [InlineData("{a}/{b}/{c=19}", "/{a}/{b}/{c}")] + [InlineData("{a}/{b}/{c?}", "/{a}/{b}/{c}")] + [InlineData("{a:int}/{b}/{c:int}", "/{a}/{b}/{c}")] + [InlineData("", "/")] + [InlineData("api", "/api")] + [InlineData("{p1}/{p2}.{p3?}", "/{p1}/{p2}.{p3}")] + public void MapRelativePathToItemPath_ReturnsItemPathForApiDescription(string relativePath, string expectedItemPath) + { + // Arrange + var apiDescription = new ApiDescription + { + RelativePath = relativePath + }; + + // Act + var itemPath = apiDescription.MapRelativePathToItemPath(); + + // Assert + Assert.Equal(expectedItemPath, itemPath); + } + + [Theory] + [InlineData("GET", OperationType.Get)] + [InlineData("POST", OperationType.Post)] + [InlineData("PUT", OperationType.Put)] + [InlineData("DELETE", OperationType.Delete)] + [InlineData("PATCH", OperationType.Patch)] + [InlineData("HEAD", OperationType.Head)] + [InlineData("OPTIONS", OperationType.Options)] + [InlineData("TRACE", OperationType.Trace)] + [InlineData("gEt", OperationType.Get)] + public void ToOperationType_ReturnsOperationTypeForApiDescription(string httpMethod, OperationType expectedOperationType) + { + // Arrange + var apiDescription = new ApiDescription + { + HttpMethod = httpMethod + }; + + // Act + var operationType = apiDescription.GetOperationType(); + + // Assert + Assert.Equal(expectedOperationType, operationType); + } + + [Theory] + [InlineData("UNKNOWN")] + [InlineData("unknown")] + public void ToOperationType_ThrowsForUnknownHttpMethod(string methodName) + { + // Arrange + var apiDescription = new ApiDescription + { + HttpMethod = methodName + }; + + // Act & Assert + var exception = Assert.Throws(() => apiDescription.GetOperationType()); + Assert.Equal($"Unsupported HTTP method: {methodName}", exception.Message); + } +} diff --git a/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs b/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs new file mode 100644 index 000000000000..d64d6e94be63 --- /dev/null +++ b/src/OpenApi/test/Extensions/OpenApiEndpointRouteBuilderExtensionsTests.cs @@ -0,0 +1,171 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.AspNetCore.Routing; +using static Microsoft.AspNetCore.OpenApi.Tests.OpenApiOperationGeneratorTests; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Readers; +using System.Text; + +public class OpenApiEndpointRouteBuilderExtensionsTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public void MapOpenApi_ReturnsEndpointConventionBuilder() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + + // Act + var returnedBuilder = builder.MapOpenApi(); + + // Assert + Assert.IsAssignableFrom(returnedBuilder); + } + + [Fact] + public void MapOpenApi_SupportsCustomizingPath() + { + // Arrange + var expectedPath = "/custom/{documentName}/openapi.json"; + var serviceProvider = CreateServiceProvider(); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + + // Act + builder.MapOpenApi(expectedPath); + + // Assert + var generatedEndpoint = Assert.IsType(builder.DataSources.First().Endpoints.First()); + Assert.Equal(expectedPath, generatedEndpoint.RoutePattern.RawText); + } + + [Fact] + public async Task MapOpenApi_ReturnsRenderedDocument() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + builder.MapOpenApi(); + var context = new DefaultHttpContext(); + var responseBodyStream = new MemoryStream(); + context.Response.Body = responseBodyStream; + context.RequestServices = serviceProvider; + context.Request.RouteValues.Add("documentName", "v1"); + var endpoint = builder.DataSources.First().Endpoints.First(); + + // Act + var requestDelegate = endpoint.RequestDelegate; + await requestDelegate(context); + + // Assert + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + ValidateOpenApiDocument(responseBodyStream, document => + { + Assert.Equal("OpenApiEndpointRouteBuilderExtensionsTests | v1", document.Info.Title); + Assert.Equal("1.0.0", document.Info.Version); + }); + } + + [Fact] + public async Task MapOpenApi_ReturnsDefaultDocumentIfNoNameProvided() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + builder.MapOpenApi("/openapi.json"); + var context = new DefaultHttpContext(); + var responseBodyStream = new MemoryStream(); + context.Response.Body = responseBodyStream; + context.RequestServices = serviceProvider; + var endpoint = builder.DataSources.First().Endpoints.First(); + + // Act + var requestDelegate = endpoint.RequestDelegate; + await requestDelegate(context); + + // Assert + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + ValidateOpenApiDocument(responseBodyStream, document => + { + Assert.Equal("OpenApiEndpointRouteBuilderExtensionsTests | v1", document.Info.Title); + Assert.Equal("1.0.0", document.Info.Version); + }); + } + + [Fact] + public async Task MapOpenApi_Returns404ForUnresolvedDocument() + { + // Arrange + var serviceProvider = CreateServiceProvider(); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + builder.MapOpenApi(); + var context = new DefaultHttpContext(); + var responseBodyStream = new MemoryStream(); + context.Response.Body = responseBodyStream; + context.RequestServices = serviceProvider; + context.Request.RouteValues.Add("documentName", "v2"); + var endpoint = builder.DataSources.First().Endpoints.First(); + + // Act + var requestDelegate = endpoint.RequestDelegate; + await requestDelegate(context); + + // Assert + Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode); + Assert.Equal("No OpenAPI document with the name 'v2' was found.", Encoding.UTF8.GetString(responseBodyStream.ToArray())); + } + + [Fact] + public async Task MapOpenApi_ReturnsDocumentIfNameProvidedInQuery() + { + // Arrange + var documentName = "v2"; + var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiEndpointRouteBuilderExtensionsTests) }; + var serviceProviderIsService = new ServiceProviderIsService(); + var serviceProvider = CreateServiceProvider(documentName); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + builder.MapOpenApi("/openapi.json"); + var context = new DefaultHttpContext(); + var responseBodyStream = new MemoryStream(); + context.Response.Body = responseBodyStream; + context.RequestServices = serviceProvider; + context.Request.QueryString = new QueryString($"?documentName={documentName}"); + var endpoint = builder.DataSources.First().Endpoints.First(); + + // Act + var requestDelegate = endpoint.RequestDelegate; + await requestDelegate(context); + + // Assert + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + ValidateOpenApiDocument(responseBodyStream, document => + { + Assert.Equal($"OpenApiEndpointRouteBuilderExtensionsTests | {documentName}", document.Info.Title); + Assert.Equal("1.0.0", document.Info.Version); + }); + } + + private static void ValidateOpenApiDocument(MemoryStream documentStream, Action action) + { + var document = new OpenApiStringReader().Read(Encoding.UTF8.GetString(documentStream.ToArray()), out var diagnostic); + Assert.Empty(diagnostic.Errors); + action(document); + } + + private static IServiceProvider CreateServiceProvider(string documentName = Microsoft.AspNetCore.OpenApi.OpenApiConstants.DefaultDocumentName) + { + var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiEndpointRouteBuilderExtensionsTests) }; + var serviceProviderIsService = new ServiceProviderIsService(); + var serviceProvider = new ServiceCollection() + .AddSingleton(serviceProviderIsService) + .AddSingleton(hostEnvironment) + .AddSingleton(CreateApiDescriptionGroupCollectionProvider()) + .AddOpenApi(documentName) + .BuildServiceProvider(); + return serviceProvider; + } +} diff --git a/src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs b/src/OpenApi/test/Extensions/OpenApiRouteHandlerBuilderExtensionTests.cs similarity index 100% rename from src/OpenApi/test/OpenApiRouteHandlerBuilderExtensionTests.cs rename to src/OpenApi/test/Extensions/OpenApiRouteHandlerBuilderExtensionTests.cs diff --git a/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs b/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000000..d113642181ef --- /dev/null +++ b/src/OpenApi/test/Extensions/OpenApiServiceCollectionExtensionsTests.cs @@ -0,0 +1,192 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.ApiDescriptions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi; + +public class OpenApiServiceCollectionExtensions +{ + [Fact] + public void AddOpenApi_WithDocumentName_ReturnsServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v2"; + + // Act + var returnedServices = services.AddOpenApi(documentName); + + // Assert + Assert.IsAssignableFrom(returnedServices); + } + + [Fact] + public void AddOpenApi_WithDocumentName_RegistersServices() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v2"; + + // Act + services.AddOpenApi(documentName); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + Assert.Equal(documentName, namedOption.DocumentName); + } + + [Fact] + public void AddOpenApi_WithDocumentNameAndConfigureOptions_ReturnsServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v2"; + + // Act + var returnedServices = services.AddOpenApi(documentName, options => { }); + + // Assert + Assert.IsAssignableFrom(returnedServices); + } + + [Fact] + public void AddOpenApi_WithDocumentNameAndConfigureOptions_RegistersServices() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v2"; + + // Act + services.AddOpenApi(documentName, options => { }); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + Assert.Equal(documentName, namedOption.DocumentName); + } + + [Fact] + public void AddOpenApi_WithoutDocumentName_ReturnsServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var returnedServices = services.AddOpenApi(); + + // Assert + Assert.IsAssignableFrom(returnedServices); + } + + [Fact] + public void AddOpenApi_WithoutDocumentName_RegistersServices() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v1"; + + // Act + services.AddOpenApi(); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + Assert.Equal(documentName, namedOption.DocumentName); + } + + [Fact] + public void AddOpenApi_WithConfigureOptions_ReturnsServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var returnedServices = services.AddOpenApi(options => { }); + + // Assert + Assert.IsAssignableFrom(returnedServices); + } + + [Fact] + public void AddOpenApi_WithConfigureOptions_RegistersServices() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v1"; + + // Act + services.AddOpenApi(options => { }); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + Assert.Equal(documentName, namedOption.DocumentName); + } + + [Fact] + public void AddOpenApi_WithDuplicateDocumentNames_UsesLastRegistration() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v2"; + + // Act + services + .AddOpenApi(documentName, options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi2_0) + .AddOpenApi(documentName, options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + Assert.Equal(documentName, namedOption.DocumentName); + // Verify last registration is used + Assert.Equal(OpenApiSpecVersion.OpenApi3_0, namedOption.OpenApiVersion); + } + + [Fact] + public void AddOpenApi_WithDuplicateDocumentNames_UsesLastRegistration_ValidateOptionsOverride() + { + // Arrange + var services = new ServiceCollection(); + var documentName = "v2"; + + // Act + services + .AddOpenApi(documentName, options => options.OpenApiVersion = OpenApiSpecVersion.OpenApi2_0) + .AddOpenApi(documentName); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiComponentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(OpenApiDocumentService) && sd.Lifetime == ServiceLifetime.Singleton && (string)sd.ServiceKey == documentName); + Assert.Contains(services, sd => sd.ServiceType == typeof(IDocumentProvider) && sd.Lifetime == ServiceLifetime.Singleton); + var options = serviceProvider.GetRequiredService>(); + var namedOption = options.Get(documentName); + Assert.Equal(documentName, namedOption.DocumentName); + Assert.Equal(OpenApiSpecVersion.OpenApi2_0, namedOption.OpenApiVersion); + } +} diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj index 507534ae26c1..edd26efec6e1 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests.csproj @@ -10,6 +10,7 @@ + @@ -17,4 +18,8 @@ + + + + diff --git a/src/OpenApi/test/Services/OpenApiDocumentProviderTests.cs b/src/OpenApi/test/Services/OpenApiDocumentProviderTests.cs new file mode 100644 index 000000000000..04e31fdec7ba --- /dev/null +++ b/src/OpenApi/test/Services/OpenApiDocumentProviderTests.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.ApiDescriptions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Readers; +using static Microsoft.AspNetCore.OpenApi.Tests.OpenApiOperationGeneratorTests; + +public class OpenApiDocumentProviderTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task GenerateAsync_ReturnsDocument() + { + // Arrange + var documentName = "v1"; + var serviceProvider = CreateServiceProvider([documentName]); + var documentProvider = new OpenApiDocumentProvider(serviceProvider); + var stringWriter = new StringWriter(); + + // Act + await documentProvider.GenerateAsync(documentName, stringWriter); + + // Assert + ValidateOpenApiDocument(stringWriter, document => + { + Assert.Equal($"{nameof(OpenApiDocumentProviderTests)} | {documentName}", document.Info.Title); + Assert.Equal("1.0.0", document.Info.Version); + }); + } + + [Fact] + public void GetDocumentNames_ReturnsAllRegisteredDocumentName() + { + // Arrange + var serviceProvider = CreateServiceProvider(["v2", "internal", "public", "v1"]); + var documentProvider = new OpenApiDocumentProvider(serviceProvider); + + // Act + var documentNames = documentProvider.GetDocumentNames(); + + // Assert + Assert.Equal(4, documentNames.Count()); + Assert.Collection(documentNames, + x => Assert.Equal("v2", x), + x => Assert.Equal("internal", x), + x => Assert.Equal("public", x), + x => Assert.Equal("v1", x)); + } + + private static void ValidateOpenApiDocument(StringWriter stringWriter, Action action) + { + var document = new OpenApiStringReader().Read(stringWriter.ToString(), out var diagnostic); + Assert.Empty(diagnostic.Errors); + action(document); + } + + private static IServiceProvider CreateServiceProvider(string[] documentNames) + { + var hostEnvironment = new HostEnvironment() { ApplicationName = nameof(OpenApiDocumentProviderTests) }; + var serviceProviderIsService = new ServiceProviderIsService(); + var serviceCollection = new ServiceCollection() + .AddSingleton(serviceProviderIsService) + .AddSingleton(hostEnvironment) + .AddSingleton(CreateApiDescriptionGroupCollectionProvider()); + foreach (var documentName in documentNames) + { + serviceCollection.AddOpenApi(documentName); + } + var serviceProvider = serviceCollection.BuildServiceProvider(); + return serviceProvider; + } +} diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs new file mode 100644 index 000000000000..3f201381c0f7 --- /dev/null +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Info.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting.Internal; +using Microsoft.Extensions.Options; +using Moq; + +public partial class OpenApiDocumentServiceTests +{ + [Fact] + public void GetOpenApiInfo_RespectsHostEnvironmentName() + { + // Arrange + var hostEnvironment = new HostingEnvironment + { + ApplicationName = "TestApplication" + }; + var docService = new OpenApiDocumentService( + "v1", + new Mock().Object, + hostEnvironment, + new Mock>().Object, + new Mock().Object); + + // Act + var info = docService.GetOpenApiInfo(); + + // Assert + Assert.Equal("TestApplication | v1", info.Title); + } + + [Fact] + public void GetOpenApiInfo_RespectsDocumentName() + { + // Arrange + var hostEnvironment = new HostingEnvironment + { + ApplicationName = "TestApplication" + }; + var docService = new OpenApiDocumentService( + "v2", + new Mock().Object, + hostEnvironment, + new Mock>().Object, + new Mock().Object); + + // Act + var info = docService.GetOpenApiInfo(); + + // Assert + Assert.Equal("TestApplication | v2", info.Title); + } +} diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Operations.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Operations.cs new file mode 100644 index 000000000000..18f5023f9b0a --- /dev/null +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Operations.cs @@ -0,0 +1,181 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.OpenApi.Models; + +public partial class OpenApiDocumentServiceTests +{ + [Fact] + public async Task GetOpenApiOperation_CapturesSummary() + { + // Arrange + var builder = CreateBuilder(); + var summary = "Get all todos"; + + // Act + builder.MapGet("/api/todos", () => { }).WithSummary(summary); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api/todos"].Operations[OperationType.Get]; + Assert.Equal(summary, operation.Summary); + }); + } + + [Fact] + public async Task GetOpenApiOperation_CapturesLastSummary() + { + // Arrange + var builder = CreateBuilder(); + var summary = "Get all todos"; + + // Act + builder.MapGet("/api/todos", () => { }).WithSummary(summary).WithSummary(summary + "1"); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api/todos"].Operations[OperationType.Get]; + Assert.Equal(summary + "1", operation.Summary); + }); + } + + [Fact] + public async Task GetOpenApiOperation_CapturesDescription() + { + // Arrange + var builder = CreateBuilder(); + var description = "Returns all the todos provided in an array."; + + // Act + builder.MapGet("/api/todos", () => { }).WithDescription(description); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api/todos"].Operations[OperationType.Get]; + Assert.Equal(description, operation.Description); + }); + } + + [Fact] + public async Task GetOpenApiOperation_CapturesDescriptionLastDescription() + { + // Arrange + var builder = CreateBuilder(); + var description = "Returns all the todos provided in an array."; + + // Act + builder.MapGet("/api/todos", () => { }).WithDescription(description).WithDescription(description + "1"); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api/todos"].Operations[OperationType.Get]; + Assert.Equal(description + "1", operation.Description); + }); + } + + [Fact] + public async Task GetOpenApiOperation_CapturesTags() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }).WithTags(["todos", "v1"]); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api/todos"].Operations[OperationType.Get]; + Assert.Collection(operation.Tags, tag => + { + Assert.Equal("todos", tag.Name); + }, + tag => + { + Assert.Equal("v1", tag.Name); + }); + }); + } + + [Fact] + public async Task GetOpenApiOperation_CapturesTagsLastTags() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }).WithTags(["todos", "v1"]).WithTags(["todos", "v2"]); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api/todos"].Operations[OperationType.Get]; + Assert.Collection(operation.Tags, tag => + { + Assert.Equal("todos", tag.Name); + }, + tag => + { + Assert.Equal("v2", tag.Name); + }); + }); + } + + [Fact] + public async Task GetOpenApiOperation_SetsDefaultValueForTags() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = document.Paths["/api/todos"].Operations[OperationType.Get]; + Assert.Collection(document.Tags, tag => + { + Assert.Equal(nameof(OpenApiDocumentServiceTests), tag.Name); + }); + Assert.Collection(operation.Tags, tag => + { + Assert.Equal(nameof(OpenApiDocumentServiceTests), tag.Name); + }); + }); + } + + [Fact] + public async Task GetOpenApiOperation_CapturesTagsInDocument() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }).WithTags(["todos", "v1"]); + builder.MapGet("/api/users", () => { }).WithTags(["users", "v1"]); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + Assert.Collection(document.Tags, tag => + { + Assert.Equal("todos", tag.Name); + }, + tag => + { + Assert.Equal("v1", tag.Name); + }, + tag => + { + Assert.Equal("users", tag.Name); + }); + }); + } +} diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Parameters.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Parameters.cs new file mode 100644 index 000000000000..3610923576e1 --- /dev/null +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Parameters.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Models; + +public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task GetOpenApiParameters_GeneratesParameterLocationCorrectly() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos/{id}", (int id) => { }); + builder.MapGet("/api/todos", (int id) => { }); + builder.MapGet("/api", ([FromHeader(Name = "X-Header")] string header) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var pathParameter = Assert.Single(document.Paths["/api/todos/{id}"].Operations[OperationType.Get].Parameters); + Assert.Equal("id", pathParameter.Name); + Assert.Equal(ParameterLocation.Path, pathParameter.In); + + var queryParameter = Assert.Single(document.Paths["/api/todos"].Operations[OperationType.Get].Parameters); + Assert.Equal("id", queryParameter.Name); + Assert.Equal(ParameterLocation.Query, queryParameter.In); + + var headerParameter = Assert.Single(document.Paths["/api"].Operations[OperationType.Get].Parameters); + Assert.Equal("X-Header", headerParameter.Name); + Assert.Equal(ParameterLocation.Header, headerParameter.In); + }); + } + +#nullable enable + [Fact] + public async Task GetOpenApiParameters_RouteParametersAreAlwaysRequired() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos/{id}", (int id) => { }); + builder.MapGet("/api/todos/{guid}", (Guid? guid) => { }); + builder.MapGet("/api/todos/{isCompleted}", (bool isCompleted = false) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var pathParameter = Assert.Single(document.Paths["/api/todos/{id}"].Operations[OperationType.Get].Parameters); + Assert.Equal("id", pathParameter.Name); + Assert.True(pathParameter.Required); + var guidParameter = Assert.Single(document.Paths["/api/todos/{guid}"].Operations[OperationType.Get].Parameters); + Assert.Equal("guid", guidParameter.Name); + Assert.True(guidParameter.Required); + var isCompletedParameter = Assert.Single(document.Paths["/api/todos/{isCompleted}"].Operations[OperationType.Get].Parameters); + Assert.Equal("isCompleted", isCompletedParameter.Name); + Assert.True(isCompletedParameter.Required); + }); + } + + [Fact] + public async Task GetOpenApiParameters_SetsRequirednessForQueryParameters() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", (int id) => { }); + builder.MapGet("/api/users", (int? id) => { }); + builder.MapGet("/api/projects", (int id = 1) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var queryParameter = Assert.Single(document.Paths["/api/todos"].Operations[OperationType.Get].Parameters); + Assert.Equal("id", queryParameter.Name); + Assert.True(queryParameter.Required); + var nullableQueryParameter = Assert.Single(document.Paths["/api/users"].Operations[OperationType.Get].Parameters); + Assert.Equal("id", nullableQueryParameter.Name); + Assert.False(nullableQueryParameter.Required); + var defaultQueryParameter = Assert.Single(document.Paths["/api/projects"].Operations[OperationType.Get].Parameters); + Assert.Equal("id", defaultQueryParameter.Name); + Assert.False(defaultQueryParameter.Required); + }); + } + + [Fact] + public async Task GetOpenApiParameters_SetsRequirednessForHeaderParameters() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", ([FromHeader(Name = "X-Header")] string header) => { }); + builder.MapGet("/api/users", ([FromHeader(Name = "X-Header")] Guid? header) => { }); + builder.MapGet("/api/projects", ([FromHeader(Name = "X-Header")] string header = "0000-0000-0000-0000") => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var headerParameter = Assert.Single(document.Paths["/api/todos"].Operations[OperationType.Get].Parameters); + Assert.Equal("X-Header", headerParameter.Name); + Assert.True(headerParameter.Required); + var nullableHeaderParameter = Assert.Single(document.Paths["/api/users"].Operations[OperationType.Get].Parameters); + Assert.Equal("X-Header", nullableHeaderParameter.Name); + Assert.False(nullableHeaderParameter.Required); + var defaultHeaderParameter = Assert.Single(document.Paths["/api/projects"].Operations[OperationType.Get].Parameters); + Assert.Equal("X-Header", defaultHeaderParameter.Name); + Assert.False(defaultHeaderParameter.Required); + }); + } +#nullable restore + + [Fact] + public async Task GetOpenApiRequestBody_SkipsRequestBodyParameters() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/api/users", (IFormFile formFile, IFormFileCollection formFiles) => { }); + builder.MapPost("/api/todos", (Todo todo) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var usersOperation = document.Paths["/api/users"].Operations[OperationType.Post]; + Assert.Null(usersOperation.Parameters); + var todosOperation = document.Paths["/api/todos"].Operations[OperationType.Post]; + Assert.Null(todosOperation.Parameters); + }); + } +} diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Paths.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Paths.cs new file mode 100644 index 000000000000..a74353f762f0 --- /dev/null +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Paths.cs @@ -0,0 +1,171 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.OpenApi.Models; + +public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task GetOpenApiPaths_ReturnsPaths() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }); + builder.MapGet("/api/users", () => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/api/todos", path.Key); + Assert.Collection(path.Value.Operations.OrderBy(o => o.Key), + operation => + { + Assert.Equal(OperationType.Get, operation.Key); + }); + }, + path => + { + Assert.Equal("/api/users", path.Key); + Assert.Collection(path.Value.Operations.OrderBy(o => o.Key), + operation => + { + Assert.Equal(OperationType.Get, operation.Key); + }); + }); + }); + } + + [Fact] + public async Task GetOpenApiPaths_RespectsShouldInclude() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }).WithMetadata(new EndpointGroupNameAttribute("v1")); + builder.MapGet("/api/users", () => { }).WithMetadata(new EndpointGroupNameAttribute("v2")); + + // Assert -- The default `ShouldInclude` implementation only includes endpoints that + // match the document name. Since we don't set a document name explicitly, this will + // match against the default document name ("v1") and the document will only contain + // the endpoint with that group name. + await VerifyOpenApiDocument(builder, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/api/todos", path.Key); + } + ); + }); + } + + [Fact] + public async Task GetOpenApiPaths_RespectsSamePaths() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }); + builder.MapPost("/api/todos", () => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/api/todos", path.Key); + Assert.Collection(path.Value.Operations.OrderBy(o => o.Key), + operation => + { + Assert.Equal(OperationType.Get, operation.Key); + }, + operation => + { + Assert.Equal(OperationType.Post, operation.Key); + }); + } + ); + }); + } + + [Fact] + public async Task GetOpenApiPaths_HandlesRouteParameters() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos/{id}", () => { }); + builder.MapPost("/api/todos/{id}", () => { }); + builder.MapMethods("/api/todos/{id}", ["PATCH", "PUT"], () => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/api/todos/{id}", path.Key); + Assert.Collection(path.Value.Operations.OrderBy(o => o.Key), + operation => + { + Assert.Equal(OperationType.Get, operation.Key); + }, + operation => + { + Assert.Equal(OperationType.Put, operation.Key); + }, + operation => + { + Assert.Equal(OperationType.Post, operation.Key); + }, + operation => + { + Assert.Equal(OperationType.Patch, operation.Key); + }); + } + ); + }); + } + + [Fact] + public async Task GetOpenApiPaths_HandlesRouteConstraints() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos/{id:int}", () => { }); + builder.MapPost("/api/todos/{id:int}", () => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/api/todos/{id}", path.Key); + Assert.Collection(path.Value.Operations.OrderBy(o => o.Key), + operation => + { + Assert.Equal(OperationType.Get, operation.Key); + }, + operation => + { + Assert.Equal(OperationType.Post, operation.Key); + }); + } + ); + }); + } +} diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.RequestBody.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.RequestBody.cs new file mode 100644 index 000000000000..16604a533fe8 --- /dev/null +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.RequestBody.cs @@ -0,0 +1,391 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Models; + +public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task GetRequestBody_VerifyDefaultFormEncoding() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", (IFormFile formFile) => { }); + + // Assert -- The defaults for form encoding are Explode = true and Style = Form + // which align with the encoding formats that are used by ASP.NET Core's binding layer. + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + var encoding = content.Value.Encoding["multipart/form-data"]; + Assert.True(encoding.Explode); + Assert.Equal(ParameterStyle.Form, encoding.Style); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetRequestBody_HandlesIFormFile(bool withAttribute) + { + // Arrange + var builder = CreateBuilder(); + + // Act + if (withAttribute) + { + builder.MapPost("/", ([FromForm] IFormFile formFile) => { }); + } + else + { + builder.MapPost("/", (IFormFile formFile) => { }); + } + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.NotNull(content.Value.Schema.Properties); + Assert.Contains("formFile", content.Value.Schema.Properties); + var formFileProperty = content.Value.Schema.Properties["formFile"]; + Assert.Equal("string", formFileProperty.Type); + Assert.Equal("binary", formFileProperty.Format); + }); + } + +#nullable enable + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetRequestBody_HandlesIFormFileOptionality(bool isOptional) + { + // Arrange + var builder = CreateBuilder(); + + // Act + if (isOptional) + { + builder.MapPost("/", (IFormFile? formFile) => { }); + } + else + { + builder.MapPost("/", (IFormFile formFile) => { }); + } + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.Equal(!isOptional, operation.RequestBody.Required); + }); + } +#nullable restore + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetRequestBody_HandlesIFormFileCollection(bool withAttribute) + { + // Arrange + var builder = CreateBuilder(); + + // Act + if (withAttribute) + { + builder.MapPost("/", ([FromForm] IFormFileCollection formFileCollection) => { }); + } + else + { + builder.MapPost("/", (IFormFileCollection formFileCollection) => { }); + } + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.NotNull(content.Value.Schema.Properties); + Assert.Contains("formFileCollection", content.Value.Schema.Properties); + var formFileProperty = content.Value.Schema.Properties["formFileCollection"]; + Assert.Equal("array", formFileProperty.Type); + Assert.Equal("string", formFileProperty.Items.Type); + Assert.Equal("binary", formFileProperty.Items.Format); + }); + } + +#nullable enable + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetRequestBody_HandlesIFormFileCollectionOptionality(bool isOptional) + { + // Arrange + var builder = CreateBuilder(); + + // Act + if (isOptional) + { + builder.MapPost("/", (IFormFileCollection? formFile) => { }); + } + else + { + builder.MapPost("/", (IFormFileCollection formFile) => { }); + } + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.Equal(!isOptional, operation.RequestBody.Required); + }); + } +#nullable restore + + [Fact] + public async Task GetRequestBody_MultipleFormFileParameters() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", (IFormFile formFile1, IFormFile formFile2) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("multipart/form-data", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.NotNull(content.Value.Schema.Properties); + Assert.Contains("formFile1", content.Value.Schema.Properties); + Assert.Contains("formFile2", content.Value.Schema.Properties); + var formFile1Property = content.Value.Schema.Properties["formFile1"]; + Assert.Equal("string", formFile1Property.Type); + Assert.Equal("binary", formFile1Property.Format); + var formFile2Property = content.Value.Schema.Properties["formFile2"]; + Assert.Equal("string", formFile2Property.Type); + Assert.Equal("binary", formFile2Property.Format); + }); + } + + [Fact] + public async Task GetRequestBody_IFormFileHandlesAcceptsMetadata() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", (IFormFile formFile) => { }).Accepts(typeof(IFormFile), "application/magic-foo-content-type"); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/magic-foo-content-type", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.NotNull(content.Value.Schema.Properties); + Assert.Contains("formFile", content.Value.Schema.Properties); + var formFileProperty = content.Value.Schema.Properties["formFile"]; + Assert.Equal("string", formFileProperty.Type); + Assert.Equal("binary", formFileProperty.Format); + }); + } + + [Fact] + public async Task GetRequestBody_IFormFileHandlesConsumesAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", [Consumes(typeof(IFormFile), "application/magic-foo-content-type")] (IFormFile formFile) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/magic-foo-content-type", content.Key); + Assert.Equal("object", content.Value.Schema.Type); + Assert.NotNull(content.Value.Schema.Properties); + Assert.Contains("formFile", content.Value.Schema.Properties); + var formFileProperty = content.Value.Schema.Properties["formFile"]; + Assert.Equal("string", formFileProperty.Type); + Assert.Equal("binary", formFileProperty.Format); + }); + } + + [Fact] + public async Task GetRequestBody_HandlesJsonBody() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", (TodoWithDueDate name) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/json", content.Key); + }); + } + +#nullable enable + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetRequestBody_HandlesJsonBodyOptionality(bool isOptional) + { + // Arrange + var builder = CreateBuilder(); + + // Act + if (isOptional) + { + builder.MapPost("/", (TodoWithDueDate? name) => { }); + } + else + { + builder.MapPost("/", (TodoWithDueDate name) => { }); + } + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.Equal(!isOptional, operation.RequestBody.Required); + }); + + } +#nullable restore + + [Fact] + public async Task GetRequestBody_HandlesJsonBodyWithAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", ([FromBody] string name) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.False(operation.RequestBody.Required); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/json", content.Key); + }); + } + + [Fact] + public async Task GetRequestBody_HandlesJsonBodyWithAcceptsMetadata() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", (string name) => { }).Accepts(typeof(string), "application/magic-foo-content-type"); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/magic-foo-content-type", content.Key); + }); + } + + [Fact] + public async Task GetRequestBody_HandlesJsonBodyWithConsumesAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", [Consumes(typeof(string), "application/magic-foo-content-type")] (string name) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.NotNull(operation.RequestBody); + Assert.NotNull(operation.RequestBody.Content); + var content = Assert.Single(operation.RequestBody.Content); + Assert.Equal("application/magic-foo-content-type", content.Key); + }); + } + + [Fact] + public async Task GetOpenApiRequestBody_SetsNullRequestBodyWithNoParameters() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapPost("/", (string name) => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var paths = Assert.Single(document.Paths.Values); + var operation = paths.Operations[OperationType.Post]; + Assert.Null(operation.RequestBody); + }); + } + +} diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Responses.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Responses.cs new file mode 100644 index 000000000000..640073eeebc9 --- /dev/null +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTests.Responses.cs @@ -0,0 +1,257 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Models; + +public partial class OpenApiDocumentServiceTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task GetOpenApiResponse_SupportsMultipleResponseViaAttributes() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", + [ProducesResponseType(typeof(TimeSpan), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + () => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + Assert.Collection(operation.Responses.OrderBy(r => r.Key), + response => + { + Assert.Equal("201", response.Key); + Assert.Equal("Created", response.Value.Description); + }, + response => + { + Assert.Equal("400", response.Key); + Assert.Equal("Bad Request", response.Value.Description); + }); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsProblemDetailsResponse() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status400BadRequest, typeof(ProblemDetails), ["application/json+problem"])); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("400", response.Key); + Assert.Equal("Bad Request", response.Value.Description); + Assert.Equal("application/json+problem", response.Value.Content.Keys.Single()); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsMultipleResponsesForStatusCode() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }) + // Simulates metadata provided by IEndpointMetadataProvider + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK)) + // Simulates metadata added via `Produces` call + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(string), ["text/plain"])); + + // Assert + await VerifyOpenApiDocument(builder, document => { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); + var content = Assert.Single(response.Value.Content); + Assert.Equal("text/plain", content.Key); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsMultipleResponseTypesWithTypeForStatusCode() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }) + // Simulates metadata provided by IEndpointMetadataProvider + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/json"])) + // Simulates metadata added via `Produces` call + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TodoWithDueDate), ["application/json"])); + + // Assert + await VerifyOpenApiDocument(builder, document => { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); + var content = Assert.Single(response.Value.Content); + Assert.Equal("application/json", content.Key); + // Todo: Check that this generates a schema using `oneOf`. + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsMultipleResponseTypesWitDifferentContentTypes() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/json", "application/xml"])); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); + Assert.Collection(response.Value.Content.OrderBy(c => c.Key), + content => + { + Assert.Equal("application/json", content.Key); + }, + content => + { + Assert.Equal("application/xml", content.Key); + }); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsDifferentResponseTypesWitDifferentContentTypes() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(TodoWithDueDate), ["application/json"])) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/xml"])); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); + Assert.Collection(response.Value.Content.OrderBy(c => c.Key), + content => + { + Assert.Equal("application/xml", content.Key); + }); + }); + } + + [Fact] + public async Task GetOpenApiResponse_ProducesDefaultResponse() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", () => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsMvcProducesAttribute() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", [Produces("application/json", "application/xml")] () => new Todo(1, "Test todo", false, DateTime.Now)); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal("200", response.Key); + Assert.Equal("OK", response.Value.Description); + Assert.Collection(response.Value.Content.OrderBy(c => c.Key), + content => + { + Assert.Equal("application/json", content.Key); + }, + content => + { + Assert.Equal("application/xml", content.Key); + }); + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsGeneratingDefaultResponseField() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", [ProducesDefaultResponseType(typeof(Error))] () => { }); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var response = Assert.Single(operation.Responses); + Assert.Equal(Microsoft.AspNetCore.OpenApi.OpenApiConstants.DefaultOpenApiResponseKey, response.Key); + Assert.Empty(response.Value.Description); + // Todo: Validate generated schema. + }); + } + + [Fact] + public async Task GetOpenApiResponse_SupportsGeneratingDefaultResponseWithSuccessResponse() + { + // Arrange + var builder = CreateBuilder(); + + // Act + builder.MapGet("/api/todos", [ProducesDefaultResponseType(typeof(Error))] () => { }) + .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(Todo), ["application/json"])); + + // Assert + await VerifyOpenApiDocument(builder, document => + { + var operation = Assert.Single(document.Paths["/api/todos"].Operations.Values); + var defaultResponse = operation.Responses[Microsoft.AspNetCore.OpenApi.OpenApiConstants.DefaultOpenApiResponseKey]; + Assert.NotNull(defaultResponse); + Assert.Empty(defaultResponse.Description); + var okResponse = operation.Responses["200"]; + Assert.NotNull(okResponse); + Assert.Equal("OK", okResponse.Description); + Assert.Equal("application/json", Assert.Single(okResponse.Content).Key); + // Todo: Validate generated schema. + }); + } +} diff --git a/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs b/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs new file mode 100644 index 000000000000..ac3bd3b6e3d4 --- /dev/null +++ b/src/OpenApi/test/Services/OpenApiDocumentServiceTestsBase.cs @@ -0,0 +1,139 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Moq; +using static Microsoft.AspNetCore.OpenApi.Tests.OpenApiOperationGeneratorTests; + +public abstract class OpenApiDocumentServiceTestBase +{ + public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, Action verifyOpenApiDocument) + => await VerifyOpenApiDocument(builder, new OpenApiOptions(), verifyOpenApiDocument); + + public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, OpenApiOptions openApiOptions, Action verifyOpenApiDocument) + { + var documentService = CreateDocumentService(builder, openApiOptions); + var document = await documentService.GetOpenApiDocumentAsync(); + verifyOpenApiDocument(document); + } + + internal static OpenApiDocumentService CreateDocumentService(IEndpointRouteBuilder builder, OpenApiOptions openApiOptions) + { + var context = new ApiDescriptionProviderContext([]); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var hostEnvironment = new HostEnvironment + { + ApplicationName = nameof(OpenApiDocumentServiceTests) + }; + var options = new Mock>(); + options.Setup(o => o.Get(It.IsAny())).Returns(openApiOptions); + + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + var apiDescriptionGroupCollectionProvider = CreateApiDescriptionGroupCollectionProvider(context.Results); + + var documentService = new OpenApiDocumentService("Test", apiDescriptionGroupCollectionProvider, hostEnvironment, options.Object, builder.ServiceProvider); + ((TestServiceProvider)builder.ServiceProvider).TestDocumentService = documentService; + + return documentService; + } + + public static IApiDescriptionGroupCollectionProvider CreateApiDescriptionGroupCollectionProvider(IList apiDescriptions = null) + { + var apiDescriptionGroup = new ApiDescriptionGroup("testGroupName", (apiDescriptions ?? Array.Empty()).AsReadOnly()); + var apiDescriptionGroupCollection = new ApiDescriptionGroupCollection([apiDescriptionGroup], 1); + var apiDescriptionGroupCollectionProvider = new Mock(); + apiDescriptionGroupCollectionProvider.Setup(p => p.ApiDescriptionGroups).Returns(apiDescriptionGroupCollection); + return apiDescriptionGroupCollectionProvider.Object; + } + + private static EndpointMetadataApiDescriptionProvider CreateEndpointMetadataApiDescriptionProvider(EndpointDataSource endpointDataSource) => new EndpointMetadataApiDescriptionProvider( + endpointDataSource, + new HostEnvironment { ApplicationName = nameof(OpenApiDocumentServiceTests) }, + new DefaultParameterPolicyFactory(Options.Create(new RouteOptions()), new TestServiceProvider()), + new ServiceProviderIsService()); + + internal static TestEndpointRouteBuilder CreateBuilder(IServiceCollection serviceCollection = null) + { + var serviceProvider = new TestServiceProvider(); + serviceProvider.SetInternalServiceProvider(serviceCollection ?? new ServiceCollection()); + return new TestEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + } + + internal class TestEndpointRouteBuilder : IEndpointRouteBuilder + { + public TestEndpointRouteBuilder(IApplicationBuilder applicationBuilder) + { + ApplicationBuilder = applicationBuilder ?? throw new ArgumentNullException(nameof(applicationBuilder)); + DataSources = new List(); + } + + public IApplicationBuilder ApplicationBuilder { get; } + + public IApplicationBuilder CreateApplicationBuilder() => ApplicationBuilder.New(); + + public ICollection DataSources { get; } + + public IServiceProvider ServiceProvider => ApplicationBuilder.ApplicationServices; + } + + private class TestServiceProvider : IServiceProvider, IKeyedServiceProvider + { + public static TestServiceProvider Instance { get; } = new TestServiceProvider(); + private IKeyedServiceProvider _serviceProvider; + internal OpenApiDocumentService TestDocumentService { get; set; } + internal OpenApiComponentService TestComponentService { get; set; } = new OpenApiComponentService(); + + public void SetInternalServiceProvider(IServiceCollection serviceCollection) + { + _serviceProvider = serviceCollection.BuildServiceProvider(); + } + + public object GetKeyedService(Type serviceType, object serviceKey) + { + if (serviceType == typeof(OpenApiDocumentService)) + { + return TestDocumentService; + } + if (serviceType == typeof(OpenApiComponentService)) + { + return TestComponentService; + } + + return _serviceProvider.GetKeyedService(serviceType, serviceKey); + } + + public object GetRequiredKeyedService(Type serviceType, object serviceKey) + { + if (serviceType == typeof(OpenApiDocumentService)) + { + return TestDocumentService; + } + if (serviceType == typeof(OpenApiComponentService)) + { + return TestComponentService; + } + + return _serviceProvider.GetRequiredKeyedService(serviceType, serviceKey); + } + + public object GetService(Type serviceType) + { + if (serviceType == typeof(IOptions)) + { + return Options.Create(new RouteHandlerOptions()); + } + + return _serviceProvider.GetService(serviceType); + } + } +} diff --git a/src/OpenApi/test/OpenApiGeneratorTests.cs b/src/OpenApi/test/Services/OpenApiGeneratorTests.cs similarity index 100% rename from src/OpenApi/test/OpenApiGeneratorTests.cs rename to src/OpenApi/test/Services/OpenApiGeneratorTests.cs diff --git a/src/OpenApi/test/SharedTypes.cs b/src/OpenApi/test/SharedTypes.cs new file mode 100644 index 000000000000..4689aa2a58f8 --- /dev/null +++ b/src/OpenApi/test/SharedTypes.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// This file contains shared types that are used across tests, sample apps, +// and benchmark apps. + +public record Todo(int Id, string Title, bool Completed, DateTime CreatedAt); + +public record TodoWithDueDate(int Id, string Title, bool Completed, DateTime CreatedAt, DateTime DueDate) : Todo(Id, Title, Completed, CreatedAt); + +public record Error(int code, string Message); diff --git a/src/OpenApi/test/Transformers/DocumentTransformerTests.cs b/src/OpenApi/test/Transformers/DocumentTransformerTests.cs new file mode 100644 index 000000000000..2af401a19b0b --- /dev/null +++ b/src/OpenApi/test/Transformers/DocumentTransformerTests.cs @@ -0,0 +1,230 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; + +public class DocumentTransformerTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task DocumentTransformer_RunsInRegisteredOrder() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer((document, context, cancellationToken) => + { + document.Info.Description = "1"; + return Task.CompletedTask; + }); + options.UseTransformer((document, context, cancellationToken) => + { + Assert.Equal("1", document.Info.Description); + document.Info.Description = "2"; + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal("2", document.Info.Description); + }); + } + + [Fact] + public async Task DocumentTransformer_SupportsActivatedTransformers() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer(); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal("Info Description", document.Info.Description); + }); + } + + [Fact] + public async Task DocumentTransformer_SupportsInstanceTransformers() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer(new ActivatedTransformer()); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal("Info Description", document.Info.Description); + }); + } + + [Fact] + public async Task DocumentTransformer_SupportsActivatedTransformerWithSingletonDependency() + { + var serviceCollection = new ServiceCollection().AddSingleton(); + var builder = CreateBuilder(serviceCollection); + + builder.MapGet("/todo", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer(); + + // Assert that singleton dependency is only instantiated once + // regardless of the number of requests. + string description = null; + await VerifyOpenApiDocument(builder, options, document => + { + description = document.Info.Description; + Assert.Equal(Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture), description); + }); + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal(description, document.Info.Description); + Assert.Equal(Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture), description); + }); + } + + [Fact] + public async Task DocumentTransformer_SupportsActivatedTransformerWithTransientDependency() + { + var serviceCollection = new ServiceCollection().AddTransient(); + var builder = CreateBuilder(serviceCollection); + + builder.MapGet("/todo", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer(); + + // Assert that transient dependency is instantiated twice for each + // request to the OpenAPI document. + string description = null; + await VerifyOpenApiDocument(builder, options, document => + { + description = document.Info.Description; + Assert.Equal(Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture), description); + }); + await VerifyOpenApiDocument(builder, options, document => + { + Assert.NotEqual(description, document.Info.Description); + Assert.Equal(Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture), document.Info.Description); + }); + } + + [Fact] + public async Task DocumentTransformer_SupportsDisposableActivatedTransformer() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer(); + + DisposableTransformer.DisposeCount = 0; + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal("Info Description", document.Info.Description); + }); + Assert.Equal(1, DisposableTransformer.DisposeCount); + } + + [Fact] + public async Task DocumentTransformer_SupportsAsyncDisposableActivatedTransformer() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer(); + + AsyncDisposableTransformer.DisposeCount = 0; + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Equal("Info Description", document.Info.Description); + }); + Assert.Equal(1, AsyncDisposableTransformer.DisposeCount); + } + + private class ActivatedTransformer : IOpenApiDocumentTransformer + { + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + document.Info.Description = "Info Description"; + return Task.CompletedTask; + } + } + + private class DisposableTransformer : IOpenApiDocumentTransformer, IDisposable + { + internal bool Disposed = false; + internal static int DisposeCount = 0; + + public void Dispose() + { + Disposed = true; + DisposeCount += 1; + } + + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + document.Info.Description = "Info Description"; + return Task.CompletedTask; + } + } + + private class AsyncDisposableTransformer : IOpenApiDocumentTransformer, IAsyncDisposable + { + internal bool Disposed = false; + internal static int DisposeCount = 0; + + public ValueTask DisposeAsync() + { + Disposed = true; + DisposeCount += 1; + return ValueTask.CompletedTask; + } + + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + document.Info.Description = "Info Description"; + return Task.CompletedTask; + } + } + + private class ActivatedTransformerWithDependency(Dependency dependency) : IOpenApiDocumentTransformer + { + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + dependency.TestMethod(); + document.Info.Description = Dependency.InstantiationCount.ToString(CultureInfo.InvariantCulture); + return Task.CompletedTask; + } + } + + private class Dependency + { + public Dependency() + { + InstantiationCount += 1; + } + + internal void TestMethod() { } + + internal static int InstantiationCount = 0; + } +} diff --git a/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs b/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs new file mode 100644 index 000000000000..dba656f4cf30 --- /dev/null +++ b/src/OpenApi/test/Transformers/OpenApiOptionsTests.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +public class OpenApiOptionsTests +{ + [Fact] + public void UseTransformer_WithDocumentTransformerDelegate() + { + // Arrange + var options = new OpenApiOptions(); + var transformer = new Func((document, context, cancellationToken) => + { + document.Info.Title = "New Title"; + return Task.CompletedTask; + }); + + // Act + var result = options.UseTransformer(transformer); + + // Assert + var insertedTransformer = Assert.Single(options.DocumentTransformers); + Assert.IsType(insertedTransformer); + Assert.IsType(result); + } + + [Fact] + public void UseTransformer_WithDocumentTransformerInstance() + { + // Arrange + var options = new OpenApiOptions(); + var transformer = new TestOpenApiDocumentTransformer(); + + // Act + var result = options.UseTransformer(transformer); + + // Assert + var insertedTransformer = Assert.Single(options.DocumentTransformers); + Assert.Same(transformer, insertedTransformer); + Assert.IsType(result); + } + + [Fact] + public void UseTransformer_WithDocumentTransformerType() + { + // Arrange + var options = new OpenApiOptions(); + + // Act + var result = options.UseTransformer(); + + // Assert + var insertedTransformer = Assert.Single(options.DocumentTransformers); + Assert.IsType(insertedTransformer); + Assert.IsType(result); + } + + [Fact] + public void UseTransformer_WithOperationTransformerDelegate() + { + // Arrange + var options = new OpenApiOptions(); + var transformer = new Func((operation, context, cancellationToken) => + { + operation.Description = "New Description"; + return Task.CompletedTask; + }); + + // Act + var result = options.UseOperationTransformer(transformer); + + // Assert + var insertedTransformer = Assert.Single(options.DocumentTransformers); + Assert.IsType(insertedTransformer); + Assert.IsType(result); + } + + private class TestOpenApiDocumentTransformer : IOpenApiDocumentTransformer + { + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/src/OpenApi/test/Transformers/OperationTransformerTests.cs b/src/OpenApi/test/Transformers/OperationTransformerTests.cs new file mode 100644 index 000000000000..877d2dfd52ba --- /dev/null +++ b/src/OpenApi/test/Transformers/OperationTransformerTests.cs @@ -0,0 +1,148 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OpenApi; + +public class OperationTransformerTests : OpenApiDocumentServiceTestBase +{ + [Fact] + public async Task OperationTransformer_CanAccessApiDescription() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseOperationTransformer((operation, context, cancellationToken) => + { + var apiDescription = context.Description; + operation.Description = apiDescription.RelativePath; + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("todo", operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("user", operation.Description); + }); + }); + } + + [Fact] + public async Task OperationTransformer_RunsInRegisteredOrder() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseOperationTransformer((operation, context, cancellationToken) => + { + operation.Description = "1"; + return Task.CompletedTask; + }); + options.UseOperationTransformer((operation, context, cancellationToken) => + { + Assert.Equal("1", operation.Description); + operation.Description = "2"; + return Task.CompletedTask; + }); + options.UseOperationTransformer((operation, context, cancellationToken) => + { + Assert.Equal("2", operation.Description); + operation.Description = "3"; + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("3", operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("3", operation.Description); + }); + }); + } + + [Fact] + public async Task OperationTransformer_CanMutateOperationViaDocumentTransformer() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + builder.MapGet("/user", () => { }); + + var options = new OpenApiOptions(); + options.UseTransformer((document, context, cancellationToken) => + { + foreach (var pathItem in document.Paths.Values) + { + foreach (var operation in pathItem.Operations.Values) + { + operation.Description = "3"; + } + } + return Task.CompletedTask; + }); + + await VerifyOpenApiDocument(builder, options, document => + { + Assert.Collection(document.Paths.OrderBy(p => p.Key), + path => + { + Assert.Equal("/todo", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("3", operation.Description); + }, + path => + { + Assert.Equal("/user", path.Key); + var operation = Assert.Single(path.Value.Operations.Values); + Assert.Equal("3", operation.Description); + }); + }); + } + + [Fact] + public async Task OperationTransformer_ThrowsExceptionIfDescriptionIdNotFound() + { + var builder = CreateBuilder(); + + builder.MapGet("/todo", () => { }); + + var options = new OpenApiOptions(); + options.UseOperationTransformer((operation, context, cancellationToken) => + { + operation.Extensions.Remove("x-aspnetcore-id"); + return Task.CompletedTask; + }); + options.UseOperationTransformer((operation, context, cancellationToken) => + { + return Task.CompletedTask; + }); + + var exception = await Assert.ThrowsAsync(() => VerifyOpenApiDocument(builder, options, _ => { })); + Assert.Equal("Cached operation transformer context not found. Please ensure that the operation contains the `x-aspnetcore-id` extension attribute.", exception.Message); + } +}