Add support for ResponseCache in Razor Pages #6627
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,27 +2,16 @@ | |
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. | ||
|
||
using System; | ||
using System.Globalization; | ||
using System.Linq; | ||
using Microsoft.AspNetCore.Http; | ||
using Microsoft.AspNetCore.Mvc.Core; | ||
using Microsoft.AspNetCore.Mvc.Filters; | ||
using Microsoft.AspNetCore.ResponseCaching; | ||
using Microsoft.Net.Http.Headers; | ||
|
||
namespace Microsoft.AspNetCore.Mvc.Internal | ||
{ | ||
/// <summary> | ||
/// An <see cref="IActionFilter"/> which sets the appropriate headers related to response caching. | ||
/// </summary> | ||
public class ResponseCacheFilter : IResponseCacheFilter | ||
public class ResponseCacheFilter : IActionFilter, IResponseCacheFilter | ||
{ | ||
private readonly CacheProfile _cacheProfile; | ||
private int? _cacheDuration; | ||
private ResponseCacheLocation? _cacheLocation; | ||
private bool? _cacheNoStore; | ||
private string _cacheVaryByHeader; | ||
private string[] _cacheVaryByQueryKeys; | ||
private readonly ResponseCacheFilterExecutor _executor; | ||
|
||
/// <summary> | ||
/// Creates a new instance of <see cref="ResponseCacheFilter"/> | ||
|
@@ -31,7 +20,7 @@ public class ResponseCacheFilter : IResponseCacheFilter | |
/// <see cref="ResponseCacheFilter"/>.</param> | ||
public ResponseCacheFilter(CacheProfile cacheProfile) | ||
{ | ||
_cacheProfile = cacheProfile; | ||
_executor = new ResponseCacheFilterExecutor(cacheProfile); | ||
} | ||
|
||
/// <summary> | ||
|
@@ -41,17 +30,17 @@ public ResponseCacheFilter(CacheProfile cacheProfile) | |
/// </summary> | ||
public int Duration | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we have these properties? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because you can still construct the filter |
||
{ | ||
get { return (_cacheDuration ?? _cacheProfile.Duration) ?? 0; } | ||
set { _cacheDuration = value; } | ||
get => _executor.Duration; | ||
set => _executor.Duration = value; | ||
} | ||
|
||
/// <summary> | ||
/// Gets or sets the location where the data from a particular URL must be cached. | ||
/// </summary> | ||
public ResponseCacheLocation Location | ||
{ | ||
get { return (_cacheLocation ?? _cacheProfile.Location) ?? ResponseCacheLocation.Any; } | ||
set { _cacheLocation = value; } | ||
get => _executor.Location; | ||
set => _executor.Location = value; | ||
} | ||
|
||
/// <summary> | ||
|
@@ -62,17 +51,17 @@ public ResponseCacheLocation Location | |
/// </summary> | ||
public bool NoStore | ||
{ | ||
get { return (_cacheNoStore ?? _cacheProfile.NoStore) ?? false; } | ||
set { _cacheNoStore = value; } | ||
get => _executor.NoStore; | ||
set => _executor.NoStore = value; | ||
} | ||
|
||
/// <summary> | ||
/// Gets or sets the value for the Vary response header. | ||
/// </summary> | ||
public string VaryByHeader | ||
{ | ||
get { return _cacheVaryByHeader ?? _cacheProfile.VaryByHeader; } | ||
set { _cacheVaryByHeader = value; } | ||
get => _executor.VaryByHeader; | ||
set => _executor.VaryByHeader = value; | ||
} | ||
|
||
/// <summary> | ||
|
@@ -83,8 +72,8 @@ public string VaryByHeader | |
/// </remarks> | ||
public string[] VaryByQueryKeys | ||
{ | ||
get { return _cacheVaryByQueryKeys ?? _cacheProfile.VaryByQueryKeys; } | ||
set { _cacheVaryByQueryKeys = value; } | ||
get => _executor.VaryByQueryKeys; | ||
set => _executor.VaryByQueryKeys = value; | ||
} | ||
|
||
/// <inheritdoc /> | ||
|
@@ -97,101 +86,17 @@ public void OnActionExecuting(ActionExecutingContext context) | |
|
||
// If there are more filters which can override the values written by this filter, | ||
// then skip execution of this filter. | ||
if (IsOverridden(context)) | ||
if (ResponseCacheFilterExecutor.IsOverridden(this, context)) | ||
{ | ||
return; | ||
} | ||
|
||
if (!NoStore) | ||
{ | ||
// Duration MUST be set (either in the cache profile or in this filter) unless NoStore is true. | ||
if (_cacheProfile.Duration == null && _cacheDuration == null) | ||
{ | ||
throw new InvalidOperationException( | ||
Resources.FormatResponseCache_SpecifyDuration(nameof(NoStore), nameof(Duration))); | ||
} | ||
} | ||
|
||
var headers = context.HttpContext.Response.Headers; | ||
|
||
// Clear all headers | ||
headers.Remove(HeaderNames.Vary); | ||
headers.Remove(HeaderNames.CacheControl); | ||
headers.Remove(HeaderNames.Pragma); | ||
|
||
if (!string.IsNullOrEmpty(VaryByHeader)) | ||
{ | ||
headers[HeaderNames.Vary] = VaryByHeader; | ||
} | ||
|
||
if (VaryByQueryKeys != null) | ||
{ | ||
var responseCachingFeature = context.HttpContext.Features.Get<IResponseCachingFeature>(); | ||
if (responseCachingFeature == null) | ||
{ | ||
throw new InvalidOperationException(Resources.FormatVaryByQueryKeys_Requires_ResponseCachingMiddleware(nameof(VaryByQueryKeys))); | ||
} | ||
responseCachingFeature.VaryByQueryKeys = VaryByQueryKeys; | ||
} | ||
|
||
if (NoStore) | ||
{ | ||
headers[HeaderNames.CacheControl] = "no-store"; | ||
|
||
// Cache-control: no-store, no-cache is valid. | ||
if (Location == ResponseCacheLocation.None) | ||
{ | ||
headers.AppendCommaSeparatedValues(HeaderNames.CacheControl, "no-cache"); | ||
headers[HeaderNames.Pragma] = "no-cache"; | ||
} | ||
} | ||
else | ||
{ | ||
string cacheControlValue = null; | ||
switch (Location) | ||
{ | ||
case ResponseCacheLocation.Any: | ||
cacheControlValue = "public"; | ||
break; | ||
case ResponseCacheLocation.Client: | ||
cacheControlValue = "private"; | ||
break; | ||
case ResponseCacheLocation.None: | ||
cacheControlValue = "no-cache"; | ||
headers[HeaderNames.Pragma] = "no-cache"; | ||
break; | ||
} | ||
|
||
cacheControlValue = string.Format( | ||
CultureInfo.InvariantCulture, | ||
"{0}{1}max-age={2}", | ||
cacheControlValue, | ||
cacheControlValue != null ? "," : null, | ||
Duration); | ||
|
||
if (cacheControlValue != null) | ||
{ | ||
headers[HeaderNames.CacheControl] = cacheControlValue; | ||
} | ||
} | ||
_executor.Execute(context); | ||
} | ||
|
||
/// <inheritdoc /> | ||
public void OnActionExecuted(ActionExecutedContext context) | ||
{ | ||
} | ||
|
||
// internal for Unit Testing purposes. | ||
internal bool IsOverridden(ActionExecutingContext context) | ||
{ | ||
if (context == null) | ||
{ | ||
throw new ArgumentNullException(nameof(context)); | ||
} | ||
|
||
// Return true if there are any filters which are after the current filter. In which case the current | ||
// filter should be skipped. | ||
return context.Filters.OfType<IResponseCacheFilter>().Last() != this; | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
// 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.Diagnostics; | ||
using Microsoft.AspNetCore.Http; | ||
using Microsoft.AspNetCore.Mvc.Core; | ||
using Microsoft.AspNetCore.Mvc.Filters; | ||
using Microsoft.AspNetCore.ResponseCaching; | ||
using Microsoft.Net.Http.Headers; | ||
|
||
namespace Microsoft.AspNetCore.Mvc.Internal | ||
{ | ||
public class ResponseCacheFilterExecutor | ||
{ | ||
private readonly CacheProfile _cacheProfile; | ||
private int? _cacheDuration; | ||
private ResponseCacheLocation? _cacheLocation; | ||
private bool? _cacheNoStore; | ||
private string _cacheVaryByHeader; | ||
private string[] _cacheVaryByQueryKeys; | ||
|
||
public ResponseCacheFilterExecutor(CacheProfile cacheProfile) | ||
{ | ||
_cacheProfile = cacheProfile ?? throw new ArgumentNullException(nameof(cacheProfile)); | ||
} | ||
|
||
public int Duration | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than duplicating all of this, it would be better pass in the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have two different types of |
||
{ | ||
get => _cacheDuration ?? _cacheProfile.Duration ?? 0; | ||
set => _cacheDuration = value; | ||
} | ||
|
||
public ResponseCacheLocation Location | ||
{ | ||
get => _cacheLocation ?? _cacheProfile.Location ?? ResponseCacheLocation.Any; | ||
set => _cacheLocation = value; | ||
} | ||
|
||
public bool NoStore | ||
{ | ||
get => _cacheNoStore ?? _cacheProfile.NoStore ?? false; | ||
set => _cacheNoStore = value; | ||
} | ||
|
||
public string VaryByHeader | ||
{ | ||
get => _cacheVaryByHeader ?? _cacheProfile.VaryByHeader; | ||
set => _cacheVaryByHeader = value; | ||
} | ||
|
||
public string[] VaryByQueryKeys | ||
{ | ||
get => _cacheVaryByQueryKeys ?? _cacheProfile.VaryByQueryKeys; | ||
set => _cacheVaryByQueryKeys = value; | ||
} | ||
|
||
public void Execute(FilterContext context) | ||
{ | ||
if (context == null) | ||
{ | ||
throw new ArgumentNullException(nameof(context)); | ||
} | ||
|
||
if (!NoStore) | ||
{ | ||
// Duration MUST be set (either in the cache profile or in this filter) unless NoStore is true. | ||
if (_cacheProfile.Duration == null && _cacheDuration == null) | ||
{ | ||
throw new InvalidOperationException( | ||
Resources.FormatResponseCache_SpecifyDuration(nameof(NoStore), nameof(Duration))); | ||
} | ||
} | ||
|
||
var headers = context.HttpContext.Response.Headers; | ||
|
||
// Clear all headers | ||
headers.Remove(HeaderNames.Vary); | ||
headers.Remove(HeaderNames.CacheControl); | ||
headers.Remove(HeaderNames.Pragma); | ||
|
||
if (!string.IsNullOrEmpty(VaryByHeader)) | ||
{ | ||
headers[HeaderNames.Vary] = VaryByHeader; | ||
} | ||
|
||
if (VaryByQueryKeys != null) | ||
{ | ||
var responseCachingFeature = context.HttpContext.Features.Get<IResponseCachingFeature>(); | ||
if (responseCachingFeature == null) | ||
{ | ||
throw new InvalidOperationException( | ||
Resources.FormatVaryByQueryKeys_Requires_ResponseCachingMiddleware(nameof(VaryByQueryKeys))); | ||
} | ||
responseCachingFeature.VaryByQueryKeys = VaryByQueryKeys; | ||
} | ||
|
||
if (NoStore) | ||
{ | ||
headers[HeaderNames.CacheControl] = "no-store"; | ||
|
||
// Cache-control: no-store, no-cache is valid. | ||
if (Location == ResponseCacheLocation.None) | ||
{ | ||
headers.AppendCommaSeparatedValues(HeaderNames.CacheControl, "no-cache"); | ||
headers[HeaderNames.Pragma] = "no-cache"; | ||
} | ||
} | ||
else | ||
{ | ||
string cacheControlValue; | ||
switch (Location) | ||
{ | ||
case ResponseCacheLocation.Any: | ||
cacheControlValue = "public,"; | ||
break; | ||
case ResponseCacheLocation.Client: | ||
cacheControlValue = "private,"; | ||
break; | ||
case ResponseCacheLocation.None: | ||
cacheControlValue = "no-cache,"; | ||
headers[HeaderNames.Pragma] = "no-cache"; | ||
break; | ||
default: | ||
cacheControlValue = null; | ||
break; | ||
} | ||
|
||
cacheControlValue = $"{cacheControlValue}max-age={Duration}"; | ||
headers[HeaderNames.CacheControl] = cacheControlValue; | ||
} | ||
} | ||
|
||
public static bool IsOverridden(IResponseCacheFilter executingFilter, FilterContext context) | ||
{ | ||
Debug.Assert(context != null); | ||
|
||
// Return true if there are any filters which are after the current filter. In which case the current | ||
// filter should be skipped. | ||
for (var i = context.Filters.Count - 1; i >= 0; i--) | ||
{ | ||
var filter = context.Filters[i]; | ||
if (filter is IResponseCacheFilter) | ||
{ | ||
return !object.ReferenceEquals(executingFilter, filter); | ||
} | ||
} | ||
|
||
Debug.Fail("The executing filter must be part of the filter context."); | ||
return false; | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a breaking change
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use a different marker type for pages? I thought we were kinda ok changing this since it's in
.Internal
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh! it is, cool. Ok that's all fine then