From 7300d9e9366d5dbca9bfbde97db14d6ff3ba2ce1 Mon Sep 17 00:00:00 2001 From: John Luo Date: Fri, 2 Sep 2016 20:32:45 -0700 Subject: [PATCH] Refactoring ResponseCacheContext - Extract key creationg into a service - Extract cacheability checks into a service - Add a ResponseCachingState feature to preserve response cache context between operations - Recognize Set-Cookie as not-cacheable --- .../CacheabilityValidator.cs | 206 +++++ .../ResponseCachingExtensions.cs | 0 .../ResponseCachingHttpContextExtensions.cs | 12 +- ...ponseCachingServiceCollectionExtensions.cs | 8 +- .../Interfaces/ICacheabilityValidator.cs | 33 + .../Interfaces/IKeyProvider.cs | 25 + .../IResponseCachingCacheKeyModifier.cs | 17 - .../IResponseCachingCacheabilityValidator.cs | 24 - ...ySerializer.cs => CacheEntrySerializer.cs} | 66 +- ...CacheKeyModifier.cs => CachedVaryRules.cs} | 6 +- .../Internal/DistributedResponseCache.cs | 4 +- .../Internal/HttpContextInternalExtensions.cs | 44 ++ .../Internal/NoopCacheabilityValidator.cs | 14 - .../Internal/ResponseCachingState.cs | 89 +++ .../KeyProvider.cs | 157 ++++ .../OverrideResult.cs | 23 - .../ResponseCachingContext.cs | 493 ++---------- .../ResponseCachingFeature.cs | 2 +- .../ResponseCachingMiddleware.cs | 98 +-- .../CachedVaryBy.cs => VaryRules.cs} | 4 +- .../CacheEntrySerializerTests.cs | 157 ++++ .../CacheabilityValidatorTests.cs | 624 +++++++++++++++ ...efaultResponseCacheEntrySerializerTests.cs | 126 --- .../HttpContextInternalExtensionTests.cs | 37 + .../KeyProviderTests.cs | 155 ++++ .../ResponseCachingContextTests.cs | 730 +----------------- .../ResponseCachingTests.cs | 53 +- 27 files changed, 1720 insertions(+), 1487 deletions(-) create mode 100644 src/Microsoft.AspNetCore.ResponseCaching/CacheabilityValidator.cs rename src/Microsoft.AspNetCore.ResponseCaching/{ => Extensions}/ResponseCachingExtensions.cs (100%) rename src/Microsoft.AspNetCore.ResponseCaching/{ => Extensions}/ResponseCachingHttpContextExtensions.cs (63%) rename src/Microsoft.AspNetCore.ResponseCaching/{ => Extensions}/ResponseCachingServiceCollectionExtensions.cs (85%) create mode 100644 src/Microsoft.AspNetCore.ResponseCaching/Interfaces/ICacheabilityValidator.cs create mode 100644 src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IKeyProvider.cs delete mode 100644 src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheKeyModifier.cs delete mode 100644 src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheabilityValidator.cs rename src/Microsoft.AspNetCore.ResponseCaching/Internal/{DefaultResponseCacheEntrySerializer.cs => CacheEntrySerializer.cs} (74%) rename src/Microsoft.AspNetCore.ResponseCaching/Internal/{NoopCacheKeyModifier.cs => CachedVaryRules.cs} (57%) create mode 100644 src/Microsoft.AspNetCore.ResponseCaching/Internal/HttpContextInternalExtensions.cs delete mode 100644 src/Microsoft.AspNetCore.ResponseCaching/Internal/NoopCacheabilityValidator.cs create mode 100644 src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingState.cs create mode 100644 src/Microsoft.AspNetCore.ResponseCaching/KeyProvider.cs delete mode 100644 src/Microsoft.AspNetCore.ResponseCaching/OverrideResult.cs rename src/Microsoft.AspNetCore.ResponseCaching/{Internal/CachedVaryBy.cs => VaryRules.cs} (78%) create mode 100644 test/Microsoft.AspNetCore.ResponseCaching.Tests/CacheEntrySerializerTests.cs create mode 100644 test/Microsoft.AspNetCore.ResponseCaching.Tests/CacheabilityValidatorTests.cs delete mode 100644 test/Microsoft.AspNetCore.ResponseCaching.Tests/DefaultResponseCacheEntrySerializerTests.cs create mode 100644 test/Microsoft.AspNetCore.ResponseCaching.Tests/HttpContextInternalExtensionTests.cs create mode 100644 test/Microsoft.AspNetCore.ResponseCaching.Tests/KeyProviderTests.cs diff --git a/src/Microsoft.AspNetCore.ResponseCaching/CacheabilityValidator.cs b/src/Microsoft.AspNetCore.ResponseCaching/CacheabilityValidator.cs new file mode 100644 index 0000000000..0e46bd470d --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/CacheabilityValidator.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 Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Headers; +using Microsoft.AspNetCore.ResponseCaching.Internal; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.ResponseCaching +{ + public class CacheabilityValidator : ICacheabilityValidator + { + public virtual bool RequestIsCacheable(HttpContext httpContext) + { + var state = httpContext.GetResponseCachingState(); + + // 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; + if (!string.Equals("GET", request.Method, StringComparison.OrdinalIgnoreCase) && + !string.Equals("HEAD", request.Method, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Verify existence of authorization headers + // TODO: The server may indicate that the response to these request are cacheable + if (!StringValues.IsNullOrEmpty(request.Headers[HeaderNames.Authorization])) + { + return false; + } + + // Verify request cache-control parameters + // TODO: no-cache requests can be retrieved upon validation with origin + if (!StringValues.IsNullOrEmpty(request.Headers[HeaderNames.CacheControl])) + { + if (state.RequestCacheControl.NoCache) + { + return false; + } + } + else + { + // Support for legacy HTTP 1.0 cache directive + var pragmaHeaderValues = request.Headers[HeaderNames.Pragma]; + foreach (var directive in pragmaHeaderValues) + { + if (string.Equals("no-cache", directive, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + } + + // TODO: Verify global middleware settings? Explicit ignore list, range requests, etc. + return true; + } + + public virtual bool ResponseIsCacheable(HttpContext httpContext) + { + var state = httpContext.GetResponseCachingState(); + + // Only cache pages explicitly marked with public + // TODO: Consider caching responses that are not marked as public but otherwise cacheable? + if (!state.ResponseCacheControl.Public) + { + return false; + } + + // Check no-store + if (state.RequestCacheControl.NoStore || state.ResponseCacheControl.NoStore) + { + return false; + } + + // Check no-cache + // TODO: Handle no-cache with headers + if (state.ResponseCacheControl.NoCache) + { + return false; + } + + var response = httpContext.Response; + + // Do not cache responses with Set-Cookie headers + if (!StringValues.IsNullOrEmpty(response.Headers[HeaderNames.SetCookie])) + { + return false; + } + + // Do not cache responses varying by * + var varyHeader = response.Headers[HeaderNames.Vary]; + if (varyHeader.Count == 1 && string.Equals(varyHeader, "*", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // TODO: public MAY override the cacheability checks for private and status codes + + // Check private + if (state.ResponseCacheControl.Private) + { + return false; + } + + // Check response code + // TODO: RFC also lists 203, 204, 206, 300, 301, 404, 405, 410, 414, and 501 as cacheable by default + if (response.StatusCode != StatusCodes.Status200OK) + { + return false; + } + + // Check response freshness + // TODO: apparent age vs corrected age value + if (state.ResponseHeaders.Date == null) + { + if (state.ResponseCacheControl.SharedMaxAge == null && + state.ResponseCacheControl.MaxAge == null && + state.ResponseTime > state.ResponseHeaders.Expires) + { + return false; + } + } + else + { + var age = state.ResponseTime - state.ResponseHeaders.Date.Value; + + // Validate shared max age + if (age > state.ResponseCacheControl.SharedMaxAge) + { + return false; + } + else if (state.ResponseCacheControl.SharedMaxAge == null) + { + // Validate max age + if (age > state.ResponseCacheControl.MaxAge) + { + return false; + } + else if (state.ResponseCacheControl.MaxAge == null) + { + // Validate expiration + if (state.ResponseTime > state.ResponseHeaders.Expires) + { + return false; + } + } + } + } + + return true; + } + + public virtual bool CachedEntryIsFresh(HttpContext httpContext, ResponseHeaders cachedResponseHeaders) + { + var state = httpContext.GetResponseCachingState(); + var age = state.CachedEntryAge; + + // Add min-fresh requirements + if (state.RequestCacheControl.MinFresh != null) + { + age += state.RequestCacheControl.MinFresh.Value; + } + + // Validate shared max age, this overrides any max age settings for shared caches + if (age > cachedResponseHeaders.CacheControl.SharedMaxAge) + { + // shared max age implies must revalidate + return false; + } + else if (cachedResponseHeaders.CacheControl.SharedMaxAge == null) + { + // Validate max age + if (age > cachedResponseHeaders.CacheControl.MaxAge || age > state.RequestCacheControl.MaxAge) + { + // Must revalidate + if (cachedResponseHeaders.CacheControl.MustRevalidate) + { + return false; + } + + // Request allows stale values + if (age < state.RequestCacheControl.MaxStaleLimit) + { + // TODO: Add warning header indicating the response is stale + return true; + } + + return false; + } + else if (cachedResponseHeaders.CacheControl.MaxAge == null && state.RequestCacheControl.MaxAge == null) + { + // Validate expiration + if (state.ResponseTime > cachedResponseHeaders.Expires) + { + return false; + } + } + } + + return true; + } + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs b/src/Microsoft.AspNetCore.ResponseCaching/Extensions/ResponseCachingExtensions.cs similarity index 100% rename from src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs rename to src/Microsoft.AspNetCore.ResponseCaching/Extensions/ResponseCachingExtensions.cs diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingHttpContextExtensions.cs b/src/Microsoft.AspNetCore.ResponseCaching/Extensions/ResponseCachingHttpContextExtensions.cs similarity index 63% rename from src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingHttpContextExtensions.cs rename to src/Microsoft.AspNetCore.ResponseCaching/Extensions/ResponseCachingHttpContextExtensions.cs index e446b4491b..ae6481949c 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingHttpContextExtensions.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Extensions/ResponseCachingHttpContextExtensions.cs @@ -2,25 +2,21 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.ResponseCaching.Internal; namespace Microsoft.AspNetCore.ResponseCaching { // TODO: Temporary interface for endpoints to specify options for response caching public static class ResponseCachingHttpContextExtensions { - public static void AddResponseCachingFeature(this HttpContext httpContext) + public static ResponseCachingState GetResponseCachingState(this HttpContext httpContext) { - httpContext.Features.Set(new ResponseCachingFeature()); - } - - public static void RemoveResponseCachingFeature(this HttpContext httpContext) - { - httpContext.Features.Set(null); + return httpContext.Features.Get(); } public static ResponseCachingFeature GetResponseCachingFeature(this HttpContext httpContext) { - return httpContext.Features.Get() ?? new ResponseCachingFeature(); + return httpContext.Features.Get(); } } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.ResponseCaching/Extensions/ResponseCachingServiceCollectionExtensions.cs similarity index 85% rename from src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingServiceCollectionExtensions.cs rename to src/Microsoft.AspNetCore.ResponseCaching/Extensions/ResponseCachingServiceCollectionExtensions.cs index c2ad4a050d..4877d34bdc 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Extensions/ResponseCachingServiceCollectionExtensions.cs @@ -16,7 +16,7 @@ namespace Microsoft.Extensions.DependencyInjection { throw new ArgumentNullException(nameof(services)); } - + services.AddMemoryCache(); services.AddResponseCachingServices(); services.TryAdd(ServiceDescriptor.Singleton()); @@ -30,7 +30,7 @@ namespace Microsoft.Extensions.DependencyInjection { throw new ArgumentNullException(nameof(services)); } - + services.AddDistributedMemoryCache(); services.AddResponseCachingServices(); services.TryAdd(ServiceDescriptor.Singleton()); @@ -40,8 +40,8 @@ namespace Microsoft.Extensions.DependencyInjection private static IServiceCollection AddResponseCachingServices(this IServiceCollection services) { - services.TryAdd(ServiceDescriptor.Singleton()); - services.TryAdd(ServiceDescriptor.Singleton()); + services.TryAdd(ServiceDescriptor.Singleton()); + services.TryAdd(ServiceDescriptor.Singleton()); return services; } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/ICacheabilityValidator.cs b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/ICacheabilityValidator.cs new file mode 100644 index 0000000000..01ded6818a --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/ICacheabilityValidator.cs @@ -0,0 +1,33 @@ +// 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; +using Microsoft.AspNetCore.Http.Headers; + +namespace Microsoft.AspNetCore.ResponseCaching +{ + public interface ICacheabilityValidator + { + /// + /// Determine the cacheability of an HTTP request. + /// + /// The . + /// true if the request is cacheable; otherwise false. + bool RequestIsCacheable(HttpContext httpContext); + + /// + /// Determine the cacheability of an HTTP response. + /// + /// The . + /// true if the response is cacheable; otherwise false. + bool ResponseIsCacheable(HttpContext httpContext); + + /// + /// Determine the freshness of the cached entry. + /// + /// The . + /// The of the cached entry. + /// true if the cached entry is fresh; otherwise false. + bool CachedEntryIsFresh(HttpContext httpContext, ResponseHeaders cachedResponseHeaders); + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IKeyProvider.cs b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IKeyProvider.cs new file mode 100644 index 0000000000..888498f908 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IKeyProvider.cs @@ -0,0 +1,25 @@ +// 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 IKeyProvider + { + /// + /// Create a key using the HTTP request. + /// + /// The . + /// The created base key. + string CreateBaseKey(HttpContext httpContext); + + /// + /// Create a key using the HTTP context and vary rules. + /// + /// The . + /// The . + /// The created base key. + string CreateVaryKey(HttpContext httpContext, VaryRules varyRules); + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheKeyModifier.cs b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheKeyModifier.cs deleted file mode 100644 index d78c27cd90..0000000000 --- a/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheKeyModifier.cs +++ /dev/null @@ -1,17 +0,0 @@ -// 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 IResponseCachingCacheKeyModifier - { - /// - /// Create a key segment that is prepended to the default cache key. - /// - /// The . - /// The key segment that will be prepended to the default cache key. - string CreatKeyPrefix(HttpContext httpContext); - } -} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheabilityValidator.cs b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheabilityValidator.cs deleted file mode 100644 index 11b46560c8..0000000000 --- a/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IResponseCachingCacheabilityValidator.cs +++ /dev/null @@ -1,24 +0,0 @@ -// 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/DefaultResponseCacheEntrySerializer.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntrySerializer.cs similarity index 74% rename from src/Microsoft.AspNetCore.ResponseCaching/Internal/DefaultResponseCacheEntrySerializer.cs rename to src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntrySerializer.cs index e0079b1a2c..0f4f3f5a37 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/DefaultResponseCacheEntrySerializer.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntrySerializer.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.ResponseCaching.Internal { - internal static class DefaultResponseCacheSerializer + internal static class CacheEntrySerializer { private const int FormatVersion = 1; @@ -37,8 +37,8 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal // Serialization Format // Format version (int) - // Type (char: 'R' for CachedResponse, 'V' for CachedVaryBy) - // Type-dependent data (see CachedResponse and CachedVaryBy) + // Type (char: 'R' for CachedResponse, 'V' for CachedVaryRules) + // Type-dependent data (see CachedResponse and CachedVaryRules) public static object Read(BinaryReader reader) { if (reader == null) @@ -60,11 +60,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal } else if (type == 'V') { - var cachedResponse = ReadCachedVaryBy(reader); - return cachedResponse; + var cachedVaryRules = ReadCachedVaryRules(reader); + return cachedVaryRules; } - // Unable to read as CachedResponse or CachedVaryBy + // Unable to read as CachedResponse or CachedVaryRules return null; } @@ -96,12 +96,19 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal } // Serialization Format - // Headers count - // Headers if count > 0 (comma separated string) - // Params count - // Params if count > 0 (comma separated string) - private static CachedVaryBy ReadCachedVaryBy(BinaryReader reader) + // ContainsVaryRules (bool) + // If containing vary rules: + // Headers count + // Headers if count > 0 (comma separated string) + // Params count + // Params if count > 0 (comma separated string) + private static CachedVaryRules ReadCachedVaryRules(BinaryReader reader) { + if (!reader.ReadBoolean()) + { + return new CachedVaryRules(); + } + var headerCount = reader.ReadInt32(); var headers = new string[headerCount]; for (var index = 0; index < headerCount; index++) @@ -115,7 +122,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal param[index] = reader.ReadString(); } - return new CachedVaryBy { Headers = headers, Params = param }; + return new CachedVaryRules { VaryRules = new VaryRules() { Headers = headers, Params = param } }; } // See serialization format above @@ -132,14 +139,16 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal } writer.Write(FormatVersion); - + if (entry is CachedResponse) { + writer.Write('R'); WriteCachedResponse(writer, entry as CachedResponse); } - else if (entry is CachedVaryBy) + else if (entry is CachedVaryRules) { - WriteCachedVaryBy(writer, entry as CachedVaryBy); + writer.Write('V'); + WriteCachedVaryRules(writer, entry as CachedVaryRules); } else { @@ -150,7 +159,6 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal // See serialization format above private static void WriteCachedResponse(BinaryWriter writer, CachedResponse entry) { - writer.Write('R'); writer.Write(entry.Created.UtcTicks); writer.Write(entry.StatusCode); writer.Write(entry.Headers.Count); @@ -165,20 +173,26 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal } // See serialization format above - private static void WriteCachedVaryBy(BinaryWriter writer, CachedVaryBy entry) + private static void WriteCachedVaryRules(BinaryWriter writer, CachedVaryRules varyRules) { - writer.Write('V'); - - writer.Write(entry.Headers.Count); - foreach (var header in entry.Headers) + if (varyRules.VaryRules == null) { - writer.Write(header); + writer.Write(false); } - - writer.Write(entry.Params.Count); - foreach (var param in entry.Params) + else { - writer.Write(param); + writer.Write(true); + writer.Write(varyRules.VaryRules.Headers.Count); + foreach (var header in varyRules.VaryRules.Headers) + { + writer.Write(header); + } + + writer.Write(varyRules.VaryRules.Params.Count); + foreach (var param in varyRules.VaryRules.Params) + { + writer.Write(param); + } } } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/NoopCacheKeyModifier.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedVaryRules.cs similarity index 57% rename from src/Microsoft.AspNetCore.ResponseCaching/Internal/NoopCacheKeyModifier.cs rename to src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedVaryRules.cs index a32332c366..bd74a08c8c 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/NoopCacheKeyModifier.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedVaryRules.cs @@ -1,12 +1,10 @@ // 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 NoopCacheKeyModifier : IResponseCachingCacheKeyModifier + internal class CachedVaryRules { - public string CreatKeyPrefix(HttpContext httpContext) => null; + internal VaryRules VaryRules; } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/DistributedResponseCache.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/DistributedResponseCache.cs index c9068b6288..7de5d8b946 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/DistributedResponseCache.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/DistributedResponseCache.cs @@ -24,7 +24,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal { try { - return DefaultResponseCacheSerializer.Deserialize(_cache.Get(key)); + return CacheEntrySerializer.Deserialize(_cache.Get(key)); } catch { @@ -51,7 +51,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal { _cache.Set( key, - DefaultResponseCacheSerializer.Serialize(entry), + CacheEntrySerializer.Serialize(entry), new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = validFor diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/HttpContextInternalExtensions.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/HttpContextInternalExtensions.cs new file mode 100644 index 0000000000..3c148ef340 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/HttpContextInternalExtensions.cs @@ -0,0 +1,44 @@ +// 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; + +namespace Microsoft.AspNetCore.ResponseCaching.Internal +{ + internal static class HttpContextInternalExtensions + { + internal static void AddResponseCachingFeature(this HttpContext httpContext) + { + if (httpContext.GetResponseCachingFeature() != null) + { + throw new InvalidOperationException($"Another instance of {nameof(ResponseCachingFeature)} already exists. Only one instance of {nameof(ResponseCachingMiddleware)} can be configured for an application."); + } + httpContext.Features.Set(new ResponseCachingFeature()); + } + + internal static void RemoveResponseCachingFeature(this HttpContext httpContext) + { + httpContext.Features.Set(null); + } + + internal static void AddResponseCachingState(this HttpContext httpContext) + { + if (httpContext.GetResponseCachingState() != null) + { + throw new InvalidOperationException($"Another instance of {nameof(ResponseCachingState)} already exists. Only one instance of {nameof(ResponseCachingMiddleware)} can be configured for an application."); + } + httpContext.Features.Set(new ResponseCachingState(httpContext)); + } + + internal static void RemoveResponseCachingState(this HttpContext httpContext) + { + httpContext.Features.Set(null); + } + + internal static ResponseCachingState GetResponseCachingState(this HttpContext httpContext) + { + return httpContext.Features.Get(); + } + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/NoopCacheabilityValidator.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/NoopCacheabilityValidator.cs deleted file mode 100644 index 309e900f4b..0000000000 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/NoopCacheabilityValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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/Internal/ResponseCachingState.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingState.cs new file mode 100644 index 0000000000..3ff862a502 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingState.cs @@ -0,0 +1,89 @@ +// 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.Http.Headers; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.ResponseCaching.Internal +{ + public class ResponseCachingState + { + private static readonly CacheControlHeaderValue EmptyCacheControl = new CacheControlHeaderValue(); + + private readonly HttpContext _httpContext; + + private RequestHeaders _requestHeaders; + private ResponseHeaders _responseHeaders; + private CacheControlHeaderValue _requestCacheControl; + private CacheControlHeaderValue _responseCacheControl; + + internal ResponseCachingState(HttpContext httpContext) + { + _httpContext = httpContext; + } + + public bool ShouldCacheResponse { get; internal set; } + + public string BaseKey { get; internal set; } + + public string VaryKey { get; internal set; } + + public DateTimeOffset ResponseTime { get; internal set; } + + public TimeSpan CachedEntryAge { get; internal set; } + + public TimeSpan CachedResponseValidFor { get; internal set; } + + internal CachedResponse CachedResponse { get; set; } + + public RequestHeaders RequestHeaders + { + get + { + if (_requestHeaders == null) + { + _requestHeaders = _httpContext.Request.GetTypedHeaders(); + } + return _requestHeaders; + } + } + + public ResponseHeaders ResponseHeaders + { + get + { + if (_responseHeaders == null) + { + _responseHeaders = _httpContext.Response.GetTypedHeaders(); + } + return _responseHeaders; + } + } + + public CacheControlHeaderValue RequestCacheControl + { + get + { + if (_requestCacheControl == null) + { + _requestCacheControl = RequestHeaders.CacheControl ?? EmptyCacheControl; + } + return _requestCacheControl; + } + } + + public CacheControlHeaderValue ResponseCacheControl + { + get + { + if (_responseCacheControl == null) + { + _responseCacheControl = ResponseHeaders.CacheControl ?? EmptyCacheControl; + } + return _responseCacheControl; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/KeyProvider.cs b/src/Microsoft.AspNetCore.ResponseCaching/KeyProvider.cs new file mode 100644 index 0000000000..6eac525b33 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/KeyProvider.cs @@ -0,0 +1,157 @@ +// 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.Linq; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.ResponseCaching.Internal; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.ResponseCaching +{ + public class KeyProvider : IKeyProvider + { + // Use the record separator for delimiting components of the cache key to avoid possible collisions + private static readonly char KeyDelimiter = '\x1e'; + + private readonly ObjectPool _builderPool; + private readonly ResponseCachingOptions _options; + + public KeyProvider(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; + } + + // GET/PATH + // TODO: Method invariant retrieval? E.g. HEAD after GET to the same resource. + public virtual string CreateBaseKey(HttpContext httpContext) + { + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + var request = httpContext.Request; + var builder = _builderPool.Get(); + + try + { + builder + .Append(request.Method.ToUpperInvariant()) + .Append(KeyDelimiter) + .Append(_options.CaseSensitivePaths ? request.Path.Value : request.Path.Value.ToUpperInvariant()); + + return builder.ToString();; + } + finally + { + _builderPool.Return(builder); + } + } + + // BaseKeyHHeaderName=HeaderValueQQueryName=QueryValue + public virtual string CreateVaryKey(HttpContext httpContext, VaryRules varyRules) + { + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + if (varyRules == null) + { + // TODO: replace this with a GUID + return httpContext.GetResponseCachingState()?.BaseKey ?? CreateBaseKey(httpContext); + } + + var request = httpContext.Request; + var builder = _builderPool.Get(); + + try + { + // TODO: replace this with a GUID + builder.Append(httpContext.GetResponseCachingState()?.BaseKey ?? CreateBaseKey(httpContext)); + + // Vary by headers + if (varyRules?.Headers.Count > 0) + { + // Append a group separator for the header segment of the cache key + builder.Append(KeyDelimiter) + .Append('H'); + + foreach (var header in varyRules.Headers) + { + var value = httpContext.Request.Headers[header]; + + // TODO: How to handle null/empty string? + if (StringValues.IsNullOrEmpty(value)) + { + value = "null"; + } + + builder.Append(KeyDelimiter) + .Append(header) + .Append("=") + .Append(value); + } + } + + // Vary by query params + if (varyRules?.Params.Count > 0) + { + // Append a group separator for the query parameter segment of the cache key + builder.Append(KeyDelimiter) + .Append('Q'); + + if (varyRules.Params.Count == 1 && string.Equals(varyRules.Params[0], "*", StringComparison.Ordinal)) + { + // Vary by all available query params + foreach (var query in httpContext.Request.Query.OrderBy(q => q.Key, StringComparer.OrdinalIgnoreCase)) + { + builder.Append(KeyDelimiter) + .Append(query.Key.ToUpperInvariant()) + .Append("=") + .Append(query.Value); + } + } + else + { + foreach (var param in varyRules.Params) + { + var value = httpContext.Request.Query[param]; + + // TODO: How to handle null/empty string? + if (StringValues.IsNullOrEmpty(value)) + { + value = "null"; + } + + builder.Append(KeyDelimiter) + .Append(param) + .Append("=") + .Append(value); + } + } + } + + return builder.ToString(); + } + finally + { + _builderPool.Return(builder); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/OverrideResult.cs b/src/Microsoft.AspNetCore.ResponseCaching/OverrideResult.cs deleted file mode 100644 index e2a168aaeb..0000000000 --- a/src/Microsoft.AspNetCore.ResponseCaching/OverrideResult.cs +++ /dev/null @@ -1,23 +0,0 @@ -// 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 142ded2e5a..cd34857e84 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs @@ -4,15 +4,12 @@ using System; using System.IO; using System.Globalization; -using System.Linq; -using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; 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; @@ -20,54 +17,37 @@ namespace Microsoft.AspNetCore.ResponseCaching { internal class ResponseCachingContext { - private static readonly CacheControlHeaderValue EmptyCacheControl = new CacheControlHeaderValue(); - // Use the record separator for delimiting components of the cache key to avoid possible collisions - private static readonly char KeyDelimiter = '\x1e'; - private readonly HttpContext _httpContext; private readonly IResponseCache _cache; private readonly ResponseCachingOptions _options; - private readonly ObjectPool _builderPool; - private readonly IResponseCachingCacheabilityValidator _cacheabilityValidator; - private readonly IResponseCachingCacheKeyModifier _cacheKeyModifier; + private readonly ICacheabilityValidator _cacheabilityValidator; + private readonly IKeyProvider _keyProvider; - private string _cacheKey; - private ResponseType? _responseType; - private RequestHeaders _requestHeaders; - private ResponseHeaders _responseHeaders; - private CacheControlHeaderValue _requestCacheControl; - private CacheControlHeaderValue _responseCacheControl; - private bool? _cacheResponse; - private CachedResponse _cachedResponse; - private TimeSpan _cachedResponseValidFor; - internal DateTimeOffset _responseTime; + private ResponseCachingState _state; - // Internal for testing internal ResponseCachingContext( HttpContext httpContext, IResponseCache cache, ResponseCachingOptions options, - ObjectPool builderPool, - IResponseCachingCacheabilityValidator cacheabilityValidator, - IResponseCachingCacheKeyModifier cacheKeyModifier) + ICacheabilityValidator cacheabilityValidator, + IKeyProvider keyProvider) { _httpContext = httpContext; _cache = cache; _options = options; - _builderPool = builderPool; _cacheabilityValidator = cacheabilityValidator; - _cacheKeyModifier = cacheKeyModifier; + _keyProvider = keyProvider; } - internal bool CacheResponse + internal ResponseCachingState State { get { - if (_cacheResponse == null) + if (_state == null) { - _cacheResponse = ResponseIsCacheable(); + _state = _httpContext.GetResponseCachingState(); } - return _cacheResponse.Value; + return _state; } } @@ -79,349 +59,17 @@ namespace Microsoft.AspNetCore.ResponseCaching private IHttpSendFileFeature OriginalSendFileFeature { get; set; } - private RequestHeaders RequestHeaders - { - get - { - if (_requestHeaders == null) - { - _requestHeaders = _httpContext.Request.GetTypedHeaders(); - } - return _requestHeaders; - } - } - - private ResponseHeaders ResponseHeaders - { - get - { - if (_responseHeaders == null) - { - _responseHeaders = _httpContext.Response.GetTypedHeaders(); - } - return _responseHeaders; - } - } - - private CacheControlHeaderValue RequestCacheControl - { - get - { - if (_requestCacheControl == null) - { - _requestCacheControl = RequestHeaders.CacheControl ?? EmptyCacheControl; - } - return _requestCacheControl; - } - } - - private CacheControlHeaderValue ResponseCacheControl - { - get - { - if (_responseCacheControl == null) - { - _responseCacheControl = ResponseHeaders.CacheControl ?? EmptyCacheControl; - } - return _responseCacheControl; - } - } - - // GET;/PATH;VaryBy - // TODO: Method invariant retrieval? E.g. HEAD after GET to the same resource. - internal string CreateCacheKey() - { - return CreateCacheKey(varyBy: null); - } - - internal string CreateCacheKey(CachedVaryBy varyBy) - { - var request = _httpContext.Request; - var builder = _builderPool.Get(); - - try - { - // Prepend custom cache key prefix - var customKeyPrefix = _cacheKeyModifier.CreatKeyPrefix(_httpContext); - if (!string.IsNullOrEmpty(customKeyPrefix)) - { - builder.Append(customKeyPrefix) - .Append(KeyDelimiter); - } - - // Default key - builder - .Append(request.Method.ToUpperInvariant()) - .Append(KeyDelimiter) - .Append(_options.CaseSensitivePaths ? request.Path.Value : request.Path.Value.ToUpperInvariant()); - - // Vary by headers - if (varyBy?.Headers.Count > 0) - { - // Append a group separator for the header segment of the cache key - builder.Append(KeyDelimiter) - .Append('H'); - - // TODO: resolve key format and delimiters - foreach (var header in varyBy.Headers) - { - // TODO: Normalization of order, case? - var value = _httpContext.Request.Headers[header]; - - // TODO: How to handle null/empty string? - if (StringValues.IsNullOrEmpty(value)) - { - value = "null"; - } - - builder.Append(KeyDelimiter) - .Append(header) - .Append("=") - .Append(value); - } - } - - // Vary by query params - if (varyBy?.Params.Count > 0) - { - // Append a group separator for the query parameter segment of the cache key - builder.Append(KeyDelimiter) - .Append('Q'); - - if (varyBy.Params.Count == 1 && string.Equals(varyBy.Params[0], "*", StringComparison.Ordinal)) - { - // Vary by all available query params - foreach (var query in _httpContext.Request.Query.OrderBy(q => q.Key, StringComparer.OrdinalIgnoreCase)) - { - builder.Append(KeyDelimiter) - .Append(query.Key.ToUpperInvariant()) - .Append("=") - .Append(query.Value); - } - } - else - { - // TODO: resolve key format and delimiters - foreach (var param in varyBy.Params) - { - // TODO: Normalization of order, case? - var value = _httpContext.Request.Query[param]; - - // TODO: How to handle null/empty string? - if (StringValues.IsNullOrEmpty(value)) - { - value = "null"; - } - - builder.Append(KeyDelimiter) - .Append(param) - .Append("=") - .Append(value); - } - } - } - - return builder.ToString(); - } - finally - { - _builderPool.Return(builder); - } - } - - 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; - if (string.Equals("GET", request.Method, StringComparison.OrdinalIgnoreCase)) - { - _responseType = ResponseType.FullReponse; - } - else if (string.Equals("HEAD", request.Method, StringComparison.OrdinalIgnoreCase)) - { - _responseType = ResponseType.HeadersOnly; - } - else - { - return false; - } - - // Verify existence of authorization headers - // TODO: The server may indicate that the response to these request are cacheable - if (!string.IsNullOrEmpty(request.Headers[HeaderNames.Authorization])) - { - return false; - } - - // Verify request cache-control parameters - // TODO: no-cache requests can be retrieved upon validation with origin - if (!string.IsNullOrEmpty(request.Headers[HeaderNames.CacheControl])) - { - if (RequestCacheControl.NoCache) - { - return false; - } - } - else - { - // Support for legacy HTTP 1.0 cache directive - var pragmaHeaderValues = request.Headers[HeaderNames.Pragma]; - foreach (var directive in pragmaHeaderValues) - { - if (string.Equals("no-cache", directive, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - } - } - - // TODO: Verify global middleware settings? Explicit ignore list, range requests, etc. - return true; - } - - 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) - { - return false; - } - - // Check no-store - if (RequestCacheControl.NoStore || ResponseCacheControl.NoStore) - { - return false; - } - - // Check no-cache - // TODO: Handle no-cache with headers - if (ResponseCacheControl.NoCache) - { - return false; - } - - var response = _httpContext.Response; - - // Do not cache responses varying by * - if (string.Equals(response.Headers[HeaderNames.Vary], "*", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - // TODO: public MAY override the cacheability checks for private and status codes - - // Check private - if (ResponseCacheControl.Private) - { - return false; - } - - // Check response code - // TODO: RFC also lists 203, 204, 206, 300, 301, 404, 405, 410, 414, and 501 as cacheable by default - if (response.StatusCode != StatusCodes.Status200OK) - { - return false; - } - - // Check response freshness - // TODO: apparent age vs corrected age value - var responseAge = _responseTime - ResponseHeaders.Date ?? TimeSpan.Zero; - if (!EntryIsFresh(ResponseHeaders, responseAge, verifyAgainstRequest: false)) - { - return false; - } - - return true; - } - - internal bool EntryIsFresh(ResponseHeaders responseHeaders, TimeSpan age, bool verifyAgainstRequest) - { - var responseCacheControl = responseHeaders.CacheControl ?? EmptyCacheControl; - - // Add min-fresh requirements - if (verifyAgainstRequest) - { - age += RequestCacheControl.MinFresh ?? TimeSpan.Zero; - } - - // Validate shared max age, this overrides any max age settings for shared caches - if (age > responseCacheControl.SharedMaxAge) - { - // shared max age implies must revalidate - return false; - } - else if (responseCacheControl.SharedMaxAge == null) - { - // Validate max age - if (age > responseCacheControl.MaxAge || (verifyAgainstRequest && age > RequestCacheControl.MaxAge)) - { - // Must revalidate - if (responseCacheControl.MustRevalidate) - { - return false; - } - - // Request allows stale values - if (verifyAgainstRequest && age < RequestCacheControl.MaxStaleLimit) - { - // TODO: Add warning header indicating the response is stale - return true; - } - - return false; - } - else if (responseCacheControl.MaxAge == null && (!verifyAgainstRequest || RequestCacheControl.MaxAge == null)) - { - // Validate expiration - if (_responseTime > responseHeaders.Expires) - { - return false; - } - } - } - - return true; - } - internal async Task TryServeFromCacheAsync() { - _cacheKey = CreateCacheKey(); - var cacheEntry = _cache.Get(_cacheKey); + State.BaseKey = _keyProvider.CreateBaseKey(_httpContext); + var cacheEntry = _cache.Get(State.BaseKey); var responseServed = false; - if (cacheEntry is CachedVaryBy) + if (cacheEntry is CachedVaryRules) { - // Request contains VaryBy rules, recompute key and try again - _cacheKey = CreateCacheKey(cacheEntry as CachedVaryBy); - cacheEntry = _cache.Get(_cacheKey); + // Request contains vary rules, recompute key and try again + var varyKey = _keyProvider.CreateVaryKey(_httpContext, ((CachedVaryRules)cacheEntry).VaryRules); + cacheEntry = _cache.Get(varyKey); } if (cacheEntry is CachedResponse) @@ -429,17 +77,18 @@ namespace Microsoft.AspNetCore.ResponseCaching var cachedResponse = cacheEntry as CachedResponse; var cachedResponseHeaders = new ResponseHeaders(cachedResponse.Headers); - _responseTime = _options.SystemClock.UtcNow; - var age = _responseTime - cachedResponse.Created; - age = age > TimeSpan.Zero ? age : TimeSpan.Zero; + State.ResponseTime = _options.SystemClock.UtcNow; + var cachedEntryAge = State.ResponseTime - cachedResponse.Created; + State.CachedEntryAge = cachedEntryAge > TimeSpan.Zero ? cachedEntryAge : TimeSpan.Zero; - if (EntryIsFresh(cachedResponseHeaders, age, verifyAgainstRequest: true)) + if (_cacheabilityValidator.CachedEntryIsFresh(_httpContext, cachedResponseHeaders)) { + responseServed = true; + // Check conditional request rules if (ConditionalRequestSatisfied(cachedResponseHeaders)) { _httpContext.Response.StatusCode = StatusCodes.Status304NotModified; - responseServed = true; } else { @@ -451,33 +100,20 @@ namespace Microsoft.AspNetCore.ResponseCaching response.Headers.Add(header); } - response.Headers[HeaderNames.Age] = age.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture); + response.Headers[HeaderNames.Age] = State.CachedEntryAge.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture); - if (_responseType == ResponseType.HeadersOnly) - { - responseServed = true; - } - else if (_responseType == ResponseType.FullReponse) - { - // Copy the cached response body - var body = cachedResponse.Body; + var body = cachedResponse.Body; + + // Copy the cached response body + if (body.Length > 0) + { // Add a content-length if required - if (response.ContentLength == null && string.IsNullOrEmpty(response.Headers[HeaderNames.TransferEncoding])) + if (response.ContentLength == null && StringValues.IsNullOrEmpty(response.Headers[HeaderNames.TransferEncoding])) { response.ContentLength = body.Length; } - - if (body.Length > 0) - { - await response.Body.WriteAsync(body, 0, body.Length); - } - - responseServed = true; - } - else - { - throw new InvalidOperationException($"{nameof(_responseType)} not specified or is unrecognized."); + await response.Body.WriteAsync(body, 0, body.Length); } } } @@ -487,7 +123,7 @@ namespace Microsoft.AspNetCore.ResponseCaching } } - if (!responseServed && RequestCacheControl.OnlyIfCached) + if (!responseServed && State.RequestCacheControl.OnlyIfCached) { _httpContext.Response.StatusCode = StatusCodes.Status504GatewayTimeout; responseServed = true; @@ -498,7 +134,7 @@ namespace Microsoft.AspNetCore.ResponseCaching internal bool ConditionalRequestSatisfied(ResponseHeaders cachedResponseHeaders) { - var ifNoneMatchHeader = RequestHeaders.IfNoneMatch; + var ifNoneMatchHeader = State.RequestHeaders.IfNoneMatch; if (ifNoneMatchHeader != null) { @@ -518,7 +154,7 @@ namespace Microsoft.AspNetCore.ResponseCaching } } } - else if ((cachedResponseHeaders.LastModified ?? cachedResponseHeaders.Date) <= RequestHeaders.IfUnmodifiedSince) + else if ((cachedResponseHeaders.LastModified ?? cachedResponseHeaders.Date) <= State.RequestHeaders.IfUnmodifiedSince) { return true; } @@ -528,57 +164,56 @@ namespace Microsoft.AspNetCore.ResponseCaching internal void FinalizeCachingHeaders() { - if (CacheResponse) + if (_cacheabilityValidator.ResponseIsCacheable(_httpContext)) { + State.ShouldCacheResponse = true; + // Create the cache entry now var response = _httpContext.Response; var varyHeaderValue = response.Headers[HeaderNames.Vary]; - var varyParamsValue = _httpContext.GetResponseCachingFeature().VaryByParams; - _cachedResponseValidFor = ResponseCacheControl.SharedMaxAge - ?? ResponseCacheControl.MaxAge - ?? (ResponseHeaders.Expires - _responseTime) + var varyParamsValue = _httpContext.GetResponseCachingFeature()?.VaryParams ?? StringValues.Empty; + State.CachedResponseValidFor = State.ResponseCacheControl.SharedMaxAge + ?? State.ResponseCacheControl.MaxAge + ?? (State.ResponseHeaders.Expires - State.ResponseTime) // TODO: Heuristics for expiration? ?? TimeSpan.FromSeconds(10); - // Check if any VaryBy rules exist + // Check if any vary rules exist if (!StringValues.IsNullOrEmpty(varyHeaderValue) || !StringValues.IsNullOrEmpty(varyParamsValue)) { - if (varyParamsValue.Count > 1) + var cachedVaryRules = new CachedVaryRules { - Array.Sort(varyParamsValue.ToArray(), StringComparer.OrdinalIgnoreCase); - } - - var cachedVaryBy = new CachedVaryBy - { - // TODO: VaryBy Encoding - Headers = varyHeaderValue, - Params = varyParamsValue + VaryRules = new VaryRules() + { + // TODO: Vary Encoding + Headers = varyHeaderValue, + Params = varyParamsValue + } }; // TODO: Overwrite? - _cache.Set(_cacheKey, cachedVaryBy, _cachedResponseValidFor); - _cacheKey = CreateCacheKey(cachedVaryBy); + _cache.Set(State.BaseKey, cachedVaryRules, State.CachedResponseValidFor); + State.VaryKey = _keyProvider.CreateVaryKey(_httpContext, cachedVaryRules.VaryRules); } // Ensure date header is set - if (ResponseHeaders.Date == null) + if (State.ResponseHeaders.Date == null) { - ResponseHeaders.Date = _responseTime; + State.ResponseHeaders.Date = State.ResponseTime; } // Store the response to cache - _cachedResponse = new CachedResponse + State.CachedResponse = new CachedResponse { - Created = ResponseHeaders.Date.Value, + Created = State.ResponseHeaders.Date.Value, StatusCode = _httpContext.Response.StatusCode }; - foreach (var header in ResponseHeaders.Headers) + foreach (var header in State.ResponseHeaders.Headers) { - if (!string.Equals(header.Key, HeaderNames.Age, StringComparison.OrdinalIgnoreCase) - && !string.Equals(header.Key, HeaderNames.SetCookie, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(header.Key, HeaderNames.Age, StringComparison.OrdinalIgnoreCase)) { - _cachedResponse.Headers.Add(header); + State.CachedResponse.Headers.Add(header); } } } @@ -590,11 +225,11 @@ namespace Microsoft.AspNetCore.ResponseCaching internal void FinalizeCachingBody() { - if (CacheResponse && ResponseCacheStream.BufferingEnabled) + if (State.ShouldCacheResponse && ResponseCacheStream.BufferingEnabled) { - _cachedResponse.Body = ResponseCacheStream.BufferedStream.ToArray(); + State.CachedResponse.Body = ResponseCacheStream.BufferedStream.ToArray(); - _cache.Set(_cacheKey, _cachedResponse, _cachedResponseValidFor); + _cache.Set(State.VaryKey ?? State.BaseKey, State.CachedResponse, State.CachedResponseValidFor); } } @@ -603,7 +238,7 @@ namespace Microsoft.AspNetCore.ResponseCaching if (!ResponseStarted) { ResponseStarted = true; - _responseTime = _options.SystemClock.UtcNow; + State.ResponseTime = _options.SystemClock.UtcNow; FinalizeCachingHeaders(); } @@ -640,11 +275,5 @@ namespace Microsoft.AspNetCore.ResponseCaching // TODO: Move this temporary interface with endpoint to HttpAbstractions _httpContext.RemoveResponseCachingFeature(); } - - private enum ResponseType - { - HeadersOnly = 0, - FullReponse = 1 - } } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingFeature.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingFeature.cs index 4341f20ae7..e02c8e28ec 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingFeature.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingFeature.cs @@ -8,6 +8,6 @@ namespace Microsoft.AspNetCore.ResponseCaching // TODO: Temporary interface for endpoints to specify options for response caching public class ResponseCachingFeature { - public StringValues VaryByParams { get; set; } + public StringValues VaryParams { get; set; } } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs index 240c44b4d5..f5b62fd2ae 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs @@ -2,12 +2,11 @@ // 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.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.ResponseCaching.Internal; using Microsoft.Extensions.Internal; -using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.ResponseCaching @@ -23,17 +22,15 @@ namespace Microsoft.AspNetCore.ResponseCaching private readonly RequestDelegate _next; private readonly IResponseCache _cache; private readonly ResponseCachingOptions _options; - private readonly ObjectPool _builderPool; - private readonly IResponseCachingCacheabilityValidator _cacheabilityValidator; - private readonly IResponseCachingCacheKeyModifier _cacheKeyModifier; + private readonly ICacheabilityValidator _cacheabilityValidator; + private readonly IKeyProvider _keyProvider; public ResponseCachingMiddleware( RequestDelegate next, IResponseCache cache, IOptions options, - ObjectPoolProvider poolProvider, - IResponseCachingCacheabilityValidator cacheabilityValidator, - IResponseCachingCacheKeyModifier cacheKeyModifier) + ICacheabilityValidator cacheabilityValidator, + IKeyProvider keyProvider) { if (next == null) { @@ -47,71 +44,74 @@ namespace Microsoft.AspNetCore.ResponseCaching { throw new ArgumentNullException(nameof(options)); } - if (poolProvider == null) - { - throw new ArgumentNullException(nameof(poolProvider)); - } if (cacheabilityValidator == null) { throw new ArgumentNullException(nameof(cacheabilityValidator)); } - if (cacheKeyModifier == null) + if (keyProvider == null) { - throw new ArgumentNullException(nameof(cacheKeyModifier)); + throw new ArgumentNullException(nameof(keyProvider)); } _next = next; _cache = cache; _options = options.Value; - _builderPool = poolProvider.CreateStringBuilderPool(); _cacheabilityValidator = cacheabilityValidator; - _cacheKeyModifier = cacheKeyModifier; + _keyProvider = keyProvider; } public async Task Invoke(HttpContext context) { - var cachingContext = new ResponseCachingContext( - context, - _cache, - _options, - _builderPool, - _cacheabilityValidator, - _cacheKeyModifier); + context.AddResponseCachingState(); - // Should we attempt any caching logic? - if (cachingContext.RequestIsCacheable()) + try { - // Can this request be served from cache? - if (await cachingContext.TryServeFromCacheAsync()) + var cachingContext = new ResponseCachingContext( + context, + _cache, + _options, + _cacheabilityValidator, + _keyProvider); + + // Should we attempt any caching logic? + if (_cacheabilityValidator.RequestIsCacheable(context)) { - return; + // Can this request be served from cache? + if (await cachingContext.TryServeFromCacheAsync()) + { + return; + } + + // Hook up to listen to the response stream + cachingContext.ShimResponseStream(); + + try + { + // Subscribe to OnStarting event + context.Response.OnStarting(OnStartingCallback, cachingContext); + + await _next(context); + + // If there was no response body, check the response headers now. We can cache things like redirects. + cachingContext.OnResponseStarting(); + + // Finalize the cache entry + cachingContext.FinalizeCachingBody(); + } + finally + { + cachingContext.UnshimResponseStream(); + } } - - // Hook up to listen to the response stream - cachingContext.ShimResponseStream(); - - try + else { - // Subscribe to OnStarting event - context.Response.OnStarting(OnStartingCallback, cachingContext); - + // TODO: Invalidate resources for successful unsafe methods? Required by RFC await _next(context); - - // If there was no response body, check the response headers now. We can cache things like redirects. - cachingContext.OnResponseStarting(); - - // Finalize the cache entry - cachingContext.FinalizeCachingBody(); - } - finally - { - cachingContext.UnshimResponseStream(); } } - else + finally { - // TODO: Invalidate resources for successful unsafe methods? Required by RFC - await _next(context); + context.RemoveResponseCachingState(); } } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedVaryBy.cs b/src/Microsoft.AspNetCore.ResponseCaching/VaryRules.cs similarity index 78% rename from src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedVaryBy.cs rename to src/Microsoft.AspNetCore.ResponseCaching/VaryRules.cs index 1fb1a4501d..46d9419528 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedVaryBy.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/VaryRules.cs @@ -3,9 +3,9 @@ using Microsoft.Extensions.Primitives; -namespace Microsoft.AspNetCore.ResponseCaching.Internal +namespace Microsoft.AspNetCore.ResponseCaching { - internal class CachedVaryBy + public class VaryRules { internal StringValues Headers { get; set; } internal StringValues Params { get; set; } diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/CacheEntrySerializerTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/CacheEntrySerializerTests.cs new file mode 100644 index 0000000000..e85dabe9f6 --- /dev/null +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/CacheEntrySerializerTests.cs @@ -0,0 +1,157 @@ +// 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.Linq; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.ResponseCaching.Internal; +using Xunit; + +namespace Microsoft.AspNetCore.ResponseCaching.Tests +{ + public class CacheEntrySerializerTests + { + [Fact] + public void Serialize_NullObject_Throws() + { + Assert.Throws(() => CacheEntrySerializer.Serialize(null)); + } + + [Fact] + public void Serialize_UnknownObject_Throws() + { + Assert.Throws(() => CacheEntrySerializer.Serialize(new object())); + } + + [Fact] + public void RoundTrip_CachedResponses_Succeeds() + { + var headers = new HeaderDictionary(); + headers["keyA"] = "valueA"; + headers["keyB"] = "valueB"; + var cachedEntry = new CachedResponse() + { + Created = DateTimeOffset.UtcNow, + StatusCode = StatusCodes.Status200OK, + Body = Encoding.ASCII.GetBytes("Hello world"), + Headers = headers + }; + + AssertCachedResponsesEqual(cachedEntry, (CachedResponse)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedEntry))); + } + + [Fact] + public void RoundTrip_Empty_CachedVaryRules_Succeeds() + { + var cachedVaryRules = new CachedVaryRules(); + + AssertCachedVaryRulesEqual(cachedVaryRules, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRules))); + } + + [Fact] + public void RoundTrip_CachedVaryRules_EmptyRules_Succeeds() + { + var cachedVaryRules = new CachedVaryRules() + { + VaryRules = new VaryRules() + }; + + AssertCachedVaryRulesEqual(cachedVaryRules, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRules))); + } + + [Fact] + public void RoundTrip_HeadersOnly_CachedVaryRules_Succeeds() + { + var headers = new[] { "headerA", "headerB" }; + var cachedVaryRules = new CachedVaryRules() + { + VaryRules = new VaryRules() + { + Headers = headers + } + }; + + AssertCachedVaryRulesEqual(cachedVaryRules, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRules))); + } + + [Fact] + public void RoundTrip_ParamsOnly_CachedVaryRules_Succeeds() + { + var param = new[] { "paramA", "paramB" }; + var cachedVaryRules = new CachedVaryRules() + { + VaryRules = new VaryRules() + { + Params = param + } + }; + + AssertCachedVaryRulesEqual(cachedVaryRules, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRules))); + } + + [Fact] + public void RoundTrip_HeadersAndParams_CachedVaryRules_Succeeds() + { + var headers = new[] { "headerA", "headerB" }; + var param = new[] { "paramA", "paramB" }; + var cachedVaryRules = new CachedVaryRules() + { + VaryRules = new VaryRules() + { + Headers = headers, + Params = param + } + }; + + AssertCachedVaryRulesEqual(cachedVaryRules, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRules))); + } + + [Fact] + public void Deserialize_InvalidEntries_ReturnsNull() + { + var headers = new[] { "headerA", "headerB" }; + var cachedVaryRules = new CachedVaryRules() + { + VaryRules = new VaryRules() + { + Headers = headers + } + }; + var serializedEntry = CacheEntrySerializer.Serialize(cachedVaryRules); + Array.Reverse(serializedEntry); + + Assert.Null(CacheEntrySerializer.Deserialize(serializedEntry)); + } + + private static void AssertCachedResponsesEqual(CachedResponse expected, CachedResponse actual) + { + Assert.NotNull(actual); + Assert.NotNull(expected); + Assert.Equal(expected.Created, actual.Created); + Assert.Equal(expected.StatusCode, actual.StatusCode); + Assert.Equal(expected.Headers.Count, actual.Headers.Count); + foreach (var expectedHeader in expected.Headers) + { + Assert.Equal(expectedHeader.Value, actual.Headers[expectedHeader.Key]); + } + Assert.True(expected.Body.SequenceEqual(actual.Body)); + } + + private static void AssertCachedVaryRulesEqual(CachedVaryRules expected, CachedVaryRules actual) + { + Assert.NotNull(actual); + Assert.NotNull(expected); + if (expected.VaryRules == null) + { + Assert.Null(actual.VaryRules); + } + else + { + Assert.NotNull(actual.VaryRules); + Assert.Equal(expected.VaryRules.Headers, actual.VaryRules.Headers); + Assert.Equal(expected.VaryRules.Params, actual.VaryRules.Params); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/CacheabilityValidatorTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/CacheabilityValidatorTests.cs new file mode 100644 index 0000000000..5ead7ab80e --- /dev/null +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/CacheabilityValidatorTests.cs @@ -0,0 +1,624 @@ +// 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.Http.Headers; +using Microsoft.AspNetCore.ResponseCaching.Internal; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.ResponseCaching.Tests +{ + public class CacheabilityValidatorTests + { + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + public void RequestIsCacheable_CacheableMethods_Allowed(string method) + { + var httpContext = CreateDefaultContext(); + httpContext.Request.Method = method; + + Assert.True(new CacheabilityValidator().RequestIsCacheable(httpContext)); + } + + [Theory] + [InlineData("POST")] + [InlineData("OPTIONS")] + [InlineData("PUT")] + [InlineData("DELETE")] + [InlineData("TRACE")] + [InlineData("CONNECT")] + [InlineData("")] + [InlineData(null)] + public void RequestIsCacheable_UncacheableMethods_NotAllowed(string method) + { + var httpContext = CreateDefaultContext(); + httpContext.Request.Method = method; + + Assert.False(new CacheabilityValidator().RequestIsCacheable(httpContext)); + } + + [Fact] + public void RequestIsCacheable_AuthorizationHeaders_NotAllowed() + { + var httpContext = CreateDefaultContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Headers[HeaderNames.Authorization] = "Basic plaintextUN:plaintextPW"; + + Assert.False(new CacheabilityValidator().RequestIsCacheable(httpContext)); + } + + [Fact] + public void RequestIsCacheable_NoCache_NotAllowed() + { + var httpContext = CreateDefaultContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + NoCache = true + }; + + Assert.False(new CacheabilityValidator().RequestIsCacheable(httpContext)); + } + + [Fact] + public void RequestIsCacheable_NoStore_Allowed() + { + var httpContext = CreateDefaultContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + NoStore = true + }; + + Assert.True(new CacheabilityValidator().RequestIsCacheable(httpContext)); + } + + [Fact] + public void RequestIsCacheable_LegacyDirectives_NotAllowed() + { + var httpContext = CreateDefaultContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Headers[HeaderNames.Pragma] = "no-cache"; + + Assert.False(new CacheabilityValidator().RequestIsCacheable(httpContext)); + } + + [Fact] + public void RequestIsCacheable_LegacyDirectives_OverridenByCacheControl() + { + var httpContext = CreateDefaultContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Headers[HeaderNames.Pragma] = "no-cache"; + httpContext.Request.Headers[HeaderNames.CacheControl] = "max-age=10"; + + Assert.True(new CacheabilityValidator().RequestIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_NoPublic_NotAllowed() + { + var httpContext = CreateDefaultContext(); + + Assert.False(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_Public_Allowed() + { + var httpContext = CreateDefaultContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + + Assert.True(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_NoCache_NotAllowed() + { + var httpContext = CreateDefaultContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true, + NoCache = true + }; + + Assert.False(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_RequestNoStore_NotAllowed() + { + var httpContext = CreateDefaultContext(); + httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + NoStore = true + }; + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + + Assert.False(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_ResponseNoStore_NotAllowed() + { + var httpContext = CreateDefaultContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true, + NoStore = true + }; + + Assert.False(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_SetCookieHeader_NotAllowed() + { + var httpContext = CreateDefaultContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + httpContext.Response.Headers[HeaderNames.SetCookie] = "cookieName=cookieValue"; + + Assert.False(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_VaryHeaderByStar_NotAllowed() + { + var httpContext = CreateDefaultContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + httpContext.Response.Headers[HeaderNames.Vary] = "*"; + + Assert.False(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_Private_NotAllowed() + { + var httpContext = CreateDefaultContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true, + Private = true + }; + + Assert.False(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Theory] + [InlineData(StatusCodes.Status200OK)] + public void ResponseIsCacheable_SuccessStatusCodes_Allowed(int statusCode) + { + var httpContext = CreateDefaultContext(); + httpContext.Response.StatusCode = statusCode; + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + + Assert.True(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Theory] + [InlineData(StatusCodes.Status201Created)] + [InlineData(StatusCodes.Status202Accepted)] + [InlineData(StatusCodes.Status203NonAuthoritative)] + [InlineData(StatusCodes.Status204NoContent)] + [InlineData(StatusCodes.Status205ResetContent)] + [InlineData(StatusCodes.Status206PartialContent)] + [InlineData(StatusCodes.Status207MultiStatus)] + [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.Status422UnprocessableEntity)] + [InlineData(StatusCodes.Status423Locked)] + [InlineData(StatusCodes.Status424FailedDependency)] + [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)] + public void ResponseIsCacheable_NonSuccessStatusCodes_NotAllowed(int statusCode) + { + var httpContext = CreateDefaultContext(); + httpContext.Response.StatusCode = statusCode; + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + + Assert.False(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_NoExpiryRequirements_IsAllowed() + { + var httpContext = CreateDefaultContext(); + httpContext.Response.StatusCode = StatusCodes.Status200OK; + var headers = httpContext.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + + var utcNow = DateTimeOffset.UtcNow; + headers.Date = utcNow; + httpContext.GetResponseCachingState().ResponseTime = DateTimeOffset.MaxValue; + + Assert.True(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_PastExpiry_NotAllowed() + { + var httpContext = CreateDefaultContext(); + httpContext.Response.StatusCode = StatusCodes.Status200OK; + var headers = httpContext.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + var utcNow = DateTimeOffset.UtcNow; + headers.Expires = utcNow; + + headers.Date = utcNow; + httpContext.GetResponseCachingState().ResponseTime = DateTimeOffset.MaxValue; + + Assert.False(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_MaxAgeOverridesExpiry_ToAllowed() + { + var utcNow = DateTimeOffset.UtcNow; + var httpContext = CreateDefaultContext(); + httpContext.Response.StatusCode = StatusCodes.Status200OK; + var headers = httpContext.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }; + headers.Expires = utcNow; + headers.Date = utcNow; + httpContext.GetResponseCachingState().ResponseTime = utcNow + TimeSpan.FromSeconds(9); + + Assert.True(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_MaxAgeOverridesExpiry_ToNotAllowed() + { + var utcNow = DateTimeOffset.UtcNow; + var httpContext = CreateDefaultContext(); + httpContext.Response.StatusCode = StatusCodes.Status200OK; + var headers = httpContext.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }; + headers.Expires = utcNow; + headers.Date = utcNow; + httpContext.GetResponseCachingState().ResponseTime = utcNow + TimeSpan.FromSeconds(11); + + Assert.False(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_SharedMaxAgeOverridesMaxAge_ToAllowed() + { + var utcNow = DateTimeOffset.UtcNow; + var httpContext = CreateDefaultContext(); + httpContext.Response.StatusCode = StatusCodes.Status200OK; + var headers = httpContext.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10), + SharedMaxAge = TimeSpan.FromSeconds(15) + }; + headers.Date = utcNow; + httpContext.GetResponseCachingState().ResponseTime = utcNow + TimeSpan.FromSeconds(11); + + Assert.True(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void ResponseIsCacheable_SharedMaxAgeOverridesMaxAge_ToNotFresh() + { + var utcNow = DateTimeOffset.UtcNow; + var httpContext = CreateDefaultContext(); + httpContext.Response.StatusCode = StatusCodes.Status200OK; + var headers = httpContext.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10), + SharedMaxAge = TimeSpan.FromSeconds(5) + }; + headers.Date = utcNow; + httpContext.GetResponseCachingState().ResponseTime = utcNow + TimeSpan.FromSeconds(6); + + Assert.False(new CacheabilityValidator().ResponseIsCacheable(httpContext)); + } + + [Fact] + public void EntryIsFresh_NoExpiryRequirements_IsFresh() + { + var utcNow = DateTimeOffset.UtcNow; + var httpContext = CreateDefaultContext(); + httpContext.GetResponseCachingState().ResponseTime = DateTimeOffset.MaxValue; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()) + { + CacheControl = new CacheControlHeaderValue() + { + Public = true + } + }; + + Assert.True(new CacheabilityValidator().CachedEntryIsFresh(httpContext, cachedHeaders)); + } + + [Fact] + public void EntryIsFresh_PastExpiry_IsNotFresh() + { + var httpContext = CreateDefaultContext(); + httpContext.GetResponseCachingState().ResponseTime = DateTimeOffset.MaxValue; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()) + { + CacheControl = new CacheControlHeaderValue() + { + Public = true + }, + Expires = DateTimeOffset.UtcNow + }; + + Assert.False(new CacheabilityValidator().CachedEntryIsFresh(httpContext, cachedHeaders)); + } + + [Fact] + public void EntryIsFresh_MaxAgeOverridesExpiry_ToFresh() + { + var utcNow = DateTimeOffset.UtcNow; + var httpContext = CreateDefaultContext(); + var state = httpContext.GetResponseCachingState(); + state.CachedEntryAge = TimeSpan.FromSeconds(9); + state.ResponseTime = utcNow + state.CachedEntryAge; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()) + { + CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }, + Expires = utcNow + }; + + Assert.True(new CacheabilityValidator().CachedEntryIsFresh(httpContext, cachedHeaders)); + } + + [Fact] + public void EntryIsFresh_MaxAgeOverridesExpiry_ToNotFresh() + { + var utcNow = DateTimeOffset.UtcNow; + var httpContext = CreateDefaultContext(); + var state = httpContext.GetResponseCachingState(); + state.CachedEntryAge = TimeSpan.FromSeconds(11); + state.ResponseTime = utcNow + state.CachedEntryAge; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()) + { + CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }, + Expires = utcNow + }; + + Assert.False(new CacheabilityValidator().CachedEntryIsFresh(httpContext, cachedHeaders)); + } + + [Fact] + public void EntryIsFresh_SharedMaxAgeOverridesMaxAge_ToFresh() + { + var utcNow = DateTimeOffset.UtcNow; + var httpContext = CreateDefaultContext(); + var state = httpContext.GetResponseCachingState(); + state.CachedEntryAge = TimeSpan.FromSeconds(11); + state.ResponseTime = utcNow + state.CachedEntryAge; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()) + { + CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10), + SharedMaxAge = TimeSpan.FromSeconds(15) + }, + Expires = utcNow + }; + + Assert.True(new CacheabilityValidator().CachedEntryIsFresh(httpContext, cachedHeaders)); + } + + [Fact] + public void EntryIsFresh_SharedMaxAgeOverridesMaxAge_ToNotFresh() + { + var utcNow = DateTimeOffset.UtcNow; + var httpContext = CreateDefaultContext(); + var state = httpContext.GetResponseCachingState(); + state.CachedEntryAge = TimeSpan.FromSeconds(6); + state.ResponseTime = utcNow + state.CachedEntryAge; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()) + { + CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10), + SharedMaxAge = TimeSpan.FromSeconds(5) + }, + Expires = utcNow + }; + + Assert.False(new CacheabilityValidator().CachedEntryIsFresh(httpContext, cachedHeaders)); + } + + [Fact] + public void EntryIsFresh_MinFreshReducesFreshness_ToNotFresh() + { + var httpContext = CreateDefaultContext(); + httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + MinFresh = TimeSpan.FromSeconds(3) + }; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()) + { + CacheControl = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(10), + SharedMaxAge = TimeSpan.FromSeconds(5) + } + }; + httpContext.GetResponseCachingState().CachedEntryAge = TimeSpan.FromSeconds(3); + + Assert.False(new CacheabilityValidator().CachedEntryIsFresh(httpContext, cachedHeaders)); + } + + [Fact] + public void EntryIsFresh_RequestMaxAgeRestrictAge_ToNotFresh() + { + var httpContext = CreateDefaultContext(); + httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5) + }; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()) + { + CacheControl = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(10), + } + }; + httpContext.GetResponseCachingState().CachedEntryAge = TimeSpan.FromSeconds(6); + + Assert.False(new CacheabilityValidator().CachedEntryIsFresh(httpContext, cachedHeaders)); + } + + [Fact] + public void EntryIsFresh_MaxStaleOverridesFreshness_ToFresh() + { + var httpContext = CreateDefaultContext(); + httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + MaxStale = true, // This value must be set to true in order to specify MaxStaleLimit + MaxStaleLimit = TimeSpan.FromSeconds(10) + }; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()) + { + CacheControl = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + } + }; + httpContext.GetResponseCachingState().CachedEntryAge = TimeSpan.FromSeconds(6); + + Assert.True(new CacheabilityValidator().CachedEntryIsFresh(httpContext, cachedHeaders)); + } + + [Fact] + public void EntryIsFresh_MustRevalidateOverridesRequestMaxStale_ToNotFresh() + { + var httpContext = CreateDefaultContext(); + httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + MaxStale = true, // This value must be set to true in order to specify MaxStaleLimit + MaxStaleLimit = TimeSpan.FromSeconds(10) + }; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()) + { + CacheControl = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(5), + MustRevalidate = true + } + }; + httpContext.GetResponseCachingState().CachedEntryAge = TimeSpan.FromSeconds(6); + + Assert.False(new CacheabilityValidator().CachedEntryIsFresh(httpContext, cachedHeaders)); + } + + [Fact] + public void EntryIsFresh_IgnoresRequestVerificationWhenSpecified() + { + var httpContext = CreateDefaultContext(); + httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + MinFresh = TimeSpan.FromSeconds(1), + MaxAge = TimeSpan.FromSeconds(3) + }; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()) + { + CacheControl = new CacheControlHeaderValue() + { + MaxAge = TimeSpan.FromSeconds(10), + SharedMaxAge = TimeSpan.FromSeconds(5) + } + }; + httpContext.GetResponseCachingState().CachedEntryAge = TimeSpan.FromSeconds(3); + + Assert.True(new CacheabilityValidator().CachedEntryIsFresh(httpContext, cachedHeaders)); + } + + private static HttpContext CreateDefaultContext() + { + var context = new DefaultHttpContext(); + context.AddResponseCachingState(); + return context; + } + } +} diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/DefaultResponseCacheEntrySerializerTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/DefaultResponseCacheEntrySerializerTests.cs deleted file mode 100644 index 2469af5ff8..0000000000 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/DefaultResponseCacheEntrySerializerTests.cs +++ /dev/null @@ -1,126 +0,0 @@ -// 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.Linq; -using Microsoft.AspNetCore.ResponseCaching.Internal; -using Xunit; -using Microsoft.AspNetCore.Http; -using System.Text; - -namespace Microsoft.AspNetCore.ResponseCaching.Tests -{ - public class DefaultResponseCacheEntrySerializerTests - { - [Fact] - public void Serialize_NullObject_Throws() - { - Assert.Throws(() => DefaultResponseCacheSerializer.Serialize(null)); - } - - [Fact] - public void Serialize_UnknownObject_Throws() - { - Assert.Throws(() => DefaultResponseCacheSerializer.Serialize(new object())); - } - - [Fact] - public void RoundTrip_CachedResponses_Succeeds() - { - var headers = new HeaderDictionary(); - headers["keyA"] = "valueA"; - headers["keyB"] = "valueB"; - var cachedEntry = new CachedResponse() - { - Created = DateTimeOffset.UtcNow, - StatusCode = StatusCodes.Status200OK, - Body = Encoding.ASCII.GetBytes("Hello world"), - Headers = headers - }; - - AssertCachedResponsesEqual(cachedEntry, (CachedResponse)DefaultResponseCacheSerializer.Deserialize(DefaultResponseCacheSerializer.Serialize(cachedEntry))); - } - - [Fact] - public void RoundTrip_Empty_CachedVaryBy_Succeeds() - { - var cachedVaryBy = new CachedVaryBy(); - - AssertCachedVarybyEqual(cachedVaryBy, (CachedVaryBy)DefaultResponseCacheSerializer.Deserialize(DefaultResponseCacheSerializer.Serialize(cachedVaryBy))); - } - - [Fact] - public void RoundTrip_HeadersOnly_CachedVaryBy_Succeeds() - { - var headers = new[] { "headerA", "headerB" }; - var cachedVaryBy = new CachedVaryBy() - { - Headers = headers - }; - - AssertCachedVarybyEqual(cachedVaryBy, (CachedVaryBy)DefaultResponseCacheSerializer.Deserialize(DefaultResponseCacheSerializer.Serialize(cachedVaryBy))); - } - - [Fact] - public void RoundTrip_ParamsOnly_CachedVaryBy_Succeeds() - { - var param = new[] { "paramA", "paramB" }; - var cachedVaryBy = new CachedVaryBy() - { - Params = param - }; - - AssertCachedVarybyEqual(cachedVaryBy, (CachedVaryBy)DefaultResponseCacheSerializer.Deserialize(DefaultResponseCacheSerializer.Serialize(cachedVaryBy))); - } - - [Fact] - public void RoundTrip_HeadersAndParams_CachedVaryBy_Succeeds() - { - var headers = new[] { "headerA", "headerB" }; - var param = new[] { "paramA", "paramB" }; - var cachedVaryBy = new CachedVaryBy() - { - Headers = headers, - Params = param - }; - - AssertCachedVarybyEqual(cachedVaryBy, (CachedVaryBy)DefaultResponseCacheSerializer.Deserialize(DefaultResponseCacheSerializer.Serialize(cachedVaryBy))); - } - - [Fact] - public void Deserialize_InvalidEntries_ReturnsNull() - { - var headers = new[] { "headerA", "headerB" }; - var cachedVaryBy = new CachedVaryBy() - { - Headers = headers - }; - var serializedEntry = DefaultResponseCacheSerializer.Serialize(cachedVaryBy); - Array.Reverse(serializedEntry); - - Assert.Null(DefaultResponseCacheSerializer.Deserialize(serializedEntry)); - } - - private static void AssertCachedResponsesEqual(CachedResponse expected, CachedResponse actual) - { - Assert.NotNull(actual); - Assert.NotNull(expected); - Assert.Equal(expected.Created, actual.Created); - Assert.Equal(expected.StatusCode, actual.StatusCode); - Assert.Equal(expected.Headers.Count, actual.Headers.Count); - foreach (var expectedHeader in expected.Headers) - { - Assert.Equal(expectedHeader.Value, actual.Headers[expectedHeader.Key]); - } - Assert.True(expected.Body.SequenceEqual(actual.Body)); - } - - private static void AssertCachedVarybyEqual(CachedVaryBy expected, CachedVaryBy actual) - { - Assert.NotNull(actual); - Assert.NotNull(expected); - Assert.Equal(expected.Headers, actual.Headers); - Assert.Equal(expected.Params, actual.Params); - } - } -} diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/HttpContextInternalExtensionTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/HttpContextInternalExtensionTests.cs new file mode 100644 index 0000000000..74e15534bf --- /dev/null +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/HttpContextInternalExtensionTests.cs @@ -0,0 +1,37 @@ +// 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 HttpContextInternalExtensionTests + { + [Fact] + public void AddingSecondResponseCachingFeature_Throws() + { + var httpContext = new DefaultHttpContext(); + + // Should not throw + httpContext.AddResponseCachingFeature(); + + // Should throw + Assert.ThrowsAny(() => httpContext.AddResponseCachingFeature()); + } + + [Fact] + public void AddingSecondResponseCachingState_Throws() + { + var httpContext = new DefaultHttpContext(); + + // Should not throw + httpContext.AddResponseCachingState(); + + // Should throw + Assert.ThrowsAny(() => httpContext.AddResponseCachingState()); + } + } +} diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/KeyProviderTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/KeyProviderTests.cs new file mode 100644 index 0000000000..8f96278fc3 --- /dev/null +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/KeyProviderTests.cs @@ -0,0 +1,155 @@ +// 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.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.ResponseCaching.Tests +{ + public class DefaultKeyProviderTests + { + private static readonly char KeyDelimiter = '\x1e'; + + [Fact] + public void DefaultKeyProvider_CreateBaseKey_IncludesOnlyNormalizedMethodAndPath() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "head"; + httpContext.Request.Path = "/path/subpath"; + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("example.com", 80); + httpContext.Request.PathBase = "/pathBase"; + httpContext.Request.QueryString = new QueryString("?query.Key=a&query.Value=b"); + var keyProvider = CreateTestKeyProvider(); + + Assert.Equal($"HEAD{KeyDelimiter}/PATH/SUBPATH", keyProvider.CreateBaseKey(httpContext)); + } + + [Fact] + public void DefaultKeyProvider_CreateBaseKey_CaseInsensitivePath_NormalizesPath() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Path = "/Path"; + var keyProvider = CreateTestKeyProvider(new ResponseCachingOptions() + { + CaseSensitivePaths = false + }); + + Assert.Equal($"GET{KeyDelimiter}/PATH", keyProvider.CreateBaseKey(httpContext)); + } + + [Fact] + public void DefaultKeyProvider_CreateBaseKey_CaseSensitivePath_PreservesPathCase() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Path = "/Path"; + var keyProvider = CreateTestKeyProvider(new ResponseCachingOptions() + { + CaseSensitivePaths = true + }); + + Assert.Equal($"GET{KeyDelimiter}/Path", keyProvider.CreateBaseKey(httpContext)); + } + + [Fact] + public void DefaultKeyProvider_CreateVaryKey_IncludesListedHeadersOnly() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Path = "/"; + httpContext.Request.Headers["HeaderA"] = "ValueA"; + httpContext.Request.Headers["HeaderB"] = "ValueB"; + var keyProvider = CreateTestKeyProvider(); + + Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=null", + keyProvider.CreateVaryKey(httpContext, new VaryRules() + { + Headers = new string[] { "HeaderA", "HeaderC" } + })); + } + + [Fact] + public void DefaultKeyProvider_CreateVaryKey_IncludesListedParamsOnly() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Path = "/"; + httpContext.Request.QueryString = new QueryString("?ParamA=ValueA&ParamB=ValueB"); + var keyProvider = CreateTestKeyProvider(); + + Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", + keyProvider.CreateVaryKey(httpContext, new VaryRules() + { + Params = new string[] { "ParamA", "ParamC" } + })); + } + + [Fact] + public void DefaultKeyProvider_CreateVaryKey_IncludesParams_ParamNameCaseInsensitive_UseParamCasing() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Path = "/"; + httpContext.Request.QueryString = new QueryString("?parama=ValueA¶mB=ValueB"); + var keyProvider = CreateTestKeyProvider(); + + Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", + keyProvider.CreateVaryKey(httpContext, new VaryRules() + { + Params = new string[] { "ParamA", "ParamC" } + })); + } + + [Fact] + public void DefaultKeyProvider_CreateVaryKey_IncludesAllQueryParamsGivenAsterisk() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Path = "/"; + httpContext.Request.QueryString = new QueryString("?ParamA=ValueA&ParamB=ValueB"); + var keyProvider = CreateTestKeyProvider(); + + // To support case insensitivity, all param keys are converted to upper case. + // Explicit params uses the casing specified in the setting. + Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}Q{KeyDelimiter}PARAMA=ValueA{KeyDelimiter}PARAMB=ValueB", + keyProvider.CreateVaryKey(httpContext, new VaryRules() + { + Params = new string[] { "*" } + })); + } + + [Fact] + public void DefaultKeyProvider_CreateVaryKey_IncludesListedHeadersAndParams() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Path = "/"; + httpContext.Request.Headers["HeaderA"] = "ValueA"; + httpContext.Request.Headers["HeaderB"] = "ValueB"; + httpContext.Request.QueryString = new QueryString("?ParamA=ValueA&ParamB=ValueB"); + var keyProvider = CreateTestKeyProvider(); + + Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=null{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", + keyProvider.CreateVaryKey(httpContext, new VaryRules() + { + Headers = new string[] { "HeaderA", "HeaderC" }, + Params = new string[] { "ParamA", "ParamC" } + })); + } + + private static IKeyProvider CreateTestKeyProvider() + { + return CreateTestKeyProvider(new ResponseCachingOptions()); + } + + private static IKeyProvider CreateTestKeyProvider(ResponseCachingOptions options) + { + return new KeyProvider(new DefaultObjectPoolProvider(), Options.Create(options)); + } + } +} diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs index 1227eb9b80..c4caca269b 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; @@ -12,6 +11,7 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Headers; using Microsoft.AspNetCore.ResponseCaching.Internal; using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; using Xunit; @@ -19,708 +19,6 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests { public class ResponseCachingContextTests { - private static readonly char KeyDelimiter = '\x1e'; - - [Theory] - [InlineData("GET")] - [InlineData("HEAD")] - public void RequestIsCacheable_CacheableMethods_Allowed(string method) - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = method; - var context = CreateTestContext(httpContext); - - Assert.True(context.RequestIsCacheable()); - } - - [Theory] - [InlineData("POST")] - [InlineData("OPTIONS")] - [InlineData("PUT")] - [InlineData("DELETE")] - [InlineData("TRACE")] - [InlineData("CONNECT")] - [InlineData("")] - [InlineData(null)] - public void RequestIsCacheable_UncacheableMethods_NotAllowed(string method) - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = method; - var context = CreateTestContext(httpContext); - - Assert.False(context.RequestIsCacheable()); - } - - [Fact] - public void RequestIsCacheable_AuthorizationHeaders_NotAllowed() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Headers[HeaderNames.Authorization] = "Basic plaintextUN:plaintextPW"; - var context = CreateTestContext(httpContext); - - Assert.False(context.RequestIsCacheable()); - } - - [Fact] - public void RequestIsCacheable_NoCache_NotAllowed() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - NoCache = true - }; - var context = CreateTestContext(httpContext); - - Assert.False(context.RequestIsCacheable()); - } - - [Fact] - public void RequestIsCacheable_NoStore_Allowed() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - NoStore = true - }; - var context = CreateTestContext(httpContext); - - Assert.True(context.RequestIsCacheable()); - } - - [Fact] - public void RequestIsCacheable_LegacyDirectives_NotAllowed() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Headers[HeaderNames.Pragma] = "no-cache"; - var context = CreateTestContext(httpContext); - - Assert.False(context.RequestIsCacheable()); - } - - [Fact] - public void RequestIsCacheable_LegacyDirectives_OverridenByCacheControl() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Headers[HeaderNames.Pragma] = "no-cache"; - httpContext.Request.Headers[HeaderNames.CacheControl] = "max-age=10"; - 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() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "head"; - httpContext.Request.Path = "/path/subpath"; - httpContext.Request.Scheme = "https"; - 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 = CreateTestContext(httpContext); - - Assert.Equal($"HEAD{KeyDelimiter}/PATH/SUBPATH", context.CreateCacheKey()); - } - - [Fact] - public void CreateCacheKey_Includes_ListedVaryByHeadersOnly() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Path = "/"; - httpContext.Request.Headers["HeaderA"] = "ValueA"; - httpContext.Request.Headers["HeaderB"] = "ValueB"; - var context = CreateTestContext(httpContext); - - Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=null", context.CreateCacheKey(new CachedVaryBy() - { - Headers = new string[] { "HeaderA", "HeaderC" } - })); - } - - [Fact] - public void CreateCacheKey_Includes_ListedVaryByParamsOnly() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Path = "/"; - httpContext.Request.QueryString = new QueryString("?ParamA=ValueA&ParamB=ValueB"); - var context = CreateTestContext(httpContext); - - Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", context.CreateCacheKey(new CachedVaryBy() - { - Params = new string[] { "ParamA", "ParamC" } - })); - } - - [Fact] - public void CreateCacheKey_Includes_VaryByParams_ParamNameCaseInsensitive_UseVaryByCasing() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Path = "/"; - httpContext.Request.QueryString = new QueryString("?parama=ValueA¶mB=ValueB"); - var context = CreateTestContext(httpContext); - - Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", context.CreateCacheKey(new CachedVaryBy() - { - Params = new string[] { "ParamA", "ParamC" } - })); - } - - [Fact] - public void CreateCacheKey_Includes_AllQueryParamsGivenAsterisk() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Path = "/"; - httpContext.Request.QueryString = new QueryString("?ParamA=ValueA&ParamB=ValueB"); - var context = CreateTestContext(httpContext); - - // To support case insensitivity, all param keys are converted to lower case. - // Explicit VaryBy uses the casing specified in the setting. - Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}Q{KeyDelimiter}PARAMA=ValueA{KeyDelimiter}PARAMB=ValueB", context.CreateCacheKey(new CachedVaryBy() - { - Params = new string[] { "*" } - })); - } - - [Fact] - public void CreateCacheKey_Includes_ListedVaryByHeadersAndParams() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Path = "/"; - httpContext.Request.Headers["HeaderA"] = "ValueA"; - httpContext.Request.Headers["HeaderB"] = "ValueB"; - httpContext.Request.QueryString = new QueryString("?ParamA=ValueA&ParamB=ValueB"); - var context = CreateTestContext(httpContext); - - Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=null{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", context.CreateCacheKey(new CachedVaryBy() - { - Headers = new string[] { "HeaderA", "HeaderC" }, - Params = new string[] { "ParamA", "ParamC" } - })); - } - - private class KeyModifier : IResponseCachingCacheKeyModifier - { - public string CreatKeyPrefix(HttpContext httpContext) => "CustomizedKeyPrefix"; - } - - [Fact] - public void CreateCacheKey_CacheKeyModifier_AddsPrefix() - { - 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 KeyModifier()); - - Assert.Equal($"CustomizedKeyPrefix{KeyDelimiter}GET{KeyDelimiter}/{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=null", responseCachingContext.CreateCacheKey(new CachedVaryBy() - { - Headers = new string[] { "HeaderA", "HeaderC" } - })); - } - - [Fact] - public void CreateCacheKey_CaseInsensitivePath_NormalizesPath() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Path = "/Path"; - var context = CreateTestContext(httpContext, new ResponseCachingOptions() - { - CaseSensitivePaths = false - }); - - Assert.Equal($"GET{KeyDelimiter}/PATH", context.CreateCacheKey()); - } - - [Fact] - public void CreateCacheKey_CaseSensitivePath_PreservesPathCase() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Path = "/Path"; - var context = CreateTestContext(httpContext, new ResponseCachingOptions() - { - CaseSensitivePaths = true - }); - - Assert.Equal($"GET{KeyDelimiter}/Path", context.CreateCacheKey()); - } - - [Fact] - public void ResponseIsCacheable_NoPublic_NotAllowed() - { - var httpContext = new DefaultHttpContext(); - var context = CreateTestContext(httpContext); - - Assert.False(context.ResponseIsCacheable()); - } - - [Fact] - public void ResponseIsCacheable_Public_Allowed() - { - var httpContext = new DefaultHttpContext(); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true - }; - var context = CreateTestContext(httpContext); - - Assert.True(context.ResponseIsCacheable()); - } - - [Fact] - public void ResponseIsCacheable_NoCache_NotAllowed() - { - var httpContext = new DefaultHttpContext(); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true, - NoCache = true - }; - var context = CreateTestContext(httpContext); - - Assert.False(context.ResponseIsCacheable()); - } - - [Fact] - public void ResponseIsCacheable_RequestNoStore_NotAllowed() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - NoStore = true - }; - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true - }; - var context = CreateTestContext(httpContext); - - Assert.False(context.ResponseIsCacheable()); - } - - [Fact] - public void ResponseIsCacheable_ResponseNoStore_NotAllowed() - { - var httpContext = new DefaultHttpContext(); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true, - NoStore = true - }; - var context = CreateTestContext(httpContext); - - Assert.False(context.ResponseIsCacheable()); - } - - [Fact] - public void ResponseIsCacheable_VaryByStar_NotAllowed() - { - var httpContext = new DefaultHttpContext(); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true - }; - httpContext.Response.Headers[HeaderNames.Vary] = "*"; - var context = CreateTestContext(httpContext); - - Assert.False(context.ResponseIsCacheable()); - } - - [Fact] - public void ResponseIsCacheable_Private_NotAllowed() - { - var httpContext = new DefaultHttpContext(); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true, - Private = true - }; - var context = CreateTestContext(httpContext); - - Assert.False(context.ResponseIsCacheable()); - } - - [Theory] - [InlineData(StatusCodes.Status200OK)] - public void ResponseIsCacheable_SuccessStatusCodes_Allowed(int statusCode) - { - var httpContext = new DefaultHttpContext(); - httpContext.Response.StatusCode = statusCode; - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true - }; - var context = CreateTestContext(httpContext); - - Assert.True(context.ResponseIsCacheable()); - } - - [Theory] - [InlineData(StatusCodes.Status201Created)] - [InlineData(StatusCodes.Status202Accepted)] - [InlineData(StatusCodes.Status203NonAuthoritative)] - [InlineData(StatusCodes.Status204NoContent)] - [InlineData(StatusCodes.Status205ResetContent)] - [InlineData(StatusCodes.Status206PartialContent)] - [InlineData(StatusCodes.Status207MultiStatus)] - [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.Status422UnprocessableEntity)] - [InlineData(StatusCodes.Status423Locked)] - [InlineData(StatusCodes.Status424FailedDependency)] - [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)] - public void ResponseIsCacheable_NonSuccessStatusCodes_NotAllowed(int statusCode) - { - var httpContext = new DefaultHttpContext(); - httpContext.Response.StatusCode = statusCode; - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true - }; - 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 = CreateTestContext(httpContext); - - Assert.True(context.EntryIsFresh(new ResponseHeaders(new HeaderDictionary()), TimeSpan.MaxValue, verifyAgainstRequest: false)); - } - - [Fact] - public void EntryIsFresh_PastExpiry_IsNotFresh() - { - var httpContext = new DefaultHttpContext(); - var utcNow = DateTimeOffset.UtcNow; - httpContext.Response.GetTypedHeaders().Expires = utcNow; - var context = CreateTestContext(httpContext); - context._responseTime = utcNow; - - Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.MaxValue, verifyAgainstRequest: false)); - } - - [Fact] - public void EntryIsFresh_MaxAgeOverridesExpiry_ToFresh() - { - var utcNow = DateTimeOffset.UtcNow; - var httpContext = new DefaultHttpContext(); - - var responseHeaders = httpContext.Response.GetTypedHeaders(); - responseHeaders.Expires = utcNow; - responseHeaders.CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromSeconds(10) - }; - - var context = CreateTestContext(httpContext); - context._responseTime = utcNow; - - Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(10), verifyAgainstRequest: false)); - } - - [Fact] - public void EntryIsFresh_MaxAgeOverridesExpiry_ToNotFresh() - { - var utcNow = DateTimeOffset.UtcNow; - var httpContext = new DefaultHttpContext(); - - var responseHeaders = httpContext.Response.GetTypedHeaders(); - responseHeaders.Expires = utcNow; - responseHeaders.CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromSeconds(10) - }; - - var context = CreateTestContext(httpContext); - context._responseTime = utcNow; - - Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(11), verifyAgainstRequest: false)); - } - - [Fact] - public void EntryIsFresh_SharedMaxAgeOverridesMaxAge_ToFresh() - { - var httpContext = new DefaultHttpContext(); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromSeconds(10), - SharedMaxAge = TimeSpan.FromSeconds(15) - }; - var context = CreateTestContext(httpContext); - - Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(11), verifyAgainstRequest: false)); - } - - [Fact] - public void EntryIsFresh_SharedMaxAgeOverridesMaxAge_ToNotFresh() - { - var httpContext = new DefaultHttpContext(); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromSeconds(10), - SharedMaxAge = TimeSpan.FromSeconds(5) - }; - var context = CreateTestContext(httpContext); - - Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(6), verifyAgainstRequest: false)); - } - - [Fact] - public void EntryIsFresh_MinFreshReducesFreshness_ToNotFresh() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MinFresh = TimeSpan.FromSeconds(3) - }; - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromSeconds(10), - SharedMaxAge = TimeSpan.FromSeconds(5) - }; - var context = CreateTestContext(httpContext); - - Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(3), verifyAgainstRequest: true)); - } - - [Fact] - public void EntryIsFresh_RequestMaxAgeRestrictAge_ToNotFresh() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromSeconds(5) - }; - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromSeconds(10), - }; - var context = CreateTestContext(httpContext); - - Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(6), verifyAgainstRequest: true)); - } - - [Fact] - public void EntryIsFresh_MaxStaleOverridesFreshness_ToFresh() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromSeconds(5), - MaxStale = true, // This value must be set to true in order to specify MaxStaleLimit - MaxStaleLimit = TimeSpan.FromSeconds(10) - }; - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromSeconds(5), - }; - var context = CreateTestContext(httpContext); - - Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(6), verifyAgainstRequest: true)); - } - - [Fact] - public void EntryIsFresh_MustRevalidateOverridesRequestMaxStale_ToNotFresh() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromSeconds(5), - MaxStale = true, // This value must be set to true in order to specify MaxStaleLimit - MaxStaleLimit = TimeSpan.FromSeconds(10) - }; - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromSeconds(5), - MustRevalidate = true - }; - var context = CreateTestContext(httpContext); - - Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(6), verifyAgainstRequest: true)); - } - - [Fact] - public void EntryIsFresh_IgnoresRequestVerificationWhenSpecified() - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MinFresh = TimeSpan.FromSeconds(1), - MaxAge = TimeSpan.FromSeconds(3) - }; - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - MaxAge = TimeSpan.FromSeconds(10), - SharedMaxAge = TimeSpan.FromSeconds(5) - }; - var context = CreateTestContext(httpContext); - - Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(3), verifyAgainstRequest: false)); - } [Fact] public void ConditionalRequestSatisfied_NotConditionalRequest_Fails() @@ -861,8 +159,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests return CreateTestContext( httpContext, new ResponseCachingOptions(), - new NoopCacheKeyModifier(), - new NoopCacheabilityValidator()); + new CacheabilityValidator()); } private static ResponseCachingContext CreateTestContext(HttpContext httpContext, ResponseCachingOptions options) @@ -870,41 +167,30 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests return CreateTestContext( httpContext, options, - new NoopCacheKeyModifier(), - new NoopCacheabilityValidator()); + new CacheabilityValidator()); } - private static ResponseCachingContext CreateTestContext(HttpContext httpContext, IResponseCachingCacheKeyModifier cacheKeyModifier) + private static ResponseCachingContext CreateTestContext(HttpContext httpContext, ICacheabilityValidator cacheabilityValidator) { return CreateTestContext( httpContext, new ResponseCachingOptions(), - cacheKeyModifier, - new NoopCacheabilityValidator()); - } - - private static ResponseCachingContext CreateTestContext(HttpContext httpContext, IResponseCachingCacheabilityValidator cacheabilityValidator) - { - return CreateTestContext( - httpContext, - new ResponseCachingOptions(), - new NoopCacheKeyModifier(), cacheabilityValidator); } private static ResponseCachingContext CreateTestContext( HttpContext httpContext, ResponseCachingOptions options, - IResponseCachingCacheKeyModifier cacheKeyModifier, - IResponseCachingCacheabilityValidator cacheabilityValidator) + ICacheabilityValidator cacheabilityValidator) { + httpContext.AddResponseCachingState(); + return new ResponseCachingContext( httpContext, new TestResponseCache(), options, - new DefaultObjectPool(new StringBuilderPooledObjectPolicy()), cacheabilityValidator, - cacheKeyModifier); + new KeyProvider(new DefaultObjectPoolProvider(), Options.Create(options))); } private class TestResponseCache : IResponseCache diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs index 783dfd8f86..c28c31e8c1 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs @@ -49,7 +49,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public async void ServesCachedContent_IfVaryByHeader_Matches() + public async void ServesCachedContent_IfVaryHeader_Matches() { var builder = CreateBuilderWithResponseCaching(async (context) => { @@ -69,7 +69,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public async void ServesFreshContent_IfVaryByHeader_Mismatches() + public async void ServesFreshContent_IfVaryHeader_Mismatches() { var builder = CreateBuilderWithResponseCaching(async (context) => { @@ -90,11 +90,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public async void ServesCachedContent_IfVaryByParams_Matches() + public async void ServesCachedContent_IfVaryParams_Matches() { var builder = CreateBuilderWithResponseCaching(async (context) => { - context.GetResponseCachingFeature().VaryByParams = "param"; + context.GetResponseCachingFeature().VaryParams = "param"; await DefaultRequestDelegate(context); }); @@ -109,11 +109,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public async void ServesCachedContent_IfVaryByParamsExplicit_Matches_ParamNameCaseInsensitive() + public async void ServesCachedContent_IfVaryParamsExplicit_Matches_ParamNameCaseInsensitive() { var builder = CreateBuilderWithResponseCaching(async (context) => { - context.GetResponseCachingFeature().VaryByParams = new[] { "ParamA", "paramb" }; + context.GetResponseCachingFeature().VaryParams = new[] { "ParamA", "paramb" }; await DefaultRequestDelegate(context); }); @@ -128,11 +128,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public async void ServesCachedContent_IfVaryByParamsStar_Matches_ParamNameCaseInsensitive() + public async void ServesCachedContent_IfVaryParamsStar_Matches_ParamNameCaseInsensitive() { var builder = CreateBuilderWithResponseCaching(async (context) => { - context.GetResponseCachingFeature().VaryByParams = new[] { "*" }; + context.GetResponseCachingFeature().VaryParams = new[] { "*" }; await DefaultRequestDelegate(context); }); @@ -147,11 +147,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public async void ServesCachedContent_IfVaryByParamsExplicit_Matches_OrderInsensitive() + public async void ServesCachedContent_IfVaryParamsExplicit_Matches_OrderInsensitive() { var builder = CreateBuilderWithResponseCaching(async (context) => { - context.GetResponseCachingFeature().VaryByParams = new[] { "ParamB", "ParamA" }; + context.GetResponseCachingFeature().VaryParams = new[] { "ParamB", "ParamA" }; await DefaultRequestDelegate(context); }); @@ -166,11 +166,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public async void ServesCachedContent_IfVaryByParamsStar_Matches_OrderInsensitive() + public async void ServesCachedContent_IfVaryParamsStar_Matches_OrderInsensitive() { var builder = CreateBuilderWithResponseCaching(async (context) => { - context.GetResponseCachingFeature().VaryByParams = new[] { "*" }; + context.GetResponseCachingFeature().VaryParams = new[] { "*" }; await DefaultRequestDelegate(context); }); @@ -185,11 +185,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public async void ServesFreshContent_IfVaryByParams_Mismatches() + public async void ServesFreshContent_IfVaryParams_Mismatches() { var builder = CreateBuilderWithResponseCaching(async (context) => { - context.GetResponseCachingFeature().VaryByParams = "param"; + context.GetResponseCachingFeature().VaryParams = "param"; await DefaultRequestDelegate(context); }); @@ -204,11 +204,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public async void ServesFreshContent_IfVaryByParamsExplicit_Mismatch_ParamValueCaseSensitive() + public async void ServesFreshContent_IfVaryParamsExplicit_Mismatch_ParamValueCaseSensitive() { var builder = CreateBuilderWithResponseCaching(async (context) => { - context.GetResponseCachingFeature().VaryByParams = new[] { "ParamA", "ParamB" }; + context.GetResponseCachingFeature().VaryParams = new[] { "ParamA", "ParamB" }; await DefaultRequestDelegate(context); }); @@ -223,11 +223,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public async void ServesFreshContent_IfVaryByParamsStar_Mismatch_ParamValueCaseSensitive() + public async void ServesFreshContent_IfVaryParamsStar_Mismatch_ParamValueCaseSensitive() { var builder = CreateBuilderWithResponseCaching(async (context) => { - context.GetResponseCachingFeature().VaryByParams = new[] { "*" }; + context.GetResponseCachingFeature().VaryParams = new[] { "*" }; await DefaultRequestDelegate(context); }); @@ -281,7 +281,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public async void ServesCachedContent_WithoutSetCookie() + public async void ServesFreshContent_IfSetCookie_IsSpecified() { var builder = CreateBuilderWithResponseCaching(async (context) => { @@ -295,20 +295,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests var initialResponse = await client.GetAsync(""); var subsequentResponse = await client.GetAsync(""); - initialResponse.EnsureSuccessStatusCode(); - subsequentResponse.EnsureSuccessStatusCode(); - - foreach (var header in initialResponse.Headers) - { - if (!string.Equals(HeaderNames.SetCookie, header.Key, StringComparison.OrdinalIgnoreCase)) - { - Assert.Equal(initialResponse.Headers.GetValues(header.Key), subsequentResponse.Headers.GetValues(header.Key)); - } - } - Assert.True(initialResponse.Headers.Contains(HeaderNames.SetCookie)); - Assert.True(subsequentResponse.Headers.Contains(HeaderNames.Age)); - Assert.False(subsequentResponse.Headers.Contains(HeaderNames.SetCookie)); - Assert.Equal(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + await AssertResponseNotCachedAsync(initialResponse, subsequentResponse); } }