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: [![AppVeyor](https://ci.appveyor.com/api/projects/status/p52yj0kghdyicvwu/branch/dev?svg=true)](https://ci.appveyor.com/project/aspnetci/ResponseCaching/branch/dev) + +Travis: [![Travis](https://travis-ci.org/aspnet/ResponseCaching.svg?branch=dev)](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 callback, object state) { } + } + + [Theory] + // If allowResponseCaching is false, other settings will not matter but are included for completeness + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + [InlineData(true, true, true)] + public async Task Invoke_AddsResponseCachingFeature_Always(bool allowResponseCaching, bool allowCacheLookup, bool allowCacheStorage) + { + var responseCachingFeatureAdded = false; + var middleware = TestUtils.CreateTestMiddleware(next: httpContext => + { + responseCachingFeatureAdded = httpContext.Features.Get() != null; + return Task.CompletedTask; + }, + policyProvider: new TestResponseCachingPolicyProvider + { + AttemptResponseCachingValue = allowResponseCaching, + AllowCacheLookupValue = allowCacheLookup, + AllowCacheStorageValue = allowCacheStorage + }); + + var context = new DefaultHttpContext(); + context.Features.Set(new FakeResponseFeature()); + await middleware.Invoke(context); + + Assert.True(responseCachingFeatureAdded); + } + + [Fact] + public void GetOrderCasingNormalizedStringValues_NormalizesCasingToUpper() + { + var uppercaseStrings = new StringValues(new[] { "STRINGA", "STRINGB" }); + var lowercaseStrings = new StringValues(new[] { "stringA", "stringB" }); + + var normalizedStrings = ResponseCachingMiddleware.GetOrderCasingNormalizedStringValues(lowercaseStrings); + + Assert.Equal(uppercaseStrings, normalizedStrings); + } + + [Fact] + public void GetOrderCasingNormalizedStringValues_NormalizesOrder() + { + var orderedStrings = new StringValues(new[] { "STRINGA", "STRINGB" }); + var reverseOrderStrings = new StringValues(new[] { "STRINGB", "STRINGA" }); + + var normalizedStrings = ResponseCachingMiddleware.GetOrderCasingNormalizedStringValues(reverseOrderStrings); + + Assert.Equal(orderedStrings, normalizedStrings); + } + + [Fact] + public void GetOrderCasingNormalizedStringValues_PreservesCommas() + { + var originalStrings = new StringValues(new[] { "STRINGA, STRINGB" }); + + var normalizedStrings = ResponseCachingMiddleware.GetOrderCasingNormalizedStringValues(originalStrings); + + Assert.Equal(originalStrings, normalizedStrings); + } + } +} diff --git a/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingPolicyProviderTests.cs b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingPolicyProviderTests.cs new file mode 100644 index 0000000000..4f1307b4bc --- /dev/null +++ b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingPolicyProviderTests.cs @@ -0,0 +1,794 @@ +// 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 Microsoft.Extensions.Logging.Testing; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.ResponseCaching.Tests +{ + public class ResponseCachingPolicyProviderTests + { + public static TheoryData CacheableMethods + { + get + { + return new TheoryData + { + HttpMethods.Get, + HttpMethods.Head + }; + } + } + + [Theory] + [MemberData(nameof(CacheableMethods))] + public void AttemptResponseCaching_CacheableMethods_Allowed(string method) + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Method = method; + + Assert.True(new ResponseCachingPolicyProvider().AttemptResponseCaching(context)); + Assert.Empty(sink.Writes); + } + public static TheoryData NonCacheableMethods + { + get + { + return new TheoryData + { + HttpMethods.Post, + HttpMethods.Put, + HttpMethods.Delete, + HttpMethods.Trace, + HttpMethods.Connect, + HttpMethods.Options, + "", + null + }; + } + } + + [Theory] + [MemberData(nameof(NonCacheableMethods))] + public void AttemptResponseCaching_UncacheableMethods_NotAllowed(string method) + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Method = method; + + Assert.False(new ResponseCachingPolicyProvider().AttemptResponseCaching(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.RequestMethodNotCacheable); + } + + [Fact] + public void AttemptResponseCaching_AuthorizationHeaders_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Request.Headers[HeaderNames.Authorization] = "Basic plaintextUN:plaintextPW"; + + Assert.False(new ResponseCachingPolicyProvider().AttemptResponseCaching(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.RequestWithAuthorizationNotCacheable); + } + + [Fact] + public void AllowCacheStorage_NoStore_Allowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + NoStore = true + }.ToString(); + + Assert.True(new ResponseCachingPolicyProvider().AllowCacheLookup(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void AllowCacheLookup_NoCache_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + NoCache = true + }.ToString(); + + Assert.False(new ResponseCachingPolicyProvider().AllowCacheLookup(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.RequestWithNoCacheNotCacheable); + } + + [Fact] + public void AllowCacheLookup_LegacyDirectives_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Request.Headers[HeaderNames.Pragma] = "no-cache"; + + Assert.False(new ResponseCachingPolicyProvider().AllowCacheLookup(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.RequestWithPragmaNoCacheNotCacheable); + } + + [Fact] + public void AllowCacheLookup_LegacyDirectives_OverridenByCacheControl() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Request.Headers[HeaderNames.Pragma] = "no-cache"; + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = "max-age=10"; + + Assert.True(new ResponseCachingPolicyProvider().AllowCacheLookup(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void AllowCacheStorage_NoStore_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Method = HttpMethods.Get; + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + NoStore = true + }.ToString(); + + Assert.False(new ResponseCachingPolicyProvider().AllowCacheStorage(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void IsResponseCacheable_NoPublic_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + + Assert.False(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseWithoutPublicNotCacheable); + } + + [Fact] + public void IsResponseCacheable_Public_Allowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true + }.ToString(); + + Assert.True(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void IsResponseCacheable_NoCache_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true, + NoCache = true + }.ToString(); + + Assert.False(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseWithNoCacheNotCacheable); + } + + [Fact] + public void IsResponseCacheable_ResponseNoStore_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true, + NoStore = true + }.ToString(); + + Assert.False(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseWithNoStoreNotCacheable); + } + + [Fact] + public void IsResponseCacheable_SetCookieHeader_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true + }.ToString(); + context.HttpContext.Response.Headers[HeaderNames.SetCookie] = "cookieName=cookieValue"; + + Assert.False(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseWithSetCookieNotCacheable); + } + + [Fact] + public void IsResponseCacheable_VaryHeaderByStar_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true + }.ToString(); + context.HttpContext.Response.Headers[HeaderNames.Vary] = "*"; + + Assert.False(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseWithVaryStarNotCacheable); + } + + [Fact] + public void IsResponseCacheable_Private_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true, + Private = true + }.ToString(); + + Assert.False(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseWithPrivateNotCacheable); + } + + [Theory] + [InlineData(StatusCodes.Status200OK)] + public void IsResponseCacheable_SuccessStatusCodes_Allowed(int statusCode) + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.StatusCode = statusCode; + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true + }.ToString(); + + Assert.True(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + Assert.Empty(sink.Writes); + } + + [Theory] + [InlineData(StatusCodes.Status100Continue)] + [InlineData(StatusCodes.Status101SwitchingProtocols)] + [InlineData(StatusCodes.Status102Processing)] + [InlineData(StatusCodes.Status201Created)] + [InlineData(StatusCodes.Status202Accepted)] + [InlineData(StatusCodes.Status203NonAuthoritative)] + [InlineData(StatusCodes.Status204NoContent)] + [InlineData(StatusCodes.Status205ResetContent)] + [InlineData(StatusCodes.Status206PartialContent)] + [InlineData(StatusCodes.Status207MultiStatus)] + [InlineData(StatusCodes.Status208AlreadyReported)] + [InlineData(StatusCodes.Status226IMUsed)] + [InlineData(StatusCodes.Status300MultipleChoices)] + [InlineData(StatusCodes.Status301MovedPermanently)] + [InlineData(StatusCodes.Status302Found)] + [InlineData(StatusCodes.Status303SeeOther)] + [InlineData(StatusCodes.Status304NotModified)] + [InlineData(StatusCodes.Status305UseProxy)] + [InlineData(StatusCodes.Status306SwitchProxy)] + [InlineData(StatusCodes.Status307TemporaryRedirect)] + [InlineData(StatusCodes.Status308PermanentRedirect)] + [InlineData(StatusCodes.Status400BadRequest)] + [InlineData(StatusCodes.Status401Unauthorized)] + [InlineData(StatusCodes.Status402PaymentRequired)] + [InlineData(StatusCodes.Status403Forbidden)] + [InlineData(StatusCodes.Status404NotFound)] + [InlineData(StatusCodes.Status405MethodNotAllowed)] + [InlineData(StatusCodes.Status406NotAcceptable)] + [InlineData(StatusCodes.Status407ProxyAuthenticationRequired)] + [InlineData(StatusCodes.Status408RequestTimeout)] + [InlineData(StatusCodes.Status409Conflict)] + [InlineData(StatusCodes.Status410Gone)] + [InlineData(StatusCodes.Status411LengthRequired)] + [InlineData(StatusCodes.Status412PreconditionFailed)] + [InlineData(StatusCodes.Status413RequestEntityTooLarge)] + [InlineData(StatusCodes.Status414RequestUriTooLong)] + [InlineData(StatusCodes.Status415UnsupportedMediaType)] + [InlineData(StatusCodes.Status416RequestedRangeNotSatisfiable)] + [InlineData(StatusCodes.Status417ExpectationFailed)] + [InlineData(StatusCodes.Status418ImATeapot)] + [InlineData(StatusCodes.Status419AuthenticationTimeout)] + [InlineData(StatusCodes.Status421MisdirectedRequest)] + [InlineData(StatusCodes.Status422UnprocessableEntity)] + [InlineData(StatusCodes.Status423Locked)] + [InlineData(StatusCodes.Status424FailedDependency)] + [InlineData(StatusCodes.Status426UpgradeRequired)] + [InlineData(StatusCodes.Status428PreconditionRequired)] + [InlineData(StatusCodes.Status429TooManyRequests)] + [InlineData(StatusCodes.Status431RequestHeaderFieldsTooLarge)] + [InlineData(StatusCodes.Status451UnavailableForLegalReasons)] + [InlineData(StatusCodes.Status500InternalServerError)] + [InlineData(StatusCodes.Status501NotImplemented)] + [InlineData(StatusCodes.Status502BadGateway)] + [InlineData(StatusCodes.Status503ServiceUnavailable)] + [InlineData(StatusCodes.Status504GatewayTimeout)] + [InlineData(StatusCodes.Status505HttpVersionNotsupported)] + [InlineData(StatusCodes.Status506VariantAlsoNegotiates)] + [InlineData(StatusCodes.Status507InsufficientStorage)] + [InlineData(StatusCodes.Status508LoopDetected)] + [InlineData(StatusCodes.Status510NotExtended)] + [InlineData(StatusCodes.Status511NetworkAuthenticationRequired)] + public void IsResponseCacheable_NonSuccessStatusCodes_NotAllowed(int statusCode) + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.StatusCode = statusCode; + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true + }.ToString(); + + Assert.False(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ResponseWithUnsuccessfulStatusCodeNotCacheable); + } + + [Fact] + public void IsResponseCacheable_NoExpiryRequirements_IsAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true + }.ToString(); + + var utcNow = DateTimeOffset.UtcNow; + context.HttpContext.Response.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow); + context.ResponseTime = DateTimeOffset.MaxValue; + + Assert.True(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void IsResponseCacheable_AtExpiry_NotAllowed() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true + }.ToString(); + var utcNow = DateTimeOffset.UtcNow; + context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(utcNow); + + context.HttpContext.Response.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow); + context.ResponseTime = utcNow; + + Assert.False(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationExpiresExceeded); + } + + [Fact] + public void IsResponseCacheable_MaxAgeOverridesExpiry_ToAllowed() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }.ToString(); + context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(utcNow); + context.HttpContext.Response.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow); + context.ResponseTime = utcNow + TimeSpan.FromSeconds(9); + + Assert.True(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void IsResponseCacheable_MaxAgeOverridesExpiry_ToNotAllowed() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }.ToString(); + context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(utcNow); + context.HttpContext.Response.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow); + context.ResponseTime = utcNow + TimeSpan.FromSeconds(10); + + Assert.False(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationMaxAgeExceeded); + } + + [Fact] + public void IsResponseCacheable_SharedMaxAgeOverridesMaxAge_ToAllowed() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10), + SharedMaxAge = TimeSpan.FromSeconds(15) + }.ToString(); + context.HttpContext.Response.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow); + context.ResponseTime = utcNow + TimeSpan.FromSeconds(11); + + Assert.True(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void IsResponseCacheable_SharedMaxAgeOverridesMaxAge_ToNotAllowed() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + context.HttpContext.Response.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10), + SharedMaxAge = TimeSpan.FromSeconds(5) + }.ToString(); + context.HttpContext.Response.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow); + context.ResponseTime = utcNow + TimeSpan.FromSeconds(5); + + Assert.False(new ResponseCachingPolicyProvider().IsResponseCacheable(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationSharedMaxAgeExceeded); + } + + [Fact] + public void IsCachedEntryFresh_NoCachedCacheControl_FallsbackToEmptyCacheControl() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.ResponseTime = DateTimeOffset.MaxValue; + context.CachedEntryAge = TimeSpan.MaxValue; + context.CachedResponseHeaders = new HeaderDictionary(); + + Assert.True(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void IsCachedEntryFresh_NoExpiryRequirements_IsFresh() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.ResponseTime = DateTimeOffset.MaxValue; + context.CachedEntryAge = TimeSpan.MaxValue; + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true + }.ToString(); + + Assert.True(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void IsCachedEntryFresh_AtExpiry_IsNotFresh() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.ResponseTime = utcNow; + context.CachedEntryAge = TimeSpan.Zero; + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true + }.ToString(); + context.CachedResponseHeaders[HeaderNames.Expires] = HeaderUtilities.FormatDate(utcNow); + + Assert.False(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationExpiresExceeded); + } + + [Fact] + public void IsCachedEntryFresh_MaxAgeOverridesExpiry_ToFresh() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.CachedEntryAge = TimeSpan.FromSeconds(9); + context.ResponseTime = utcNow + context.CachedEntryAge; + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }.ToString(); + context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(utcNow); + + Assert.True(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void IsCachedEntryFresh_MaxAgeOverridesExpiry_ToNotFresh() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.CachedEntryAge = TimeSpan.FromSeconds(10); + context.ResponseTime = utcNow + context.CachedEntryAge; + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }.ToString(); + context.HttpContext.Response.Headers[HeaderNames.Expires] = HeaderUtilities.FormatDate(utcNow); + + Assert.False(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationMaxAgeExceeded); + } + + [Fact] + public void IsCachedEntryFresh_SharedMaxAgeOverridesMaxAge_ToFresh() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.CachedEntryAge = TimeSpan.FromSeconds(11); + context.ResponseTime = utcNow + context.CachedEntryAge; + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10), + SharedMaxAge = TimeSpan.FromSeconds(15) + }.ToString(); + context.CachedResponseHeaders[HeaderNames.Expires] = HeaderUtilities.FormatDate(utcNow); + + Assert.True(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + Assert.Empty(sink.Writes); + } + + [Fact] + public void IsCachedEntryFresh_SharedMaxAgeOverridesMaxAge_ToNotFresh() + { + var utcNow = DateTimeOffset.UtcNow; + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.CachedEntryAge = TimeSpan.FromSeconds(5); + context.ResponseTime = utcNow + context.CachedEntryAge; + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10), + SharedMaxAge = TimeSpan.FromSeconds(5) + }.ToString(); + context.CachedResponseHeaders[HeaderNames.Expires] = HeaderUtilities.FormatDate(utcNow); + + Assert.False(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationSharedMaxAgeExceeded); + } + + [Fact] + public void IsCachedEntryFresh_MinFreshReducesFreshness_ToNotFresh() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MinFresh = TimeSpan.FromSeconds(2) + }.ToString(); + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(10), + SharedMaxAge = TimeSpan.FromSeconds(5) + }.ToString(); + context.CachedEntryAge = TimeSpan.FromSeconds(3); + + Assert.False(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationMinFreshAdded, + LoggedMessage.ExpirationSharedMaxAgeExceeded); + } + + [Fact] + public void IsCachedEntryFresh_RequestMaxAgeRestrictAge_ToNotFresh() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5) + }.ToString(); + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(10), + }.ToString(); + context.CachedEntryAge = TimeSpan.FromSeconds(5); + + Assert.False(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationMaxAgeExceeded); + } + + [Fact] + public void IsCachedEntryFresh_MaxStaleOverridesFreshness_ToFresh() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + MaxStale = true, // This value must be set to true in order to specify MaxStaleLimit + MaxStaleLimit = TimeSpan.FromSeconds(2) + }.ToString(); + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + }.ToString(); + context.CachedEntryAge = TimeSpan.FromSeconds(6); + + Assert.True(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationMaxStaleSatisfied); + } + + [Fact] + public void IsCachedEntryFresh_MaxStaleInfiniteOverridesFreshness_ToFresh() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + MaxStale = true // No value specified means a MaxStaleLimit of infinity + }.ToString(); + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + }.ToString(); + context.CachedEntryAge = TimeSpan.FromSeconds(6); + + Assert.True(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationInfiniteMaxStaleSatisfied); + } + + [Fact] + public void IsCachedEntryFresh_MaxStaleOverridesFreshness_ButStillNotFresh() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + MaxStale = true, // This value must be set to true in order to specify MaxStaleLimit + MaxStaleLimit = TimeSpan.FromSeconds(1) + }.ToString(); + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + }.ToString(); + context.CachedEntryAge = TimeSpan.FromSeconds(6); + + Assert.False(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationMaxAgeExceeded); + } + + [Fact] + public void IsCachedEntryFresh_MustRevalidateOverridesRequestMaxStale_ToNotFresh() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + MaxStale = true, // This value must be set to true in order to specify MaxStaleLimit + MaxStaleLimit = TimeSpan.FromSeconds(2) + }.ToString(); + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + MustRevalidate = true + }.ToString(); + context.CachedEntryAge = TimeSpan.FromSeconds(6); + + Assert.False(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationMustRevalidate); + } + + [Fact] + public void IsCachedEntryFresh_ProxyRevalidateOverridesRequestMaxStale_ToNotFresh() + { + var sink = new TestSink(); + var context = TestUtils.CreateTestContext(sink); + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + MaxStale = true, // This value must be set to true in order to specify MaxStaleLimit + MaxStaleLimit = TimeSpan.FromSeconds(2) + }.ToString(); + context.CachedResponseHeaders = new HeaderDictionary(); + context.CachedResponseHeaders[HeaderNames.CacheControl] = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + MustRevalidate = true + }.ToString(); + context.CachedEntryAge = TimeSpan.FromSeconds(6); + + Assert.False(new ResponseCachingPolicyProvider().IsCachedEntryFresh(context)); + TestUtils.AssertLoggedMessages( + sink.Writes, + LoggedMessage.ExpirationMustRevalidate); + } + } +} diff --git a/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs new file mode 100644 index 0000000000..25fc1360e8 --- /dev/null +++ b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs @@ -0,0 +1,849 @@ +// 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.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.ResponseCaching.Tests +{ + public class ResponseCachingTests + { + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async void ServesCachedContent_IfAvailable(string method) + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async void ServesFreshContent_IfNotAvailable(string method) + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "different")); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_Post() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.PostAsync("", new StringContent(string.Empty)); + var subsequentResponse = await client.PostAsync("", new StringContent(string.Empty)); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_Head_Get() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var subsequentResponse = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, "")); + var initialResponse = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "")); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_Get_Head() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "")); + var subsequentResponse = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, "")); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async void ServesFreshContent_If_CacheControlNoCache(string method) + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + // verify the response is cached + var cachedResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + await AssertCachedResponseAsync(initialResponse, cachedResponse); + + // assert cached response no longer served + client.DefaultRequestHeaders.CacheControl = + new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true }; + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async void ServesFreshContent_If_PragmaNoCache(string method) + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + // verify the response is cached + var cachedResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + await AssertCachedResponseAsync(initialResponse, cachedResponse); + + // assert cached response no longer served + client.DefaultRequestHeaders.Pragma.Clear(); + client.DefaultRequestHeaders.Pragma.Add(new System.Net.Http.Headers.NameValueHeaderValue("no-cache")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async void ServesCachedContent_If_PathCasingDiffers(string method) + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "path")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "PATH")); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async void ServesFreshContent_If_ResponseExpired(string method) + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "?Expires=0")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public async void ServesFreshContent_If_Authorization_HeaderExists(string method) + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("abc"); + var initialResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + var subsequentResponse = await client.SendAsync(TestUtils.CreateRequest(method, "")); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesCachedContent_IfVaryHeader_Matches() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Response.Headers[HeaderNames.Vary] = HeaderNames.From); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + client.DefaultRequestHeaders.From = "user@example.com"; + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_IfVaryHeader_Mismatches() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Response.Headers[HeaderNames.Vary] = HeaderNames.From); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + client.DefaultRequestHeaders.From = "user@example.com"; + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user2@example.com"; + var subsequentResponse = await client.GetAsync(""); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesCachedContent_IfVaryQueryKeys_Matches() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Features.Get().VaryByQueryKeys = new[] { "query" }); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?query=value"); + var subsequentResponse = await client.GetAsync("?query=value"); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesCachedContent_IfVaryQueryKeysExplicit_Matches_QueryKeyCaseInsensitive() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Features.Get().VaryByQueryKeys = new[] { "QueryA", "queryb" }); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?querya=valuea&queryb=valueb"); + var subsequentResponse = await client.GetAsync("?QueryA=valuea&QueryB=valueb"); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesCachedContent_IfVaryQueryKeyStar_Matches_QueryKeyCaseInsensitive() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Features.Get().VaryByQueryKeys = new[] { "*" }); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?querya=valuea&queryb=valueb"); + var subsequentResponse = await client.GetAsync("?QueryA=valuea&QueryB=valueb"); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesCachedContent_IfVaryQueryKeyExplicit_Matches_OrderInsensitive() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Features.Get().VaryByQueryKeys = new[] { "QueryB", "QueryA" }); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?QueryA=ValueA&QueryB=ValueB"); + var subsequentResponse = await client.GetAsync("?QueryB=ValueB&QueryA=ValueA"); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesCachedContent_IfVaryQueryKeyStar_Matches_OrderInsensitive() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Features.Get().VaryByQueryKeys = new[] { "*" }); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?QueryA=ValueA&QueryB=ValueB"); + var subsequentResponse = await client.GetAsync("?QueryB=ValueB&QueryA=ValueA"); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_IfVaryQueryKey_Mismatches() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Features.Get().VaryByQueryKeys = new[] { "query" }); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?query=value"); + var subsequentResponse = await client.GetAsync("?query=value2"); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_IfVaryQueryKeyExplicit_Mismatch_QueryKeyCaseSensitive() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Features.Get().VaryByQueryKeys = new[] { "QueryA", "QueryB" }); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?querya=valuea&queryb=valueb"); + var subsequentResponse = await client.GetAsync("?querya=ValueA&queryb=ValueB"); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_IfVaryQueryKeyStar_Mismatch_QueryKeyValueCaseSensitive() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Features.Get().VaryByQueryKeys = new[] { "*" }); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?querya=valuea&queryb=valueb"); + var subsequentResponse = await client.GetAsync("?querya=ValueA&queryb=ValueB"); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_IfRequestRequirements_NotMet() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(0) + }; + var subsequentResponse = await client.GetAsync(""); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void Serves504_IfOnlyIfCachedHeader_IsSpecified() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue() + { + OnlyIfCached = true + }; + var subsequentResponse = await client.GetAsync("/different"); + + initialResponse.EnsureSuccessStatusCode(); + Assert.Equal(System.Net.HttpStatusCode.GatewayTimeout, subsequentResponse.StatusCode); + } + } + } + + [Fact] + public async void ServesFreshContent_IfSetCookie_IsSpecified() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Response.Headers[HeaderNames.SetCookie] = "cookieName=cookieValue"); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync(""); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesCachedContent_IfIHttpSendFileFeature_NotUsed() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(app => + { + app.Use(async (context, next) => + { + context.Features.Set(new DummySendFileFeature()); + await next.Invoke(); + }); + }); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_IfIHttpSendFileFeature_Used() + { + var builders = TestUtils.CreateBuildersWithResponseCaching( + app => + { + app.Use(async (context, next) => + { + context.Features.Set(new DummySendFileFeature()); + await next.Invoke(); + }); + }, + contextAction: async context => await context.Features.Get().SendFileAsync("dummy", 0, 0, CancellationToken.None)); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync(""); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesCachedContent_IfSubsequentRequestContainsNoStore() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue() + { + NoStore = true + }; + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_IfInitialRequestContainsNoStore() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue() + { + NoStore = true + }; + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync(""); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_IfInitialResponseContainsNoStore() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Response.Headers[HeaderNames.CacheControl] = CacheControlHeaderValue.NoStoreString); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync(""); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void Serves304_IfIfModifiedSince_Satisfied() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.IfModifiedSince = DateTimeOffset.MaxValue; + var subsequentResponse = await client.GetAsync(""); + + initialResponse.EnsureSuccessStatusCode(); + Assert.Equal(System.Net.HttpStatusCode.NotModified, subsequentResponse.StatusCode); + } + } + } + + [Fact] + public async void ServesCachedContent_IfIfModifiedSince_NotSatisfied() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.IfModifiedSince = DateTimeOffset.MinValue; + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void Serves304_IfIfNoneMatch_Satisfied() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Response.GetTypedHeaders().ETag = new EntityTagHeaderValue("\"E1\"")); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.IfNoneMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue("\"E1\"")); + var subsequentResponse = await client.GetAsync(""); + + initialResponse.EnsureSuccessStatusCode(); + Assert.Equal(System.Net.HttpStatusCode.NotModified, subsequentResponse.StatusCode); + } + } + } + + [Fact] + public async void ServesCachedContent_IfIfNoneMatch_NotSatisfied() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Response.GetTypedHeaders().ETag = new EntityTagHeaderValue("\"E1\"")); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.IfNoneMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue("\"E2\"")); + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesCachedContent_IfBodySize_IsCacheable() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(options: new ResponseCachingOptions() + { + MaximumBodySize = 100 + }); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_IfBodySize_IsNotCacheable() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(options: new ResponseCachingOptions() + { + MaximumBodySize = 1 + }); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + var subsequentResponse = await client.GetAsync("/different"); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_CaseSensitivePaths_IsNotCacheable() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(options: new ResponseCachingOptions() + { + UseCaseSensitivePaths = true + }); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("/path"); + var subsequentResponse = await client.GetAsync("/Path"); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesCachedContent_WithoutReplacingCachedVaryBy_OnCacheMiss() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Response.Headers[HeaderNames.Vary] = HeaderNames.From); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + client.DefaultRequestHeaders.From = "user@example.com"; + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user2@example.com"; + var otherResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user@example.com"; + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesFreshContent_IfCachedVaryByUpdated_OnCacheMiss() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Response.Headers[HeaderNames.Vary] = context.Request.Headers[HeaderNames.Pragma]); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + client.DefaultRequestHeaders.From = "user@example.com"; + client.DefaultRequestHeaders.Pragma.Clear(); + client.DefaultRequestHeaders.Pragma.Add(new System.Net.Http.Headers.NameValueHeaderValue("From")); + client.DefaultRequestHeaders.MaxForwards = 1; + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user2@example.com"; + client.DefaultRequestHeaders.Pragma.Clear(); + client.DefaultRequestHeaders.Pragma.Add(new System.Net.Http.Headers.NameValueHeaderValue("Max-Forwards")); + client.DefaultRequestHeaders.MaxForwards = 2; + var otherResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user@example.com"; + client.DefaultRequestHeaders.Pragma.Clear(); + client.DefaultRequestHeaders.Pragma.Add(new System.Net.Http.Headers.NameValueHeaderValue("From")); + client.DefaultRequestHeaders.MaxForwards = 1; + var subsequentResponse = await client.GetAsync(""); + + await AssertFreshResponseAsync(initialResponse, subsequentResponse); + } + } + } + + [Fact] + public async void ServesCachedContent_IfCachedVaryByNotUpdated_OnCacheMiss() + { + var builders = TestUtils.CreateBuildersWithResponseCaching(contextAction: context => context.Response.Headers[HeaderNames.Vary] = context.Request.Headers[HeaderNames.Pragma]); + + foreach (var builder in builders) + { + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + client.DefaultRequestHeaders.From = "user@example.com"; + client.DefaultRequestHeaders.Pragma.Clear(); + client.DefaultRequestHeaders.Pragma.Add(new System.Net.Http.Headers.NameValueHeaderValue("From")); + client.DefaultRequestHeaders.MaxForwards = 1; + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user2@example.com"; + client.DefaultRequestHeaders.Pragma.Clear(); + client.DefaultRequestHeaders.Pragma.Add(new System.Net.Http.Headers.NameValueHeaderValue("From")); + client.DefaultRequestHeaders.MaxForwards = 2; + var otherResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user@example.com"; + client.DefaultRequestHeaders.Pragma.Clear(); + client.DefaultRequestHeaders.Pragma.Add(new System.Net.Http.Headers.NameValueHeaderValue("From")); + client.DefaultRequestHeaders.MaxForwards = 1; + var subsequentResponse = await client.GetAsync(""); + + await AssertCachedResponseAsync(initialResponse, subsequentResponse); + } + } + } + + private static async Task AssertCachedResponseAsync(HttpResponseMessage initialResponse, HttpResponseMessage subsequentResponse) + { + initialResponse.EnsureSuccessStatusCode(); + subsequentResponse.EnsureSuccessStatusCode(); + + foreach (var header in initialResponse.Headers) + { + Assert.Equal(initialResponse.Headers.GetValues(header.Key), subsequentResponse.Headers.GetValues(header.Key)); + } + Assert.True(subsequentResponse.Headers.Contains(HeaderNames.Age)); + Assert.Equal(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + } + + private static async Task AssertFreshResponseAsync(HttpResponseMessage initialResponse, HttpResponseMessage subsequentResponse) + { + initialResponse.EnsureSuccessStatusCode(); + subsequentResponse.EnsureSuccessStatusCode(); + + Assert.False(subsequentResponse.Headers.Contains(HeaderNames.Age)); + + if (initialResponse.RequestMessage.Method == HttpMethod.Head && + subsequentResponse.RequestMessage.Method == HttpMethod.Head) + { + Assert.True(initialResponse.Headers.Contains("X-Value")); + Assert.NotEqual(initialResponse.Headers.GetValues("X-Value"), subsequentResponse.Headers.GetValues("X-Value")); + } + else + { + Assert.NotEqual(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + } + } + } +} diff --git a/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/SegmentReadStreamTests.cs b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/SegmentReadStreamTests.cs new file mode 100644 index 0000000000..5247df3096 --- /dev/null +++ b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/SegmentReadStreamTests.cs @@ -0,0 +1,285 @@ +// 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.Linq; +using Microsoft.AspNetCore.ResponseCaching.Internal; +using Xunit; + +namespace Microsoft.AspNetCore.ResponseCaching.Tests +{ + public class SegmentReadStreamTests + { + public class TestStreamInitInfo + { + internal List Segments { get; set; } + internal int SegmentSize { get; set; } + internal long Length { get; set; } + } + + public static TheoryData TestStreams + { + get + { + return new TheoryData + { + // Partial Segment + new TestStreamInitInfo() + { + Segments = new List(new[] + { + new byte[] { 0, 1, 2, 3, 4 }, + new byte[] { 5, 6, 7, 8, 9 }, + new byte[] { 10, 11, 12 }, + }), + SegmentSize = 5, + Length = 13 + }, + // Full Segments + new TestStreamInitInfo() + { + Segments = new List(new[] + { + new byte[] { 0, 1, 2, 3, 4 }, + new byte[] { 5, 6, 7, 8, 9 }, + new byte[] { 10, 11, 12, 13, 14 }, + }), + SegmentSize = 5, + Length = 15 + } + }; + } + } + + [Fact] + public void SegmentReadStream_NullSegments_Throws() + { + Assert.Throws(() => new SegmentReadStream(null, 0)); + } + + [Fact] + public void Position_ResetToZero_Succeeds() + { + var stream = new SegmentReadStream(new List(), 0); + + // This should not throw + stream.Position = 0; + } + + [Theory] + [InlineData(1)] + [InlineData(-1)] + [InlineData(100)] + [InlineData(long.MaxValue)] + [InlineData(long.MinValue)] + public void Position_SetToNonZero_Throws(long position) + { + var stream = new SegmentReadStream(new List(new[] { new byte[100] }), 100); + + Assert.Throws(() => stream.Position = position); + } + + [Fact] + public void WriteOperations_Throws() + { + var stream = new SegmentReadStream(new List(), 0); + + + Assert.Throws(() => stream.Flush()); + Assert.Throws(() => stream.Write(new byte[1], 0, 0)); + } + + [Fact] + public void SetLength_Throws() + { + var stream = new SegmentReadStream(new List(), 0); + + Assert.Throws(() => stream.SetLength(0)); + } + + [Theory] + [InlineData(SeekOrigin.Current)] + [InlineData(SeekOrigin.End)] + public void Seek_NotBegin_Throws(SeekOrigin origin) + { + var stream = new SegmentReadStream(new List(), 0); + + Assert.Throws(() => stream.Seek(0, origin)); + } + + [Theory] + [InlineData(1)] + [InlineData(-1)] + [InlineData(100)] + [InlineData(long.MaxValue)] + [InlineData(long.MinValue)] + public void Seek_NotZero_Throws(long offset) + { + var stream = new SegmentReadStream(new List(), 0); + + Assert.Throws(() => stream.Seek(offset, SeekOrigin.Begin)); + } + + [Theory] + [MemberData(nameof(TestStreams))] + public void ReadByte_CanReadAllBytes(TestStreamInitInfo info) + { + var stream = new SegmentReadStream(info.Segments, info.Length); + + for (var i = 0; i < stream.Length; i++) + { + Assert.Equal(i, stream.Position); + Assert.Equal(i, stream.ReadByte()); + } + Assert.Equal(stream.Length, stream.Position); + Assert.Equal(-1, stream.ReadByte()); + Assert.Equal(stream.Length, stream.Position); + } + + [Theory] + [MemberData(nameof(TestStreams))] + public void Read_CountLessThanSegmentSize_CanReadAllBytes(TestStreamInitInfo info) + { + var stream = new SegmentReadStream(info.Segments, info.Length); + var count = info.SegmentSize - 1; + + for (var i = 0; i < stream.Length; i+=count) + { + var output = new byte[count]; + var expectedOutput = new byte[count]; + var expectedBytesRead = Math.Min(count, stream.Length - i); + for (var j = 0; j < expectedBytesRead; j++) + { + expectedOutput[j] = (byte)(i + j); + } + Assert.Equal(i, stream.Position); + Assert.Equal(expectedBytesRead, stream.Read(output, 0, count)); + Assert.True(expectedOutput.SequenceEqual(output)); + } + Assert.Equal(stream.Length, stream.Position); + Assert.Equal(0, stream.Read(new byte[count], 0, count)); + Assert.Equal(stream.Length, stream.Position); + } + + [Theory] + [MemberData(nameof(TestStreams))] + public void Read_CountEqualSegmentSize_CanReadAllBytes(TestStreamInitInfo info) + { + var stream = new SegmentReadStream(info.Segments, info.Length); + var count = info.SegmentSize; + + for (var i = 0; i < stream.Length; i += count) + { + var output = new byte[count]; + var expectedOutput = new byte[count]; + var expectedBytesRead = Math.Min(count, stream.Length - i); + for (var j = 0; j < expectedBytesRead; j++) + { + expectedOutput[j] = (byte)(i + j); + } + Assert.Equal(i, stream.Position); + Assert.Equal(expectedBytesRead, stream.Read(output, 0, count)); + Assert.True(expectedOutput.SequenceEqual(output)); + } + Assert.Equal(stream.Length, stream.Position); + Assert.Equal(0, stream.Read(new byte[count], 0, count)); + Assert.Equal(stream.Length, stream.Position); + } + + [Theory] + [MemberData(nameof(TestStreams))] + public void Read_CountGreaterThanSegmentSize_CanReadAllBytes(TestStreamInitInfo info) + { + var stream = new SegmentReadStream(info.Segments, info.Length); + var count = info.SegmentSize + 1; + + for (var i = 0; i < stream.Length; i += count) + { + var output = new byte[count]; + var expectedOutput = new byte[count]; + var expectedBytesRead = Math.Min(count, stream.Length - i); + for (var j = 0; j < expectedBytesRead; j++) + { + expectedOutput[j] = (byte)(i + j); + } + Assert.Equal(i, stream.Position); + Assert.Equal(expectedBytesRead, stream.Read(output, 0, count)); + Assert.True(expectedOutput.SequenceEqual(output)); + } + Assert.Equal(stream.Length, stream.Position); + Assert.Equal(0, stream.Read(new byte[count], 0, count)); + Assert.Equal(stream.Length, stream.Position); + } + + [Theory] + [MemberData(nameof(TestStreams))] + public void CopyToAsync_CopiesAllBytes(TestStreamInitInfo info) + { + var stream = new SegmentReadStream(info.Segments, info.Length); + var writeStream = new SegmentWriteStream(info.SegmentSize); + + stream.CopyTo(writeStream); + + Assert.Equal(stream.Length, stream.Position); + Assert.Equal(stream.Length, writeStream.Length); + var writeSegments = writeStream.GetSegments(); + for (var i = 0; i < info.Segments.Count; i++) + { + Assert.True(writeSegments[i].SequenceEqual(info.Segments[i])); + } + } + + [Theory] + [MemberData(nameof(TestStreams))] + public void CopyToAsync_CopiesFromCurrentPosition(TestStreamInitInfo info) + { + var skippedBytes = info.SegmentSize; + var writeStream = new SegmentWriteStream((int)info.Length); + var stream = new SegmentReadStream(info.Segments, info.Length); + stream.Read(new byte[skippedBytes], 0, skippedBytes); + + stream.CopyTo(writeStream); + + Assert.Equal(stream.Length, stream.Position); + Assert.Equal(stream.Length - skippedBytes, writeStream.Length); + var writeSegments = writeStream.GetSegments(); + + for (var i = skippedBytes; i < info.Length; i++) + { + Assert.Equal(info.Segments[i / info.SegmentSize][i % info.SegmentSize], writeSegments[0][i - skippedBytes]); + } + } + + [Theory] + [MemberData(nameof(TestStreams))] + public void CopyToAsync_CopiesFromStart_AfterReset(TestStreamInitInfo info) + { + var skippedBytes = info.SegmentSize; + var writeStream = new SegmentWriteStream(info.SegmentSize); + var stream = new SegmentReadStream(info.Segments, info.Length); + stream.Read(new byte[skippedBytes], 0, skippedBytes); + + stream.CopyTo(writeStream); + + // Assert bytes read from current location to the end + Assert.Equal(stream.Length, stream.Position); + Assert.Equal(stream.Length - skippedBytes, writeStream.Length); + + // Reset + stream.Position = 0; + writeStream = new SegmentWriteStream(info.SegmentSize); + + stream.CopyTo(writeStream); + + Assert.Equal(stream.Length, stream.Position); + Assert.Equal(stream.Length, writeStream.Length); + var writeSegments = writeStream.GetSegments(); + for (var i = 0; i < info.Segments.Count; i++) + { + Assert.True(writeSegments[i].SequenceEqual(info.Segments[i])); + } + } + } +} diff --git a/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/SegmentWriteStreamTests.cs b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/SegmentWriteStreamTests.cs new file mode 100644 index 0000000000..6043128e7b --- /dev/null +++ b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/SegmentWriteStreamTests.cs @@ -0,0 +1,113 @@ +// 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.Linq; +using Microsoft.AspNetCore.ResponseCaching.Internal; +using Xunit; + +namespace Microsoft.AspNetCore.ResponseCaching.Tests +{ + public class SegmentWriteStreamTests + { + private static byte[] WriteData = new byte[] + { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 + }; + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void SegmentWriteStream_InvalidSegmentSize_Throws(int segmentSize) + { + Assert.Throws(() => new SegmentWriteStream(segmentSize)); + } + + [Fact] + public void ReadAndSeekOperations_Throws() + { + var stream = new SegmentWriteStream(1); + + Assert.Throws(() => stream.Read(new byte[1], 0, 0)); + Assert.Throws(() => stream.Position = 0); + Assert.Throws(() => stream.Seek(0, SeekOrigin.Begin)); + } + + [Fact] + public void GetSegments_ExtractionDisablesWriting() + { + var stream = new SegmentWriteStream(1); + + Assert.True(stream.CanWrite); + Assert.Empty(stream.GetSegments()); + Assert.False(stream.CanWrite); + } + + [Theory] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + public void WriteByte_CanWriteAllBytes(int segmentSize) + { + var stream = new SegmentWriteStream(segmentSize); + + foreach (var datum in WriteData) + { + stream.WriteByte(datum); + } + var segments = stream.GetSegments(); + + Assert.Equal(WriteData.Length, stream.Length); + Assert.Equal((WriteData.Length + segmentSize - 1)/ segmentSize, segments.Count); + + for (var i = 0; i < WriteData.Length; i += segmentSize) + { + var expectedSegmentSize = Math.Min(segmentSize, WriteData.Length - i); + var expectedSegment = new byte[expectedSegmentSize]; + for (int j = 0; j < expectedSegmentSize; j++) + { + expectedSegment[j] = (byte)(i + j); + } + var segment = segments[i / segmentSize]; + + Assert.Equal(expectedSegmentSize, segment.Length); + Assert.True(expectedSegment.SequenceEqual(segment)); + } + } + + [Theory] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + public void Write_CanWriteAllBytes(int writeSize) + { + var segmentSize = 5; + var stream = new SegmentWriteStream(segmentSize); + + + for (var i = 0; i < WriteData.Length; i += writeSize) + { + stream.Write(WriteData, i, Math.Min(writeSize, WriteData.Length - i)); + } + var segments = stream.GetSegments(); + + Assert.Equal(WriteData.Length, stream.Length); + Assert.Equal((WriteData.Length + segmentSize - 1) / segmentSize, segments.Count); + + for (var i = 0; i < WriteData.Length; i += segmentSize) + { + var expectedSegmentSize = Math.Min(segmentSize, WriteData.Length - i); + var expectedSegment = new byte[expectedSegmentSize]; + for (int j = 0; j < expectedSegmentSize; j++) + { + expectedSegment[j] = (byte)(i + j); + } + var segment = segments[i / segmentSize]; + + Assert.Equal(expectedSegmentSize, segment.Length); + Assert.True(expectedSegment.SequenceEqual(segment)); + } + } + } +} diff --git a/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/TestUtils.cs b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/TestUtils.cs new file mode 100644 index 0000000000..09f21a8878 --- /dev/null +++ b/src/ResponseCaching/test/Microsoft.AspNetCore.ResponseCaching.Tests/TestUtils.cs @@ -0,0 +1,396 @@ +// 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.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.ResponseCaching.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; +using Xunit; +using ISystemClock = Microsoft.AspNetCore.ResponseCaching.Internal.ISystemClock; + +namespace Microsoft.AspNetCore.ResponseCaching.Tests +{ + internal class TestUtils + { + static TestUtils() + { + // Force sharding in tests + StreamUtilities.BodySegmentSize = 10; + } + + private static bool TestRequestDelegate(HttpContext context, string guid) + { + var headers = context.Response.GetTypedHeaders(); + + var expires = context.Request.Query["Expires"]; + if (!string.IsNullOrEmpty(expires)) + { + headers.Expires = DateTimeOffset.Now.AddSeconds(int.Parse(expires)); + } + + if (headers.CacheControl == null) + { + headers.CacheControl = new CacheControlHeaderValue + { + Public = true, + MaxAge = string.IsNullOrEmpty(expires) ? TimeSpan.FromSeconds(10) : (TimeSpan?)null + }; + } + else + { + headers.CacheControl.Public = true; + headers.CacheControl.MaxAge = string.IsNullOrEmpty(expires) ? TimeSpan.FromSeconds(10) : (TimeSpan?)null; + } + headers.Date = DateTimeOffset.UtcNow; + headers.Headers["X-Value"] = guid; + + if (context.Request.Method != "HEAD") + { + return true; + } + return false; + } + + internal static async Task TestRequestDelegateWriteAsync(HttpContext context) + { + var uniqueId = Guid.NewGuid().ToString(); + if (TestRequestDelegate(context, uniqueId)) + { + await context.Response.WriteAsync(uniqueId); + } + } + + internal static Task TestRequestDelegateWrite(HttpContext context) + { + var uniqueId = Guid.NewGuid().ToString(); + if (TestRequestDelegate(context, uniqueId)) + { + context.Response.Write(uniqueId); + } + return Task.CompletedTask; + } + + internal static IResponseCachingKeyProvider CreateTestKeyProvider() + { + return CreateTestKeyProvider(new ResponseCachingOptions()); + } + + internal static IResponseCachingKeyProvider CreateTestKeyProvider(ResponseCachingOptions options) + { + return new ResponseCachingKeyProvider(new DefaultObjectPoolProvider(), Options.Create(options)); + } + + internal static IEnumerable CreateBuildersWithResponseCaching( + Action configureDelegate = null, + ResponseCachingOptions options = null, + Action contextAction = null) + { + return CreateBuildersWithResponseCaching(configureDelegate, options, new RequestDelegate[] + { + context => + { + contextAction?.Invoke(context); + return TestRequestDelegateWrite(context); + }, + context => + { + contextAction?.Invoke(context); + return TestRequestDelegateWriteAsync(context); + }, + }); + } + + private static IEnumerable CreateBuildersWithResponseCaching( + Action configureDelegate = null, + ResponseCachingOptions options = null, + IEnumerable requestDelegates = null) + { + if (configureDelegate == null) + { + configureDelegate = app => { }; + } + if (requestDelegates == null) + { + requestDelegates = new RequestDelegate[] + { + TestRequestDelegateWriteAsync, + TestRequestDelegateWrite + }; + } + + foreach (var requestDelegate in requestDelegates) + { + // Test with in memory ResponseCache + yield return new WebHostBuilder() + .ConfigureServices(services => + { + services.AddResponseCaching(responseCachingOptions => + { + if (options != null) + { + responseCachingOptions.MaximumBodySize = options.MaximumBodySize; + responseCachingOptions.UseCaseSensitivePaths = options.UseCaseSensitivePaths; + responseCachingOptions.SystemClock = options.SystemClock; + } + }); + }) + .Configure(app => + { + configureDelegate(app); + app.UseResponseCaching(); + app.Run(requestDelegate); + }); + } + } + + internal static ResponseCachingMiddleware CreateTestMiddleware( + RequestDelegate next = null, + IResponseCache cache = null, + ResponseCachingOptions options = null, + TestSink testSink = null, + IResponseCachingKeyProvider keyProvider = null, + IResponseCachingPolicyProvider policyProvider = null) + { + if (next == null) + { + next = httpContext => Task.CompletedTask; + } + if (cache == null) + { + cache = new TestResponseCache(); + } + if (options == null) + { + options = new ResponseCachingOptions(); + } + if (keyProvider == null) + { + keyProvider = new ResponseCachingKeyProvider(new DefaultObjectPoolProvider(), Options.Create(options)); + } + if (policyProvider == null) + { + policyProvider = new TestResponseCachingPolicyProvider(); + } + + return new ResponseCachingMiddleware( + next, + Options.Create(options), + testSink == null ? (ILoggerFactory)NullLoggerFactory.Instance : new TestLoggerFactory(testSink, true), + policyProvider, + cache, + keyProvider); + } + + internal static ResponseCachingContext CreateTestContext() + { + return new ResponseCachingContext(new DefaultHttpContext(), NullLogger.Instance) + { + ResponseTime = DateTimeOffset.UtcNow + }; + } + + internal static ResponseCachingContext CreateTestContext(ITestSink testSink) + { + return new ResponseCachingContext(new DefaultHttpContext(), new TestLogger("ResponseCachingTests", testSink, true)) + { + ResponseTime = DateTimeOffset.UtcNow + }; + } + + internal static void AssertLoggedMessages(IEnumerable messages, params LoggedMessage[] expectedMessages) + { + var messageList = messages.ToList(); + Assert.Equal(messageList.Count, expectedMessages.Length); + + for (var i = 0; i < messageList.Count; i++) + { + Assert.Equal(expectedMessages[i].EventId, messageList[i].EventId); + Assert.Equal(expectedMessages[i].LogLevel, messageList[i].LogLevel); + } + } + + public static HttpRequestMessage CreateRequest(string method, string requestUri) + { + return new HttpRequestMessage(new HttpMethod(method), requestUri); + } + } + + internal static class HttpResponseWritingExtensions + { + internal static void Write(this HttpResponse response, string text) + { + if (response == null) + { + throw new ArgumentNullException(nameof(response)); + } + + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + byte[] data = Encoding.UTF8.GetBytes(text); + response.Body.Write(data, 0, data.Length); + } + } + + internal class LoggedMessage + { + internal static LoggedMessage RequestMethodNotCacheable => new LoggedMessage(1, LogLevel.Debug); + internal static LoggedMessage RequestWithAuthorizationNotCacheable => new LoggedMessage(2, LogLevel.Debug); + internal static LoggedMessage RequestWithNoCacheNotCacheable => new LoggedMessage(3, LogLevel.Debug); + internal static LoggedMessage RequestWithPragmaNoCacheNotCacheable => new LoggedMessage(4, LogLevel.Debug); + internal static LoggedMessage ExpirationMinFreshAdded => new LoggedMessage(5, LogLevel.Debug); + internal static LoggedMessage ExpirationSharedMaxAgeExceeded => new LoggedMessage(6, LogLevel.Debug); + internal static LoggedMessage ExpirationMustRevalidate => new LoggedMessage(7, LogLevel.Debug); + internal static LoggedMessage ExpirationMaxStaleSatisfied => new LoggedMessage(8, LogLevel.Debug); + internal static LoggedMessage ExpirationMaxAgeExceeded => new LoggedMessage(9, LogLevel.Debug); + internal static LoggedMessage ExpirationExpiresExceeded => new LoggedMessage(10, LogLevel.Debug); + internal static LoggedMessage ResponseWithoutPublicNotCacheable => new LoggedMessage(11, LogLevel.Debug); + internal static LoggedMessage ResponseWithNoStoreNotCacheable => new LoggedMessage(12, LogLevel.Debug); + internal static LoggedMessage ResponseWithNoCacheNotCacheable => new LoggedMessage(13, LogLevel.Debug); + internal static LoggedMessage ResponseWithSetCookieNotCacheable => new LoggedMessage(14, LogLevel.Debug); + internal static LoggedMessage ResponseWithVaryStarNotCacheable => new LoggedMessage(15, LogLevel.Debug); + internal static LoggedMessage ResponseWithPrivateNotCacheable => new LoggedMessage(16, LogLevel.Debug); + internal static LoggedMessage ResponseWithUnsuccessfulStatusCodeNotCacheable => new LoggedMessage(17, LogLevel.Debug); + internal static LoggedMessage NotModifiedIfNoneMatchStar => new LoggedMessage(18, LogLevel.Debug); + internal static LoggedMessage NotModifiedIfNoneMatchMatched => new LoggedMessage(19, LogLevel.Debug); + internal static LoggedMessage NotModifiedIfModifiedSinceSatisfied => new LoggedMessage(20, LogLevel.Debug); + internal static LoggedMessage NotModifiedServed => new LoggedMessage(21, LogLevel.Information); + internal static LoggedMessage CachedResponseServed => new LoggedMessage(22, LogLevel.Information); + internal static LoggedMessage GatewayTimeoutServed => new LoggedMessage(23, LogLevel.Information); + internal static LoggedMessage NoResponseServed => new LoggedMessage(24, LogLevel.Information); + internal static LoggedMessage VaryByRulesUpdated => new LoggedMessage(25, LogLevel.Debug); + internal static LoggedMessage ResponseCached => new LoggedMessage(26, LogLevel.Information); + internal static LoggedMessage ResponseNotCached => new LoggedMessage(27, LogLevel.Information); + internal static LoggedMessage ResponseContentLengthMismatchNotCached => new LoggedMessage(28, LogLevel.Warning); + internal static LoggedMessage ExpirationInfiniteMaxStaleSatisfied => new LoggedMessage(29, LogLevel.Debug); + + private LoggedMessage(int evenId, LogLevel logLevel) + { + EventId = evenId; + LogLevel = logLevel; + } + + internal int EventId { get; } + internal LogLevel LogLevel { get; } + } + + internal class DummySendFileFeature : IHttpSendFileFeature + { + public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellation) + { + return Task.CompletedTask; + } + } + + internal class TestResponseCachingPolicyProvider : IResponseCachingPolicyProvider + { + public bool AllowCacheLookupValue { get; set; } = false; + public bool AllowCacheStorageValue { get; set; } = false; + public bool AttemptResponseCachingValue { get; set; } = false; + public bool IsCachedEntryFreshValue { get; set; } = true; + public bool IsResponseCacheableValue { get; set; } = true; + + public bool AllowCacheLookup(ResponseCachingContext context) => AllowCacheLookupValue; + + public bool AllowCacheStorage(ResponseCachingContext context) => AllowCacheStorageValue; + + public bool AttemptResponseCaching(ResponseCachingContext context) => AttemptResponseCachingValue; + + public bool IsCachedEntryFresh(ResponseCachingContext context) => IsCachedEntryFreshValue; + + public bool IsResponseCacheable(ResponseCachingContext context) => IsResponseCacheableValue; + } + + internal class TestResponseCachingKeyProvider : IResponseCachingKeyProvider + { + private readonly string _baseKey; + private readonly StringValues _varyKey; + + public TestResponseCachingKeyProvider(string lookupBaseKey = null, StringValues? lookupVaryKey = null) + { + _baseKey = lookupBaseKey; + if (lookupVaryKey.HasValue) + { + _varyKey = lookupVaryKey.Value; + } + } + + public IEnumerable CreateLookupVaryByKeys(ResponseCachingContext context) + { + foreach (var varyKey in _varyKey) + { + yield return _baseKey + varyKey; + } + } + + public string CreateBaseKey(ResponseCachingContext context) + { + return _baseKey; + } + + public string CreateStorageVaryByKey(ResponseCachingContext context) + { + throw new NotImplementedException(); + } + } + + internal class TestResponseCache : IResponseCache + { + private readonly IDictionary _storage = new Dictionary(); + public int GetCount { get; private set; } + public int SetCount { get; private set; } + + public IResponseCacheEntry Get(string key) + { + GetCount++; + try + { + return _storage[key]; + } + catch + { + return null; + } + } + + public Task GetAsync(string key) + { + return Task.FromResult(Get(key)); + } + + public void Set(string key, IResponseCacheEntry entry, TimeSpan validFor) + { + SetCount++; + _storage[key] = entry; + } + + public Task SetAsync(string key, IResponseCacheEntry entry, TimeSpan validFor) + { + Set(key, entry, validFor); + return Task.CompletedTask; + } + } + + internal class TestClock : ISystemClock + { + public DateTimeOffset UtcNow { get; set; } + } +} diff --git a/src/ResponseCaching/version.props b/src/ResponseCaching/version.props new file mode 100644 index 0000000000..669c874829 --- /dev/null +++ b/src/ResponseCaching/version.props @@ -0,0 +1,12 @@ + + + 2.1.1 + rtm + $(VersionPrefix) + $(VersionPrefix)-$(VersionSuffix)-final + t000 + a- + $(FeatureBranchVersionPrefix)$(VersionSuffix)-$([System.Text.RegularExpressions.Regex]::Replace('$(FeatureBranchVersionSuffix)', '[^\w-]', '-')) + $(VersionSuffix)-$(BuildNumber) + +