diff --git a/src/ResponseCaching/.gitignore b/src/ResponseCaching/.gitignore
new file mode 100644
index 0000000000..23826aae91
--- /dev/null
+++ b/src/ResponseCaching/.gitignore
@@ -0,0 +1,33 @@
+[Oo]bj/
+[Bb]in/
+TestResults/
+.nuget/
+_ReSharper.*/
+packages/
+artifacts/
+PublishProfiles/
+*.user
+*.suo
+*.cache
+*.docstates
+_ReSharper.*
+nuget.exe
+*net45.csproj
+*net451.csproj
+*k10.csproj
+*.psess
+*.vsp
+*.pidb
+*.userprefs
+*DS_Store
+*.ncrunchsolution
+*.*sdf
+*.ipch
+*.sln.ide
+project.lock.json
+/.vs/
+.vscode/
+.build/
+.testPublish/
+launchSettings.json
+global.json
diff --git a/src/ResponseCaching/Directory.Build.props b/src/ResponseCaching/Directory.Build.props
new file mode 100644
index 0000000000..10c6cc3933
--- /dev/null
+++ b/src/ResponseCaching/Directory.Build.props
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+ Microsoft ASP.NET Core
+ https://github.com/aspnet/ResponseCaching
+ git
+ $(MSBuildThisFileDirectory)
+ $(MSBuildThisFileDirectory)build\Key.snk
+ true
+ true
+ true
+
+
+
diff --git a/src/ResponseCaching/Directory.Build.targets b/src/ResponseCaching/Directory.Build.targets
new file mode 100644
index 0000000000..53b3f6e1da
--- /dev/null
+++ b/src/ResponseCaching/Directory.Build.targets
@@ -0,0 +1,7 @@
+
+
+ $(MicrosoftNETCoreApp20PackageVersion)
+ $(MicrosoftNETCoreApp21PackageVersion)
+ $(NETStandardLibrary20PackageVersion)
+
+
diff --git a/src/ResponseCaching/NuGetPackageVerifier.json b/src/ResponseCaching/NuGetPackageVerifier.json
new file mode 100644
index 0000000000..b153ab1515
--- /dev/null
+++ b/src/ResponseCaching/NuGetPackageVerifier.json
@@ -0,0 +1,7 @@
+{
+ "Default": {
+ "rules": [
+ "DefaultCompositeRule"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/src/ResponseCaching/README.md b/src/ResponseCaching/README.md
new file mode 100644
index 0000000000..5c6ea8d9c8
--- /dev/null
+++ b/src/ResponseCaching/README.md
@@ -0,0 +1,9 @@
+ASP.NET Core Response Caching
+========
+AppVeyor: [](https://ci.appveyor.com/project/aspnetci/ResponseCaching/branch/dev)
+
+Travis: [](https://travis-ci.org/aspnet/ResponseCaching)
+
+This repo hosts the ASP.NET Core middleware for response caching.
+
+This project is part of ASP.NET Core. You can find samples, documentation and getting started instructions for ASP.NET Core at the [Home](https://github.com/aspnet/home) repo.
diff --git a/src/ResponseCaching/ResponseCaching.sln b/src/ResponseCaching/ResponseCaching.sln
new file mode 100644
index 0000000000..69a5397248
--- /dev/null
+++ b/src/ResponseCaching/ResponseCaching.sln
@@ -0,0 +1,66 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio 15
+VisualStudioVersion = 15.0.26730.10
+MinimumVisualStudioVersion = 15.0.26730.03
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{367AABAF-E03C-4491-A9A7-BDDE8903D1B4}"
+ ProjectSection(SolutionItems) = preProject
+ src\Directory.Build.props = src\Directory.Build.props
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{C51DF5BD-B53D-4795-BC01-A9AB066BF286}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{89A50974-E9D4-4F87-ACF2-6A6005E64931}"
+ ProjectSection(SolutionItems) = preProject
+ test\Directory.Build.props = test\Directory.Build.props
+ EndProjectSection
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ResponseCachingSample", "samples\ResponseCachingSample\ResponseCachingSample.csproj", "{1139BDEE-FA15-474D-8855-0AB91F23CF26}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.ResponseCaching.Tests", "test\Microsoft.AspNetCore.ResponseCaching.Tests\Microsoft.AspNetCore.ResponseCaching.Tests.csproj", "{151B2027-3936-44B9-A4A0-E1E5902125AB}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.ResponseCaching", "src\Microsoft.AspNetCore.ResponseCaching\Microsoft.AspNetCore.ResponseCaching.csproj", "{D1031270-DBD3-4F02-A3DC-3E7DADE8EBE6}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.ResponseCaching.Abstractions", "src\Microsoft.AspNetCore.ResponseCaching.Abstractions\Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj", "{2D1022E8-CBB6-478D-A420-CB888D0EF7B7}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B984DDCF-0D61-44C4-9D30-2BC59EE6BD29}"
+ ProjectSection(SolutionItems) = preProject
+ Directory.Build.props = Directory.Build.props
+ Directory.Build.targets = Directory.Build.targets
+ EndProjectSection
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {1139BDEE-FA15-474D-8855-0AB91F23CF26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1139BDEE-FA15-474D-8855-0AB91F23CF26}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1139BDEE-FA15-474D-8855-0AB91F23CF26}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1139BDEE-FA15-474D-8855-0AB91F23CF26}.Release|Any CPU.Build.0 = Release|Any CPU
+ {151B2027-3936-44B9-A4A0-E1E5902125AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {151B2027-3936-44B9-A4A0-E1E5902125AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {151B2027-3936-44B9-A4A0-E1E5902125AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {151B2027-3936-44B9-A4A0-E1E5902125AB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D1031270-DBD3-4F02-A3DC-3E7DADE8EBE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D1031270-DBD3-4F02-A3DC-3E7DADE8EBE6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D1031270-DBD3-4F02-A3DC-3E7DADE8EBE6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D1031270-DBD3-4F02-A3DC-3E7DADE8EBE6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2D1022E8-CBB6-478D-A420-CB888D0EF7B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2D1022E8-CBB6-478D-A420-CB888D0EF7B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2D1022E8-CBB6-478D-A420-CB888D0EF7B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2D1022E8-CBB6-478D-A420-CB888D0EF7B7}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {1139BDEE-FA15-474D-8855-0AB91F23CF26} = {C51DF5BD-B53D-4795-BC01-A9AB066BF286}
+ {151B2027-3936-44B9-A4A0-E1E5902125AB} = {89A50974-E9D4-4F87-ACF2-6A6005E64931}
+ {D1031270-DBD3-4F02-A3DC-3E7DADE8EBE6} = {367AABAF-E03C-4491-A9A7-BDDE8903D1B4}
+ {2D1022E8-CBB6-478D-A420-CB888D0EF7B7} = {367AABAF-E03C-4491-A9A7-BDDE8903D1B4}
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {6F6B4994-06D7-4D35-B0F7-F60913AA8402}
+ EndGlobalSection
+EndGlobal
diff --git a/src/ResponseCaching/build/Key.snk b/src/ResponseCaching/build/Key.snk
new file mode 100644
index 0000000000..e10e4889c1
Binary files /dev/null and b/src/ResponseCaching/build/Key.snk differ
diff --git a/src/ResponseCaching/build/dependencies.props b/src/ResponseCaching/build/dependencies.props
new file mode 100644
index 0000000000..6735646e70
--- /dev/null
+++ b/src/ResponseCaching/build/dependencies.props
@@ -0,0 +1,32 @@
+
+
+ $(MSBuildAllProjects);$(MSBuildThisFileFullPath)
+
+
+
+
+ 2.1.3-rtm-15802
+ 2.0.0
+ 2.1.2
+ 15.6.1
+ 2.0.3
+ 2.3.1
+ 2.4.0-beta.1.build3945
+
+
+
+
+
+
+
+ 2.1.1
+ 2.1.1
+ 2.1.1
+ 2.1.2
+ 2.1.1
+ 2.1.1
+ 2.1.1
+ 2.1.1
+ 2.1.1
+
+
\ No newline at end of file
diff --git a/src/ResponseCaching/build/repo.props b/src/ResponseCaching/build/repo.props
new file mode 100644
index 0000000000..dab1601c88
--- /dev/null
+++ b/src/ResponseCaching/build/repo.props
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Internal.AspNetCore.Universe.Lineup
+ 2.1.0-rc1-*
+ https://dotnet.myget.org/F/aspnetcore-dev/api/v3/index.json
+
+
+
+
+
+
+
diff --git a/src/ResponseCaching/build/sources.props b/src/ResponseCaching/build/sources.props
new file mode 100644
index 0000000000..9215df9751
--- /dev/null
+++ b/src/ResponseCaching/build/sources.props
@@ -0,0 +1,17 @@
+
+
+
+
+ $(DotNetRestoreSources)
+
+ $(RestoreSources);
+ https://dotnet.myget.org/F/dotnet-core/api/v3/index.json;
+ https://dotnet.myget.org/F/aspnetcore-dev/api/v3/index.json;
+ https://dotnet.myget.org/F/aspnetcore-tools/api/v3/index.json;
+
+
+ $(RestoreSources);
+ https://api.nuget.org/v3/index.json;
+
+
+
diff --git a/src/ResponseCaching/samples/ResponseCachingSample/README.md b/src/ResponseCaching/samples/ResponseCachingSample/README.md
new file mode 100644
index 0000000000..08583fda40
--- /dev/null
+++ b/src/ResponseCaching/samples/ResponseCachingSample/README.md
@@ -0,0 +1,6 @@
+ASP.NET Core Response Caching Sample
+===================================
+
+This sample illustrates the usage of ASP.NET Core response caching middleware. The application sends a `Hello World!` message and the current time along with a `Cache-Control` header to configure caching behavior. The application also sends a `Vary` header to configure the cache to serve the response only if the `Accept-Encoding` header of subsequent requests matches that from the original request.
+
+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/ResponseCaching/samples/ResponseCachingSample/ResponseCachingSample.csproj b/src/ResponseCaching/samples/ResponseCachingSample/ResponseCachingSample.csproj
new file mode 100644
index 0000000000..3739141e06
--- /dev/null
+++ b/src/ResponseCaching/samples/ResponseCachingSample/ResponseCachingSample.csproj
@@ -0,0 +1,16 @@
+
+
+
+ netcoreapp2.1
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ResponseCaching/samples/ResponseCachingSample/Startup.cs b/src/ResponseCaching/samples/ResponseCachingSample/Startup.cs
new file mode 100644
index 0000000000..ca2e7fbcf3
--- /dev/null
+++ b/src/ResponseCaching/samples/ResponseCachingSample/Startup.cs
@@ -0,0 +1,49 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Net.Http.Headers;
+
+namespace ResponseCachingSample
+{
+ public class Startup
+ {
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddResponseCaching();
+ }
+
+ public void Configure(IApplicationBuilder app)
+ {
+ app.UseResponseCaching();
+ app.Run(async (context) =>
+ {
+ context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
+ {
+ Public = true,
+ MaxAge = TimeSpan.FromSeconds(10)
+ };
+ context.Response.Headers[HeaderNames.Vary] = new string[] { "Accept-Encoding" };
+
+ await context.Response.WriteAsync("Hello World! " + DateTime.UtcNow);
+ });
+ }
+
+ public static void Main(string[] args)
+ {
+ var host = new WebHostBuilder()
+ .UseKestrel()
+ .UseContentRoot(Directory.GetCurrentDirectory())
+ .UseIISIntegration()
+ .UseStartup()
+ .Build();
+
+ host.Run();
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Directory.Build.props b/src/ResponseCaching/src/Directory.Build.props
new file mode 100644
index 0000000000..1e0980f663
--- /dev/null
+++ b/src/ResponseCaching/src/Directory.Build.props
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching.Abstractions/IResponseCachingFeature.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching.Abstractions/IResponseCachingFeature.cs
new file mode 100644
index 0000000000..c68c4c8c5c
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching.Abstractions/IResponseCachingFeature.cs
@@ -0,0 +1,16 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.AspNetCore.ResponseCaching
+{
+ ///
+ /// A feature for configuring additional response cache options on the HTTP response.
+ ///
+ public interface IResponseCachingFeature
+ {
+ ///
+ /// Gets or sets the query keys used by the response cache middleware for calculating secondary vary keys.
+ ///
+ string[] VaryByQueryKeys { get; set; }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching.Abstractions/Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching.Abstractions/Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj
new file mode 100644
index 0000000000..28f2df9c86
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching.Abstractions/Microsoft.AspNetCore.ResponseCaching.Abstractions.csproj
@@ -0,0 +1,14 @@
+
+
+
+ ASP.NET Core response caching middleware abstractions and feature interface definitions.
+ netstandard2.0
+ true
+ aspnetcore;cache;caching
+
+
+
+
+
+
+
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching.Abstractions/baseline.netcore.json b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching.Abstractions/baseline.netcore.json
new file mode 100644
index 0000000000..f8993e5232
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching.Abstractions/baseline.netcore.json
@@ -0,0 +1,34 @@
+{
+ "AssemblyIdentity": "Microsoft.AspNetCore.ResponseCaching.Abstractions, Version=2.1.1.0, Culture=neutral, PublicKeyToken=adb9793829ddae60",
+ "Types": [
+ {
+ "Name": "Microsoft.AspNetCore.ResponseCaching.IResponseCachingFeature",
+ "Visibility": "Public",
+ "Kind": "Interface",
+ "Abstract": true,
+ "ImplementedInterfaces": [],
+ "Members": [
+ {
+ "Kind": "Method",
+ "Name": "get_VaryByQueryKeys",
+ "Parameters": [],
+ "ReturnType": "System.String[]",
+ "GenericParameter": []
+ },
+ {
+ "Kind": "Method",
+ "Name": "set_VaryByQueryKeys",
+ "Parameters": [
+ {
+ "Name": "value",
+ "Type": "System.String[]"
+ }
+ ],
+ "ReturnType": "System.Void",
+ "GenericParameter": []
+ }
+ ],
+ "GenericParameters": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntry/CacheEntryHelpers .cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntry/CacheEntryHelpers .cs
new file mode 100644
index 0000000000..f23286a77e
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntry/CacheEntryHelpers .cs
@@ -0,0 +1,88 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.Extensions.Primitives;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ internal static class CacheEntryHelpers
+ {
+
+ internal static long EstimateCachedResponseSize(CachedResponse 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 EstimateCachedVaryByRulesySize(CachedVaryByRules cachedVaryByRules)
+ {
+ if (cachedVaryByRules == null)
+ {
+ return 0L;
+ }
+
+ checked
+ {
+ var size = 0L;
+
+ // VaryByKeyPrefix
+ if (!string.IsNullOrEmpty(cachedVaryByRules.VaryByKeyPrefix))
+ {
+ size = cachedVaryByRules.VaryByKeyPrefix.Length * sizeof(char);
+ }
+
+ // Headers
+ size += EstimateStringValuesSize(cachedVaryByRules.Headers);
+
+ // QueryKeys
+ size += EstimateStringValuesSize(cachedVaryByRules.QueryKeys);
+
+ 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 += stringValues[i].Length * sizeof(char);
+ }
+ }
+
+ return size;
+ }
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntry/CachedResponse.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntry/CachedResponse.cs
new file mode 100644
index 0000000000..62734f8039
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntry/CachedResponse.cs
@@ -0,0 +1,20 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using Microsoft.AspNetCore.Http;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ public class CachedResponse : IResponseCacheEntry
+ {
+ public DateTimeOffset Created { get; set; }
+
+ public int StatusCode { get; set; }
+
+ public IHeaderDictionary Headers { get; set; }
+
+ public Stream Body { get; set; }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntry/CachedVaryByRules.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntry/CachedVaryByRules.cs
new file mode 100644
index 0000000000..d183724628
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntry/CachedVaryByRules.cs
@@ -0,0 +1,16 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.Extensions.Primitives;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ public class CachedVaryByRules : IResponseCacheEntry
+ {
+ public string VaryByKeyPrefix { get; set; }
+
+ public StringValues Headers { get; set; }
+
+ public StringValues QueryKeys { get; set; }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/FastGuid.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/FastGuid.cs
new file mode 100644
index 0000000000..76cac184ae
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/FastGuid.cs
@@ -0,0 +1,79 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Threading;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ internal class FastGuid
+ {
+ // Base32 encoding - in ascii sort order for easy text based sorting
+ private static readonly string _encode32Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUV";
+ // Global ID
+ private static long NextId;
+
+ // Instance components
+ private string _idString;
+ internal long IdValue { get; private set; }
+
+ internal string IdString
+ {
+ get
+ {
+ if (_idString == null)
+ {
+ _idString = GenerateGuidString(this);
+ }
+ return _idString;
+ }
+ }
+
+ // Static constructor to initialize global components
+ static FastGuid()
+ {
+ var guidBytes = Guid.NewGuid().ToByteArray();
+
+ // Use the first 4 bytes from the Guid to initialize global ID
+ NextId =
+ guidBytes[0] << 32 |
+ guidBytes[1] << 40 |
+ guidBytes[2] << 48 |
+ guidBytes[3] << 56;
+ }
+
+ internal FastGuid(long id)
+ {
+ IdValue = id;
+ }
+
+ internal static FastGuid NewGuid()
+ {
+ return new FastGuid(Interlocked.Increment(ref NextId));
+ }
+
+ private static unsafe string GenerateGuidString(FastGuid guid)
+ {
+ // stackalloc to allocate array on stack rather than heap
+ char* charBuffer = stackalloc char[13];
+
+ // ID
+ charBuffer[0] = _encode32Chars[(int)(guid.IdValue >> 60) & 31];
+ charBuffer[1] = _encode32Chars[(int)(guid.IdValue >> 55) & 31];
+ charBuffer[2] = _encode32Chars[(int)(guid.IdValue >> 50) & 31];
+ charBuffer[3] = _encode32Chars[(int)(guid.IdValue >> 45) & 31];
+ charBuffer[4] = _encode32Chars[(int)(guid.IdValue >> 40) & 31];
+ charBuffer[5] = _encode32Chars[(int)(guid.IdValue >> 35) & 31];
+ charBuffer[6] = _encode32Chars[(int)(guid.IdValue >> 30) & 31];
+ charBuffer[7] = _encode32Chars[(int)(guid.IdValue >> 25) & 31];
+ charBuffer[8] = _encode32Chars[(int)(guid.IdValue >> 20) & 31];
+ charBuffer[9] = _encode32Chars[(int)(guid.IdValue >> 15) & 31];
+ charBuffer[10] = _encode32Chars[(int)(guid.IdValue >> 10) & 31];
+ charBuffer[11] = _encode32Chars[(int)(guid.IdValue >> 5) & 31];
+ charBuffer[12] = _encode32Chars[(int)guid.IdValue & 31];
+
+ // string ctor overload that takes char*
+ return new string(charBuffer, 0, 13);
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/ISystemClock.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/ISystemClock.cs
new file mode 100644
index 0000000000..4b560e3dad
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/ISystemClock.cs
@@ -0,0 +1,18 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ ///
+ /// Abstracts the system clock to facilitate testing.
+ ///
+ internal interface ISystemClock
+ {
+ ///
+ /// Retrieves the current system time in UTC.
+ ///
+ DateTimeOffset UtcNow { get; }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCache.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCache.cs
new file mode 100644
index 0000000000..41c85b277a
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCache.cs
@@ -0,0 +1,17 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ public interface IResponseCache
+ {
+ IResponseCacheEntry Get(string key);
+ Task GetAsync(string key);
+
+ void Set(string key, IResponseCacheEntry entry, TimeSpan validFor);
+ Task SetAsync(string key, IResponseCacheEntry entry, TimeSpan validFor);
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCacheEntry.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCacheEntry.cs
new file mode 100644
index 0000000000..a8227fe243
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCacheEntry.cs
@@ -0,0 +1,9 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ public interface IResponseCacheEntry
+ {
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCachingKeyProvider.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCachingKeyProvider.cs
new file mode 100644
index 0000000000..ac6a20f005
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCachingKeyProvider.cs
@@ -0,0 +1,31 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Collections.Generic;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ public interface IResponseCachingKeyProvider
+ {
+ ///
+ /// Create a base key for a response cache entry.
+ ///
+ /// The .
+ /// The created base key.
+ string CreateBaseKey(ResponseCachingContext context);
+
+ ///
+ /// Create a vary key for storing cached responses.
+ ///
+ /// The .
+ /// The created vary key.
+ string CreateStorageVaryByKey(ResponseCachingContext context);
+
+ ///
+ /// Create one or more vary keys for looking up cached responses.
+ ///
+ /// The .
+ /// An ordered containing the vary keys to try when looking up items.
+ IEnumerable CreateLookupVaryByKeys(ResponseCachingContext context);
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCachingPolicyProvider.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCachingPolicyProvider.cs
new file mode 100644
index 0000000000..51a040098b
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/Interfaces/IResponseCachingPolicyProvider.cs
@@ -0,0 +1,43 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ public interface IResponseCachingPolicyProvider
+ {
+ ///
+ /// Determine whether the response caching logic should be attempted for the incoming HTTP request.
+ ///
+ /// The .
+ /// true if response caching logic should be attempted; otherwise false.
+ bool AttemptResponseCaching(ResponseCachingContext context);
+
+ ///
+ /// Determine whether a cache lookup is allowed for the incoming HTTP request.
+ ///
+ /// The .
+ /// true if cache lookup for this request is allowed; otherwise false.
+ bool AllowCacheLookup(ResponseCachingContext context);
+
+ ///
+ /// Determine whether storage of the response is allowed for the incoming HTTP request.
+ ///
+ /// The .
+ /// true if storage of the response for this request is allowed; otherwise false.
+ bool AllowCacheStorage(ResponseCachingContext context);
+
+ ///
+ /// Determine whether the response received by the middleware can be cached for future requests.
+ ///
+ /// The .
+ /// true if the response is cacheable; otherwise false.
+ bool IsResponseCacheable(ResponseCachingContext context);
+
+ ///
+ /// Determine whether the response retrieved from the response cache is fresh and can be served.
+ ///
+ /// The .
+ /// true if the cached entry is fresh; otherwise false.
+ bool IsCachedEntryFresh(ResponseCachingContext context);
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/LoggerExtensions.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/LoggerExtensions.cs
new file mode 100644
index 0000000000..f8a0bf3151
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/LoggerExtensions.cs
@@ -0,0 +1,310 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using Microsoft.Extensions.Logging;
+using Microsoft.Net.Http.Headers;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ ///
+ /// Defines *all* the logger messages produced by response caching
+ ///
+ internal static class LoggerExtensions
+ {
+ private static Action _logRequestMethodNotCacheable;
+ private static Action _logRequestWithAuthorizationNotCacheable;
+ private static Action _logRequestWithNoCacheNotCacheable;
+ private static Action _logRequestWithPragmaNoCacheNotCacheable;
+ private static Action _logExpirationMinFreshAdded;
+ private static Action _logExpirationSharedMaxAgeExceeded;
+ private static Action _logExpirationMustRevalidate;
+ private static Action _logExpirationMaxStaleSatisfied;
+ private static Action _logExpirationMaxAgeExceeded;
+ private static Action _logExpirationExpiresExceeded;
+ private static Action _logResponseWithoutPublicNotCacheable;
+ private static Action _logResponseWithNoStoreNotCacheable;
+ private static Action _logResponseWithNoCacheNotCacheable;
+ private static Action _logResponseWithSetCookieNotCacheable;
+ private static Action _logResponseWithVaryStarNotCacheable;
+ private static Action _logResponseWithPrivateNotCacheable;
+ private static Action _logResponseWithUnsuccessfulStatusCodeNotCacheable;
+ private static Action _logNotModifiedIfNoneMatchStar;
+ private static Action _logNotModifiedIfNoneMatchMatched;
+ private static Action _logNotModifiedIfModifiedSinceSatisfied;
+ private static Action _logNotModifiedServed;
+ private static Action _logCachedResponseServed;
+ private static Action _logGatewayTimeoutServed;
+ private static Action _logNoResponseServed;
+ private static Action _logVaryByRulesUpdated;
+ private static Action _logResponseCached;
+ private static Action _logResponseNotCached;
+ private static Action _logResponseContentLengthMismatchNotCached;
+ private static Action _logExpirationInfiniteMaxStaleSatisfied;
+
+ static LoggerExtensions()
+ {
+ _logRequestMethodNotCacheable = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 1,
+ formatString: "The request cannot be served from cache because it uses the HTTP method: {Method}.");
+ _logRequestWithAuthorizationNotCacheable = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 2,
+ formatString: $"The request cannot be served from cache because it contains an '{HeaderNames.Authorization}' header.");
+ _logRequestWithNoCacheNotCacheable = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 3,
+ formatString: "The request cannot be served from cache because it contains a 'no-cache' cache directive.");
+ _logRequestWithPragmaNoCacheNotCacheable = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 4,
+ formatString: "The request cannot be served from cache because it contains a 'no-cache' pragma directive.");
+ _logExpirationMinFreshAdded = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 5,
+ formatString: "Adding a minimum freshness requirement of {Duration} specified by the 'min-fresh' cache directive.");
+ _logExpirationSharedMaxAgeExceeded = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 6,
+ formatString: "The age of the entry is {Age} and has exceeded the maximum age for shared caches of {SharedMaxAge} specified by the 's-maxage' cache directive.");
+ _logExpirationMustRevalidate = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 7,
+ formatString: "The age of the entry is {Age} and has exceeded the maximum age of {MaxAge} specified by the 'max-age' cache directive. It must be revalidated because the 'must-revalidate' or 'proxy-revalidate' cache directive is specified.");
+ _logExpirationMaxStaleSatisfied = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 8,
+ formatString: "The age of the entry is {Age} and has exceeded the maximum age of {MaxAge} specified by the 'max-age' cache directive. However, it satisfied the maximum stale allowance of {MaxStale} specified by the 'max-stale' cache directive.");
+ _logExpirationMaxAgeExceeded = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 9,
+ formatString: "The age of the entry is {Age} and has exceeded the maximum age of {MaxAge} specified by the 'max-age' cache directive.");
+ _logExpirationExpiresExceeded = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 10,
+ formatString: $"The response time of the entry is {{ResponseTime}} and has exceeded the expiry date of {{Expired}} specified by the '{HeaderNames.Expires}' header.");
+ _logResponseWithoutPublicNotCacheable = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 11,
+ formatString: "Response is not cacheable because it does not contain the 'public' cache directive.");
+ _logResponseWithNoStoreNotCacheable = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 12,
+ formatString: "Response is not cacheable because it or its corresponding request contains a 'no-store' cache directive.");
+ _logResponseWithNoCacheNotCacheable = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 13,
+ formatString: "Response is not cacheable because it contains a 'no-cache' cache directive.");
+ _logResponseWithSetCookieNotCacheable = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 14,
+ formatString: $"Response is not cacheable because it contains a '{HeaderNames.SetCookie}' header.");
+ _logResponseWithVaryStarNotCacheable = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 15,
+ formatString: $"Response is not cacheable because it contains a '{HeaderNames.Vary}' header with a value of *.");
+ _logResponseWithPrivateNotCacheable = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 16,
+ formatString: "Response is not cacheable because it contains the 'private' cache directive.");
+ _logResponseWithUnsuccessfulStatusCodeNotCacheable = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 17,
+ formatString: "Response is not cacheable because its status code {StatusCode} does not indicate success.");
+ _logNotModifiedIfNoneMatchStar = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 18,
+ formatString: $"The '{HeaderNames.IfNoneMatch}' header of the request contains a value of *.");
+ _logNotModifiedIfNoneMatchMatched = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 19,
+ formatString: $"The ETag {{ETag}} in the '{HeaderNames.IfNoneMatch}' header matched the ETag of a cached entry.");
+ _logNotModifiedIfModifiedSinceSatisfied = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 20,
+ formatString: $"The last modified date of {{LastModified}} is before the date {{IfModifiedSince}} specified in the '{HeaderNames.IfModifiedSince}' header.");
+ _logNotModifiedServed = LoggerMessage.Define(
+ logLevel: LogLevel.Information,
+ eventId: 21,
+ formatString: "The content requested has not been modified.");
+ _logCachedResponseServed = LoggerMessage.Define(
+ logLevel: LogLevel.Information,
+ eventId: 22,
+ formatString: "Serving response from cache.");
+ _logGatewayTimeoutServed = LoggerMessage.Define(
+ logLevel: LogLevel.Information,
+ eventId: 23,
+ formatString: "No cached response available for this request and the 'only-if-cached' cache directive was specified.");
+ _logNoResponseServed = LoggerMessage.Define(
+ logLevel: LogLevel.Information,
+ eventId: 24,
+ formatString: "No cached response available for this request.");
+ _logVaryByRulesUpdated = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 25,
+ formatString: "Vary by rules were updated. Headers: {Headers}, Query keys: {QueryKeys}");
+ _logResponseCached = LoggerMessage.Define(
+ logLevel: LogLevel.Information,
+ eventId: 26,
+ formatString: "The response has been cached.");
+ _logResponseNotCached = LoggerMessage.Define(
+ logLevel: LogLevel.Information,
+ eventId: 27,
+ formatString: "The response could not be cached for this request.");
+ _logResponseContentLengthMismatchNotCached = LoggerMessage.Define(
+ logLevel: LogLevel.Warning,
+ eventId: 28,
+ formatString: $"The response could not be cached for this request because the '{HeaderNames.ContentLength}' did not match the body length.");
+ _logExpirationInfiniteMaxStaleSatisfied = LoggerMessage.Define(
+ logLevel: LogLevel.Debug,
+ eventId: 29,
+ formatString: "The age of the entry is {Age} and has exceeded the maximum age of {MaxAge} specified by the 'max-age' cache directive. However, the 'max-stale' cache directive was specified without an assigned value and a stale response of any age is accepted.");
+ }
+
+ internal static void LogRequestMethodNotCacheable(this ILogger logger, string method)
+ {
+ _logRequestMethodNotCacheable(logger, method, null);
+ }
+
+ internal static void LogRequestWithAuthorizationNotCacheable(this ILogger logger)
+ {
+ _logRequestWithAuthorizationNotCacheable(logger, null);
+ }
+
+ internal static void LogRequestWithNoCacheNotCacheable(this ILogger logger)
+ {
+ _logRequestWithNoCacheNotCacheable(logger, null);
+ }
+
+ internal static void LogRequestWithPragmaNoCacheNotCacheable(this ILogger logger)
+ {
+ _logRequestWithPragmaNoCacheNotCacheable(logger, null);
+ }
+
+ internal static void LogExpirationMinFreshAdded(this ILogger logger, TimeSpan duration)
+ {
+ _logExpirationMinFreshAdded(logger, duration, null);
+ }
+
+ internal static void LogExpirationSharedMaxAgeExceeded(this ILogger logger, TimeSpan age, TimeSpan sharedMaxAge)
+ {
+ _logExpirationSharedMaxAgeExceeded(logger, age, sharedMaxAge, null);
+ }
+
+ internal static void LogExpirationMustRevalidate(this ILogger logger, TimeSpan age, TimeSpan maxAge)
+ {
+ _logExpirationMustRevalidate(logger, age, maxAge, null);
+ }
+
+ internal static void LogExpirationMaxStaleSatisfied(this ILogger logger, TimeSpan age, TimeSpan maxAge, TimeSpan maxStale)
+ {
+ _logExpirationMaxStaleSatisfied(logger, age, maxAge, maxStale, null);
+ }
+
+ internal static void LogExpirationMaxAgeExceeded(this ILogger logger, TimeSpan age, TimeSpan sharedMaxAge)
+ {
+ _logExpirationMaxAgeExceeded(logger, age, sharedMaxAge, null);
+ }
+
+ internal static void LogExpirationExpiresExceeded(this ILogger logger, DateTimeOffset responseTime, DateTimeOffset expires)
+ {
+ _logExpirationExpiresExceeded(logger, responseTime, expires, null);
+ }
+
+ internal static void LogResponseWithoutPublicNotCacheable(this ILogger logger)
+ {
+ _logResponseWithoutPublicNotCacheable(logger, null);
+ }
+
+ internal static void LogResponseWithNoStoreNotCacheable(this ILogger logger)
+ {
+ _logResponseWithNoStoreNotCacheable(logger, null);
+ }
+
+ internal static void LogResponseWithNoCacheNotCacheable(this ILogger logger)
+ {
+ _logResponseWithNoCacheNotCacheable(logger, null);
+ }
+
+ internal static void LogResponseWithSetCookieNotCacheable(this ILogger logger)
+ {
+ _logResponseWithSetCookieNotCacheable(logger, null);
+ }
+
+ internal static void LogResponseWithVaryStarNotCacheable(this ILogger logger)
+ {
+ _logResponseWithVaryStarNotCacheable(logger, null);
+ }
+
+ internal static void LogResponseWithPrivateNotCacheable(this ILogger logger)
+ {
+ _logResponseWithPrivateNotCacheable(logger, null);
+ }
+
+ internal static void LogResponseWithUnsuccessfulStatusCodeNotCacheable(this ILogger logger, int statusCode)
+ {
+ _logResponseWithUnsuccessfulStatusCodeNotCacheable(logger, statusCode, null);
+ }
+
+ internal static void LogNotModifiedIfNoneMatchStar(this ILogger logger)
+ {
+ _logNotModifiedIfNoneMatchStar(logger, null);
+ }
+
+ internal static void LogNotModifiedIfNoneMatchMatched(this ILogger logger, EntityTagHeaderValue etag)
+ {
+ _logNotModifiedIfNoneMatchMatched(logger, etag, null);
+ }
+
+ internal static void LogNotModifiedIfModifiedSinceSatisfied(this ILogger logger, DateTimeOffset lastModified, DateTimeOffset ifModifiedSince)
+ {
+ _logNotModifiedIfModifiedSinceSatisfied(logger, lastModified, ifModifiedSince, null);
+ }
+
+ internal static void LogNotModifiedServed(this ILogger logger)
+ {
+ _logNotModifiedServed(logger, null);
+ }
+
+ internal static void LogCachedResponseServed(this ILogger logger)
+ {
+ _logCachedResponseServed(logger, null);
+ }
+
+ internal static void LogGatewayTimeoutServed(this ILogger logger)
+ {
+ _logGatewayTimeoutServed(logger, null);
+ }
+
+ internal static void LogNoResponseServed(this ILogger logger)
+ {
+ _logNoResponseServed(logger, null);
+ }
+
+ internal static void LogVaryByRulesUpdated(this ILogger logger, string headers, string queryKeys)
+ {
+ _logVaryByRulesUpdated(logger, headers, queryKeys, null);
+ }
+
+ internal static void LogResponseCached(this ILogger logger)
+ {
+ _logResponseCached(logger, null);
+ }
+
+ internal static void LogResponseNotCached(this ILogger logger)
+ {
+ _logResponseNotCached(logger, null);
+ }
+
+ internal static void LogResponseContentLengthMismatchNotCached(this ILogger logger)
+ {
+ _logResponseContentLengthMismatchNotCached(logger, null);
+ }
+
+ internal static void LogExpirationInfiniteMaxStaleSatisfied(this ILogger logger, TimeSpan age, TimeSpan maxAge)
+ {
+ _logExpirationInfiniteMaxStaleSatisfied(logger, age, maxAge, null);
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryCachedResponse.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryCachedResponse.cs
new file mode 100644
index 0000000000..d24e63a9ff
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryCachedResponse.cs
@@ -0,0 +1,22 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Http;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ internal class MemoryCachedResponse
+ {
+ public DateTimeOffset Created { get; set; }
+
+ public int StatusCode { get; set; }
+
+ public IHeaderDictionary Headers { get; set; } = new HeaderDictionary();
+
+ public List BodySegments { get; set; }
+
+ public long BodyLength { get; set; }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryResponseCache.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryResponseCache.cs
new file mode 100644
index 0000000000..d69d48ebbd
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryResponseCache.cs
@@ -0,0 +1,93 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ public class MemoryResponseCache : IResponseCache
+ {
+ private readonly IMemoryCache _cache;
+
+ public MemoryResponseCache(IMemoryCache cache)
+ {
+ if (cache == null)
+ {
+ throw new ArgumentNullException(nameof(cache));
+ }
+
+ _cache = cache;
+ }
+
+ public IResponseCacheEntry Get(string key)
+ {
+ var entry = _cache.Get(key);
+
+ var memoryCachedResponse = entry as MemoryCachedResponse;
+ if (memoryCachedResponse != null)
+ {
+ return new CachedResponse
+ {
+ Created = memoryCachedResponse.Created,
+ StatusCode = memoryCachedResponse.StatusCode,
+ Headers = memoryCachedResponse.Headers,
+ Body = new SegmentReadStream(memoryCachedResponse.BodySegments, memoryCachedResponse.BodyLength)
+ };
+ }
+ else
+ {
+ return entry as IResponseCacheEntry;
+ }
+ }
+
+ public Task GetAsync(string key)
+ {
+ return Task.FromResult(Get(key));
+ }
+
+ public void Set(string key, IResponseCacheEntry entry, TimeSpan validFor)
+ {
+ var cachedResponse = entry as CachedResponse;
+ if (cachedResponse != null)
+ {
+ var segmentStream = new SegmentWriteStream(StreamUtilities.BodySegmentSize);
+ cachedResponse.Body.CopyTo(segmentStream);
+
+ _cache.Set(
+ key,
+ new MemoryCachedResponse
+ {
+ Created = cachedResponse.Created,
+ StatusCode = cachedResponse.StatusCode,
+ Headers = cachedResponse.Headers,
+ BodySegments = segmentStream.GetSegments(),
+ BodyLength = segmentStream.Length
+ },
+ new MemoryCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = validFor,
+ Size = CacheEntryHelpers.EstimateCachedResponseSize(cachedResponse)
+ });
+ }
+ else
+ {
+ _cache.Set(
+ key,
+ entry,
+ new MemoryCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = validFor,
+ Size = CacheEntryHelpers.EstimateCachedVaryByRulesySize(entry as CachedVaryByRules)
+ });
+ }
+ }
+
+ public Task SetAsync(string key, IResponseCacheEntry entry, TimeSpan validFor)
+ {
+ Set(key, entry, validFor);
+ return Task.CompletedTask;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingContext.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingContext.cs
new file mode 100644
index 0000000000..2d8c79b11b
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingContext.cs
@@ -0,0 +1,134 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.Logging;
+using Microsoft.Net.Http.Headers;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ public class ResponseCachingContext
+ {
+ private DateTimeOffset? _responseDate;
+ private bool _parsedResponseDate;
+ private DateTimeOffset? _responseExpires;
+ private bool _parsedResponseExpires;
+ private TimeSpan? _responseSharedMaxAge;
+ private bool _parsedResponseSharedMaxAge;
+ private TimeSpan? _responseMaxAge;
+ private bool _parsedResponseMaxAge;
+
+ internal ResponseCachingContext(HttpContext httpContext, ILogger logger)
+ {
+ HttpContext = httpContext;
+ Logger = logger;
+ }
+
+ public HttpContext HttpContext { get; }
+
+ public DateTimeOffset? ResponseTime { get; internal set; }
+
+ public TimeSpan? CachedEntryAge { get; internal set; }
+
+ public CachedVaryByRules CachedVaryByRules { get; internal set; }
+
+ internal ILogger Logger { get; }
+
+ internal bool ShouldCacheResponse { get; set; }
+
+ internal string BaseKey { get; set; }
+
+ internal string StorageVaryKey { get; set; }
+
+ internal TimeSpan CachedResponseValidFor { get; set; }
+
+ internal CachedResponse CachedResponse { get; set; }
+
+ internal bool ResponseStarted { get; set; }
+
+ internal Stream OriginalResponseStream { get; set; }
+
+ internal ResponseCachingStream ResponseCachingStream { get; set; }
+
+ internal IHttpSendFileFeature OriginalSendFileFeature { get; set; }
+
+ internal IHeaderDictionary CachedResponseHeaders { get; set; }
+
+ internal DateTimeOffset? ResponseDate
+ {
+ get
+ {
+ if (!_parsedResponseDate)
+ {
+ _parsedResponseDate = true;
+ DateTimeOffset date;
+ if (HeaderUtilities.TryParseDate(HttpContext.Response.Headers[HeaderNames.Date].ToString(), out date))
+ {
+ _responseDate = date;
+ }
+ else
+ {
+ _responseDate = null;
+ }
+ }
+ return _responseDate;
+ }
+ set
+ {
+ // Don't reparse the response date again if it's explicitly set
+ _parsedResponseDate = true;
+ _responseDate = value;
+ }
+ }
+
+ internal DateTimeOffset? ResponseExpires
+ {
+ get
+ {
+ if (!_parsedResponseExpires)
+ {
+ _parsedResponseExpires = true;
+ DateTimeOffset expires;
+ if (HeaderUtilities.TryParseDate(HttpContext.Response.Headers[HeaderNames.Expires].ToString(), out expires))
+ {
+ _responseExpires = expires;
+ }
+ else
+ {
+ _responseExpires = null;
+ }
+ }
+ return _responseExpires;
+ }
+ }
+
+ internal TimeSpan? ResponseSharedMaxAge
+ {
+ get
+ {
+ if (!_parsedResponseSharedMaxAge)
+ {
+ _parsedResponseSharedMaxAge = true;
+ HeaderUtilities.TryParseSeconds(HttpContext.Response.Headers[HeaderNames.CacheControl], CacheControlHeaderValue.SharedMaxAgeString, out _responseSharedMaxAge);
+ }
+ return _responseSharedMaxAge;
+ }
+ }
+
+ internal TimeSpan? ResponseMaxAge
+ {
+ get
+ {
+ if (!_parsedResponseMaxAge)
+ {
+ _parsedResponseMaxAge = true;
+ HeaderUtilities.TryParseSeconds(HttpContext.Response.Headers[HeaderNames.CacheControl], CacheControlHeaderValue.MaxAgeString, out _responseMaxAge);
+ }
+ return _responseMaxAge;
+ }
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingKeyProvider.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingKeyProvider.cs
new file mode 100644
index 0000000000..d69d9008eb
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingKeyProvider.cs
@@ -0,0 +1,219 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.Extensions.ObjectPool;
+using Microsoft.Extensions.Options;
+using Microsoft.Extensions.Primitives;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ public class ResponseCachingKeyProvider : IResponseCachingKeyProvider
+ {
+ // Use the record separator for delimiting components of the cache key to avoid possible collisions
+ private static readonly char KeyDelimiter = '\x1e';
+ // Use the unit separator for delimiting subcomponents of the cache key to avoid possible collisions
+ private static readonly char KeySubDelimiter = '\x1f';
+
+ private readonly ObjectPool _builderPool;
+ private readonly ResponseCachingOptions _options;
+
+ public ResponseCachingKeyProvider(ObjectPoolProvider poolProvider, IOptions options)
+ {
+ if (poolProvider == null)
+ {
+ throw new ArgumentNullException(nameof(poolProvider));
+ }
+ if (options == null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+
+ _builderPool = poolProvider.CreateStringBuilderPool();
+ _options = options.Value;
+ }
+
+ public IEnumerable CreateLookupVaryByKeys(ResponseCachingContext context)
+ {
+ return new string[] { CreateStorageVaryByKey(context) };
+ }
+
+ // GETSCHEMEHOST:PORT/PATHBASE/PATH
+ public string CreateBaseKey(ResponseCachingContext context)
+ {
+ if (context == null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+
+ 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);
+ }
+
+ return builder.ToString();
+ }
+ finally
+ {
+ _builderPool.Return(builder);
+ }
+ }
+
+ // BaseKeyHHeaderName=HeaderValueQQueryName=QueryValue1QueryValue2
+ public string CreateStorageVaryByKey(ResponseCachingContext context)
+ {
+ if (context == null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+
+ var varyByRules = context.CachedVaryByRules;
+ if (varyByRules == null)
+ {
+ throw new InvalidOperationException($"{nameof(CachedVaryByRules)} must not be null on the {nameof(ResponseCachingContext)}");
+ }
+
+ if ((StringValues.IsNullOrEmpty(varyByRules.Headers) && StringValues.IsNullOrEmpty(varyByRules.QueryKeys)))
+ {
+ return varyByRules.VaryByKeyPrefix;
+ }
+
+ var request = context.HttpContext.Request;
+ var builder = _builderPool.Get();
+
+ try
+ {
+ // Prepend with the Guid of the CachedVaryByRules
+ builder.Append(varyByRules.VaryByKeyPrefix);
+
+ // Vary by headers
+ if (varyByRules?.Headers.Count > 0)
+ {
+ // Append a group separator for the header segment of the cache key
+ builder.Append(KeyDelimiter)
+ .Append('H');
+
+ for (var i = 0; i < varyByRules.Headers.Count; i++)
+ {
+ var header = varyByRules.Headers[i];
+ var headerValues = context.HttpContext.Request.Headers[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))
+ {
+ // 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];
+ 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 class QueryKeyComparer : IComparer>
+ {
+ private 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/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingPolicyProvider.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingPolicyProvider.cs
new file mode 100644
index 0000000000..8ffc59612e
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingPolicyProvider.cs
@@ -0,0 +1,248 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Primitives;
+using Microsoft.Net.Http.Headers;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ public class ResponseCachingPolicyProvider : IResponseCachingPolicyProvider
+ {
+ public virtual bool AttemptResponseCaching(ResponseCachingContext context)
+ {
+ var request = context.HttpContext.Request;
+
+ // Verify the method
+ if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method))
+ {
+ context.Logger.LogRequestMethodNotCacheable(request.Method);
+ return false;
+ }
+
+ // Verify existence of authorization headers
+ if (!StringValues.IsNullOrEmpty(request.Headers[HeaderNames.Authorization]))
+ {
+ context.Logger.LogRequestWithAuthorizationNotCacheable();
+ return false;
+ }
+
+ return true;
+ }
+
+ public virtual bool AllowCacheLookup(ResponseCachingContext context)
+ {
+ var request = context.HttpContext.Request;
+
+ // Verify request cache-control parameters
+ if (!StringValues.IsNullOrEmpty(request.Headers[HeaderNames.CacheControl]))
+ {
+ if (HeaderUtilities.ContainsCacheDirective(request.Headers[HeaderNames.CacheControl], CacheControlHeaderValue.NoCacheString))
+ {
+ context.Logger.LogRequestWithNoCacheNotCacheable();
+ return false;
+ }
+ }
+ else
+ {
+ // Support for legacy HTTP 1.0 cache directive
+ var pragmaHeaderValues = request.Headers[HeaderNames.Pragma];
+ if (HeaderUtilities.ContainsCacheDirective(request.Headers[HeaderNames.Pragma], CacheControlHeaderValue.NoCacheString))
+ {
+ context.Logger.LogRequestWithPragmaNoCacheNotCacheable();
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public virtual bool AllowCacheStorage(ResponseCachingContext context)
+ {
+ // Check request no-store
+ return !HeaderUtilities.ContainsCacheDirective(context.HttpContext.Request.Headers[HeaderNames.CacheControl], CacheControlHeaderValue.NoStoreString);
+ }
+
+ public virtual bool IsResponseCacheable(ResponseCachingContext context)
+ {
+ var responseCacheControlHeader = context.HttpContext.Response.Headers[HeaderNames.CacheControl];
+
+ // Only cache pages explicitly marked with public
+ if (!HeaderUtilities.ContainsCacheDirective(responseCacheControlHeader, CacheControlHeaderValue.PublicString))
+ {
+ context.Logger.LogResponseWithoutPublicNotCacheable();
+ return false;
+ }
+
+ // Check response no-store
+ if (HeaderUtilities.ContainsCacheDirective(responseCacheControlHeader, CacheControlHeaderValue.NoStoreString))
+ {
+ context.Logger.LogResponseWithNoStoreNotCacheable();
+ return false;
+ }
+
+ // Check no-cache
+ if (HeaderUtilities.ContainsCacheDirective(responseCacheControlHeader, CacheControlHeaderValue.NoCacheString))
+ {
+ context.Logger.LogResponseWithNoCacheNotCacheable();
+ return false;
+ }
+
+ var response = context.HttpContext.Response;
+
+ // Do not cache responses with Set-Cookie headers
+ if (!StringValues.IsNullOrEmpty(response.Headers[HeaderNames.SetCookie]))
+ {
+ context.Logger.LogResponseWithSetCookieNotCacheable();
+ return false;
+ }
+
+ // Do not cache responses varying by *
+ var varyHeader = response.Headers[HeaderNames.Vary];
+ if (varyHeader.Count == 1 && string.Equals(varyHeader, "*", StringComparison.OrdinalIgnoreCase))
+ {
+ context.Logger.LogResponseWithVaryStarNotCacheable();
+ return false;
+ }
+
+ // Check private
+ if (HeaderUtilities.ContainsCacheDirective(responseCacheControlHeader, CacheControlHeaderValue.PrivateString))
+ {
+ context.Logger.LogResponseWithPrivateNotCacheable();
+ return false;
+ }
+
+ // Check response code
+ if (response.StatusCode != StatusCodes.Status200OK)
+ {
+ context.Logger.LogResponseWithUnsuccessfulStatusCodeNotCacheable(response.StatusCode);
+ return false;
+ }
+
+ // Check response freshness
+ if (!context.ResponseDate.HasValue)
+ {
+ if (!context.ResponseSharedMaxAge.HasValue &&
+ !context.ResponseMaxAge.HasValue &&
+ context.ResponseTime.Value >= context.ResponseExpires)
+ {
+ context.Logger.LogExpirationExpiresExceeded(context.ResponseTime.Value, context.ResponseExpires.Value);
+ return false;
+ }
+ }
+ else
+ {
+ var age = context.ResponseTime.Value - context.ResponseDate.Value;
+
+ // Validate shared max age
+ if (age >= context.ResponseSharedMaxAge)
+ {
+ context.Logger.LogExpirationSharedMaxAgeExceeded(age, context.ResponseSharedMaxAge.Value);
+ return false;
+ }
+ else if (!context.ResponseSharedMaxAge.HasValue)
+ {
+ // Validate max age
+ if (age >= context.ResponseMaxAge)
+ {
+ context.Logger.LogExpirationMaxAgeExceeded(age, context.ResponseMaxAge.Value);
+ return false;
+ }
+ else if (!context.ResponseMaxAge.HasValue)
+ {
+ // Validate expiration
+ if (context.ResponseTime.Value >= context.ResponseExpires)
+ {
+ context.Logger.LogExpirationExpiresExceeded(context.ResponseTime.Value, context.ResponseExpires.Value);
+ return false;
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+
+ public virtual bool IsCachedEntryFresh(ResponseCachingContext context)
+ {
+ var age = context.CachedEntryAge.Value;
+ var cachedCacheControlHeaders = context.CachedResponseHeaders[HeaderNames.CacheControl];
+ var requestCacheControlHeaders = context.HttpContext.Request.Headers[HeaderNames.CacheControl];
+
+ // Add min-fresh requirements
+ TimeSpan? minFresh;
+ if (HeaderUtilities.TryParseSeconds(requestCacheControlHeaders, CacheControlHeaderValue.MinFreshString, out minFresh))
+ {
+ age += minFresh.Value;
+ context.Logger.LogExpirationMinFreshAdded(minFresh.Value);
+ }
+
+ // Validate shared max age, this overrides any max age settings for shared caches
+ TimeSpan? cachedSharedMaxAge;
+ HeaderUtilities.TryParseSeconds(cachedCacheControlHeaders, CacheControlHeaderValue.SharedMaxAgeString, out cachedSharedMaxAge);
+
+ if (age >= cachedSharedMaxAge)
+ {
+ // shared max age implies must revalidate
+ context.Logger.LogExpirationSharedMaxAgeExceeded(age, cachedSharedMaxAge.Value);
+ return false;
+ }
+ else if (!cachedSharedMaxAge.HasValue)
+ {
+ TimeSpan? requestMaxAge;
+ HeaderUtilities.TryParseSeconds(requestCacheControlHeaders, CacheControlHeaderValue.MaxAgeString, out requestMaxAge);
+
+ TimeSpan? cachedMaxAge;
+ HeaderUtilities.TryParseSeconds(cachedCacheControlHeaders, CacheControlHeaderValue.MaxAgeString, out cachedMaxAge);
+
+ var lowestMaxAge = cachedMaxAge < requestMaxAge ? cachedMaxAge : requestMaxAge ?? cachedMaxAge;
+ // Validate max age
+ if (age >= lowestMaxAge)
+ {
+ // Must revalidate or proxy revalidate
+ if (HeaderUtilities.ContainsCacheDirective(cachedCacheControlHeaders, CacheControlHeaderValue.MustRevalidateString)
+ || HeaderUtilities.ContainsCacheDirective(cachedCacheControlHeaders, CacheControlHeaderValue.ProxyRevalidateString))
+ {
+ context.Logger.LogExpirationMustRevalidate(age, lowestMaxAge.Value);
+ return false;
+ }
+
+ TimeSpan? requestMaxStale;
+ var maxStaleExist = HeaderUtilities.ContainsCacheDirective(requestCacheControlHeaders, CacheControlHeaderValue.MaxStaleString);
+ HeaderUtilities.TryParseSeconds(requestCacheControlHeaders, CacheControlHeaderValue.MaxStaleString, out requestMaxStale);
+
+ // Request allows stale values with no age limit
+ if (maxStaleExist && !requestMaxStale.HasValue)
+ {
+ context.Logger.LogExpirationInfiniteMaxStaleSatisfied(age, lowestMaxAge.Value);
+ return true;
+ }
+
+ // Request allows stale values with age limit
+ if (requestMaxStale.HasValue && age - lowestMaxAge < requestMaxStale)
+ {
+ context.Logger.LogExpirationMaxStaleSatisfied(age, lowestMaxAge.Value, requestMaxStale.Value);
+ return true;
+ }
+
+ context.Logger.LogExpirationMaxAgeExceeded(age, lowestMaxAge.Value);
+ return false;
+ }
+ else if (!cachedMaxAge.HasValue && !requestMaxAge.HasValue)
+ {
+ // Validate expiration
+ DateTimeOffset expires;
+ if (HeaderUtilities.TryParseDate(context.CachedResponseHeaders[HeaderNames.Expires].ToString(), out expires) &&
+ context.ResponseTime.Value >= expires)
+ {
+ context.Logger.LogExpirationExpiresExceeded(context.ResponseTime.Value, expires);
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/SendFileFeatureWrapper.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/SendFileFeatureWrapper.cs
new file mode 100644
index 0000000000..2716e4cd37
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/SendFileFeatureWrapper.cs
@@ -0,0 +1,28 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http.Features;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ internal class SendFileFeatureWrapper : IHttpSendFileFeature
+ {
+ private readonly IHttpSendFileFeature _originalSendFileFeature;
+ private readonly ResponseCachingStream _responseCachingStream;
+
+ public SendFileFeatureWrapper(IHttpSendFileFeature originalSendFileFeature, ResponseCachingStream responseCachingStream)
+ {
+ _originalSendFileFeature = originalSendFileFeature;
+ _responseCachingStream = responseCachingStream;
+ }
+
+ // Flush and disable the buffer if anyone tries to call the SendFile feature.
+ public Task SendFileAsync(string path, long offset, long? length, CancellationToken cancellation)
+ {
+ _responseCachingStream.DisableBuffering();
+ return _originalSendFileFeature.SendFileAsync(path, offset, length, cancellation);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/StringBuilderExtensions.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/StringBuilderExtensions.cs
new file mode 100644
index 0000000000..98cfa7e172
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/StringBuilderExtensions.cs
@@ -0,0 +1,24 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Text;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ 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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/SystemClock.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/SystemClock.cs
new file mode 100644
index 0000000000..39b6e4735a
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Internal/SystemClock.cs
@@ -0,0 +1,24 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ ///
+ /// Provides access to the normal system clock.
+ ///
+ internal class SystemClock : ISystemClock
+ {
+ ///
+ /// Retrieves the current system time in UTC.
+ ///
+ public DateTimeOffset UtcNow
+ {
+ get
+ {
+ return DateTimeOffset.UtcNow;
+ }
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Microsoft.AspNetCore.ResponseCaching.csproj b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Microsoft.AspNetCore.ResponseCaching.csproj
new file mode 100644
index 0000000000..c2547522ff
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Microsoft.AspNetCore.ResponseCaching.csproj
@@ -0,0 +1,23 @@
+
+
+
+ ASP.NET Core middleware for caching HTTP responses on the server.
+ netstandard2.0
+ $(NoWarn);CS1591
+ true
+ true
+ aspnetcore;cache;caching
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Properties/AssemblyInfo.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..7a0fd0e4de
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Properties/AssemblyInfo.cs
@@ -0,0 +1,6 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Microsoft.AspNetCore.ResponseCaching.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
\ No newline at end of file
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs
new file mode 100644
index 0000000000..76b81dbccb
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs
@@ -0,0 +1,22 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using Microsoft.AspNetCore.ResponseCaching;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.Builder
+{
+ public static class ResponseCachingExtensions
+ {
+ public static IApplicationBuilder UseResponseCaching(this IApplicationBuilder app)
+ {
+ if (app == null)
+ {
+ throw new ArgumentNullException(nameof(app));
+ }
+
+ return app.UseMiddleware();
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingFeature.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingFeature.cs
new file mode 100644
index 0000000000..14232b97de
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingFeature.cs
@@ -0,0 +1,34 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+
+namespace Microsoft.AspNetCore.ResponseCaching
+{
+ public class ResponseCachingFeature : IResponseCachingFeature
+ {
+ private string[] _varyByQueryKeys;
+
+ public string[] VaryByQueryKeys
+ {
+ get
+ {
+ return _varyByQueryKeys;
+ }
+ set
+ {
+ if (value?.Length > 1)
+ {
+ for (var i = 0; i < value.Length; i++)
+ {
+ if (string.IsNullOrEmpty(value[i]))
+ {
+ throw new ArgumentException($"When {nameof(value)} contains more than one value, it cannot contain a null or empty value.", nameof(value));
+ }
+ }
+ }
+ _varyByQueryKeys = value;
+ }
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs
new file mode 100644
index 0000000000..d2eee86ad7
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs
@@ -0,0 +1,528 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.AspNetCore.ResponseCaching.Internal;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Microsoft.Extensions.Primitives;
+using Microsoft.Net.Http.Headers;
+
+namespace Microsoft.AspNetCore.ResponseCaching
+{
+ public class ResponseCachingMiddleware
+ {
+ private static readonly TimeSpan DefaultExpirationTimeSpan = TimeSpan.FromSeconds(10);
+
+ private readonly RequestDelegate _next;
+ private readonly ResponseCachingOptions _options;
+ private readonly ILogger _logger;
+ private readonly IResponseCachingPolicyProvider _policyProvider;
+ private readonly IResponseCache _cache;
+ private readonly IResponseCachingKeyProvider _keyProvider;
+
+ public ResponseCachingMiddleware(
+ RequestDelegate next,
+ IOptions options,
+ ILoggerFactory loggerFactory,
+ IResponseCachingPolicyProvider policyProvider,
+ IResponseCachingKeyProvider keyProvider)
+ : this(
+ next,
+ options,
+ loggerFactory,
+ policyProvider,
+ new MemoryResponseCache(new MemoryCache(new MemoryCacheOptions
+ {
+ SizeLimit = options.Value.SizeLimit
+ })), keyProvider)
+ { }
+
+ // for testing
+ internal ResponseCachingMiddleware(
+ RequestDelegate next,
+ IOptions options,
+ ILoggerFactory loggerFactory,
+ IResponseCachingPolicyProvider policyProvider,
+ IResponseCache cache,
+ IResponseCachingKeyProvider keyProvider)
+ {
+ if (next == null)
+ {
+ throw new ArgumentNullException(nameof(next));
+ }
+ if (options == null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+ if (loggerFactory == null)
+ {
+ throw new ArgumentNullException(nameof(loggerFactory));
+ }
+ if (policyProvider == null)
+ {
+ throw new ArgumentNullException(nameof(policyProvider));
+ }
+ if (cache == null)
+ {
+ throw new ArgumentNullException(nameof(cache));
+ }
+ if (keyProvider == null)
+ {
+ throw new ArgumentNullException(nameof(keyProvider));
+ }
+
+ _next = next;
+ _options = options.Value;
+ _logger = loggerFactory.CreateLogger();
+ _policyProvider = policyProvider;
+ _cache = cache;
+ _keyProvider = keyProvider;
+ }
+
+ public async Task Invoke(HttpContext httpContext)
+ {
+ var context = new ResponseCachingContext(httpContext, _logger);
+
+ // Should we attempt any caching logic?
+ if (_policyProvider.AttemptResponseCaching(context))
+ {
+ // Can this request be served from cache?
+ if (_policyProvider.AllowCacheLookup(context) && await TryServeFromCacheAsync(context))
+ {
+ return;
+ }
+
+ // Should we store the response to this request?
+ if (_policyProvider.AllowCacheStorage(context))
+ {
+ // Hook up to listen to the response stream
+ ShimResponseStream(context);
+
+ try
+ {
+ await _next(httpContext);
+
+ // If there was no response body, check the response headers now. We can cache things like redirects.
+ await StartResponseAsync(context);
+
+ // Finalize the cache entry
+ await FinalizeCacheBodyAsync(context);
+ }
+ finally
+ {
+ UnshimResponseStream(context);
+ }
+
+ return;
+ }
+ }
+
+ // Response should not be captured but add IResponseCachingFeature which may be required when the response is generated
+ AddResponseCachingFeature(httpContext);
+
+ try
+ {
+ await _next(httpContext);
+ }
+ finally
+ {
+ RemoveResponseCachingFeature(httpContext);
+ }
+ }
+
+ internal async Task TryServeCachedResponseAsync(ResponseCachingContext context, IResponseCacheEntry cacheEntry)
+ {
+ var cachedResponse = cacheEntry as CachedResponse;
+ if (cachedResponse == null)
+ {
+ return false;
+ }
+
+ context.CachedResponse = cachedResponse;
+ context.CachedResponseHeaders = cachedResponse.Headers;
+ context.ResponseTime = _options.SystemClock.UtcNow;
+ var cachedEntryAge = context.ResponseTime.Value - context.CachedResponse.Created;
+ context.CachedEntryAge = cachedEntryAge > TimeSpan.Zero ? cachedEntryAge : TimeSpan.Zero;
+
+ if (_policyProvider.IsCachedEntryFresh(context))
+ {
+ // Check conditional request rules
+ if (ContentIsNotModified(context))
+ {
+ _logger.LogNotModifiedServed();
+ context.HttpContext.Response.StatusCode = StatusCodes.Status304NotModified;
+ }
+ 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[HeaderNames.Age] = HeaderUtilities.FormatNonNegativeInt64(context.CachedEntryAge.Value.Ticks / TimeSpan.TicksPerSecond);
+
+ // Copy the cached response body
+ var body = context.CachedResponse.Body;
+ if (body.Length > 0)
+ {
+ try
+ {
+ await body.CopyToAsync(response.Body, StreamUtilities.BodySegmentSize, context.HttpContext.RequestAborted);
+ }
+ catch (OperationCanceledException)
+ {
+ context.HttpContext.Abort();
+ }
+ }
+ _logger.LogCachedResponseServed();
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ internal async Task TryServeFromCacheAsync(ResponseCachingContext context)
+ {
+ context.BaseKey = _keyProvider.CreateBaseKey(context);
+ var cacheEntry = await _cache.GetAsync(context.BaseKey);
+
+ var cachedVaryByRules = cacheEntry as CachedVaryByRules;
+ if (cachedVaryByRules != null)
+ {
+ // Request contains vary rules, recompute key(s) and try again
+ context.CachedVaryByRules = cachedVaryByRules;
+
+ foreach (var varyKey in _keyProvider.CreateLookupVaryByKeys(context))
+ {
+ if (await TryServeCachedResponseAsync(context, await _cache.GetAsync(varyKey)))
+ {
+ return true;
+ }
+ }
+ }
+ else
+ {
+ if (await TryServeCachedResponseAsync(context, cacheEntry))
+ {
+ return true;
+ }
+ }
+
+ if (HeaderUtilities.ContainsCacheDirective(context.HttpContext.Request.Headers[HeaderNames.CacheControl], CacheControlHeaderValue.OnlyIfCachedString))
+ {
+ _logger.LogGatewayTimeoutServed();
+ context.HttpContext.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
+ return true;
+ }
+
+ _logger.LogNoResponseServed();
+ return false;
+ }
+
+
+ ///
+ /// Finalize cache headers.
+ ///
+ ///
+ /// true if a vary by entry needs to be stored in the cache; otherwise false.
+ private bool OnFinalizeCacheHeaders(ResponseCachingContext context)
+ {
+ if (_policyProvider.IsResponseCacheable(context))
+ {
+ var storeVaryByEntry = false;
+ context.ShouldCacheResponse = true;
+
+ // Create the cache entry now
+ var response = context.HttpContext.Response;
+ var varyHeaders = new StringValues(response.Headers.GetCommaSeparatedValues(HeaderNames.Vary));
+ var varyQueryKeys = new StringValues(context.HttpContext.Features.Get()?.VaryByQueryKeys);
+ context.CachedResponseValidFor = context.ResponseSharedMaxAge ??
+ context.ResponseMaxAge ??
+ (context.ResponseExpires - context.ResponseTime.Value) ??
+ DefaultExpirationTimeSpan;
+
+ // Generate a base key if none exist
+ if (string.IsNullOrEmpty(context.BaseKey))
+ {
+ context.BaseKey = _keyProvider.CreateBaseKey(context);
+ }
+
+ // Check if any vary rules exist
+ if (!StringValues.IsNullOrEmpty(varyHeaders) || !StringValues.IsNullOrEmpty(varyQueryKeys))
+ {
+ // Normalize order and casing of vary by rules
+ var normalizedVaryHeaders = GetOrderCasingNormalizedStringValues(varyHeaders);
+ var normalizedVaryQueryKeys = GetOrderCasingNormalizedStringValues(varyQueryKeys);
+
+ // Update vary rules if they are different
+ if (context.CachedVaryByRules == null ||
+ !StringValues.Equals(context.CachedVaryByRules.QueryKeys, normalizedVaryQueryKeys) ||
+ !StringValues.Equals(context.CachedVaryByRules.Headers, normalizedVaryHeaders))
+ {
+ context.CachedVaryByRules = new CachedVaryByRules
+ {
+ VaryByKeyPrefix = FastGuid.NewGuid().IdString,
+ Headers = normalizedVaryHeaders,
+ QueryKeys = normalizedVaryQueryKeys
+ };
+ }
+
+ // Always overwrite the CachedVaryByRules to update the expiry information
+ _logger.LogVaryByRulesUpdated(normalizedVaryHeaders, normalizedVaryQueryKeys);
+ storeVaryByEntry = true;
+
+ context.StorageVaryKey = _keyProvider.CreateStorageVaryByKey(context);
+ }
+
+ // Ensure date header is set
+ if (!context.ResponseDate.HasValue)
+ {
+ context.ResponseDate = context.ResponseTime.Value;
+ // Setting the date on the raw response headers.
+ context.HttpContext.Response.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(context.ResponseDate.Value);
+ }
+
+ // Store the response on the state
+ context.CachedResponse = new CachedResponse
+ {
+ Created = context.ResponseDate.Value,
+ StatusCode = context.HttpContext.Response.StatusCode,
+ Headers = new HeaderDictionary()
+ };
+
+ foreach (var header in context.HttpContext.Response.Headers)
+ {
+ if (!string.Equals(header.Key, HeaderNames.Age, StringComparison.OrdinalIgnoreCase))
+ {
+ context.CachedResponse.Headers[header.Key] = header.Value;
+ }
+ }
+
+ return storeVaryByEntry;
+ }
+
+ context.ResponseCachingStream.DisableBuffering();
+ return false;
+ }
+
+ internal void FinalizeCacheHeaders(ResponseCachingContext context)
+ {
+ if (OnFinalizeCacheHeaders(context))
+ {
+ _cache.Set(context.BaseKey, context.CachedVaryByRules, context.CachedResponseValidFor);
+ }
+ }
+
+ internal Task FinalizeCacheHeadersAsync(ResponseCachingContext context)
+ {
+ if (OnFinalizeCacheHeaders(context))
+ {
+ return _cache.SetAsync(context.BaseKey, context.CachedVaryByRules, context.CachedResponseValidFor);
+ }
+ return Task.CompletedTask;
+ }
+
+ internal async Task FinalizeCacheBodyAsync(ResponseCachingContext context)
+ {
+ if (context.ShouldCacheResponse && context.ResponseCachingStream.BufferingEnabled)
+ {
+ var contentLength = context.HttpContext.Response.ContentLength;
+ var bufferStream = context.ResponseCachingStream.GetBufferStream();
+ if (!contentLength.HasValue || contentLength == bufferStream.Length)
+ {
+ var response = context.HttpContext.Response;
+ // Add a content-length if required
+ if (!response.ContentLength.HasValue && StringValues.IsNullOrEmpty(response.Headers[HeaderNames.TransferEncoding]))
+ {
+ context.CachedResponse.Headers[HeaderNames.ContentLength] = HeaderUtilities.FormatNonNegativeInt64(bufferStream.Length);
+ }
+
+ context.CachedResponse.Body = bufferStream;
+ _logger.LogResponseCached();
+ await _cache.SetAsync(context.StorageVaryKey ?? context.BaseKey, context.CachedResponse, context.CachedResponseValidFor);
+ }
+ else
+ {
+ _logger.LogResponseContentLengthMismatchNotCached();
+ }
+ }
+ else
+ {
+ _logger.LogResponseNotCached();
+ }
+ }
+
+ ///
+ /// Mark the response as started and set the response time if no reponse was started yet.
+ ///
+ ///
+ /// true if the response was not started before this call; otherwise false.
+ private bool OnStartResponse(ResponseCachingContext context)
+ {
+ if (!context.ResponseStarted)
+ {
+ context.ResponseStarted = true;
+ context.ResponseTime = _options.SystemClock.UtcNow;
+
+ return true;
+ }
+ return false;
+ }
+
+ internal void StartResponse(ResponseCachingContext context)
+ {
+ if (OnStartResponse(context))
+ {
+ FinalizeCacheHeaders(context);
+ }
+ }
+
+ internal Task StartResponseAsync(ResponseCachingContext context)
+ {
+ if (OnStartResponse(context))
+ {
+ return FinalizeCacheHeadersAsync(context);
+ }
+ return Task.CompletedTask;
+ }
+
+ internal static void AddResponseCachingFeature(HttpContext context)
+ {
+ if (context.Features.Get() != null)
+ {
+ throw new InvalidOperationException($"Another instance of {nameof(ResponseCachingFeature)} already exists. Only one instance of {nameof(ResponseCachingMiddleware)} can be configured for an application.");
+ }
+ context.Features.Set(new ResponseCachingFeature());
+ }
+
+ internal void ShimResponseStream(ResponseCachingContext context)
+ {
+ // Shim response stream
+ context.OriginalResponseStream = context.HttpContext.Response.Body;
+ context.ResponseCachingStream = new ResponseCachingStream(
+ context.OriginalResponseStream,
+ _options.MaximumBodySize,
+ StreamUtilities.BodySegmentSize,
+ () => StartResponse(context),
+ () => StartResponseAsync(context));
+ context.HttpContext.Response.Body = context.ResponseCachingStream;
+
+ // Shim IHttpSendFileFeature
+ context.OriginalSendFileFeature = context.HttpContext.Features.Get();
+ if (context.OriginalSendFileFeature != null)
+ {
+ context.HttpContext.Features.Set(new SendFileFeatureWrapper(context.OriginalSendFileFeature, context.ResponseCachingStream));
+ }
+
+ // Add IResponseCachingFeature
+ AddResponseCachingFeature(context.HttpContext);
+ }
+
+ internal static void RemoveResponseCachingFeature(HttpContext context) =>
+ context.Features.Set(null);
+
+ internal static void UnshimResponseStream(ResponseCachingContext context)
+ {
+ // Unshim response stream
+ context.HttpContext.Response.Body = context.OriginalResponseStream;
+
+ // Unshim IHttpSendFileFeature
+ context.HttpContext.Features.Set(context.OriginalSendFileFeature);
+
+ // Remove IResponseCachingFeature
+ RemoveResponseCachingFeature(context.HttpContext);
+ }
+
+ internal static bool ContentIsNotModified(ResponseCachingContext context)
+ {
+ var cachedResponseHeaders = context.CachedResponseHeaders;
+ var ifNoneMatchHeader = context.HttpContext.Request.Headers[HeaderNames.IfNoneMatch];
+
+ if (!StringValues.IsNullOrEmpty(ifNoneMatchHeader))
+ {
+ if (ifNoneMatchHeader.Count == 1 && StringSegment.Equals(ifNoneMatchHeader[0], EntityTagHeaderValue.Any.Tag, StringComparison.OrdinalIgnoreCase))
+ {
+ context.Logger.LogNotModifiedIfNoneMatchStar();
+ return true;
+ }
+
+ EntityTagHeaderValue eTag;
+ IList ifNoneMatchEtags;
+ if (!StringValues.IsNullOrEmpty(cachedResponseHeaders[HeaderNames.ETag])
+ && EntityTagHeaderValue.TryParse(cachedResponseHeaders[HeaderNames.ETag].ToString(), out eTag)
+ && EntityTagHeaderValue.TryParseList(ifNoneMatchHeader, out ifNoneMatchEtags))
+ {
+ for (var i = 0; i < ifNoneMatchEtags.Count; i++)
+ {
+ var requestETag = ifNoneMatchEtags[i];
+ if (eTag.Compare(requestETag, useStrongComparison: false))
+ {
+ context.Logger.LogNotModifiedIfNoneMatchMatched(requestETag);
+ return true;
+ }
+ }
+ }
+ }
+ else
+ {
+ var ifModifiedSince = context.HttpContext.Request.Headers[HeaderNames.IfModifiedSince];
+ if (!StringValues.IsNullOrEmpty(ifModifiedSince))
+ {
+ DateTimeOffset modified;
+ if (!HeaderUtilities.TryParseDate(cachedResponseHeaders[HeaderNames.LastModified].ToString(), out modified) &&
+ !HeaderUtilities.TryParseDate(cachedResponseHeaders[HeaderNames.Date].ToString(), out modified))
+ {
+ return false;
+ }
+
+ DateTimeOffset modifiedSince;
+ if (HeaderUtilities.TryParseDate(ifModifiedSince.ToString(), out modifiedSince) &&
+ modified <= modifiedSince)
+ {
+ context.Logger.LogNotModifiedIfModifiedSinceSatisfied(modified, modifiedSince);
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ // Normalize order and casing
+ internal static StringValues GetOrderCasingNormalizedStringValues(StringValues stringValues)
+ {
+ 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);
+ }
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingOptions.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingOptions.cs
new file mode 100644
index 0000000000..4fa75e2135
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingOptions.cs
@@ -0,0 +1,32 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.ComponentModel;
+using Microsoft.AspNetCore.ResponseCaching.Internal;
+
+namespace Microsoft.AspNetCore.ResponseCaching
+{
+ public class ResponseCachingOptions
+ {
+ ///
+ /// The size limit for the response cache middleware in bytes. The default is set to 100 MB.
+ ///
+ 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.
+ ///
+ public long MaximumBodySize { get; set; } = 64 * 1024 * 1024;
+
+ ///
+ /// true if request paths are case-sensitive; otherwise false. The default is to treat paths as case-insensitive.
+ ///
+ public bool UseCaseSensitivePaths { get; set; } = false;
+
+ ///
+ /// For testing purposes only.
+ ///
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ internal ISystemClock SystemClock { get; set; } = new SystemClock();
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingServicesExtensions.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingServicesExtensions.cs
new file mode 100644
index 0000000000..ef6f815b5e
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingServicesExtensions.cs
@@ -0,0 +1,58 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using Microsoft.AspNetCore.ResponseCaching;
+using Microsoft.AspNetCore.ResponseCaching.Internal;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ ///
+ /// Extension methods for the ResponseCaching middleware.
+ ///
+ public static class ResponseCachingServicesExtensions
+ {
+ ///
+ /// Add response caching services.
+ ///
+ /// The for adding services.
+ ///
+ public static IServiceCollection AddResponseCaching(this IServiceCollection services)
+ {
+ if (services == null)
+ {
+ throw new ArgumentNullException(nameof(services));
+ }
+
+ services.AddMemoryCache();
+ services.TryAdd(ServiceDescriptor.Singleton());
+ services.TryAdd(ServiceDescriptor.Singleton());
+
+ return services;
+ }
+
+ ///
+ /// Add response caching services and configure the related options.
+ ///
+ /// The for adding services.
+ /// A delegate to configure the .
+ ///
+ public static IServiceCollection AddResponseCaching(this IServiceCollection services, Action configureOptions)
+ {
+ if (services == null)
+ {
+ throw new ArgumentNullException(nameof(services));
+ }
+ if (configureOptions == null)
+ {
+ throw new ArgumentNullException(nameof(configureOptions));
+ }
+
+ services.Configure(configureOptions);
+ services.AddResponseCaching();
+
+ return services;
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Streams/ResponseCachingStream.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Streams/ResponseCachingStream.cs
new file mode 100644
index 0000000000..c9d476c97d
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Streams/ResponseCachingStream.cs
@@ -0,0 +1,200 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ internal class ResponseCachingStream : Stream
+ {
+ private readonly Stream _innerStream;
+ private readonly long _maxBufferSize;
+ private readonly int _segmentSize;
+ private SegmentWriteStream _segmentWriteStream;
+ private Action _startResponseCallback;
+ private Func _startResponseCallbackAsync;
+
+ internal ResponseCachingStream(Stream innerStream, long maxBufferSize, int segmentSize, Action startResponseCallback, Func startResponseCallbackAsync)
+ {
+ _innerStream = innerStream;
+ _maxBufferSize = maxBufferSize;
+ _segmentSize = segmentSize;
+ _startResponseCallback = startResponseCallback;
+ _startResponseCallbackAsync = startResponseCallbackAsync;
+ _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 Stream GetBufferStream()
+ {
+ if (!BufferingEnabled)
+ {
+ throw new InvalidOperationException("Buffer stream cannot be retrieved since buffering is disabled.");
+ }
+ return new SegmentReadStream(_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
+ {
+ await _startResponseCallbackAsync();
+ await _innerStream.FlushAsync();
+ }
+ 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)
+ {
+ try
+ {
+ await _startResponseCallbackAsync();
+ await _innerStream.WriteAsync(buffer, offset, count, cancellationToken);
+ }
+ catch
+ {
+ DisableBuffering();
+ throw;
+ }
+
+ if (BufferingEnabled)
+ {
+ if (_segmentWriteStream.Length + count > _maxBufferSize)
+ {
+ DisableBuffering();
+ }
+ else
+ {
+ await _segmentWriteStream.WriteAsync(buffer, offset, count, 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)
+ {
+ return StreamUtilities.ToIAsyncResult(WriteAsync(buffer, offset, count), callback, state);
+ }
+
+ public override void EndWrite(IAsyncResult asyncResult)
+ {
+ if (asyncResult == null)
+ {
+ throw new ArgumentNullException(nameof(asyncResult));
+ }
+ ((Task)asyncResult).GetAwaiter().GetResult();
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Streams/SegmentReadStream.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Streams/SegmentReadStream.cs
new file mode 100644
index 0000000000..83c60dd0c5
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Streams/SegmentReadStream.cs
@@ -0,0 +1,230 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ internal class SegmentReadStream : Stream
+ {
+ private readonly List _segments;
+ private readonly long _length;
+ private int _segmentIndex;
+ private int _segmentOffset;
+ private long _position;
+
+ internal SegmentReadStream(List segments, long length)
+ {
+ if (segments == null)
+ {
+ throw new ArgumentNullException(nameof(segments));
+ }
+
+ _segments = segments;
+ _length = length;
+ }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => true;
+
+ public override bool CanWrite => false;
+
+ public override long Length => _length;
+
+ public override long Position
+ {
+ get
+ {
+ return _position;
+ }
+ set
+ {
+ // The stream only supports a full rewind. This will need an update if random access becomes a required feature.
+ if (value != 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), value, $"{nameof(Position)} can only be set to 0.");
+ }
+
+ _position = 0;
+ _segmentOffset = 0;
+ _segmentIndex = 0;
+ }
+ }
+
+ public override void Flush()
+ {
+ throw new NotSupportedException("The stream does not support writing.");
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ if (buffer == null)
+ {
+ throw new ArgumentNullException(nameof(buffer));
+ }
+ if (offset < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(offset), offset, "Non-negative number required.");
+ }
+ // Read of length 0 will return zero and indicate end of stream.
+ if (count <= 0 )
+ {
+ throw new ArgumentOutOfRangeException(nameof(count), count, "Positive 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 (_segmentIndex == _segments.Count)
+ {
+ return 0;
+ }
+
+ var bytesRead = 0;
+ while (count > 0)
+ {
+ if (_segmentOffset == _segments[_segmentIndex].Length)
+ {
+ // Move to the next segment
+ _segmentIndex++;
+ _segmentOffset = 0;
+
+ if (_segmentIndex == _segments.Count)
+ {
+ break;
+ }
+ }
+
+ // Read up to the end of the segment
+ var segmentBytesRead = Math.Min(count, _segments[_segmentIndex].Length - _segmentOffset);
+ Buffer.BlockCopy(_segments[_segmentIndex], _segmentOffset, buffer, offset, segmentBytesRead);
+ bytesRead += segmentBytesRead;
+ _segmentOffset += segmentBytesRead;
+ _position += segmentBytesRead;
+ offset += segmentBytesRead;
+ count -= segmentBytesRead;
+ }
+
+ return bytesRead;
+ }
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(Read(buffer, offset, count));
+ }
+
+ public override int ReadByte()
+ {
+ if (Position == Length)
+ {
+ return -1;
+ }
+
+ if (_segmentOffset == _segments[_segmentIndex].Length)
+ {
+ // Move to the next segment
+ _segmentIndex++;
+ _segmentOffset = 0;
+ }
+
+ var byteRead = _segments[_segmentIndex][_segmentOffset];
+ _segmentOffset++;
+ _position++;
+
+ return byteRead;
+ }
+
+ public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback callback, object state)
+ {
+ var tcs = new TaskCompletionSource(state);
+
+ try
+ {
+ tcs.TrySetResult(Read(buffer, offset, count));
+ }
+ catch (Exception ex)
+ {
+ tcs.TrySetException(ex);
+ }
+
+ if (callback != null)
+ {
+ // Offload callbacks to avoid stack dives on sync completions.
+ var ignored = Task.Run(() =>
+ {
+ try
+ {
+ callback(tcs.Task);
+ }
+ catch (Exception)
+ {
+ // Suppress exceptions on background threads.
+ }
+ });
+ }
+
+ return tcs.Task;
+ }
+
+ public override int EndRead(IAsyncResult asyncResult)
+ {
+ if (asyncResult == null)
+ {
+ throw new ArgumentNullException(nameof(asyncResult));
+ }
+ return ((Task)asyncResult).GetAwaiter().GetResult();
+ }
+
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ // The stream only supports a full rewind. This will need an update if random access becomes a required feature.
+ if (origin != SeekOrigin.Begin)
+ {
+ throw new ArgumentException(nameof(origin), $"{nameof(Seek)} can only be set to {nameof(SeekOrigin.Begin)}.");
+ }
+ if (offset != 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(offset), offset, $"{nameof(Seek)} can only be set to 0.");
+ }
+
+ Position = 0;
+ return Position;
+ }
+
+ public override void SetLength(long value)
+ {
+ throw new NotSupportedException("The stream does not support writing.");
+ }
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ throw new NotSupportedException("The stream does not support writing.");
+ }
+
+ public override async Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
+ {
+ if (destination == null)
+ {
+ throw new ArgumentNullException(nameof(destination));
+ }
+ if (!destination.CanWrite)
+ {
+ throw new NotSupportedException("The destination stream does not support writing.");
+ }
+
+ for (; _segmentIndex < _segments.Count; _segmentIndex++, _segmentOffset = 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var bytesCopied = _segments[_segmentIndex].Length - _segmentOffset;
+ await destination.WriteAsync(_segments[_segmentIndex], _segmentOffset, bytesCopied, cancellationToken);
+ _position += bytesCopied;
+ }
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Streams/SegmentWriteStream.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Streams/SegmentWriteStream.cs
new file mode 100644
index 0000000000..81df72a9d1
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Streams/SegmentWriteStream.cs
@@ -0,0 +1,206 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ internal class SegmentWriteStream : Stream
+ {
+ private readonly List _segments = new List();
+ private readonly MemoryStream _bufferStream = new MemoryStream();
+ 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)
+ {
+ if (buffer == null)
+ {
+ throw new ArgumentNullException(nameof(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.");
+ }
+
+ while (count > 0)
+ {
+ if ((int)_bufferStream.Length == _segmentSize)
+ {
+ _segments.Add(_bufferStream.ToArray());
+ _bufferStream.SetLength(0);
+ }
+
+ var bytesWritten = Math.Min(count, _segmentSize - (int)_bufferStream.Length);
+
+ _bufferStream.Write(buffer, offset, bytesWritten);
+ count -= bytesWritten;
+ offset += bytesWritten;
+ _length += bytesWritten;
+ }
+ }
+
+ public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ Write(buffer, offset, count);
+ return Task.CompletedTask;
+ }
+
+ 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)
+ {
+ return StreamUtilities.ToIAsyncResult(WriteAsync(buffer, offset, count), callback, state);
+ }
+
+ public override void EndWrite(IAsyncResult asyncResult)
+ {
+ if (asyncResult == null)
+ {
+ throw new ArgumentNullException(nameof(asyncResult));
+ }
+ ((Task)asyncResult).GetAwaiter().GetResult();
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Streams/StreamUtilities.cs b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Streams/StreamUtilities.cs
new file mode 100644
index 0000000000..d128a9f8f2
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/Streams/StreamUtilities.cs
@@ -0,0 +1,41 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ 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;
+
+ internal static IAsyncResult ToIAsyncResult(Task task, AsyncCallback callback, object state)
+ {
+ var tcs = new TaskCompletionSource(state);
+ task.ContinueWith(t =>
+ {
+ if (t.IsFaulted)
+ {
+ tcs.TrySetException(t.Exception.InnerExceptions);
+ }
+ else if (t.IsCanceled)
+ {
+ tcs.TrySetCanceled();
+ }
+ else
+ {
+ tcs.TrySetResult(0);
+ }
+
+ callback?.Invoke(tcs.Task);
+ }, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
+ return tcs.Task;
+ }
+ }
+}
diff --git a/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/baseline.netcore.json b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/baseline.netcore.json
new file mode 100644
index 0000000000..9bec30264e
--- /dev/null
+++ b/src/ResponseCaching/src/Microsoft.AspNetCore.ResponseCaching/baseline.netcore.json
@@ -0,0 +1,252 @@
+{
+ "AssemblyIdentity": "Microsoft.AspNetCore.ResponseCaching, Version=2.1.1.0, Culture=neutral, PublicKeyToken=adb9793829ddae60",
+ "Types": [
+ {
+ "Name": "Microsoft.Extensions.DependencyInjection.ResponseCachingServicesExtensions",
+ "Visibility": "Public",
+ "Kind": "Class",
+ "Abstract": true,
+ "Static": true,
+ "Sealed": true,
+ "ImplementedInterfaces": [],
+ "Members": [
+ {
+ "Kind": "Method",
+ "Name": "AddResponseCaching",
+ "Parameters": [
+ {
+ "Name": "services",
+ "Type": "Microsoft.Extensions.DependencyInjection.IServiceCollection"
+ }
+ ],
+ "ReturnType": "Microsoft.Extensions.DependencyInjection.IServiceCollection",
+ "Static": true,
+ "Extension": true,
+ "Visibility": "Public",
+ "GenericParameter": []
+ },
+ {
+ "Kind": "Method",
+ "Name": "AddResponseCaching",
+ "Parameters": [
+ {
+ "Name": "services",
+ "Type": "Microsoft.Extensions.DependencyInjection.IServiceCollection"
+ },
+ {
+ "Name": "configureOptions",
+ "Type": "System.Action"
+ }
+ ],
+ "ReturnType": "Microsoft.Extensions.DependencyInjection.IServiceCollection",
+ "Static": true,
+ "Extension": true,
+ "Visibility": "Public",
+ "GenericParameter": []
+ }
+ ],
+ "GenericParameters": []
+ },
+ {
+ "Name": "Microsoft.AspNetCore.Builder.ResponseCachingExtensions",
+ "Visibility": "Public",
+ "Kind": "Class",
+ "Abstract": true,
+ "Static": true,
+ "Sealed": true,
+ "ImplementedInterfaces": [],
+ "Members": [
+ {
+ "Kind": "Method",
+ "Name": "UseResponseCaching",
+ "Parameters": [
+ {
+ "Name": "app",
+ "Type": "Microsoft.AspNetCore.Builder.IApplicationBuilder"
+ }
+ ],
+ "ReturnType": "Microsoft.AspNetCore.Builder.IApplicationBuilder",
+ "Static": true,
+ "Extension": true,
+ "Visibility": "Public",
+ "GenericParameter": []
+ }
+ ],
+ "GenericParameters": []
+ },
+ {
+ "Name": "Microsoft.AspNetCore.ResponseCaching.ResponseCachingFeature",
+ "Visibility": "Public",
+ "Kind": "Class",
+ "ImplementedInterfaces": [
+ "Microsoft.AspNetCore.ResponseCaching.IResponseCachingFeature"
+ ],
+ "Members": [
+ {
+ "Kind": "Method",
+ "Name": "get_VaryByQueryKeys",
+ "Parameters": [],
+ "ReturnType": "System.String[]",
+ "Sealed": true,
+ "Virtual": true,
+ "ImplementedInterface": "Microsoft.AspNetCore.ResponseCaching.IResponseCachingFeature",
+ "Visibility": "Public",
+ "GenericParameter": []
+ },
+ {
+ "Kind": "Method",
+ "Name": "set_VaryByQueryKeys",
+ "Parameters": [
+ {
+ "Name": "value",
+ "Type": "System.String[]"
+ }
+ ],
+ "ReturnType": "System.Void",
+ "Sealed": true,
+ "Virtual": true,
+ "ImplementedInterface": "Microsoft.AspNetCore.ResponseCaching.IResponseCachingFeature",
+ "Visibility": "Public",
+ "GenericParameter": []
+ },
+ {
+ "Kind": "Constructor",
+ "Name": ".ctor",
+ "Parameters": [],
+ "Visibility": "Public",
+ "GenericParameter": []
+ }
+ ],
+ "GenericParameters": []
+ },
+ {
+ "Name": "Microsoft.AspNetCore.ResponseCaching.ResponseCachingMiddleware",
+ "Visibility": "Public",
+ "Kind": "Class",
+ "ImplementedInterfaces": [],
+ "Members": [
+ {
+ "Kind": "Method",
+ "Name": "Invoke",
+ "Parameters": [
+ {
+ "Name": "httpContext",
+ "Type": "Microsoft.AspNetCore.Http.HttpContext"
+ }
+ ],
+ "ReturnType": "System.Threading.Tasks.Task",
+ "Visibility": "Public",
+ "GenericParameter": []
+ },
+ {
+ "Kind": "Constructor",
+ "Name": ".ctor",
+ "Parameters": [
+ {
+ "Name": "next",
+ "Type": "Microsoft.AspNetCore.Http.RequestDelegate"
+ },
+ {
+ "Name": "options",
+ "Type": "Microsoft.Extensions.Options.IOptions"
+ },
+ {
+ "Name": "loggerFactory",
+ "Type": "Microsoft.Extensions.Logging.ILoggerFactory"
+ },
+ {
+ "Name": "policyProvider",
+ "Type": "Microsoft.AspNetCore.ResponseCaching.Internal.IResponseCachingPolicyProvider"
+ },
+ {
+ "Name": "keyProvider",
+ "Type": "Microsoft.AspNetCore.ResponseCaching.Internal.IResponseCachingKeyProvider"
+ }
+ ],
+ "Visibility": "Public",
+ "GenericParameter": []
+ }
+ ],
+ "GenericParameters": []
+ },
+ {
+ "Name": "Microsoft.AspNetCore.ResponseCaching.ResponseCachingOptions",
+ "Visibility": "Public",
+ "Kind": "Class",
+ "ImplementedInterfaces": [],
+ "Members": [
+ {
+ "Kind": "Method",
+ "Name": "get_SizeLimit",
+ "Parameters": [],
+ "ReturnType": "System.Int64",
+ "Visibility": "Public",
+ "GenericParameter": []
+ },
+ {
+ "Kind": "Method",
+ "Name": "set_SizeLimit",
+ "Parameters": [
+ {
+ "Name": "value",
+ "Type": "System.Int64"
+ }
+ ],
+ "ReturnType": "System.Void",
+ "Visibility": "Public",
+ "GenericParameter": []
+ },
+ {
+ "Kind": "Method",
+ "Name": "get_MaximumBodySize",
+ "Parameters": [],
+ "ReturnType": "System.Int64",
+ "Visibility": "Public",
+ "GenericParameter": []
+ },
+ {
+ "Kind": "Method",
+ "Name": "set_MaximumBodySize",
+ "Parameters": [
+ {
+ "Name": "value",
+ "Type": "System.Int64"
+ }
+ ],
+ "ReturnType": "System.Void",
+ "Visibility": "Public",
+ "GenericParameter": []
+ },
+ {
+ "Kind": "Method",
+ "Name": "get_UseCaseSensitivePaths",
+ "Parameters": [],
+ "ReturnType": "System.Boolean",
+ "Visibility": "Public",
+ "GenericParameter": []
+ },
+ {
+ "Kind": "Method",
+ "Name": "set_UseCaseSensitivePaths",
+ "Parameters": [
+ {
+ "Name": "value",
+ "Type": "System.Boolean"
+ }
+ ],
+ "ReturnType": "System.Void",
+ "Visibility": "Public",
+ "GenericParameter": []
+ },
+ {
+ "Kind": "Constructor",
+ "Name": ".ctor",
+ "Parameters": [],
+ "Visibility": "Public",
+ "GenericParameter": []
+ }
+ ],
+ "GenericParameters": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/ResponseCaching/test/Directory.Build.props b/src/ResponseCaching/test/Directory.Build.props
new file mode 100644
index 0000000000..270e1fa209
--- /dev/null
+++ b/src/ResponseCaching/test/Directory.Build.props
@@ -0,0 +1,14 @@
+
+
+
+
+ netcoreapp2.1
+ $(DeveloperBuildTestTfms)
+ netcoreapp2.1;netcoreapp2.0
+ $(StandardTestTfms);net461
+
+
+
+
+
+
diff --git a/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/Microsoft.AspNetCore.ResponseCaching.Tests.csproj b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/Microsoft.AspNetCore.ResponseCaching.Tests.csproj
new file mode 100644
index 0000000000..79223f0c73
--- /dev/null
+++ b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/Microsoft.AspNetCore.ResponseCaching.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+
+ $(StandardTestTfms)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingFeatureTests.cs b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingFeatureTests.cs
new file mode 100644
index 0000000000..3d5b57bf65
--- /dev/null
+++ b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingFeatureTests.cs
@@ -0,0 +1,59 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using Xunit;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Tests
+{
+ public class ResponseCachingFeatureTests
+ {
+ public static TheoryData ValidNullOrEmptyVaryRules
+ {
+ get
+ {
+ return new TheoryData
+ {
+ null,
+ new string[0],
+ new string[] { null },
+ new string[] { string.Empty }
+ };
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(ValidNullOrEmptyVaryRules))]
+ public void VaryByQueryKeys_Set_ValidEmptyValues_Succeeds(string[] value)
+ {
+ // Does not throw
+ new ResponseCachingFeature().VaryByQueryKeys = value;
+ }
+
+ public static TheoryData InvalidVaryRules
+ {
+ get
+ {
+ return new TheoryData
+ {
+ new string[] { null, null },
+ new string[] { null, string.Empty },
+ new string[] { string.Empty, null },
+ new string[] { string.Empty, "Valid" },
+ new string[] { "Valid", string.Empty },
+ new string[] { null, "Valid" },
+ new string[] { "Valid", null }
+ };
+ }
+ }
+
+
+ [Theory]
+ [MemberData(nameof(InvalidVaryRules))]
+ public void VaryByQueryKeys_Set_InValidEmptyValues_Throws(string[] value)
+ {
+ // Throws
+ Assert.Throws(() => new ResponseCachingFeature().VaryByQueryKeys = value);
+ }
+ }
+}
diff --git a/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingKeyProviderTests.cs b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingKeyProviderTests.cs
new file mode 100644
index 0000000000..36bd3da0c8
--- /dev/null
+++ b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingKeyProviderTests.cs
@@ -0,0 +1,218 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.ResponseCaching.Internal;
+using Xunit;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Tests
+{
+ public class ResponseCachingKeyProviderTests
+ {
+ private static readonly char KeyDelimiter = '\x1e';
+ private static readonly char KeySubDelimiter = '\x1f';
+
+ [Fact]
+ public void ResponseCachingKeyProvider_CreateStorageBaseKey_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.CreateBaseKey(context));
+ }
+
+ [Fact]
+ public void ResponseCachingKeyProvider_CreateStorageBaseKey_CaseInsensitivePath_NormalizesPath()
+ {
+ var cacheKeyProvider = TestUtils.CreateTestKeyProvider(new ResponseCachingOptions()
+ {
+ 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.CreateBaseKey(context));
+ }
+
+ [Fact]
+ public void ResponseCachingKeyProvider_CreateStorageBaseKey_CaseSensitivePath_PreservesPathCase()
+ {
+ var cacheKeyProvider = TestUtils.CreateTestKeyProvider(new ResponseCachingOptions()
+ {
+ 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.CreateBaseKey(context));
+ }
+
+ [Fact]
+ public void ResponseCachingKeyProvider_CreateStorageVaryByKey_Throws_IfVaryByRulesIsNull()
+ {
+ var cacheKeyProvider = TestUtils.CreateTestKeyProvider();
+ var context = TestUtils.CreateTestContext();
+
+ Assert.Throws(() => cacheKeyProvider.CreateStorageVaryByKey(context));
+ }
+
+ [Fact]
+ public void ResponseCachingKeyProvider_CreateStorageVaryKey_ReturnsCachedVaryByGuid_IfVaryByRulesIsEmpty()
+ {
+ var cacheKeyProvider = TestUtils.CreateTestKeyProvider();
+ var context = TestUtils.CreateTestContext();
+ context.CachedVaryByRules = new CachedVaryByRules()
+ {
+ VaryByKeyPrefix = FastGuid.NewGuid().IdString
+ };
+
+ Assert.Equal($"{context.CachedVaryByRules.VaryByKeyPrefix}", cacheKeyProvider.CreateStorageVaryByKey(context));
+ }
+
+ [Fact]
+ public void ResponseCachingKeyProvider_CreateStorageVaryKey_IncludesListedHeadersOnly()
+ {
+ var cacheKeyProvider = TestUtils.CreateTestKeyProvider();
+ var context = TestUtils.CreateTestContext();
+ context.HttpContext.Request.Headers["HeaderA"] = "ValueA";
+ context.HttpContext.Request.Headers["HeaderB"] = "ValueB";
+ context.CachedVaryByRules = new CachedVaryByRules()
+ {
+ Headers = new string[] { "HeaderA", "HeaderC" }
+ };
+
+ Assert.Equal($"{context.CachedVaryByRules.VaryByKeyPrefix}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=",
+ cacheKeyProvider.CreateStorageVaryByKey(context));
+ }
+
+ [Fact]
+ public void ResponseCachingKeyProvider_CreateStorageVaryKey_HeaderValuesAreSorted()
+ {
+ var cacheKeyProvider = TestUtils.CreateTestKeyProvider();
+ var context = TestUtils.CreateTestContext();
+ context.HttpContext.Request.Headers["HeaderA"] = "ValueB";
+ context.HttpContext.Request.Headers.Append("HeaderA", "ValueA");
+ context.CachedVaryByRules = new CachedVaryByRules()
+ {
+ Headers = new string[] { "HeaderA", "HeaderC" }
+ };
+
+ Assert.Equal($"{context.CachedVaryByRules.VaryByKeyPrefix}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueAValueB{KeyDelimiter}HeaderC=",
+ cacheKeyProvider.CreateStorageVaryByKey(context));
+ }
+
+ [Fact]
+ public void ResponseCachingKeyProvider_CreateStorageVaryKey_IncludesListedQueryKeysOnly()
+ {
+ var cacheKeyProvider = TestUtils.CreateTestKeyProvider();
+ var context = TestUtils.CreateTestContext();
+ context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryB=ValueB");
+ context.CachedVaryByRules = new CachedVaryByRules()
+ {
+ VaryByKeyPrefix = FastGuid.NewGuid().IdString,
+ QueryKeys = new string[] { "QueryA", "QueryC" }
+ };
+
+ Assert.Equal($"{context.CachedVaryByRules.VaryByKeyPrefix}{KeyDelimiter}Q{KeyDelimiter}QueryA=ValueA{KeyDelimiter}QueryC=",
+ cacheKeyProvider.CreateStorageVaryByKey(context));
+ }
+
+ [Fact]
+ public void ResponseCachingKeyProvider_CreateStorageVaryKey_IncludesQueryKeys_QueryKeyCaseInsensitive_UseQueryKeyCasing()
+ {
+ var cacheKeyProvider = TestUtils.CreateTestKeyProvider();
+ var context = TestUtils.CreateTestContext();
+ context.HttpContext.Request.QueryString = new QueryString("?queryA=ValueA&queryB=ValueB");
+ context.CachedVaryByRules = new CachedVaryByRules()
+ {
+ VaryByKeyPrefix = FastGuid.NewGuid().IdString,
+ QueryKeys = new string[] { "QueryA", "QueryC" }
+ };
+
+ Assert.Equal($"{context.CachedVaryByRules.VaryByKeyPrefix}{KeyDelimiter}Q{KeyDelimiter}QueryA=ValueA{KeyDelimiter}QueryC=",
+ cacheKeyProvider.CreateStorageVaryByKey(context));
+ }
+
+ [Fact]
+ public void ResponseCachingKeyProvider_CreateStorageVaryKey_IncludesAllQueryKeysGivenAsterisk()
+ {
+ var cacheKeyProvider = TestUtils.CreateTestKeyProvider();
+ var context = TestUtils.CreateTestContext();
+ context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryB=ValueB");
+ context.CachedVaryByRules = new CachedVaryByRules()
+ {
+ VaryByKeyPrefix = FastGuid.NewGuid().IdString,
+ 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($"{context.CachedVaryByRules.VaryByKeyPrefix}{KeyDelimiter}Q{KeyDelimiter}QUERYA=ValueA{KeyDelimiter}QUERYB=ValueB",
+ cacheKeyProvider.CreateStorageVaryByKey(context));
+ }
+
+ [Fact]
+ public void ResponseCachingKeyProvider_CreateStorageVaryKey_QueryKeysValuesNotConsolidated()
+ {
+ var cacheKeyProvider = TestUtils.CreateTestKeyProvider();
+ var context = TestUtils.CreateTestContext();
+ context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryA=ValueB");
+ context.CachedVaryByRules = new CachedVaryByRules()
+ {
+ VaryByKeyPrefix = FastGuid.NewGuid().IdString,
+ 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($"{context.CachedVaryByRules.VaryByKeyPrefix}{KeyDelimiter}Q{KeyDelimiter}QUERYA=ValueA{KeySubDelimiter}ValueB",
+ cacheKeyProvider.CreateStorageVaryByKey(context));
+ }
+
+ [Fact]
+ public void ResponseCachingKeyProvider_CreateStorageVaryKey_QueryKeysValuesAreSorted()
+ {
+ var cacheKeyProvider = TestUtils.CreateTestKeyProvider();
+ var context = TestUtils.CreateTestContext();
+ context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueB&QueryA=ValueA");
+ context.CachedVaryByRules = new CachedVaryByRules()
+ {
+ VaryByKeyPrefix = FastGuid.NewGuid().IdString,
+ 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($"{context.CachedVaryByRules.VaryByKeyPrefix}{KeyDelimiter}Q{KeyDelimiter}QUERYA=ValueA{KeySubDelimiter}ValueB",
+ cacheKeyProvider.CreateStorageVaryByKey(context));
+ }
+
+ [Fact]
+ public void ResponseCachingKeyProvider_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.CachedVaryByRules = new CachedVaryByRules()
+ {
+ VaryByKeyPrefix = FastGuid.NewGuid().IdString,
+ Headers = new string[] { "HeaderA", "HeaderC" },
+ QueryKeys = new string[] { "QueryA", "QueryC" }
+ };
+
+ Assert.Equal($"{context.CachedVaryByRules.VaryByKeyPrefix}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC={KeyDelimiter}Q{KeyDelimiter}QueryA=ValueA{KeyDelimiter}QueryC=",
+ cacheKeyProvider.CreateStorageVaryByKey(context));
+ }
+ }
+}
diff --git a/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingMiddlewareTests.cs b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingMiddlewareTests.cs
new file mode 100644
index 0000000000..831e9ea67e
--- /dev/null
+++ b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingMiddlewareTests.cs
@@ -0,0 +1,940 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.AspNetCore.ResponseCaching.Internal;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging.Testing;
+using Microsoft.Extensions.Primitives;
+using Microsoft.Net.Http.Headers;
+using Xunit;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Tests
+{
+ public class ResponseCachingMiddlewareTests
+ {
+ [Fact]
+ public async Task TryServeFromCacheAsync_OnlyIfCached_Serves504()
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider());
+ var context = TestUtils.CreateTestContext();
+ context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue()
+ {
+ OnlyIfCached = true
+ }.ToString();
+
+ Assert.True(await middleware.TryServeFromCacheAsync(context));
+ Assert.Equal(StatusCodes.Status504GatewayTimeout, context.HttpContext.Response.StatusCode);
+ TestUtils.AssertLoggedMessages(
+ sink.Writes,
+ LoggedMessage.GatewayTimeoutServed);
+ }
+
+ [Fact]
+ public async Task TryServeFromCacheAsync_CachedResponseNotFound_Fails()
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey"));
+ var context = TestUtils.CreateTestContext();
+
+ Assert.False(await middleware.TryServeFromCacheAsync(context));
+ Assert.Equal(1, cache.GetCount);
+ TestUtils.AssertLoggedMessages(
+ sink.Writes,
+ LoggedMessage.NoResponseServed);
+ }
+
+ [Fact]
+ public async Task TryServeFromCacheAsync_CachedResponseFound_Succeeds()
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey"));
+ var context = TestUtils.CreateTestContext();
+
+ await cache.SetAsync(
+ "BaseKey",
+ new CachedResponse()
+ {
+ Headers = new HeaderDictionary(),
+ Body = new SegmentReadStream(new List(0), 0)
+ },
+ TimeSpan.Zero);
+
+ Assert.True(await middleware.TryServeFromCacheAsync(context));
+ Assert.Equal(1, cache.GetCount);
+ TestUtils.AssertLoggedMessages(
+ sink.Writes,
+ LoggedMessage.CachedResponseServed);
+ }
+
+ [Fact]
+ public async Task TryServeFromCacheAsync_CachedResponseFound_OverwritesExistingHeaders()
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey"));
+ var context = TestUtils.CreateTestContext();
+
+ context.HttpContext.Response.Headers["MyHeader"] = "OldValue";
+ await cache.SetAsync(
+ "BaseKey",
+ new CachedResponse()
+ {
+ Headers = new HeaderDictionary()
+ {
+ { "MyHeader", "NewValue" }
+ },
+ Body = new SegmentReadStream(new List(0), 0)
+ },
+ TimeSpan.Zero);
+
+ Assert.True(await middleware.TryServeFromCacheAsync(context));
+ Assert.Equal("NewValue", context.HttpContext.Response.Headers["MyHeader"]);
+ Assert.Equal(1, cache.GetCount);
+ TestUtils.AssertLoggedMessages(
+ sink.Writes,
+ LoggedMessage.CachedResponseServed);
+ }
+
+ [Fact]
+ public async Task TryServeFromCacheAsync_VaryByRuleFound_CachedResponseNotFound_Fails()
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey", "VaryKey"));
+ var context = TestUtils.CreateTestContext();
+
+ await cache.SetAsync(
+ "BaseKey",
+ new CachedVaryByRules(),
+ TimeSpan.Zero);
+
+ Assert.False(await middleware.TryServeFromCacheAsync(context));
+ Assert.Equal(2, cache.GetCount);
+ TestUtils.AssertLoggedMessages(
+ sink.Writes,
+ LoggedMessage.NoResponseServed);
+ }
+
+ [Fact]
+ public async Task TryServeFromCacheAsync_VaryByRuleFound_CachedResponseFound_Succeeds()
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey", new[] { "VaryKey", "VaryKey2" }));
+ var context = TestUtils.CreateTestContext();
+
+ await cache.SetAsync(
+ "BaseKey",
+ new CachedVaryByRules(),
+ TimeSpan.Zero);
+ await cache.SetAsync(
+ "BaseKeyVaryKey2",
+ new CachedResponse()
+ {
+ Headers = new HeaderDictionary(),
+ Body = new SegmentReadStream(new List(0), 0)
+ },
+ TimeSpan.Zero);
+
+ Assert.True(await middleware.TryServeFromCacheAsync(context));
+ Assert.Equal(3, cache.GetCount);
+ TestUtils.AssertLoggedMessages(
+ sink.Writes,
+ LoggedMessage.CachedResponseServed);
+ }
+
+ [Fact]
+ public async Task TryServeFromCacheAsync_CachedResponseFound_Serves304IfPossible()
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey"));
+ var context = TestUtils.CreateTestContext();
+ context.HttpContext.Request.Headers[HeaderNames.IfNoneMatch] = "*";
+
+ await cache.SetAsync(
+ "BaseKey",
+ new CachedResponse()
+ {
+ Body = new SegmentReadStream(new List(0), 0)
+ },
+ TimeSpan.Zero);
+
+ Assert.True(await middleware.TryServeFromCacheAsync(context));
+ 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.CachedResponseHeaders = new HeaderDictionary();
+
+ Assert.False(ResponseCachingMiddleware.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.CachedResponseHeaders = new HeaderDictionary();
+
+ context.HttpContext.Request.Headers[HeaderNames.IfModifiedSince] = HeaderUtilities.FormatDate(utcNow);
+
+ // Verify modifications in the past succeeds
+ context.CachedResponseHeaders[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10));
+ Assert.True(ResponseCachingMiddleware.ContentIsNotModified(context));
+ Assert.Single(sink.Writes);
+
+ // Verify modifications at present succeeds
+ context.CachedResponseHeaders[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow);
+ Assert.True(ResponseCachingMiddleware.ContentIsNotModified(context));
+ Assert.Equal(2, sink.Writes.Count);
+
+ // Verify modifications in the future fails
+ context.CachedResponseHeaders[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10));
+ Assert.False(ResponseCachingMiddleware.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.CachedResponseHeaders = new HeaderDictionary();
+
+ context.HttpContext.Request.Headers[HeaderNames.IfModifiedSince] = HeaderUtilities.FormatDate(utcNow);
+
+ // Verify modifications in the past succeeds
+ context.CachedResponseHeaders[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10));
+ context.CachedResponseHeaders[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10));
+ Assert.True(ResponseCachingMiddleware.ContentIsNotModified(context));
+ Assert.Single(sink.Writes);
+
+ // Verify modifications at present
+ context.CachedResponseHeaders[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10));
+ context.CachedResponseHeaders[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow);
+ Assert.True(ResponseCachingMiddleware.ContentIsNotModified(context));
+ Assert.Equal(2, sink.Writes.Count);
+
+ // Verify modifications in the future fails
+ context.CachedResponseHeaders[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10));
+ context.CachedResponseHeaders[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10));
+ Assert.False(ResponseCachingMiddleware.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.CachedResponseHeaders = new HeaderDictionary();
+
+ // This would fail the IfModifiedSince checks
+ context.HttpContext.Request.Headers[HeaderNames.IfModifiedSince] = HeaderUtilities.FormatDate(utcNow);
+ context.CachedResponseHeaders[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10));
+
+ context.HttpContext.Request.Headers[HeaderNames.IfNoneMatch] = EntityTagHeaderValue.Any.ToString();
+ Assert.True(ResponseCachingMiddleware.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.CachedResponseHeaders = new HeaderDictionary();
+
+ // This would pass the IfModifiedSince checks
+ context.HttpContext.Request.Headers[HeaderNames.IfModifiedSince] = HeaderUtilities.FormatDate(utcNow);
+ context.CachedResponseHeaders[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10));
+
+ context.HttpContext.Request.Headers[HeaderNames.IfNoneMatch] = "\"E1\"";
+ Assert.False(ResponseCachingMiddleware.ContentIsNotModified(context));
+ Assert.Empty(sink.Writes);
+ }
+
+ [Fact]
+ public void ContentIsNotModified_IfNoneMatch_AnyWithoutETagInResponse_False()
+ {
+ var sink = new TestSink();
+ var context = TestUtils.CreateTestContext(sink);
+ context.CachedResponseHeaders = new HeaderDictionary();
+ context.HttpContext.Request.Headers[HeaderNames.IfNoneMatch] = "\"E1\"";
+
+ Assert.False(ResponseCachingMiddleware.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.CachedResponseHeaders = new HeaderDictionary();
+ context.CachedResponseHeaders[HeaderNames.ETag] = responseETag.ToString();
+ context.HttpContext.Request.Headers[HeaderNames.IfNoneMatch] = requestETag.ToString();
+
+ Assert.True(ResponseCachingMiddleware.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.CachedResponseHeaders = new HeaderDictionary();
+ context.CachedResponseHeaders[HeaderNames.ETag] = "\"E2\"";
+ context.HttpContext.Request.Headers[HeaderNames.IfNoneMatch] = "\"E1\"";
+
+ Assert.False(ResponseCachingMiddleware.ContentIsNotModified(context));
+ Assert.Empty(sink.Writes);
+ }
+
+ [Fact]
+ public void ContentIsNotModified_IfNoneMatch_MatchesAtLeastOneValue_True()
+ {
+ var sink = new TestSink();
+ var context = TestUtils.CreateTestContext(sink);
+ context.CachedResponseHeaders = new HeaderDictionary();
+ context.CachedResponseHeaders[HeaderNames.ETag] = "\"E2\"";
+ context.HttpContext.Request.Headers[HeaderNames.IfNoneMatch] = new string[] { "\"E0\", \"E1\"", "\"E1\", \"E2\"" };
+
+ Assert.True(ResponseCachingMiddleware.ContentIsNotModified(context));
+ TestUtils.AssertLoggedMessages(
+ sink.Writes,
+ LoggedMessage.NotModifiedIfNoneMatchMatched);
+ }
+
+ [Fact]
+ public async Task StartResponsegAsync_IfAllowResponseCaptureIsTrue_SetsResponseTime()
+ {
+ var clock = new TestClock
+ {
+ UtcNow = DateTimeOffset.UtcNow
+ };
+ var middleware = TestUtils.CreateTestMiddleware(options: new ResponseCachingOptions
+ {
+ SystemClock = clock
+ });
+ var context = TestUtils.CreateTestContext();
+ context.ResponseTime = null;
+
+ await middleware.StartResponseAsync(context);
+
+ Assert.Equal(clock.UtcNow, context.ResponseTime);
+ }
+
+ [Fact]
+ public async Task StartResponseAsync_IfAllowResponseCaptureIsTrue_SetsResponseTimeOnlyOnce()
+ {
+ var clock = new TestClock
+ {
+ UtcNow = DateTimeOffset.UtcNow
+ };
+ var middleware = TestUtils.CreateTestMiddleware(options: new ResponseCachingOptions
+ {
+ SystemClock = clock
+ });
+ var context = TestUtils.CreateTestContext();
+ var initialTime = clock.UtcNow;
+ context.ResponseTime = null;
+
+ await middleware.StartResponseAsync(context);
+ Assert.Equal(initialTime, context.ResponseTime);
+
+ clock.UtcNow += TimeSpan.FromSeconds(10);
+
+ await middleware.StartResponseAsync(context);
+ Assert.NotEqual(clock.UtcNow, context.ResponseTime);
+ Assert.Equal(initialTime, context.ResponseTime);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheHeadersAsync_UpdateShouldCacheResponse_IfResponseCacheable()
+ {
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, policyProvider: new ResponseCachingPolicyProvider());
+ var context = TestUtils.CreateTestContext();
+
+ context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue()
+ {
+ Public = true
+ }.ToString();
+
+ Assert.False(context.ShouldCacheResponse);
+
+ await middleware.FinalizeCacheHeadersAsync(context);
+
+ Assert.True(context.ShouldCacheResponse);
+ Assert.Empty(sink.Writes);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheHeadersAsync_DoNotUpdateShouldCacheResponse_IfResponseIsNotCacheable()
+ {
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, policyProvider: new ResponseCachingPolicyProvider());
+ var context = TestUtils.CreateTestContext();
+
+ middleware.ShimResponseStream(context);
+
+ await middleware.FinalizeCacheHeadersAsync(context);
+
+ Assert.False(context.ShouldCacheResponse);
+ Assert.Empty(sink.Writes);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheHeadersAsync_DefaultResponseValidity_Is10Seconds()
+ {
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink);
+ var context = TestUtils.CreateTestContext();
+
+ await middleware.FinalizeCacheHeadersAsync(context);
+
+ Assert.Equal(TimeSpan.FromSeconds(10), context.CachedResponseValidFor);
+ Assert.Empty(sink.Writes);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheHeadersAsync_ResponseValidity_UseExpiryIfAvailable()
+ {
+ var clock = new TestClock
+ {
+ UtcNow = DateTimeOffset.MinValue
+ };
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, options: new ResponseCachingOptions
+ {
+ SystemClock = clock
+ });
+ var context = TestUtils.CreateTestContext();
+
+ context.ResponseTime = clock.UtcNow;
+ context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(clock.UtcNow + TimeSpan.FromSeconds(11));
+
+ await middleware.FinalizeCacheHeadersAsync(context);
+
+ Assert.Equal(TimeSpan.FromSeconds(11), context.CachedResponseValidFor);
+ Assert.Empty(sink.Writes);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheHeadersAsync_ResponseValidity_UseMaxAgeIfAvailable()
+ {
+ var clock = new TestClock
+ {
+ UtcNow = DateTimeOffset.UtcNow
+ };
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, options: new ResponseCachingOptions
+ {
+ SystemClock = clock
+ });
+ var context = TestUtils.CreateTestContext();
+
+ context.ResponseTime = clock.UtcNow;
+ context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue()
+ {
+ MaxAge = TimeSpan.FromSeconds(12)
+ }.ToString();
+
+ context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(clock.UtcNow + TimeSpan.FromSeconds(11));
+
+ await middleware.FinalizeCacheHeadersAsync(context);
+
+ Assert.Equal(TimeSpan.FromSeconds(12), context.CachedResponseValidFor);
+ Assert.Empty(sink.Writes);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheHeadersAsync_ResponseValidity_UseSharedMaxAgeIfAvailable()
+ {
+ var clock = new TestClock
+ {
+ UtcNow = DateTimeOffset.UtcNow
+ };
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, options: new ResponseCachingOptions
+ {
+ SystemClock = clock
+ });
+ var context = TestUtils.CreateTestContext();
+
+ context.ResponseTime = clock.UtcNow;
+ context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue()
+ {
+ MaxAge = TimeSpan.FromSeconds(12),
+ SharedMaxAge = TimeSpan.FromSeconds(13)
+ }.ToString();
+ context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(clock.UtcNow + TimeSpan.FromSeconds(11));
+
+ await middleware.FinalizeCacheHeadersAsync(context);
+
+ Assert.Equal(TimeSpan.FromSeconds(13), context.CachedResponseValidFor);
+ Assert.Empty(sink.Writes);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheHeadersAsync_UpdateCachedVaryByRules_IfNotEquivalentToPrevious()
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache);
+ var context = TestUtils.CreateTestContext();
+
+ context.HttpContext.Response.Headers[HeaderNames.Vary] = new StringValues(new[] { "headerA", "HEADERB", "HEADERc" });
+ context.HttpContext.Features.Set(new ResponseCachingFeature()
+ {
+ VaryByQueryKeys = new StringValues(new[] { "queryB", "QUERYA" })
+ });
+ var cachedVaryByRules = new CachedVaryByRules()
+ {
+ Headers = new StringValues(new[] { "HeaderA", "HeaderB" }),
+ QueryKeys = new StringValues(new[] { "QueryA", "QueryB" })
+ };
+ context.CachedVaryByRules = cachedVaryByRules;
+
+ await middleware.FinalizeCacheHeadersAsync(context);
+
+ Assert.Equal(1, cache.SetCount);
+ Assert.NotSame(cachedVaryByRules, context.CachedVaryByRules);
+ TestUtils.AssertLoggedMessages(
+ sink.Writes,
+ LoggedMessage.VaryByRulesUpdated);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheHeadersAsync_UpdateCachedVaryByRules_IfEquivalentToPrevious()
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache);
+ var context = TestUtils.CreateTestContext();
+
+ context.HttpContext.Response.Headers[HeaderNames.Vary] = new StringValues(new[] { "headerA", "HEADERB" });
+ context.HttpContext.Features.Set(new ResponseCachingFeature()
+ {
+ VaryByQueryKeys = new StringValues(new[] { "queryB", "QUERYA" })
+ });
+ var cachedVaryByRules = new CachedVaryByRules()
+ {
+ VaryByKeyPrefix = FastGuid.NewGuid().IdString,
+ Headers = new StringValues(new[] { "HEADERA", "HEADERB" }),
+ QueryKeys = new StringValues(new[] { "QUERYA", "QUERYB" })
+ };
+ context.CachedVaryByRules = cachedVaryByRules;
+
+ await middleware.FinalizeCacheHeadersAsync(context);
+
+ // An update to the cache is always made but the entry should be the same
+ Assert.Equal(1, cache.SetCount);
+ Assert.Same(cachedVaryByRules, context.CachedVaryByRules);
+ TestUtils.AssertLoggedMessages(
+ sink.Writes,
+ LoggedMessage.VaryByRulesUpdated);
+ }
+
+ 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 async Task FinalizeCacheHeadersAsync_UpdateCachedVaryByRules_NullOrEmptyRules(StringValues vary)
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache);
+ var context = TestUtils.CreateTestContext();
+
+ context.HttpContext.Response.Headers[HeaderNames.Vary] = vary;
+ context.HttpContext.Features.Set(new ResponseCachingFeature()
+ {
+ VaryByQueryKeys = vary
+ });
+
+ await middleware.FinalizeCacheHeadersAsync(context);
+
+ // Vary rules should not be updated
+ Assert.Equal(0, cache.SetCount);
+ Assert.Empty(sink.Writes);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheHeadersAsync_AddsDate_IfNoneSpecified()
+ {
+ var clock = new TestClock
+ {
+ UtcNow = DateTimeOffset.UtcNow
+ };
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, options: new ResponseCachingOptions
+ {
+ SystemClock = clock
+ });
+ var context = TestUtils.CreateTestContext();
+
+ Assert.True(StringValues.IsNullOrEmpty(context.HttpContext.Response.Headers[HeaderNames.Date]));
+
+ await middleware.FinalizeCacheHeadersAsync(context);
+
+ Assert.Equal(HeaderUtilities.FormatDate(clock.UtcNow), context.HttpContext.Response.Headers[HeaderNames.Date]);
+ Assert.Empty(sink.Writes);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheHeadersAsync_DoNotAddDate_IfSpecified()
+ {
+ var utcNow = DateTimeOffset.MinValue;
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink);
+ var context = TestUtils.CreateTestContext();
+
+ context.HttpContext.Response.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow);
+ context.ResponseTime = utcNow + TimeSpan.FromSeconds(10);
+
+ Assert.Equal(HeaderUtilities.FormatDate(utcNow), context.HttpContext.Response.Headers[HeaderNames.Date]);
+
+ await middleware.FinalizeCacheHeadersAsync(context);
+
+ Assert.Equal(HeaderUtilities.FormatDate(utcNow), context.HttpContext.Response.Headers[HeaderNames.Date]);
+ Assert.Empty(sink.Writes);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheHeadersAsync_StoresCachedResponse_InState()
+ {
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink);
+ var context = TestUtils.CreateTestContext();
+
+ Assert.Null(context.CachedResponse);
+
+ await middleware.FinalizeCacheHeadersAsync(context);
+
+ Assert.NotNull(context.CachedResponse);
+ Assert.Empty(sink.Writes);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheHeadersAsync_SplitsVaryHeaderByCommas()
+ {
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink);
+ var context = TestUtils.CreateTestContext();
+
+ context.HttpContext.Response.Headers[HeaderNames.Vary] = "HeaderB, heaDera";
+
+ await middleware.FinalizeCacheHeadersAsync(context);
+
+ Assert.Equal(new StringValues(new[] { "HEADERA", "HEADERB" }), context.CachedVaryByRules.Headers);
+ TestUtils.AssertLoggedMessages(
+ sink.Writes,
+ LoggedMessage.VaryByRulesUpdated);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheBody_Cache_IfContentLengthMatches()
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache);
+ var context = TestUtils.CreateTestContext();
+
+ context.ShouldCacheResponse = true;
+ middleware.ShimResponseStream(context);
+ context.HttpContext.Response.ContentLength = 20;
+
+ await context.HttpContext.Response.WriteAsync(new string('0', 20));
+
+ context.CachedResponse = new CachedResponse();
+ context.BaseKey = "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_IfContentLengthMismatches()
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache);
+ var context = TestUtils.CreateTestContext();
+
+ context.ShouldCacheResponse = true;
+ middleware.ShimResponseStream(context);
+ context.HttpContext.Response.ContentLength = 9;
+
+ await context.HttpContext.Response.WriteAsync(new string('0', 10));
+
+ context.CachedResponse = new CachedResponse();
+ context.BaseKey = "BaseKey";
+ context.CachedResponseValidFor = TimeSpan.FromSeconds(10);
+
+ await middleware.FinalizeCacheBodyAsync(context);
+
+ Assert.Equal(0, cache.SetCount);
+ TestUtils.AssertLoggedMessages(
+ sink.Writes,
+ LoggedMessage.ResponseContentLengthMismatchNotCached);
+ }
+
+ [Fact]
+ public async Task FinalizeCacheBody_Cache_IfContentLengthAbsent()
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache);
+ var context = TestUtils.CreateTestContext();
+
+ context.ShouldCacheResponse = true;
+ middleware.ShimResponseStream(context);
+
+ await context.HttpContext.Response.WriteAsync(new string('0', 10));
+
+ context.CachedResponse = new CachedResponse()
+ {
+ Headers = new HeaderDictionary()
+ };
+ context.BaseKey = "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_IfShouldCacheResponseFalse()
+ {
+ var cache = new TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache);
+ var context = TestUtils.CreateTestContext();
+
+ middleware.ShimResponseStream(context);
+ await context.HttpContext.Response.WriteAsync(new string('0', 10));
+ context.ShouldCacheResponse = false;
+
+ 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 TestResponseCache();
+ var sink = new TestSink();
+ var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache);
+ var context = TestUtils.CreateTestContext();
+
+ context.ShouldCacheResponse = true;
+ middleware.ShimResponseStream(context);
+ await context.HttpContext.Response.WriteAsync(new string('0', 10));
+
+ context.ResponseCachingStream.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 MemoryResponseCache(new MemoryCache(new MemoryCacheOptions
+ {
+ SizeLimit = 100
+ })));
+ var context = TestUtils.CreateTestContext();
+
+ context.ShouldCacheResponse = true;
+ middleware.ShimResponseStream(context);
+
+ await context.HttpContext.Response.WriteAsync(new string('0', 101));
+
+ context.CachedResponse = new CachedResponse() { Headers = new HeaderDictionary() };
+ 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));
+ }
+
+ [Fact]
+ public void AddResponseCachingFeature_SecondInvocation_Throws()
+ {
+ var httpContext = new DefaultHttpContext();
+
+ // Should not throw
+ ResponseCachingMiddleware.AddResponseCachingFeature(httpContext);
+
+ // Should throw
+ Assert.ThrowsAny(() => ResponseCachingMiddleware.AddResponseCachingFeature(httpContext));
+ }
+
+ private class FakeResponseFeature : HttpResponseFeature
+ {
+ public override void OnStarting(Func