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);
+ }
+}