From 52f219b16ebd167941ffc0b0bea77ca7e4241722 Mon Sep 17 00:00:00 2001 From: John Luo Date: Mon, 29 Aug 2016 12:55:38 -0700 Subject: [PATCH] Support conditional requests and send 304 when possible --- .../ResponseCachingContext.cs | 90 ++++++++---- .../ResponseCachingContextTests.cs | 135 ++++++++++++++++++ .../ResponseCachingTests.cs | 116 +++++++++++++++ 3 files changed, 315 insertions(+), 26 deletions(-) diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs index 01ec349..7caa7b2 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs @@ -443,41 +443,50 @@ internal async Task TryServeFromCacheAsync() if (EntryIsFresh(cachedResponseHeaders, age, verifyAgainstRequest: true)) { - var response = _httpContext.Response; - // Copy the cached status code and response headers - response.StatusCode = cachedResponse.StatusCode; - foreach (var header in cachedResponse.Headers) - { - response.Headers.Add(header); - } - - response.Headers[HeaderNames.Age] = age.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture); - - if (_responseType == ResponseType.HeadersOnly) + // Check conditional request rules + if (ConditionalRequestSatisfied(cachedResponseHeaders)) { + _httpContext.Response.StatusCode = StatusCodes.Status304NotModified; responseServed = true; } - else if (_responseType == ResponseType.FullReponse) + else { - // Copy the cached response body - var body = cachedResponse.Body; - - // Add a content-length if required - if (response.ContentLength == null && string.IsNullOrEmpty(response.Headers[HeaderNames.TransferEncoding])) + var response = _httpContext.Response; + // Copy the cached status code and response headers + response.StatusCode = cachedResponse.StatusCode; + foreach (var header in cachedResponse.Headers) { - response.ContentLength = body.Length; + response.Headers.Add(header); } - if (body.Length > 0) + response.Headers[HeaderNames.Age] = age.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture); + + if (_responseType == ResponseType.HeadersOnly) { - await response.Body.WriteAsync(body, 0, body.Length); + responseServed = true; } + else if (_responseType == ResponseType.FullReponse) + { + // Copy the cached response body + var body = cachedResponse.Body; - responseServed = true; - } - else - { - throw new InvalidOperationException($"{nameof(_responseType)} not specified or is unrecognized."); + // Add a content-length if required + if (response.ContentLength == null && string.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."); + } } } else @@ -489,13 +498,42 @@ internal async Task TryServeFromCacheAsync() if (!responseServed && RequestCacheControl.OnlyIfCached) { _httpContext.Response.StatusCode = StatusCodes.Status504GatewayTimeout; - responseServed = true; } return responseServed; } + internal bool ConditionalRequestSatisfied(ResponseHeaders cachedResponseHeaders) + { + var ifNoneMatchHeader = RequestHeaders.IfNoneMatch; + + if (ifNoneMatchHeader != null) + { + if (ifNoneMatchHeader.Count == 1 && ifNoneMatchHeader[0].Equals(EntityTagHeaderValue.Any)) + { + return true; + } + + if (cachedResponseHeaders.ETag != null) + { + foreach (var tag in ifNoneMatchHeader) + { + if (cachedResponseHeaders.ETag.Compare(tag, useStrongComparison: true)) + { + return true; + } + } + } + } + else if ((cachedResponseHeaders.LastModified ?? cachedResponseHeaders.Date) <= RequestHeaders.IfUnmodifiedSince) + { + return true; + } + + return false; + } + internal void FinalizeCachingHeaders() { if (CacheResponse) diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs index fa47d71..54bd5a5 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.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.Text; using System.Threading; using System.Threading.Tasks; @@ -692,6 +693,140 @@ public void EntryIsFresh_IgnoresRequestVerificationWhenSpecified() Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(3), verifyAgainstRequest: false)); } + [Fact] + public void ConditionalRequestSatisfied_NotConditionalRequest_Fails() + { + var context = CreateTestContext(new DefaultHttpContext()); + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()); + + Assert.False(context.ConditionalRequestSatisfied(cachedHeaders)); + } + + [Fact] + public void ConditionalRequestSatisfied_IfUnmodifiedSince_FallsbackToDateHeader() + { + var utcNow = DateTimeOffset.UtcNow; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()); + var httpContext = new DefaultHttpContext(); + var context = CreateTestContext(httpContext); + + httpContext.Request.GetTypedHeaders().IfUnmodifiedSince = utcNow; + + // Verify modifications in the past succeeds + cachedHeaders.Date = utcNow - TimeSpan.FromSeconds(10); + Assert.True(context.ConditionalRequestSatisfied(cachedHeaders)); + + // Verify modifications at present succeeds + cachedHeaders.Date = utcNow; + Assert.True(context.ConditionalRequestSatisfied(cachedHeaders)); + + // Verify modifications in the future fails + cachedHeaders.Date = utcNow + TimeSpan.FromSeconds(10); + Assert.False(context.ConditionalRequestSatisfied(cachedHeaders)); + } + + [Fact] + public void ConditionalRequestSatisfied_IfUnmodifiedSince_LastModifiedOverridesDateHeader() + { + var utcNow = DateTimeOffset.UtcNow; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()); + var httpContext = new DefaultHttpContext(); + var context = CreateTestContext(httpContext); + + httpContext.Request.GetTypedHeaders().IfUnmodifiedSince = utcNow; + + // Verify modifications in the past succeeds + cachedHeaders.Date = utcNow + TimeSpan.FromSeconds(10); + cachedHeaders.LastModified = utcNow - TimeSpan.FromSeconds(10); + Assert.True(context.ConditionalRequestSatisfied(cachedHeaders)); + + // Verify modifications at present + cachedHeaders.Date = utcNow + TimeSpan.FromSeconds(10); + cachedHeaders.LastModified = utcNow; + Assert.True(context.ConditionalRequestSatisfied(cachedHeaders)); + + // Verify modifications in the future fails + cachedHeaders.Date = utcNow - TimeSpan.FromSeconds(10); + cachedHeaders.LastModified = utcNow + TimeSpan.FromSeconds(10); + Assert.False(context.ConditionalRequestSatisfied(cachedHeaders)); + } + + [Fact] + public void ConditionalRequestSatisfied_IfNoneMatch_Overrides_IfUnmodifiedSince_ToPass() + { + var utcNow = DateTimeOffset.UtcNow; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()); + var httpContext = new DefaultHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + var context = CreateTestContext(httpContext); + + // This would fail the IfUnmodifiedSince checks + requestHeaders.IfUnmodifiedSince = utcNow; + cachedHeaders.LastModified = utcNow + TimeSpan.FromSeconds(10); + + requestHeaders.IfNoneMatch = new List(new[] { EntityTagHeaderValue.Any }); + Assert.True(context.ConditionalRequestSatisfied(cachedHeaders)); + } + + [Fact] + public void ConditionalRequestSatisfied_IfNoneMatch_Overrides_IfUnmodifiedSince_ToFail() + { + var utcNow = DateTimeOffset.UtcNow; + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()); + var httpContext = new DefaultHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + var context = CreateTestContext(httpContext); + + // This would pass the IfUnmodifiedSince checks + requestHeaders.IfUnmodifiedSince = utcNow; + cachedHeaders.LastModified = utcNow - TimeSpan.FromSeconds(10); + + requestHeaders.IfNoneMatch = new List(new[] { new EntityTagHeaderValue("\"E1\"") }); + Assert.False(context.ConditionalRequestSatisfied(cachedHeaders)); + } + + [Fact] + public void ConditionalRequestSatisfied_IfNoneMatch_AnyWithoutETagInResponse_Passes() + { + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()); + var httpContext = new DefaultHttpContext(); + var context = CreateTestContext(httpContext); + + httpContext.Request.GetTypedHeaders().IfNoneMatch = new List(new[] { new EntityTagHeaderValue("\"E1\"") }); + + Assert.False(context.ConditionalRequestSatisfied(cachedHeaders)); + } + + [Fact] + public void ConditionalRequestSatisfied_IfNoneMatch_ExplicitWithMatch_Passes() + { + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()) + { + ETag = new EntityTagHeaderValue("\"E1\"") + }; + var httpContext = new DefaultHttpContext(); + var context = CreateTestContext(httpContext); + + httpContext.Request.GetTypedHeaders().IfNoneMatch = new List(new[] { new EntityTagHeaderValue("\"E1\"") }); + + Assert.True(context.ConditionalRequestSatisfied(cachedHeaders)); + } + + [Fact] + public void ConditionalRequestSatisfied_IfNoneMatch_ExplicitWithoutMatch_Fails() + { + var cachedHeaders = new ResponseHeaders(new HeaderDictionary()) + { + ETag = new EntityTagHeaderValue("\"E2\"") + }; + var httpContext = new DefaultHttpContext(); + var context = CreateTestContext(httpContext); + + httpContext.Request.GetTypedHeaders().IfNoneMatch = new List(new[] { new EntityTagHeaderValue("\"E1\"") }); + + Assert.False(context.ConditionalRequestSatisfied(cachedHeaders)); + } + private static ResponseCachingContext CreateTestContext(HttpContext httpContext) { return CreateTestContext( diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs index 2648025..400e5af 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs @@ -636,6 +636,122 @@ private static async Task AssertResponseNotCachedAsync(HttpResponseMessage initi Assert.NotEqual(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync()); } + [Fact] + public async void Serves304_IfIfModifiedSince_Satisfied() + { + 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; + await context.Response.WriteAsync(uniqueId); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.IfUnmodifiedSince = DateTimeOffset.MaxValue; + var subsequentResponse = await client.GetAsync(""); + + initialResponse.EnsureSuccessStatusCode(); + Assert.Equal(System.Net.HttpStatusCode.NotModified, subsequentResponse.StatusCode); + } + } + + [Fact] + public async void ServesCachedContent_IfIfModifiedSince_NotSatisfied() + { + var 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; + await context.Response.WriteAsync(uniqueId); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue; + var subsequentResponse = await client.GetAsync(""); + + await AssertResponseCachedAsync(initialResponse, subsequentResponse); + } + } + + [Fact] + public async void Serves304_IfIfNoneMatch_Satisfied() + { + 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; + headers.ETag = new EntityTagHeaderValue("\"E1\""); + await context.Response.WriteAsync(uniqueId); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.IfNoneMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue("\"E1\"")); + var subsequentResponse = await client.GetAsync(""); + + initialResponse.EnsureSuccessStatusCode(); + Assert.Equal(System.Net.HttpStatusCode.NotModified, subsequentResponse.StatusCode); + } + } + + [Fact] + public async void ServesCachedContent_IfIfNoneMatch_NotSatisfied() + { + var 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; + headers.ETag = new EntityTagHeaderValue("\"E1\""); + await context.Response.WriteAsync(uniqueId); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.IfNoneMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue("\"E2\"")); + var subsequentResponse = await client.GetAsync(""); + + await AssertResponseCachedAsync(initialResponse, subsequentResponse); + } + } + private static IWebHostBuilder CreateBuilderWithResponseCaching(RequestDelegate requestDelegate) => CreateBuilderWithResponseCaching(app => { }, requestDelegate);