diff --git a/AspNetCore.sln b/AspNetCore.sln index 3aff0dd693ef..1b28f6de1e20 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1654,6 +1654,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Compon EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.SdkAnalyzers.Tests", "src\Tools\SDK-Analyzers\Components\test\Microsoft.AspNetCore.Components.SdkAnalyzers.Tests.csproj", "{DC349A25-0DBF-4468-99E1-B95C22D3A7EF}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OutputCaching", "OutputCaching", "{AA5ABFBC-177C-421E-B743-005E0FD1248B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.OutputCaching", "src\Middleware\OutputCaching\src\Microsoft.AspNetCore.OutputCaching.csproj", "{5D5A3B60-A014-447C-9126-B1FA6C821C8D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{B5AC1D8B-9D43-4261-AE0F-6B7574656F2C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OutputCachingSample", "src\Middleware\OutputCaching\samples\OutputCachingSample\OutputCachingSample.csproj", "{C3FFA4E4-0E7E-4866-A15F-034245BFD800}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RequestDecompression", "RequestDecompression", "{5465F96F-33D5-454E-9C40-494E58AEEE5D}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.RequestDecompression.Tests", "src\Middleware\RequestDecompression\test\Microsoft.AspNetCore.RequestDecompression.Tests.csproj", "{97996D39-7722-4AFC-A41A-AD61CA7A413D}" @@ -1700,6 +1708,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildAfterTargetingPack", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BuildAfterTargetingPack", "src\BuildAfterTargetingPack\BuildAfterTargetingPack.csproj", "{8FED7E65-A7DD-4F13-8980-BF03E77B6C85}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.OutputCaching.Tests", "src\Middleware\OutputCaching\test\Microsoft.AspNetCore.OutputCaching.Tests.csproj", "{046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ResultsOfTGenerator", "src\Http\Http.Results\tools\ResultsOfTGenerator\ResultsOfTGenerator.csproj", "{9716D0D0-2251-44DD-8596-67D253EEF41C}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OpenApi", "OpenApi", "{2299CCD8-8F9C-4F2B-A633-9BF4DA81022B}" @@ -9991,6 +10001,38 @@ Global {DC349A25-0DBF-4468-99E1-B95C22D3A7EF}.Release|x64.Build.0 = Release|Any CPU {DC349A25-0DBF-4468-99E1-B95C22D3A7EF}.Release|x86.ActiveCfg = Release|Any CPU {DC349A25-0DBF-4468-99E1-B95C22D3A7EF}.Release|x86.Build.0 = Release|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Debug|arm64.ActiveCfg = Debug|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Debug|arm64.Build.0 = Debug|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Debug|x64.ActiveCfg = Debug|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Debug|x64.Build.0 = Debug|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Debug|x86.ActiveCfg = Debug|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Debug|x86.Build.0 = Debug|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Release|Any CPU.Build.0 = Release|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Release|arm64.ActiveCfg = Release|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Release|arm64.Build.0 = Release|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Release|x64.ActiveCfg = Release|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Release|x64.Build.0 = Release|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Release|x86.ActiveCfg = Release|Any CPU + {5D5A3B60-A014-447C-9126-B1FA6C821C8D}.Release|x86.Build.0 = Release|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Debug|arm64.ActiveCfg = Debug|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Debug|arm64.Build.0 = Debug|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Debug|x64.Build.0 = Debug|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Debug|x86.Build.0 = Debug|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Release|Any CPU.Build.0 = Release|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Release|arm64.ActiveCfg = Release|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Release|arm64.Build.0 = Release|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Release|x64.ActiveCfg = Release|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Release|x64.Build.0 = Release|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Release|x86.ActiveCfg = Release|Any CPU + {C3FFA4E4-0E7E-4866-A15F-034245BFD800}.Release|x86.Build.0 = Release|Any CPU {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Debug|Any CPU.Build.0 = Debug|Any CPU {97996D39-7722-4AFC-A41A-AD61CA7A413D}.Debug|arm64.ActiveCfg = Debug|Any CPU @@ -10215,6 +10257,22 @@ Global {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|x64.Build.0 = Release|Any CPU {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|x86.ActiveCfg = Release|Any CPU {8FED7E65-A7DD-4F13-8980-BF03E77B6C85}.Release|x86.Build.0 = Release|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Debug|arm64.ActiveCfg = Debug|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Debug|arm64.Build.0 = Debug|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Debug|x64.ActiveCfg = Debug|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Debug|x64.Build.0 = Debug|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Debug|x86.ActiveCfg = Debug|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Debug|x86.Build.0 = Debug|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Release|Any CPU.Build.0 = Release|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Release|arm64.ActiveCfg = Release|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Release|arm64.Build.0 = Release|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Release|x64.ActiveCfg = Release|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Release|x64.Build.0 = Release|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Release|x86.ActiveCfg = Release|Any CPU + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7}.Release|x86.Build.0 = Release|Any CPU {9716D0D0-2251-44DD-8596-67D253EEF41C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9716D0D0-2251-44DD-8596-67D253EEF41C}.Debug|Any CPU.Build.0 = Debug|Any CPU {9716D0D0-2251-44DD-8596-67D253EEF41C}.Debug|arm64.ActiveCfg = Debug|Any CPU @@ -11242,6 +11300,10 @@ Global {CC45FA2D-128B-485D-BA6D-DFD9735CB3C3} = {6C06163A-80E9-49C1-817C-B391852BA563} {825BCF97-67A9-4834-B3A8-C3DC97A90E41} = {CC45FA2D-128B-485D-BA6D-DFD9735CB3C3} {DC349A25-0DBF-4468-99E1-B95C22D3A7EF} = {CC45FA2D-128B-485D-BA6D-DFD9735CB3C3} + {AA5ABFBC-177C-421E-B743-005E0FD1248B} = {E5963C9F-20A6-4385-B364-814D2581FADF} + {5D5A3B60-A014-447C-9126-B1FA6C821C8D} = {AA5ABFBC-177C-421E-B743-005E0FD1248B} + {B5AC1D8B-9D43-4261-AE0F-6B7574656F2C} = {AA5ABFBC-177C-421E-B743-005E0FD1248B} + {C3FFA4E4-0E7E-4866-A15F-034245BFD800} = {B5AC1D8B-9D43-4261-AE0F-6B7574656F2C} {5465F96F-33D5-454E-9C40-494E58AEEE5D} = {E5963C9F-20A6-4385-B364-814D2581FADF} {97996D39-7722-4AFC-A41A-AD61CA7A413D} = {5465F96F-33D5-454E-9C40-494E58AEEE5D} {37144E52-611B-40E8-807C-2821F5A814CB} = {5465F96F-33D5-454E-9C40-494E58AEEE5D} @@ -11265,6 +11327,7 @@ Global {B7DAA48B-8E5E-4A5D-9FEB-E6D49AE76A04} = {41BB7BA4-AC08-4E9A-83EA-6D587A5B951C} {489020F2-80D9-4468-A5D3-07E785837A5D} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} {8FED7E65-A7DD-4F13-8980-BF03E77B6C85} = {489020F2-80D9-4468-A5D3-07E785837A5D} + {046F43BC-BEE4-48B7-8C09-ED0A1054A2D7} = {AA5ABFBC-177C-421E-B743-005E0FD1248B} {9716D0D0-2251-44DD-8596-67D253EEF41C} = {323C3EB6-1D15-4B3D-918D-699D7F64DED9} {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} = {017429CC-C5FB-48B4-9C46-034E29EE2F06} {3AEFB466-6310-4F3F-923F-9154224E3629} = {2299CCD8-8F9C-4F2B-A633-9BF4DA81022B} diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index c0842d685c55..f0d8504957ac 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -89,6 +89,7 @@ + diff --git a/eng/SharedFramework.Local.props b/eng/SharedFramework.Local.props index b727b8df0f26..df28fe453b25 100644 --- a/eng/SharedFramework.Local.props +++ b/eng/SharedFramework.Local.props @@ -77,6 +77,7 @@ + diff --git a/eng/TrimmableProjects.props b/eng/TrimmableProjects.props index 5f86c590f41a..b4c71948fe7f 100644 --- a/eng/TrimmableProjects.props +++ b/eng/TrimmableProjects.props @@ -66,6 +66,7 @@ + diff --git a/src/Framework/Framework.slnf b/src/Framework/Framework.slnf index e24240682606..d3f3abdc9f19 100644 --- a/src/Framework/Framework.slnf +++ b/src/Framework/Framework.slnf @@ -56,6 +56,7 @@ "src\\Middleware\\HttpsPolicy\\src\\Microsoft.AspNetCore.HttpsPolicy.csproj", "src\\Middleware\\Localization.Routing\\src\\Microsoft.AspNetCore.Localization.Routing.csproj", "src\\Middleware\\Localization\\src\\Microsoft.AspNetCore.Localization.csproj", + "src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj", "src\\Middleware\\ResponseCaching.Abstractions\\src\\Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj", "src\\Middleware\\ResponseCaching\\src\\Microsoft.AspNetCore.ResponseCaching.csproj", "src\\Middleware\\ResponseCompression\\src\\Microsoft.AspNetCore.ResponseCompression.csproj", diff --git a/src/Framework/test/TestData.cs b/src/Framework/test/TestData.cs index b36cb3fec262..28aaa88dcc7b 100644 --- a/src/Framework/test/TestData.cs +++ b/src/Framework/test/TestData.cs @@ -73,6 +73,7 @@ static TestData() "Microsoft.AspNetCore.Mvc.RazorPages", "Microsoft.AspNetCore.Mvc.TagHelpers", "Microsoft.AspNetCore.Mvc.ViewFeatures", + "Microsoft.AspNetCore.OutputCaching", "Microsoft.AspNetCore.Razor", "Microsoft.AspNetCore.Razor.Runtime", "Microsoft.AspNetCore.RequestDecompression", @@ -209,6 +210,7 @@ static TestData() { "Microsoft.AspNetCore.Mvc.RazorPages", "7.0.0.0" }, { "Microsoft.AspNetCore.Mvc.TagHelpers", "7.0.0.0" }, { "Microsoft.AspNetCore.Mvc.ViewFeatures", "7.0.0.0" }, + { "Microsoft.AspNetCore.OutputCaching", "7.0.0.0" }, { "Microsoft.AspNetCore.Razor", "7.0.0.0" }, { "Microsoft.AspNetCore.Razor.Runtime", "7.0.0.0" }, { "Microsoft.AspNetCore.RequestDecompression", "7.0.0.0" }, diff --git a/src/Middleware/CORS/test/testassets/CorsMiddlewareWebSite/Properties/launchSettings.json b/src/Middleware/CORS/test/testassets/CorsMiddlewareWebSite/Properties/launchSettings.json index 485cac49a974..3528609bc2ff 100644 --- a/src/Middleware/CORS/test/testassets/CorsMiddlewareWebSite/Properties/launchSettings.json +++ b/src/Middleware/CORS/test/testassets/CorsMiddlewareWebSite/Properties/launchSettings.json @@ -9,4 +9,4 @@ "applicationUrl": "https://localhost:61226;http://localhost:61227" } } -} \ No newline at end of file +} diff --git a/src/Middleware/Diagnostics/test/testassets/DatabaseErrorPageSample/Properties/launchSettings.json b/src/Middleware/Diagnostics/test/testassets/DatabaseErrorPageSample/Properties/launchSettings.json index 3fbc5274ba38..b2929adc68de 100644 --- a/src/Middleware/Diagnostics/test/testassets/DatabaseErrorPageSample/Properties/launchSettings.json +++ b/src/Middleware/Diagnostics/test/testassets/DatabaseErrorPageSample/Properties/launchSettings.json @@ -9,4 +9,4 @@ "applicationUrl": "https://localhost:61218;http://localhost:61219" } } -} \ No newline at end of file +} diff --git a/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/Properties/launchSettings.json b/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/Properties/launchSettings.json index 8f4f5d821bdc..919228c3341c 100644 --- a/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/Properties/launchSettings.json +++ b/src/Middleware/Diagnostics/test/testassets/DeveloperExceptionPageSample/Properties/launchSettings.json @@ -9,4 +9,4 @@ "applicationUrl": "https://localhost:61228;http://localhost:61229" } } -} \ No newline at end of file +} diff --git a/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/Properties/launchSettings.json b/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/Properties/launchSettings.json index c69c9dd556ac..86c5743f7e60 100644 --- a/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/Properties/launchSettings.json +++ b/src/Middleware/Diagnostics/test/testassets/ExceptionHandlerSample/Properties/launchSettings.json @@ -9,4 +9,4 @@ "applicationUrl": "https://localhost:61220;http://localhost:61221" } } -} \ No newline at end of file +} diff --git a/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/Properties/launchSettings.json b/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/Properties/launchSettings.json index f27a4afd7135..97b0bd2ba905 100644 --- a/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/Properties/launchSettings.json +++ b/src/Middleware/Diagnostics/test/testassets/StatusCodePagesSample/Properties/launchSettings.json @@ -9,4 +9,4 @@ "applicationUrl": "https://localhost:61230;http://localhost:61231" } } -} \ No newline at end of file +} diff --git a/src/Middleware/Diagnostics/test/testassets/WelcomePageSample/Properties/launchSettings.json b/src/Middleware/Diagnostics/test/testassets/WelcomePageSample/Properties/launchSettings.json index b82719da65c1..10feca9e72b6 100644 --- a/src/Middleware/Diagnostics/test/testassets/WelcomePageSample/Properties/launchSettings.json +++ b/src/Middleware/Diagnostics/test/testassets/WelcomePageSample/Properties/launchSettings.json @@ -9,4 +9,4 @@ "applicationUrl": "https://localhost:61223;http://localhost:61225" } } -} \ No newline at end of file +} diff --git a/src/Middleware/Localization/testassets/LocalizationWebsite/Properties/launchSettings.json b/src/Middleware/Localization/testassets/LocalizationWebsite/Properties/launchSettings.json index 3b71d90077f8..7494427a696b 100644 --- a/src/Middleware/Localization/testassets/LocalizationWebsite/Properties/launchSettings.json +++ b/src/Middleware/Localization/testassets/LocalizationWebsite/Properties/launchSettings.json @@ -9,4 +9,4 @@ "applicationUrl": "https://localhost:61222;http://localhost:61224" } } -} \ No newline at end of file +} diff --git a/src/Middleware/Middleware.slnf b/src/Middleware/Middleware.slnf index 6033a2555e0d..c75854876fbf 100644 --- a/src/Middleware/Middleware.slnf +++ b/src/Middleware/Middleware.slnf @@ -76,6 +76,9 @@ "src\\Middleware\\MiddlewareAnalysis\\samples\\MiddlewareAnalysisSample\\MiddlewareAnalysisSample.csproj", "src\\Middleware\\MiddlewareAnalysis\\src\\Microsoft.AspNetCore.MiddlewareAnalysis.csproj", "src\\Middleware\\MiddlewareAnalysis\\test\\Microsoft.AspNetCore.MiddlewareAnalysis.Tests.csproj", + "src\\Middleware\\OutputCaching\\samples\\OutputCachingSample\\OutputCachingSample.csproj", + "src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj", + "src\\Middleware\\OutputCaching\\test\\Microsoft.AspNetCore.OutputCaching.Tests.csproj", "src\\Middleware\\RateLimiting\\src\\Microsoft.AspNetCore.RateLimiting.csproj", "src\\Middleware\\RateLimiting\\test\\Microsoft.AspNetCore.RateLimiting.Tests.csproj", "src\\Middleware\\RequestDecompression\\perf\\Microbenchmarks\\Microsoft.AspNetCore.RequestDecompression.Microbenchmarks.csproj", diff --git a/src/Middleware/OutputCaching/OutputCaching.slnf b/src/Middleware/OutputCaching/OutputCaching.slnf new file mode 100644 index 000000000000..7d85ca7205c3 --- /dev/null +++ b/src/Middleware/OutputCaching/OutputCaching.slnf @@ -0,0 +1,10 @@ +{ + "solution": { + "path": "..\\..\\..\\AspNetCore.sln", + "projects": [ + "src\\Middleware\\OutputCaching\\samples\\OutputCachingSample\\OutputCachingSample.csproj", + "src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj", + "src\\Middleware\\OutputCaching\\test\\Microsoft.AspNetCore.OutputCaching.Tests.csproj" + ] + } +} \ No newline at end of file diff --git a/src/Middleware/OutputCaching/samples/OutputCachingSample/.vscode/launch.json b/src/Middleware/OutputCaching/samples/OutputCachingSample/.vscode/launch.json new file mode 100644 index 000000000000..93e3163e870f --- /dev/null +++ b/src/Middleware/OutputCaching/samples/OutputCachingSample/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/bin/Debug/net7.0/OutputCachingSample.dll", + "args": [], + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/src/Middleware/OutputCaching/samples/OutputCachingSample/.vscode/tasks.json b/src/Middleware/OutputCaching/samples/OutputCachingSample/.vscode/tasks.json new file mode 100644 index 000000000000..5b41ee7a098c --- /dev/null +++ b/src/Middleware/OutputCaching/samples/OutputCachingSample/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/OutputCachingSample.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/OutputCachingSample.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/OutputCachingSample.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/src/Middleware/OutputCaching/samples/OutputCachingSample/Gravatar.cs b/src/Middleware/OutputCaching/samples/OutputCachingSample/Gravatar.cs new file mode 100644 index 000000000000..08eccafbb9e6 --- /dev/null +++ b/src/Middleware/OutputCaching/samples/OutputCachingSample/Gravatar.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +public static class Gravatar +{ + public static async Task WriteGravatar(HttpContext context) + { + const string type = "monsterid"; // identicon, monsterid, wavatar + const int size = 200; + var hash = Guid.NewGuid().ToString("n"); + + context.Response.StatusCode = 200; + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync($""); + await context.Response.WriteAsync($"
Generated at {DateTime.Now:hh:mm:ss.ff}
"); + } +} diff --git a/src/Middleware/OutputCaching/samples/OutputCachingSample/OutputCachingSample.csproj b/src/Middleware/OutputCaching/samples/OutputCachingSample/OutputCachingSample.csproj new file mode 100644 index 000000000000..8e76982e4dfc --- /dev/null +++ b/src/Middleware/OutputCaching/samples/OutputCachingSample/OutputCachingSample.csproj @@ -0,0 +1,14 @@ + + + + $(DefaultNetCoreTargetFramework) + enable + + + + + + + + + \ No newline at end of file diff --git a/src/Middleware/OutputCaching/samples/OutputCachingSample/Properties/launchSettings.json b/src/Middleware/OutputCaching/samples/OutputCachingSample/Properties/launchSettings.json new file mode 100644 index 000000000000..b544fed19552 --- /dev/null +++ b/src/Middleware/OutputCaching/samples/OutputCachingSample/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:54270/", + "sslPort": 44398 + } + }, + "profiles": { + "OutputCachingSample": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Middleware/OutputCaching/samples/OutputCachingSample/README.md b/src/Middleware/OutputCaching/samples/OutputCachingSample/README.md new file mode 100644 index 000000000000..f88770eaaf01 --- /dev/null +++ b/src/Middleware/OutputCaching/samples/OutputCachingSample/README.md @@ -0,0 +1,6 @@ +ASP.NET Core Output Caching Sample +=================================== + +This sample illustrates the usage of ASP.NET Core output caching middleware. The application sends a `Hello World!` message and the current time. A different cache entry is created for each variation of the query string. + +When running the sample, a response will be served from cache when possible and will be stored for up to 10 seconds. diff --git a/src/Middleware/OutputCaching/samples/OutputCachingSample/Startup.cs b/src/Middleware/OutputCaching/samples/OutputCachingSample/Startup.cs new file mode 100644 index 000000000000..ca91981bc6b7 --- /dev/null +++ b/src/Middleware/OutputCaching/samples/OutputCachingSample/Startup.cs @@ -0,0 +1,75 @@ +// 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.OutputCaching; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOutputCache(options => +{ + // Define policies for all requests which are not configured per endpoint or per request + options.AddBasePolicy(builder => builder.With(c => c.HttpContext.Request.Path.StartsWithSegments("/js")).Expire(TimeSpan.FromDays(1))); + options.AddBasePolicy(builder => builder.With(c => c.HttpContext.Request.Path.StartsWithSegments("/js")).NoCache()); + + options.AddPolicy("NoCache", b => b.NoCache()); +}); + +var app = builder.Build(); + +app.UseOutputCache(); + +app.MapGet("/", Gravatar.WriteGravatar); + +app.MapGet("/cached", Gravatar.WriteGravatar).CacheOutput(); + +app.MapGet("/nocache", Gravatar.WriteGravatar).CacheOutput(x => x.NoCache()); + +app.MapGet("/profile", Gravatar.WriteGravatar).CacheOutput("NoCache"); + +app.MapGet("/attribute", [OutputCache(PolicyName = "NoCache")] () => Gravatar.WriteGravatar); + +var blog = app.MapGroup("blog").CacheOutput(x => x.Tag("blog")); +blog.MapGet("/", Gravatar.WriteGravatar); +blog.MapGet("/post/{id}", Gravatar.WriteGravatar).CacheOutput(x => x.Tag("blog", "byid")); // Calling CacheOutput() here overwrites the group's policy + +app.MapPost("/purge/{tag}", async (IOutputCacheStore cache, string tag) => +{ + // POST such that the endpoint is not cached itself + + await cache.EvictByTagAsync(tag, default); +}); + +// Cached entries will vary by culture, but any other additional query is ignored and returns the same cached content +app.MapGet("/query", Gravatar.WriteGravatar).CacheOutput(p => p.VaryByQuery("culture")); + +app.MapGet("/vary", Gravatar.WriteGravatar).CacheOutput(c => c.VaryByValue((context) => new KeyValuePair("time", (DateTime.Now.Second % 2).ToString(CultureInfo.InvariantCulture)))); + +long requests = 0; + +// Locking is enabled by default +app.MapGet("/lock", async (context) => +{ + await Task.Delay(1000); + await context.Response.WriteAsync($"
{requests++}
"); +}).CacheOutput(p => p.AllowLocking(false).Expire(TimeSpan.FromMilliseconds(1))); + +// Etag +app.MapGet("/etag", async (context) => +{ + // If the client sends an If-None-Match header with the etag value, the server + // returns 304 if the cache entry is fresh instead of the full response + + var etag = $"\"{Guid.NewGuid():n}\""; + context.Response.Headers.ETag = etag; + + await Gravatar.WriteGravatar(context); + + var cacheContext = context.Features.Get()?.Context; + +}).CacheOutput(); + +// When the request header If-Modified-Since is provided, return 304 if the cached entry is older +app.MapGet("/ims", Gravatar.WriteGravatar).CacheOutput(); + +await app.RunAsync(); diff --git a/src/Middleware/OutputCaching/samples/OutputCachingSample/appsettings.Development.json b/src/Middleware/OutputCaching/samples/OutputCachingSample/appsettings.Development.json new file mode 100644 index 000000000000..4e8090a0eea5 --- /dev/null +++ b/src/Middleware/OutputCaching/samples/OutputCachingSample/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.OutputCaching": "Debug" + } + } +} diff --git a/src/Middleware/OutputCaching/samples/OutputCachingSample/appsettings.json b/src/Middleware/OutputCaching/samples/OutputCachingSample/appsettings.json new file mode 100644 index 000000000000..d9d9a9bff6fd --- /dev/null +++ b/src/Middleware/OutputCaching/samples/OutputCachingSample/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Middleware/OutputCaching/src/CacheEntryHelpers.cs b/src/Middleware/OutputCaching/src/CacheEntryHelpers.cs new file mode 100644 index 000000000000..e7fdcf1bcbf3 --- /dev/null +++ b/src/Middleware/OutputCaching/src/CacheEntryHelpers.cs @@ -0,0 +1,59 @@ +// 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.Primitives; + +namespace Microsoft.AspNetCore.OutputCaching; + +internal static class CacheEntryHelpers +{ + internal static long EstimateCachedResponseSize(OutputCacheEntry cachedResponse) + { + if (cachedResponse == null) + { + return 0L; + } + + checked + { + // StatusCode + long size = sizeof(int); + + // Headers + if (cachedResponse.Headers != null) + { + foreach (var item in cachedResponse.Headers) + { + size += (item.Key.Length * sizeof(char)) + EstimateStringValuesSize(item.Value); + } + } + + // Body + if (cachedResponse.Body != null) + { + size += cachedResponse.Body.Length; + } + + return size; + } + } + + internal static long EstimateStringValuesSize(StringValues stringValues) + { + checked + { + var size = 0L; + + for (var i = 0; i < stringValues.Count; i++) + { + var stringValue = stringValues[i]; + if (!string.IsNullOrEmpty(stringValue)) + { + size += stringValue.Length * sizeof(char); + } + } + + return size; + } + } +} diff --git a/src/Middleware/OutputCaching/src/CacheVaryByRules.cs b/src/Middleware/OutputCaching/src/CacheVaryByRules.cs new file mode 100644 index 000000000000..28caea7acea2 --- /dev/null +++ b/src/Middleware/OutputCaching/src/CacheVaryByRules.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.OutputCaching; + +/// +/// Represents vary-by rules. +/// +public sealed class CacheVaryByRules +{ + private Dictionary? _varyByCustom; + + internal bool HasVaryByCustom => _varyByCustom != null && _varyByCustom.Any(); + + /// + /// Gets a dictionary of key-pair values to vary the cache by. + /// + public IDictionary VaryByCustom => _varyByCustom ??= new(); + + /// + /// Gets or sets the list of headers to vary by. + /// + public StringValues Headers { get; set; } + + /// + /// Gets or sets the list of query string keys to vary by. + /// + public StringValues QueryKeys { get; set; } + + /// + /// Gets or sets a prefix to vary by. + /// + public StringValues VaryByPrefix { get; set; } +} diff --git a/src/Middleware/OutputCaching/src/CachedResponseBody.cs b/src/Middleware/OutputCaching/src/CachedResponseBody.cs new file mode 100644 index 000000000000..dd46a5ece715 --- /dev/null +++ b/src/Middleware/OutputCaching/src/CachedResponseBody.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Pipelines; + +namespace Microsoft.AspNetCore.OutputCaching; + +/// +/// Represents a cached response body. +/// +internal sealed class CachedResponseBody +{ + /// + /// Creates a new instance. + /// + /// The segments. + /// The length. + public CachedResponseBody(List segments, long length) + { + ArgumentNullException.ThrowIfNull(segments); + + Segments = segments; + Length = length; + } + + /// + /// Gets the segments of the body. + /// + public List Segments { get; } + + /// + /// Gets the length of the body. + /// + public long Length { get; } + + /// + /// Copies the body to a . + /// + /// The destination + /// The cancellation token. + /// + public async Task CopyToAsync(PipeWriter destination, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(destination); + + foreach (var segment in Segments) + { + cancellationToken.ThrowIfCancellationRequested(); + + await destination.WriteAsync(segment, cancellationToken); + } + } +} diff --git a/src/Middleware/OutputCaching/src/DispatcherExtensions.cs b/src/Middleware/OutputCaching/src/DispatcherExtensions.cs new file mode 100644 index 000000000000..173b7147d120 --- /dev/null +++ b/src/Middleware/OutputCaching/src/DispatcherExtensions.cs @@ -0,0 +1,89 @@ +// 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; + +namespace Microsoft.AspNetCore.OutputCaching; + +internal sealed class WorkDispatcher where TKey : notnull +{ + private readonly ConcurrentDictionary> _workers = new(); + + public async Task ScheduleAsync(TKey key, Func> valueFactory) + { + ArgumentNullException.ThrowIfNull(key); + + while (true) + { + if (_workers.TryGetValue(key, out var task)) + { + return await task; + } + + // This is the task that we'll return to all waiters. We'll complete it when the factory is complete + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (_workers.TryAdd(key, tcs.Task)) + { + try + { + var value = await valueFactory(key); + tcs.TrySetResult(value); + return await tcs.Task; + } + catch (Exception ex) + { + // Make sure all waiters see the exception + tcs.SetException(ex); + + throw; + } + finally + { + // We remove the entry if the factory failed so it's not a permanent failure + // and future gets can retry (this could be a pluggable policy) + _workers.TryRemove(key, out _); + } + } + } + } + + public async Task ScheduleAsync(TKey key, TState state, Func> valueFactory) + { + ArgumentNullException.ThrowIfNull(key); + + while (true) + { + if (_workers.TryGetValue(key, out var task)) + { + return await task; + } + + // This is the task that we'll return to all waiters. We'll complete it when the factory is complete + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (_workers.TryAdd(key, tcs.Task)) + { + try + { + var value = await valueFactory(key, state); + tcs.TrySetResult(value); + return await tcs.Task; + } + catch (Exception ex) + { + // Make sure all waiters see the exception + tcs.SetException(ex); + + throw; + } + finally + { + // We remove the entry if the factory failed so it's not a permanent failure + // and future gets can retry (this could be a pluggable policy) + _workers.TryRemove(key, out _); + } + } + } + } +} diff --git a/src/Middleware/OutputCaching/src/IOutputCacheFeature.cs b/src/Middleware/OutputCaching/src/IOutputCacheFeature.cs new file mode 100644 index 000000000000..db651bd7c0c1 --- /dev/null +++ b/src/Middleware/OutputCaching/src/IOutputCacheFeature.cs @@ -0,0 +1,15 @@ +// 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.OutputCaching; + +/// +/// A feature for configuring additional output cache options on the HTTP response. +/// +public interface IOutputCacheFeature +{ + /// + /// Gets the cache context. + /// + OutputCacheContext Context { get; } +} diff --git a/src/Middleware/OutputCaching/src/IOutputCacheKeyProvider.cs b/src/Middleware/OutputCaching/src/IOutputCacheKeyProvider.cs new file mode 100644 index 000000000000..e86cf6c6797b --- /dev/null +++ b/src/Middleware/OutputCaching/src/IOutputCacheKeyProvider.cs @@ -0,0 +1,14 @@ +// 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.OutputCaching; + +internal interface IOutputCacheKeyProvider +{ + /// + /// Create a key for storing cached responses. + /// + /// The . + /// The created key. + string CreateStorageKey(OutputCacheContext context); +} diff --git a/src/Middleware/OutputCaching/src/IOutputCachePolicy.cs b/src/Middleware/OutputCaching/src/IOutputCachePolicy.cs new file mode 100644 index 000000000000..aae03b76dbaf --- /dev/null +++ b/src/Middleware/OutputCaching/src/IOutputCachePolicy.cs @@ -0,0 +1,30 @@ +// 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.OutputCaching; + +/// +/// An implementation of this interface can update how the current request is cached. +/// +public interface IOutputCachePolicy +{ + /// + /// Updates the before the cache middleware is invoked. + /// At that point the cache middleware can still be enabled or disabled for the request. + /// + /// The current request's cache context. + ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellation); + + /// + /// Updates the before the cached response is used. + /// At that point the freshness of the cached response can be updated. + /// + /// The current request's cache context. + ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellation); + + /// + /// Updates the before the response is served and can be cached. + /// At that point cacheability of the response can be updated. + /// + ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellation); +} diff --git a/src/Middleware/OutputCaching/src/IOutputCacheStore.cs b/src/Middleware/OutputCaching/src/IOutputCacheStore.cs new file mode 100644 index 000000000000..ffba017ef6f9 --- /dev/null +++ b/src/Middleware/OutputCaching/src/IOutputCacheStore.cs @@ -0,0 +1,36 @@ +// 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.OutputCaching; + +/// +/// Represents a store for cached responses. +/// +public interface IOutputCacheStore +{ + /// + /// Evicts cached responses by tag. + /// + /// The tag to evict. + /// Indicates that the operation should be cancelled. + ValueTask EvictByTagAsync(string tag, CancellationToken cancellationToken); + + /// + /// Gets the cached response for the given key, if it exists. + /// If no cached response exists for the given key, null is returned. + /// + /// The cache key to look up. + /// Indicates that the operation should be cancelled. + /// The response cache entry if it exists; otherwise null. + ValueTask GetAsync(string key, CancellationToken cancellationToken); + + /// + /// Stores the given response in the response cache. + /// + /// The cache key to store the response under. + /// The response cache entry to store. + /// The tags associated with the cache entry to store. + /// The amount of time the entry will be kept in the cache before expiring, relative to now. + /// Indicates that the operation should be cancelled. + ValueTask SetAsync(string key, byte[] value, string[]? tags, TimeSpan validFor, CancellationToken cancellationToken); +} diff --git a/src/Middleware/OutputCaching/src/ISystemClock.cs b/src/Middleware/OutputCaching/src/ISystemClock.cs new file mode 100644 index 000000000000..8e2ead45b9d7 --- /dev/null +++ b/src/Middleware/OutputCaching/src/ISystemClock.cs @@ -0,0 +1,15 @@ +// 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.OutputCaching; + +/// +/// Abstracts the system clock to facilitate testing. +/// +internal interface ISystemClock +{ + /// + /// Retrieves the current system time in UTC. + /// + DateTimeOffset UtcNow { get; } +} diff --git a/src/Middleware/OutputCaching/src/LoggerExtensions.cs b/src/Middleware/OutputCaching/src/LoggerExtensions.cs new file mode 100644 index 000000000000..642f7252a623 --- /dev/null +++ b/src/Middleware/OutputCaching/src/LoggerExtensions.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.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.OutputCaching; + +/// +/// Defines the logger messages produced by output caching +/// +internal static partial class LoggerExtensions +{ + [LoggerMessage(1, LogLevel.Debug, "The request cannot be served from cache because it uses the HTTP method: {Method}.", + EventName = "RequestMethodNotCacheable")] + internal static partial void RequestMethodNotCacheable(this ILogger logger, string method); + + [LoggerMessage(2, LogLevel.Debug, "The request cannot be served from cache because it contains an 'Authorization' header.", + EventName = "RequestWithAuthorizationNotCacheable")] + internal static partial void RequestWithAuthorizationNotCacheable(this ILogger logger); + + [LoggerMessage(3, LogLevel.Debug, "Response is not cacheable because it contains a 'SetCookie' header.", EventName = "ResponseWithSetCookieNotCacheable")] + internal static partial void ResponseWithSetCookieNotCacheable(this ILogger logger); + + [LoggerMessage(4, LogLevel.Debug, "Response is not cacheable because its status code {StatusCode} does not indicate success.", + EventName = "ResponseWithUnsuccessfulStatusCodeNotCacheable")] + internal static partial void ResponseWithUnsuccessfulStatusCodeNotCacheable(this ILogger logger, int statusCode); + + [LoggerMessage(5, LogLevel.Debug, "The 'IfNoneMatch' header of the request contains a value of *.", EventName = "NotModifiedIfNoneMatchStar")] + internal static partial void NotModifiedIfNoneMatchStar(this ILogger logger); + + [LoggerMessage(6, LogLevel.Debug, "The ETag {ETag} in the 'IfNoneMatch' header matched the ETag of a cached entry.", + EventName = "NotModifiedIfNoneMatchMatched")] + internal static partial void NotModifiedIfNoneMatchMatched(this ILogger logger, EntityTagHeaderValue etag); + + [LoggerMessage(7, LogLevel.Debug, "The last modified date of {LastModified} is before the date {IfModifiedSince} specified in the 'IfModifiedSince' header.", + EventName = "NotModifiedIfModifiedSinceSatisfied")] + internal static partial void NotModifiedIfModifiedSinceSatisfied(this ILogger logger, DateTimeOffset lastModified, DateTimeOffset ifModifiedSince); + + [LoggerMessage(8, LogLevel.Information, "The content requested has not been modified.", EventName = "NotModifiedServed")] + internal static partial void NotModifiedServed(this ILogger logger); + + [LoggerMessage(9, LogLevel.Information, "Serving response from cache.", EventName = "CachedResponseServed")] + internal static partial void CachedResponseServed(this ILogger logger); + + [LoggerMessage(10, LogLevel.Information, "No cached response available for this request and the 'only-if-cached' cache directive was specified.", + EventName = "GatewayTimeoutServed")] + internal static partial void GatewayTimeoutServed(this ILogger logger); + + [LoggerMessage(11, LogLevel.Information, "No cached response available for this request.", EventName = "NoResponseServed")] + internal static partial void NoResponseServed(this ILogger logger); + + [LoggerMessage(12, LogLevel.Debug, "Vary by rules were updated. Headers: {Headers}, Query keys: {QueryKeys}", EventName = "VaryByRulesUpdated")] + internal static partial void VaryByRulesUpdated(this ILogger logger, string headers, string queryKeys); + + [LoggerMessage(13, LogLevel.Information, "The response has been cached.", EventName = "ResponseCached")] + internal static partial void ResponseCached(this ILogger logger); + + [LoggerMessage(14, LogLevel.Information, "The response could not be cached for this request.", EventName = "ResponseNotCached")] + internal static partial void ResponseNotCached(this ILogger logger); + + [LoggerMessage(15, LogLevel.Warning, "The response could not be cached for this request because the 'Content-Length' did not match the body length.", + EventName = "ResponseContentLengthMismatchNotCached")] + internal static partial void ResponseContentLengthMismatchNotCached(this ILogger logger); + + [LoggerMessage(16, LogLevel.Debug, "The response time of the entry is {ResponseTime} and has exceeded its expiry date.", + EventName = "ExpirationExpiresExceeded")] + internal static partial void ExpirationExpiresExceeded(this ILogger logger, DateTimeOffset responseTime); + +} diff --git a/src/Middleware/OutputCaching/src/Memory/MemoryOutputCacheStore.cs b/src/Middleware/OutputCaching/src/Memory/MemoryOutputCacheStore.cs new file mode 100644 index 000000000000..3f1df73c7b80 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Memory/MemoryOutputCacheStore.cs @@ -0,0 +1,96 @@ +// 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.Caching.Memory; + +namespace Microsoft.AspNetCore.OutputCaching.Memory; + +internal sealed class MemoryOutputCacheStore : IOutputCacheStore +{ + private readonly IMemoryCache _cache; + private readonly Dictionary> _taggedEntries = new(); + private readonly object _tagsLock = new(); + + internal MemoryOutputCacheStore(IMemoryCache cache) + { + ArgumentNullException.ThrowIfNull(cache); + + _cache = cache; + } + + public ValueTask EvictByTagAsync(string tag, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(tag); + + lock (_tagsLock) + { + if (_taggedEntries.TryGetValue(tag, out var keys)) + { + foreach (var key in keys) + { + _cache.Remove(key); + } + + _taggedEntries.Remove(tag); + } + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask GetAsync(string key, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(key); + + var entry = _cache.Get(key) as byte[]; + return ValueTask.FromResult(entry); + } + + /// + public ValueTask SetAsync(string key, byte[] value, string[]? tags, TimeSpan validFor, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(value); + + if (tags != null) + { + // Lock with SetEntry() to prevent EvictByTagAsync() from trying to remove a tag whose entry hasn't been added yet. + // It might be acceptable to not lock SetEntry() since in this case Remove(key) would just no-op and the user retry to evict. + + lock (_tagsLock) + { + foreach (var tag in tags) + { + if (!_taggedEntries.TryGetValue(tag, out var keys)) + { + keys = new HashSet(); + _taggedEntries[tag] = keys; + } + + keys.Add(key); + } + + SetEntry(); + } + } + else + { + SetEntry(); + } + + void SetEntry() + { + _cache.Set( + key, + value, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = validFor, + Size = value.Length + }); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Middleware/OutputCaching/src/Microsoft.AspNetCore.OutputCaching.csproj b/src/Middleware/OutputCaching/src/Microsoft.AspNetCore.OutputCaching.csproj new file mode 100644 index 000000000000..1396a66e27f1 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Microsoft.AspNetCore.OutputCaching.csproj @@ -0,0 +1,26 @@ + + + + ASP.NET Core middleware for caching HTTP responses on the server. + $(DefaultNetCoreTargetFramework) + true + false + true + + + + + + + + + + + + + + + + + + diff --git a/src/Middleware/OutputCaching/src/OutputCacheApplicationBuilderExtensions.cs b/src/Middleware/OutputCaching/src/OutputCacheApplicationBuilderExtensions.cs new file mode 100644 index 000000000000..4d6caa87d0cc --- /dev/null +++ b/src/Middleware/OutputCaching/src/OutputCacheApplicationBuilderExtensions.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. + +using Microsoft.AspNetCore.OutputCaching; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Extension methods for adding the to an application. +/// +public static class OutputCacheApplicationBuilderExtensions +{ + /// + /// Adds the for caching HTTP responses. + /// + /// The . + public static IApplicationBuilder UseOutputCache(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + return app.UseMiddleware(); + } +} diff --git a/src/Middleware/OutputCaching/src/OutputCacheAttribute.cs b/src/Middleware/OutputCaching/src/OutputCacheAttribute.cs new file mode 100644 index 000000000000..ab48a0355609 --- /dev/null +++ b/src/Middleware/OutputCaching/src/OutputCacheAttribute.cs @@ -0,0 +1,88 @@ +// 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.OutputCaching; + +/// +/// Specifies the parameters necessary for setting appropriate headers in output caching. +/// +/// +/// This attribute requires the output cache middleware. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public sealed class OutputCacheAttribute : Attribute +{ + // A nullable-int cannot be used as an Attribute parameter. + // Hence this nullable-int is present to back the Duration property. + // The same goes for nullable-ResponseCacheLocation and nullable-bool. + private int? _duration; + private bool? _noCache; + + private IOutputCachePolicy? _builtPolicy; + + /// + /// Gets or sets the duration in seconds for which the response is cached. + /// + public int Duration + { + get => _duration ?? 0; + init => _duration = value; + } + + /// + /// Gets or sets the value which determines whether the reponse should be cached or not. + /// When set to , the response won't be cached. + /// + public bool NoStore + { + get => _noCache ?? false; + init => _noCache = value; + } + + /// + /// Gets or sets the query keys to vary by. + /// + public string[]? VaryByQueryKeys { get; init; } + + /// + /// Gets or sets the headers to vary by. + /// + public string[]? VaryByHeaders { get; init; } + + /// + /// Gets or sets the value of the cache policy name. + /// + public string? PolicyName { get; init; } + + internal IOutputCachePolicy BuildPolicy() + { + if (_builtPolicy != null) + { + return _builtPolicy; + } + + var builder = new OutputCachePolicyBuilder(); + + if (PolicyName != null) + { + builder.AddPolicy(new NamedPolicy(PolicyName)); + } + + if (_noCache != null && _noCache.Value) + { + builder.NoCache(); + } + + if (VaryByQueryKeys != null) + { + builder.VaryByQuery(VaryByQueryKeys); + } + + if (_duration != null) + { + builder.Expire(TimeSpan.FromSeconds(_duration.Value)); + } + + return _builtPolicy = builder.Build(); + } +} diff --git a/src/Middleware/OutputCaching/src/OutputCacheContext.cs b/src/Middleware/OutputCaching/src/OutputCacheContext.cs new file mode 100644 index 000000000000..667ca5dcce94 --- /dev/null +++ b/src/Middleware/OutputCaching/src/OutputCacheContext.cs @@ -0,0 +1,85 @@ +// 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.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.OutputCaching; + +/// +/// Represent the current cache context for the request. +/// +public sealed class OutputCacheContext +{ + internal OutputCacheContext(HttpContext httpContext, IOutputCacheStore store, OutputCacheOptions options, ILogger logger) + { + HttpContext = httpContext; + Logger = logger; + Store = store; + Options = options; + } + + /// + /// Determines whether the output caching logic should be configured for the incoming HTTP request. + /// + public bool EnableOutputCaching { get; set; } + + /// + /// Determines whether a cache lookup is allowed for the incoming HTTP request. + /// + public bool AllowCacheLookup { get; set; } + + /// + /// Determines whether storage of the response is allowed for the incoming HTTP request. + /// + public bool AllowCacheStorage { get; set; } + + /// + /// Determines whether the request should be locked. + /// + public bool AllowLocking { get; set; } + + /// + /// Gets the . + /// + public HttpContext HttpContext { get; } + + /// + /// Gets the response time. + /// + public DateTimeOffset? ResponseTime { get; internal set; } + + /// + /// Gets the instance. + /// + public CacheVaryByRules CacheVaryByRules { get; set; } = new(); + + /// + /// Gets the tags of the cached response. + /// + public HashSet Tags { get; } = new(); + + /// + /// Gets or sets the amount of time the response should be cached for. + /// + public TimeSpan? ResponseExpirationTimeSpan { get; set; } + + internal string CacheKey { get; set; } = default!; + + internal TimeSpan CachedResponseValidFor { get; set; } + + internal bool IsCacheEntryFresh { get; set; } + + internal TimeSpan CachedEntryAge { get; set; } + + internal OutputCacheEntry CachedResponse { get; set; } = default!; + + internal bool ResponseStarted { get; set; } + + internal Stream OriginalResponseStream { get; set; } = default!; + + internal OutputCacheStream OutputCacheStream { get; set; } = default!; + internal ILogger Logger { get; } + internal OutputCacheOptions Options { get; } + internal IOutputCacheStore Store { get; } +} diff --git a/src/Middleware/OutputCaching/src/OutputCacheEntry.cs b/src/Middleware/OutputCaching/src/OutputCacheEntry.cs new file mode 100644 index 000000000000..325f2e660718 --- /dev/null +++ b/src/Middleware/OutputCaching/src/OutputCacheEntry.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.AspNetCore.Http; + +namespace Microsoft.AspNetCore.OutputCaching; + +internal sealed class OutputCacheEntry +{ + /// + /// Gets the created date and time of the cache entry. + /// + public DateTimeOffset Created { get; set; } + + /// + /// Gets the status code of the cache entry. + /// + public int StatusCode { get; set; } + + /// + /// Gets the headers of the cache entry. + /// + public HeaderDictionary Headers { get; set; } = default!; + + /// + /// Gets the body of the cache entry. + /// + public CachedResponseBody Body { get; set; } = default!; + + /// + /// Gets the tags of the cache entry. + /// + public string[] Tags { get; set; } = Array.Empty(); +} diff --git a/src/Middleware/OutputCaching/src/OutputCacheEntryFormatter.cs b/src/Middleware/OutputCaching/src/OutputCacheEntryFormatter.cs new file mode 100644 index 000000000000..b6dbef1c2c51 --- /dev/null +++ b/src/Middleware/OutputCaching/src/OutputCacheEntryFormatter.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Text.Json; +using Microsoft.AspNetCore.OutputCaching.Serialization; + +namespace Microsoft.AspNetCore.OutputCaching; +/// +/// Formats instance to match structures supported by the implementations. +/// +internal static class OutputCacheEntryFormatter +{ + public static async ValueTask GetAsync(string key, IOutputCacheStore store, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(key); + + var content = await store.GetAsync(key, cancellationToken); + + if (content == null) + { + return null; + } + + var formatter = JsonSerializer.Deserialize(content, FormatterEntrySerializerContext.Default.FormatterEntry); + + if (formatter == null) + { + return null; + } + + var outputCacheEntry = new OutputCacheEntry + { + StatusCode = formatter.StatusCode, + Created = formatter.Created, + Tags = formatter.Tags, + Headers = new(), + Body = new CachedResponseBody(formatter.Body, formatter.Body.Sum(x => x.Length)) + }; + + if (formatter.Headers != null) + { + foreach (var header in formatter.Headers) + { + outputCacheEntry.Headers.TryAdd(header.Key, header.Value); + } + } + + return outputCacheEntry; + } + + public static async ValueTask StoreAsync(string key, OutputCacheEntry value, TimeSpan duration, IOutputCacheStore store, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(value.Body); + ArgumentNullException.ThrowIfNull(value.Headers); + + var formatterEntry = new FormatterEntry + { + StatusCode = value.StatusCode, + Created = value.Created, + Tags = value.Tags, + Body = value.Body.Segments + }; + + if (value.Headers != null) + { + formatterEntry.Headers = new(); + foreach (var header in value.Headers) + { + formatterEntry.Headers.TryAdd(header.Key, header.Value.ToArray()); + } + } + + using var bufferStream = new MemoryStream(); + + JsonSerializer.Serialize(bufferStream, formatterEntry, FormatterEntrySerializerContext.Default.FormatterEntry); + + await store.SetAsync(key, bufferStream.ToArray(), value.Tags ?? Array.Empty(), duration, cancellationToken); + } +} diff --git a/src/Middleware/OutputCaching/src/OutputCacheFeature.cs b/src/Middleware/OutputCaching/src/OutputCacheFeature.cs new file mode 100644 index 000000000000..b1ce32b97a69 --- /dev/null +++ b/src/Middleware/OutputCaching/src/OutputCacheFeature.cs @@ -0,0 +1,14 @@ +// 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.OutputCaching; + +internal sealed class OutputCacheFeature : IOutputCacheFeature +{ + public OutputCacheFeature(OutputCacheContext context) + { + Context = context; + } + + public OutputCacheContext Context { get; } +} diff --git a/src/Middleware/OutputCaching/src/OutputCacheKeyProvider.cs b/src/Middleware/OutputCaching/src/OutputCacheKeyProvider.cs new file mode 100644 index 000000000000..dc8cce4afc15 --- /dev/null +++ b/src/Middleware/OutputCaching/src/OutputCacheKeyProvider.cs @@ -0,0 +1,190 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Text; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.OutputCaching; + +internal sealed class OutputCacheKeyProvider : IOutputCacheKeyProvider +{ + // Use the record separator for delimiting components of the cache key to avoid possible collisions + private const char KeyDelimiter = '\x1e'; + // Use the unit separator for delimiting subcomponents of the cache key to avoid possible collisions + private const char KeySubDelimiter = '\x1f'; + + private readonly ObjectPool _builderPool; + private readonly OutputCacheOptions _options; + + internal OutputCacheKeyProvider(ObjectPoolProvider poolProvider, IOptions options) + { + ArgumentNullException.ThrowIfNull(poolProvider); + ArgumentNullException.ThrowIfNull(options); + + _builderPool = poolProvider.CreateStringBuilderPool(); + _options = options.Value; + } + + // GETSCHEMEHOST:PORT/PATHBASE/PATHHHeaderName=HeaderValueQQueryName=QueryValue1QueryValue2 + public string CreateStorageKey(OutputCacheContext context) + { + ArgumentNullException.ThrowIfNull(_builderPool); + + var varyByRules = context.CacheVaryByRules; + if (varyByRules == null) + { + throw new InvalidOperationException($"{nameof(CacheVaryByRules)} must not be null on the {nameof(OutputCacheContext)}"); + } + + var request = context.HttpContext.Request; + var builder = _builderPool.Get(); + + try + { + builder + .AppendUpperInvariant(request.Method) + .Append(KeyDelimiter) + .AppendUpperInvariant(request.Scheme) + .Append(KeyDelimiter) + .AppendUpperInvariant(request.Host.Value); + + if (_options.UseCaseSensitivePaths) + { + builder + .Append(request.PathBase.Value) + .Append(request.Path.Value); + } + else + { + builder + .AppendUpperInvariant(request.PathBase.Value) + .AppendUpperInvariant(request.Path.Value); + } + + // Vary by prefix and custom + var prefixCount = varyByRules?.VaryByPrefix.Count ?? 0; + if (prefixCount > 0) + { + // Append a group separator for the header segment of the cache key + builder.Append(KeyDelimiter) + .Append('C'); + + for (var i = 0; i < prefixCount; i++) + { + var value = varyByRules?.VaryByPrefix[i] ?? string.Empty; + builder.Append(KeyDelimiter).Append(value); + } + } + + // Vary by headers + var headersCount = varyByRules?.Headers.Count ?? 0; + if (headersCount > 0) + { + // Append a group separator for the header segment of the cache key + builder.Append(KeyDelimiter) + .Append('H'); + + var requestHeaders = context.HttpContext.Request.Headers; + for (var i = 0; i < headersCount; i++) + { + var header = varyByRules!.Headers[i] ?? string.Empty; + var headerValues = requestHeaders[header]; + builder.Append(KeyDelimiter) + .Append(header) + .Append('='); + + var headerValuesArray = headerValues.ToArray(); + Array.Sort(headerValuesArray, StringComparer.Ordinal); + + for (var j = 0; j < headerValuesArray.Length; j++) + { + builder.Append(headerValuesArray[j]); + } + } + } + + // Vary by query keys + if (varyByRules?.QueryKeys.Count > 0) + { + // Append a group separator for the query key segment of the cache key + builder.Append(KeyDelimiter) + .Append('Q'); + + if (varyByRules.QueryKeys.Count == 1 && string.Equals(varyByRules.QueryKeys[0], "*", StringComparison.Ordinal) && context.HttpContext.Request.Query.Count > 0) + { + // Vary by all available query keys + var queryArray = context.HttpContext.Request.Query.ToArray(); + // Query keys are aggregated case-insensitively whereas the query values are compared ordinally. + Array.Sort(queryArray, QueryKeyComparer.OrdinalIgnoreCase); + + for (var i = 0; i < queryArray.Length; i++) + { + builder.Append(KeyDelimiter) + .AppendUpperInvariant(queryArray[i].Key) + .Append('='); + + var queryValueArray = queryArray[i].Value.ToArray(); + Array.Sort(queryValueArray, StringComparer.Ordinal); + + for (var j = 0; j < queryValueArray.Length; j++) + { + if (j > 0) + { + builder.Append(KeySubDelimiter); + } + + builder.Append(queryValueArray[j]); + } + } + } + else + { + for (var i = 0; i < varyByRules.QueryKeys.Count; i++) + { + var queryKey = varyByRules.QueryKeys[i] ?? string.Empty; + var queryKeyValues = context.HttpContext.Request.Query[queryKey]; + builder.Append(KeyDelimiter) + .Append(queryKey) + .Append('='); + + var queryValueArray = queryKeyValues.ToArray(); + Array.Sort(queryValueArray, StringComparer.Ordinal); + + for (var j = 0; j < queryValueArray.Length; j++) + { + if (j > 0) + { + builder.Append(KeySubDelimiter); + } + + builder.Append(queryValueArray[j]); + } + } + } + } + + return builder.ToString(); + } + finally + { + _builderPool.Return(builder); + } + } + + private sealed class QueryKeyComparer : IComparer> + { + private readonly StringComparer _stringComparer; + + public static QueryKeyComparer OrdinalIgnoreCase { get; } = new QueryKeyComparer(StringComparer.OrdinalIgnoreCase); + + public QueryKeyComparer(StringComparer stringComparer) + { + _stringComparer = stringComparer; + } + + public int Compare(KeyValuePair x, KeyValuePair y) => _stringComparer.Compare(x.Key, y.Key); + } +} diff --git a/src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs b/src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs new file mode 100644 index 000000000000..a526358e114b --- /dev/null +++ b/src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs @@ -0,0 +1,620 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.OutputCaching; + +/// +/// Enable HTTP response caching. +/// +internal sealed class OutputCacheMiddleware +{ + // see https://tools.ietf.org/html/rfc7232#section-4.1 + private static readonly string[] HeadersToIncludeIn304 = + new[] { "Cache-Control", "Content-Location", "Date", "ETag", "Expires", "Vary" }; + + private readonly RequestDelegate _next; + private readonly OutputCacheOptions _options; + private readonly ILogger _logger; + private readonly IOutputCacheStore _store; + private readonly IOutputCacheKeyProvider _keyProvider; + private readonly WorkDispatcher _outputCacheEntryDispatcher; + private readonly WorkDispatcher _requestDispatcher; + + /// + /// Creates a new . + /// + /// The representing the next middleware in the pipeline. + /// The options for this middleware. + /// The used for logging. + /// The store. + /// The used for creating instances. + public OutputCacheMiddleware( + RequestDelegate next, + IOptions options, + ILoggerFactory loggerFactory, + IOutputCacheStore outputCache, + ObjectPoolProvider poolProvider + ) + : this( + next, + options, + loggerFactory, + outputCache, + new OutputCacheKeyProvider(poolProvider, options)) + { } + + // for testing + internal OutputCacheMiddleware( + RequestDelegate next, + IOptions options, + ILoggerFactory loggerFactory, + IOutputCacheStore cache, + IOutputCacheKeyProvider keyProvider) + { + ArgumentNullException.ThrowIfNull(next); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(loggerFactory); + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(keyProvider); + + _next = next; + _options = options.Value; + _logger = loggerFactory.CreateLogger(); + _store = cache; + _keyProvider = keyProvider; + _outputCacheEntryDispatcher = new(); + _requestDispatcher = new(); + } + + /// + /// Invokes the logic of the middleware. + /// + /// The . + /// A that completes when the middleware has completed processing. + public Task Invoke(HttpContext httpContext) + { + // Skip the middleware if there is no policy for the current request + if (!TryGetRequestPolicies(httpContext, out var policies)) + { + return _next(httpContext); + } + + return InvokeAwaited(httpContext, policies); + } + + private async Task InvokeAwaited(HttpContext httpContext, IReadOnlyList policies) + { + var context = new OutputCacheContext(httpContext, _store, _options, _logger); + + // Add IOutputCacheFeature + AddOutputCacheFeature(context); + + try + { + foreach (var policy in policies) + { + await policy.CacheRequestAsync(context, httpContext.RequestAborted); + } + + // Should we attempt any caching logic? + if (context.EnableOutputCaching) + { + // Can this request be served from cache? + if (context.AllowCacheLookup) + { + if (await TryServeFromCacheAsync(context, policies)) + { + return; + } + } + + // Should we store the response to this request? + if (context.AllowCacheStorage) + { + // It is also a pre-condition to reponse locking + + var executed = false; + + if (context.AllowLocking) + { + var cacheEntry = await _requestDispatcher.ScheduleAsync(context.CacheKey, key => ExecuteResponseAsync()); + + // The current request was processed, nothing more to do + if (executed) + { + return; + } + + // If the result was processed by another request, try to serve it from cache entry (no lookup) + if (await TryServeCachedResponseAsync(context, cacheEntry, policies)) + { + return; + } + + // If the cache entry couldn't be served, continue to processing the request as usual + } + + await ExecuteResponseAsync(); + + async Task ExecuteResponseAsync() + { + // Hook up to listen to the response stream + ShimResponseStream(context); + + try + { + await _next(httpContext); + + // The next middleware might change the policy + foreach (var policy in policies) + { + await policy.ServeResponseAsync(context, httpContext.RequestAborted); + } + + // If there was no response body, check the response headers now. We can cache things like redirects. + StartResponse(context); + + // Finalize the cache entry + await FinalizeCacheBodyAsync(context); + + executed = true; + } + finally + { + UnshimResponseStream(context); + } + + return context.CachedResponse; + } + + return; + } + } + + await _next(httpContext); + } + finally + { + RemoveOutputCacheFeature(httpContext); + } + } + + internal bool TryGetRequestPolicies(HttpContext httpContext, out IReadOnlyList policies) + { + policies = Array.Empty(); + List? result = null; + + if (_options.BasePolicies != null) + { + result = new(); + result.AddRange(_options.BasePolicies); + } + + var metadata = httpContext.GetEndpoint()?.Metadata; + + var policy = metadata?.GetMetadata(); + + if (policy != null) + { + result ??= new(); + result.Add(policy); + } + + var attribute = metadata?.GetMetadata(); + + if (attribute != null) + { + result ??= new(); + result.Add(attribute.BuildPolicy()); + } + + if (result != null) + { + policies = result; + return true; + } + + return false; + } + + internal async Task TryServeCachedResponseAsync(OutputCacheContext context, OutputCacheEntry? cacheEntry, IReadOnlyList policies) + { + if (cacheEntry == null) + { + return false; + } + + context.CachedResponse = cacheEntry; + context.ResponseTime = _options.SystemClock.UtcNow; + var cacheEntryAge = context.ResponseTime.Value - context.CachedResponse.Created; + context.CachedEntryAge = cacheEntryAge > TimeSpan.Zero ? cacheEntryAge : TimeSpan.Zero; + + foreach (var policy in policies) + { + await policy.ServeFromCacheAsync(context, context.HttpContext.RequestAborted); + } + + context.IsCacheEntryFresh = true; + + // Validate expiration + if (context.CachedEntryAge <= TimeSpan.Zero) + { + context.Logger.ExpirationExpiresExceeded(context.ResponseTime!.Value); + context.IsCacheEntryFresh = false; + } + + if (context.IsCacheEntryFresh) + { + var cachedResponseHeaders = context.CachedResponse.Headers; + + // Check conditional request rules + if (ContentIsNotModified(context)) + { + _logger.NotModifiedServed(); + context.HttpContext.Response.StatusCode = StatusCodes.Status304NotModified; + + if (cachedResponseHeaders != null) + { + foreach (var key in HeadersToIncludeIn304) + { + if (cachedResponseHeaders.TryGetValue(key, out var values)) + { + context.HttpContext.Response.Headers[key] = values; + } + } + } + } + else + { + var response = context.HttpContext.Response; + // Copy the cached status code and response headers + response.StatusCode = context.CachedResponse.StatusCode; + foreach (var header in context.CachedResponse.Headers) + { + response.Headers[header.Key] = header.Value; + } + + // Note: int64 division truncates result and errors may be up to 1 second. This reduction in + // accuracy of age calculation is considered appropriate since it is small compared to clock + // skews and the "Age" header is an estimate of the real age of cached content. + response.Headers.Age = HeaderUtilities.FormatNonNegativeInt64(context.CachedEntryAge.Ticks / TimeSpan.TicksPerSecond); + + // Copy the cached response body + var body = context.CachedResponse.Body; + if (body.Length > 0) + { + try + { + await body.CopyToAsync(response.BodyWriter, context.HttpContext.RequestAborted); + } + catch (OperationCanceledException) + { + context.HttpContext.Abort(); + } + } + _logger.CachedResponseServed(); + } + return true; + } + + return false; + } + + internal async Task TryServeFromCacheAsync(OutputCacheContext cacheContext, IReadOnlyList policies) + { + CreateCacheKey(cacheContext); + + // Locking cache lookups by default + // TODO: should it be part of the cache implementations or can we assume all caches would benefit from it? + // It makes sense for caches that use IO (disk, network) or need to deserialize the state but could also be a global option + + var cacheEntry = await _outputCacheEntryDispatcher.ScheduleAsync(cacheContext.CacheKey, cacheContext, static async (key, cacheContext) => await OutputCacheEntryFormatter.GetAsync(key, cacheContext.Store, cacheContext.HttpContext.RequestAborted)); + + if (await TryServeCachedResponseAsync(cacheContext, cacheEntry, policies)) + { + return true; + } + + if (HeaderUtilities.ContainsCacheDirective(cacheContext.HttpContext.Request.Headers.CacheControl, CacheControlHeaderValue.OnlyIfCachedString)) + { + _logger.GatewayTimeoutServed(); + cacheContext.HttpContext.Response.StatusCode = StatusCodes.Status504GatewayTimeout; + return true; + } + + _logger.NoResponseServed(); + return false; + } + + internal void CreateCacheKey(OutputCacheContext context) + { + if (!string.IsNullOrEmpty(context.CacheKey)) + { + return; + } + + var varyHeaders = context.CacheVaryByRules.Headers; + var varyQueryKeys = context.CacheVaryByRules.QueryKeys; + var varyByCustomKeys = context.CacheVaryByRules.VaryByCustom; + var varyByPrefix = context.CacheVaryByRules.VaryByPrefix; + + // Check if any vary rules exist + if (!StringValues.IsNullOrEmpty(varyHeaders) || !StringValues.IsNullOrEmpty(varyQueryKeys) || !StringValues.IsNullOrEmpty(varyByPrefix) || varyByCustomKeys?.Count > 0) + { + // Normalize order and casing of vary by rules + var normalizedVaryHeaders = GetOrderCasingNormalizedStringValues(varyHeaders); + var normalizedVaryQueryKeys = GetOrderCasingNormalizedStringValues(varyQueryKeys); + var normalizedVaryByCustom = GetOrderCasingNormalizedDictionary(varyByCustomKeys); + + // Update vary rules with normalized values + context.CacheVaryByRules = new CacheVaryByRules + { + VaryByPrefix = varyByPrefix + normalizedVaryByCustom, + Headers = normalizedVaryHeaders, + QueryKeys = normalizedVaryQueryKeys + }; + + // TODO: Add same condition on LogLevel in Response Caching + // Always overwrite the CachedVaryByRules to update the expiry information + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.VaryByRulesUpdated(normalizedVaryHeaders.ToString(), normalizedVaryQueryKeys.ToString()); + } + } + + context.CacheKey = _keyProvider.CreateStorageKey(context); + } + + /// + /// Finalize cache headers. + /// + /// + internal void FinalizeCacheHeaders(OutputCacheContext context) + { + if (context.AllowCacheStorage) + { + // Create the cache entry now + var response = context.HttpContext.Response; + var headers = response.Headers; + + context.CachedResponseValidFor = context.ResponseExpirationTimeSpan ?? _options.DefaultExpirationTimeSpan; + + // Setting the date on the raw response headers. + headers.Date = HeaderUtilities.FormatDate(context.ResponseTime!.Value); + + // Store the response on the state + context.CachedResponse = new OutputCacheEntry + { + Created = context.ResponseTime!.Value, + StatusCode = response.StatusCode, + Headers = new HeaderDictionary(), + Tags = context.Tags.ToArray() + }; + + foreach (var header in headers) + { + if (!string.Equals(header.Key, HeaderNames.Age, StringComparison.OrdinalIgnoreCase)) + { + context.CachedResponse.Headers[header.Key] = header.Value; + } + } + + return; + } + + context.OutputCacheStream.DisableBuffering(); + } + + /// + /// Stores the response body + /// + internal async ValueTask FinalizeCacheBodyAsync(OutputCacheContext context) + { + if (context.AllowCacheStorage && context.OutputCacheStream.BufferingEnabled) + { + // If AllowCacheLookup is false, the cache key was not created + CreateCacheKey(context); + + var contentLength = context.HttpContext.Response.ContentLength; + var cachedResponseBody = context.OutputCacheStream.GetCachedResponseBody(); + if (!contentLength.HasValue || contentLength == cachedResponseBody.Length + || (cachedResponseBody.Length == 0 + && HttpMethods.IsHead(context.HttpContext.Request.Method))) + { + var response = context.HttpContext.Response; + // Add a content-length if required + if (!response.ContentLength.HasValue && StringValues.IsNullOrEmpty(response.Headers.TransferEncoding)) + { + context.CachedResponse.Headers.ContentLength = cachedResponseBody.Length; + } + + context.CachedResponse.Body = cachedResponseBody; + _logger.ResponseCached(); + + if (string.IsNullOrEmpty(context.CacheKey)) + { + throw new InvalidOperationException("Cache key must be defined"); + } + + await OutputCacheEntryFormatter.StoreAsync(context.CacheKey, context.CachedResponse, context.CachedResponseValidFor, _store, context.HttpContext.RequestAborted); + } + else + { + _logger.ResponseContentLengthMismatchNotCached(); + } + } + else + { + _logger.ResponseNotCached(); + } + } + + /// + /// Mark the response as started and set the response time if no response was started yet. + /// + /// + /// true if the response was not started before this call; otherwise false. + private bool OnStartResponse(OutputCacheContext context) + { + if (!context.ResponseStarted) + { + context.ResponseStarted = true; + context.ResponseTime = _options.SystemClock.UtcNow; + + return true; + } + return false; + } + + internal void StartResponse(OutputCacheContext context) + { + if (OnStartResponse(context)) + { + FinalizeCacheHeaders(context); + } + } + + internal static void AddOutputCacheFeature(OutputCacheContext context) + { + if (context.HttpContext.Features.Get() != null) + { + throw new InvalidOperationException($"Another instance of {nameof(OutputCacheFeature)} already exists. Only one instance of {nameof(OutputCacheMiddleware)} can be configured for an application."); + } + + context.HttpContext.Features.Set(new OutputCacheFeature(context)); + } + + internal void ShimResponseStream(OutputCacheContext context) + { + // Shim response stream + context.OriginalResponseStream = context.HttpContext.Response.Body; + context.OutputCacheStream = new OutputCacheStream( + context.OriginalResponseStream, + _options.MaximumBodySize, + StreamUtilities.BodySegmentSize, + () => StartResponse(context)); + context.HttpContext.Response.Body = context.OutputCacheStream; + } + + internal static void RemoveOutputCacheFeature(HttpContext context) => + context.Features.Set(null); + + internal static void UnshimResponseStream(OutputCacheContext context) + { + // Unshim response stream + context.HttpContext.Response.Body = context.OriginalResponseStream; + + // Remove IOutputCachingFeature + RemoveOutputCacheFeature(context.HttpContext); + } + + internal static bool ContentIsNotModified(OutputCacheContext context) + { + var cachedResponseHeaders = context.CachedResponse.Headers; + var ifNoneMatchHeader = context.HttpContext.Request.Headers.IfNoneMatch; + + if (!StringValues.IsNullOrEmpty(ifNoneMatchHeader)) + { + if (ifNoneMatchHeader.Count == 1 && StringSegment.Equals(ifNoneMatchHeader[0], EntityTagHeaderValue.Any.Tag, StringComparison.OrdinalIgnoreCase)) + { + context.Logger.NotModifiedIfNoneMatchStar(); + return true; + } + + if (!StringValues.IsNullOrEmpty(cachedResponseHeaders[HeaderNames.ETag]) + && EntityTagHeaderValue.TryParse(cachedResponseHeaders[HeaderNames.ETag].ToString(), out var eTag) + && EntityTagHeaderValue.TryParseList(ifNoneMatchHeader, out var ifNoneMatchEtags)) + { + for (var i = 0; i < ifNoneMatchEtags?.Count; i++) + { + var requestETag = ifNoneMatchEtags[i]; + if (eTag.Compare(requestETag, useStrongComparison: false)) + { + context.Logger.NotModifiedIfNoneMatchMatched(requestETag); + return true; + } + } + } + } + else + { + var ifModifiedSince = context.HttpContext.Request.Headers.IfModifiedSince; + if (!StringValues.IsNullOrEmpty(ifModifiedSince)) + { + if (!HeaderUtilities.TryParseDate(cachedResponseHeaders[HeaderNames.LastModified].ToString(), out var modified) && + !HeaderUtilities.TryParseDate(cachedResponseHeaders[HeaderNames.Date].ToString(), out modified)) + { + return false; + } + + if (HeaderUtilities.TryParseDate(ifModifiedSince.ToString(), out var modifiedSince) && + modified <= modifiedSince) + { + context.Logger.NotModifiedIfModifiedSinceSatisfied(modified, modifiedSince); + return true; + } + } + } + + return false; + } + + // Normalize order and casing + internal static StringValues GetOrderCasingNormalizedStringValues(StringValues stringValues) + { + if (stringValues.Count == 0) + { + return StringValues.Empty; + } + else if (stringValues.Count == 1) + { + return new StringValues(stringValues.ToString().ToUpperInvariant()); + } + else + { + var originalArray = stringValues.ToArray(); + var newArray = new string[originalArray.Length]; + + for (var i = 0; i < originalArray.Length; i++) + { + newArray[i] = originalArray[i]!.ToUpperInvariant(); + } + + // Since the casing has already been normalized, use Ordinal comparison + Array.Sort(newArray, StringComparer.Ordinal); + + return new StringValues(newArray); + } + } + + internal static StringValues GetOrderCasingNormalizedDictionary(IDictionary? dictionary) + { + const char KeySubDelimiter = '\x1f'; + + if (dictionary == null || dictionary.Count == 0) + { + return StringValues.Empty; + } + + var newArray = new string[dictionary.Count]; + + var i = 0; + foreach (var (key, value) in dictionary) + { + newArray[i++] = $"{key.ToUpperInvariant()}{KeySubDelimiter}{value}"; + } + + // Since the casing has already been normalized, use Ordinal comparison + Array.Sort(newArray, StringComparer.Ordinal); + + return new StringValues(newArray); + } +} diff --git a/src/Middleware/OutputCaching/src/OutputCacheOptions.cs b/src/Middleware/OutputCaching/src/OutputCacheOptions.cs new file mode 100644 index 000000000000..fdd316d4d3be --- /dev/null +++ b/src/Middleware/OutputCaching/src/OutputCacheOptions.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; + +namespace Microsoft.AspNetCore.OutputCaching; + +/// +/// Options for configuring the . +/// +public class OutputCacheOptions +{ + /// + /// The size limit for the output cache middleware in bytes. The default is set to 100 MB. + /// When this limit is exceeded, no new responses will be cached until older entries are + /// evicted. + /// + public long SizeLimit { get; set; } = 100 * 1024 * 1024; + + /// + /// The largest cacheable size for the response body in bytes. The default is set to 64 MB. + /// If the response body exceeds this limit, it will not be cached by the . + /// + public long MaximumBodySize { get; set; } = 64 * 1024 * 1024; + + /// + /// The duration a response is cached when no specific value is defined by a policy. The default is set to 60 seconds. + /// + public TimeSpan DefaultExpirationTimeSpan { get; set; } = TimeSpan.FromSeconds(60); + + /// + /// true if request paths are case-sensitive; otherwise false. The default is to treat paths as case-insensitive. + /// + public bool UseCaseSensitivePaths { get; set; } + + /// + /// Gets the application . + /// + public IServiceProvider ApplicationServices { get; internal set; } = default!; + + internal Dictionary? NamedPolicies { get; set; } + + internal List? BasePolicies { get; set; } + + /// + /// For testing purposes only. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + internal ISystemClock SystemClock { get; set; } = new SystemClock(); + + /// + /// Defines a which can be referenced by name. + /// + /// The name of the policy. + /// The policy to add + public void AddPolicy(string name, IOutputCachePolicy policy) + { + NamedPolicies ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + NamedPolicies[name] = policy; + } + + /// + /// Defines a which can be referenced by name. + /// + /// The name of the policy. + /// an action on . + public void AddPolicy(string name, Action build) + { + var builder = new OutputCachePolicyBuilder(); + build(builder); + NamedPolicies ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + NamedPolicies[name] = builder.Build(); + } + + /// + /// Adds an instance to base policies. + /// + /// The policy to add + public void AddBasePolicy(IOutputCachePolicy policy) + { + BasePolicies ??= new(); + BasePolicies.Add(policy); + } + + /// + /// Builds and adds an instance to base policies. + /// + /// an action on . + public void AddBasePolicy(Action build) + { + var builder = new OutputCachePolicyBuilder(); + build(builder); + BasePolicies ??= new(); + BasePolicies.Add(builder.Build()); + } +} diff --git a/src/Middleware/OutputCaching/src/OutputCacheOptionsSetup.cs b/src/Middleware/OutputCaching/src/OutputCacheOptionsSetup.cs new file mode 100644 index 000000000000..76353e2d798a --- /dev/null +++ b/src/Middleware/OutputCaching/src/OutputCacheOptionsSetup.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.Extensions.Options; + +namespace Microsoft.AspNetCore.OutputCaching; + +internal sealed class OutputCacheOptionsSetup : IConfigureOptions +{ + private readonly IServiceProvider _services; + + public OutputCacheOptionsSetup(IServiceProvider services) + { + _services = services; + } + + public void Configure(OutputCacheOptions options) + { + options.ApplicationServices = _services; + } +} diff --git a/src/Middleware/OutputCaching/src/OutputCachePolicyBuilder.cs b/src/Middleware/OutputCaching/src/OutputCachePolicyBuilder.cs new file mode 100644 index 000000000000..e2c2e59403bb --- /dev/null +++ b/src/Middleware/OutputCaching/src/OutputCachePolicyBuilder.cs @@ -0,0 +1,244 @@ +// 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 System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OutputCaching.Policies; + +namespace Microsoft.AspNetCore.OutputCaching; + +/// +/// Provides helper methods to create custom policies. +/// +public sealed class OutputCachePolicyBuilder +{ + private const DynamicallyAccessedMemberTypes ActivatorAccessibility = DynamicallyAccessedMemberTypes.PublicConstructors; + + private IOutputCachePolicy? _builtPolicy; + private readonly List _policies = new(); + private List>>? _requirements; + + /// + /// Creates a new instance. + /// + public OutputCachePolicyBuilder() + { + _builtPolicy = null; + _policies.Add(DefaultPolicy.Instance); + } + + internal OutputCachePolicyBuilder AddPolicy(IOutputCachePolicy policy) + { + _builtPolicy = null; + _policies.Add(policy); + return this; + } + + /// + /// Adds a dynamically resolved policy. + /// + /// The type of policy to add + public OutputCachePolicyBuilder AddPolicy([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type policyType) + { + return AddPolicy(new TypedPolicy(policyType)); + } + + /// + /// Adds a dynamically resolved policy. + /// + /// The policy type. + public OutputCachePolicyBuilder AddPolicy<[DynamicallyAccessedMembers(ActivatorAccessibility)] T>() where T : IOutputCachePolicy + { + return AddPolicy(typeof(T)); + } + + /// + /// Adds a requirement to the current policy. + /// + /// The predicate applied to the policy. + public OutputCachePolicyBuilder With(Func> predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + + _builtPolicy = null; + _requirements ??= new(); + _requirements.Add(predicate); + return this; + } + + /// + /// Adds a requirement to the current policy. + /// + /// The predicate applied to the policy. + public OutputCachePolicyBuilder With(Func predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + + _builtPolicy = null; + _requirements ??= new(); + _requirements.Add((c, t) => Task.FromResult(predicate(c))); + return this; + } + + /// + /// Adds a policy to vary the cached responses by query strings. + /// + /// The query keys to vary the cached responses by. Leave empty to ignore all query strings. + /// + /// By default all query keys vary the cache entries. However when specific query keys are specified only these are then taken into account. + /// + public OutputCachePolicyBuilder VaryByQuery(params string[] queryKeys) + { + ArgumentNullException.ThrowIfNull(queryKeys); + + return AddPolicy(new VaryByQueryPolicy(queryKeys)); + } + + /// + /// Adds a policy to vary the cached responses by header. + /// + /// The headers to vary the cached responses by. + public OutputCachePolicyBuilder VaryByHeader(params string[] headers) + { + ArgumentNullException.ThrowIfNull(headers); + + return AddPolicy(new VaryByHeaderPolicy(headers)); + } + + /// + /// Adds a policy to vary the cached responses by custom values. + /// + /// The value to vary the cached responses by. + public OutputCachePolicyBuilder VaryByValue(Func> varyBy) + { + ArgumentNullException.ThrowIfNull(varyBy); + + return AddPolicy(new VaryByValuePolicy(varyBy)); + } + + /// + /// Adds a policy to vary the cached responses by custom key/value. + /// + /// The key/value to vary the cached responses by. + public OutputCachePolicyBuilder VaryByValue(Func>> varyBy) + { + ArgumentNullException.ThrowIfNull(varyBy); + + return AddPolicy(new VaryByValuePolicy(varyBy)); + } + + /// + /// Adds a policy to vary the cached responses by custom values. + /// + /// The value to vary the cached responses by. + public OutputCachePolicyBuilder VaryByValue(Func varyBy) + { + ArgumentNullException.ThrowIfNull(varyBy); + + return AddPolicy(new VaryByValuePolicy(varyBy)); + } + + /// + /// Adds a policy to vary the cached responses by custom key/value. + /// + /// The key/value to vary the cached responses by. + public OutputCachePolicyBuilder VaryByValue(Func> varyBy) + { + ArgumentNullException.ThrowIfNull(varyBy); + + return AddPolicy(new VaryByValuePolicy(varyBy)); + } + + /// + /// Adds a policy to tag the cached response. + /// + /// The tags to add to the cached reponse. + public OutputCachePolicyBuilder Tag(params string[] tags) + { + ArgumentNullException.ThrowIfNull(tags); + + return AddPolicy(new TagsPolicy(tags)); + } + + /// + /// Adds a policy to change the cached response expiration. + /// + /// The expiration of the cached reponse. + public OutputCachePolicyBuilder Expire(TimeSpan expiration) + { + return AddPolicy(new ExpirationPolicy(expiration)); + } + + /// + /// Adds a policy to change the request locking strategy. + /// + /// Whether the request should be locked. + public OutputCachePolicyBuilder AllowLocking(bool lockResponse = true) + { + return AddPolicy(lockResponse ? LockingPolicy.Enabled : LockingPolicy.Disabled); + } + + /// + /// Clears the current policies. + /// + /// It also removed the default cache policy. + public OutputCachePolicyBuilder Clear() + { + _builtPolicy = null; + if (_requirements != null) + { + _requirements.Clear(); + } + _policies.Clear(); + return this; + } + + /// + /// Clears the policies and adds one preventing any caching logic to happen. + /// + /// + /// The cache key will never be computed. + /// + public OutputCachePolicyBuilder NoCache() + { + _policies.Clear(); + return AddPolicy(EnableCachePolicy.Disabled); + } + + /// + /// Creates the . + /// + /// The instance. + internal IOutputCachePolicy Build() + { + if (_builtPolicy != null) + { + return _builtPolicy; + } + + var policies = _policies.Count == 1 + ? _policies[0] + : new CompositePolicy(_policies.ToArray()) + ; + + // If the policy was built with requirements, wrap it + if (_requirements != null && _requirements.Any()) + { + policies = new PredicatePolicy(async c => + { + foreach (var r in _requirements) + { + if (!await r(c, c.HttpContext.RequestAborted)) + { + return false; + } + } + + return true; + }, policies); + } + + return _builtPolicy = policies; + } +} diff --git a/src/Middleware/OutputCaching/src/OutputCacheServiceCollectionExtensions.cs b/src/Middleware/OutputCaching/src/OutputCacheServiceCollectionExtensions.cs new file mode 100644 index 000000000000..b0184e1339a2 --- /dev/null +++ b/src/Middleware/OutputCaching/src/OutputCacheServiceCollectionExtensions.cs @@ -0,0 +1,58 @@ +// 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.OutputCaching; +using Microsoft.AspNetCore.OutputCaching.Memory; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for the OutputCaching middleware. +/// +public static class OutputCacheServiceCollectionExtensions +{ + /// + /// Add output caching services. + /// + /// The for adding services. + /// + public static IServiceCollection AddOutputCache(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddTransient, OutputCacheOptionsSetup>(); + + services.TryAddSingleton(); + + services.TryAddSingleton(sp => + { + var outputCacheOptions = sp.GetRequiredService>(); + return new MemoryOutputCacheStore(new MemoryCache(new MemoryCacheOptions + { + SizeLimit = outputCacheOptions.Value.SizeLimit + })); + }); + return services; + } + + /// + /// Add output caching services and configure the related options. + /// + /// The for adding services. + /// A delegate to configure the . + /// + public static IServiceCollection AddOutputCache(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + services.Configure(configureOptions); + services.AddOutputCache(); + + return services; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/CompositePolicy.cs b/src/Middleware/OutputCaching/src/Policies/CompositePolicy.cs new file mode 100644 index 000000000000..b82b54386d5e --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/CompositePolicy.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. + +namespace Microsoft.AspNetCore.OutputCaching.Policies; + +/// +/// A composite policy. +/// +internal sealed class CompositePolicy : IOutputCachePolicy +{ + private readonly IOutputCachePolicy[] _policies; + + /// + /// Creates a new instance of + /// + /// The policies to include. + public CompositePolicy(params IOutputCachePolicy[] policies) + { + _policies = policies; + } + + /// + async ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + foreach (var policy in _policies) + { + await policy.CacheRequestAsync(context, cancellationToken); + } + } + + /// + async ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + foreach (var policy in _policies) + { + await policy.ServeFromCacheAsync(context, cancellationToken); + } + } + + /// + async ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + foreach (var policy in _policies) + { + await policy.ServeResponseAsync(context, cancellationToken); + } + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/DefaultPolicy.cs b/src/Middleware/OutputCaching/src/Policies/DefaultPolicy.cs new file mode 100644 index 000000000000..c17a700b0fae --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/DefaultPolicy.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.Http; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.OutputCaching; + +/// +/// A policy which caches un-authenticated, GET and HEAD, 200 responses. +/// +internal sealed class DefaultPolicy : IOutputCachePolicy +{ + public static readonly DefaultPolicy Instance = new(); + + private DefaultPolicy() + { + } + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + var attemptOutputCaching = AttemptOutputCaching(context); + context.EnableOutputCaching = true; + context.AllowCacheLookup = attemptOutputCaching; + context.AllowCacheStorage = attemptOutputCaching; + context.AllowLocking = true; + + // Vary by any query by default + context.CacheVaryByRules.QueryKeys = "*"; + + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + var response = context.HttpContext.Response; + + // Verify existence of cookie headers + if (!StringValues.IsNullOrEmpty(response.Headers.SetCookie)) + { + context.Logger.ResponseWithSetCookieNotCacheable(); + context.AllowCacheStorage = false; + return ValueTask.CompletedTask; + } + + // Check response code + if (response.StatusCode != StatusCodes.Status200OK) + { + context.Logger.ResponseWithUnsuccessfulStatusCodeNotCacheable(response.StatusCode); + context.AllowCacheStorage = false; + return ValueTask.CompletedTask; + } + + return ValueTask.CompletedTask; + } + + private static bool AttemptOutputCaching(OutputCacheContext context) + { + // Check if the current request fulfisls the requirements to be cached + + var request = context.HttpContext.Request; + + // Verify the method + if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method)) + { + context.Logger.RequestMethodNotCacheable(request.Method); + return false; + } + + // Verify existence of authorization headers + if (!StringValues.IsNullOrEmpty(request.Headers.Authorization) || request.HttpContext.User?.Identity?.IsAuthenticated == true) + { + context.Logger.RequestWithAuthorizationNotCacheable(); + return false; + } + + return true; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/EnableCachePolicy.cs b/src/Middleware/OutputCaching/src/Policies/EnableCachePolicy.cs new file mode 100644 index 000000000000..3ebb8d4764d8 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/EnableCachePolicy.cs @@ -0,0 +1,37 @@ +// 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.OutputCaching; + +/// +/// A policy that enables caching +/// +internal sealed class EnableCachePolicy : IOutputCachePolicy +{ + public static readonly EnableCachePolicy Enabled = new(); + public static readonly EnableCachePolicy Disabled = new(); + + private EnableCachePolicy() + { + } + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + context.EnableOutputCaching = this == Enabled; + + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/ExpirationPolicy.cs b/src/Middleware/OutputCaching/src/Policies/ExpirationPolicy.cs new file mode 100644 index 000000000000..5a9f963f63da --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/ExpirationPolicy.cs @@ -0,0 +1,41 @@ +// 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.OutputCaching; + +/// +/// A policy that defines a custom expiration duration. +/// +internal sealed class ExpirationPolicy : IOutputCachePolicy +{ + private readonly TimeSpan _expiration; + + /// + /// Creates a new instance. + /// + /// The expiration duration. + public ExpirationPolicy(TimeSpan expiration) + { + _expiration = expiration; + } + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + context.ResponseExpirationTimeSpan = _expiration; + + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/LockingPolicy.cs b/src/Middleware/OutputCaching/src/Policies/LockingPolicy.cs new file mode 100644 index 000000000000..504ac4e6a20a --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/LockingPolicy.cs @@ -0,0 +1,47 @@ +// 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.OutputCaching; + +/// +/// A policy that changes the locking behavior. +/// +internal sealed class LockingPolicy : IOutputCachePolicy +{ + private readonly bool _lockResponse; + + private LockingPolicy(bool lockResponse) + { + _lockResponse = lockResponse; + } + + /// + /// A policy that enables locking. + /// + public static readonly LockingPolicy Enabled = new(true); + + /// + /// A policy that disables locking. + /// + public static readonly LockingPolicy Disabled = new(false); + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + context.AllowLocking = _lockResponse; + + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/NamedPolicy.cs b/src/Middleware/OutputCaching/src/Policies/NamedPolicy.cs new file mode 100644 index 000000000000..4967d8b1b3fc --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/NamedPolicy.cs @@ -0,0 +1,69 @@ +// 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.OutputCaching; + +/// +/// A named policy. +/// +internal sealed class NamedPolicy : IOutputCachePolicy +{ + private readonly string _policyName; + + /// + /// Create a new instance. + /// + /// The name of the profile. + public NamedPolicy(string policyName) + { + _policyName = policyName; + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + var policy = GetProfilePolicy(context); + + if (policy == null) + { + return ValueTask.CompletedTask; + } + + return policy.ServeResponseAsync(context, cancellationToken); + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + var policy = GetProfilePolicy(context); + + if (policy == null) + { + return ValueTask.CompletedTask; + } + + return policy.ServeFromCacheAsync(context, cancellationToken); + } + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + var policy = GetProfilePolicy(context); + + if (policy == null) + { + return ValueTask.CompletedTask; + } + + return policy.CacheRequestAsync(context, cancellationToken); ; + } + + internal IOutputCachePolicy? GetProfilePolicy(OutputCacheContext context) + { + var policies = context.Options.NamedPolicies; + + return policies != null && policies.TryGetValue(_policyName, out var cacheProfile) + ? cacheProfile + : null; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/NoLookupPolicy.cs b/src/Middleware/OutputCaching/src/Policies/NoLookupPolicy.cs new file mode 100644 index 000000000000..d6fee5da0d9f --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/NoLookupPolicy.cs @@ -0,0 +1,36 @@ +// 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.OutputCaching; + +/// +/// A policy that prevents the response from being served from cache. +/// +internal sealed class NoLookupPolicy : IOutputCachePolicy +{ + public static NoLookupPolicy Instance = new(); + + private NoLookupPolicy() + { + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + context.AllowCacheLookup = false; + + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/NoStorePolicy.cs b/src/Middleware/OutputCaching/src/Policies/NoStorePolicy.cs new file mode 100644 index 000000000000..11a22b6c5e75 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/NoStorePolicy.cs @@ -0,0 +1,36 @@ +// 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.OutputCaching; + +/// +/// A policy that prevents the response from being cached. +/// +internal sealed class NoStorePolicy : IOutputCachePolicy +{ + public static NoStorePolicy Instance = new(); + + private NoStorePolicy() + { + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + context.AllowCacheStorage = false; + + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/OutputCacheConventionBuilderExtensions.cs b/src/Middleware/OutputCaching/src/Policies/OutputCacheConventionBuilderExtensions.cs new file mode 100644 index 000000000000..050fbeb84ed9 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/OutputCacheConventionBuilderExtensions.cs @@ -0,0 +1,81 @@ +// 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.OutputCaching; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// A set of endpoint extension methods. +/// +public static class OutputCacheConventionBuilderExtensions +{ + /// + /// Marks an endpoint to be cached with the default policy. + /// + public static TBuilder CacheOutput(this TBuilder builder) where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(builder); + + // Enable caching if this method is invoked on an endpoint, extra policies can disable it + + builder.Add(endpointBuilder => + { + endpointBuilder.Metadata.Add(DefaultPolicy.Instance); + }); + return builder; + } + + /// + /// Marks an endpoint to be cached with the specified policy. + /// + public static TBuilder CacheOutput(this TBuilder builder, IOutputCachePolicy policy) where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(builder); + + // Enable caching if this method is invoked on an endpoint, extra policies can disable it + + builder.Add(endpointBuilder => + { + endpointBuilder.Metadata.Add(policy); + }); + return builder; + } + + /// + /// Marks an endpoint to be cached using the specified policy builder. + /// + public static TBuilder CacheOutput(this TBuilder builder, Action policy) where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(builder); + + var outputCachePolicyBuilder = new OutputCachePolicyBuilder(); + + policy?.Invoke(outputCachePolicyBuilder); + + builder.Add(endpointBuilder => + { + endpointBuilder.Metadata.Add(outputCachePolicyBuilder.Build()); + }); + + return builder; + } + + /// + /// Marks an endpoint to be cached using a named policy. + /// + public static TBuilder CacheOutput(this TBuilder builder, string policyName) where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(builder); + + var policy = new NamedPolicy(policyName); + + builder.Add(endpointBuilder => + { + endpointBuilder.Metadata.Add(policy); + }); + + return builder; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/PredicatePolicy.cs b/src/Middleware/OutputCaching/src/Policies/PredicatePolicy.cs new file mode 100644 index 000000000000..d58c67fa57d8 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/PredicatePolicy.cs @@ -0,0 +1,76 @@ +// 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.OutputCaching.Policies; + +/// +/// A policy that adds a requirement to another policy. +/// +internal sealed class PredicatePolicy : IOutputCachePolicy +{ + // TODO: Accept a non async predicate too? + + private readonly Func> _predicate; + private readonly IOutputCachePolicy _policy; + + /// + /// Creates a new instance. + /// + /// The predicate. + /// The policy. + public PredicatePolicy(Func> asyncPredicate, IOutputCachePolicy policy) + { + _predicate = asyncPredicate; + _policy = policy; + } + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ExecuteAwaited(static (policy, context, cancellationToken) => policy.CacheRequestAsync(context, cancellationToken), _policy, context, cancellationToken); + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ExecuteAwaited(static (policy, context, cancellationToken) => policy.ServeFromCacheAsync(context, cancellationToken), _policy, context, cancellationToken); + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ExecuteAwaited(static (policy, context, cancellationToken) => policy.ServeResponseAsync(context, cancellationToken), _policy, context, cancellationToken); + } + + private ValueTask ExecuteAwaited(Func action, IOutputCachePolicy policy, OutputCacheContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(action); + + if (_predicate == null) + { + return action(policy, context, cancellationToken); + } + + var task = _predicate(context); + + if (task.IsCompletedSuccessfully) + { + if (task.Result) + { + return action(policy, context, cancellationToken); + } + + return ValueTask.CompletedTask; + } + + return Awaited(task); + + async ValueTask Awaited(ValueTask task) + { + if (await task) + { + await action(policy, context, cancellationToken); + } + } + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/TagsPolicy.cs b/src/Middleware/OutputCaching/src/Policies/TagsPolicy.cs new file mode 100644 index 000000000000..070e2a66b1e4 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/TagsPolicy.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. + +namespace Microsoft.AspNetCore.OutputCaching; + +/// +/// A policy that defines custom tags on the cache entry. +/// +internal sealed class TagsPolicy : IOutputCachePolicy +{ + private readonly string[] _tags; + + /// + /// Creates a new instance. + /// + /// The tags. + public TagsPolicy(params string[] tags) + { + _tags = tags; + } + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + foreach (var tag in _tags) + { + context.Tags.Add(tag); + } + + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/TypedPolicy.cs b/src/Middleware/OutputCaching/src/Policies/TypedPolicy.cs new file mode 100644 index 000000000000..d01f60a24374 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/TypedPolicy.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.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.OutputCaching.Policies; + +/// +/// A type base policy. +/// +internal sealed class TypedPolicy : IOutputCachePolicy +{ + private IOutputCachePolicy? _instance; + + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + private readonly Type _policyType; + + /// + /// Creates a new instance of + /// + /// The type of policy. + public TypedPolicy([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type policyType) + { + ArgumentNullException.ThrowIfNull(policyType); + + _policyType = policyType; + } + + private IOutputCachePolicy? CreatePolicy(OutputCacheContext context) + { + return _instance ??= ActivatorUtilities.CreateInstance(context.Options.ApplicationServices, _policyType) as IOutputCachePolicy; + } + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return CreatePolicy(context)?.CacheRequestAsync(context, cancellationToken) ?? ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return CreatePolicy(context)?.ServeFromCacheAsync(context, cancellationToken) ?? ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return CreatePolicy(context)?.ServeResponseAsync(context, cancellationToken) ?? ValueTask.CompletedTask; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/VaryByHeaderPolicy.cs b/src/Middleware/OutputCaching/src/Policies/VaryByHeaderPolicy.cs new file mode 100644 index 000000000000..35ed3ca16a26 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/VaryByHeaderPolicy.cs @@ -0,0 +1,68 @@ +// 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.Primitives; + +namespace Microsoft.AspNetCore.OutputCaching; + +/// +/// When applied, the cached content will be different for every value of the provided headers. +/// +internal sealed class VaryByHeaderPolicy : IOutputCachePolicy +{ + private readonly StringValues _headers; + + /// + /// Creates a policy that doesn't vary the cached content based on headers. + /// + public VaryByHeaderPolicy() + { + } + + /// + /// Creates a policy that varies the cached content based on the specified header. + /// + public VaryByHeaderPolicy(string header) + { + ArgumentNullException.ThrowIfNull(header); + + _headers = header; + } + + /// + /// Creates a policy that varies the cached content based on the specified query string keys. + /// + public VaryByHeaderPolicy(params string[] headers) + { + ArgumentNullException.ThrowIfNull(headers); + + _headers = headers; + } + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + // No vary by header? + if (_headers.Count == 0) + { + context.CacheVaryByRules.Headers = _headers; + return ValueTask.CompletedTask; + } + + context.CacheVaryByRules.Headers = StringValues.Concat(context.CacheVaryByRules.Headers, _headers); + + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/VaryByQueryPolicy.cs b/src/Middleware/OutputCaching/src/Policies/VaryByQueryPolicy.cs new file mode 100644 index 000000000000..e415459644ba --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/VaryByQueryPolicy.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.Extensions.Primitives; + +namespace Microsoft.AspNetCore.OutputCaching; + +/// +/// When applied, the cached content will be different for every value of the provided query string keys. +/// It also disables the default behavior which is to vary on all query string keys. +/// +internal sealed class VaryByQueryPolicy : IOutputCachePolicy +{ + private readonly StringValues _queryKeys; + + /// + /// Creates a policy that doesn't vary the cached content based on query string. + /// + public VaryByQueryPolicy() + { + } + + /// + /// Creates a policy that varies the cached content based on the specified query string key. + /// + public VaryByQueryPolicy(string queryKey) + { + _queryKeys = queryKey; + } + + /// + /// Creates a policy that varies the cached content based on the specified query string keys. + /// + public VaryByQueryPolicy(params string[] queryKeys) + { + _queryKeys = queryKeys; + } + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + // No vary by query? + if (_queryKeys.Count == 0) + { + context.CacheVaryByRules.QueryKeys = _queryKeys; + return ValueTask.CompletedTask; + } + + // If the current key is "*" (default) replace it + if (context.CacheVaryByRules.QueryKeys.Count == 1 && string.Equals(context.CacheVaryByRules.QueryKeys[0], "*", StringComparison.Ordinal)) + { + context.CacheVaryByRules.QueryKeys = _queryKeys; + return ValueTask.CompletedTask; + } + + context.CacheVaryByRules.QueryKeys = StringValues.Concat(context.CacheVaryByRules.QueryKeys, _queryKeys); + + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/VaryByValuePolicy.cs b/src/Middleware/OutputCaching/src/Policies/VaryByValuePolicy.cs new file mode 100644 index 000000000000..5de5366307a6 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/VaryByValuePolicy.cs @@ -0,0 +1,82 @@ +// 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.Http; + +namespace Microsoft.AspNetCore.OutputCaching; + +/// +/// When applied, the cached content will be different for every provided value. +/// +internal sealed class VaryByValuePolicy : IOutputCachePolicy +{ + private readonly Action? _varyBy; + private readonly Func? _varyByAsync; + + /// + /// Creates a policy that doesn't vary the cached content based on values. + /// + public VaryByValuePolicy() + { + } + + /// + /// Creates a policy that vary the cached content based on the specified value. + /// + public VaryByValuePolicy(Func varyBy) + { + _varyBy = (context, rules) => rules.VaryByPrefix += varyBy(context); + } + + /// + /// Creates a policy that vary the cached content based on the specified value. + /// + public VaryByValuePolicy(Func> varyBy) + { + _varyByAsync = async (context, rules, token) => rules.VaryByPrefix += await varyBy(context, token); + } + + /// + /// Creates a policy that vary the cached content based on the specified value. + /// + public VaryByValuePolicy(Func> varyBy) + { + _varyBy = (context, rules) => + { + var result = varyBy(context); + rules.VaryByCustom?.TryAdd(result.Key, result.Value); + }; + } + + /// + /// Creates a policy that vary the cached content based on the specified value. + /// + public VaryByValuePolicy(Func>> varyBy) + { + _varyBy = async (context, rules) => + { + var result = await varyBy(context, context.RequestAborted); + rules.VaryByCustom?.TryAdd(result.Key, result.Value); + }; + } + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + _varyBy?.Invoke(context.HttpContext, context.CacheVaryByRules); + + return _varyByAsync?.Invoke(context.HttpContext, context.CacheVaryByRules, context.HttpContext.RequestAborted) ?? ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Middleware/OutputCaching/src/PublicAPI.Shipped.txt b/src/Middleware/OutputCaching/src/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..7dc5c58110bf --- /dev/null +++ b/src/Middleware/OutputCaching/src/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt b/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..779e48f3530a --- /dev/null +++ b/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt @@ -0,0 +1,90 @@ +#nullable enable +Microsoft.AspNetCore.Builder.OutputCacheApplicationBuilderExtensions +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.CacheVaryByRules() -> void +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.Headers.get -> Microsoft.Extensions.Primitives.StringValues +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.Headers.set -> void +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.QueryKeys.get -> Microsoft.Extensions.Primitives.StringValues +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.QueryKeys.set -> void +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.VaryByCustom.get -> System.Collections.Generic.IDictionary! +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.VaryByPrefix.get -> Microsoft.Extensions.Primitives.StringValues +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.VaryByPrefix.set -> void +Microsoft.AspNetCore.OutputCaching.IOutputCacheFeature +Microsoft.AspNetCore.OutputCaching.IOutputCacheFeature.Context.get -> Microsoft.AspNetCore.OutputCaching.OutputCacheContext! +Microsoft.AspNetCore.OutputCaching.IOutputCachePolicy.CacheRequestAsync(Microsoft.AspNetCore.OutputCaching.OutputCacheContext! context, System.Threading.CancellationToken cancellation) -> System.Threading.Tasks.ValueTask +Microsoft.AspNetCore.OutputCaching.IOutputCachePolicy.ServeFromCacheAsync(Microsoft.AspNetCore.OutputCaching.OutputCacheContext! context, System.Threading.CancellationToken cancellation) -> System.Threading.Tasks.ValueTask +Microsoft.AspNetCore.OutputCaching.IOutputCachePolicy.ServeResponseAsync(Microsoft.AspNetCore.OutputCaching.OutputCacheContext! context, System.Threading.CancellationToken cancellation) -> System.Threading.Tasks.ValueTask +Microsoft.AspNetCore.OutputCaching.IOutputCacheStore +Microsoft.AspNetCore.OutputCaching.IOutputCacheStore.EvictByTagAsync(string! tag, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +Microsoft.AspNetCore.OutputCaching.IOutputCachePolicy +Microsoft.AspNetCore.OutputCaching.IOutputCacheStore.GetAsync(string! key, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +Microsoft.AspNetCore.OutputCaching.IOutputCacheStore.SetAsync(string! key, byte[]! value, string![]? tags, System.TimeSpan validFor, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.Duration.get -> int +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.Duration.init -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.NoStore.get -> bool +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.NoStore.init -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.OutputCacheAttribute() -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.PolicyName.get -> string? +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.PolicyName.init -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.VaryByHeaders.get -> string![]? +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.VaryByHeaders.init -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.VaryByQueryKeys.get -> string![]? +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.VaryByQueryKeys.init -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.EnableOutputCaching.get -> bool +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.EnableOutputCaching.set -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.AddBasePolicy(Microsoft.AspNetCore.OutputCaching.IOutputCachePolicy! policy) -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.AddBasePolicy(System.Action! build) -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.AddPolicy(string! name, Microsoft.AspNetCore.OutputCaching.IOutputCachePolicy! policy) -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.AddPolicy(string! name, System.Action! build) -> void +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.AddPolicy(System.Type! policyType) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.AddPolicy() -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.AllowLocking(bool lockResponse = true) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.Clear() -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.Expire(System.TimeSpan expiration) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.NoCache() -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.OutputCachePolicyBuilder() -> void +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.Tag(params string![]! tags) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByHeader(params string![]! headers) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByQuery(params string![]! queryKeys) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByValue(System.Func>! varyBy) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByValue(System.Func! varyBy) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByValue(System.Func>! varyBy) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByValue(System.Func>>! varyBy) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.With(System.Func!>! predicate) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCacheContext +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.AllowCacheLookup.get -> bool +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.AllowCacheLookup.set -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.AllowCacheStorage.get -> bool +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.AllowCacheStorage.set -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.AllowLocking.get -> bool +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.AllowLocking.set -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.ResponseExpirationTimeSpan.get -> System.TimeSpan? +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.ResponseExpirationTimeSpan.set -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.ResponseTime.get -> System.DateTimeOffset? +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.ApplicationServices.get -> System.IServiceProvider! +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.DefaultExpirationTimeSpan.get -> System.TimeSpan +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.DefaultExpirationTimeSpan.set -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.MaximumBodySize.get -> long +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.MaximumBodySize.set -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.OutputCacheOptions() -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.SizeLimit.get -> long +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.SizeLimit.set -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.UseCaseSensitivePaths.get -> bool +Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.UseCaseSensitivePaths.set -> void +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.With(System.Func! predicate) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.Extensions.DependencyInjection.OutputCacheConventionBuilderExtensions +Microsoft.Extensions.DependencyInjection.OutputCacheServiceCollectionExtensions +static Microsoft.AspNetCore.Builder.OutputCacheApplicationBuilderExtensions.UseOutputCache(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! +static Microsoft.Extensions.DependencyInjection.OutputCacheConventionBuilderExtensions.CacheOutput(this TBuilder builder) -> TBuilder +static Microsoft.Extensions.DependencyInjection.OutputCacheConventionBuilderExtensions.CacheOutput(this TBuilder builder, Microsoft.AspNetCore.OutputCaching.IOutputCachePolicy! policy) -> TBuilder +static Microsoft.Extensions.DependencyInjection.OutputCacheConventionBuilderExtensions.CacheOutput(this TBuilder builder, string! policyName) -> TBuilder +static Microsoft.Extensions.DependencyInjection.OutputCacheConventionBuilderExtensions.CacheOutput(this TBuilder builder, System.Action! policy) -> TBuilder +static Microsoft.Extensions.DependencyInjection.OutputCacheServiceCollectionExtensions.AddOutputCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.Extensions.DependencyInjection.OutputCacheServiceCollectionExtensions.AddOutputCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.CacheVaryByRules.get -> Microsoft.AspNetCore.OutputCaching.CacheVaryByRules! +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.CacheVaryByRules.set -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext! +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.Tags.get -> System.Collections.Generic.HashSet! diff --git a/src/Middleware/OutputCaching/src/Resources.resx b/src/Middleware/OutputCaching/src/Resources.resx new file mode 100644 index 000000000000..3a19868a7302 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Resources.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The type '{0}' is not a valid output policy. + + \ No newline at end of file diff --git a/src/Middleware/OutputCaching/src/Serialization/FormatterEntry.cs b/src/Middleware/OutputCaching/src/Serialization/FormatterEntry.cs new file mode 100644 index 000000000000..f3aadfbadd6b --- /dev/null +++ b/src/Middleware/OutputCaching/src/Serialization/FormatterEntry.cs @@ -0,0 +1,12 @@ +// 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.OutputCaching.Serialization; +internal sealed class FormatterEntry +{ + public DateTimeOffset Created { get; set; } + public int StatusCode { get; set; } + public Dictionary Headers { get; set; } = default!; + public List Body { get; set; } = default!; + public string[] Tags { get; set; } = default!; +} diff --git a/src/Middleware/OutputCaching/src/Serialization/FormatterEntrySerializerContext.cs b/src/Middleware/OutputCaching/src/Serialization/FormatterEntrySerializerContext.cs new file mode 100644 index 000000000000..6f4936740993 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Serialization/FormatterEntrySerializerContext.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.OutputCaching.Serialization; + +[JsonSourceGenerationOptions(WriteIndented = false)] +[JsonSerializable(typeof(FormatterEntry))] +internal partial class FormatterEntrySerializerContext : JsonSerializerContext +{ +} diff --git a/src/Middleware/OutputCaching/src/Streams/OutputCacheStream.cs b/src/Middleware/OutputCaching/src/Streams/OutputCacheStream.cs new file mode 100644 index 000000000000..d868586d6999 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Streams/OutputCacheStream.cs @@ -0,0 +1,187 @@ +// 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.OutputCaching; + +internal sealed class OutputCacheStream : Stream +{ + private readonly Stream _innerStream; + private readonly long _maxBufferSize; + private readonly int _segmentSize; + private readonly SegmentWriteStream _segmentWriteStream; + private readonly Action _startResponseCallback; + + internal OutputCacheStream(Stream innerStream, long maxBufferSize, int segmentSize, Action startResponseCallback) + { + _innerStream = innerStream; + _maxBufferSize = maxBufferSize; + _segmentSize = segmentSize; + _startResponseCallback = startResponseCallback; + _segmentWriteStream = new SegmentWriteStream(_segmentSize); + } + + internal bool BufferingEnabled { get; private set; } = true; + + public override bool CanRead => _innerStream.CanRead; + + public override bool CanSeek => _innerStream.CanSeek; + + public override bool CanWrite => _innerStream.CanWrite; + + public override long Length => _innerStream.Length; + + public override long Position + { + get { return _innerStream.Position; } + set + { + DisableBuffering(); + _innerStream.Position = value; + } + } + + internal CachedResponseBody GetCachedResponseBody() + { + if (!BufferingEnabled) + { + throw new InvalidOperationException("Buffer stream cannot be retrieved since buffering is disabled."); + } + return new CachedResponseBody(_segmentWriteStream.GetSegments(), _segmentWriteStream.Length); + } + + internal void DisableBuffering() + { + BufferingEnabled = false; + _segmentWriteStream.Dispose(); + } + + public override void SetLength(long value) + { + DisableBuffering(); + _innerStream.SetLength(value); + } + + public override long Seek(long offset, SeekOrigin origin) + { + DisableBuffering(); + return _innerStream.Seek(offset, origin); + } + + public override void Flush() + { + try + { + _startResponseCallback(); + _innerStream.Flush(); + } + catch + { + DisableBuffering(); + throw; + } + } + + public override async Task FlushAsync(CancellationToken cancellationToken) + { + try + { + _startResponseCallback(); + await _innerStream.FlushAsync(cancellationToken); + } + catch + { + DisableBuffering(); + throw; + } + } + + // Underlying stream is write-only, no need to override other read related methods + public override int Read(byte[] buffer, int offset, int count) + => _innerStream.Read(buffer, offset, count); + + public override void Write(byte[] buffer, int offset, int count) + { + try + { + _startResponseCallback(); + _innerStream.Write(buffer, offset, count); + } + catch + { + DisableBuffering(); + throw; + } + + if (BufferingEnabled) + { + if (_segmentWriteStream.Length + count > _maxBufferSize) + { + DisableBuffering(); + } + else + { + _segmentWriteStream.Write(buffer, offset, count); + } + } + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + await WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + try + { + _startResponseCallback(); + await _innerStream.WriteAsync(buffer, cancellationToken); + } + catch + { + DisableBuffering(); + throw; + } + + if (BufferingEnabled) + { + if (_segmentWriteStream.Length + buffer.Length > _maxBufferSize) + { + DisableBuffering(); + } + else + { + await _segmentWriteStream.WriteAsync(buffer, cancellationToken); + } + } + } + + public override void WriteByte(byte value) + { + try + { + _innerStream.WriteByte(value); + } + catch + { + DisableBuffering(); + throw; + } + + if (BufferingEnabled) + { + if (_segmentWriteStream.Length + 1 > _maxBufferSize) + { + DisableBuffering(); + } + else + { + _segmentWriteStream.WriteByte(value); + } + } + } + + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) + => TaskToApm.Begin(WriteAsync(buffer, offset, count, CancellationToken.None), callback, state); + + public override void EndWrite(IAsyncResult asyncResult) + => TaskToApm.End(asyncResult); +} diff --git a/src/Middleware/OutputCaching/src/Streams/SegmentWriteStream.cs b/src/Middleware/OutputCaching/src/Streams/SegmentWriteStream.cs new file mode 100644 index 000000000000..b7491fc174ef --- /dev/null +++ b/src/Middleware/OutputCaching/src/Streams/SegmentWriteStream.cs @@ -0,0 +1,199 @@ +// 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.OutputCaching; + +internal sealed class SegmentWriteStream : Stream +{ + private readonly List _segments = new(); + private readonly MemoryStream _bufferStream = new(); + private readonly int _segmentSize; + private long _length; + private bool _closed; + private bool _disposed; + + internal SegmentWriteStream(int segmentSize) + { + if (segmentSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(segmentSize), segmentSize, $"{nameof(segmentSize)} must be greater than 0."); + } + + _segmentSize = segmentSize; + } + + // Extracting the buffered segments closes the stream for writing + internal List GetSegments() + { + if (!_closed) + { + _closed = true; + FinalizeSegments(); + } + return _segments; + } + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => !_closed; + + public override long Length => _length; + + public override long Position + { + get + { + return _length; + } + set + { + throw new NotSupportedException("The stream does not support seeking."); + } + } + + private void DisposeMemoryStream() + { + // Clean up the memory stream + _bufferStream.SetLength(0); + _bufferStream.Capacity = 0; + _bufferStream.Dispose(); + } + + private void FinalizeSegments() + { + // Append any remaining segments + if (_bufferStream.Length > 0) + { + // Add the last segment + _segments.Add(_bufferStream.ToArray()); + } + + DisposeMemoryStream(); + } + + protected override void Dispose(bool disposing) + { + try + { + if (_disposed) + { + return; + } + + if (disposing) + { + _segments.Clear(); + DisposeMemoryStream(); + } + + _disposed = true; + _closed = true; + } + finally + { + base.Dispose(disposing); + } + } + + public override void Flush() + { + if (!CanWrite) + { + throw new ObjectDisposedException("The stream has been closed for writing."); + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException("The stream does not support reading."); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException("The stream does not support seeking."); + } + + public override void SetLength(long value) + { + throw new NotSupportedException("The stream does not support seeking."); + } + + public override void Write(byte[] buffer, int offset, int count) + { + ArgumentNullException.ThrowIfNull(buffer); + + if (offset < 0) + { + throw new ArgumentOutOfRangeException(nameof(offset), offset, "Non-negative number required."); + } + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count), count, "Non-negative number required."); + } + if (count > buffer.Length - offset) + { + throw new ArgumentException("Offset and length were out of bounds for the array or count is greater than the number of elements from index to the end of the source collection."); + } + if (!CanWrite) + { + throw new ObjectDisposedException("The stream has been closed for writing."); + } + + Write(buffer.AsSpan(offset, count)); + } + + public override void Write(ReadOnlySpan buffer) + { + while (!buffer.IsEmpty) + { + if ((int)_bufferStream.Length == _segmentSize) + { + _segments.Add(_bufferStream.ToArray()); + _bufferStream.SetLength(0); + } + + var bytesWritten = Math.Min(buffer.Length, _segmentSize - (int)_bufferStream.Length); + + _bufferStream.Write(buffer[..bytesWritten]); + buffer = buffer[bytesWritten..]; + _length += bytesWritten; + } + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + Write(buffer, offset, count); + return Task.CompletedTask; + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + Write(buffer.Span); + return default; + } + + public override void WriteByte(byte value) + { + if (!CanWrite) + { + throw new ObjectDisposedException("The stream has been closed for writing."); + } + + if ((int)_bufferStream.Length == _segmentSize) + { + _segments.Add(_bufferStream.ToArray()); + _bufferStream.SetLength(0); + } + + _bufferStream.WriteByte(value); + _length++; + } + + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) + => TaskToApm.Begin(WriteAsync(buffer, offset, count, CancellationToken.None), callback, state); + + public override void EndWrite(IAsyncResult asyncResult) + => TaskToApm.End(asyncResult); +} diff --git a/src/Middleware/OutputCaching/src/Streams/StreamUtilities.cs b/src/Middleware/OutputCaching/src/Streams/StreamUtilities.cs new file mode 100644 index 000000000000..2b9fb359c7b4 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Streams/StreamUtilities.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.OutputCaching; + +internal static class StreamUtilities +{ + /// + /// The segment size for buffering the response body in bytes. The default is set to 80 KB (81920 Bytes) to avoid allocations on the LOH. + /// + // Internal for testing + internal static int BodySegmentSize { get; set; } = 81920; +} diff --git a/src/Middleware/OutputCaching/src/StringBuilderExtensions.cs b/src/Middleware/OutputCaching/src/StringBuilderExtensions.cs new file mode 100644 index 000000000000..6835e9af6c89 --- /dev/null +++ b/src/Middleware/OutputCaching/src/StringBuilderExtensions.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. + +using System.Text; + +namespace Microsoft.AspNetCore.OutputCaching; + +internal static class StringBuilderExtensions +{ + internal static StringBuilder AppendUpperInvariant(this StringBuilder builder, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + builder.EnsureCapacity(builder.Length + value.Length); + for (var i = 0; i < value.Length; i++) + { + builder.Append(char.ToUpperInvariant(value[i])); + } + } + + return builder; + } +} diff --git a/src/Middleware/OutputCaching/src/SystemClock.cs b/src/Middleware/OutputCaching/src/SystemClock.cs new file mode 100644 index 000000000000..6cb33a828b8d --- /dev/null +++ b/src/Middleware/OutputCaching/src/SystemClock.cs @@ -0,0 +1,15 @@ +// 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.OutputCaching; + +/// +/// Provides access to the normal system clock. +/// +internal sealed class SystemClock : ISystemClock +{ + /// + /// Retrieves the current system time in UTC. + /// + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} diff --git a/src/Middleware/OutputCaching/startvs.cmd b/src/Middleware/OutputCaching/startvs.cmd new file mode 100644 index 000000000000..5c25af9f6400 --- /dev/null +++ b/src/Middleware/OutputCaching/startvs.cmd @@ -0,0 +1,3 @@ +@ECHO OFF + +%~dp0..\..\..\startvs.cmd %~dp0OutputCaching.slnf diff --git a/src/Middleware/OutputCaching/test/CachedResponseBodyTests.cs b/src/Middleware/OutputCaching/test/CachedResponseBodyTests.cs new file mode 100644 index 000000000000..6867fc2fb7e9 --- /dev/null +++ b/src/Middleware/OutputCaching/test/CachedResponseBodyTests.cs @@ -0,0 +1,122 @@ +// 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; +using System.IO.Pipelines; + +namespace Microsoft.AspNetCore.OutputCaching.Tests; + +public class CachedResponseBodyTests +{ + private readonly int _timeout = Debugger.IsAttached ? -1 : 5000; + + [Fact] + public void GetSegments() + { + var segments = new List(); + var body = new CachedResponseBody(segments, 0); + + Assert.Same(segments, body.Segments); + } + + [Fact] + public void GetLength() + { + var segments = new List(); + var body = new CachedResponseBody(segments, 42); + + Assert.Equal(42, body.Length); + } + + [Fact] + public async Task Copy_DoNothingWhenNoSegments() + { + var segments = new List(); + var receivedSegments = new List(); + var body = new CachedResponseBody(segments, 0); + + var pipe = new Pipe(); + using var cts = new CancellationTokenSource(_timeout); + + var receiverTask = ReceiveDataAsync(pipe.Reader, receivedSegments, cts.Token); + var copyTask = body.CopyToAsync(pipe.Writer, cts.Token).ContinueWith(_ => pipe.Writer.CompleteAsync()); + + await Task.WhenAll(receiverTask, copyTask); + + Assert.Empty(receivedSegments); + } + + [Fact] + public async Task Copy_SingleSegment() + { + var segments = new List + { + new byte[] { 1 } + }; + var receivedSegments = new List(); + var body = new CachedResponseBody(segments, 0); + + var pipe = new Pipe(); + + using var cts = new CancellationTokenSource(_timeout); + + var receiverTask = ReceiveDataAsync(pipe.Reader, receivedSegments, cts.Token); + var copyTask = CopyDataAsync(body, pipe.Writer, cts.Token); + + await Task.WhenAll(receiverTask, copyTask); + + Assert.Equal(segments, receivedSegments); + } + + [Fact] + public async Task Copy_MultipleSegments() + { + var segments = new List + { + new byte[] { 1 }, + new byte[] { 2, 3 } + }; + var receivedSegments = new List(); + var body = new CachedResponseBody(segments, 0); + + var pipe = new Pipe(); + + using var cts = new CancellationTokenSource(_timeout); + + var receiverTask = ReceiveDataAsync(pipe.Reader, receivedSegments, cts.Token); + var copyTask = CopyDataAsync(body, pipe.Writer, cts.Token); + + await Task.WhenAll(receiverTask, copyTask); + + Assert.Equal(new byte[] { 1, 2, 3 }, receivedSegments.SelectMany(x => x).ToArray()); + } + + static async Task CopyDataAsync(CachedResponseBody body, PipeWriter writer, CancellationToken cancellationToken) + { + await body.CopyToAsync(writer, cancellationToken); + await writer.CompleteAsync(); + } + + static async Task ReceiveDataAsync(PipeReader reader, List receivedSegments, CancellationToken cancellationToken) + { + while (true) + { + var result = await reader.ReadAsync(cancellationToken); + var buffer = result.Buffer; + + foreach (var memory in buffer) + { + receivedSegments.Add(memory.ToArray()); + } + + reader.AdvanceTo(buffer.End, buffer.End); + + if (result.IsCompleted) + { + break; + } + } + await reader.CompleteAsync(); + } +} diff --git a/src/Middleware/OutputCaching/test/MemoryOutputCacheStoreTests.cs b/src/Middleware/OutputCaching/test/MemoryOutputCacheStoreTests.cs new file mode 100644 index 000000000000..1b1158c66229 --- /dev/null +++ b/src/Middleware/OutputCaching/test/MemoryOutputCacheStoreTests.cs @@ -0,0 +1,155 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OutputCaching.Memory; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.OutputCaching.Tests; + +public class MemoryOutputCacheStoreTests +{ + [Fact] + public async Task StoreAndGetValue_Succeeds() + { + var store = new MemoryOutputCacheStore(new MemoryCache(new MemoryCacheOptions())); + var value = "abc"u8; + var key = "abc"; + + await store.SetAsync(key, value, null, TimeSpan.FromMinutes(1), default); + + var result = await store.GetAsync(key, default); + + Assert.Equal(value, result); + } + + [Fact] + public async Task StoreAndGetValue_TimesOut() + { + var testClock = new TestMemoryOptionsClock { UtcNow = DateTimeOffset.UtcNow }; + var store = new MemoryOutputCacheStore(new MemoryCache(new MemoryCacheOptions { Clock = testClock })); + var value = "abc"u8; + var key = "abc"; + + await store.SetAsync(key, value, null, TimeSpan.FromMilliseconds(5), default); + testClock.Advance(TimeSpan.FromMilliseconds(10)); + + var result = await store.GetAsync(key, default); + + Assert.Null(result); + } + + [Fact] + public async Task StoreNullKey_ThrowsException() + { + var store = new MemoryOutputCacheStore(new MemoryCache(new MemoryCacheOptions())); + var value = "abc"u8; + string key = null; + + _ = await Assert.ThrowsAsync("key", () => store.SetAsync(key, value, null, TimeSpan.FromMilliseconds(5), default).AsTask()); + } + + [Fact] + public async Task StoreNullValue_ThrowsException() + { + var store = new MemoryOutputCacheStore(new MemoryCache(new MemoryCacheOptions())); + var value = default(byte[]); + string key = "abc"; + + _ = await Assert.ThrowsAsync("value", () => store.SetAsync(key, value, null, TimeSpan.FromMilliseconds(5), default).AsTask()); + } + + [Fact] + public async Task EvictByTag_SingleTag_SingleEntry() + { + var testClock = new TestMemoryOptionsClock { UtcNow = DateTimeOffset.UtcNow }; + var store = new MemoryOutputCacheStore(new MemoryCache(new MemoryCacheOptions { Clock = testClock })); + var value = "abc"u8; + var key = "abc"; + var tags = new string[] { "tag1" }; + + await store.SetAsync(key, value, tags, TimeSpan.FromDays(1), default); + await store.EvictByTagAsync("tag1", default); + var result = await store.GetAsync(key, default); + + Assert.Null(result); + } + + [Fact] + public async Task EvictByTag_SingleTag_MultipleEntries() + { + var testClock = new TestMemoryOptionsClock { UtcNow = DateTimeOffset.UtcNow }; + var store = new MemoryOutputCacheStore(new MemoryCache(new MemoryCacheOptions { Clock = testClock })); + var value = "abc"u8; + var key1 = "abc"; + var key2 = "def"; + var tags = new string[] { "tag1" }; + + await store.SetAsync(key1, value, tags, TimeSpan.FromDays(1), default); + await store.SetAsync(key2, value, tags, TimeSpan.FromDays(1), default); + await store.EvictByTagAsync("tag1", default); + var result1 = await store.GetAsync(key1, default); + var result2 = await store.GetAsync(key2, default); + + Assert.Null(result1); + Assert.Null(result2); + } + + [Fact] + public async Task EvictByTag_MultipleTags_SingleEntry() + { + var testClock = new TestMemoryOptionsClock { UtcNow = DateTimeOffset.UtcNow }; + var store = new MemoryOutputCacheStore(new MemoryCache(new MemoryCacheOptions { Clock = testClock })); + var value = "abc"u8; + var key = "abc"; + var tags = new string[] { "tag1", "tag2" }; + + await store.SetAsync(key, value, tags, TimeSpan.FromDays(1), default); + await store.EvictByTagAsync("tag1", default); + var result1 = await store.GetAsync(key, default); + + Assert.Null(result1); + } + + [Fact] + public async Task EvictByTag_MultipleTags_MultipleEntries() + { + var testClock = new TestMemoryOptionsClock { UtcNow = DateTimeOffset.UtcNow }; + var store = new MemoryOutputCacheStore(new MemoryCache(new MemoryCacheOptions { Clock = testClock })); + var value = "abc"u8; + var key1 = "abc"; + var key2 = "def"; + var tags1 = new string[] { "tag1", "tag2" }; + var tags2 = new string[] { "tag2", "tag3" }; + + await store.SetAsync(key1, value, tags1, TimeSpan.FromDays(1), default); + await store.SetAsync(key2, value, tags2, TimeSpan.FromDays(1), default); + await store.EvictByTagAsync("tag1", default); + + var result1 = await store.GetAsync(key1, default); + var result2 = await store.GetAsync(key2, default); + + Assert.Null(result1); + Assert.NotNull(result2); + + await store.EvictByTagAsync("tag3", default); + + result1 = await store.GetAsync(key1, default); + result2 = await store.GetAsync(key2, default); + + Assert.Null(result1); + Assert.Null(result2); + } + + private class TestMemoryOptionsClock : Extensions.Internal.ISystemClock + { + public DateTimeOffset UtcNow { get; set; } + public void Advance(TimeSpan duration) + { + UtcNow += duration; + } + } +} diff --git a/src/Middleware/OutputCaching/test/Microsoft.AspNetCore.OutputCaching.Tests.csproj b/src/Middleware/OutputCaching/test/Microsoft.AspNetCore.OutputCaching.Tests.csproj new file mode 100644 index 000000000000..f3a12d8a3c3d --- /dev/null +++ b/src/Middleware/OutputCaching/test/Microsoft.AspNetCore.OutputCaching.Tests.csproj @@ -0,0 +1,18 @@ + + + $(DefaultNetCoreTargetFramework) + + + + + PreserveNewest + PreserveNewest + + + + + + + + + diff --git a/src/Middleware/OutputCaching/test/OutputCacheEntryFormatterTests.cs b/src/Middleware/OutputCaching/test/OutputCacheEntryFormatterTests.cs new file mode 100644 index 000000000000..e6610141b285 --- /dev/null +++ b/src/Middleware/OutputCaching/test/OutputCacheEntryFormatterTests.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OutputCaching.Memory; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.OutputCaching.Tests; + +public class OutputCacheEntryFormatterTests +{ + [Fact] + public async Task StoreAndGet_StoresEmptyValues() + { + var store = new TestOutputCache(); + var key = "abc"; + var entry = new OutputCacheEntry() + { + Body = new CachedResponseBody(new List(), 0), + Headers = new HeaderDictionary(), + Tags = Array.Empty() + }; + + await OutputCacheEntryFormatter.StoreAsync(key, entry, TimeSpan.Zero, store, default); + + var result = await OutputCacheEntryFormatter.GetAsync(key, store, default); + + AssertEntriesAreSame(entry, result); + } + + [Fact] + public async Task StoreAndGet_StoresAllValues() + { + var store = new TestOutputCache(); + var key = "abc"; + var entry = new OutputCacheEntry() + { + Body = new CachedResponseBody(new List() { "lorem"u8, "ipsum"u8 }, 10), + Created = DateTimeOffset.UtcNow, + Headers = new HeaderDictionary { [HeaderNames.Accept] = "text/plain", [HeaderNames.AcceptCharset] = "utf8" }, + StatusCode = StatusCodes.Status201Created, + Tags = new[] { "tag1", "tag2" } + }; + + await OutputCacheEntryFormatter.StoreAsync(key, entry, TimeSpan.Zero, store, default); + + var result = await OutputCacheEntryFormatter.GetAsync(key, store, default); + + AssertEntriesAreSame(entry, result); + } + + private static void AssertEntriesAreSame(OutputCacheEntry expected, OutputCacheEntry actual) + { + Assert.NotNull(expected); + Assert.NotNull(actual); + Assert.Equal(expected.Tags, actual.Tags); + Assert.Equal(expected.Created, actual.Created); + Assert.Equal(expected.StatusCode, actual.StatusCode); + Assert.Equal(expected.Headers, actual.Headers); + Assert.Equal(expected.Body.Length, actual.Body.Length); + Assert.Equal(expected.Body.Segments, actual.Body.Segments); + } +} diff --git a/src/Middleware/OutputCaching/test/OutputCacheKeyProviderTests.cs b/src/Middleware/OutputCaching/test/OutputCacheKeyProviderTests.cs new file mode 100644 index 000000000000..ddeb485d73a9 --- /dev/null +++ b/src/Middleware/OutputCaching/test/OutputCacheKeyProviderTests.cs @@ -0,0 +1,214 @@ +// 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.Http; + +namespace Microsoft.AspNetCore.OutputCaching.Tests; + +public class OutputCacheKeyProviderTests +{ + private const char KeyDelimiter = '\x1e'; + private const char KeySubDelimiter = '\x1f'; + + [Fact] + public void OutputCachingKeyProvider_CreateStorageKey_IncludesOnlyNormalizedMethodSchemeHostPortAndPath() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.Method = "head"; + context.HttpContext.Request.Path = "/path/subpath"; + context.HttpContext.Request.Scheme = "https"; + context.HttpContext.Request.Host = new HostString("example.com", 80); + context.HttpContext.Request.PathBase = "/pathBase"; + context.HttpContext.Request.QueryString = new QueryString("?query.Key=a&query.Value=b"); + + Assert.Equal($"HEAD{KeyDelimiter}HTTPS{KeyDelimiter}EXAMPLE.COM:80/PATHBASE/PATH/SUBPATH", cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageKey_CaseInsensitivePath_NormalizesPath() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(new OutputCacheOptions() + { + UseCaseSensitivePaths = false + }); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Request.Path = "/Path"; + + Assert.Equal($"{HttpMethods.Get}{KeyDelimiter}{KeyDelimiter}/PATH", cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageKey_CaseSensitivePath_PreservesPathCase() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(new OutputCacheOptions() + { + UseCaseSensitivePaths = true + }); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Request.Path = "/Path"; + + Assert.Equal($"{HttpMethods.Get}{KeyDelimiter}{KeyDelimiter}/Path", cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageKey_VaryByRulesIsotNull() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + + Assert.NotNull(context.CacheVaryByRules); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageKey_ReturnsCachedVaryByGuid_IfVaryByRulesIsEmpty() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.CacheVaryByRules = new CacheVaryByRules() + { + VaryByPrefix = Guid.NewGuid().ToString("n"), + }; + + Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}", cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesListedHeadersOnly() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.Headers["HeaderA"] = "ValueA"; + context.HttpContext.Request.Headers["HeaderB"] = "ValueB"; + context.CacheVaryByRules = new CacheVaryByRules() + { + Headers = new string[] { "HeaderA", "HeaderC" } + }; + + Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=", + cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageVaryKey_HeaderValuesAreSorted() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.Headers["HeaderA"] = "ValueB"; + context.HttpContext.Request.Headers.Append("HeaderA", "ValueA"); + context.CacheVaryByRules = new CacheVaryByRules() + { + Headers = new string[] { "HeaderA", "HeaderC" } + }; + + Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueAValueB{KeyDelimiter}HeaderC=", + cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesListedQueryKeysOnly() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryB=ValueB"); + context.CacheVaryByRules = new CacheVaryByRules() + { + VaryByPrefix = Guid.NewGuid().ToString("n"), + QueryKeys = new string[] { "QueryA", "QueryC" } + }; + + Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}{KeyDelimiter}Q{KeyDelimiter}QueryA=ValueA{KeyDelimiter}QueryC=", + cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesQueryKeys_QueryKeyCaseInsensitive_UseQueryKeyCasing() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.QueryString = new QueryString("?queryA=ValueA&queryB=ValueB"); + context.CacheVaryByRules = new CacheVaryByRules() + { + VaryByPrefix = Guid.NewGuid().ToString("n"), + QueryKeys = new string[] { "QueryA", "QueryC" } + }; + + Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}{KeyDelimiter}Q{KeyDelimiter}QueryA=ValueA{KeyDelimiter}QueryC=", + cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesAllQueryKeysGivenAsterisk() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryB=ValueB"); + context.CacheVaryByRules = new CacheVaryByRules() + { + VaryByPrefix = Guid.NewGuid().ToString("n"), + QueryKeys = new string[] { "*" } + }; + + // To support case insensitivity, all query keys are converted to upper case. + // Explicit query keys uses the casing specified in the setting. + Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}{KeyDelimiter}Q{KeyDelimiter}QUERYA=ValueA{KeyDelimiter}QUERYB=ValueB", + cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageVaryKey_QueryKeysValuesNotConsolidated() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryA=ValueB"); + context.CacheVaryByRules = new CacheVaryByRules() + { + VaryByPrefix = Guid.NewGuid().ToString("n"), + QueryKeys = new string[] { "*" } + }; + + // To support case insensitivity, all query keys are converted to upper case. + // Explicit query keys uses the casing specified in the setting. + Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}{KeyDelimiter}Q{KeyDelimiter}QUERYA=ValueA{KeySubDelimiter}ValueB", + cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageVaryKey_QueryKeysValuesAreSorted() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueB&QueryA=ValueA"); + context.CacheVaryByRules = new CacheVaryByRules() + { + VaryByPrefix = Guid.NewGuid().ToString("n"), + QueryKeys = new string[] { "*" } + }; + + // To support case insensitivity, all query keys are converted to upper case. + // Explicit query keys uses the casing specified in the setting. + Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}{KeyDelimiter}Q{KeyDelimiter}QUERYA=ValueA{KeySubDelimiter}ValueB", + cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesListedHeadersAndQueryKeys() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.Headers["HeaderA"] = "ValueA"; + context.HttpContext.Request.Headers["HeaderB"] = "ValueB"; + context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryB=ValueB"); + context.CacheVaryByRules = new CacheVaryByRules() + { + VaryByPrefix = Guid.NewGuid().ToString("n"), + Headers = new string[] { "HeaderA", "HeaderC" }, + QueryKeys = new string[] { "QueryA", "QueryC" } + }; + + Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC={KeyDelimiter}Q{KeyDelimiter}QueryA=ValueA{KeyDelimiter}QueryC=", + cacheKeyProvider.CreateStorageKey(context)); + } +} diff --git a/src/Middleware/OutputCaching/test/OutputCacheMiddlewareTests.cs b/src/Middleware/OutputCaching/test/OutputCacheMiddlewareTests.cs new file mode 100644 index 000000000000..690031b2a78e --- /dev/null +++ b/src/Middleware/OutputCaching/test/OutputCacheMiddlewareTests.cs @@ -0,0 +1,802 @@ +// 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.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.OutputCaching.Memory; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.OutputCaching.Tests; + +public class OutputCacheMiddlewareTests +{ + [Fact] + public async Task TryServeFromCacheAsync_OnlyIfCached_Serves504() + { + var cache = new TestOutputCache(); + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey")); + var context = TestUtils.CreateTestContext(cache); + context.HttpContext.Request.Headers.CacheControl = new CacheControlHeaderValue() + { + OnlyIfCached = true + }.ToString(); + middleware.TryGetRequestPolicies(context.HttpContext, out var policies); + + Assert.True(await middleware.TryServeFromCacheAsync(context, policies)); + Assert.Equal(StatusCodes.Status504GatewayTimeout, context.HttpContext.Response.StatusCode); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.GatewayTimeoutServed); + } + + [Fact] + public async Task TryServeFromCacheAsync_CachedResponseNotFound_Fails() + { + var cache = new TestOutputCache(); + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey")); + var context = TestUtils.CreateTestContext(cache); + middleware.TryGetRequestPolicies(context.HttpContext, out var policies); + + Assert.False(await middleware.TryServeFromCacheAsync(context, policies)); + Assert.Equal(1, cache.GetCount); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.NoResponseServed); + } + + [Fact] + public async Task TryServeFromCacheAsync_CachedResponseFound_Succeeds() + { + var cache = new TestOutputCache(); + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey")); + var context = TestUtils.CreateTestContext(cache); + middleware.TryGetRequestPolicies(context.HttpContext, out var policies); + + await OutputCacheEntryFormatter.StoreAsync( + "BaseKey", + new OutputCacheEntry() + { + Headers = new HeaderDictionary(), + Body = new CachedResponseBody(new List(0), 0) + }, + TimeSpan.Zero, + cache, + default); + + Assert.True(await middleware.TryServeFromCacheAsync(context, policies)); + Assert.Equal(1, cache.GetCount); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.CachedResponseServed); + } + + [Fact] + public async Task TryServeFromCacheAsync_CachedResponseFound_OverwritesExistingHeaders() + { + var cache = new TestOutputCache(); + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey")); + var context = TestUtils.CreateTestContext(cache); + middleware.TryGetRequestPolicies(context.HttpContext, out var policies); + context.CacheKey = "BaseKey"; + + context.HttpContext.Response.Headers["MyHeader"] = "OldValue"; + await OutputCacheEntryFormatter.StoreAsync(context.CacheKey, + new OutputCacheEntry() + { + Headers = new HeaderDictionary() + { + { "MyHeader", "NewValue" } + }, + Body = new CachedResponseBody(new List(0), 0) + }, + TimeSpan.Zero, + cache, + default); + + Assert.True(await middleware.TryServeFromCacheAsync(context, policies)); + Assert.Equal("NewValue", context.HttpContext.Response.Headers["MyHeader"]); + Assert.Equal(1, cache.GetCount); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.CachedResponseServed); + } + + [Fact] + public async Task TryServeFromCacheAsync_CachedResponseFound_Serves304IfPossible() + { + var cache = new TestOutputCache(); + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey")); + var context = TestUtils.CreateTestContext(cache); + context.HttpContext.Request.Headers.IfNoneMatch = "*"; + middleware.TryGetRequestPolicies(context.HttpContext, out var policies); + + await OutputCacheEntryFormatter.StoreAsync("BaseKey", + new OutputCacheEntry() + { + Body = new CachedResponseBody(new List(0), 0), + Headers = new() + }, + TimeSpan.Zero, + cache, + default); + + Assert.True(await middleware.TryServeFromCacheAsync(context, policies)); + Assert.Equal(1, cache.GetCount); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.NotModifiedServed); + } + + [Fact] + public void ContentIsNotModified_NotConditionalRequest_False() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; + + Assert.False(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void ContentIsNotModified_IfModifiedSince_FallsbackToDateHeader() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; + + context.HttpContext.Request.Headers.IfModifiedSince = HeaderUtilities.FormatDate(utcNow); + + // Verify modifications in the past succeeds + context.CachedResponse.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10)); + Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.Single(sink.Writes); + + // Verify modifications at present succeeds + context.CachedResponse.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow); + Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.Equal(2, sink.Writes.Count); + + // Verify modifications in the future fails + context.CachedResponse.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10)); + Assert.False(OutputCacheMiddleware.ContentIsNotModified(context)); + + // Verify logging + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.NotModifiedIfModifiedSinceSatisfied, + LoggedMessage.NotModifiedIfModifiedSinceSatisfied); + } + + [Fact] + public void ContentIsNotModified_IfModifiedSince_LastModifiedOverridesDateHeader() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; + + context.HttpContext.Request.Headers.IfModifiedSince = HeaderUtilities.FormatDate(utcNow); + + // Verify modifications in the past succeeds + context.CachedResponse.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10)); + context.CachedResponse.Headers[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10)); + Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.Single(sink.Writes); + + // Verify modifications at present + context.CachedResponse.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10)); + context.CachedResponse.Headers[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow); + Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.Equal(2, sink.Writes.Count); + + // Verify modifications in the future fails + context.CachedResponse.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10)); + context.CachedResponse.Headers[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10)); + Assert.False(OutputCacheMiddleware.ContentIsNotModified(context)); + + // Verify logging + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.NotModifiedIfModifiedSinceSatisfied, + LoggedMessage.NotModifiedIfModifiedSinceSatisfied); + } + + [Fact] + public void ContentIsNotModified_IfNoneMatch_Overrides_IfModifiedSince_ToTrue() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; + + // This would fail the IfModifiedSince checks + context.HttpContext.Request.Headers.IfModifiedSince = HeaderUtilities.FormatDate(utcNow); + context.CachedResponse.Headers[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10)); + + context.HttpContext.Request.Headers.IfNoneMatch = EntityTagHeaderValue.Any.ToString(); + Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.NotModifiedIfNoneMatchStar); + } + + [Fact] + public void ContentIsNotModified_IfNoneMatch_Overrides_IfModifiedSince_ToFalse() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; + + // This would pass the IfModifiedSince checks + context.HttpContext.Request.Headers.IfModifiedSince = HeaderUtilities.FormatDate(utcNow); + context.CachedResponse.Headers[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10)); + + context.HttpContext.Request.Headers.IfNoneMatch = "\"E1\""; + Assert.False(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void ContentIsNotModified_IfNoneMatch_AnyWithoutETagInResponse_False() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; + context.HttpContext.Request.Headers.IfNoneMatch = "\"E1\""; + + Assert.False(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.Empty(sink.Writes); + } + + public static TheoryData EquivalentWeakETags + { + get + { + return new TheoryData + { + { new EntityTagHeaderValue("\"tag\""), new EntityTagHeaderValue("\"tag\"") }, + { new EntityTagHeaderValue("\"tag\"", true), new EntityTagHeaderValue("\"tag\"") }, + { new EntityTagHeaderValue("\"tag\""), new EntityTagHeaderValue("\"tag\"", true) }, + { new EntityTagHeaderValue("\"tag\"", true), new EntityTagHeaderValue("\"tag\"", true) } + }; + } + } + + [Theory] + [MemberData(nameof(EquivalentWeakETags))] + public void ContentIsNotModified_IfNoneMatch_ExplicitWithMatch_True(EntityTagHeaderValue responseETag, EntityTagHeaderValue requestETag) + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; + context.CachedResponse.Headers[HeaderNames.ETag] = responseETag.ToString(); + context.HttpContext.Request.Headers.IfNoneMatch = requestETag.ToString(); + + Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.NotModifiedIfNoneMatchMatched); + } + + [Fact] + public void ContentIsNotModified_IfNoneMatch_ExplicitWithoutMatch_False() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; + context.CachedResponse.Headers[HeaderNames.ETag] = "\"E2\""; + context.HttpContext.Request.Headers.IfNoneMatch = "\"E1\""; + + Assert.False(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void ContentIsNotModified_IfNoneMatch_MatchesAtLeastOneValue_True() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; + context.CachedResponse.Headers[HeaderNames.ETag] = "\"E2\""; + context.HttpContext.Request.Headers.IfNoneMatch = new string[] { "\"E0\", \"E1\"", "\"E1\", \"E2\"" }; + + Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.NotModifiedIfNoneMatchMatched); + } + + [Fact] + public void StartResponsegAsync_IfAllowResponseCaptureIsTrue_SetsResponseTime() + { + var clock = new TestClock + { + UtcNow = DateTimeOffset.UtcNow + }; + var middleware = TestUtils.CreateTestMiddleware(options: new OutputCacheOptions + { + SystemClock = clock + }); + var context = TestUtils.CreateTestContext(); + context.ResponseTime = null; + + middleware.StartResponse(context); + + Assert.Equal(clock.UtcNow, context.ResponseTime); + } + + [Fact] + public void StartResponseAsync_IfAllowResponseCaptureIsTrue_SetsResponseTimeOnlyOnce() + { + var clock = new TestClock + { + UtcNow = DateTimeOffset.UtcNow + }; + var middleware = TestUtils.CreateTestMiddleware(options: new OutputCacheOptions + { + SystemClock = clock + }); + var context = TestUtils.CreateTestContext(); + var initialTime = clock.UtcNow; + context.ResponseTime = null; + + middleware.StartResponse(context); + Assert.Equal(initialTime, context.ResponseTime); + + clock.UtcNow += TimeSpan.FromSeconds(10); + + middleware.StartResponse(context); + Assert.NotEqual(clock.UtcNow, context.ResponseTime); + Assert.Equal(initialTime, context.ResponseTime); + } + + [Fact] + public void FinalizeCacheHeadersAsync_DefaultResponseValidity_Is60Seconds() + { + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var context = TestUtils.CreateTestContext(); + + middleware.FinalizeCacheHeaders(context); + + Assert.Equal(TimeSpan.FromSeconds(60), context.CachedResponseValidFor); + Assert.Empty(sink.Writes); + } + + [Fact] + public void FinalizeCacheHeadersAsync_ResponseValidity_IgnoresExpiryIfAvailable() + { + // The Expires header should not be used when set in the response + + var clock = new TestClock + { + UtcNow = DateTimeOffset.MinValue + }; + var options = new OutputCacheOptions + { + SystemClock = clock + }; + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, options: options); + var context = TestUtils.CreateTestContext(); + + context.ResponseTime = clock.UtcNow; + context.HttpContext.Response.Headers.Expires = HeaderUtilities.FormatDate(clock.UtcNow + TimeSpan.FromSeconds(11)); + + middleware.FinalizeCacheHeaders(context); + + Assert.Equal(options.DefaultExpirationTimeSpan, context.CachedResponseValidFor); + Assert.Empty(sink.Writes); + } + + [Fact] + public void FinalizeCacheHeadersAsync_ResponseValidity_UseMaxAgeIfAvailable() + { + // The MaxAge header should not be used if set in the response + + var clock = new TestClock + { + UtcNow = DateTimeOffset.UtcNow + }; + var sink = new TestSink(); + var options = new OutputCacheOptions + { + SystemClock = clock + }; + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, options: options); + var context = TestUtils.CreateTestContext(); + + context.ResponseTime = clock.UtcNow; + context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(12) + }.ToString(); + + context.HttpContext.Response.Headers.Expires = HeaderUtilities.FormatDate(clock.UtcNow + TimeSpan.FromSeconds(11)); + + middleware.FinalizeCacheHeaders(context); + + Assert.Equal(options.DefaultExpirationTimeSpan, context.CachedResponseValidFor); + Assert.Empty(sink.Writes); + } + + [Fact] + public void FinalizeCacheHeadersAsync_ResponseValidity_UseSharedMaxAgeIfAvailable() + { + var clock = new TestClock + { + UtcNow = DateTimeOffset.UtcNow + }; + var sink = new TestSink(); + var options = new OutputCacheOptions + { + SystemClock = clock + }; + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, options: options); + var context = TestUtils.CreateTestContext(); + + context.ResponseTime = clock.UtcNow; + context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(12), + SharedMaxAge = TimeSpan.FromSeconds(13) + }.ToString(); + context.HttpContext.Response.Headers.Expires = HeaderUtilities.FormatDate(clock.UtcNow + TimeSpan.FromSeconds(11)); + + middleware.FinalizeCacheHeaders(context); + + Assert.Equal(options.DefaultExpirationTimeSpan, context.CachedResponseValidFor); + Assert.Empty(sink.Writes); + } + + public static TheoryData NullOrEmptyVaryRules + { + get + { + return new TheoryData + { + default(StringValues), + StringValues.Empty, + new StringValues((string)null), + new StringValues(string.Empty), + new StringValues((string[])null), + new StringValues(new string[0]), + new StringValues(new string[] { null }), + new StringValues(new string[] { string.Empty }) + }; + } + } + + [Theory] + [MemberData(nameof(NullOrEmptyVaryRules))] + public void FinalizeCacheHeadersAsync_UpdateCachedVaryByRules_NullOrEmptyRules(StringValues vary) + { + var cache = new TestOutputCache(); + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); + var context = TestUtils.CreateTestContext(cache); + + context.HttpContext.Response.Headers.Vary = vary; + context.HttpContext.Features.Set(new OutputCacheFeature(context)); + context.CacheVaryByRules.QueryKeys = vary; + + middleware.FinalizeCacheHeaders(context); + + // Vary rules should not be updated + Assert.Equal(0, cache.SetCount); + Assert.Empty(sink.Writes); + } + + [Fact] + public void FinalizeCacheHeadersAsync_AddsDate_IfNoneSpecified() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var context = TestUtils.CreateTestContext(); + // ResponseTime is the actual value that's used to set the Date header in FinalizeCacheHeadersAsync + context.ResponseTime = utcNow; + + Assert.True(StringValues.IsNullOrEmpty(context.HttpContext.Response.Headers.Date)); + + middleware.FinalizeCacheHeaders(context); + + Assert.Equal(HeaderUtilities.FormatDate(utcNow), context.HttpContext.Response.Headers.Date); + Assert.Empty(sink.Writes); + } + + [Fact] + public void FinalizeCacheHeadersAsync_IgnoresDate_IfSpecified() + { + // The Date header should not be used when set in the response + + var utcNow = DateTimeOffset.UtcNow; + var responseTime = utcNow + TimeSpan.FromSeconds(10); + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var context = TestUtils.CreateTestContext(); + + context.HttpContext.Response.Headers.Date = HeaderUtilities.FormatDate(utcNow); + context.ResponseTime = responseTime; + + Assert.Equal(HeaderUtilities.FormatDate(utcNow), context.HttpContext.Response.Headers.Date); + + middleware.FinalizeCacheHeaders(context); + + Assert.Equal(HeaderUtilities.FormatDate(responseTime), context.HttpContext.Response.Headers.Date); + Assert.Empty(sink.Writes); + } + + [Fact] + public void FinalizeCacheHeadersAsync_StoresCachedResponse_InState() + { + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var context = TestUtils.CreateTestContext(); + + Assert.Null(context.CachedResponse); + + middleware.FinalizeCacheHeaders(context); + + Assert.NotNull(context.CachedResponse); + Assert.Empty(sink.Writes); + } + + [Fact] + public void FinalizeCacheHeadersAsync_StoresHeaders() + { + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var context = TestUtils.CreateTestContext(); + + context.HttpContext.Response.Headers.Vary = "HeaderB, heaDera"; + + middleware.FinalizeCacheHeaders(context); + + Assert.Equal(new StringValues(new[] { "HeaderB, heaDera" }), context.CachedResponse.Headers[HeaderNames.Vary]); + } + + [Fact] + public async Task FinalizeCacheBody_Cache_IfContentLengthMatches() + { + var cache = new TestOutputCache(); + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); + var context = TestUtils.CreateTestContext(cache); + + middleware.ShimResponseStream(context); + context.HttpContext.Response.ContentLength = 20; + + await context.HttpContext.Response.WriteAsync(new string('0', 20)); + + context.CachedResponse = new OutputCacheEntry { Headers = new() }; + context.CacheKey = "BaseKey"; + context.CachedResponseValidFor = TimeSpan.FromSeconds(10); + + await middleware.FinalizeCacheBodyAsync(context); + + Assert.Equal(1, cache.SetCount); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseCached); + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async Task FinalizeCacheBody_DoNotCache_IfContentLengthMismatches(string method) + { + var cache = new TestOutputCache(); + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); + var context = TestUtils.CreateTestContext(cache); + + middleware.ShimResponseStream(context); + context.HttpContext.Response.ContentLength = 9; + context.HttpContext.Request.Method = method; + + await context.HttpContext.Response.WriteAsync(new string('0', 10)); + + context.CachedResponse = new OutputCacheEntry(); + context.CacheKey = "BaseKey"; + context.CachedResponseValidFor = TimeSpan.FromSeconds(10); + + await middleware.FinalizeCacheBodyAsync(context); + + Assert.Equal(0, cache.SetCount); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseContentLengthMismatchNotCached); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FinalizeCacheBody_RequestHead_Cache_IfContentLengthPresent_AndBodyAbsentOrOfSameLength(bool includeBody) + { + var cache = new TestOutputCache(); + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); + var context = TestUtils.CreateTestContext(cache); + + middleware.ShimResponseStream(context); + context.HttpContext.Response.ContentLength = 10; + context.HttpContext.Request.Method = "HEAD"; + + if (includeBody) + { + // A response to HEAD should not include a body, but it may be present + await context.HttpContext.Response.WriteAsync(new string('0', 10)); + } + + context.CachedResponse = new OutputCacheEntry { Headers = new() }; + context.CacheKey = "BaseKey"; + context.CachedResponseValidFor = TimeSpan.FromSeconds(10); + + await middleware.FinalizeCacheBodyAsync(context); + + Assert.Equal(1, cache.SetCount); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseCached); + } + + [Fact] + public async Task FinalizeCacheBody_Cache_IfContentLengthAbsent() + { + var cache = new TestOutputCache(); + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); + var context = TestUtils.CreateTestContext(cache); + + middleware.ShimResponseStream(context); + + await context.HttpContext.Response.WriteAsync(new string('0', 10)); + + context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; + context.CacheKey = "BaseKey"; + context.CachedResponseValidFor = TimeSpan.FromSeconds(10); + + await middleware.FinalizeCacheBodyAsync(context); + + Assert.Equal(1, cache.SetCount); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseCached); + } + + [Fact] + public async Task FinalizeCacheBody_DoNotCache_IfIsResponseCacheableFalse() + { + var cache = new TestOutputCache(); + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); + var context = TestUtils.CreateTestContext(cache); + + middleware.ShimResponseStream(context); + await context.HttpContext.Response.WriteAsync(new string('0', 10)); + context.AllowCacheStorage = false; + context.CacheKey = "BaseKey"; + + await middleware.FinalizeCacheBodyAsync(context); + + Assert.Equal(0, cache.SetCount); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseNotCached); + } + + [Fact] + public async Task FinalizeCacheBody_DoNotCache_IfBufferingDisabled() + { + var cache = new TestOutputCache(); + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); + var context = TestUtils.CreateTestContext(cache); + + middleware.ShimResponseStream(context); + await context.HttpContext.Response.WriteAsync(new string('0', 10)); + + context.OutputCacheStream.DisableBuffering(); + + await middleware.FinalizeCacheBodyAsync(context); + + Assert.Equal(0, cache.SetCount); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseNotCached); + } + + [Fact] + public async Task FinalizeCacheBody_DoNotCache_IfSizeTooBig() + { + var sink = new TestSink(); + var middleware = TestUtils.CreateTestMiddleware( + testSink: sink, + keyProvider: new TestResponseCachingKeyProvider("BaseKey"), + cache: new MemoryOutputCacheStore(new MemoryCache(new MemoryCacheOptions + { + SizeLimit = 100 + }))); + var context = TestUtils.CreateTestContext(); + middleware.TryGetRequestPolicies(context.HttpContext, out var policies); + middleware.ShimResponseStream(context); + + await context.HttpContext.Response.WriteAsync(new string('0', 101)); + + context.CachedResponse = new OutputCacheEntry() { Headers = new HeaderDictionary() }; + context.CacheKey = "BaseKey"; + context.CachedResponseValidFor = TimeSpan.FromSeconds(10); + + await middleware.FinalizeCacheBodyAsync(context); + + // The response cached message will be logged but the adding of the entry will no-op + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseCached); + + // The entry cannot be retrieved + Assert.False(await middleware.TryServeFromCacheAsync(context, policies)); + } + + [Fact] + public void AddOutputCachingFeature_SecondInvocation_Throws() + { + var httpContext = new DefaultHttpContext(); + var context = TestUtils.CreateTestContext(httpContext); + + // Should not throw + OutputCacheMiddleware.AddOutputCacheFeature(context); + + // Should throw + Assert.ThrowsAny(() => OutputCacheMiddleware.AddOutputCacheFeature(context)); + } + + private class FakeResponseFeature : HttpResponseFeature + { + public override void OnStarting(Func callback, object state) { } + } + + [Fact] + public void GetOrderCasingNormalizedStringValues_NormalizesCasingToUpper() + { + var uppercaseStrings = new StringValues(new[] { "STRINGA", "STRINGB" }); + var lowercaseStrings = new StringValues(new[] { "stringA", "stringB" }); + + var normalizedStrings = OutputCacheMiddleware.GetOrderCasingNormalizedStringValues(lowercaseStrings); + + Assert.Equal(uppercaseStrings, normalizedStrings); + } + + [Fact] + public void GetOrderCasingNormalizedStringValues_NormalizesOrder() + { + var orderedStrings = new StringValues(new[] { "STRINGA", "STRINGB" }); + var reverseOrderStrings = new StringValues(new[] { "STRINGB", "STRINGA" }); + + var normalizedStrings = OutputCacheMiddleware.GetOrderCasingNormalizedStringValues(reverseOrderStrings); + + Assert.Equal(orderedStrings, normalizedStrings); + } + + [Fact] + public void GetOrderCasingNormalizedStringValues_PreservesCommas() + { + var originalStrings = new StringValues(new[] { "STRINGA, STRINGB" }); + + var normalizedStrings = OutputCacheMiddleware.GetOrderCasingNormalizedStringValues(originalStrings); + + Assert.Equal(originalStrings, normalizedStrings); + } +} diff --git a/src/Middleware/OutputCaching/test/OutputCachePoliciesTests.cs b/src/Middleware/OutputCaching/test/OutputCachePoliciesTests.cs new file mode 100644 index 000000000000..53acf44c2acc --- /dev/null +++ b/src/Middleware/OutputCaching/test/OutputCachePoliciesTests.cs @@ -0,0 +1,288 @@ +// 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.OutputCaching.Policies; + +namespace Microsoft.AspNetCore.OutputCaching.Tests; + +public class OutputCachePoliciesTests +{ + [Fact] + public async Task DefaultCachePolicy_EnablesCache() + { + IOutputCachePolicy policy = DefaultPolicy.Instance; + var context = TestUtils.CreateUninitializedContext(); + + await policy.CacheRequestAsync(context, default); + + Assert.True(context.EnableOutputCaching); + } + + [Fact] + public async Task DefaultCachePolicy_AllowsLocking() + { + IOutputCachePolicy policy = DefaultPolicy.Instance; + var context = TestUtils.CreateUninitializedContext(); + + await policy.CacheRequestAsync(context, default); + + Assert.True(context.AllowLocking); + } + + [Fact] + public async Task DefaultCachePolicy_VariesByStar() + { + IOutputCachePolicy policy = DefaultPolicy.Instance; + var context = TestUtils.CreateUninitializedContext(); + + await policy.CacheRequestAsync(context, default); + + Assert.Equal("*", context.CacheVaryByRules.QueryKeys); + } + + [Fact] + public async Task EnableCachePolicy_DisablesCache() + { + IOutputCachePolicy policy = EnableCachePolicy.Disabled; + var context = TestUtils.CreateUninitializedContext(); + context.EnableOutputCaching = true; + + await policy.CacheRequestAsync(context, default); + + Assert.False(context.EnableOutputCaching); + } + + [Fact] + public async Task ExpirationPolicy_SetsResponseExpirationTimeSpan() + { + var duration = TimeSpan.FromDays(1); + IOutputCachePolicy policy = new ExpirationPolicy(duration); + var context = TestUtils.CreateUninitializedContext(); + + await policy.CacheRequestAsync(context, default); + + Assert.Equal(duration, context.ResponseExpirationTimeSpan); + } + + [Fact] + public async Task LockingPolicy_EnablesLocking() + { + IOutputCachePolicy policy = LockingPolicy.Enabled; + var context = TestUtils.CreateUninitializedContext(); + + await policy.CacheRequestAsync(context, default); + + Assert.True(context.AllowLocking); + } + + [Fact] + public async Task LockingPolicy_DisablesLocking() + { + IOutputCachePolicy policy = LockingPolicy.Disabled; + var context = TestUtils.CreateUninitializedContext(); + + await policy.CacheRequestAsync(context, default); + + Assert.False(context.AllowLocking); + } + + [Fact] + public async Task NoLookupPolicy_DisablesLookup() + { + IOutputCachePolicy policy = NoLookupPolicy.Instance; + var context = TestUtils.CreateUninitializedContext(); + + await policy.CacheRequestAsync(context, default); + + Assert.False(context.AllowCacheLookup); + } + + [Fact] + public async Task NoStorePolicy_DisablesStore() + { + IOutputCachePolicy policy = NoStorePolicy.Instance; + var context = TestUtils.CreateUninitializedContext(); + + await policy.CacheRequestAsync(context, default); + + Assert.False(context.AllowCacheStorage); + } + + [Theory] + [InlineData(true, true, true)] + [InlineData(true, false, false)] + [InlineData(false, true, false)] + [InlineData(false, false, false)] + public async Task PredicatePolicy_Filters(bool filter, bool enabled, bool expected) + { + IOutputCachePolicy predicate = new PredicatePolicy(c => ValueTask.FromResult(filter), enabled ? EnableCachePolicy.Enabled : EnableCachePolicy.Disabled); + var context = TestUtils.CreateUninitializedContext(); + + await predicate.CacheRequestAsync(context, default); + + Assert.Equal(expected, context.EnableOutputCaching); + } + + [Fact] + public async Task ProfilePolicy_UsesNamedProfile() + { + var context = TestUtils.CreateUninitializedContext(); + context.Options.AddPolicy("enabled", EnableCachePolicy.Enabled); + context.Options.AddPolicy("disabled", EnableCachePolicy.Disabled); + + IOutputCachePolicy policy = new NamedPolicy("enabled"); + + await policy.CacheRequestAsync(context, default); + + Assert.True(context.EnableOutputCaching); + + policy = new NamedPolicy("disabled"); + + await policy.CacheRequestAsync(context, default); + + Assert.False(context.EnableOutputCaching); + } + + [Fact] + public async Task TagsPolicy_Tags() + { + var context = TestUtils.CreateUninitializedContext(); + + IOutputCachePolicy policy = new TagsPolicy("tag1", "tag2"); + + await policy.CacheRequestAsync(context, default); + + Assert.Contains("tag1", context.Tags); + Assert.Contains("tag2", context.Tags); + } + + [Fact] + public async Task VaryByHeadersPolicy_IsEmpty() + { + var context = TestUtils.CreateUninitializedContext(); + + IOutputCachePolicy policy = new VaryByHeaderPolicy(); + + await policy.CacheRequestAsync(context, default); + + Assert.Empty(context.CacheVaryByRules.Headers); + } + + [Fact] + public async Task VaryByHeadersPolicy_AddsSingleHeader() + { + var context = TestUtils.CreateUninitializedContext(); + var header = "header"; + + IOutputCachePolicy policy = new VaryByHeaderPolicy(header); + + await policy.CacheRequestAsync(context, default); + + Assert.Equal(header, context.CacheVaryByRules.Headers); + } + + [Fact] + public async Task VaryByHeadersPolicy_AddsMultipleHeaders() + { + var context = TestUtils.CreateUninitializedContext(); + var headers = new[] { "header1", "header2" }; + + IOutputCachePolicy policy = new VaryByHeaderPolicy(headers); + + await policy.CacheRequestAsync(context, default); + + Assert.Equal(headers, context.CacheVaryByRules.Headers); + } + + [Fact] + public async Task VaryByQueryPolicy_IsEmpty() + { + var context = TestUtils.CreateUninitializedContext(); + + IOutputCachePolicy policy = new VaryByQueryPolicy(); + + await policy.CacheRequestAsync(context, default); + + Assert.Empty(context.CacheVaryByRules.QueryKeys); + } + + [Fact] + public async Task VaryByQueryPolicy_AddsSingleHeader() + { + var context = TestUtils.CreateUninitializedContext(); + var query = "query"; + + IOutputCachePolicy policy = new VaryByQueryPolicy(query); + + await policy.CacheRequestAsync(context, default); + + Assert.Equal(query, context.CacheVaryByRules.QueryKeys); + } + + [Fact] + public async Task VaryByQueryPolicy_AddsMultipleHeaders() + { + var context = TestUtils.CreateUninitializedContext(); + var queries = new[] { "query1", "query2" }; + + IOutputCachePolicy policy = new VaryByQueryPolicy(queries); + + await policy.CacheRequestAsync(context, default); + + Assert.Equal(queries, context.CacheVaryByRules.QueryKeys); + } + + [Fact] + public async Task VaryByValuePolicy_SingleValue() + { + var context = TestUtils.CreateUninitializedContext(); + var value = "value"; + + IOutputCachePolicy policy = new VaryByValuePolicy(context => value); + + await policy.CacheRequestAsync(context, default); + + Assert.Equal(value, context.CacheVaryByRules.VaryByPrefix); + } + + [Fact] + public async Task VaryByValuePolicy_SingleValueAsync() + { + var context = TestUtils.CreateUninitializedContext(); + var value = "value"; + + IOutputCachePolicy policy = new VaryByValuePolicy((context, token) => ValueTask.FromResult(value)); + + await policy.CacheRequestAsync(context, default); + + Assert.Equal(value, context.CacheVaryByRules.VaryByPrefix); + } + + [Fact] + public async Task VaryByValuePolicy_KeyValuePair() + { + var context = TestUtils.CreateUninitializedContext(); + var key = "key"; + var value = "value"; + + IOutputCachePolicy policy = new VaryByValuePolicy(context => new KeyValuePair(key, value)); + + await policy.CacheRequestAsync(context, default); + + Assert.Contains(new KeyValuePair(key, value), context.CacheVaryByRules.VaryByCustom); + } + + [Fact] + public async Task VaryByValuePolicy_KeyValuePairAsync() + { + var context = TestUtils.CreateUninitializedContext(); + var key = "key"; + var value = "value"; + + IOutputCachePolicy policy = new VaryByValuePolicy((context, token) => ValueTask.FromResult(new KeyValuePair(key, value))); + + await policy.CacheRequestAsync(context, default); + + Assert.Contains(new KeyValuePair(key, value), context.CacheVaryByRules.VaryByCustom); + } +} diff --git a/src/Middleware/OutputCaching/test/OutputCachePolicyProviderTests.cs b/src/Middleware/OutputCaching/test/OutputCachePolicyProviderTests.cs new file mode 100644 index 000000000000..6238b09791aa --- /dev/null +++ b/src/Middleware/OutputCaching/test/OutputCachePolicyProviderTests.cs @@ -0,0 +1,410 @@ +// 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.Http; +using Microsoft.AspNetCore.OutputCaching.Policies; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.OutputCaching.Tests; + +public class OutputCachePolicyProviderTests +{ + public static TheoryData CacheableMethods + { + get + { + return new TheoryData + { + HttpMethods.Get, + HttpMethods.Head + }; + } + } + + public static TheoryData NonCacheableMethods + { + get + { + return new TheoryData + { + HttpMethods.Post, + HttpMethods.Put, + HttpMethods.Delete, + HttpMethods.Trace, + HttpMethods.Connect, + HttpMethods.Options, + "", + null + }; + } + } + + [Theory] + [MemberData(nameof(CacheableMethods))] + public async Task AttemptOutputCaching_CacheableMethods_IsAllowed(string method) + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + var policies = new[] { new OutputCachePolicyBuilder().Build() }; + context.HttpContext.Request.Method = method; + + foreach (var policy in policies) + { + await policy.CacheRequestAsync(context, default); + } + + Assert.True(context.AllowCacheStorage); + Assert.True(context.AllowCacheLookup); + Assert.Empty(sink.Writes); + } + + [Theory] + [MemberData(nameof(NonCacheableMethods))] + public async Task AttemptOutputCaching_UncacheableMethods_NotAllowed(string method) + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + var policy = new OutputCachePolicyBuilder().Build(); + context.HttpContext.Request.Method = method; + + await policy.CacheRequestAsync(context, default); + + Assert.False(context.AllowCacheLookup); + Assert.False(context.AllowCacheStorage); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.RequestMethodNotCacheable); + } + + [Fact] + public async Task AttemptResponseCaching_AuthorizationHeaders_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Request.Headers.Authorization = "Placeholder"; + + var policy = new OutputCachePolicyBuilder().Build(); + + await policy.CacheRequestAsync(context, default); + + Assert.False(context.AllowCacheStorage); + Assert.False(context.AllowCacheLookup); + + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.RequestWithAuthorizationNotCacheable); + } + + [Fact] + public async Task AllowCacheStorage_NoStore_IsAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Request.Headers.CacheControl = new CacheControlHeaderValue() + { + NoStore = true + }.ToString(); + + var policy = new OutputCachePolicyBuilder().Build(); + await policy.CacheRequestAsync(context, default); + + Assert.True(context.AllowCacheStorage); + Assert.Empty(sink.Writes); + } + + [Fact] + public async Task AllowCacheLookup_LegacyDirectives_OverridenByCacheControl() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Request.Headers.Pragma = "no-cache"; + context.HttpContext.Request.Headers.CacheControl = "max-age=10"; + + var policy = new OutputCachePolicyBuilder().Build(); + await policy.CacheRequestAsync(context, default); + + Assert.True(context.AllowCacheLookup); + Assert.Empty(sink.Writes); + } + + [Fact] + public async Task IsResponseCacheable_NoPublic_IsAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + + var policy = new OutputCachePolicyBuilder().Build(); + await policy.ServeResponseAsync(context, default); + + Assert.True(context.AllowCacheStorage); + Assert.True(context.AllowCacheLookup); + Assert.Empty(sink.Writes); + } + + [Fact] + public async Task IsResponseCacheable_Public_IsAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() + { + Public = true + }.ToString(); + + var policy = new OutputCachePolicyBuilder().Build(); + await policy.ServeResponseAsync(context, default); + + Assert.True(context.AllowCacheStorage); + Assert.True(context.AllowCacheLookup); + Assert.Empty(sink.Writes); + } + + [Fact] + public async Task IsResponseCacheable_NoCache_IsAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() + { + NoCache = true + }.ToString(); + + var policy = new OutputCachePolicyBuilder().Build(); + await policy.ServeResponseAsync(context, default); + + Assert.True(context.AllowCacheStorage); + Assert.True(context.AllowCacheLookup); + Assert.Empty(sink.Writes); + } + + [Fact] + public async Task IsResponseCacheable_ResponseNoStore_IsAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() + { + NoStore = true + }.ToString(); + + var policy = new OutputCachePolicyBuilder().Build(); + await policy.ServeResponseAsync(context, default); + + Assert.True(context.AllowCacheStorage); + Assert.True(context.AllowCacheLookup); + Assert.Empty(sink.Writes); + } + + [Fact] + public async Task IsResponseCacheable_SetCookieHeader_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.Headers.SetCookie = "cookieName=cookieValue"; + + var policy = new OutputCachePolicyBuilder().Build(); + await policy.ServeResponseAsync(context, default); + + Assert.False(context.AllowCacheStorage); + Assert.True(context.AllowCacheLookup); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseWithSetCookieNotCacheable); + } + + [Fact] + public async Task IsResponseCacheable_VaryHeaderByStar_IsAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.Headers.Vary = "*"; + var policy = new OutputCachePolicyBuilder().Build(); + await policy.ServeResponseAsync(context, default); + + Assert.True(context.AllowCacheStorage); + Assert.True(context.AllowCacheLookup); + Assert.Empty(sink.Writes); + } + + [Fact] + public async Task IsResponseCacheable_Private_IsAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() + { + Private = true + }.ToString(); + + var policy = new OutputCachePolicyBuilder().Build(); + await policy.ServeResponseAsync(context, default); + + Assert.True(context.AllowCacheStorage); + Assert.True(context.AllowCacheLookup); + Assert.Empty(sink.Writes); + } + + [Theory] + [InlineData(StatusCodes.Status200OK)] + public async Task IsResponseCacheable_SuccessStatusCodes_IsAllowed(int statusCode) + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.StatusCode = statusCode; + + var policy = new OutputCachePolicyBuilder().Build(); + await policy.ServeResponseAsync(context, default); + + Assert.True(context.AllowCacheStorage); + Assert.True(context.AllowCacheLookup); + Assert.Empty(sink.Writes); + } + + [Theory] + [InlineData(StatusCodes.Status100Continue)] + [InlineData(StatusCodes.Status101SwitchingProtocols)] + [InlineData(StatusCodes.Status102Processing)] + [InlineData(StatusCodes.Status201Created)] + [InlineData(StatusCodes.Status202Accepted)] + [InlineData(StatusCodes.Status203NonAuthoritative)] + [InlineData(StatusCodes.Status204NoContent)] + [InlineData(StatusCodes.Status205ResetContent)] + [InlineData(StatusCodes.Status206PartialContent)] + [InlineData(StatusCodes.Status207MultiStatus)] + [InlineData(StatusCodes.Status208AlreadyReported)] + [InlineData(StatusCodes.Status226IMUsed)] + [InlineData(StatusCodes.Status300MultipleChoices)] + [InlineData(StatusCodes.Status301MovedPermanently)] + [InlineData(StatusCodes.Status302Found)] + [InlineData(StatusCodes.Status303SeeOther)] + [InlineData(StatusCodes.Status304NotModified)] + [InlineData(StatusCodes.Status305UseProxy)] + [InlineData(StatusCodes.Status306SwitchProxy)] + [InlineData(StatusCodes.Status307TemporaryRedirect)] + [InlineData(StatusCodes.Status308PermanentRedirect)] + [InlineData(StatusCodes.Status400BadRequest)] + [InlineData(StatusCodes.Status401Unauthorized)] + [InlineData(StatusCodes.Status402PaymentRequired)] + [InlineData(StatusCodes.Status403Forbidden)] + [InlineData(StatusCodes.Status404NotFound)] + [InlineData(StatusCodes.Status405MethodNotAllowed)] + [InlineData(StatusCodes.Status406NotAcceptable)] + [InlineData(StatusCodes.Status407ProxyAuthenticationRequired)] + [InlineData(StatusCodes.Status408RequestTimeout)] + [InlineData(StatusCodes.Status409Conflict)] + [InlineData(StatusCodes.Status410Gone)] + [InlineData(StatusCodes.Status411LengthRequired)] + [InlineData(StatusCodes.Status412PreconditionFailed)] + [InlineData(StatusCodes.Status413RequestEntityTooLarge)] + [InlineData(StatusCodes.Status414RequestUriTooLong)] + [InlineData(StatusCodes.Status415UnsupportedMediaType)] + [InlineData(StatusCodes.Status416RequestedRangeNotSatisfiable)] + [InlineData(StatusCodes.Status417ExpectationFailed)] + [InlineData(StatusCodes.Status418ImATeapot)] + [InlineData(StatusCodes.Status419AuthenticationTimeout)] + [InlineData(StatusCodes.Status421MisdirectedRequest)] + [InlineData(StatusCodes.Status422UnprocessableEntity)] + [InlineData(StatusCodes.Status423Locked)] + [InlineData(StatusCodes.Status424FailedDependency)] + [InlineData(StatusCodes.Status426UpgradeRequired)] + [InlineData(StatusCodes.Status428PreconditionRequired)] + [InlineData(StatusCodes.Status429TooManyRequests)] + [InlineData(StatusCodes.Status431RequestHeaderFieldsTooLarge)] + [InlineData(StatusCodes.Status451UnavailableForLegalReasons)] + [InlineData(StatusCodes.Status500InternalServerError)] + [InlineData(StatusCodes.Status501NotImplemented)] + [InlineData(StatusCodes.Status502BadGateway)] + [InlineData(StatusCodes.Status503ServiceUnavailable)] + [InlineData(StatusCodes.Status504GatewayTimeout)] + [InlineData(StatusCodes.Status505HttpVersionNotsupported)] + [InlineData(StatusCodes.Status506VariantAlsoNegotiates)] + [InlineData(StatusCodes.Status507InsufficientStorage)] + [InlineData(StatusCodes.Status508LoopDetected)] + [InlineData(StatusCodes.Status510NotExtended)] + [InlineData(StatusCodes.Status511NetworkAuthenticationRequired)] + public async Task IsResponseCacheable_NonSuccessStatusCodes_NotAllowed(int statusCode) + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.StatusCode = statusCode; + + var policy = new OutputCachePolicyBuilder().Build(); + await policy.ServeResponseAsync(context, default); + + Assert.True(context.AllowCacheLookup); + Assert.False(context.AllowCacheStorage); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseWithUnsuccessfulStatusCodeNotCacheable); + } + + [Fact] + public async Task IsResponseCacheable_NoExpiryRequirements_IsAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + + var utcNow = DateTimeOffset.UtcNow; + context.HttpContext.Response.Headers.Date = HeaderUtilities.FormatDate(utcNow); + context.ResponseTime = DateTimeOffset.MaxValue; + + var policy = new OutputCachePolicyBuilder().Build(); + await policy.ServeResponseAsync(context, default); + + Assert.True(context.AllowCacheStorage); + Assert.True(context.AllowCacheLookup); + Assert.Empty(sink.Writes); + } + + [Fact] + public async Task IsResponseCacheable_MaxAgeOverridesExpiry_IsAllowed() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(10) + }.ToString(); + context.HttpContext.Response.Headers.Expires = HeaderUtilities.FormatDate(utcNow); + context.HttpContext.Response.Headers.Date = HeaderUtilities.FormatDate(utcNow); + context.ResponseTime = utcNow + TimeSpan.FromSeconds(9); + + var policy = new OutputCachePolicyBuilder().Build(); + await policy.ServeResponseAsync(context, default); + + Assert.True(context.AllowCacheStorage); + Assert.True(context.AllowCacheLookup); + Assert.Empty(sink.Writes); + } + + [Fact] + public async Task IsResponseCacheable_SharedMaxAgeOverridesMaxAge_IsAllowed() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(10), + SharedMaxAge = TimeSpan.FromSeconds(15) + }.ToString(); + context.HttpContext.Response.Headers.Date = HeaderUtilities.FormatDate(utcNow); + context.ResponseTime = utcNow + TimeSpan.FromSeconds(11); + + var policy = new OutputCachePolicyBuilder().Build(); + await policy.ServeResponseAsync(context, default); + + Assert.True(context.AllowCacheStorage); + Assert.True(context.AllowCacheLookup); + Assert.Empty(sink.Writes); + } +} diff --git a/src/Middleware/OutputCaching/test/OutputCacheTests.cs b/src/Middleware/OutputCaching/test/OutputCacheTests.cs new file mode 100644 index 000000000000..83080b67efcc --- /dev/null +++ b/src/Middleware/OutputCaching/test/OutputCacheTests.cs @@ -0,0 +1,985 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.OutputCaching.Tests; + +public class OutputCacheTests +{ + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async Task ServesCachedContent_IfAvailable(string method) + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async Task ServesFreshContent_IfNotAvailable(string method) + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "different")); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesFreshContent_Post() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.PostAsync("", new StringContent(string.Empty)); + var subsequentResponse = await client.PostAsync("", new StringContent(string.Empty)); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesFreshContent_Head_Get() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var subsequentResponse = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, "")); + var initialResponse = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "")); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesFreshContent_Get_Head() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "")); + var subsequentResponse = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, "")); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async Task ServesCachedContent_If_CacheControlNoCache(string method) + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + // verify the response is cached + var cachedResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + await AssertCachedResponseAsync(initialResponse, cachedResponse); + + // assert cached response still served + client.DefaultRequestHeaders.CacheControl = + new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true }; + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async Task ServesCachedContent_If_PragmaNoCache(string method) + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + // verify the response is cached + var cachedResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + await AssertCachedResponseAsync(initialResponse, cachedResponse); + + // assert cached response still served + client.DefaultRequestHeaders.Pragma.Clear(); + client.DefaultRequestHeaders.Pragma.Add(new System.Net.Http.Headers.NameValueHeaderValue("no-cache")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async Task ServesCachedContent_If_PathCasingDiffers(string method) + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "path")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "PATH")); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async Task ServesFreshContent_If_PathCasingDiffers(string method) + { + var options = new OutputCacheOptions { UseCaseSensitivePaths = true }; + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "path")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "PATH")); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async Task ServesFreshContent_If_ResponseExpired(string method) + { + var options = new OutputCacheOptions + { + DefaultExpirationTimeSpan = TimeSpan.FromMicroseconds(100) + }; + + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + await Task.Delay(1); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async Task ServesFreshContent_If_Authorization_HeaderExists(string method) + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("abc"); + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async Task ServesCachedContent_If_Authorization_HeaderExists(string method) + { + var options = new OutputCacheOptions(); + + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + // This is added after the DefaultPolicy which disables caching for authenticated requests + options.AddBasePolicy(b => b.AddPolicy()); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("abc"); + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfVaryHeader_Matches() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(contextAction: context => context.Response.Headers.Vary = HeaderNames.From); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + client.DefaultRequestHeaders.From = "user@example.com"; + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesFreshContent_IfVaryHeader_Mismatches() + { + var options = new OutputCacheOptions(); + options.AddBasePolicy(b => b.VaryByHeader(HeaderNames.From).Build()); + + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + client.DefaultRequestHeaders.From = "user@example.com"; + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user2@example.com"; + var subsequentResponse = await client.GetAsync(""); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfVaryQueryKeys_Matches() + { + var options = new OutputCacheOptions(); + options.AddBasePolicy(b => b.VaryByQuery("query")); + + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?query=value"); + var subsequentResponse = await client.GetAsync("?query=value"); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfVaryQueryKeysExplicit_Matches_QueryKeyCaseInsensitive() + { + var options = new OutputCacheOptions(); + options.AddBasePolicy(b => b.VaryByQuery("QueryA", "queryb")); + + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?querya=valuea&queryb=valueb"); + var subsequentResponse = await client.GetAsync("?QueryA=valuea&QueryB=valueb"); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfVaryQueryKeyStar_Matches_QueryKeyCaseInsensitive() + { + var options = new OutputCacheOptions(); + options.AddBasePolicy(b => b.VaryByQuery("*")); + + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?querya=valuea&queryb=valueb"); + var subsequentResponse = await client.GetAsync("?QueryA=valuea&QueryB=valueb"); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfVaryQueryKeyExplicit_Matches_OrderInsensitive() + { + var options = new OutputCacheOptions(); + options.AddBasePolicy(b => b.VaryByQuery("QueryB", "QueryA")); + + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?QueryA=ValueA&QueryB=ValueB"); + var subsequentResponse = await client.GetAsync("?QueryB=ValueB&QueryA=ValueA"); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfVaryQueryKeyStar_Matches_OrderInsensitive() + { + var options = new OutputCacheOptions(); + options.AddBasePolicy(b => b.VaryByQuery("*")); + + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?QueryA=ValueA&QueryB=ValueB"); + var subsequentResponse = await client.GetAsync("?QueryB=ValueB&QueryA=ValueA"); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesFreshContent_IfVaryQueryKey_Mismatches() + { + var options = new OutputCacheOptions(); + options.AddBasePolicy(b => b.VaryByQuery("query").Build()); + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?query=value"); + var subsequentResponse = await client.GetAsync("?query=value2"); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfOtherVaryQueryKey_Mismatches() + { + var options = new OutputCacheOptions(); + options.AddBasePolicy(b => b.VaryByQuery("query").Build()); + + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?other=value1"); + var subsequentResponse = await client.GetAsync("?other=value2"); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesFreshContent_IfVaryQueryKeyExplicit_Mismatch_QueryKeyCaseSensitive() + { + var options = new OutputCacheOptions(); + options.AddBasePolicy(new VaryByQueryPolicy("QueryA", "QueryB")); + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?querya=valuea&queryb=valueb"); + var subsequentResponse = await client.GetAsync("?querya=ValueA&queryb=ValueB"); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesFreshContent_IfVaryQueryKeyStar_Mismatch_QueryKeyValueCaseSensitive() + { + var options = new OutputCacheOptions(); + options.AddBasePolicy(new VaryByQueryPolicy("*")); + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?querya=valuea&queryb=valueb"); + var subsequentResponse = await client.GetAsync("?querya=ValueA&queryb=ValueB"); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfRequestRequirements_NotMet() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(0) + }; + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task Serves504_IfOnlyIfCachedHeader_IsSpecified() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue() + { + OnlyIfCached = true + }; + var subsequentResponse = await client.GetAsync("/different"); + + initialResponse.EnsureSuccessStatusCode(); + Assert.Equal(System.Net.HttpStatusCode.GatewayTimeout, subsequentResponse.StatusCode); + } + } + + [Fact] + public async Task ServesFreshContent_IfSetCookie_IsSpecified() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(contextAction: context => context.Response.Headers.SetCookie = "cookieName=cookieValue"); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync(""); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfSubsequentRequestContainsNoStore() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue() + { + NoStore = true + }; + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfInitialRequestContainsNoStore() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue() + { + NoStore = true + }; + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfInitialResponseContainsNoStore() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(contextAction: context => context.Response.Headers.CacheControl = CacheControlHeaderValue.NoStoreString); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task Serves304_IfIfModifiedSince_Satisfied() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(contextAction: context => + { + // Ensure these headers are also returned on the subsequent response + context.Response.GetTypedHeaders().ETag = new EntityTagHeaderValue("\"E1\""); + context.Response.Headers.ContentLocation = "/"; + context.Response.Headers.Vary = HeaderNames.From; + }); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.IfModifiedSince = DateTimeOffset.MaxValue; + var subsequentResponse = await client.GetAsync(""); + + initialResponse.EnsureSuccessStatusCode(); + Assert.Equal(System.Net.HttpStatusCode.NotModified, subsequentResponse.StatusCode); + Assert304Headers(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfIfModifiedSince_NotSatisfied() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.IfModifiedSince = DateTimeOffset.MinValue; + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task Serves304_IfIfNoneMatch_Satisfied() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(contextAction: context => + { + context.Response.GetTypedHeaders().ETag = new EntityTagHeaderValue("\"E1\""); + context.Response.Headers.ContentLocation = "/"; + context.Response.Headers.Vary = HeaderNames.From; + }); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.IfNoneMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue("\"E1\"")); + var subsequentResponse = await client.GetAsync(""); + + initialResponse.EnsureSuccessStatusCode(); + Assert.Equal(System.Net.HttpStatusCode.NotModified, subsequentResponse.StatusCode); + Assert304Headers(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfIfNoneMatch_NotSatisfied() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(contextAction: context => context.Response.GetTypedHeaders().ETag = new EntityTagHeaderValue("\"E1\"")); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.IfNoneMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue("\"E2\"")); + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfBodySize_IsCacheable() + { + var options = new OutputCacheOptions + { + MaximumBodySize = 1000 + }; + options.AddBasePolicy(b => b.Build()); + + var builders = TestUtils.CreateBuildersWithOutputCaching(options: options); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesFreshContent_IfBodySize_IsNotCacheable() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(options: new OutputCacheOptions() + { + MaximumBodySize = 1 + }); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync("/different"); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesFreshContent_CaseSensitivePaths_IsNotCacheable() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(options: new OutputCacheOptions() + { + UseCaseSensitivePaths = true + }); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("/path"); + var subsequentResponse = await client.GetAsync("/Path"); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_WithoutReplacingCachedVaryBy_OnCacheMiss() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(contextAction: context => context.Response.Headers.Vary = HeaderNames.From); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + client.DefaultRequestHeaders.From = "user@example.com"; + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user2@example.com"; + var otherResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user@example.com"; + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfCachedVaryByNotUpdated_OnCacheMiss() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(contextAction: context => context.Response.Headers.Vary = context.Request.Headers.Pragma); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + client.DefaultRequestHeaders.From = "user@example.com"; + client.DefaultRequestHeaders.Pragma.Clear(); + client.DefaultRequestHeaders.Pragma.Add(new System.Net.Http.Headers.NameValueHeaderValue("From")); + client.DefaultRequestHeaders.MaxForwards = 1; + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user2@example.com"; + client.DefaultRequestHeaders.Pragma.Clear(); + client.DefaultRequestHeaders.Pragma.Add(new System.Net.Http.Headers.NameValueHeaderValue("From")); + client.DefaultRequestHeaders.MaxForwards = 2; + var otherResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user@example.com"; + client.DefaultRequestHeaders.Pragma.Clear(); + client.DefaultRequestHeaders.Pragma.Add(new System.Net.Http.Headers.NameValueHeaderValue("From")); + client.DefaultRequestHeaders.MaxForwards = 1; + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async Task ServesCachedContent_IfAvailable_UsingHead_WithContentLength() + { + var builders = TestUtils.CreateBuildersWithOutputCaching(); + + foreach (var builder in builders) + { + using var host = builder.Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var client = server.CreateClient(); + var initialResponse = await client.SendAsync(TestUtils.CreateRequest("HEAD", "?contentLength=10")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest("HEAD", "?contentLength=10")); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + + private static void Assert304Headers(HttpResponseMessage initialResponse, HttpResponseMessage subsequentResponse) + { + // https://tools.ietf.org/html/rfc7232#section-4.1 + // The server generating a 304 response MUST generate any of the + // following header fields that would have been sent in a 200 (OK) + // response to the same request: Cache-Control, Content-Location, Date, + // ETag, Expires, and Vary. + + Assert.Equal(initialResponse.Headers.CacheControl, subsequentResponse.Headers.CacheControl); + Assert.Equal(initialResponse.Content.Headers.ContentLocation, subsequentResponse.Content.Headers.ContentLocation); + Assert.Equal(initialResponse.Headers.Date, subsequentResponse.Headers.Date); + Assert.Equal(initialResponse.Headers.ETag, subsequentResponse.Headers.ETag); + Assert.Equal(initialResponse.Content.Headers.Expires, subsequentResponse.Content.Headers.Expires); + Assert.Equal(initialResponse.Headers.Vary, subsequentResponse.Headers.Vary); + } + + private static async Task AssertCachedResponseAsync(HttpResponseMessage initialResponse, HttpResponseMessage subsequentResponse) + { + initialResponse.EnsureSuccessStatusCode(); + subsequentResponse.EnsureSuccessStatusCode(); + + foreach (var header in initialResponse.Headers) + { + Assert.Equal(initialResponse.Headers.GetValues(header.Key), subsequentResponse.Headers.GetValues(header.Key)); + } + Assert.True(subsequentResponse.Headers.Contains(HeaderNames.Age)); + Assert.Equal(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + } + + private static async Task AssertFreshResponseAsync(HttpResponseMessage initialResponse, HttpResponseMessage subsequentResponse) + { + initialResponse.EnsureSuccessStatusCode(); + subsequentResponse.EnsureSuccessStatusCode(); + + Assert.False(subsequentResponse.Headers.Contains(HeaderNames.Age)); + + if (initialResponse.RequestMessage.Method == HttpMethod.Head && + subsequentResponse.RequestMessage.Method == HttpMethod.Head) + { + Assert.True(initialResponse.Headers.Contains("X-Value")); + Assert.NotEqual(initialResponse.Headers.GetValues("X-Value"), subsequentResponse.Headers.GetValues("X-Value")); + } + else + { + Assert.NotEqual(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + } + } +} diff --git a/src/Middleware/OutputCaching/test/SegmentWriteStreamTests.cs b/src/Middleware/OutputCaching/test/SegmentWriteStreamTests.cs new file mode 100644 index 000000000000..cb8d4bcfbb69 --- /dev/null +++ b/src/Middleware/OutputCaching/test/SegmentWriteStreamTests.cs @@ -0,0 +1,105 @@ +// 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.OutputCaching.Tests; + +public class SegmentWriteStreamTests +{ + private static readonly byte[] WriteData = new byte[] + { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 + }; + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void SegmentWriteStream_InvalidSegmentSize_Throws(int segmentSize) + { + Assert.Throws(() => new SegmentWriteStream(segmentSize)); + } + + [Fact] + public void ReadAndSeekOperations_Throws() + { + var stream = new SegmentWriteStream(1); + + Assert.Throws(() => stream.Read(new byte[1], 0, 0)); + Assert.Throws(() => stream.Position = 0); + Assert.Throws(() => stream.Seek(0, SeekOrigin.Begin)); + } + + [Fact] + public void GetSegments_ExtractionDisablesWriting() + { + var stream = new SegmentWriteStream(1); + + Assert.True(stream.CanWrite); + Assert.Empty(stream.GetSegments()); + Assert.False(stream.CanWrite); + } + + [Theory] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + public void WriteByte_CanWriteAllBytes(int segmentSize) + { + var stream = new SegmentWriteStream(segmentSize); + + foreach (var datum in WriteData) + { + stream.WriteByte(datum); + } + var segments = stream.GetSegments(); + + Assert.Equal(WriteData.Length, stream.Length); + Assert.Equal((WriteData.Length + segmentSize - 1) / segmentSize, segments.Count); + + for (var i = 0; i < WriteData.Length; i += segmentSize) + { + var expectedSegmentSize = Math.Min(segmentSize, WriteData.Length - i); + var expectedSegment = new byte[expectedSegmentSize]; + for (int j = 0; j < expectedSegmentSize; j++) + { + expectedSegment[j] = (byte)(i + j); + } + var segment = segments[i / segmentSize]; + + Assert.Equal(expectedSegmentSize, segment.Length); + Assert.True(expectedSegment.SequenceEqual(segment)); + } + } + + [Theory] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + public void Write_CanWriteAllBytes(int writeSize) + { + var segmentSize = 5; + var stream = new SegmentWriteStream(segmentSize); + + for (var i = 0; i < WriteData.Length; i += writeSize) + { + stream.Write(WriteData, i, Math.Min(writeSize, WriteData.Length - i)); + } + var segments = stream.GetSegments(); + + Assert.Equal(WriteData.Length, stream.Length); + Assert.Equal((WriteData.Length + segmentSize - 1) / segmentSize, segments.Count); + + for (var i = 0; i < WriteData.Length; i += segmentSize) + { + var expectedSegmentSize = Math.Min(segmentSize, WriteData.Length - i); + var expectedSegment = new byte[expectedSegmentSize]; + for (int j = 0; j < expectedSegmentSize; j++) + { + expectedSegment[j] = (byte)(i + j); + } + var segment = segments[i / segmentSize]; + + Assert.Equal(expectedSegmentSize, segment.Length); + Assert.True(expectedSegment.SequenceEqual(segment)); + } + } +} diff --git a/src/Middleware/OutputCaching/test/TestDocument.txt b/src/Middleware/OutputCaching/test/TestDocument.txt new file mode 100644 index 000000000000..fb31ae6de8a7 --- /dev/null +++ b/src/Middleware/OutputCaching/test/TestDocument.txt @@ -0,0 +1 @@ +0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ \ No newline at end of file diff --git a/src/Middleware/OutputCaching/test/TestUtils.cs b/src/Middleware/OutputCaching/test/TestUtils.cs new file mode 100644 index 000000000000..33ac6f56c869 --- /dev/null +++ b/src/Middleware/OutputCaching/test/TestUtils.cs @@ -0,0 +1,374 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable +using System.Net.Http; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.OutputCaching.Tests; + +internal class TestUtils +{ + static TestUtils() + { + // Force sharding in tests + StreamUtilities.BodySegmentSize = 10; + } + + private static bool TestRequestDelegate(HttpContext context, string guid) + { + var headers = context.Response.GetTypedHeaders(); + headers.Date = DateTimeOffset.UtcNow; + headers.Headers["X-Value"] = guid; + + if (context.Request.Method != "HEAD") + { + return true; + } + return false; + } + + internal static async Task TestRequestDelegateWriteAsync(HttpContext context) + { + var uniqueId = Guid.NewGuid().ToString(); + if (TestRequestDelegate(context, uniqueId)) + { + await context.Response.WriteAsync(uniqueId); + } + } + + internal static async Task TestRequestDelegateSendFileAsync(HttpContext context) + { + var path = Path.Combine(AppContext.BaseDirectory, "TestDocument.txt"); + var uniqueId = Guid.NewGuid().ToString(); + if (TestRequestDelegate(context, uniqueId)) + { + await context.Response.SendFileAsync(path, 0, null); + await context.Response.WriteAsync(uniqueId); + } + } + + internal static Task TestRequestDelegateWrite(HttpContext context) + { + var uniqueId = Guid.NewGuid().ToString(); + if (TestRequestDelegate(context, uniqueId)) + { + var feature = context.Features.Get(); + if (feature != null) + { + feature.AllowSynchronousIO = true; + } + context.Response.Write(uniqueId); + } + return Task.CompletedTask; + } + + internal static IOutputCacheKeyProvider CreateTestKeyProvider() + { + return CreateTestKeyProvider(new OutputCacheOptions()); + } + + internal static IOutputCacheKeyProvider CreateTestKeyProvider(OutputCacheOptions options) + { + return new OutputCacheKeyProvider(new DefaultObjectPoolProvider(), Options.Create(options)); + } + + internal static IEnumerable CreateBuildersWithOutputCaching( + Action? configureDelegate = null, + OutputCacheOptions? options = null, + Action? contextAction = null) + { + return CreateBuildersWithOutputCaching(configureDelegate, options, new RequestDelegate[] + { + context => + { + contextAction?.Invoke(context); + return TestRequestDelegateWrite(context); + }, + context => + { + contextAction?.Invoke(context); + return TestRequestDelegateWriteAsync(context); + }, + context => + { + contextAction?.Invoke(context); + return TestRequestDelegateSendFileAsync(context); + }, + }); + } + + private static IEnumerable CreateBuildersWithOutputCaching( + Action? configureDelegate = null, + OutputCacheOptions? options = null, + IEnumerable? requestDelegates = null) + { + if (configureDelegate == null) + { + configureDelegate = app => { }; + } + if (requestDelegates == null) + { + requestDelegates = new RequestDelegate[] + { + TestRequestDelegateWriteAsync, + TestRequestDelegateWrite + }; + } + + foreach (var requestDelegate in requestDelegates) + { + // Test with in memory OutputCache + yield return new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddOutputCache(outputCachingOptions => + { + if (options != null) + { + outputCachingOptions.MaximumBodySize = options.MaximumBodySize; + outputCachingOptions.UseCaseSensitivePaths = options.UseCaseSensitivePaths; + outputCachingOptions.SystemClock = options.SystemClock; + outputCachingOptions.BasePolicies = options.BasePolicies; + outputCachingOptions.DefaultExpirationTimeSpan = options.DefaultExpirationTimeSpan; + outputCachingOptions.SizeLimit = options.SizeLimit; + } + else + { + outputCachingOptions.BasePolicies = new(); + outputCachingOptions.BasePolicies.Add(new OutputCachePolicyBuilder().Build()); + } + }); + }) + .Configure(app => + { + configureDelegate(app); + app.UseOutputCache(); + app.Run(requestDelegate); + }); + }); + } + } + + internal static OutputCacheMiddleware CreateTestMiddleware( + RequestDelegate? next = null, + IOutputCacheStore? cache = null, + OutputCacheOptions? options = null, + TestSink? testSink = null, + IOutputCacheKeyProvider? keyProvider = null + ) + { + if (next == null) + { + next = httpContext => Task.CompletedTask; + } + if (cache == null) + { + cache = new TestOutputCache(); + } + if (options == null) + { + options = new OutputCacheOptions(); + } + if (keyProvider == null) + { + keyProvider = new OutputCacheKeyProvider(new DefaultObjectPoolProvider(), Options.Create(options)); + } + + return new OutputCacheMiddleware( + next, + Options.Create(options), + testSink == null ? (ILoggerFactory)NullLoggerFactory.Instance : new TestLoggerFactory(testSink, true), + cache, + keyProvider); + } + + internal static OutputCacheContext CreateTestContext(IOutputCacheStore? cache = null, OutputCacheOptions? options = null) + { + return new OutputCacheContext(new DefaultHttpContext(), cache ?? new TestOutputCache(), options ?? Options.Create(new OutputCacheOptions()).Value, NullLogger.Instance) + { + EnableOutputCaching = true, + AllowCacheStorage = true, + AllowCacheLookup = true, + ResponseTime = DateTimeOffset.UtcNow + }; + } + + internal static OutputCacheContext CreateTestContext(HttpContext httpContext, IOutputCacheStore? cache = null, OutputCacheOptions? options = null) + { + return new OutputCacheContext(httpContext, cache ?? new TestOutputCache(), options ?? Options.Create(new OutputCacheOptions()).Value, NullLogger.Instance) + { + EnableOutputCaching = true, + AllowCacheStorage = true, + AllowCacheLookup = true, + ResponseTime = DateTimeOffset.UtcNow + }; + } + + internal static OutputCacheContext CreateTestContext(ITestSink testSink, IOutputCacheStore? cache = null, OutputCacheOptions? options = null) + { + return new OutputCacheContext(new DefaultHttpContext(), cache ?? new TestOutputCache(), options ?? Options.Create(new OutputCacheOptions()).Value, new TestLogger("OutputCachingTests", testSink, true)) + { + EnableOutputCaching = true, + AllowCacheStorage = true, + AllowCacheLookup = true, + ResponseTime = DateTimeOffset.UtcNow + }; + } + + internal static OutputCacheContext CreateUninitializedContext(IOutputCacheStore? cache = null, OutputCacheOptions? options = null) + { + return new OutputCacheContext(new DefaultHttpContext(), cache ?? new TestOutputCache(), options ?? Options.Create(new OutputCacheOptions()).Value, NullLogger.Instance) + { + }; + } + + internal static void AssertLoggedMessages(IEnumerable messages, params LoggedMessage[] expectedMessages) + { + var messageList = messages.ToList(); + Assert.Equal(expectedMessages.Length, messageList.Count); + + for (var i = 0; i < messageList.Count; i++) + { + Assert.Equal(expectedMessages[i].EventId, messageList[i].EventId); + Assert.Equal(expectedMessages[i].LogLevel, messageList[i].LogLevel); + } + } + + public static HttpRequestMessage CreateRequest(string method, string requestUri) + { + return new HttpRequestMessage(new HttpMethod(method), requestUri); + } +} + +internal static class HttpResponseWritingExtensions +{ + internal static void Write(this HttpResponse response, string text) + { + ArgumentNullException.ThrowIfNull(response); + ArgumentNullException.ThrowIfNull(text); + + var data = Encoding.UTF8.GetBytes(text); + response.Body.Write(data, 0, data.Length); + } +} + +internal class LoggedMessage +{ + internal static LoggedMessage RequestMethodNotCacheable => new LoggedMessage(1, LogLevel.Debug); + internal static LoggedMessage RequestWithAuthorizationNotCacheable => new LoggedMessage(2, LogLevel.Debug); + internal static LoggedMessage ResponseWithSetCookieNotCacheable => new LoggedMessage(3, LogLevel.Debug); + internal static LoggedMessage ResponseWithUnsuccessfulStatusCodeNotCacheable => new LoggedMessage(4, LogLevel.Debug); + internal static LoggedMessage NotModifiedIfNoneMatchStar => new LoggedMessage(5, LogLevel.Debug); + internal static LoggedMessage NotModifiedIfNoneMatchMatched => new LoggedMessage(6, LogLevel.Debug); + internal static LoggedMessage NotModifiedIfModifiedSinceSatisfied => new LoggedMessage(7, LogLevel.Debug); + internal static LoggedMessage NotModifiedServed => new LoggedMessage(8, LogLevel.Information); + internal static LoggedMessage CachedResponseServed => new LoggedMessage(9, LogLevel.Information); + internal static LoggedMessage GatewayTimeoutServed => new LoggedMessage(10, LogLevel.Information); + internal static LoggedMessage NoResponseServed => new LoggedMessage(11, LogLevel.Information); + internal static LoggedMessage VaryByRulesUpdated => new LoggedMessage(12, LogLevel.Debug); + internal static LoggedMessage ResponseCached => new LoggedMessage(13, LogLevel.Information); + internal static LoggedMessage ResponseNotCached => new LoggedMessage(14, LogLevel.Information); + internal static LoggedMessage ResponseContentLengthMismatchNotCached => new LoggedMessage(15, LogLevel.Warning); + internal static LoggedMessage ExpirationExpiresExceeded => new LoggedMessage(15, LogLevel.Debug); + + private LoggedMessage(int evenId, LogLevel logLevel) + { + EventId = evenId; + LogLevel = logLevel; + } + + internal int EventId { get; } + internal LogLevel LogLevel { get; } +} + +internal class TestResponseCachingKeyProvider : IOutputCacheKeyProvider +{ + private readonly string _key; + + public TestResponseCachingKeyProvider(string key) + { + _key = key; + } + + public string CreateStorageKey(OutputCacheContext? context) + { + return _key; + } +} + +internal class TestOutputCache : IOutputCacheStore +{ + private readonly Dictionary _storage = new(); + public int GetCount { get; private set; } + public int SetCount { get; private set; } + + public ValueTask EvictByTagAsync(string tag, CancellationToken token) + { + throw new NotImplementedException(); + } + + public ValueTask GetAsync(string? key, CancellationToken token) + { + ArgumentNullException.ThrowIfNull(key); + + GetCount++; + try + { + return ValueTask.FromResult(_storage[key]); + } + catch + { + return ValueTask.FromResult(default(byte[])); + } + } + + public ValueTask SetAsync(string key, byte[] entry, string[]? tags, TimeSpan validFor, CancellationToken token) + { + SetCount++; + _storage[key] = entry; + + return ValueTask.CompletedTask; + } +} + +internal class TestClock : ISystemClock +{ + public DateTimeOffset UtcNow { get; set; } +} + +internal class AllowTestPolicy : IOutputCachePolicy +{ + public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + context.AllowCacheLookup = true; + context.AllowCacheStorage = true; + return ValueTask.CompletedTask; + } + + public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Mvc/Mvc.Core/src/Filters/IOutputCacheFilter.cs b/src/Mvc/Mvc.Core/src/Filters/IOutputCacheFilter.cs new file mode 100644 index 000000000000..d852233e4862 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Filters/IOutputCacheFilter.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. + +namespace Microsoft.AspNetCore.Mvc.Filters; + +/// +/// A filter which sets the appropriate headers related to Output caching. +/// +internal interface IOutputCacheFilter : IFilterMetadata +{ +} diff --git a/src/Mvc/Mvc.Core/src/Filters/OutputCacheFilter.cs b/src/Mvc/Mvc.Core/src/Filters/OutputCacheFilter.cs new file mode 100644 index 000000000000..08beb1bf295b --- /dev/null +++ b/src/Mvc/Mvc.Core/src/Filters/OutputCacheFilter.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.Core; +using Microsoft.AspNetCore.OutputCaching; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.Filters; + +/// +/// An which sets the appropriate headers related to output caching. +/// +internal partial class OutputCacheFilter : IActionFilter +{ + private readonly ILogger _logger; + + /// + /// Creates a new instance of + /// + /// The . + public OutputCacheFilter(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(GetType()); + } + + public void OnActionExecuting(ActionExecutingContext context) + { + ArgumentNullException.ThrowIfNull(context); + + // If there are more filters which can override the values written by this filter, + // then skip execution of this filter. + var effectivePolicy = context.FindEffectivePolicy(); + if (effectivePolicy != null && effectivePolicy != this) + { + Log.NotMostEffectiveFilter(_logger, GetType(), effectivePolicy.GetType(), typeof(IOutputCacheFilter)); + return; + } + + var outputCachingFeature = context.HttpContext.Features.Get(); + if (outputCachingFeature == null) + { + throw new InvalidOperationException( + Resources.FormatOutputCacheAttribute_Requires_OutputCachingMiddleware(nameof(OutputCacheAttribute))); + } + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Execution of filter {OverriddenFilter} is preempted by filter {OverridingFilter} which is the most effective filter implementing policy {FilterPolicy}.", EventName = "NotMostEffectiveFilter")] + public static partial void NotMostEffectiveFilter(ILogger logger, Type overriddenFilter, Type overridingFilter, Type filterPolicy); + } +} diff --git a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj index 9d9b2ac58e91..d437875d6aa5 100644 --- a/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj +++ b/src/Mvc/Mvc.Core/src/Microsoft.AspNetCore.Mvc.Core.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core MVC core components. Contains common action result types, attribute routing, application model conventions, API explorer, application parts, filters, formatters, model binding, and more. @@ -48,6 +48,7 @@ Microsoft.AspNetCore.Mvc.RouteAttribute + diff --git a/src/Mvc/Mvc.Core/src/Resources.resx b/src/Mvc/Mvc.Core/src/Resources.resx index 206a2f420b5c..3427c5ab3f1f 100644 --- a/src/Mvc/Mvc.Core/src/Resources.resx +++ b/src/Mvc/Mvc.Core/src/Resources.resx @@ -510,7 +510,10 @@ Could not parse '{0}'. Content types with wildcards are not supported. + + '{0}' requires the output cache middleware. + The type '{0}' does not contain a TryParse method and the binder '{1}' cannot be used. - \ No newline at end of file + diff --git a/src/Mvc/Mvc.slnf b/src/Mvc/Mvc.slnf index 1dee0bc428eb..2599ea39e358 100644 --- a/src/Mvc/Mvc.slnf +++ b/src/Mvc/Mvc.slnf @@ -46,6 +46,7 @@ "src\\Middleware\\HttpOverrides\\src\\Microsoft.AspNetCore.HttpOverrides.csproj", "src\\Middleware\\Localization.Routing\\src\\Microsoft.AspNetCore.Localization.Routing.csproj", "src\\Middleware\\Localization\\src\\Microsoft.AspNetCore.Localization.csproj", + "src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj", "src\\Middleware\\ResponseCaching.Abstractions\\src\\Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj", "src\\Middleware\\ResponseCaching\\src\\Microsoft.AspNetCore.ResponseCaching.csproj", "src\\Middleware\\Session\\src\\Microsoft.AspNetCore.Session.csproj", diff --git a/src/Mvc/samples/MvcSandbox/MvcSandbox.csproj b/src/Mvc/samples/MvcSandbox/MvcSandbox.csproj index b6774817765a..6c3cf21a5ac2 100644 --- a/src/Mvc/samples/MvcSandbox/MvcSandbox.csproj +++ b/src/Mvc/samples/MvcSandbox/MvcSandbox.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) true diff --git a/src/Shared/TaskToApm.cs b/src/Shared/TaskToApm.cs index 0647dc4a00ed..cec6fa53df53 100644 --- a/src/Shared/TaskToApm.cs +++ b/src/Shared/TaskToApm.cs @@ -42,7 +42,7 @@ public static void End(IAsyncResult asyncResult) return; } - throw new ArgumentNullException(nameof(asyncResult)); + ArgumentNullException.ThrowIfNull(asyncResult, nameof(asyncResult)); } /// Processes an IAsyncResult returned by Begin.