Skip to content
This repository was archived by the owner on Nov 22, 2018. It is now read-only.

Commit 52f219b

Browse files
committed
Support conditional requests and send 304 when possible
1 parent a5e9215 commit 52f219b

File tree

3 files changed

+315
-26
lines changed

3 files changed

+315
-26
lines changed

src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -443,41 +443,50 @@ internal async Task<bool> TryServeFromCacheAsync()
443443

444444
if (EntryIsFresh(cachedResponseHeaders, age, verifyAgainstRequest: true))
445445
{
446-
var response = _httpContext.Response;
447-
// Copy the cached status code and response headers
448-
response.StatusCode = cachedResponse.StatusCode;
449-
foreach (var header in cachedResponse.Headers)
450-
{
451-
response.Headers.Add(header);
452-
}
453-
454-
response.Headers[HeaderNames.Age] = age.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture);
455-
456-
if (_responseType == ResponseType.HeadersOnly)
446+
// Check conditional request rules
447+
if (ConditionalRequestSatisfied(cachedResponseHeaders))
457448
{
449+
_httpContext.Response.StatusCode = StatusCodes.Status304NotModified;
458450
responseServed = true;
459451
}
460-
else if (_responseType == ResponseType.FullReponse)
452+
else
461453
{
462-
// Copy the cached response body
463-
var body = cachedResponse.Body;
464-
465-
// Add a content-length if required
466-
if (response.ContentLength == null && string.IsNullOrEmpty(response.Headers[HeaderNames.TransferEncoding]))
454+
var response = _httpContext.Response;
455+
// Copy the cached status code and response headers
456+
response.StatusCode = cachedResponse.StatusCode;
457+
foreach (var header in cachedResponse.Headers)
467458
{
468-
response.ContentLength = body.Length;
459+
response.Headers.Add(header);
469460
}
470461

471-
if (body.Length > 0)
462+
response.Headers[HeaderNames.Age] = age.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture);
463+
464+
if (_responseType == ResponseType.HeadersOnly)
472465
{
473-
await response.Body.WriteAsync(body, 0, body.Length);
466+
responseServed = true;
474467
}
468+
else if (_responseType == ResponseType.FullReponse)
469+
{
470+
// Copy the cached response body
471+
var body = cachedResponse.Body;
475472

476-
responseServed = true;
477-
}
478-
else
479-
{
480-
throw new InvalidOperationException($"{nameof(_responseType)} not specified or is unrecognized.");
473+
// Add a content-length if required
474+
if (response.ContentLength == null && string.IsNullOrEmpty(response.Headers[HeaderNames.TransferEncoding]))
475+
{
476+
response.ContentLength = body.Length;
477+
}
478+
479+
if (body.Length > 0)
480+
{
481+
await response.Body.WriteAsync(body, 0, body.Length);
482+
}
483+
484+
responseServed = true;
485+
}
486+
else
487+
{
488+
throw new InvalidOperationException($"{nameof(_responseType)} not specified or is unrecognized.");
489+
}
481490
}
482491
}
483492
else
@@ -489,13 +498,42 @@ internal async Task<bool> TryServeFromCacheAsync()
489498
if (!responseServed && RequestCacheControl.OnlyIfCached)
490499
{
491500
_httpContext.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
492-
493501
responseServed = true;
494502
}
495503

496504
return responseServed;
497505
}
498506

507+
internal bool ConditionalRequestSatisfied(ResponseHeaders cachedResponseHeaders)
508+
{
509+
var ifNoneMatchHeader = RequestHeaders.IfNoneMatch;
510+
511+
if (ifNoneMatchHeader != null)
512+
{
513+
if (ifNoneMatchHeader.Count == 1 && ifNoneMatchHeader[0].Equals(EntityTagHeaderValue.Any))
514+
{
515+
return true;
516+
}
517+
518+
if (cachedResponseHeaders.ETag != null)
519+
{
520+
foreach (var tag in ifNoneMatchHeader)
521+
{
522+
if (cachedResponseHeaders.ETag.Compare(tag, useStrongComparison: true))
523+
{
524+
return true;
525+
}
526+
}
527+
}
528+
}
529+
else if ((cachedResponseHeaders.LastModified ?? cachedResponseHeaders.Date) <= RequestHeaders.IfUnmodifiedSince)
530+
{
531+
return true;
532+
}
533+
534+
return false;
535+
}
536+
499537
internal void FinalizeCachingHeaders()
500538
{
501539
if (CacheResponse)

test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Collections.Generic;
56
using System.Text;
67
using System.Threading;
78
using System.Threading.Tasks;
@@ -692,6 +693,140 @@ public void EntryIsFresh_IgnoresRequestVerificationWhenSpecified()
692693
Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(3), verifyAgainstRequest: false));
693694
}
694695

696+
[Fact]
697+
public void ConditionalRequestSatisfied_NotConditionalRequest_Fails()
698+
{
699+
var context = CreateTestContext(new DefaultHttpContext());
700+
var cachedHeaders = new ResponseHeaders(new HeaderDictionary());
701+
702+
Assert.False(context.ConditionalRequestSatisfied(cachedHeaders));
703+
}
704+
705+
[Fact]
706+
public void ConditionalRequestSatisfied_IfUnmodifiedSince_FallsbackToDateHeader()
707+
{
708+
var utcNow = DateTimeOffset.UtcNow;
709+
var cachedHeaders = new ResponseHeaders(new HeaderDictionary());
710+
var httpContext = new DefaultHttpContext();
711+
var context = CreateTestContext(httpContext);
712+
713+
httpContext.Request.GetTypedHeaders().IfUnmodifiedSince = utcNow;
714+
715+
// Verify modifications in the past succeeds
716+
cachedHeaders.Date = utcNow - TimeSpan.FromSeconds(10);
717+
Assert.True(context.ConditionalRequestSatisfied(cachedHeaders));
718+
719+
// Verify modifications at present succeeds
720+
cachedHeaders.Date = utcNow;
721+
Assert.True(context.ConditionalRequestSatisfied(cachedHeaders));
722+
723+
// Verify modifications in the future fails
724+
cachedHeaders.Date = utcNow + TimeSpan.FromSeconds(10);
725+
Assert.False(context.ConditionalRequestSatisfied(cachedHeaders));
726+
}
727+
728+
[Fact]
729+
public void ConditionalRequestSatisfied_IfUnmodifiedSince_LastModifiedOverridesDateHeader()
730+
{
731+
var utcNow = DateTimeOffset.UtcNow;
732+
var cachedHeaders = new ResponseHeaders(new HeaderDictionary());
733+
var httpContext = new DefaultHttpContext();
734+
var context = CreateTestContext(httpContext);
735+
736+
httpContext.Request.GetTypedHeaders().IfUnmodifiedSince = utcNow;
737+
738+
// Verify modifications in the past succeeds
739+
cachedHeaders.Date = utcNow + TimeSpan.FromSeconds(10);
740+
cachedHeaders.LastModified = utcNow - TimeSpan.FromSeconds(10);
741+
Assert.True(context.ConditionalRequestSatisfied(cachedHeaders));
742+
743+
// Verify modifications at present
744+
cachedHeaders.Date = utcNow + TimeSpan.FromSeconds(10);
745+
cachedHeaders.LastModified = utcNow;
746+
Assert.True(context.ConditionalRequestSatisfied(cachedHeaders));
747+
748+
// Verify modifications in the future fails
749+
cachedHeaders.Date = utcNow - TimeSpan.FromSeconds(10);
750+
cachedHeaders.LastModified = utcNow + TimeSpan.FromSeconds(10);
751+
Assert.False(context.ConditionalRequestSatisfied(cachedHeaders));
752+
}
753+
754+
[Fact]
755+
public void ConditionalRequestSatisfied_IfNoneMatch_Overrides_IfUnmodifiedSince_ToPass()
756+
{
757+
var utcNow = DateTimeOffset.UtcNow;
758+
var cachedHeaders = new ResponseHeaders(new HeaderDictionary());
759+
var httpContext = new DefaultHttpContext();
760+
var requestHeaders = httpContext.Request.GetTypedHeaders();
761+
var context = CreateTestContext(httpContext);
762+
763+
// This would fail the IfUnmodifiedSince checks
764+
requestHeaders.IfUnmodifiedSince = utcNow;
765+
cachedHeaders.LastModified = utcNow + TimeSpan.FromSeconds(10);
766+
767+
requestHeaders.IfNoneMatch = new List<EntityTagHeaderValue>(new[] { EntityTagHeaderValue.Any });
768+
Assert.True(context.ConditionalRequestSatisfied(cachedHeaders));
769+
}
770+
771+
[Fact]
772+
public void ConditionalRequestSatisfied_IfNoneMatch_Overrides_IfUnmodifiedSince_ToFail()
773+
{
774+
var utcNow = DateTimeOffset.UtcNow;
775+
var cachedHeaders = new ResponseHeaders(new HeaderDictionary());
776+
var httpContext = new DefaultHttpContext();
777+
var requestHeaders = httpContext.Request.GetTypedHeaders();
778+
var context = CreateTestContext(httpContext);
779+
780+
// This would pass the IfUnmodifiedSince checks
781+
requestHeaders.IfUnmodifiedSince = utcNow;
782+
cachedHeaders.LastModified = utcNow - TimeSpan.FromSeconds(10);
783+
784+
requestHeaders.IfNoneMatch = new List<EntityTagHeaderValue>(new[] { new EntityTagHeaderValue("\"E1\"") });
785+
Assert.False(context.ConditionalRequestSatisfied(cachedHeaders));
786+
}
787+
788+
[Fact]
789+
public void ConditionalRequestSatisfied_IfNoneMatch_AnyWithoutETagInResponse_Passes()
790+
{
791+
var cachedHeaders = new ResponseHeaders(new HeaderDictionary());
792+
var httpContext = new DefaultHttpContext();
793+
var context = CreateTestContext(httpContext);
794+
795+
httpContext.Request.GetTypedHeaders().IfNoneMatch = new List<EntityTagHeaderValue>(new[] { new EntityTagHeaderValue("\"E1\"") });
796+
797+
Assert.False(context.ConditionalRequestSatisfied(cachedHeaders));
798+
}
799+
800+
[Fact]
801+
public void ConditionalRequestSatisfied_IfNoneMatch_ExplicitWithMatch_Passes()
802+
{
803+
var cachedHeaders = new ResponseHeaders(new HeaderDictionary())
804+
{
805+
ETag = new EntityTagHeaderValue("\"E1\"")
806+
};
807+
var httpContext = new DefaultHttpContext();
808+
var context = CreateTestContext(httpContext);
809+
810+
httpContext.Request.GetTypedHeaders().IfNoneMatch = new List<EntityTagHeaderValue>(new[] { new EntityTagHeaderValue("\"E1\"") });
811+
812+
Assert.True(context.ConditionalRequestSatisfied(cachedHeaders));
813+
}
814+
815+
[Fact]
816+
public void ConditionalRequestSatisfied_IfNoneMatch_ExplicitWithoutMatch_Fails()
817+
{
818+
var cachedHeaders = new ResponseHeaders(new HeaderDictionary())
819+
{
820+
ETag = new EntityTagHeaderValue("\"E2\"")
821+
};
822+
var httpContext = new DefaultHttpContext();
823+
var context = CreateTestContext(httpContext);
824+
825+
httpContext.Request.GetTypedHeaders().IfNoneMatch = new List<EntityTagHeaderValue>(new[] { new EntityTagHeaderValue("\"E1\"") });
826+
827+
Assert.False(context.ConditionalRequestSatisfied(cachedHeaders));
828+
}
829+
695830
private static ResponseCachingContext CreateTestContext(HttpContext httpContext)
696831
{
697832
return CreateTestContext(

test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,122 @@ private static async Task AssertResponseNotCachedAsync(HttpResponseMessage initi
636636
Assert.NotEqual(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync());
637637
}
638638

639+
[Fact]
640+
public async void Serves304_IfIfModifiedSince_Satisfied()
641+
{
642+
var builder = CreateBuilderWithResponseCaching(async (context) =>
643+
{
644+
var uniqueId = Guid.NewGuid().ToString();
645+
var headers = context.Response.GetTypedHeaders();
646+
headers.CacheControl = new CacheControlHeaderValue()
647+
{
648+
Public = true,
649+
MaxAge = TimeSpan.FromSeconds(10)
650+
};
651+
headers.Date = DateTimeOffset.UtcNow;
652+
headers.Headers["X-Value"] = uniqueId;
653+
await context.Response.WriteAsync(uniqueId);
654+
});
655+
656+
using (var server = new TestServer(builder))
657+
{
658+
var client = server.CreateClient();
659+
var initialResponse = await client.GetAsync("");
660+
client.DefaultRequestHeaders.IfUnmodifiedSince = DateTimeOffset.MaxValue;
661+
var subsequentResponse = await client.GetAsync("");
662+
663+
initialResponse.EnsureSuccessStatusCode();
664+
Assert.Equal(System.Net.HttpStatusCode.NotModified, subsequentResponse.StatusCode);
665+
}
666+
}
667+
668+
[Fact]
669+
public async void ServesCachedContent_IfIfModifiedSince_NotSatisfied()
670+
{
671+
var builder = CreateBuilderWithResponseCaching(async (context) =>
672+
{
673+
var uniqueId = Guid.NewGuid().ToString();
674+
var headers = context.Response.GetTypedHeaders();
675+
headers.CacheControl = new CacheControlHeaderValue()
676+
{
677+
Public = true,
678+
MaxAge = TimeSpan.FromSeconds(10)
679+
};
680+
headers.Date = DateTimeOffset.UtcNow;
681+
headers.Headers["X-Value"] = uniqueId;
682+
await context.Response.WriteAsync(uniqueId);
683+
});
684+
685+
using (var server = new TestServer(builder))
686+
{
687+
var client = server.CreateClient();
688+
var initialResponse = await client.GetAsync("");
689+
client.DefaultRequestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue;
690+
var subsequentResponse = await client.GetAsync("");
691+
692+
await AssertResponseCachedAsync(initialResponse, subsequentResponse);
693+
}
694+
}
695+
696+
[Fact]
697+
public async void Serves304_IfIfNoneMatch_Satisfied()
698+
{
699+
var builder = CreateBuilderWithResponseCaching(async (context) =>
700+
{
701+
var uniqueId = Guid.NewGuid().ToString();
702+
var headers = context.Response.GetTypedHeaders();
703+
headers.CacheControl = new CacheControlHeaderValue()
704+
{
705+
Public = true,
706+
MaxAge = TimeSpan.FromSeconds(10)
707+
};
708+
headers.Date = DateTimeOffset.UtcNow;
709+
headers.Headers["X-Value"] = uniqueId;
710+
headers.ETag = new EntityTagHeaderValue("\"E1\"");
711+
await context.Response.WriteAsync(uniqueId);
712+
});
713+
714+
using (var server = new TestServer(builder))
715+
{
716+
var client = server.CreateClient();
717+
var initialResponse = await client.GetAsync("");
718+
client.DefaultRequestHeaders.IfNoneMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue("\"E1\""));
719+
var subsequentResponse = await client.GetAsync("");
720+
721+
initialResponse.EnsureSuccessStatusCode();
722+
Assert.Equal(System.Net.HttpStatusCode.NotModified, subsequentResponse.StatusCode);
723+
}
724+
}
725+
726+
[Fact]
727+
public async void ServesCachedContent_IfIfNoneMatch_NotSatisfied()
728+
{
729+
var builder = CreateBuilderWithResponseCaching(async (context) =>
730+
{
731+
var uniqueId = Guid.NewGuid().ToString();
732+
var headers = context.Response.GetTypedHeaders();
733+
headers.CacheControl = new CacheControlHeaderValue()
734+
{
735+
Public = true,
736+
MaxAge = TimeSpan.FromSeconds(10)
737+
};
738+
headers.Date = DateTimeOffset.UtcNow;
739+
headers.Headers["X-Value"] = uniqueId;
740+
headers.ETag = new EntityTagHeaderValue("\"E1\"");
741+
await context.Response.WriteAsync(uniqueId);
742+
});
743+
744+
using (var server = new TestServer(builder))
745+
{
746+
var client = server.CreateClient();
747+
var initialResponse = await client.GetAsync("");
748+
client.DefaultRequestHeaders.IfNoneMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue("\"E2\""));
749+
var subsequentResponse = await client.GetAsync("");
750+
751+
await AssertResponseCachedAsync(initialResponse, subsequentResponse);
752+
}
753+
}
754+
639755
private static IWebHostBuilder CreateBuilderWithResponseCaching(RequestDelegate requestDelegate) =>
640756
CreateBuilderWithResponseCaching(app => { }, requestDelegate);
641757

0 commit comments

Comments
 (0)