From 65b89668bb0e49d4b168de6a634739468a1d95f9 Mon Sep 17 00:00:00 2001 From: John Luo Date: Thu, 8 Sep 2016 17:16:32 -0700 Subject: [PATCH] Allow lookup of multiple keys - Do not cache if content-length mismatches with the length of the response body --- .../Interfaces/IKeyProvider.cs | 24 +- .../Internal/ResponseCachingState.cs | 4 +- .../KeyProvider.cs | 15 +- .../ResponseCachingContext.cs | 159 ++++---- .../KeyProviderTests.cs | 38 +- .../ResponseCachingContextTests.cs | 378 ++++++++++++++---- 6 files changed, 435 insertions(+), 183 deletions(-) diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IKeyProvider.cs b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IKeyProvider.cs index 972b9917fe..2013b202b5 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IKeyProvider.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IKeyProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Collections.Generic; using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.ResponseCaching @@ -8,18 +9,33 @@ namespace Microsoft.AspNetCore.ResponseCaching public interface IKeyProvider { /// - /// Create a base key using the HTTP request. + /// Create a base key using the HTTP context for storing items. /// /// The . /// The created base key. - string CreateBaseKey(HttpContext httpContext); + string CreateStorageBaseKey(HttpContext httpContext); /// - /// Create a vary key using the HTTP context and vary rules. + /// Create one or more base keys using the HTTP context for looking up items. + /// + /// The . + /// An ordered containing the base keys to try when looking up items. + IEnumerable CreateLookupBaseKey(HttpContext httpContext); + + /// + /// Create a vary key using the HTTP context and vary rules for storing items. /// /// The . /// The . /// The created vary key. - string CreateVaryKey(HttpContext httpContext, VaryRules varyRules); + string CreateStorageVaryKey(HttpContext httpContext, VaryRules varyRules); + + /// + /// Create one or more vary keys using the HTTP context and vary rules for looking up items. + /// + /// The . + /// The . + /// An ordered containing the vary keys to try when looking up items. + IEnumerable CreateLookupVaryKey(HttpContext httpContext, VaryRules varyRules); } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingState.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingState.cs index 6ea80b95b0..422f7c9746 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingState.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingState.cs @@ -26,9 +26,9 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal public bool ShouldCacheResponse { get; internal set; } - public string BaseKey { get; internal set; } + public string StorageBaseKey { get; internal set; } - public string VaryKey { get; internal set; } + public string StorageVaryKey { get; internal set; } public DateTimeOffset ResponseTime { get; internal set; } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/KeyProvider.cs b/src/Microsoft.AspNetCore.ResponseCaching/KeyProvider.cs index 027d2e3b77..4de7a40b36 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/KeyProvider.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/KeyProvider.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.AspNetCore.Builder; @@ -35,9 +36,19 @@ namespace Microsoft.AspNetCore.ResponseCaching _options = options.Value; } + public virtual IEnumerable CreateLookupBaseKey(HttpContext httpContext) + { + return new string[] { CreateStorageBaseKey(httpContext) }; + } + + public virtual IEnumerable CreateLookupVaryKey(HttpContext httpContext, VaryRules varyRules) + { + return new string[] { CreateStorageVaryKey(httpContext, varyRules) }; + } + // GET/PATH // TODO: Method invariant retrieval? E.g. HEAD after GET to the same resource. - public virtual string CreateBaseKey(HttpContext httpContext) + public virtual string CreateStorageBaseKey(HttpContext httpContext) { if (httpContext == null) { @@ -63,7 +74,7 @@ namespace Microsoft.AspNetCore.ResponseCaching } // BaseKeyHHeaderName=HeaderValueQQueryName=QueryValue - public virtual string CreateVaryKey(HttpContext httpContext, VaryRules varyRules) + public virtual string CreateStorageVaryKey(HttpContext httpContext, VaryRules varyRules) { if (httpContext == null) { diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs index 4b9fe410ba..55dc1824ca 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs @@ -59,84 +59,101 @@ namespace Microsoft.AspNetCore.ResponseCaching private IHttpSendFileFeature OriginalSendFileFeature { get; set; } - internal async Task TryServeFromCacheAsync() + internal async Task TryServeCachedResponseAsync(CachedResponse cachedResponse) { - State.BaseKey = _keyProvider.CreateBaseKey(_httpContext); - var cacheEntry = _cache.Get(State.BaseKey); - var responseServed = false; + State.CachedResponse = cachedResponse; + var cachedResponseHeaders = new ResponseHeaders(State.CachedResponse.Headers); - if (cacheEntry is CachedVaryRules) + State.ResponseTime = _options.SystemClock.UtcNow; + var cachedEntryAge = State.ResponseTime - State.CachedResponse.Created; + State.CachedEntryAge = cachedEntryAge > TimeSpan.Zero ? cachedEntryAge : TimeSpan.Zero; + + if (_cacheabilityValidator.CachedEntryIsFresh(_httpContext, cachedResponseHeaders)) { - // Request contains vary rules, recompute key and try again - State.CachedVaryRules = cacheEntry as CachedVaryRules; - var varyKey = _keyProvider.CreateVaryKey(_httpContext, ((CachedVaryRules)cacheEntry).VaryRules); - cacheEntry = _cache.Get(varyKey); - } - - if (cacheEntry is CachedResponse) - { - State.CachedResponse = cacheEntry as CachedResponse; - var cachedResponseHeaders = new ResponseHeaders(State.CachedResponse.Headers); - - State.ResponseTime = _options.SystemClock.UtcNow; - var cachedEntryAge = State.ResponseTime - State.CachedResponse.Created; - State.CachedEntryAge = cachedEntryAge > TimeSpan.Zero ? cachedEntryAge : TimeSpan.Zero; - - if (_cacheabilityValidator.CachedEntryIsFresh(_httpContext, cachedResponseHeaders)) + // Check conditional request rules + if (ConditionalRequestSatisfied(cachedResponseHeaders)) { - responseServed = true; - - // Check conditional request rules - if (ConditionalRequestSatisfied(cachedResponseHeaders)) - { - _httpContext.Response.StatusCode = StatusCodes.Status304NotModified; - } - else - { - var response = _httpContext.Response; - // Copy the cached status code and response headers - response.StatusCode = State.CachedResponse.StatusCode; - foreach (var header in State.CachedResponse.Headers) - { - response.Headers.Add(header); - } - - response.Headers[HeaderNames.Age] = State.CachedEntryAge.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture); - - var body = State.CachedResponse.Body ?? - ((CachedResponseBody)_cache.Get(State.CachedResponse.BodyKeyPrefix))?.Body; - - // If the body is not found, something went wrong. - if (body == null) - { - return false; - } - - // Copy the cached response body - if (body.Length > 0) - { - // Add a content-length if required - if (response.ContentLength == null && StringValues.IsNullOrEmpty(response.Headers[HeaderNames.TransferEncoding])) - { - response.ContentLength = body.Length; - } - await response.Body.WriteAsync(body, 0, body.Length); - } - } + _httpContext.Response.StatusCode = StatusCodes.Status304NotModified; } else { - // TODO: Validate with endpoint instead + var response = _httpContext.Response; + // Copy the cached status code and response headers + response.StatusCode = State.CachedResponse.StatusCode; + foreach (var header in State.CachedResponse.Headers) + { + response.Headers.Add(header); + } + + response.Headers[HeaderNames.Age] = State.CachedEntryAge.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture); + + var body = State.CachedResponse.Body ?? + ((CachedResponseBody)_cache.Get(State.CachedResponse.BodyKeyPrefix))?.Body; + + // If the body is not found, something went wrong. + if (body == null) + { + return false; + } + + // Copy the cached response body + if (body.Length > 0) + { + // Add a content-length if required + if (response.ContentLength == null && StringValues.IsNullOrEmpty(response.Headers[HeaderNames.TransferEncoding])) + { + response.ContentLength = body.Length; + } + await response.Body.WriteAsync(body, 0, body.Length); + } + } + + return true; + } + else + { + // TODO: Validate with endpoint instead + } + + return false; + } + + internal async Task TryServeFromCacheAsync() + { + foreach (var baseKey in _keyProvider.CreateLookupBaseKey(_httpContext)) + { + var cacheEntry = _cache.Get(baseKey); + + if (cacheEntry is CachedVaryRules) + { + // Request contains vary rules, recompute key(s) and try again + State.CachedVaryRules = cacheEntry as CachedVaryRules; + + foreach (var varyKey in _keyProvider.CreateLookupVaryKey(_httpContext, State.CachedVaryRules.VaryRules)) + { + cacheEntry = _cache.Get(varyKey); + + if (cacheEntry is CachedResponse && await TryServeCachedResponseAsync(cacheEntry as CachedResponse)) + { + return true; + } + } + } + + if (cacheEntry is CachedResponse && await TryServeCachedResponseAsync(cacheEntry as CachedResponse)) + { + return true; } } - if (!responseServed && State.RequestCacheControl.OnlyIfCached) + + if (State.RequestCacheControl.OnlyIfCached) { _httpContext.Response.StatusCode = StatusCodes.Status504GatewayTimeout; - responseServed = true; + return true; } - return responseServed; + return false; } internal bool ConditionalRequestSatisfied(ResponseHeaders cachedResponseHeaders) @@ -174,6 +191,7 @@ namespace Microsoft.AspNetCore.ResponseCaching if (_cacheabilityValidator.ResponseIsCacheable(_httpContext)) { State.ShouldCacheResponse = true; + State.StorageBaseKey = _keyProvider.CreateStorageBaseKey(_httpContext); // Create the cache entry now var response = _httpContext.Response; @@ -209,10 +227,10 @@ namespace Microsoft.AspNetCore.ResponseCaching }; State.CachedVaryRules = cachedVaryRules; - _cache.Set(State.BaseKey, cachedVaryRules, State.CachedResponseValidFor); + _cache.Set(State.StorageBaseKey, cachedVaryRules, State.CachedResponseValidFor); } - State.VaryKey = _keyProvider.CreateVaryKey(_httpContext, State.CachedVaryRules.VaryRules); + State.StorageVaryKey = _keyProvider.CreateStorageVaryKey(_httpContext, State.CachedVaryRules.VaryRules); } // Ensure date header is set @@ -245,12 +263,15 @@ namespace Microsoft.AspNetCore.ResponseCaching internal void FinalizeCachingBody() { - if (State.ShouldCacheResponse && ResponseCacheStream.BufferingEnabled) + if (State.ShouldCacheResponse && + ResponseCacheStream.BufferingEnabled && + (State.ResponseHeaders.ContentLength == null || + State.ResponseHeaders.ContentLength == ResponseCacheStream.BufferedStream.Length)) { if (ResponseCacheStream.BufferedStream.Length >= _options.MinimumSplitBodySize) { // Store response and response body separately - _cache.Set(State.VaryKey ?? State.BaseKey, State.CachedResponse, State.CachedResponseValidFor); + _cache.Set(State.StorageVaryKey ?? State.StorageBaseKey, State.CachedResponse, State.CachedResponseValidFor); var cachedResponseBody = new CachedResponseBody() { @@ -263,7 +284,7 @@ namespace Microsoft.AspNetCore.ResponseCaching { // Store response and response body together State.CachedResponse.Body = ResponseCacheStream.BufferedStream.ToArray(); - _cache.Set(State.VaryKey ?? State.BaseKey, State.CachedResponse, State.CachedResponseValidFor); + _cache.Set(State.StorageVaryKey ?? State.StorageBaseKey, State.CachedResponse, State.CachedResponseValidFor); } } } diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/KeyProviderTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/KeyProviderTests.cs index 17922f5afa..98e1450b9b 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/KeyProviderTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/KeyProviderTests.cs @@ -19,7 +19,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests }; [Fact] - public void DefaultKeyProvider_CreateBaseKey_IncludesOnlyNormalizedMethodAndPath() + public void DefaultKeyProvider_CreateStorageBaseKey_IncludesOnlyNormalizedMethodAndPath() { var httpContext = CreateDefaultContext(); httpContext.Request.Method = "head"; @@ -30,11 +30,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests httpContext.Request.QueryString = new QueryString("?query.Key=a&query.Value=b"); var keyProvider = CreateTestKeyProvider(); - Assert.Equal($"HEAD{KeyDelimiter}/PATH/SUBPATH", keyProvider.CreateBaseKey(httpContext)); + Assert.Equal($"HEAD{KeyDelimiter}/PATH/SUBPATH", keyProvider.CreateStorageBaseKey(httpContext)); } [Fact] - public void DefaultKeyProvider_CreateBaseKey_CaseInsensitivePath_NormalizesPath() + public void DefaultKeyProvider_CreateStorageBaseKey_CaseInsensitivePath_NormalizesPath() { var httpContext = CreateDefaultContext(); httpContext.Request.Method = "GET"; @@ -44,11 +44,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests CaseSensitivePaths = false }); - Assert.Equal($"GET{KeyDelimiter}/PATH", keyProvider.CreateBaseKey(httpContext)); + Assert.Equal($"GET{KeyDelimiter}/PATH", keyProvider.CreateStorageBaseKey(httpContext)); } [Fact] - public void DefaultKeyProvider_CreateBaseKey_CaseSensitivePath_PreservesPathCase() + public void DefaultKeyProvider_CreateStorageBaseKey_CaseSensitivePath_PreservesPathCase() { var httpContext = CreateDefaultContext(); httpContext.Request.Method = "GET"; @@ -58,21 +58,21 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests CaseSensitivePaths = true }); - Assert.Equal($"GET{KeyDelimiter}/Path", keyProvider.CreateBaseKey(httpContext)); + Assert.Equal($"GET{KeyDelimiter}/Path", keyProvider.CreateStorageBaseKey(httpContext)); } [Fact] - public void DefaultKeyProvider_CreateVaryKey_ReturnsCachedVaryGuid_IfVaryRulesIsNullOrEmpty() + public void DefaultKeyProvider_CreateStorageVaryKey_ReturnsCachedVaryGuid_IfVaryRulesIsNullOrEmpty() { var httpContext = CreateDefaultContext(); var keyProvider = CreateTestKeyProvider(); - Assert.Equal($"{TestVaryRules.VaryKeyPrefix}", keyProvider.CreateVaryKey(httpContext, null)); - Assert.Equal($"{TestVaryRules.VaryKeyPrefix}", keyProvider.CreateVaryKey(httpContext, new VaryRules())); + Assert.Equal($"{TestVaryRules.VaryKeyPrefix}", keyProvider.CreateStorageVaryKey(httpContext, null)); + Assert.Equal($"{TestVaryRules.VaryKeyPrefix}", keyProvider.CreateStorageVaryKey(httpContext, new VaryRules())); } [Fact] - public void DefaultKeyProvider_CreateVaryKey_IncludesListedHeadersOnly() + public void DefaultKeyProvider_CreateStorageVaryKey_IncludesListedHeadersOnly() { var httpContext = CreateDefaultContext(); httpContext.Request.Headers["HeaderA"] = "ValueA"; @@ -80,42 +80,42 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests var keyProvider = CreateTestKeyProvider(); Assert.Equal($"{TestVaryRules.VaryKeyPrefix}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=null", - keyProvider.CreateVaryKey(httpContext, new VaryRules() + keyProvider.CreateStorageVaryKey(httpContext, new VaryRules() { Headers = new string[] { "HeaderA", "HeaderC" } })); } [Fact] - public void DefaultKeyProvider_CreateVaryKey_IncludesListedParamsOnly() + public void DefaultKeyProvider_CreateStorageVaryKey_IncludesListedParamsOnly() { var httpContext = CreateDefaultContext(); httpContext.Request.QueryString = new QueryString("?ParamA=ValueA&ParamB=ValueB"); var keyProvider = CreateTestKeyProvider(); Assert.Equal($"{TestVaryRules.VaryKeyPrefix}{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", - keyProvider.CreateVaryKey(httpContext, new VaryRules() + keyProvider.CreateStorageVaryKey(httpContext, new VaryRules() { Params = new string[] { "ParamA", "ParamC" } })); } [Fact] - public void DefaultKeyProvider_CreateVaryKey_IncludesParams_ParamNameCaseInsensitive_UseParamCasing() + public void DefaultKeyProvider_CreateStorageVaryKey_IncludesParams_ParamNameCaseInsensitive_UseParamCasing() { var httpContext = CreateDefaultContext(); httpContext.Request.QueryString = new QueryString("?parama=ValueA¶mB=ValueB"); var keyProvider = CreateTestKeyProvider(); Assert.Equal($"{TestVaryRules.VaryKeyPrefix}{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", - keyProvider.CreateVaryKey(httpContext, new VaryRules() + keyProvider.CreateStorageVaryKey(httpContext, new VaryRules() { Params = new string[] { "ParamA", "ParamC" } })); } [Fact] - public void DefaultKeyProvider_CreateVaryKey_IncludesAllQueryParamsGivenAsterisk() + public void DefaultKeyProvider_CreateStorageVaryKey_IncludesAllQueryParamsGivenAsterisk() { var httpContext = CreateDefaultContext(); httpContext.Request.QueryString = new QueryString("?ParamA=ValueA&ParamB=ValueB"); @@ -124,14 +124,14 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests // To support case insensitivity, all param keys are converted to upper case. // Explicit params uses the casing specified in the setting. Assert.Equal($"{TestVaryRules.VaryKeyPrefix}{KeyDelimiter}Q{KeyDelimiter}PARAMA=ValueA{KeyDelimiter}PARAMB=ValueB", - keyProvider.CreateVaryKey(httpContext, new VaryRules() + keyProvider.CreateStorageVaryKey(httpContext, new VaryRules() { Params = new string[] { "*" } })); } [Fact] - public void DefaultKeyProvider_CreateVaryKey_IncludesListedHeadersAndParams() + public void DefaultKeyProvider_CreateStorageVaryKey_IncludesListedHeadersAndParams() { var httpContext = CreateDefaultContext(); httpContext.Request.Headers["HeaderA"] = "ValueA"; @@ -140,7 +140,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests var keyProvider = CreateTestKeyProvider(); Assert.Equal($"{TestVaryRules.VaryKeyPrefix}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=null{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", - keyProvider.CreateVaryKey(httpContext, new VaryRules() + keyProvider.CreateStorageVaryKey(httpContext, new VaryRules() { Headers = new string[] { "HeaderA", "HeaderC" }, Params = new string[] { "ParamA", "ParamC" } diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs index ec11698d0d..c900f3161e 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs @@ -10,16 +10,100 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Headers; using Microsoft.AspNetCore.ResponseCaching.Internal; +using Microsoft.Extensions.Internal; using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using Xunit; -using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.ResponseCaching.Tests { public class ResponseCachingContextTests { + [Fact] + public async Task TryServeFromCacheAsync_OnlyIfCached_Serves504() + { + var cache = new TestResponseCache(); + var httpContext = new DefaultHttpContext(); + var context = CreateTestContext(httpContext, responseCache: cache, keyProvider: new TestKeyProvider()); + httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + OnlyIfCached = true + }; + + Assert.True(await context.TryServeFromCacheAsync()); + Assert.Equal(StatusCodes.Status504GatewayTimeout, httpContext.Response.StatusCode); + } + + [Fact] + public async Task TryServeFromCacheAsync_CachedResponseNotFound_Fails() + { + var cache = new TestResponseCache(); + var httpContext = new DefaultHttpContext(); + var context = CreateTestContext(httpContext, responseCache: cache, keyProvider: new TestKeyProvider(new[] { "BaseKey", "BaseKey2" })); + + Assert.False(await context.TryServeFromCacheAsync()); + Assert.Equal(2, cache.GetCount); + } + + [Fact] + public async Task TryServeFromCacheAsync_CachedResponseFound_Succeeds() + { + var cache = new TestResponseCache(); + var httpContext = new DefaultHttpContext(); + var context = CreateTestContext(httpContext, responseCache: cache, keyProvider: new TestKeyProvider(new[] { "BaseKey", "BaseKey2" })); + + cache.Set( + "BaseKey2", + new CachedResponse() + { + Body = new byte[0] + }, + TimeSpan.Zero); + + Assert.True(await context.TryServeFromCacheAsync()); + Assert.Equal(2, cache.GetCount); + } + + [Fact] + public async Task TryServeFromCacheAsync_VaryRuleFound_CachedResponseNotFound_Fails() + { + var cache = new TestResponseCache(); + var httpContext = new DefaultHttpContext(); + var context = CreateTestContext(httpContext, responseCache: cache, keyProvider: new TestKeyProvider(new[] { "BaseKey", "BaseKey2" })); + + cache.Set( + "BaseKey2", + new CachedVaryRules(), + TimeSpan.Zero); + + Assert.False(await context.TryServeFromCacheAsync()); + Assert.Equal(2, cache.GetCount); + } + + [Fact] + public async Task TryServeFromCacheAsync_VaryRuleFound_CachedResponseFound_Succeeds() + { + var cache = new TestResponseCache(); + var httpContext = new DefaultHttpContext(); + var context = CreateTestContext(httpContext, responseCache: cache, keyProvider: new TestKeyProvider(new[] { "BaseKey", "BaseKey2" }, new[] { "VaryKey", "VaryKey2" })); + + cache.Set( + "BaseKey2", + new CachedVaryRules(), + TimeSpan.Zero); + cache.Set( + "BaseKey2VaryKey2", + new CachedResponse() + { + Body = new byte[0] + }, + TimeSpan.Zero); + + Assert.True(await context.TryServeFromCacheAsync()); + Assert.Equal(6, cache.GetCount); + } [Fact] public void ConditionalRequestSatisfied_NotConditionalRequest_Fails() @@ -159,7 +243,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests public void FinalizeCachingHeaders_DoNotUpdateShouldCacheResponse_IfResponseIsNotCacheable() { var httpContext = new DefaultHttpContext(); - var context = CreateTestContext(httpContext); + var context = CreateTestContext(httpContext, cacheabilityValidator: new CacheabilityValidator()); var state = httpContext.GetResponseCachingState(); Assert.False(state.ShouldCacheResponse); @@ -178,7 +262,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests { Public = true }; - var context = CreateTestContext(httpContext); + var context = CreateTestContext(httpContext, cacheabilityValidator: new CacheabilityValidator()); var state = httpContext.GetResponseCachingState(); Assert.False(state.ShouldCacheResponse); @@ -192,10 +276,6 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests public void FinalizeCachingHeaders_DefaultResponseValidity_Is10Seconds() { var httpContext = new DefaultHttpContext(); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true - }; var context = CreateTestContext(httpContext); context.FinalizeCachingHeaders(); @@ -207,10 +287,6 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests public void FinalizeCachingHeaders_ResponseValidity_UseExpiryIfAvailable() { var httpContext = new DefaultHttpContext(); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true - }; var context = CreateTestContext(httpContext); var state = httpContext.GetResponseCachingState(); @@ -229,7 +305,6 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests var httpContext = new DefaultHttpContext(); httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() { - Public = true, MaxAge = TimeSpan.FromSeconds(12) }; var context = CreateTestContext(httpContext); @@ -249,7 +324,6 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests var httpContext = new DefaultHttpContext(); httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() { - Public = true, MaxAge = TimeSpan.FromSeconds(12), SharedMaxAge = TimeSpan.FromSeconds(13) }; @@ -269,16 +343,12 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests { var httpContext = new DefaultHttpContext(); var cache = new TestResponseCache(); - var context = CreateTestContext(httpContext, cache, new ResponseCachingOptions()); + var context = CreateTestContext(httpContext, cache); var state = httpContext.GetResponseCachingState(); httpContext.Response.Headers[HeaderNames.Vary] = new StringValues(new[] { "headerA", "HEADERB", "HEADERc" }); httpContext.AddResponseCachingFeature(); httpContext.GetResponseCachingFeature().VaryParams = new StringValues(new[] { "paramB", "PARAMAA" }); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true, - }; var cachedVaryRules = new CachedVaryRules() { VaryRules = new VaryRules() @@ -291,7 +361,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests context.FinalizeCachingHeaders(); - Assert.Equal(1, cache.StoredItems); + Assert.Equal(1, cache.SetCount); Assert.NotSame(cachedVaryRules, state.CachedVaryRules); } @@ -300,16 +370,12 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests { var httpContext = new DefaultHttpContext(); var cache = new TestResponseCache(); - var context = CreateTestContext(httpContext, cache, new ResponseCachingOptions()); + var context = CreateTestContext(httpContext, cache); var state = httpContext.GetResponseCachingState(); httpContext.Response.Headers[HeaderNames.Vary] = new StringValues(new[] { "headerA", "HEADERB" }); httpContext.AddResponseCachingFeature(); httpContext.GetResponseCachingFeature().VaryParams = new StringValues(new[] { "paramB", "PARAMA" }); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true, - }; var cachedVaryRules = new CachedVaryRules() { VaryKeyPrefix = FastGuid.NewGuid().IdString, @@ -323,7 +389,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests context.FinalizeCachingHeaders(); - Assert.Equal(0, cache.StoredItems); + Assert.Equal(0, cache.SetCount); Assert.Same(cachedVaryRules, state.CachedVaryRules); } @@ -331,10 +397,6 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests public void FinalizeCachingHeaders_DoNotAddDate_IfSpecified() { var httpContext = new DefaultHttpContext(); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true - }; var context = CreateTestContext(httpContext); var state = httpContext.GetResponseCachingState(); var utcNow = DateTimeOffset.MinValue; @@ -351,10 +413,6 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests public void FinalizeCachingHeaders_AddsDate_IfNoneSpecified() { var httpContext = new DefaultHttpContext(); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true - }; var context = CreateTestContext(httpContext); var state = httpContext.GetResponseCachingState(); var utcNow = DateTimeOffset.MinValue; @@ -372,10 +430,6 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests public void FinalizeCachingHeaders_StoresCachedResponse_InState() { var httpContext = new DefaultHttpContext(); - httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true - }; var context = CreateTestContext(httpContext); var state = httpContext.GetResponseCachingState(); @@ -391,7 +445,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests { var httpContext = new DefaultHttpContext(); var cache = new TestResponseCache(); - var context = CreateTestContext(httpContext, cache, new ResponseCachingOptions()); + var context = CreateTestContext(httpContext, cache); context.ShimResponseStream(); await httpContext.Response.WriteAsync(new string('0', 70 * 1024)); @@ -402,12 +456,12 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests { BodyKeyPrefix = FastGuid.NewGuid().IdString }; - state.BaseKey = "BaseKey"; + state.StorageBaseKey = "BaseKey"; state.CachedResponseValidFor = TimeSpan.FromSeconds(10); context.FinalizeCachingBody(); - Assert.Equal(2, cache.StoredItems); + Assert.Equal(2, cache.SetCount); } [Fact] @@ -415,7 +469,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests { var httpContext = new DefaultHttpContext(); var cache = new TestResponseCache(); - var context = CreateTestContext(httpContext, cache, new ResponseCachingOptions()); + var context = CreateTestContext(httpContext, cache); context.ShimResponseStream(); await httpContext.Response.WriteAsync(new string('0', 70 * 1024 - 1)); @@ -426,12 +480,113 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests { BodyKeyPrefix = FastGuid.NewGuid().IdString }; - state.BaseKey = "BaseKey"; + state.StorageBaseKey = "BaseKey"; state.CachedResponseValidFor = TimeSpan.FromSeconds(10); context.FinalizeCachingBody(); - Assert.Equal(1, cache.StoredItems); + Assert.Equal(1, cache.SetCount); + } + + [Fact] + public async Task FinalizeCachingBody_StoreResponseBodySeparately_LimitIsConfigurable() + { + var httpContext = new DefaultHttpContext(); + var cache = new TestResponseCache(); + var context = CreateTestContext(httpContext, cache, new ResponseCachingOptions() + { + MinimumSplitBodySize = 2048 + }); + + context.ShimResponseStream(); + await httpContext.Response.WriteAsync(new string('0', 1024)); + + var state = httpContext.GetResponseCachingState(); + state.ShouldCacheResponse = true; + state.CachedResponse = new CachedResponse() + { + BodyKeyPrefix = FastGuid.NewGuid().IdString + }; + state.StorageBaseKey = "BaseKey"; + state.CachedResponseValidFor = TimeSpan.FromSeconds(10); + + context.FinalizeCachingBody(); + + Assert.Equal(1, cache.SetCount); + } + + [Fact] + public async Task FinalizeCachingBody_Cache_IfContentLengthMatches() + { + var httpContext = new DefaultHttpContext(); + var cache = new TestResponseCache(); + var context = CreateTestContext(httpContext, cache); + + context.ShimResponseStream(); + httpContext.Response.ContentLength = 10; + await httpContext.Response.WriteAsync(new string('0', 10)); + + var state = httpContext.GetResponseCachingState(); + state.ShouldCacheResponse = true; + state.CachedResponse = new CachedResponse() + { + BodyKeyPrefix = FastGuid.NewGuid().IdString + }; + state.StorageBaseKey = "BaseKey"; + state.CachedResponseValidFor = TimeSpan.FromSeconds(10); + + context.FinalizeCachingBody(); + + Assert.Equal(1, cache.SetCount); + } + + [Fact] + public async Task FinalizeCachingBody_DoNotCache_IfContentLengthMismatches() + { + var httpContext = new DefaultHttpContext(); + var cache = new TestResponseCache(); + var context = CreateTestContext(httpContext, cache); + + context.ShimResponseStream(); + httpContext.Response.ContentLength = 9; + await httpContext.Response.WriteAsync(new string('0', 10)); + + var state = httpContext.GetResponseCachingState(); + state.ShouldCacheResponse = true; + state.CachedResponse = new CachedResponse() + { + BodyKeyPrefix = FastGuid.NewGuid().IdString + }; + state.StorageBaseKey = "BaseKey"; + state.CachedResponseValidFor = TimeSpan.FromSeconds(10); + + context.FinalizeCachingBody(); + + Assert.Equal(0, cache.SetCount); + } + + [Fact] + public async Task FinalizeCachingBody_Cache_IfContentLengthAbsent() + { + var httpContext = new DefaultHttpContext(); + var cache = new TestResponseCache(); + var context = CreateTestContext(httpContext, cache); + + context.ShimResponseStream(); + await httpContext.Response.WriteAsync(new string('0', 10)); + + var state = httpContext.GetResponseCachingState(); + state.ShouldCacheResponse = true; + state.CachedResponse = new CachedResponse() + { + BodyKeyPrefix = FastGuid.NewGuid().IdString + }; + state.StorageBaseKey = "BaseKey"; + state.CachedResponseValidFor = TimeSpan.FromSeconds(10); + + context.FinalizeCachingBody(); + + Assert.Equal(1, cache.SetCount); } [Fact] @@ -456,48 +611,30 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests Assert.Equal(orderedStrings, normalizedStrings); } - private static ResponseCachingContext CreateTestContext(HttpContext httpContext) - { - return CreateTestContext( - httpContext, - new TestResponseCache(), - new ResponseCachingOptions(), - new CacheabilityValidator()); - } - - private static ResponseCachingContext CreateTestContext(HttpContext httpContext, ResponseCachingOptions options) - { - return CreateTestContext( - httpContext, - new TestResponseCache(), - options, - new CacheabilityValidator()); - } - - private static ResponseCachingContext CreateTestContext(HttpContext httpContext, ICacheabilityValidator cacheabilityValidator) - { - return CreateTestContext( - httpContext, - new TestResponseCache(), - new ResponseCachingOptions(), - cacheabilityValidator); - } - - private static ResponseCachingContext CreateTestContext(HttpContext httpContext, IResponseCache responseCache, ResponseCachingOptions options) - { - return CreateTestContext( - httpContext, - responseCache, - options, - new CacheabilityValidator()); - } - private static ResponseCachingContext CreateTestContext( HttpContext httpContext, - IResponseCache responseCache, - ResponseCachingOptions options, - ICacheabilityValidator cacheabilityValidator) + IResponseCache responseCache = null, + ResponseCachingOptions options = null, + IKeyProvider keyProvider = null, + ICacheabilityValidator cacheabilityValidator = null) { + if (responseCache == null) + { + responseCache = new TestResponseCache(); + } + if (options == null) + { + options = new ResponseCachingOptions(); + } + if (keyProvider == null) + { + keyProvider = new KeyProvider(new DefaultObjectPoolProvider(), Options.Create(options)); + } + if (cacheabilityValidator == null) + { + cacheabilityValidator = new TestCacheabilityValidator(); + } + httpContext.AddResponseCachingState(); return new ResponseCachingContext( @@ -505,16 +642,82 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests responseCache, options, cacheabilityValidator, - new KeyProvider(new DefaultObjectPoolProvider(), Options.Create(options))); + keyProvider); + } + + private class TestCacheabilityValidator : ICacheabilityValidator + { + public bool CachedEntryIsFresh(HttpContext httpContext, ResponseHeaders cachedResponseHeaders) => true; + + public bool RequestIsCacheable(HttpContext httpContext) => true; + + public bool ResponseIsCacheable(HttpContext httpContext) => true; + } + + private class TestKeyProvider : IKeyProvider + { + private readonly StringValues _baseKey; + private readonly StringValues _varyKey; + + public TestKeyProvider(StringValues? lookupBaseKey = null, StringValues? lookupVaryKey = null) + { + if (lookupBaseKey.HasValue) + { + _baseKey = lookupBaseKey.Value; + } + if (lookupVaryKey.HasValue) + { + _varyKey = lookupVaryKey.Value; + } + } + + public IEnumerable CreateLookupBaseKey(HttpContext httpContext) => _baseKey; + + + public IEnumerable CreateLookupVaryKey(HttpContext httpContext, VaryRules varyRules) + { + foreach (var baseKey in _baseKey) + { + foreach (var varyKey in _varyKey) + { + yield return baseKey + varyKey; + } + } + } + + public string CreateBodyKey(HttpContext httpContext) + { + throw new NotImplementedException(); + } + + public string CreateStorageBaseKey(HttpContext httpContext) + { + throw new NotImplementedException(); + } + + public string CreateStorageVaryKey(HttpContext httpContext, VaryRules varyRules) + { + throw new NotImplementedException(); + } } private class TestResponseCache : IResponseCache { - public int StoredItems { get; private set; } + private readonly IDictionary _storage = new Dictionary(); + public int GetCount { get; private set; } + public int SetCount { get; private set; } public object Get(string key) { - return null; + GetCount++; + try + { + return _storage[key]; + } + catch + { + return null; + } } public void Remove(string key) @@ -523,7 +726,8 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests public void Set(string key, object entry, TimeSpan validFor) { - StoredItems++; + SetCount++; + _storage[key] = entry; } } @@ -531,7 +735,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests { public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellation) { - return Task.FromResult(0); + return TaskCache.CompletedTask; } } }