diff --git a/src/Microsoft.AspNetCore.ResponseCaching/IResponseCache.cs b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCache.cs similarity index 100% rename from src/Microsoft.AspNetCore.ResponseCaching/IResponseCache.cs rename to src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCache.cs diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheKeySuffixProvider.cs b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheKeySuffixProvider.cs new file mode 100644 index 0000000..7514fa4 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheKeySuffixProvider.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 Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.ResponseCaching +{ + public interface IResponseCachingCacheKeySuffixProvider + { + /// + /// Create a key segment that is appended to the default cache key. + /// + /// The . + /// The key segment that will be appended to the default cache key. + string CreateCustomKeySuffix(HttpContext httpContext); + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheabilityValidator.cs b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheabilityValidator.cs new file mode 100644 index 0000000..11b4656 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheabilityValidator.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 Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.ResponseCaching +{ + public interface IResponseCachingCacheabilityValidator + { + /// + /// Override default behavior for determining cacheability of an HTTP request. + /// + /// The . + /// The . + OverrideResult RequestIsCacheableOverride(HttpContext httpContext); + + /// + /// Override default behavior for determining cacheability of an HTTP response. + /// + /// The . + /// The . + OverrideResult ResponseIsCacheableOverride(HttpContext httpContext); + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/NoopCacheKeySuffixProvider.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/NoopCacheKeySuffixProvider.cs new file mode 100644 index 0000000..43daeb5 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/NoopCacheKeySuffixProvider.cs @@ -0,0 +1,12 @@ +// 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.AspNetCore.Http; + +namespace Microsoft.AspNetCore.ResponseCaching.Internal +{ + internal class NoopCacheKeySuffixProvider : IResponseCachingCacheKeySuffixProvider + { + public string CreateCustomKeySuffix(HttpContext httpContext) => null; + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/NoopCacheabilityValidator.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/NoopCacheabilityValidator.cs new file mode 100644 index 0000000..309e900 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/NoopCacheabilityValidator.cs @@ -0,0 +1,14 @@ +// 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.AspNetCore.Http; + +namespace Microsoft.AspNetCore.ResponseCaching.Internal +{ + internal class NoopCacheabilityValidator : IResponseCachingCacheabilityValidator + { + public OverrideResult RequestIsCacheableOverride(HttpContext httpContext) => OverrideResult.UseDefaultLogic; + + public OverrideResult ResponseIsCacheableOverride(HttpContext httpContext) => OverrideResult.UseDefaultLogic; + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/OverrideResult.cs b/src/Microsoft.AspNetCore.ResponseCaching/OverrideResult.cs new file mode 100644 index 0000000..e2a168a --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/OverrideResult.cs @@ -0,0 +1,23 @@ +// 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 +{ + public enum OverrideResult + { + /// + /// Use the default logic for determining cacheability. + /// + UseDefaultLogic, + + /// + /// Ignore default logic and do not cache. + /// + DoNotCache, + + /// + /// Ignore default logic and cache. + /// + Cache + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs index 54f6384..2ba747d 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs @@ -10,14 +10,23 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Headers; using Microsoft.AspNetCore.ResponseCaching.Internal; +using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.ResponseCaching { - public class ResponseCachingContext + internal class ResponseCachingContext { private static readonly CacheControlHeaderValue EmptyCacheControl = new CacheControlHeaderValue(); + + private readonly HttpContext _httpContext; + private readonly IResponseCache _cache; + private readonly ISystemClock _clock; + private readonly ObjectPool _builderPool; + private readonly IResponseCachingCacheabilityValidator _cacheabilityValidator; + private readonly IResponseCachingCacheKeySuffixProvider _cacheKeySuffixProvider; + private string _cacheKey; private ResponseType? _responseType; private RequestHeaders _requestHeaders; @@ -28,31 +37,32 @@ public class ResponseCachingContext private CachedResponse _cachedResponse; private TimeSpan _cachedResponseValidFor; internal DateTimeOffset _responseTime; - - public ResponseCachingContext(HttpContext httpContext, IResponseCache cache) - : this(httpContext, cache, new SystemClock()) + + internal ResponseCachingContext( + HttpContext httpContext, + IResponseCache cache, + ObjectPool builderPool, + IResponseCachingCacheabilityValidator cacheabilityValidator, + IResponseCachingCacheKeySuffixProvider cacheKeySuffixProvider) + : this(httpContext, cache, new SystemClock(), builderPool, cacheabilityValidator, cacheKeySuffixProvider) { } // Internal for testing - internal ResponseCachingContext(HttpContext httpContext, IResponseCache cache, ISystemClock clock) + internal ResponseCachingContext( + HttpContext httpContext, + IResponseCache cache, + ISystemClock clock, + ObjectPool builderPool, + IResponseCachingCacheabilityValidator cacheabilityValidator, + IResponseCachingCacheKeySuffixProvider cacheKeySuffixProvider) { - if (cache == null) - { - throw new ArgumentNullException(nameof(cache)); - } - if (httpContext == null) - { - throw new ArgumentNullException(nameof(httpContext)); - } - if (clock == null) - { - throw new ArgumentNullException(nameof(clock)); - } - - HttpContext = httpContext; - Cache = cache; - Clock = clock; + _httpContext = httpContext; + _cache = cache; + _clock = clock; + _builderPool = builderPool; + _cacheabilityValidator = cacheabilityValidator; + _cacheKeySuffixProvider = cacheKeySuffixProvider; } internal bool CacheResponse @@ -72,12 +82,6 @@ internal bool CacheResponse internal bool ResponseStarted { get; set; } - private ISystemClock Clock { get; } - - private HttpContext HttpContext { get; } - - private IResponseCache Cache { get; } - private Stream OriginalResponseStream { get; set; } private ResponseCacheStream ResponseCacheStream { get; set; } @@ -90,7 +94,7 @@ private RequestHeaders RequestHeaders { if (_requestHeaders == null) { - _requestHeaders = HttpContext.Request.GetTypedHeaders(); + _requestHeaders = _httpContext.Request.GetTypedHeaders(); } return _requestHeaders; } @@ -102,7 +106,7 @@ private ResponseHeaders ResponseHeaders { if (_responseHeaders == null) { - _responseHeaders = HttpContext.Response.GetTypedHeaders(); + _responseHeaders = _httpContext.Response.GetTypedHeaders(); } return _responseHeaders; } @@ -141,53 +145,75 @@ internal string CreateCacheKey() internal string CreateCacheKey(CachedVaryBy varyBy) { - var request = HttpContext.Request; - var builder = new StringBuilder() - .Append(request.Method.ToUpperInvariant()) - .Append(";") - .Append(request.Path.Value.ToUpperInvariant()) - .Append(CreateVaryByCacheKey(varyBy)); - - return builder.ToString(); - } + var request = _httpContext.Request; + var builder = _builderPool?.Get() ?? new StringBuilder(); - private string CreateVaryByCacheKey(CachedVaryBy varyBy) - { - // TODO: resolve key format and delimiters - if (varyBy == null || varyBy.Headers.Count == 0) + try { - return string.Empty; - } + builder + .Append(request.Method.ToUpperInvariant()) + .Append(";") + .Append(request.Path.Value.ToUpperInvariant()); - var builder = new StringBuilder(";"); + if (varyBy?.Headers.Count > 0) + { + // TODO: resolve key format and delimiters + foreach (var header in varyBy.Headers) + { + // TODO: Normalization of order, case? + var value = _httpContext.Request.Headers[header]; - foreach (var header in varyBy.Headers) - { - // TODO: Normalization of order, case? - var value = HttpContext.Request.Headers[header].ToString(); + // TODO: How to handle null/empty string? + if (StringValues.IsNullOrEmpty(value)) + { + value = "null"; + } + + builder.Append(";") + .Append(header) + .Append("=") + .Append(value); + } + } + // TODO: Parse querystring params - // TODO: How to handle null/empty string? - if (string.IsNullOrEmpty(value)) + // Append custom cache key segment + var customKey = _cacheKeySuffixProvider.CreateCustomKeySuffix(_httpContext); + if (!string.IsNullOrEmpty(customKey)) { - value = "null"; + builder.Append(";") + .Append(customKey); } - builder.Append(header) - .Append("=") - .Append(value) - .Append(";"); + return builder.ToString(); + } + finally + { + if (_builderPool != null) + { + _builderPool.Return(builder); + } } - - // Parse querystring params - - return builder.ToString(); } internal bool RequestIsCacheable() { + // Use optional override if specified by user + switch(_cacheabilityValidator.RequestIsCacheableOverride(_httpContext)) + { + case OverrideResult.UseDefaultLogic: + break; + case OverrideResult.DoNotCache: + return false; + case OverrideResult.Cache: + return true; + default: + throw new NotSupportedException($"Unrecognized result from {nameof(_cacheabilityValidator.RequestIsCacheableOverride)}."); + } + // Verify the method // TODO: RFC lists POST as a cacheable method when explicit freshness information is provided, but this is not widely implemented. Will revisit. - var request = HttpContext.Request; + var request = _httpContext.Request; if (string.Equals("GET", request.Method, StringComparison.OrdinalIgnoreCase)) { _responseType = ResponseType.FullReponse; @@ -236,6 +262,19 @@ internal bool RequestIsCacheable() internal bool ResponseIsCacheable() { + // Use optional override if specified by user + switch (_cacheabilityValidator.ResponseIsCacheableOverride(_httpContext)) + { + case OverrideResult.UseDefaultLogic: + break; + case OverrideResult.DoNotCache: + return false; + case OverrideResult.Cache: + return true; + default: + throw new NotSupportedException($"Unrecognized result from {nameof(_cacheabilityValidator.ResponseIsCacheableOverride)}."); + } + // Only cache pages explicitly marked with public // TODO: Consider caching responses that are not marked as public but otherwise cacheable? if (!ResponseCacheControl.Public) @@ -256,7 +295,7 @@ internal bool ResponseIsCacheable() return false; } - var response = HttpContext.Response; + var response = _httpContext.Response; // Do not cache responses varying by * if (string.Equals(response.Headers[HeaderNames.Vary], "*", StringComparison.OrdinalIgnoreCase)) @@ -334,14 +373,14 @@ internal bool EntryIsFresh(ResponseHeaders responseHeaders, TimeSpan age, bool v internal async Task TryServeFromCacheAsync() { _cacheKey = CreateCacheKey(); - var cacheEntry = Cache.Get(_cacheKey); + var cacheEntry = _cache.Get(_cacheKey); var responseServed = false; if (cacheEntry is CachedVaryBy) { // Request contains VaryBy rules, recompute key and try again _cacheKey = CreateCacheKey(cacheEntry as CachedVaryBy); - cacheEntry = Cache.Get(_cacheKey); + cacheEntry = _cache.Get(_cacheKey); } if (cacheEntry is CachedResponse) @@ -349,13 +388,13 @@ internal async Task TryServeFromCacheAsync() var cachedResponse = cacheEntry as CachedResponse; var cachedResponseHeaders = new ResponseHeaders(cachedResponse.Headers); - _responseTime = Clock.UtcNow; + _responseTime = _clock.UtcNow; var age = _responseTime - cachedResponse.Created; age = age > TimeSpan.Zero ? age : TimeSpan.Zero; if (EntryIsFresh(cachedResponseHeaders, age, verifyAgainstRequest: true)) { - var response = HttpContext.Response; + var response = _httpContext.Response; // Copy the cached status code and response headers response.StatusCode = cachedResponse.StatusCode; foreach (var header in cachedResponse.Headers) @@ -400,7 +439,7 @@ internal async Task TryServeFromCacheAsync() if (!responseServed && RequestCacheControl.OnlyIfCached) { - HttpContext.Response.StatusCode = StatusCodes.Status504GatewayTimeout; + _httpContext.Response.StatusCode = StatusCodes.Status504GatewayTimeout; responseServed = true; } @@ -413,7 +452,7 @@ internal void FinalizeCachingHeaders() if (CacheResponse) { // Create the cache entry now - var response = HttpContext.Response; + var response = _httpContext.Response; var varyHeaderValue = response.Headers[HeaderNames.Vary]; _cachedResponseValidFor = ResponseCacheControl.SharedMaxAge ?? ResponseCacheControl.MaxAge @@ -432,7 +471,7 @@ internal void FinalizeCachingHeaders() }; // TODO: Overwrite? - Cache.Set(_cacheKey, cachedVaryBy, _cachedResponseValidFor); + _cache.Set(_cacheKey, cachedVaryBy, _cachedResponseValidFor); _cacheKey = CreateCacheKey(cachedVaryBy); } @@ -446,7 +485,7 @@ internal void FinalizeCachingHeaders() _cachedResponse = new CachedResponse { Created = ResponseHeaders.Date.Value, - StatusCode = HttpContext.Response.StatusCode + StatusCode = _httpContext.Response.StatusCode }; foreach (var header in ResponseHeaders.Headers) @@ -470,7 +509,7 @@ internal void FinalizeCachingBody() { _cachedResponse.Body = ResponseCacheStream.BufferedStream.ToArray(); - Cache.Set(_cacheKey, _cachedResponse, _cachedResponseValidFor); + _cache.Set(_cacheKey, _cachedResponse, _cachedResponseValidFor); } } @@ -479,7 +518,7 @@ internal void OnResponseStarting() if (!ResponseStarted) { ResponseStarted = true; - _responseTime = Clock.UtcNow; + _responseTime = _clock.UtcNow; FinalizeCachingHeaders(); } @@ -490,25 +529,25 @@ internal void ShimResponseStream() // TODO: Consider caching large responses on disk and serving them from there. // Shim response stream - OriginalResponseStream = HttpContext.Response.Body; + OriginalResponseStream = _httpContext.Response.Body; ResponseCacheStream = new ResponseCacheStream(OriginalResponseStream); - HttpContext.Response.Body = ResponseCacheStream; + _httpContext.Response.Body = ResponseCacheStream; // Shim IHttpSendFileFeature - OriginalSendFileFeature = HttpContext.Features.Get(); + OriginalSendFileFeature = _httpContext.Features.Get(); if (OriginalSendFileFeature != null) { - HttpContext.Features.Set(new SendFileFeatureWrapper(OriginalSendFileFeature, ResponseCacheStream)); + _httpContext.Features.Set(new SendFileFeatureWrapper(OriginalSendFileFeature, ResponseCacheStream)); } } internal void UnshimResponseStream() { // Unshim response stream - HttpContext.Response.Body = OriginalResponseStream; + _httpContext.Response.Body = OriginalResponseStream; // Unshim IHttpSendFileFeature - HttpContext.Features.Set(OriginalSendFileFeature); + _httpContext.Features.Set(OriginalSendFileFeature); } private enum ResponseType diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs index 60817a6..76b81db 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs @@ -1,7 +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. +using System; using Microsoft.AspNetCore.ResponseCaching; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Builder { @@ -9,6 +11,11 @@ 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/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs index 5c43ff1..9c6a922 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs @@ -2,13 +2,14 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.ResponseCaching { - // http://tools.ietf.org/html/rfc7234 public class ResponseCachingMiddleware { private static readonly Func OnStartingCallback = state => @@ -19,26 +20,53 @@ public class ResponseCachingMiddleware private readonly RequestDelegate _next; private readonly IResponseCache _cache; + private readonly ObjectPool _builderPool; + private readonly IResponseCachingCacheabilityValidator _cacheabilityValidator; + private readonly IResponseCachingCacheKeySuffixProvider _cacheKeySuffixProvider; - public ResponseCachingMiddleware(RequestDelegate next, IResponseCache cache) + public ResponseCachingMiddleware( + RequestDelegate next, + IResponseCache cache, + ObjectPoolProvider poolProvider, + IResponseCachingCacheabilityValidator cacheabilityValidator, + IResponseCachingCacheKeySuffixProvider cacheKeySuffixProvider) { + if (next == null) + { + throw new ArgumentNullException(nameof(next)); + } if (cache == null) { throw new ArgumentNullException(nameof(cache)); } - - if (next == null) + if (poolProvider == null) { - throw new ArgumentNullException(nameof(next)); + throw new ArgumentNullException(nameof(poolProvider)); + } + if (cacheabilityValidator == null) + { + throw new ArgumentNullException(nameof(cacheabilityValidator)); + } + if (cacheKeySuffixProvider == null) + { + throw new ArgumentNullException(nameof(cacheKeySuffixProvider)); } _next = next; _cache = cache; + _builderPool = poolProvider.CreateStringBuilderPool(); + _cacheabilityValidator = cacheabilityValidator; + _cacheKeySuffixProvider = cacheKeySuffixProvider; } public async Task Invoke(HttpContext context) { - var cachingContext = new ResponseCachingContext(context, _cache); + var cachingContext = new ResponseCachingContext( + context, + _cache, + _builderPool, + _cacheabilityValidator, + _cacheKeySuffixProvider); // Should we attempt any caching logic? if (cachingContext.RequestIsCacheable()) diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingServiceCollectionExtensions.cs index b1e7263..b6e18f7 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static IServiceCollection AddMemoryResponseCache(this IServiceCollection } services.AddMemoryCache(); + services.AddResponseCachingServices(); services.TryAdd(ServiceDescriptor.Singleton()); return services; @@ -31,9 +32,18 @@ public static IServiceCollection AddDistributedResponseCache(this IServiceCollec } services.AddDistributedMemoryCache(); + services.AddResponseCachingServices(); services.TryAdd(ServiceDescriptor.Singleton()); return services; } + + private static IServiceCollection AddResponseCachingServices(this IServiceCollection services) + { + services.TryAdd(ServiceDescriptor.Singleton()); + services.TryAdd(ServiceDescriptor.Singleton()); + + return services; + } } } diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs index beeff86..b10cbb0 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs @@ -2,7 +2,6 @@ // 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; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -23,7 +22,7 @@ public void RequestIsCacheable_CacheableMethods_Allowed(string method) { var httpContext = new DefaultHttpContext(); httpContext.Request.Method = method; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.True(context.RequestIsCacheable()); } @@ -41,7 +40,7 @@ public void RequestIsCacheable_UncacheableMethods_NotAllowed(string method) { var httpContext = new DefaultHttpContext(); httpContext.Request.Method = method; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.RequestIsCacheable()); } @@ -52,7 +51,7 @@ public void RequestIsCacheable_AuthorizationHeaders_NotAllowed() var httpContext = new DefaultHttpContext(); httpContext.Request.Method = "GET"; httpContext.Request.Headers[HeaderNames.Authorization] = "Basic plaintextUN:plaintextPW"; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.RequestIsCacheable()); } @@ -66,7 +65,7 @@ public void RequestIsCacheable_NoCache_NotAllowed() { NoCache = true }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.RequestIsCacheable()); } @@ -80,7 +79,7 @@ public void RequestIsCacheable_NoStore_Allowed() { NoStore = true }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.True(context.RequestIsCacheable()); } @@ -91,7 +90,7 @@ public void RequestIsCacheable_LegacyDirectives_NotAllowed() var httpContext = new DefaultHttpContext(); httpContext.Request.Method = "GET"; httpContext.Request.Headers[HeaderNames.Pragma] = "no-cache"; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.RequestIsCacheable()); } @@ -103,11 +102,61 @@ public void RequestIsCacheable_LegacyDirectives_OverridenByCacheControl() httpContext.Request.Method = "GET"; httpContext.Request.Headers[HeaderNames.Pragma] = "no-cache"; httpContext.Request.Headers[HeaderNames.CacheControl] = "max-age=10"; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.True(context.RequestIsCacheable()); } + private class AllowUnrecognizedHTTPMethodRequests : IResponseCachingCacheabilityValidator + { + public OverrideResult RequestIsCacheableOverride(HttpContext httpContext) => + httpContext.Request.Method == "UNRECOGNIZED" ? OverrideResult.Cache : OverrideResult.DoNotCache; + + public OverrideResult ResponseIsCacheableOverride(HttpContext httpContext) => OverrideResult.UseDefaultLogic; + } + + [Fact] + public void RequestIsCacheableOverride_OverridesDefaultBehavior_ToAllowed() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "UNRECOGNIZED"; + var responseCachingContext = CreateTestContext(httpContext, new AllowUnrecognizedHTTPMethodRequests()); + + Assert.True(responseCachingContext.RequestIsCacheable()); + } + + private class DisallowGetHTTPMethodRequests : IResponseCachingCacheabilityValidator + { + public OverrideResult RequestIsCacheableOverride(HttpContext httpContext) => + httpContext.Request.Method == "GET" ? OverrideResult.DoNotCache : OverrideResult.Cache; + + public OverrideResult ResponseIsCacheableOverride(HttpContext httpContext) => OverrideResult.UseDefaultLogic; + } + + [Fact] + public void RequestIsCacheableOverride_OverridesDefaultBehavior_ToNotAllowed() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + var responseCachingContext = CreateTestContext(httpContext, new DisallowGetHTTPMethodRequests()); + + Assert.False(responseCachingContext.RequestIsCacheable()); + } + + [Fact] + public void RequestIsCacheableOverride_IgnoreFallsBackToDefaultBehavior() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + var responseCachingContext = CreateTestContext(httpContext, new NoopCacheabilityValidator()); + + Assert.True(responseCachingContext.RequestIsCacheable()); + + httpContext.Request.Method = "UNRECOGNIZED"; + + Assert.False(responseCachingContext.RequestIsCacheable()); + } + [Fact] public void CreateCacheKey_Includes_UppercaseMethodAndPath() { @@ -118,7 +167,7 @@ public void CreateCacheKey_Includes_UppercaseMethodAndPath() httpContext.Request.Host = new HostString("example.com", 80); httpContext.Request.PathBase = "/pathBase"; httpContext.Request.QueryString = new QueryString("?query.Key=a&query.Value=b"); - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.Equal("HEAD;/PATH/SUBPATH", context.CreateCacheKey()); } @@ -131,9 +180,30 @@ public void CreateCacheKey_Includes_ListedVaryByHeadersOnly() httpContext.Request.Path = "/"; httpContext.Request.Headers["HeaderA"] = "ValueA"; httpContext.Request.Headers["HeaderB"] = "ValueB"; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); - Assert.Equal("GET;/;HeaderA=ValueA;HeaderC=null;", context.CreateCacheKey(new CachedVaryBy() + Assert.Equal("GET;/;HeaderA=ValueA;HeaderC=null", context.CreateCacheKey(new CachedVaryBy() + { + Headers = new string[] { "HeaderA", "HeaderC" } + })); + } + + private class CustomizeKeySuffixProvider : IResponseCachingCacheKeySuffixProvider + { + public string CreateCustomKeySuffix(HttpContext httpContext) => "CustomizedKey"; + } + + [Fact] + public void CreateCacheKey_OptionalCacheKey_AppendedToDefaultKey() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Path = "/"; + httpContext.Request.Headers["HeaderA"] = "ValueA"; + httpContext.Request.Headers["HeaderB"] = "ValueB"; + var responseCachingContext = CreateTestContext(httpContext, new CustomizeKeySuffixProvider()); + + Assert.Equal("GET;/;HeaderA=ValueA;HeaderC=null;CustomizedKey", responseCachingContext.CreateCacheKey(new CachedVaryBy() { Headers = new string[] { "HeaderA", "HeaderC" } })); @@ -143,7 +213,7 @@ public void CreateCacheKey_Includes_ListedVaryByHeadersOnly() public void ResponseIsCacheable_NoPublic_NotAllowed() { var httpContext = new DefaultHttpContext(); - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.ResponseIsCacheable()); } @@ -156,7 +226,7 @@ public void ResponseIsCacheable_Public_Allowed() { Public = true }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.True(context.ResponseIsCacheable()); } @@ -170,7 +240,7 @@ public void ResponseIsCacheable_NoCache_NotAllowed() Public = true, NoCache = true }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.ResponseIsCacheable()); } @@ -187,7 +257,7 @@ public void ResponseIsCacheable_RequestNoStore_NotAllowed() { Public = true }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.ResponseIsCacheable()); } @@ -201,7 +271,7 @@ public void ResponseIsCacheable_ResponseNoStore_NotAllowed() Public = true, NoStore = true }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.ResponseIsCacheable()); } @@ -215,7 +285,7 @@ public void ResponseIsCacheable_VaryByStar_NotAllowed() Public = true }; httpContext.Response.Headers[HeaderNames.Vary] = "*"; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.ResponseIsCacheable()); } @@ -229,7 +299,7 @@ public void ResponseIsCacheable_Private_NotAllowed() Public = true, Private = true }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.ResponseIsCacheable()); } @@ -244,7 +314,7 @@ public void ResponseIsCacheable_SuccessStatusCodes_Allowed(int statusCode) { Public = true }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.True(context.ResponseIsCacheable()); } @@ -306,16 +376,78 @@ public void ResponseIsCacheable_NonSuccessStatusCodes_NotAllowed(int statusCode) { Public = true }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.ResponseIsCacheable()); } - + + private class Allow500Response : IResponseCachingCacheabilityValidator + { + public OverrideResult RequestIsCacheableOverride(HttpContext httpContext) => OverrideResult.UseDefaultLogic; + + public OverrideResult ResponseIsCacheableOverride(HttpContext httpContext) => + httpContext.Response.StatusCode == StatusCodes.Status500InternalServerError ? OverrideResult.Cache : OverrideResult.DoNotCache; + } + + [Fact] + public void ResponseIsCacheableOverride_OverridesDefaultBehavior_ToAllowed() + { + var httpContext = new DefaultHttpContext(); + httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + var responseCachingContext = CreateTestContext(httpContext, new Allow500Response()); + + Assert.True(responseCachingContext.ResponseIsCacheable()); + } + + private class Disallow200Response : IResponseCachingCacheabilityValidator + { + public OverrideResult RequestIsCacheableOverride(HttpContext httpContext) => OverrideResult.UseDefaultLogic; + + public OverrideResult ResponseIsCacheableOverride(HttpContext httpContext) => + httpContext.Response.StatusCode == StatusCodes.Status200OK ? OverrideResult.DoNotCache : OverrideResult.Cache; + } + + [Fact] + public void ResponseIsCacheableOverride_OverridesDefaultBehavior_ToNotAllowed() + { + var httpContext = new DefaultHttpContext(); + httpContext.Response.StatusCode = StatusCodes.Status200OK; + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + var responseCachingContext = CreateTestContext(httpContext, new Disallow200Response()); + + Assert.False(responseCachingContext.ResponseIsCacheable()); + } + + [Fact] + public void ResponseIsCacheableOverride_IgnoreFallsBackToDefaultBehavior() + { + var httpContext = new DefaultHttpContext(); + httpContext.Response.StatusCode = StatusCodes.Status200OK; + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + var responseCachingContext = CreateTestContext(httpContext, new NoopCacheabilityValidator()); + + Assert.True(responseCachingContext.ResponseIsCacheable()); + + httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + + Assert.False(responseCachingContext.ResponseIsCacheable()); + } + [Fact] public void EntryIsFresh_NoExpiryRequirements_IsFresh() { var httpContext = new DefaultHttpContext(); - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.True(context.EntryIsFresh(new ResponseHeaders(new HeaderDictionary()), TimeSpan.MaxValue, verifyAgainstRequest: false)); } @@ -326,7 +458,7 @@ public void EntryIsFresh_PastExpiry_IsNotFresh() var httpContext = new DefaultHttpContext(); var utcNow = DateTimeOffset.UtcNow; httpContext.Response.GetTypedHeaders().Expires = utcNow; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); context._responseTime = utcNow; Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.MaxValue, verifyAgainstRequest: false)); @@ -345,7 +477,7 @@ public void EntryIsFresh_MaxAgeOverridesExpiry_ToFresh() MaxAge = TimeSpan.FromSeconds(10) }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); context._responseTime = utcNow; Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(10), verifyAgainstRequest: false)); @@ -364,7 +496,7 @@ public void EntryIsFresh_MaxAgeOverridesExpiry_ToNotFresh() MaxAge = TimeSpan.FromSeconds(10) }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); context._responseTime = utcNow; Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(11), verifyAgainstRequest: false)); @@ -379,7 +511,7 @@ public void EntryIsFresh_SharedMaxAgeOverridesMaxAge_ToFresh() MaxAge = TimeSpan.FromSeconds(10), SharedMaxAge = TimeSpan.FromSeconds(15) }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(11), verifyAgainstRequest: false)); } @@ -393,7 +525,7 @@ public void EntryIsFresh_SharedMaxAgeOverridesMaxAge_ToNotFresh() MaxAge = TimeSpan.FromSeconds(10), SharedMaxAge = TimeSpan.FromSeconds(5) }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(6), verifyAgainstRequest: false)); } @@ -411,7 +543,7 @@ public void EntryIsFresh_MinFreshReducesFreshness_ToNotFresh() MaxAge = TimeSpan.FromSeconds(10), SharedMaxAge = TimeSpan.FromSeconds(5) }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(3), verifyAgainstRequest: true)); } @@ -428,7 +560,7 @@ public void EntryIsFresh_RequestMaxAgeRestrictAge_ToNotFresh() { MaxAge = TimeSpan.FromSeconds(10), }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(6), verifyAgainstRequest: true)); } @@ -447,7 +579,7 @@ public void EntryIsFresh_MaxStaleOverridesFreshness_ToFresh() { MaxAge = TimeSpan.FromSeconds(5), }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(6), verifyAgainstRequest: true)); } @@ -467,7 +599,7 @@ public void EntryIsFresh_MustRevalidateOverridesRequestMaxStale_ToNotFresh() MaxAge = TimeSpan.FromSeconds(5), MustRevalidate = true }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(6), verifyAgainstRequest: true)); } @@ -486,11 +618,49 @@ public void EntryIsFresh_IgnoresRequestVerificationWhenSpecified() MaxAge = TimeSpan.FromSeconds(10), SharedMaxAge = TimeSpan.FromSeconds(5) }; - var context = new ResponseCachingContext(httpContext, new TestResponseCache()); + var context = CreateTestContext(httpContext); Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(3), verifyAgainstRequest: false)); } + private static ResponseCachingContext CreateTestContext(HttpContext httpContext) + { + return CreateTestContext( + httpContext, + new NoopCacheKeySuffixProvider(), + new NoopCacheabilityValidator()); + } + + private static ResponseCachingContext CreateTestContext(HttpContext httpContext, IResponseCachingCacheKeySuffixProvider cacheKeySuffixProvider) + { + return CreateTestContext( + httpContext, + cacheKeySuffixProvider, + new NoopCacheabilityValidator()); + } + + private static ResponseCachingContext CreateTestContext(HttpContext httpContext, IResponseCachingCacheabilityValidator cacheabilityValidator) + { + return CreateTestContext( + httpContext, + new NoopCacheKeySuffixProvider(), + cacheabilityValidator); + } + + private static ResponseCachingContext CreateTestContext( + HttpContext httpContext, + IResponseCachingCacheKeySuffixProvider cacheKeySuffixProvider, + IResponseCachingCacheabilityValidator cacheabilityValidator) + { + return new ResponseCachingContext( + httpContext, + new TestResponseCache(), + new SystemClock(), + null, + cacheabilityValidator, + cacheKeySuffixProvider); + } + private class TestResponseCache : IResponseCache { public object Get(string key)