OutputCache: allow returning cached responses as fully fresh (Date: now, Age: 0)
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 290
Description
### Is there an existing issue for this?
- [X] I have searched the existing issues
### Is your feature request related to a problem? Please describe the problem.
I am trying to combine OutputCache with the Cache-Control response header.
The idea is that this allows for 3 layers of caching:
1. The OutputCache
2. Infrastructure cache. In my case caching on Azure FrontDoor POPs.
3. Browser cache.
This concept can be nicely implemented by writing a custom `IOutputCachePolicy`. The OutputCache is really nice and allows to control exactly the right things.
Except two response headers:
1. `Age` https://github.com/dotnet/aspnetcore/blob/ec293ee75c0c022370951a459b188fa81ec8b7c3/src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs#L307
2. `Date` https://github.com/dotnet/aspnetcore/blob/ec293ee75c0c022370951a459b188fa81ec8b7c3/src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs#L400
These headers are set _after_ all hooks of the Policies are invoked. Therefore one cannot control them in the custom policy.
Why does this matter?
With this approach we are telling the world (proxies such as CDNs and browsers) that the cached responses we are serving have a certain age that needs to be considered. However, if we know that we purge the OutputCache whenever there is new content, we can consider the Age to be 0.
### Describe the solution you'd like
I would like to be able to return cached responses with:
- `Date`: Now datetime
- `Age`: `0` (or maybe omitted?)
Because I know I will purge all relevant content from the OutputCache using `IOutputCacheStore.EvictByTagAsync(tagName)` calls, cached OutputCache responses are 'as fresh' as non-cached responses.
I can then properly use `Cache-Control: max-age` and `Cache-Control: s-max-age` to instruct the CDN / browser when to check whether there is new content. These can then use a rather small age, because the average request will result in a cached OutputCache response.
### Additional context
The implementation of my custom `IOutputCachePolicy`.
```c#
public record CacheOptions
{
public required TimeSpan? OutputCacheDuration { get; init; }
public required CacheControlHeaderValue HttpCacheControlHeader { get; init; }
}
public class OutputCacheWithHttpResponseCacheControlHeaderPolicy(CacheOptions options)
: IOutputCachePolicy
{
ValueTask IOutputCachePolicy.CacheRequestAsync(
OutputCacheContext context,
CancellationToken cancellationToken)
{
SetOutputCacheConfiguration(context);
SetCacheControlResponseHeader(context);
return ValueTask.CompletedTask;
}
ValueTask IOutputCachePolicy.ServeFromCacheAsync
(OutputCacheContext context, CancellationToken cancellationToken)
{
return ValueTask.CompletedTask;
}
ValueTask IOutputCachePolicy.ServeResponseAsync
(OutputCacheContext context, CancellationToken cancellationToken)
{
context.AllowCacheStorage = context.HttpContext.Response.IsValidForCaching();
return ValueTask.CompletedTask;
}
private void SetOutputCacheConfiguration(OutputCacheContext context)
{
var requestValidForOutputCaching =
options.OutputCacheDuration != null &&
context.HttpContext.Request.IsValidForCaching();
context.EnableOutputCaching = true;
context.AllowLocking = true;
context.AllowCacheLookup = requestValidForOutputCaching;
context.AllowCacheStorage = requestValidForOutputCaching;
context.ResponseExpirationTimeSpan = options.OutputCacheDuration;
context.CacheVaryByRules.QueryKeys = "*";
context.CacheVaryByRules.VaryByHost = true;
}
private void SetCacheControlResponseHeader(OutputCacheContext context)
{
context.HttpContext.Response.Headers.CacheControl = context.HttpContext.Request.IsValidForCaching()
? options.HttpCacheControlHeader.ToString()
: "no-store,no-cache";
}
}
public static class OutputCacheExtensions
{
public static bool IsValidForCaching(this HttpRequest request)
{
return
request.MethodValidForOutputCaching() &&
!request.IsAuthenticated() &&
!request.IsCachingDisabledByQuery();
}
public static bool IsValidForCaching(this HttpResponse response)
{
return
!response.HasSetCookieHeader() &&
response.StatusCodeValidForOutputCaching();
}
private static bool MethodValidForOutputCaching(this HttpRequest request)
{
return HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method);
}
private static bool IsAuthenticated(this HttpRequest request)
{
return
!StringValues.IsNullOrEmpty(request.Headers.Authorization) ||
request.HttpContext.User.Identity?.IsAuthenticated == true;
}
private static bool IsCachingDisabledByQuery(this HttpRequest request)
{
return request.Query["noCache"] == "true";
}
private static bool HasSetCookieHeader(this HttpResponse response)
{
return !StringValues.IsNullOrEmpty(response.Headers.SetCookie);
}
private static bool StatusCodeValidForOutputCaching(this HttpResponse response)
{
return response.StatusCode is
StatusCodes.Status200OK or
StatusCodes.Status301MovedPermanently;
}
}
```
Usage:
```c#
public static class CachePolicies
{
public const string ApplicationLongCdnShortBrowserNone = "ApplicationLongCdnShortBrowserNone";
public const string ApplicationShortCdnShortBrowserShort = "ApplicationShortCdnShortBrowserShort";
public const string None = "None";
public static void AddNamedCustomCachePolicies(this OutputCacheOptions options)
{
options.AddPolicy(ApplicationLongCdnShortBrowserNone, new OutputCacheWithHttpResponseCacheControlHeaderPolicy(
new CacheOptions
{
OutputCacheDuration = TimeSpan.FromHours(1),
HttpCacheControlHeader = new CacheControlHeaderValue
{
Public = true,
SharedMaxAge = TimeSpan.FromMinutes(5)
}
}));
options.AddPolicy(ApplicationShortCdnShortBrowserShort, new OutputCacheWithHttpResponseCacheControlHeaderPolicy(
new CacheOptions
{
OutputCacheDuration = TimeSpan.FromMinutes(5),
HttpCacheControlHeader = new CacheControlHeaderValue
{
Public = true,
MaxAge = TimeSpan.FromMinutes(5)
}
}));
options.AddPolicy(None, new OutputCacheWithHttpResponseCacheControlHeaderPolicy(
new CacheOptions
{
OutputCacheDuration = null,
HttpCacheControlHeader = new CacheControlHeaderValue
{
NoStore = true,
NoCache = true
}
}));
}
}
```
And on the controller / action:
```c#
[OutputCache(PolicyName = CachePolicies.ApplicationLongCdnShortBrowserNone)]
```
Contributor guide
Assessment
This issue has not been assessed yet.