Add implementation for HTTP caching
This commit is contained in:
parent
7d716d2007
commit
62aabc1bae
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")]
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue