diff --git a/samples/ResponseCachingSample/Startup.cs b/samples/ResponseCachingSample/Startup.cs
index 6dc0ffc99e..510f0d0e0e 100644
--- a/samples/ResponseCachingSample/Startup.cs
+++ b/samples/ResponseCachingSample/Startup.cs
@@ -29,7 +29,7 @@ namespace ResponseCachingSample
Public = true,
MaxAge = TimeSpan.FromSeconds(10)
};
- context.Response.Headers["Vary"] = new string[] { "Accept-Encoding", "Non-Existent" };
+ context.Response.Headers[HeaderNames.Vary] = new string[] { "Accept-Encoding", "Non-Existent" };
await context.Response.WriteAsync("Hello World! " + DateTime.UtcNow);
});
diff --git a/src/Microsoft.AspNetCore.ResponseCaching/IResponseCache.cs b/src/Microsoft.AspNetCore.ResponseCaching/IResponseCache.cs
index a3ba37eeac..dd120e6ec6 100644
--- a/src/Microsoft.AspNetCore.ResponseCaching/IResponseCache.cs
+++ b/src/Microsoft.AspNetCore.ResponseCaching/IResponseCache.cs
@@ -1,13 +1,14 @@
// 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;
+
namespace Microsoft.AspNetCore.ResponseCaching
{
public interface IResponseCache
{
object Get(string key);
- // TODO: Set expiry policy in the underlying cache?
- void Set(string key, object entry);
+ void Set(string key, object entry, TimeSpan validFor);
void Remove(string key);
}
}
diff --git a/src/Microsoft.AspNetCore.ResponseCaching/IResponseCachingOptions.cs b/src/Microsoft.AspNetCore.ResponseCaching/IResponseCachingOptions.cs
deleted file mode 100644
index 4c121c8a63..0000000000
--- a/src/Microsoft.AspNetCore.ResponseCaching/IResponseCachingOptions.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-// Copyright (c) .NET Foundation. All rights reserved.
-// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
-
-namespace Microsoft.AspNetCore.ResponseCaching
-{
- interface IResponseCachingOptions
- {
- int MaxCachedItemBytes { get; set; }
- }
-}
diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedResponse.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedResponse.cs
index 63caf4bcc8..4743d370b5 100644
--- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedResponse.cs
+++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedResponse.cs
@@ -8,11 +8,12 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal
{
internal class CachedResponse
{
+ internal DateTimeOffset Created { get; set; }
+
internal int StatusCode { get; set; }
internal IHeaderDictionary Headers { get; set; } = new HeaderDictionary();
internal byte[] Body { get; set; }
- public DateTimeOffset Created { get; set; }
}
}
diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/DefaultResponseCacheEntrySerializer.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/DefaultResponseCacheEntrySerializer.cs
index 4885a6bfcf..5112081b18 100644
--- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/DefaultResponseCacheEntrySerializer.cs
+++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/DefaultResponseCacheEntrySerializer.cs
@@ -69,6 +69,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal
}
// Serialization Format
+ // Creation time - UtcTicks (long)
// Status code (int)
// Header count (int)
// Header(s)
@@ -78,6 +79,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal
// Body (byte[])
private static CachedResponse ReadCachedResponse(BinaryReader reader)
{
+ var created = new DateTimeOffset(reader.ReadInt64(), TimeSpan.Zero);
var statusCode = reader.ReadInt32();
var headerCount = reader.ReadInt32();
var headers = new HeaderDictionary();
@@ -90,7 +92,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal
var bodyLength = reader.ReadInt32();
var body = reader.ReadBytes(bodyLength);
- return new CachedResponse { StatusCode = statusCode, Headers = headers, Body = body };
+ return new CachedResponse { Created = created, StatusCode = statusCode, Headers = headers, Body = body };
}
// Serialization Format
@@ -135,6 +137,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal
private static void WriteCachedResponse(BinaryWriter writer, CachedResponse entry)
{
writer.Write(nameof(CachedResponse));
+ writer.Write(entry.Created.UtcTicks);
writer.Write(entry.StatusCode);
writer.Write(entry.Headers.Count);
foreach (var header in entry.Headers)
diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/DistributedResponseCache.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/DistributedResponseCache.cs
index d87a4be2b1..c9068b6288 100644
--- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/DistributedResponseCache.cs
+++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/DistributedResponseCache.cs
@@ -45,11 +45,17 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal
}
}
- public void Set(string key, object entry)
+ public void Set(string key, object entry, TimeSpan validFor)
{
try
{
- _cache.Set(key, DefaultResponseCacheSerializer.Serialize(entry));
+ _cache.Set(
+ key,
+ DefaultResponseCacheSerializer.Serialize(entry),
+ new DistributedCacheEntryOptions()
+ {
+ AbsoluteExpirationRelativeToNow = validFor
+ });
}
catch
{
diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/ISystemClock.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ISystemClock.cs
new file mode 100644
index 0000000000..4b560e3dad
--- /dev/null
+++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ISystemClock.cs
@@ -0,0 +1,18 @@
+// 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;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ ///
+ /// Abstracts the system clock to facilitate testing.
+ ///
+ internal interface ISystemClock
+ {
+ ///
+ /// Retrieves the current system time in UTC.
+ ///
+ DateTimeOffset UtcNow { get; }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryResponseCache.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryResponseCache.cs
index 2937ad439d..117d112db9 100644
--- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryResponseCache.cs
+++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryResponseCache.cs
@@ -30,9 +30,15 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal
_cache.Remove(key);
}
- public void Set(string key, object entry)
+ public void Set(string key, object entry, TimeSpan validFor)
{
- _cache.Set(key, entry);
+ _cache.Set(
+ key,
+ entry,
+ new MemoryCacheEntryOptions()
+ {
+ AbsoluteExpirationRelativeToNow = validFor
+ });
}
}
}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCacheStream.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCacheStream.cs
new file mode 100644
index 0000000000..bf9d90ee21
--- /dev/null
+++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCacheStream.cs
@@ -0,0 +1,164 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ internal class ResponseCacheStream : Stream
+ {
+ private readonly Stream _innerStream;
+
+ public ResponseCacheStream(Stream innerStream)
+ {
+ _innerStream = innerStream;
+ }
+
+ public MemoryStream BufferedStream { get; } = new MemoryStream();
+
+ public bool BufferingEnabled { get; set; } = true;
+
+ public override bool CanRead => _innerStream.CanRead;
+
+ public override bool CanSeek => _innerStream.CanSeek;
+
+ public override bool CanWrite => _innerStream.CanWrite;
+
+ public override long Length => _innerStream.Length;
+
+ public override long Position
+ {
+ get { return _innerStream.Position; }
+ set { _innerStream.Position = value; }
+ }
+
+ public void DisableBuffering()
+ {
+ BufferingEnabled = false;
+ BufferedStream.Dispose();
+ }
+
+ public override void SetLength(long value)
+ {
+ DisableBuffering();
+ _innerStream.SetLength(value);
+ }
+
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ DisableBuffering();
+ return _innerStream.Seek(offset, origin);
+ }
+
+ public override void Flush()
+ => _innerStream.Flush();
+
+ public override Task FlushAsync(CancellationToken cancellationToken)
+ => _innerStream.FlushAsync();
+
+ // Underlying stream is write-only, no need to override other read related methods
+ public override int Read(byte[] buffer, int offset, int count)
+ => _innerStream.Read(buffer, offset, count);
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ try
+ {
+ _innerStream.Write(buffer, offset, count);
+ }
+ catch
+ {
+ DisableBuffering();
+ throw;
+ }
+
+ if (BufferingEnabled)
+ {
+ BufferedStream.Write(buffer, offset, count);
+ }
+ }
+
+ public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await _innerStream.WriteAsync(buffer, offset, count, cancellationToken);
+ }
+ catch
+ {
+ DisableBuffering();
+ throw;
+ }
+
+ if (BufferingEnabled)
+ {
+ await BufferedStream.WriteAsync(buffer, offset, count, cancellationToken);
+ }
+ }
+
+ public override void WriteByte(byte value)
+ {
+ try
+ {
+ _innerStream.WriteByte(value);
+ }
+ catch
+ {
+ DisableBuffering();
+ throw;
+ }
+
+ if (BufferingEnabled)
+ {
+ BufferedStream.WriteByte(value);
+ }
+ }
+
+#if NETSTANDARD1_3
+ public IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, object state)
+#else
+ public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, object state)
+#endif
+ {
+ return ToIAsyncResult(WriteAsync(buffer, offset, count), callback, state);
+ }
+#if NETSTANDARD1_3
+ public void EndWrite(IAsyncResult asyncResult)
+#else
+ public override void EndWrite(IAsyncResult asyncResult)
+#endif
+ {
+ if (asyncResult == null)
+ {
+ throw new ArgumentNullException(nameof(asyncResult));
+ }
+ ((Task)asyncResult).GetAwaiter().GetResult();
+ }
+
+ private static IAsyncResult ToIAsyncResult(Task task, AsyncCallback callback, object state)
+ {
+ var tcs = new TaskCompletionSource(state);
+ task.ContinueWith(t =>
+ {
+ if (t.IsFaulted)
+ {
+ tcs.TrySetException(t.Exception.InnerExceptions);
+ }
+ else if (t.IsCanceled)
+ {
+ tcs.TrySetCanceled();
+ }
+ else
+ {
+ tcs.TrySetResult(0);
+ }
+
+ callback?.Invoke(tcs.Task);
+ }, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
+ return tcs.Task;
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/SendFileFeatureWrapper.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/SendFileFeatureWrapper.cs
new file mode 100644
index 0000000000..5d6264228e
--- /dev/null
+++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/SendFileFeatureWrapper.cs
@@ -0,0 +1,28 @@
+// 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.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http.Features;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ internal class SendFileFeatureWrapper : IHttpSendFileFeature
+ {
+ private readonly IHttpSendFileFeature _originalSendFileFeature;
+ private readonly ResponseCacheStream _responseCacheStream;
+
+ public SendFileFeatureWrapper(IHttpSendFileFeature originalSendFileFeature, ResponseCacheStream responseCacheStream)
+ {
+ _originalSendFileFeature = originalSendFileFeature;
+ _responseCacheStream = responseCacheStream;
+ }
+
+ // Flush and disable the buffer if anyone tries to call the SendFile feature.
+ public Task SendFileAsync(string path, long offset, long? length, CancellationToken cancellation)
+ {
+ _responseCacheStream.DisableBuffering();
+ return _originalSendFileFeature.SendFileAsync(path, offset, length, cancellation);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/SystemClock.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/SystemClock.cs
new file mode 100644
index 0000000000..39b6e4735a
--- /dev/null
+++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/SystemClock.cs
@@ -0,0 +1,24 @@
+// 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;
+
+namespace Microsoft.AspNetCore.ResponseCaching.Internal
+{
+ ///
+ /// Provides access to the normal system clock.
+ ///
+ internal class SystemClock : ISystemClock
+ {
+ ///
+ /// Retrieves the current system time in UTC.
+ ///
+ public DateTimeOffset UtcNow
+ {
+ get
+ {
+ return DateTimeOffset.UtcNow;
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.ResponseCaching/Properties/AssemblyInfo.cs
index 32dcddfc57..f6017986e9 100644
--- a/src/Microsoft.AspNetCore.ResponseCaching/Properties/AssemblyInfo.cs
+++ b/src/Microsoft.AspNetCore.ResponseCaching/Properties/AssemblyInfo.cs
@@ -3,9 +3,11 @@
using System.Reflection;
using System.Resources;
+using System.Runtime.CompilerServices;
[assembly: AssemblyMetadata("Serviceable", "True")]
[assembly: NeutralResourcesLanguage("en-us")]
[assembly: AssemblyCompany("Microsoft Corporation.")]
[assembly: AssemblyCopyright("© Microsoft Corporation. All rights reserved.")]
-[assembly: AssemblyProduct("Microsoft ASP.NET Core")]
\ No newline at end of file
+[assembly: AssemblyProduct("Microsoft ASP.NET Core")]
+[assembly: InternalsVisibleTo("Microsoft.AspNetCore.ResponseCaching.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs
index a163e79cb6..4eb18dc0af 100644
--- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs
+++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs
@@ -2,84 +2,144 @@
// 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.IO;
using System.Globalization;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.AspNetCore.Http.Headers;
using Microsoft.AspNetCore.ResponseCaching.Internal;
using Microsoft.Extensions.Primitives;
+using Microsoft.Net.Http.Headers;
namespace Microsoft.AspNetCore.ResponseCaching
{
public class ResponseCachingContext
{
+ private static readonly CacheControlHeaderValue EmptyCacheControl = new CacheControlHeaderValue();
private string _cacheKey;
- private RequestType _requestType;
+ private ResponseType? _responseType;
+ private RequestHeaders _requestHeaders;
+ private ResponseHeaders _responseHeaders;
+ private CacheControlHeaderValue _requestCacheControl;
+ private CacheControlHeaderValue _responseCacheControl;
+ private bool? _cacheResponse;
+ private CachedResponse _cachedResponse;
+ private TimeSpan _cachedResponseValidFor;
+ internal DateTimeOffset _responseTime;
public ResponseCachingContext(HttpContext httpContext, IResponseCache cache)
+ : this(httpContext, cache, new SystemClock())
+ {
+ }
+
+ // Internal for testing
+ internal ResponseCachingContext(HttpContext httpContext, IResponseCache cache, ISystemClock clock)
{
if (cache == null)
{
throw new ArgumentNullException(nameof(cache));
}
-
if (httpContext == null)
{
throw new ArgumentNullException(nameof(httpContext));
}
+ if (clock == null)
+ {
+ throw new ArgumentNullException(nameof(clock));
+ }
HttpContext = httpContext;
Cache = cache;
+ Clock = clock;
}
+ internal bool CacheResponse
+ {
+ get
+ {
+ if (_cacheResponse == null)
+ {
+ // TODO: apparent age vs corrected age value
+ var responseAge = _responseTime - ResponseHeaders.Date ?? TimeSpan.Zero;
+
+ _cacheResponse = ResponseIsCacheable() && EntryIsFresh(ResponseHeaders, responseAge, verifyAgainstRequest: false);
+ }
+ return _cacheResponse.Value;
+ }
+ }
+
+ internal bool ResponseStarted { get; set; }
+
+ private ISystemClock Clock { get; }
+
private HttpContext HttpContext { get; }
private IResponseCache Cache { get; }
private Stream OriginalResponseStream { get; set; }
- private MemoryStream Buffer { get; set; }
+ private ResponseCacheStream ResponseCacheStream { get; set; }
- internal bool ResponseStarted { get; set; }
+ private IHttpSendFileFeature OriginalSendFileFeature { get; set; }
- private bool CacheResponse { get; set; }
-
- private bool IsProxied { get; set; }
-
- public bool CheckRequestAllowsCaching()
+ private RequestHeaders RequestHeaders
{
- // Verify the method
- // TODO: What other methods should be supported?
- if (string.Equals("GET", HttpContext.Request.Method, StringComparison.OrdinalIgnoreCase))
+ get
{
- _requestType = RequestType.FullReponse;
+ if (_requestHeaders == null)
+ {
+ _requestHeaders = HttpContext.Request.GetTypedHeaders();
+ }
+ return _requestHeaders;
+ }
+ }
+
+ private ResponseHeaders ResponseHeaders
+ {
+ get
+ {
+ if (_responseHeaders == null)
+ {
+ _responseHeaders = HttpContext.Response.GetTypedHeaders();
+ }
+ return _responseHeaders;
+ }
+ }
+
+ private CacheControlHeaderValue RequestCacheControl
+ {
+ get
+ {
+ if (_requestCacheControl == null)
+ {
+ _requestCacheControl = RequestHeaders.CacheControl ?? EmptyCacheControl;
+ }
+ return _requestCacheControl;
+ }
+ }
+
+ private CacheControlHeaderValue ResponseCacheControl
+ {
+ get
+ {
+ if (_responseCacheControl == null)
+ {
+ _responseCacheControl = ResponseHeaders.CacheControl ?? EmptyCacheControl;
+ }
+ return _responseCacheControl;
}
- else if (string.Equals("HEAD", HttpContext.Request.Method, StringComparison.OrdinalIgnoreCase) ||
- string.Equals("OPTIONS", HttpContext.Request.Method, StringComparison.OrdinalIgnoreCase))
- {
- _requestType = RequestType.HeadersOnly;
- }
- else
- {
- _requestType = RequestType.NotCached;
- return false;
- }
-
- // Verify the request headers do not opt-out of caching
- // TODO:
- return true;
}
- // Only QueryString is treated as case sensitive
// GET;/PATH;VaryBy
- private string CreateCacheKey()
+ // TODO: Method invariant retrieval? E.g. HEAD after GET to the same resource.
+ internal string CreateCacheKey()
{
return CreateCacheKey(varyBy: null);
}
- private string CreateCacheKey(CachedVaryBy varyBy)
+ internal string CreateCacheKey(CachedVaryBy varyBy)
{
var request = HttpContext.Request;
var builder = new StringBuilder()
@@ -94,18 +154,19 @@ namespace Microsoft.AspNetCore.ResponseCaching
private string CreateVaryByCacheKey(CachedVaryBy varyBy)
{
// TODO: resolve key format and delimiters
- if (varyBy == null)
+ if (varyBy == null || varyBy.Headers.Count == 0)
{
return string.Empty;
}
- var request = HttpContext.Request;
var builder = new StringBuilder(";");
foreach (var header in varyBy.Headers)
{
- var value = request.Headers[header].ToString();
- // null vs Empty?
+ // TODO: Normalization of order, case?
+ var value = HttpContext.Request.Headers[header].ToString();
+
+ // TODO: How to handle null/empty string?
if (string.IsNullOrEmpty(value))
{
value = "null";
@@ -122,10 +183,159 @@ namespace Microsoft.AspNetCore.ResponseCaching
return builder.ToString();
}
+ internal bool RequestIsCacheable()
+ {
+ // Verify the method
+ // TODO: RFC lists POST as a cacheable method when explicit freshness information is provided, but this is not widely implemented. Will revisit.
+ var request = HttpContext.Request;
+ if (string.Equals("GET", request.Method, StringComparison.OrdinalIgnoreCase))
+ {
+ _responseType = ResponseType.FullReponse;
+ }
+ else if (string.Equals("HEAD", request.Method, StringComparison.OrdinalIgnoreCase))
+ {
+ _responseType = ResponseType.HeadersOnly;
+ }
+ else
+ {
+ return false;
+ }
+
+ // Verify existence of authorization headers
+ // TODO: The server may indicate that the response to these request are cacheable
+ if (!string.IsNullOrEmpty(request.Headers[HeaderNames.Authorization]))
+ {
+ return false;
+ }
+
+ // Verify request cache-control parameters
+ // TODO: no-cache requests can be retrieved upon validation with origin
+ if (!string.IsNullOrEmpty(request.Headers[HeaderNames.CacheControl]))
+ {
+ if (RequestCacheControl.NoCache || RequestCacheControl.NoStore)
+ {
+ return false;
+ }
+ }
+ else
+ {
+ // Support for legacy HTTP 1.0 cache directive
+ var pragmaHeaderValues = request.Headers[HeaderNames.Pragma];
+ foreach (var directive in pragmaHeaderValues)
+ {
+ if (string.Equals("no-cache", directive, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+ }
+ }
+
+ // TODO: Verify global middleware settings? Explicit ignore list, range requests, etc.
+ return true;
+ }
+
+ internal bool ResponseIsCacheable()
+ {
+ // Only cache pages explicitly marked with public
+ // TODO: Consider caching responses that are not marked as public but otherwise cacheable?
+ if (!ResponseCacheControl.Public)
+ {
+ return false;
+ }
+
+ // Check no-store
+ if (ResponseCacheControl.NoStore)
+ {
+ return false;
+ }
+
+ // Check no-cache
+ // TODO: Handle no-cache with headers
+ if (ResponseCacheControl.NoCache)
+ {
+ return false;
+ }
+
+ var response = HttpContext.Response;
+
+ // Do not cache responses varying by *
+ if (string.Equals(response.Headers[HeaderNames.Vary], "*", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ // TODO: public MAY override the cacheability checks for private and status codes
+
+ // Check private
+ if (ResponseCacheControl.Private)
+ {
+ return false;
+ }
+
+ // Check response code
+ // TODO: RFC also lists 203, 204, 206, 300, 301, 404, 405, 410, 414, and 501 as cacheable by default
+ if (response.StatusCode != StatusCodes.Status200OK)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ internal bool EntryIsFresh(ResponseHeaders responseHeaders, TimeSpan age, bool verifyAgainstRequest)
+ {
+ var responseCacheControl = responseHeaders.CacheControl ?? EmptyCacheControl;
+
+ // Add min-fresh requirements
+ if (verifyAgainstRequest)
+ {
+ age += RequestCacheControl.MinFresh ?? TimeSpan.Zero;
+ }
+
+ // Validate shared max age, this overrides any max age settings for shared caches
+ if (age > responseCacheControl.SharedMaxAge)
+ {
+ // shared max age implies must revalidate
+ return false;
+ }
+ else if (responseCacheControl.SharedMaxAge == null)
+ {
+ // Validate max age
+ if (age > responseCacheControl.MaxAge || (verifyAgainstRequest && age > RequestCacheControl.MaxAge))
+ {
+ // Must revalidate
+ if (responseCacheControl.MustRevalidate)
+ {
+ return false;
+ }
+
+ // Request allows stale values
+ if (verifyAgainstRequest && age < RequestCacheControl.MaxStaleLimit)
+ {
+ // TODO: Add warning header indicating the response is stale
+ return true;
+ }
+
+ return false;
+ }
+ else if (responseCacheControl.MaxAge == null && (!verifyAgainstRequest || RequestCacheControl.MaxAge == null))
+ {
+ // Validate expiration
+ if (_responseTime > responseHeaders.Expires)
+ {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
internal async Task TryServeFromCacheAsync()
{
_cacheKey = CreateCacheKey();
var cacheEntry = Cache.Get(_cacheKey);
+ var responseServed = false;
if (cacheEntry is CachedVaryBy)
{
@@ -136,73 +346,80 @@ namespace Microsoft.AspNetCore.ResponseCaching
if (cacheEntry is CachedResponse)
{
- // TODO: Compare cached request headers
-
- // TODO: Content negotiation if there are multiple cached response formats?
-
- // TODO: Verify content freshness, or else re-validate the data?
-
var cachedResponse = cacheEntry as CachedResponse;
+ var cachedResponseHeaders = new ResponseHeaders(cachedResponse.Headers);
- var response = HttpContext.Response;
- // Copy the cached status code and response headers
- response.StatusCode = cachedResponse.StatusCode;
- foreach (var pair in cachedResponse.Headers)
+ _responseTime = Clock.UtcNow;
+ var age = _responseTime - cachedResponse.Created;
+ age = age > TimeSpan.Zero ? age : TimeSpan.Zero;
+
+ if (EntryIsFresh(cachedResponseHeaders, age, verifyAgainstRequest: true))
{
- response.Headers[pair.Key] = pair.Value;
- }
+ 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);
+ }
- // TODO: Allow setting proxied _isProxied
- var age = Math.Max((DateTimeOffset.UtcNow - cacheEntry.Created).TotalSeconds, 0.0);
- var ageString = (age > int.MaxValue ? int.MaxValue : (int)age).ToString(CultureInfo.InvariantCulture);
- response.Headers[IsProxied ? "Age" : "X-Cache-Age"] = ageString;
+ response.Headers[HeaderNames.Age] = age.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture);
- if (_requestType == RequestType.HeadersOnly)
- {
- response.Headers["Content-Length"] = "0";
+ if (_responseType == ResponseType.HeadersOnly)
+ {
+ responseServed = true;
+ }
+ else if (_responseType == ResponseType.FullReponse)
+ {
+ // 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]))
+ {
+ 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
{
- // Copy the cached response body
- var body = cachedResponse.Body;
- response.Headers["Content-Length"] = body.Length.ToString(CultureInfo.InvariantCulture);
- if (body.Length > 0)
- {
- await response.Body.WriteAsync(body, 0, body.Length);
- }
+ // TODO: Validate with endpoint instead
}
- return true;
}
- return false;
- }
-
- internal void HookResponseStream()
- {
- // TODO: Use a wrapper stream to listen for writes (e.g. the start of the response),
- // check the headers, and verify if we should cache the response.
- // Then we should stream data out to the client at the same time as we buffer for the cache.
- // For now we'll just buffer everything in memory before checking the response headers.
- // TODO: Consider caching large responses on disk and serving them from there.
- OriginalResponseStream = HttpContext.Response.Body;
- Buffer = new MemoryStream();
- HttpContext.Response.Body = Buffer;
- }
-
- internal bool OnResponseStarting()
- {
- // Evaluate the response headers, see if we should buffer and cache
- CacheResponse = true; // TODO:
- return CacheResponse;
- }
-
- internal void FinalizeCaching()
- {
- // Don't cache errors? 404 etc
- if (CacheResponse && HttpContext.Response.StatusCode == 200)
+ if (!responseServed && RequestCacheControl.OnlyIfCached)
{
+ HttpContext.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
+
+ responseServed = true;
+ }
+
+ return responseServed;
+ }
+
+ internal void FinalizeCachingHeaders()
+ {
+ if (CacheResponse)
+ {
+ // Create the cache entry now
var response = HttpContext.Response;
- var varyHeaderValue = response.Headers["Vary"];
+ var varyHeaderValue = response.Headers[HeaderNames.Vary];
+ _cachedResponseValidFor = ResponseCacheControl.SharedMaxAge
+ ?? ResponseCacheControl.MaxAge
+ ?? (ResponseHeaders.Expires - _responseTime)
+ // TODO: Heuristics for expiration?
+ ?? TimeSpan.FromSeconds(10);
// Check if any VaryBy rules exist
if (!StringValues.IsNullOrEmpty(varyHeaderValue))
@@ -210,61 +427,94 @@ namespace Microsoft.AspNetCore.ResponseCaching
var cachedVaryBy = new CachedVaryBy
{
// Only vary by headers for now
+ // TODO: VaryBy Encoding
Headers = varyHeaderValue
};
- Cache.Set(_cacheKey, cachedVaryBy);
+ // TODO: Overwrite?
+ Cache.Set(_cacheKey, cachedVaryBy, _cachedResponseValidFor);
_cacheKey = CreateCacheKey(cachedVaryBy);
}
- // Store the response to cache
- var cachedResponse = new CachedResponse
+ // Ensure date header is set
+ if (ResponseHeaders.Date == null)
{
- Created = DateTimeOffset.UtcNow,
- StatusCode = HttpContext.Response.StatusCode,
- Body = Buffer.ToArray()
- };
-
- var headers = HttpContext.Response.Headers;
- var count = headers.Count
- - (headers.ContainsKey("Date") ? 1 : 0)
- - (headers.ContainsKey("Content-Length") ? 1 : 0)
- - (headers.ContainsKey("Age") ? 1 : 0);
- var cachedHeaders = new List>(count);
- var age = 0;
- foreach (var entry in headers)
- {
- // Reduce create date by Age
- if (entry.Key == "Age" && int.TryParse(entry.Value, out age) && age > 0)
- {
- cacheEntry.Created -= new TimeSpan(0, 0, age);
- }
- // Don't copy Date header or Content-Length
- else if (entry.Key != "Date" && entry.Key != "Content-Length")
- {
- cachedHeaders.Add(entry);
- }
+ ResponseHeaders.Date = _responseTime;
}
- Cache.Set(_cacheKey, cachedResponse); // TODO: Timeouts
+ // Store the response to cache
+ _cachedResponse = new CachedResponse
+ {
+ Created = ResponseHeaders.Date.Value,
+ StatusCode = HttpContext.Response.StatusCode
+ };
+
+ foreach (var header in ResponseHeaders.Headers)
+ {
+ if (!string.Equals(header.Key, HeaderNames.Age, StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(header.Key, HeaderNames.SetCookie, StringComparison.OrdinalIgnoreCase))
+ {
+ _cachedResponse.Headers.Add(header);
+ }
+ }
+ }
+ else
+ {
+ ResponseCacheStream.DisableBuffering();
}
-
- // TODO: TEMP, flush the buffer to the client
- Buffer.Seek(0, SeekOrigin.Begin);
- Buffer.CopyTo(OriginalResponseStream);
}
- internal void UnhookResponseStream()
+ internal void FinalizeCachingBody()
{
- // Unhook the response stream.
+ if (CacheResponse && ResponseCacheStream.BufferingEnabled)
+ {
+ _cachedResponse.Body = ResponseCacheStream.BufferedStream.ToArray();
+
+ Cache.Set(_cacheKey, _cachedResponse, _cachedResponseValidFor);
+ }
+ }
+
+ internal void OnResponseStarting()
+ {
+ if (!ResponseStarted)
+ {
+ ResponseStarted = true;
+ _responseTime = Clock.UtcNow;
+
+ FinalizeCachingHeaders();
+ }
+ }
+
+ internal void ShimResponseStream()
+ {
+ // TODO: Consider caching large responses on disk and serving them from there.
+
+ // Shim response stream
+ OriginalResponseStream = HttpContext.Response.Body;
+ ResponseCacheStream = new ResponseCacheStream(OriginalResponseStream);
+ HttpContext.Response.Body = ResponseCacheStream;
+
+ // Shim IHttpSendFileFeature
+ OriginalSendFileFeature = HttpContext.Features.Get();
+ if (OriginalSendFileFeature != null)
+ {
+ HttpContext.Features.Set(new SendFileFeatureWrapper(OriginalSendFileFeature, ResponseCacheStream));
+ }
+ }
+
+ internal void UnshimResponseStream()
+ {
+ // Unshim response stream
HttpContext.Response.Body = OriginalResponseStream;
+
+ // Unshim IHttpSendFileFeature
+ HttpContext.Features.Set(OriginalSendFileFeature);
}
- private enum RequestType
+ private enum ResponseType
{
- NotCached = 0,
- HeadersOnly,
- FullReponse
+ HeadersOnly = 0,
+ FullReponse = 1
}
}
}
diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs
index c18f66ecd9..5c43ff15fe 100644
--- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs
+++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs
@@ -4,12 +4,19 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
namespace Microsoft.AspNetCore.ResponseCaching
{
// http://tools.ietf.org/html/rfc7234
public class ResponseCachingMiddleware
{
+ private static readonly Func