Add implementation for HTTP caching

This commit is contained in:
John Luo 2016-07-30 17:47:05 -07:00
parent 7d716d2007
commit 62aabc1bae
19 changed files with 1543 additions and 184 deletions

View File

@ -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);
});

View File

@ -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);
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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)

View File

@ -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
{

View File

@ -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
{
/// <summary>
/// Abstracts the system clock to facilitate testing.
/// </summary>
internal interface ISystemClock
{
/// <summary>
/// Retrieves the current system time in UTC.
/// </summary>
DateTimeOffset UtcNow { get; }
}
}

View File

@ -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
});
}
}
}

View File

@ -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<int>(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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -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
{
/// <summary>
/// Provides access to the normal system clock.
/// </summary>
internal class SystemClock : ISystemClock
{
/// <summary>
/// Retrieves the current system time in UTC.
/// </summary>
public DateTimeOffset UtcNow
{
get
{
return DateTimeOffset.UtcNow;
}
}
}
}

View File

@ -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")]
[assembly: AssemblyProduct("Microsoft ASP.NET Core")]
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.ResponseCaching.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]

View File

@ -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<bool> 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<KeyValuePair<string, StringValues>>(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<IHttpSendFileFeature>();
if (OriginalSendFileFeature != null)
{
HttpContext.Features.Set<IHttpSendFileFeature>(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
}
}
}

View File

@ -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<object, Task> OnStartingCallback = state =>
{
((ResponseCachingContext)state).OnResponseStarting();
return Task.FromResult(0);
};
private readonly RequestDelegate _next;
private readonly IResponseCache _cache;
@ -32,8 +39,9 @@ namespace Microsoft.AspNetCore.ResponseCaching
public async Task Invoke(HttpContext context)
{
var cachingContext = new ResponseCachingContext(context, _cache);
// Should we attempt any caching logic?
if (cachingContext.CheckRequestAllowsCaching())
if (cachingContext.RequestIsCacheable())
{
// Can this request be served from cache?
if (await cachingContext.TryServeFromCacheAsync())
@ -42,27 +50,29 @@ namespace Microsoft.AspNetCore.ResponseCaching
}
// Hook up to listen to the response stream
cachingContext.HookResponseStream();
cachingContext.ShimResponseStream();
try
{
// Subscribe to OnStarting event
context.Response.OnStarting(OnStartingCallback, cachingContext);
await _next(context);
// If there was no response body, check the response headers now. We can cache things like redirects.
if (!cachingContext.ResponseStarted)
{
cachingContext.OnResponseStarting();
}
cachingContext.OnResponseStarting();
// Finalize the cache entry
cachingContext.FinalizeCaching();
cachingContext.FinalizeCachingBody();
}
finally
{
cachingContext.UnhookResponseStream();
cachingContext.UnshimResponseStream();
}
}
else
{
// TODO: Invalidate resources for successful unsafe methods? Required by RFC
await _next(context);
}
}

View File

@ -22,6 +22,7 @@
},
"dependencies": {
"Microsoft.AspNetCore.Http": "1.1.0-*",
"Microsoft.AspNetCore.Http.Extensions": "1.1.0-*",
"Microsoft.Extensions.Caching.Memory": "1.1.0-*"
},
"frameworks": {

View File

@ -0,0 +1,92 @@
// 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.Linq;
using Microsoft.AspNetCore.ResponseCaching.Internal;
using Xunit;
using Microsoft.AspNetCore.Http;
using System.Text;
namespace Microsoft.AspNetCore.ResponseCaching.Tests
{
public class DefaultResponseCacheEntrySerializerTests
{
[Fact]
public void SerializeNullObjectThrows()
{
Assert.Throws<ArgumentNullException>(() => DefaultResponseCacheSerializer.Serialize(null));
}
[Fact]
public void SerializeUnknownObjectThrows()
{
Assert.Throws<NotSupportedException>(() => DefaultResponseCacheSerializer.Serialize(new object()));
}
[Fact]
public void RoundTripCachedResponsesSucceeds()
{
var headers = new HeaderDictionary();
headers["keyA"] = "valueA";
headers["keyB"] = "valueB";
var cachedEntry = new CachedResponse()
{
Created = DateTimeOffset.UtcNow,
StatusCode = StatusCodes.Status200OK,
Body = Encoding.ASCII.GetBytes("Hello world"),
Headers = headers
};
AssertCachedResponsesEqual(cachedEntry, (CachedResponse)DefaultResponseCacheSerializer.Deserialize(DefaultResponseCacheSerializer.Serialize(cachedEntry)));
}
[Fact]
public void RoundTripCachedVaryBySucceeds()
{
var headers = new[] { "headerA", "headerB" };
var cachedVaryBy = new CachedVaryBy()
{
Headers = headers
};
AssertCachedVarybyEqual(cachedVaryBy, (CachedVaryBy)DefaultResponseCacheSerializer.Deserialize(DefaultResponseCacheSerializer.Serialize(cachedVaryBy)));
}
[Fact]
public void DeserializeInvalidEntriesReturnsNull()
{
var headers = new[] { "headerA", "headerB" };
var cachedVaryBy = new CachedVaryBy()
{
Headers = headers
};
var serializedEntry = DefaultResponseCacheSerializer.Serialize(cachedVaryBy);
Array.Reverse(serializedEntry);
Assert.Null(DefaultResponseCacheSerializer.Deserialize(serializedEntry));
}
private static void AssertCachedResponsesEqual(CachedResponse expected, CachedResponse actual)
{
Assert.NotNull(actual);
Assert.NotNull(expected);
Assert.Equal(expected.Created, actual.Created);
Assert.Equal(expected.StatusCode, actual.StatusCode);
Assert.Equal(expected.Headers.Count, actual.Headers.Count);
foreach (var expectedHeader in expected.Headers)
{
Assert.Equal(expectedHeader.Value, actual.Headers[expectedHeader.Key]);
}
Assert.True(expected.Body.SequenceEqual(actual.Body));
}
private static void AssertCachedVarybyEqual(CachedVaryBy expected, CachedVaryBy actual)
{
Assert.NotNull(actual);
Assert.NotNull(expected);
Assert.Equal(expected.Headers, actual.Headers);
}
}
}

View File

@ -7,7 +7,7 @@
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" />
<PropertyGroup Label="Globals">
<ProjectGuid>151b2027-3936-44b9-a4a0-e1e5902125ab</ProjectGuid>
<RootNamespace>Microsoft.AspNet.ResponseCaching.Tests</RootNamespace>
<RootNamespace>Microsoft.AspNetCore.ResponseCaching.Tests</RootNamespace>
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath>
<OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath>
</PropertyGroup>

View File

@ -2,33 +2,462 @@
// 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.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Http.Headers;
using Microsoft.AspNetCore.ResponseCaching.Internal;
using Microsoft.Net.Http.Headers;
using Xunit;
namespace Microsoft.AspNetCore.ResponseCaching.Tests
{
public class ResponseCachingContextTests
{
[Fact]
public void CheckRequestAllowsCaching_Method_GET_Allowed()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "GET";
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.True(context.CheckRequestAllowsCaching());
}
[Theory]
[InlineData("POST")]
public void CheckRequestAllowsCaching_Method_Unsafe_NotAllowed(string method)
[InlineData("GET")]
[InlineData("HEAD")]
public void RequestIsCacheable_CacheableMethods_Allowed(string method)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = method;
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.CheckRequestAllowsCaching());
Assert.True(context.RequestIsCacheable());
}
[Theory]
[InlineData("POST")]
[InlineData("OPTIONS")]
[InlineData("PUT")]
[InlineData("DELETE")]
[InlineData("TRACE")]
[InlineData("CONNECT")]
[InlineData("")]
[InlineData(null)]
public void RequestIsCacheable_UncacheableMethods_NotAllowed(string method)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = method;
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.RequestIsCacheable());
}
[Fact]
public void RequestIsCacheable_AuthorizationHeaders_NotAllowed()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "GET";
httpContext.Request.Headers[HeaderNames.Authorization] = "Basic plaintextUN:plaintextPW";
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.RequestIsCacheable());
}
[Theory]
[InlineData("no-cache")]
[InlineData("no-store")]
[InlineData("no-cache, no-store")]
public void RequestIsCacheable_ExplicitDisablingDirectives_NotAllowed(string directive)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "GET";
httpContext.Request.Headers[HeaderNames.CacheControl] = directive;
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.RequestIsCacheable());
}
[Fact]
public void RequestIsCacheable_LegacyDirectives_NotAllowed()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "GET";
httpContext.Request.Headers[HeaderNames.Pragma] = "no-cache";
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.RequestIsCacheable());
}
[Fact]
public void RequestIsCacheable_LegacyDirectives_OverridenByCacheControl()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "GET";
httpContext.Request.Headers[HeaderNames.Pragma] = "no-cache";
httpContext.Request.Headers[HeaderNames.CacheControl] = "max-age=10";
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.True(context.RequestIsCacheable());
}
[Fact]
public void CreateCacheKey_Includes_UppercaseMethodAndPath()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "head";
httpContext.Request.Path = "/path/subpath";
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("example.com", 80);
httpContext.Request.PathBase = "/pathBase";
httpContext.Request.QueryString = new QueryString("?query.Key=a&query.Value=b");
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.Equal("HEAD;/PATH/SUBPATH", context.CreateCacheKey());
}
[Fact]
public void CreateCacheKey_Includes_ListedVaryByHeadersOnly()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "GET";
httpContext.Request.Path = "/";
httpContext.Request.Headers["HeaderA"] = "ValueA";
httpContext.Request.Headers["HeaderB"] = "ValueB";
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.Equal("GET;/;HeaderA=ValueA;HeaderC=null;", context.CreateCacheKey(new CachedVaryBy()
{
Headers = new string[] { "HeaderA", "HeaderC" }
}));
}
[Fact]
public void ResponseIsCacheable_NoPublic_NotAllowed()
{
var httpContext = new DefaultHttpContext();
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.ResponseIsCacheable());
}
[Fact]
public void ResponseIsCacheable_Public_Allowed()
{
var httpContext = new DefaultHttpContext();
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
Public = true
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.True(context.ResponseIsCacheable());
}
[Fact]
public void ResponseIsCacheable_NoCache_NotAllowed()
{
var httpContext = new DefaultHttpContext();
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
Public = true,
NoCache = true
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.ResponseIsCacheable());
}
[Fact]
public void ResponseIsCacheable_NoStore_NotAllowed()
{
var httpContext = new DefaultHttpContext();
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
Public = true,
NoStore = true
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.ResponseIsCacheable());
}
[Fact]
public void ResponseIsCacheable_VaryByStar_NotAllowed()
{
var httpContext = new DefaultHttpContext();
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
Public = true
};
httpContext.Response.Headers[HeaderNames.Vary] = "*";
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.ResponseIsCacheable());
}
[Fact]
public void ResponseIsCacheable_Private_NotAllowed()
{
var httpContext = new DefaultHttpContext();
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
Public = true,
Private = true
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.ResponseIsCacheable());
}
[Theory]
[InlineData(StatusCodes.Status200OK)]
public void ResponseIsCacheable_SuccessStatusCodes_Allowed(int statusCode)
{
var httpContext = new DefaultHttpContext();
httpContext.Response.StatusCode = statusCode;
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
Public = true
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.True(context.ResponseIsCacheable());
}
[Theory]
[InlineData(StatusCodes.Status201Created)]
[InlineData(StatusCodes.Status202Accepted)]
[InlineData(StatusCodes.Status203NonAuthoritative)]
[InlineData(StatusCodes.Status204NoContent)]
[InlineData(StatusCodes.Status205ResetContent)]
[InlineData(StatusCodes.Status206PartialContent)]
[InlineData(StatusCodes.Status207MultiStatus)]
[InlineData(StatusCodes.Status300MultipleChoices)]
[InlineData(StatusCodes.Status301MovedPermanently)]
[InlineData(StatusCodes.Status302Found)]
[InlineData(StatusCodes.Status303SeeOther)]
[InlineData(StatusCodes.Status304NotModified)]
[InlineData(StatusCodes.Status305UseProxy)]
[InlineData(StatusCodes.Status306SwitchProxy)]
[InlineData(StatusCodes.Status307TemporaryRedirect)]
[InlineData(StatusCodes.Status308PermanentRedirect)]
[InlineData(StatusCodes.Status400BadRequest)]
[InlineData(StatusCodes.Status401Unauthorized)]
[InlineData(StatusCodes.Status402PaymentRequired)]
[InlineData(StatusCodes.Status403Forbidden)]
[InlineData(StatusCodes.Status404NotFound)]
[InlineData(StatusCodes.Status405MethodNotAllowed)]
[InlineData(StatusCodes.Status406NotAcceptable)]
[InlineData(StatusCodes.Status407ProxyAuthenticationRequired)]
[InlineData(StatusCodes.Status408RequestTimeout)]
[InlineData(StatusCodes.Status409Conflict)]
[InlineData(StatusCodes.Status410Gone)]
[InlineData(StatusCodes.Status411LengthRequired)]
[InlineData(StatusCodes.Status412PreconditionFailed)]
[InlineData(StatusCodes.Status413RequestEntityTooLarge)]
[InlineData(StatusCodes.Status414RequestUriTooLong)]
[InlineData(StatusCodes.Status415UnsupportedMediaType)]
[InlineData(StatusCodes.Status416RequestedRangeNotSatisfiable)]
[InlineData(StatusCodes.Status417ExpectationFailed)]
[InlineData(StatusCodes.Status418ImATeapot)]
[InlineData(StatusCodes.Status419AuthenticationTimeout)]
[InlineData(StatusCodes.Status422UnprocessableEntity)]
[InlineData(StatusCodes.Status423Locked)]
[InlineData(StatusCodes.Status424FailedDependency)]
[InlineData(StatusCodes.Status451UnavailableForLegalReasons)]
[InlineData(StatusCodes.Status500InternalServerError)]
[InlineData(StatusCodes.Status501NotImplemented)]
[InlineData(StatusCodes.Status502BadGateway)]
[InlineData(StatusCodes.Status503ServiceUnavailable)]
[InlineData(StatusCodes.Status504GatewayTimeout)]
[InlineData(StatusCodes.Status505HttpVersionNotsupported)]
[InlineData(StatusCodes.Status506VariantAlsoNegotiates)]
[InlineData(StatusCodes.Status507InsufficientStorage)]
public void ResponseIsCacheable_NonSuccessStatusCodes_NotAllowed(int statusCode)
{
var httpContext = new DefaultHttpContext();
httpContext.Response.StatusCode = statusCode;
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
Public = true
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.ResponseIsCacheable());
}
[Fact]
public void EntryIsFresh_NoExpiryRequirements_IsFresh()
{
var httpContext = new DefaultHttpContext();
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.True(context.EntryIsFresh(new ResponseHeaders(new HeaderDictionary()), TimeSpan.MaxValue, verifyAgainstRequest: false));
}
[Fact]
public void EntryIsFresh_PastExpiry_IsNotFresh()
{
var httpContext = new DefaultHttpContext();
var utcNow = DateTimeOffset.UtcNow;
httpContext.Response.GetTypedHeaders().Expires = utcNow;
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
context._responseTime = utcNow;
Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.MaxValue, verifyAgainstRequest: false));
}
[Fact]
public void EntryIsFresh_MaxAgeOverridesExpiry_ToFresh()
{
var utcNow = DateTimeOffset.UtcNow;
var httpContext = new DefaultHttpContext();
var responseHeaders = httpContext.Response.GetTypedHeaders();
responseHeaders.Expires = utcNow;
responseHeaders.CacheControl = new CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(10)
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
context._responseTime = utcNow;
Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(10), verifyAgainstRequest: false));
}
[Fact]
public void EntryIsFresh_MaxAgeOverridesExpiry_ToNotFresh()
{
var utcNow = DateTimeOffset.UtcNow;
var httpContext = new DefaultHttpContext();
var responseHeaders = httpContext.Response.GetTypedHeaders();
responseHeaders.Expires = utcNow;
responseHeaders.CacheControl = new CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(10)
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
context._responseTime = utcNow;
Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(11), verifyAgainstRequest: false));
}
[Fact]
public void EntryIsFresh_SharedMaxAgeOverridesMaxAge_ToFresh()
{
var httpContext = new DefaultHttpContext();
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(10),
SharedMaxAge = TimeSpan.FromSeconds(15)
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(11), verifyAgainstRequest: false));
}
[Fact]
public void EntryIsFresh_SharedMaxAgeOverridesMaxAge_ToNotFresh()
{
var httpContext = new DefaultHttpContext();
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(10),
SharedMaxAge = TimeSpan.FromSeconds(5)
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(6), verifyAgainstRequest: false));
}
[Fact]
public void EntryIsFresh_MinFreshReducesFreshness_ToNotFresh()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
MinFresh = TimeSpan.FromSeconds(3)
};
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(10),
SharedMaxAge = TimeSpan.FromSeconds(5)
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(3), verifyAgainstRequest: true));
}
[Fact]
public void EntryIsFresh_RequestMaxAgeRestrictAge_ToNotFresh()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(5)
};
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(10),
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(6), verifyAgainstRequest: true));
}
[Fact]
public void EntryIsFresh_MaxStaleOverridesFreshness_ToFresh()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(5),
MaxStale = true, // This value must be set to true in order to specify MaxStaleLimit
MaxStaleLimit = TimeSpan.FromSeconds(10)
};
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(5),
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(6), verifyAgainstRequest: true));
}
[Fact]
public void EntryIsFresh_MustRevalidateOverridesRequestMaxStale_ToNotFresh()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(5),
MaxStale = true, // This value must be set to true in order to specify MaxStaleLimit
MaxStaleLimit = TimeSpan.FromSeconds(10)
};
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(5),
MustRevalidate = true
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(6), verifyAgainstRequest: true));
}
[Fact]
public void EntryIsFresh_IgnoresRequestVerificationWhenSpecified()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
MinFresh = TimeSpan.FromSeconds(1),
MaxAge = TimeSpan.FromSeconds(3)
};
httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(10),
SharedMaxAge = TimeSpan.FromSeconds(5)
};
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.True(context.EntryIsFresh(httpContext.Response.GetTypedHeaders(), TimeSpan.FromSeconds(3), verifyAgainstRequest: false));
}
private class TestResponseCache : IResponseCache
@ -42,9 +471,17 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests
{
}
public void Set(string key, object entry)
public void Set(string key, object entry, TimeSpan validFor)
{
}
}
private class TestHttpSendFileFeature : IHttpSendFileFeature
{
public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellation)
{
return Task.FromResult(0);
}
}
}
}

View File

@ -1,12 +1,16 @@
// 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.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using System;
using Microsoft.Net.Http.Headers;
using Xunit;
namespace Microsoft.AspNetCore.ResponseCaching.Tests
@ -16,19 +20,272 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests
[Fact]
public async void ServesCachedContentIfAvailable()
{
var builder = new WebHostBuilder()
.ConfigureServices(services =>
var builder = CreateBuilderWithResponseCaching(async (context) =>
{
var uniqueId = Guid.NewGuid().ToString();
var headers = context.Response.GetTypedHeaders();
headers.CacheControl = new CacheControlHeaderValue()
{
services.AddDistributedResponseCache();
})
.Configure(app =>
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("");
var subsequentResponse = await client.GetAsync("");
initialResponse.EnsureSuccessStatusCode();
subsequentResponse.EnsureSuccessStatusCode();
foreach (var header in initialResponse.Headers)
{
app.UseResponseCaching();
app.Run(async (context) =>
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());
}
}
[Fact]
public async void ServesFreshContentIfNotAvailable()
{
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("");
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());
}
}
[Fact]
public async void ServesCachedContentIfVaryByMatches()
{
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("");
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());
}
}
[Fact]
public async void ServesFreshContentIfRequestRequirementsNotMet()
{
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.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue()
{
MaxAge = TimeSpan.FromSeconds(0)
};
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 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()
{
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.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue()
{
OnlyIfCached = true
};
var subsequentResponse = await client.GetAsync("/different");
initialResponse.EnsureSuccessStatusCode();
Assert.Equal(System.Net.HttpStatusCode.GatewayTimeout, subsequentResponse.StatusCode);
}
}
[Fact]
public async void ServesCachedContentWithoutSetCookie()
{
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.Headers[HeaderNames.SetCookie] = "cookieName=cookieValue";
await context.Response.WriteAsync(uniqueId);
});
using (var server = new TestServer(builder))
{
var client = server.CreateClient();
var initialResponse = await client.GetAsync("");
var subsequentResponse = await client.GetAsync("");
initialResponse.EnsureSuccessStatusCode();
subsequentResponse.EnsureSuccessStatusCode();
foreach (var header in initialResponse.Headers)
{
if (!string.Equals(HeaderNames.SetCookie, header.Key, StringComparison.OrdinalIgnoreCase))
{
context.Response.Headers["Cache-Control"] = "public";
await context.Response.WriteAsync(DateTime.UtcNow.ToString());
Assert.Equal(initialResponse.Headers.GetValues(header.Key), subsequentResponse.Headers.GetValues(header.Key));
}
}
Assert.True(initialResponse.Headers.Contains(HeaderNames.SetCookie));
Assert.True(subsequentResponse.Headers.Contains(HeaderNames.Age));
Assert.False(subsequentResponse.Headers.Contains(HeaderNames.SetCookie));
Assert.Equal(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync());
}
}
[Fact]
public async void ServesCachedContentIfIHttpSendFileFeatureNotUsed()
{
var builder = CreateBuilderWithResponseCaching(
app =>
{
app.Use(async (context, next) =>
{
context.Features.Set<IHttpSendFileFeature>(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;
await context.Response.WriteAsync(uniqueId);
});
using (var server = new TestServer(builder))
@ -40,11 +297,80 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests
initialResponse.EnsureSuccessStatusCode();
subsequentResponse.EnsureSuccessStatusCode();
// TODO: Check for the appropriate headers once we actually set them
Assert.False(initialResponse.Headers.Contains("Served_From_Cache"));
Assert.True(subsequentResponse.Headers.Contains("Served_From_Cache"));
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());
}
}
[Fact]
public async void ServesFreshContentIfIHttpSendFileFeatureUsed()
{
var builder = CreateBuilderWithResponseCaching(
app =>
{
app.Use(async (context, next) =>
{
context.Features.Set<IHttpSendFileFeature>(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;
await context.Features.Get<IHttpSendFileFeature>().SendFileAsync("dummy", 0, 0, CancellationToken.None);
await context.Response.WriteAsync(uniqueId);
});
using (var server = new TestServer(builder))
{
var client = server.CreateClient();
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());
}
}
private static IWebHostBuilder CreateBuilderWithResponseCaching(RequestDelegate requestDelegate) =>
CreateBuilderWithResponseCaching(app => { }, requestDelegate);
private static IWebHostBuilder CreateBuilderWithResponseCaching(Action<IApplicationBuilder> configureDelegate, RequestDelegate requestDelegate)
{
return new WebHostBuilder()
.ConfigureServices(services =>
{
services.AddDistributedResponseCache();
})
.Configure(app =>
{
configureDelegate(app);
app.UseResponseCaching();
app.Run(requestDelegate);
});
}
private class DummySendFileFeature : IHttpSendFileFeature
{
public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellation)
{
return Task.FromResult(0);
}
}
}
}