diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedVaryBy.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedVaryBy.cs index 8ff382cd09..1fb1a4501d 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedVaryBy.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedVaryBy.cs @@ -8,5 +8,6 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal internal class CachedVaryBy { internal StringValues Headers { get; set; } + internal StringValues Params { get; set; } } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/DefaultResponseCacheEntrySerializer.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/DefaultResponseCacheEntrySerializer.cs index 5112081b18..23b06f26a7 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/DefaultResponseCacheEntrySerializer.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/DefaultResponseCacheEntrySerializer.cs @@ -96,12 +96,26 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal } // Serialization Format - // Headers (comma separated string) + // Headers count + // Headers if count > 0 (comma separated string) + // Params count + // Params if count > 0 (comma separated string) private static CachedVaryBy ReadCachedVaryBy(BinaryReader reader) { - var headers = reader.ReadString().Split(','); + var headerCount = reader.ReadInt32(); + var headers = new string[headerCount]; + for (var index = 0; index < headerCount; index++) + { + headers[index] = reader.ReadString(); + } + var paramCount = reader.ReadInt32(); + var param = new string[paramCount]; + for (var index = 0; index < paramCount; index++) + { + param[index] = reader.ReadString(); + } - return new CachedVaryBy { Headers = headers }; + return new CachedVaryBy { Headers = headers, Params = param }; } // See serialization format above @@ -154,7 +168,18 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal private static void WriteCachedVaryBy(BinaryWriter writer, CachedVaryBy entry) { writer.Write(nameof(CachedVaryBy)); - writer.Write(entry.Headers); + + writer.Write(entry.Headers.Count); + foreach (var header in entry.Headers) + { + writer.Write(header); + } + + writer.Write(entry.Params.Count); + foreach (var param in entry.Params) + { + writer.Write(param); + } } } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs index b7c9b7a626..1fcb159239 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs @@ -4,6 +4,7 @@ using System; using System.IO; using System.Globalization; +using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -19,6 +20,8 @@ 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; @@ -150,13 +153,19 @@ namespace Microsoft.AspNetCore.ResponseCaching try { + // Default key builder .Append(request.Method.ToUpperInvariant()) - .Append(";") + .Append(KeyDelimiter) .Append(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) { @@ -169,19 +178,62 @@ namespace Microsoft.AspNetCore.ResponseCaching value = "null"; } - builder.Append(";") + builder.Append(KeyDelimiter) .Append(header) .Append("=") .Append(value); } } - // TODO: Parse querystring params + + // 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], "*")) + { + // 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); + } + } + } // Append custom cache key segment var customKey = _cacheKeySuffixProvider.CreateCustomKeySuffix(_httpContext); if (!string.IsNullOrEmpty(customKey)) { - builder.Append(";") + // Append a group separator for the custom segment of the cache key + builder.Append(KeyDelimiter) + .Append('C'); + + builder.Append(KeyDelimiter) .Append(customKey); } @@ -451,6 +503,7 @@ namespace Microsoft.AspNetCore.ResponseCaching // 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) @@ -458,13 +511,18 @@ namespace Microsoft.AspNetCore.ResponseCaching ?? TimeSpan.FromSeconds(10); // Check if any VaryBy rules exist - if (!StringValues.IsNullOrEmpty(varyHeaderValue)) + if (!StringValues.IsNullOrEmpty(varyHeaderValue) || !StringValues.IsNullOrEmpty(varyParamsValue)) { + if (varyParamsValue.Count > 1) + { + Array.Sort(varyParamsValue.ToArray(), StringComparer.OrdinalIgnoreCase); + } + var cachedVaryBy = new CachedVaryBy { - // Only vary by headers for now // TODO: VaryBy Encoding - Headers = varyHeaderValue + Headers = varyHeaderValue, + Params = varyParamsValue }; // TODO: Overwrite? @@ -536,6 +594,9 @@ namespace Microsoft.AspNetCore.ResponseCaching { _httpContext.Features.Set(new SendFileFeatureWrapper(OriginalSendFileFeature, ResponseCacheStream)); } + + // TODO: Move this temporary interface with endpoint to HttpAbstractions + _httpContext.AddResponseCachingFeature(); } internal void UnshimResponseStream() @@ -545,6 +606,9 @@ namespace Microsoft.AspNetCore.ResponseCaching // Unshim IHttpSendFileFeature _httpContext.Features.Set(OriginalSendFileFeature); + + // TODO: Move this temporary interface with endpoint to HttpAbstractions + _httpContext.RemoveResponseCachingFeature(); } private enum ResponseType diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs index 76b81dbccb..45d905cea6 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs @@ -3,7 +3,6 @@ using System; using Microsoft.AspNetCore.ResponseCaching; -using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Builder { diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingFeature.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingFeature.cs new file mode 100644 index 0000000000..4341f20ae7 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingFeature.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.ResponseCaching +{ + // TODO: Temporary interface for endpoints to specify options for response caching + public class ResponseCachingFeature + { + public StringValues VaryByParams { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingHttpContextExtensions.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingHttpContextExtensions.cs new file mode 100644 index 0000000000..e446b4491b --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingHttpContextExtensions.cs @@ -0,0 +1,26 @@ +// 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 +{ + // TODO: Temporary interface for endpoints to specify options for response caching + public static class ResponseCachingHttpContextExtensions + { + public static void AddResponseCachingFeature(this HttpContext httpContext) + { + httpContext.Features.Set(new ResponseCachingFeature()); + } + + public static void RemoveResponseCachingFeature(this HttpContext httpContext) + { + httpContext.Features.Set(null); + } + + public static ResponseCachingFeature GetResponseCachingFeature(this HttpContext httpContext) + { + return httpContext.Features.Get() ?? new ResponseCachingFeature(); + } + } +} diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/DefaultResponseCacheEntrySerializerTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/DefaultResponseCacheEntrySerializerTests.cs index b0da702792..2469af5ff8 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/DefaultResponseCacheEntrySerializerTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/DefaultResponseCacheEntrySerializerTests.cs @@ -13,19 +13,19 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests public class DefaultResponseCacheEntrySerializerTests { [Fact] - public void SerializeNullObjectThrows() + public void Serialize_NullObject_Throws() { Assert.Throws(() => DefaultResponseCacheSerializer.Serialize(null)); } [Fact] - public void SerializeUnknownObjectThrows() + public void Serialize_UnknownObject_Throws() { Assert.Throws(() => DefaultResponseCacheSerializer.Serialize(new object())); } [Fact] - public void RoundTripCachedResponsesSucceeds() + public void RoundTrip_CachedResponses_Succeeds() { var headers = new HeaderDictionary(); headers["keyA"] = "valueA"; @@ -42,7 +42,15 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public void RoundTripCachedVaryBySucceeds() + 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() @@ -53,9 +61,34 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests 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 DeserializeInvalidEntriesReturnsNull() + 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() @@ -87,6 +120,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests 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/ResponseCachingContextTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs index 74e3819763..fa47d71309 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs @@ -17,6 +17,8 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests { public class ResponseCachingContextTests { + private static readonly char KeyDelimiter = '\x1e'; + [Theory] [InlineData("GET")] [InlineData("HEAD")] @@ -171,7 +173,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests httpContext.Request.QueryString = new QueryString("?query.Key=a&query.Value=b"); var context = CreateTestContext(httpContext); - Assert.Equal("HEAD;/PATH/SUBPATH", context.CreateCacheKey()); + Assert.Equal($"HEAD{KeyDelimiter}/PATH/SUBPATH", context.CreateCacheKey()); } [Fact] @@ -184,12 +186,77 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests httpContext.Request.Headers["HeaderB"] = "ValueB"; var context = CreateTestContext(httpContext); - Assert.Equal("GET;/;HeaderA=ValueA;HeaderC=null", context.CreateCacheKey(new CachedVaryBy() + 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 CustomizeKeySuffixProvider : IResponseCachingCacheKeySuffixProvider { public string CreateCustomKeySuffix(HttpContext httpContext) => "CustomizedKey"; @@ -205,7 +272,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests httpContext.Request.Headers["HeaderB"] = "ValueB"; var responseCachingContext = CreateTestContext(httpContext, new CustomizeKeySuffixProvider()); - Assert.Equal("GET;/;HeaderA=ValueA;HeaderC=null;CustomizedKey", responseCachingContext.CreateCacheKey(new CachedVaryBy() + Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=null{KeyDelimiter}C{KeyDelimiter}CustomizedKey", responseCachingContext.CreateCacheKey(new CachedVaryBy() { Headers = new string[] { "HeaderA", "HeaderC" } })); diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs index 9c700e6917..2648025acd 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.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.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; @@ -18,7 +19,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests public class ResponseCachingTests { [Fact] - public async void ServesCachedContentIfAvailable() + public async void ServesCachedContent_IfAvailable() { var builder = CreateBuilderWithResponseCaching(async (context) => { @@ -40,20 +41,12 @@ 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) - { - Assert.Equal(initialResponse.Headers.GetValues(header.Key), subsequentResponse.Headers.GetValues(header.Key)); - } - Assert.True(subsequentResponse.Headers.Contains(HeaderNames.Age)); - Assert.Equal(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + await AssertResponseCachedAsync(initialResponse, subsequentResponse); } } [Fact] - public async void ServesFreshContentIfNotAvailable() + public async void ServesFreshContent_IfNotAvailable() { var builder = CreateBuilderWithResponseCaching(async (context) => { @@ -75,16 +68,12 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests var initialResponse = await client.GetAsync(""); var subsequentResponse = await client.GetAsync("/different"); - initialResponse.EnsureSuccessStatusCode(); - subsequentResponse.EnsureSuccessStatusCode(); - - Assert.False(subsequentResponse.Headers.Contains(HeaderNames.Age)); - Assert.NotEqual(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + await AssertResponseNotCachedAsync(initialResponse, subsequentResponse); } } [Fact] - public async void ServesCachedContentIfVaryByMatches() + public async void ServesCachedContent_IfVaryByHeader_Matches() { var builder = CreateBuilderWithResponseCaching(async (context) => { @@ -108,20 +97,284 @@ 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) - { - Assert.Equal(initialResponse.Headers.GetValues(header.Key), subsequentResponse.Headers.GetValues(header.Key)); - } - Assert.True(subsequentResponse.Headers.Contains(HeaderNames.Age)); - Assert.Equal(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + await AssertResponseCachedAsync(initialResponse, subsequentResponse); } } [Fact] - public async void ServesFreshContentIfRequestRequirementsNotMet() + public async void ServesFreshContent_IfVaryByHeader_Mismatches() + { + var builder = CreateBuilderWithResponseCaching(async (context) => + { + var uniqueId = Guid.NewGuid().ToString(); + var headers = context.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }; + headers.Date = DateTimeOffset.UtcNow; + headers.Headers["X-Value"] = uniqueId; + context.Response.Headers[HeaderNames.Vary] = HeaderNames.From; + await context.Response.WriteAsync(uniqueId); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + client.DefaultRequestHeaders.From = "user@example.com"; + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user2@example.com"; + var subsequentResponse = await client.GetAsync(""); + + await AssertResponseNotCachedAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async void ServesCachedContent_IfVaryByParams_Matches() + { + var builder = CreateBuilderWithResponseCaching(async (context) => + { + var uniqueId = Guid.NewGuid().ToString(); + var headers = context.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }; + headers.Date = DateTimeOffset.UtcNow; + headers.Headers["X-Value"] = uniqueId; + context.GetResponseCachingFeature().VaryByParams = "param"; + await context.Response.WriteAsync(uniqueId); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?param=value"); + var subsequentResponse = await client.GetAsync("?param=value"); + + await AssertResponseCachedAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async void ServesCachedContent_IfVaryByParamsExplicit_Matches_ParamNameCaseInsensitive() + { + var builder = CreateBuilderWithResponseCaching( + app => + { + app.Use(async (context, next) => + { + context.Features.Set(new DummySendFileFeature()); + await next.Invoke(); + }); + }, + async (context) => + { + var uniqueId = Guid.NewGuid().ToString(); + var headers = context.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }; + headers.Date = DateTimeOffset.UtcNow; + headers.Headers["X-Value"] = uniqueId; + context.GetResponseCachingFeature().VaryByParams = new[] { "ParamA", "paramb" }; + await context.Response.WriteAsync(uniqueId); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?parama=valuea¶mb=valueb"); + var subsequentResponse = await client.GetAsync("?ParamA=valuea&ParamB=valueb"); + + await AssertResponseCachedAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async void ServesCachedContent_IfVaryByParamsStar_Matches_ParamNameCaseInsensitive() + { + var builder = CreateBuilderWithResponseCaching( + app => + { + app.Use(async (context, next) => + { + context.Features.Set(new DummySendFileFeature()); + await next.Invoke(); + }); + }, + async (context) => + { + var uniqueId = Guid.NewGuid().ToString(); + var headers = context.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }; + headers.Date = DateTimeOffset.UtcNow; + headers.Headers["X-Value"] = uniqueId; + context.GetResponseCachingFeature().VaryByParams = new[] { "*" }; + await context.Response.WriteAsync(uniqueId); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?parama=valuea¶mb=valueb"); + var subsequentResponse = await client.GetAsync("?ParamA=valuea&ParamB=valueb"); + + await AssertResponseCachedAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async void ServesCachedContent_IfVaryByParamsExplicit_Matches_OrderInsensitive() + { + var builder = CreateBuilderWithResponseCaching(async (context) => + { + var uniqueId = Guid.NewGuid().ToString(); + var headers = context.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }; + headers.Date = DateTimeOffset.UtcNow; + headers.Headers["X-Value"] = uniqueId; + context.GetResponseCachingFeature().VaryByParams = new[] { "ParamB", "ParamA" }; + await context.Response.WriteAsync(uniqueId); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?ParamA=ValueA&ParamB=ValueB"); + var subsequentResponse = await client.GetAsync("?ParamB=ValueB&ParamA=ValueA"); + + await AssertResponseCachedAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async void ServesCachedContent_IfVaryByParamsStar_Matches_OrderInsensitive() + { + var builder = CreateBuilderWithResponseCaching(async (context) => + { + var uniqueId = Guid.NewGuid().ToString(); + var headers = context.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }; + headers.Date = DateTimeOffset.UtcNow; + headers.Headers["X-Value"] = uniqueId; + context.GetResponseCachingFeature().VaryByParams = new[] { "*" }; + await context.Response.WriteAsync(uniqueId); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?ParamA=ValueA&ParamB=ValueB"); + var subsequentResponse = await client.GetAsync("?ParamB=ValueB&ParamA=ValueA"); + + await AssertResponseCachedAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async void ServesFreshContent_IfVaryByParams_Mismatches() + { + var builder = CreateBuilderWithResponseCaching(async (context) => + { + var uniqueId = Guid.NewGuid().ToString(); + var headers = context.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }; + headers.Date = DateTimeOffset.UtcNow; + headers.Headers["X-Value"] = uniqueId; + context.GetResponseCachingFeature().VaryByParams = "param"; + await context.Response.WriteAsync(uniqueId); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?param=value"); + var subsequentResponse = await client.GetAsync("?param=value2"); + + await AssertResponseNotCachedAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async void ServesFreshContent_IfVaryByParamsExplicit_Mismatch_ParamValueCaseSensitive() + { + var builder = CreateBuilderWithResponseCaching(async (context) => + { + var uniqueId = Guid.NewGuid().ToString(); + var headers = context.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }; + headers.Date = DateTimeOffset.UtcNow; + headers.Headers["X-Value"] = uniqueId; + context.GetResponseCachingFeature().VaryByParams = new[] { "ParamA", "ParamB" }; + await context.Response.WriteAsync(uniqueId); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?parama=valuea¶mb=valueb"); + var subsequentResponse = await client.GetAsync("?parama=ValueA¶mb=ValueB"); + + await AssertResponseNotCachedAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async void ServesFreshContent_IfVaryByParamsStar_Mismatch_ParamValueCaseSensitive() + { + var builder = CreateBuilderWithResponseCaching(async (context) => + { + var uniqueId = Guid.NewGuid().ToString(); + var headers = context.Response.GetTypedHeaders(); + headers.CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }; + headers.Date = DateTimeOffset.UtcNow; + headers.Headers["X-Value"] = uniqueId; + context.GetResponseCachingFeature().VaryByParams = new[] { "*" }; + await context.Response.WriteAsync(uniqueId); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync("?parama=valuea¶mb=valueb"); + var subsequentResponse = await client.GetAsync("?parama=ValueA¶mb=ValueB"); + + await AssertResponseNotCachedAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async void ServesFreshContent_IfRequestRequirements_NotMet() { var builder = CreateBuilderWithResponseCaching(async (context) => { @@ -147,50 +400,12 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests }; var subsequentResponse = await client.GetAsync(""); - initialResponse.EnsureSuccessStatusCode(); - subsequentResponse.EnsureSuccessStatusCode(); - - Assert.False(subsequentResponse.Headers.Contains(HeaderNames.Age)); - Assert.NotEqual(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + await AssertResponseNotCachedAsync(initialResponse, subsequentResponse); } } [Fact] - public async void ServesFreshContentIfVaryByMismatches() - { - var builder = CreateBuilderWithResponseCaching(async (context) => - { - var uniqueId = Guid.NewGuid().ToString(); - var headers = context.Response.GetTypedHeaders(); - headers.CacheControl = new CacheControlHeaderValue() - { - Public = true, - MaxAge = TimeSpan.FromSeconds(10) - }; - headers.Date = DateTimeOffset.UtcNow; - headers.Headers["X-Value"] = uniqueId; - context.Response.Headers[HeaderNames.Vary] = HeaderNames.From; - await context.Response.WriteAsync(uniqueId); - }); - - using (var server = new TestServer(builder)) - { - var client = server.CreateClient(); - client.DefaultRequestHeaders.From = "user@example.com"; - var initialResponse = await client.GetAsync(""); - client.DefaultRequestHeaders.From = "user2@example.com"; - var subsequentResponse = await client.GetAsync(""); - - initialResponse.EnsureSuccessStatusCode(); - subsequentResponse.EnsureSuccessStatusCode(); - - Assert.False(subsequentResponse.Headers.Contains(HeaderNames.Age)); - Assert.NotEqual(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); - } - } - - [Fact] - public async void Serves504IfOnlyIfCachedHeaderIsSpecified() + public async void Serves504_IfOnlyIfCachedHeader_IsSpecified() { var builder = CreateBuilderWithResponseCaching(async (context) => { @@ -222,7 +437,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public async void ServesCachedContentWithoutSetCookie() + public async void ServesCachedContent_WithoutSetCookie() { var builder = CreateBuilderWithResponseCaching(async (context) => { @@ -263,7 +478,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public async void ServesCachedContentIfIHttpSendFileFeatureNotUsed() + public async void ServesCachedContent_IfIHttpSendFileFeature_NotUsed() { var builder = CreateBuilderWithResponseCaching( app => @@ -294,20 +509,12 @@ 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) - { - Assert.Equal(initialResponse.Headers.GetValues(header.Key), subsequentResponse.Headers.GetValues(header.Key)); - } - Assert.True(subsequentResponse.Headers.Contains(HeaderNames.Age)); - Assert.Equal(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + await AssertResponseCachedAsync(initialResponse, subsequentResponse); } } [Fact] - public async void ServesFreshContentIfIHttpSendFileFeatureUsed() + public async void ServesFreshContent_IfIHttpSendFileFeature_Used() { var builder = CreateBuilderWithResponseCaching( app => @@ -339,16 +546,12 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests var initialResponse = await client.GetAsync(""); var subsequentResponse = await client.GetAsync(""); - initialResponse.EnsureSuccessStatusCode(); - subsequentResponse.EnsureSuccessStatusCode(); - - Assert.False(subsequentResponse.Headers.Contains(HeaderNames.Age)); - Assert.NotEqual(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + await AssertResponseNotCachedAsync(initialResponse, subsequentResponse); } } [Fact] - public async void ServesCachedContentIfSubsequentRequestContainsNoStore() + public async void ServesCachedContent_IfSubsequentRequest_ContainsNoStore() { var builder = CreateBuilderWithResponseCaching( async (context) => @@ -375,20 +578,12 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests }; var subsequentResponse = await client.GetAsync(""); - initialResponse.EnsureSuccessStatusCode(); - subsequentResponse.EnsureSuccessStatusCode(); - - foreach (var header in initialResponse.Headers) - { - Assert.Equal(initialResponse.Headers.GetValues(header.Key), subsequentResponse.Headers.GetValues(header.Key)); - } - Assert.True(subsequentResponse.Headers.Contains(HeaderNames.Age)); - Assert.Equal(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + await AssertResponseCachedAsync(initialResponse, subsequentResponse); } } [Fact] - public async void ServesFreshContentIfInitialRequestContainsNoStore() + public async void ServesFreshContent_IfInitialRequestContains_NoStore() { var builder = CreateBuilderWithResponseCaching( async (context) => @@ -415,14 +610,32 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests var initialResponse = await client.GetAsync(""); var subsequentResponse = await client.GetAsync(""); - initialResponse.EnsureSuccessStatusCode(); - subsequentResponse.EnsureSuccessStatusCode(); - - Assert.False(subsequentResponse.Headers.Contains(HeaderNames.Age)); - Assert.NotEqual(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + await AssertResponseNotCachedAsync(initialResponse, subsequentResponse); } } + private static async Task AssertResponseCachedAsync(HttpResponseMessage initialResponse, HttpResponseMessage subsequentResponse) + { + initialResponse.EnsureSuccessStatusCode(); + subsequentResponse.EnsureSuccessStatusCode(); + + foreach (var header in initialResponse.Headers) + { + Assert.Equal(initialResponse.Headers.GetValues(header.Key), subsequentResponse.Headers.GetValues(header.Key)); + } + Assert.True(subsequentResponse.Headers.Contains(HeaderNames.Age)); + Assert.Equal(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + } + + private static async Task AssertResponseNotCachedAsync(HttpResponseMessage initialResponse, HttpResponseMessage subsequentResponse) + { + initialResponse.EnsureSuccessStatusCode(); + subsequentResponse.EnsureSuccessStatusCode(); + + Assert.False(subsequentResponse.Headers.Contains(HeaderNames.Age)); + Assert.NotEqual(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); + } + private static IWebHostBuilder CreateBuilderWithResponseCaching(RequestDelegate requestDelegate) => CreateBuilderWithResponseCaching(app => { }, requestDelegate);