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