Add implementation for HTTP caching
This commit is contained in:
parent
7d716d2007
commit
62aabc1bae
|
|
@ -29,7 +29,7 @@ namespace ResponseCachingSample
|
||||||
Public = true,
|
Public = true,
|
||||||
MaxAge = TimeSpan.FromSeconds(10)
|
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);
|
await context.Response.WriteAsync("Hello World! " + DateTime.UtcNow);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
// Copyright (c) .NET Foundation. All rights reserved.
|
// 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.
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace Microsoft.AspNetCore.ResponseCaching
|
namespace Microsoft.AspNetCore.ResponseCaching
|
||||||
{
|
{
|
||||||
public interface IResponseCache
|
public interface IResponseCache
|
||||||
{
|
{
|
||||||
object Get(string key);
|
object Get(string key);
|
||||||
// TODO: Set expiry policy in the underlying cache?
|
void Set(string key, object entry, TimeSpan validFor);
|
||||||
void Set(string key, object entry);
|
|
||||||
void Remove(string key);
|
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 class CachedResponse
|
||||||
{
|
{
|
||||||
|
internal DateTimeOffset Created { get; set; }
|
||||||
|
|
||||||
internal int StatusCode { get; set; }
|
internal int StatusCode { get; set; }
|
||||||
|
|
||||||
internal IHeaderDictionary Headers { get; set; } = new HeaderDictionary();
|
internal IHeaderDictionary Headers { get; set; } = new HeaderDictionary();
|
||||||
|
|
||||||
internal byte[] Body { get; set; }
|
internal byte[] Body { get; set; }
|
||||||
public DateTimeOffset Created { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serialization Format
|
// Serialization Format
|
||||||
|
// Creation time - UtcTicks (long)
|
||||||
// Status code (int)
|
// Status code (int)
|
||||||
// Header count (int)
|
// Header count (int)
|
||||||
// Header(s)
|
// Header(s)
|
||||||
|
|
@ -78,6 +79,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal
|
||||||
// Body (byte[])
|
// Body (byte[])
|
||||||
private static CachedResponse ReadCachedResponse(BinaryReader reader)
|
private static CachedResponse ReadCachedResponse(BinaryReader reader)
|
||||||
{
|
{
|
||||||
|
var created = new DateTimeOffset(reader.ReadInt64(), TimeSpan.Zero);
|
||||||
var statusCode = reader.ReadInt32();
|
var statusCode = reader.ReadInt32();
|
||||||
var headerCount = reader.ReadInt32();
|
var headerCount = reader.ReadInt32();
|
||||||
var headers = new HeaderDictionary();
|
var headers = new HeaderDictionary();
|
||||||
|
|
@ -90,7 +92,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal
|
||||||
var bodyLength = reader.ReadInt32();
|
var bodyLength = reader.ReadInt32();
|
||||||
var body = reader.ReadBytes(bodyLength);
|
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
|
// Serialization Format
|
||||||
|
|
@ -135,6 +137,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal
|
||||||
private static void WriteCachedResponse(BinaryWriter writer, CachedResponse entry)
|
private static void WriteCachedResponse(BinaryWriter writer, CachedResponse entry)
|
||||||
{
|
{
|
||||||
writer.Write(nameof(CachedResponse));
|
writer.Write(nameof(CachedResponse));
|
||||||
|
writer.Write(entry.Created.UtcTicks);
|
||||||
writer.Write(entry.StatusCode);
|
writer.Write(entry.StatusCode);
|
||||||
writer.Write(entry.Headers.Count);
|
writer.Write(entry.Headers.Count);
|
||||||
foreach (var header in entry.Headers)
|
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
|
try
|
||||||
{
|
{
|
||||||
_cache.Set(key, DefaultResponseCacheSerializer.Serialize(entry));
|
_cache.Set(
|
||||||
|
key,
|
||||||
|
DefaultResponseCacheSerializer.Serialize(entry),
|
||||||
|
new DistributedCacheEntryOptions()
|
||||||
|
{
|
||||||
|
AbsoluteExpirationRelativeToNow = validFor
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch
|
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);
|
_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.Reflection;
|
||||||
using System.Resources;
|
using System.Resources;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
[assembly: AssemblyMetadata("Serviceable", "True")]
|
[assembly: AssemblyMetadata("Serviceable", "True")]
|
||||||
[assembly: NeutralResourcesLanguage("en-us")]
|
[assembly: NeutralResourcesLanguage("en-us")]
|
||||||
[assembly: AssemblyCompany("Microsoft Corporation.")]
|
[assembly: AssemblyCompany("Microsoft Corporation.")]
|
||||||
[assembly: AssemblyCopyright("© Microsoft Corporation. All rights reserved.")]
|
[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.
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
using Microsoft.AspNetCore.Http.Headers;
|
||||||
using Microsoft.AspNetCore.ResponseCaching.Internal;
|
using Microsoft.AspNetCore.ResponseCaching.Internal;
|
||||||
using Microsoft.Extensions.Primitives;
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
||||||
namespace Microsoft.AspNetCore.ResponseCaching
|
namespace Microsoft.AspNetCore.ResponseCaching
|
||||||
{
|
{
|
||||||
public class ResponseCachingContext
|
public class ResponseCachingContext
|
||||||
{
|
{
|
||||||
|
private static readonly CacheControlHeaderValue EmptyCacheControl = new CacheControlHeaderValue();
|
||||||
private string _cacheKey;
|
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)
|
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)
|
if (cache == null)
|
||||||
{
|
{
|
||||||
throw new ArgumentNullException(nameof(cache));
|
throw new ArgumentNullException(nameof(cache));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (httpContext == null)
|
if (httpContext == null)
|
||||||
{
|
{
|
||||||
throw new ArgumentNullException(nameof(httpContext));
|
throw new ArgumentNullException(nameof(httpContext));
|
||||||
}
|
}
|
||||||
|
if (clock == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(clock));
|
||||||
|
}
|
||||||
|
|
||||||
HttpContext = httpContext;
|
HttpContext = httpContext;
|
||||||
Cache = cache;
|
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 HttpContext HttpContext { get; }
|
||||||
|
|
||||||
private IResponseCache Cache { get; }
|
private IResponseCache Cache { get; }
|
||||||
|
|
||||||
private Stream OriginalResponseStream { get; set; }
|
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 RequestHeaders RequestHeaders
|
||||||
|
|
||||||
private bool IsProxied { get; set; }
|
|
||||||
|
|
||||||
public bool CheckRequestAllowsCaching()
|
|
||||||
{
|
{
|
||||||
// Verify the method
|
get
|
||||||
// TODO: What other methods should be supported?
|
|
||||||
if (string.Equals("GET", HttpContext.Request.Method, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
_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
|
// 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);
|
return CreateCacheKey(varyBy: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string CreateCacheKey(CachedVaryBy varyBy)
|
internal string CreateCacheKey(CachedVaryBy varyBy)
|
||||||
{
|
{
|
||||||
var request = HttpContext.Request;
|
var request = HttpContext.Request;
|
||||||
var builder = new StringBuilder()
|
var builder = new StringBuilder()
|
||||||
|
|
@ -94,18 +154,19 @@ namespace Microsoft.AspNetCore.ResponseCaching
|
||||||
private string CreateVaryByCacheKey(CachedVaryBy varyBy)
|
private string CreateVaryByCacheKey(CachedVaryBy varyBy)
|
||||||
{
|
{
|
||||||
// TODO: resolve key format and delimiters
|
// TODO: resolve key format and delimiters
|
||||||
if (varyBy == null)
|
if (varyBy == null || varyBy.Headers.Count == 0)
|
||||||
{
|
{
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = HttpContext.Request;
|
|
||||||
var builder = new StringBuilder(";");
|
var builder = new StringBuilder(";");
|
||||||
|
|
||||||
foreach (var header in varyBy.Headers)
|
foreach (var header in varyBy.Headers)
|
||||||
{
|
{
|
||||||
var value = request.Headers[header].ToString();
|
// TODO: Normalization of order, case?
|
||||||
// null vs Empty?
|
var value = HttpContext.Request.Headers[header].ToString();
|
||||||
|
|
||||||
|
// TODO: How to handle null/empty string?
|
||||||
if (string.IsNullOrEmpty(value))
|
if (string.IsNullOrEmpty(value))
|
||||||
{
|
{
|
||||||
value = "null";
|
value = "null";
|
||||||
|
|
@ -122,10 +183,159 @@ namespace Microsoft.AspNetCore.ResponseCaching
|
||||||
return builder.ToString();
|
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()
|
internal async Task<bool> TryServeFromCacheAsync()
|
||||||
{
|
{
|
||||||
_cacheKey = CreateCacheKey();
|
_cacheKey = CreateCacheKey();
|
||||||
var cacheEntry = Cache.Get(_cacheKey);
|
var cacheEntry = Cache.Get(_cacheKey);
|
||||||
|
var responseServed = false;
|
||||||
|
|
||||||
if (cacheEntry is CachedVaryBy)
|
if (cacheEntry is CachedVaryBy)
|
||||||
{
|
{
|
||||||
|
|
@ -136,73 +346,80 @@ namespace Microsoft.AspNetCore.ResponseCaching
|
||||||
|
|
||||||
if (cacheEntry is CachedResponse)
|
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 cachedResponse = cacheEntry as CachedResponse;
|
||||||
|
var cachedResponseHeaders = new ResponseHeaders(cachedResponse.Headers);
|
||||||
|
|
||||||
var response = HttpContext.Response;
|
_responseTime = Clock.UtcNow;
|
||||||
// Copy the cached status code and response headers
|
var age = _responseTime - cachedResponse.Created;
|
||||||
response.StatusCode = cachedResponse.StatusCode;
|
age = age > TimeSpan.Zero ? age : TimeSpan.Zero;
|
||||||
foreach (var pair in cachedResponse.Headers)
|
|
||||||
|
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
|
response.Headers[HeaderNames.Age] = age.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture);
|
||||||
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;
|
|
||||||
|
|
||||||
if (_requestType == RequestType.HeadersOnly)
|
if (_responseType == ResponseType.HeadersOnly)
|
||||||
{
|
{
|
||||||
response.Headers["Content-Length"] = "0";
|
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
|
else
|
||||||
{
|
{
|
||||||
// Copy the cached response body
|
// TODO: Validate with endpoint instead
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
if (!responseServed && RequestCacheControl.OnlyIfCached)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
|
HttpContext.Response.StatusCode = StatusCodes.Status504GatewayTimeout;
|
||||||
|
|
||||||
|
responseServed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseServed;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void FinalizeCachingHeaders()
|
||||||
|
{
|
||||||
|
if (CacheResponse)
|
||||||
|
{
|
||||||
|
// Create the cache entry now
|
||||||
var response = HttpContext.Response;
|
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
|
// Check if any VaryBy rules exist
|
||||||
if (!StringValues.IsNullOrEmpty(varyHeaderValue))
|
if (!StringValues.IsNullOrEmpty(varyHeaderValue))
|
||||||
|
|
@ -210,61 +427,94 @@ namespace Microsoft.AspNetCore.ResponseCaching
|
||||||
var cachedVaryBy = new CachedVaryBy
|
var cachedVaryBy = new CachedVaryBy
|
||||||
{
|
{
|
||||||
// Only vary by headers for now
|
// Only vary by headers for now
|
||||||
|
// TODO: VaryBy Encoding
|
||||||
Headers = varyHeaderValue
|
Headers = varyHeaderValue
|
||||||
};
|
};
|
||||||
|
|
||||||
Cache.Set(_cacheKey, cachedVaryBy);
|
// TODO: Overwrite?
|
||||||
|
Cache.Set(_cacheKey, cachedVaryBy, _cachedResponseValidFor);
|
||||||
_cacheKey = CreateCacheKey(cachedVaryBy);
|
_cacheKey = CreateCacheKey(cachedVaryBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the response to cache
|
// Ensure date header is set
|
||||||
var cachedResponse = new CachedResponse
|
if (ResponseHeaders.Date == null)
|
||||||
{
|
{
|
||||||
Created = DateTimeOffset.UtcNow,
|
ResponseHeaders.Date = _responseTime;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
HttpContext.Response.Body = OriginalResponseStream;
|
||||||
|
|
||||||
|
// Unshim IHttpSendFileFeature
|
||||||
|
HttpContext.Features.Set(OriginalSendFileFeature);
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum RequestType
|
private enum ResponseType
|
||||||
{
|
{
|
||||||
NotCached = 0,
|
HeadersOnly = 0,
|
||||||
HeadersOnly,
|
FullReponse = 1
|
||||||
FullReponse
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,19 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
|
||||||
namespace Microsoft.AspNetCore.ResponseCaching
|
namespace Microsoft.AspNetCore.ResponseCaching
|
||||||
{
|
{
|
||||||
// http://tools.ietf.org/html/rfc7234
|
// http://tools.ietf.org/html/rfc7234
|
||||||
public class ResponseCachingMiddleware
|
public class ResponseCachingMiddleware
|
||||||
{
|
{
|
||||||
|
private static readonly Func<object, Task> OnStartingCallback = state =>
|
||||||
|
{
|
||||||
|
((ResponseCachingContext)state).OnResponseStarting();
|
||||||
|
return Task.FromResult(0);
|
||||||
|
};
|
||||||
|
|
||||||
private readonly RequestDelegate _next;
|
private readonly RequestDelegate _next;
|
||||||
private readonly IResponseCache _cache;
|
private readonly IResponseCache _cache;
|
||||||
|
|
||||||
|
|
@ -32,8 +39,9 @@ namespace Microsoft.AspNetCore.ResponseCaching
|
||||||
public async Task Invoke(HttpContext context)
|
public async Task Invoke(HttpContext context)
|
||||||
{
|
{
|
||||||
var cachingContext = new ResponseCachingContext(context, _cache);
|
var cachingContext = new ResponseCachingContext(context, _cache);
|
||||||
|
|
||||||
// Should we attempt any caching logic?
|
// Should we attempt any caching logic?
|
||||||
if (cachingContext.CheckRequestAllowsCaching())
|
if (cachingContext.RequestIsCacheable())
|
||||||
{
|
{
|
||||||
// Can this request be served from cache?
|
// Can this request be served from cache?
|
||||||
if (await cachingContext.TryServeFromCacheAsync())
|
if (await cachingContext.TryServeFromCacheAsync())
|
||||||
|
|
@ -42,27 +50,29 @@ namespace Microsoft.AspNetCore.ResponseCaching
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook up to listen to the response stream
|
// Hook up to listen to the response stream
|
||||||
cachingContext.HookResponseStream();
|
cachingContext.ShimResponseStream();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Subscribe to OnStarting event
|
||||||
|
context.Response.OnStarting(OnStartingCallback, cachingContext);
|
||||||
|
|
||||||
await _next(context);
|
await _next(context);
|
||||||
|
|
||||||
// If there was no response body, check the response headers now. We can cache things like redirects.
|
// 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
|
// Finalize the cache entry
|
||||||
cachingContext.FinalizeCaching();
|
cachingContext.FinalizeCachingBody();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
cachingContext.UnhookResponseStream();
|
cachingContext.UnshimResponseStream();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// TODO: Invalidate resources for successful unsafe methods? Required by RFC
|
||||||
await _next(context);
|
await _next(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.AspNetCore.Http": "1.1.0-*",
|
"Microsoft.AspNetCore.Http": "1.1.0-*",
|
||||||
|
"Microsoft.AspNetCore.Http.Extensions": "1.1.0-*",
|
||||||
"Microsoft.Extensions.Caching.Memory": "1.1.0-*"
|
"Microsoft.Extensions.Caching.Memory": "1.1.0-*"
|
||||||
},
|
},
|
||||||
"frameworks": {
|
"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)' != ''" />
|
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" />
|
||||||
<PropertyGroup Label="Globals">
|
<PropertyGroup Label="Globals">
|
||||||
<ProjectGuid>151b2027-3936-44b9-a4a0-e1e5902125ab</ProjectGuid>
|
<ProjectGuid>151b2027-3936-44b9-a4a0-e1e5902125ab</ProjectGuid>
|
||||||
<RootNamespace>Microsoft.AspNet.ResponseCaching.Tests</RootNamespace>
|
<RootNamespace>Microsoft.AspNetCore.ResponseCaching.Tests</RootNamespace>
|
||||||
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath>
|
<BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath>
|
||||||
<OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath>
|
<OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,462 @@
|
||||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Http;
|
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;
|
using Xunit;
|
||||||
|
|
||||||
namespace Microsoft.AspNetCore.ResponseCaching.Tests
|
namespace Microsoft.AspNetCore.ResponseCaching.Tests
|
||||||
{
|
{
|
||||||
public class ResponseCachingContextTests
|
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]
|
[Theory]
|
||||||
[InlineData("POST")]
|
[InlineData("GET")]
|
||||||
public void CheckRequestAllowsCaching_Method_Unsafe_NotAllowed(string method)
|
[InlineData("HEAD")]
|
||||||
|
public void RequestIsCacheable_CacheableMethods_Allowed(string method)
|
||||||
{
|
{
|
||||||
var httpContext = new DefaultHttpContext();
|
var httpContext = new DefaultHttpContext();
|
||||||
httpContext.Request.Method = method;
|
httpContext.Request.Method = method;
|
||||||
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
|
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
|
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.
|
// 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.
|
// 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.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System;
|
using Microsoft.Net.Http.Headers;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Microsoft.AspNetCore.ResponseCaching.Tests
|
namespace Microsoft.AspNetCore.ResponseCaching.Tests
|
||||||
|
|
@ -16,19 +20,272 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests
|
||||||
[Fact]
|
[Fact]
|
||||||
public async void ServesCachedContentIfAvailable()
|
public async void ServesCachedContentIfAvailable()
|
||||||
{
|
{
|
||||||
var builder = new WebHostBuilder()
|
var builder = CreateBuilderWithResponseCaching(async (context) =>
|
||||||
.ConfigureServices(services =>
|
{
|
||||||
|
var uniqueId = Guid.NewGuid().ToString();
|
||||||
|
var headers = context.Response.GetTypedHeaders();
|
||||||
|
headers.CacheControl = new CacheControlHeaderValue()
|
||||||
{
|
{
|
||||||
services.AddDistributedResponseCache();
|
Public = true,
|
||||||
})
|
MaxAge = TimeSpan.FromSeconds(10)
|
||||||
.Configure(app =>
|
};
|
||||||
|
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();
|
Assert.Equal(initialResponse.Headers.GetValues(header.Key), subsequentResponse.Headers.GetValues(header.Key));
|
||||||
app.Run(async (context) =>
|
}
|
||||||
|
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";
|
Assert.Equal(initialResponse.Headers.GetValues(header.Key), subsequentResponse.Headers.GetValues(header.Key));
|
||||||
await context.Response.WriteAsync(DateTime.UtcNow.ToString());
|
}
|
||||||
|
}
|
||||||
|
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))
|
using (var server = new TestServer(builder))
|
||||||
|
|
@ -40,11 +297,80 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests
|
||||||
initialResponse.EnsureSuccessStatusCode();
|
initialResponse.EnsureSuccessStatusCode();
|
||||||
subsequentResponse.EnsureSuccessStatusCode();
|
subsequentResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
// TODO: Check for the appropriate headers once we actually set them
|
foreach (var header in initialResponse.Headers)
|
||||||
Assert.False(initialResponse.Headers.Contains("Served_From_Cache"));
|
{
|
||||||
Assert.True(subsequentResponse.Headers.Contains("Served_From_Cache"));
|
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());
|
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