diff --git a/src/Middleware/OutputCaching/src/CacheVaryByRules.cs b/src/Middleware/OutputCaching/src/CacheVaryByRules.cs index 28caea7acea2..abbd9f8d62aa 100644 --- a/src/Middleware/OutputCaching/src/CacheVaryByRules.cs +++ b/src/Middleware/OutputCaching/src/CacheVaryByRules.cs @@ -21,9 +21,14 @@ public sealed class CacheVaryByRules public IDictionary VaryByCustom => _varyByCustom ??= new(); /// - /// Gets or sets the list of headers to vary by. + /// Gets or sets the list of route value names to vary by. /// - public StringValues Headers { get; set; } + public StringValues RouteValueNames { get; set; } + + /// + /// Gets or sets the list of header names to vary by. + /// + public StringValues HeaderNames { get; set; } /// /// Gets or sets the list of query string keys to vary by. diff --git a/src/Middleware/OutputCaching/src/LoggerExtensions.cs b/src/Middleware/OutputCaching/src/LoggerExtensions.cs index 642f7252a623..7a05e21f7c4c 100644 --- a/src/Middleware/OutputCaching/src/LoggerExtensions.cs +++ b/src/Middleware/OutputCaching/src/LoggerExtensions.cs @@ -11,59 +11,44 @@ namespace Microsoft.AspNetCore.OutputCaching; /// internal static partial class LoggerExtensions { - [LoggerMessage(1, LogLevel.Debug, "The request cannot be served from cache because it uses the HTTP method: {Method}.", - EventName = "RequestMethodNotCacheable")] - internal static partial void RequestMethodNotCacheable(this ILogger logger, string method); - - [LoggerMessage(2, LogLevel.Debug, "The request cannot be served from cache because it contains an 'Authorization' header.", - EventName = "RequestWithAuthorizationNotCacheable")] - internal static partial void RequestWithAuthorizationNotCacheable(this ILogger logger); - - [LoggerMessage(3, LogLevel.Debug, "Response is not cacheable because it contains a 'SetCookie' header.", EventName = "ResponseWithSetCookieNotCacheable")] - internal static partial void ResponseWithSetCookieNotCacheable(this ILogger logger); - - [LoggerMessage(4, LogLevel.Debug, "Response is not cacheable because its status code {StatusCode} does not indicate success.", - EventName = "ResponseWithUnsuccessfulStatusCodeNotCacheable")] - internal static partial void ResponseWithUnsuccessfulStatusCodeNotCacheable(this ILogger logger, int statusCode); - - [LoggerMessage(5, LogLevel.Debug, "The 'IfNoneMatch' header of the request contains a value of *.", EventName = "NotModifiedIfNoneMatchStar")] + [LoggerMessage(1, LogLevel.Debug, "The 'IfNoneMatch' header of the request contains a value of *.", EventName = "NotModifiedIfNoneMatchStar")] internal static partial void NotModifiedIfNoneMatchStar(this ILogger logger); - [LoggerMessage(6, LogLevel.Debug, "The ETag {ETag} in the 'IfNoneMatch' header matched the ETag of a cached entry.", + [LoggerMessage(2, LogLevel.Debug, "The ETag {ETag} in the 'IfNoneMatch' header matched the ETag of a cached entry.", EventName = "NotModifiedIfNoneMatchMatched")] internal static partial void NotModifiedIfNoneMatchMatched(this ILogger logger, EntityTagHeaderValue etag); - [LoggerMessage(7, LogLevel.Debug, "The last modified date of {LastModified} is before the date {IfModifiedSince} specified in the 'IfModifiedSince' header.", + [LoggerMessage(3, LogLevel.Debug, "The last modified date of {LastModified} is before the date {IfModifiedSince} specified in the 'IfModifiedSince' header.", EventName = "NotModifiedIfModifiedSinceSatisfied")] internal static partial void NotModifiedIfModifiedSinceSatisfied(this ILogger logger, DateTimeOffset lastModified, DateTimeOffset ifModifiedSince); - [LoggerMessage(8, LogLevel.Information, "The content requested has not been modified.", EventName = "NotModifiedServed")] + [LoggerMessage(4, LogLevel.Information, "The content requested has not been modified.", EventName = "NotModifiedServed")] internal static partial void NotModifiedServed(this ILogger logger); - [LoggerMessage(9, LogLevel.Information, "Serving response from cache.", EventName = "CachedResponseServed")] + [LoggerMessage(5, LogLevel.Information, "Serving response from cache.", EventName = "CachedResponseServed")] internal static partial void CachedResponseServed(this ILogger logger); - [LoggerMessage(10, LogLevel.Information, "No cached response available for this request and the 'only-if-cached' cache directive was specified.", + [LoggerMessage(6, LogLevel.Information, "No cached response available for this request and the 'only-if-cached' cache directive was specified.", EventName = "GatewayTimeoutServed")] internal static partial void GatewayTimeoutServed(this ILogger logger); - [LoggerMessage(11, LogLevel.Information, "No cached response available for this request.", EventName = "NoResponseServed")] + [LoggerMessage(7, LogLevel.Information, "No cached response available for this request.", EventName = "NoResponseServed")] internal static partial void NoResponseServed(this ILogger logger); - [LoggerMessage(12, LogLevel.Debug, "Vary by rules were updated. Headers: {Headers}, Query keys: {QueryKeys}", EventName = "VaryByRulesUpdated")] - internal static partial void VaryByRulesUpdated(this ILogger logger, string headers, string queryKeys); + [LoggerMessage(8, LogLevel.Debug, "Vary by rules were updated. Header names: {HeaderNames}, Query keys: {QueryKeys}, Route value names: {RouteValueNames}", EventName = "VaryByRulesUpdated")] + internal static partial void VaryByRulesUpdated(this ILogger logger, string headerNames, string queryKeys, string routeValueNames); - [LoggerMessage(13, LogLevel.Information, "The response has been cached.", EventName = "ResponseCached")] + [LoggerMessage(9, LogLevel.Information, "The response has been cached.", EventName = "ResponseCached")] internal static partial void ResponseCached(this ILogger logger); - [LoggerMessage(14, LogLevel.Information, "The response could not be cached for this request.", EventName = "ResponseNotCached")] + [LoggerMessage(10, LogLevel.Information, "The response could not be cached for this request.", EventName = "ResponseNotCached")] internal static partial void ResponseNotCached(this ILogger logger); - [LoggerMessage(15, LogLevel.Warning, "The response could not be cached for this request because the 'Content-Length' did not match the body length.", + [LoggerMessage(11, LogLevel.Warning, "The response could not be cached for this request because the 'Content-Length' did not match the body length.", EventName = "ResponseContentLengthMismatchNotCached")] internal static partial void ResponseContentLengthMismatchNotCached(this ILogger logger); - [LoggerMessage(16, LogLevel.Debug, "The response time of the entry is {ResponseTime} and has exceeded its expiry date.", + [LoggerMessage(12, LogLevel.Debug, "The response time of the entry is {ResponseTime} and has exceeded its expiry date.", EventName = "ExpirationExpiresExceeded")] internal static partial void ExpirationExpiresExceeded(this ILogger logger, DateTimeOffset responseTime); diff --git a/src/Middleware/OutputCaching/src/OutputCacheAttribute.cs b/src/Middleware/OutputCaching/src/OutputCacheAttribute.cs index ab48a0355609..b5bbd028bdb6 100644 --- a/src/Middleware/OutputCaching/src/OutputCacheAttribute.cs +++ b/src/Middleware/OutputCaching/src/OutputCacheAttribute.cs @@ -45,9 +45,14 @@ public bool NoStore public string[]? VaryByQueryKeys { get; init; } /// - /// Gets or sets the headers to vary by. + /// Gets or sets the header names to vary by. /// - public string[]? VaryByHeaders { get; init; } + public string[]? VaryByHeaderNames { get; init; } + + /// + /// Gets or sets the route value names to vary by. + /// + public string[]? VaryByRouteValueNames { get; init; } /// /// Gets or sets the value of the cache policy name. @@ -78,6 +83,16 @@ internal IOutputCachePolicy BuildPolicy() builder.VaryByQuery(VaryByQueryKeys); } + if (VaryByHeaderNames != null) + { + builder.VaryByHeader(VaryByHeaderNames); + } + + if (VaryByRouteValueNames != null) + { + builder.VaryByRouteValue(VaryByRouteValueNames); + } + if (_duration != null) { builder.Expire(TimeSpan.FromSeconds(_duration.Value)); diff --git a/src/Middleware/OutputCaching/src/OutputCacheContext.cs b/src/Middleware/OutputCaching/src/OutputCacheContext.cs index 667ca5dcce94..be51451ec169 100644 --- a/src/Middleware/OutputCaching/src/OutputCacheContext.cs +++ b/src/Middleware/OutputCaching/src/OutputCacheContext.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.OutputCaching; @@ -11,12 +10,8 @@ namespace Microsoft.AspNetCore.OutputCaching; /// public sealed class OutputCacheContext { - internal OutputCacheContext(HttpContext httpContext, IOutputCacheStore store, OutputCacheOptions options, ILogger logger) + public OutputCacheContext() { - HttpContext = httpContext; - Logger = logger; - Store = store; - Options = options; } /// @@ -42,17 +37,17 @@ internal OutputCacheContext(HttpContext httpContext, IOutputCacheStore store, Ou /// /// Gets the . /// - public HttpContext HttpContext { get; } + public required HttpContext HttpContext { get; init; } /// - /// Gets the response time. + /// Gets or sets the response time. /// - public DateTimeOffset? ResponseTime { get; internal set; } + public DateTimeOffset? ResponseTime { get; set; } /// /// Gets the instance. /// - public CacheVaryByRules CacheVaryByRules { get; set; } = new(); + public CacheVaryByRules CacheVaryByRules { get; } = new(); /// /// Gets the tags of the cached response. @@ -79,7 +74,4 @@ internal OutputCacheContext(HttpContext httpContext, IOutputCacheStore store, Ou internal Stream OriginalResponseStream { get; set; } = default!; internal OutputCacheStream OutputCacheStream { get; set; } = default!; - internal ILogger Logger { get; } - internal OutputCacheOptions Options { get; } - internal IOutputCacheStore Store { get; } } diff --git a/src/Middleware/OutputCaching/src/OutputCacheKeyProvider.cs b/src/Middleware/OutputCaching/src/OutputCacheKeyProvider.cs index dc8cce4afc15..456ff0147220 100644 --- a/src/Middleware/OutputCaching/src/OutputCacheKeyProvider.cs +++ b/src/Middleware/OutputCaching/src/OutputCacheKeyProvider.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using System.Linq; using System.Text; using Microsoft.Extensions.ObjectPool; @@ -28,7 +29,7 @@ internal OutputCacheKeyProvider(ObjectPoolProvider poolProvider, IOptionsSCHEMEHOST:PORT/PATHBASE/PATHHHeaderName=HeaderValueQQueryName=QueryValue1QueryValue2 + // GETSCHEMEHOST:PORT/PATHBASE/PATHHHeaderName=HeaderValueQQueryName=QueryValue1QueryValue2RRouteName1=RouteValue1RouteName2=RouteValue2 public string CreateStorageKey(OutputCacheContext context) { ArgumentNullException.ThrowIfNull(_builderPool); @@ -79,8 +80,8 @@ public string CreateStorageKey(OutputCacheContext context) } } - // Vary by headers - var headersCount = varyByRules?.Headers.Count ?? 0; + // Vary by header names + var headersCount = varyByRules?.HeaderNames.Count ?? 0; if (headersCount > 0) { // Append a group separator for the header segment of the cache key @@ -90,7 +91,7 @@ public string CreateStorageKey(OutputCacheContext context) var requestHeaders = context.HttpContext.Request.Headers; for (var i = 0; i < headersCount; i++) { - var header = varyByRules!.Headers[i] ?? string.Empty; + var header = varyByRules!.HeaderNames[i] ?? string.Empty; var headerValues = requestHeaders[header]; builder.Append(KeyDelimiter) .Append(header) @@ -166,6 +167,29 @@ public string CreateStorageKey(OutputCacheContext context) } } + // Vary by route value names + var routeValueNamesCount = varyByRules?.RouteValueNames.Count ?? 0; + if (routeValueNamesCount > 0) + { + // Append a group separator for the route values segment of the cache key + builder.Append(KeyDelimiter) + .Append('R'); + + for (var i = 0; i < routeValueNamesCount; i++) + { + // The lookup key can't be null + var routeValueName = varyByRules!.RouteValueNames[i] ?? string.Empty; + + // RouteValueNames returns null if the key doesn't exist + var routeValueValue = context.HttpContext.Request.RouteValues[routeValueName]; + + builder.Append(KeyDelimiter) + .Append(routeValueName) + .Append('=') + .Append(Convert.ToString(routeValueValue, CultureInfo.InvariantCulture)); + } + } + return builder.ToString(); } finally diff --git a/src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs b/src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs index a526358e114b..78ea55b1566c 100644 --- a/src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs +++ b/src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs @@ -92,7 +92,7 @@ public Task Invoke(HttpContext httpContext) private async Task InvokeAwaited(HttpContext httpContext, IReadOnlyList policies) { - var context = new OutputCacheContext(httpContext, _store, _options, _logger); + var context = new OutputCacheContext { HttpContext = httpContext }; // Add IOutputCacheFeature AddOutputCacheFeature(context); @@ -247,7 +247,7 @@ internal async Task TryServeCachedResponseAsync(OutputCacheContext context // Validate expiration if (context.CachedEntryAge <= TimeSpan.Zero) { - context.Logger.ExpirationExpiresExceeded(context.ResponseTime!.Value); + _logger.ExpirationExpiresExceeded(context.ResponseTime!.Value); context.IsCacheEntryFresh = false; } @@ -316,7 +316,7 @@ internal async Task TryServeFromCacheAsync(OutputCacheContext cacheContext // TODO: should it be part of the cache implementations or can we assume all caches would benefit from it? // It makes sense for caches that use IO (disk, network) or need to deserialize the state but could also be a global option - var cacheEntry = await _outputCacheEntryDispatcher.ScheduleAsync(cacheContext.CacheKey, cacheContext, static async (key, cacheContext) => await OutputCacheEntryFormatter.GetAsync(key, cacheContext.Store, cacheContext.HttpContext.RequestAborted)); + var cacheEntry = await _outputCacheEntryDispatcher.ScheduleAsync(cacheContext.CacheKey, (Store: _store, CacheContext: cacheContext), static async (key, state) => await OutputCacheEntryFormatter.GetAsync(key, state.Store, state.CacheContext.HttpContext.RequestAborted)); if (await TryServeCachedResponseAsync(cacheContext, cacheEntry, policies)) { @@ -341,32 +341,33 @@ internal void CreateCacheKey(OutputCacheContext context) return; } - var varyHeaders = context.CacheVaryByRules.Headers; + var varyHeaderNames = context.CacheVaryByRules.HeaderNames; + var varyRouteValueNames = context.CacheVaryByRules.RouteValueNames; var varyQueryKeys = context.CacheVaryByRules.QueryKeys; - var varyByCustomKeys = context.CacheVaryByRules.VaryByCustom; + var varyByCustomKeys = context.CacheVaryByRules.HasVaryByCustom ? context.CacheVaryByRules.VaryByCustom : null; var varyByPrefix = context.CacheVaryByRules.VaryByPrefix; // Check if any vary rules exist - if (!StringValues.IsNullOrEmpty(varyHeaders) || !StringValues.IsNullOrEmpty(varyQueryKeys) || !StringValues.IsNullOrEmpty(varyByPrefix) || varyByCustomKeys?.Count > 0) + if (!StringValues.IsNullOrEmpty(varyHeaderNames) || !StringValues.IsNullOrEmpty(varyRouteValueNames) || !StringValues.IsNullOrEmpty(varyQueryKeys) || !StringValues.IsNullOrEmpty(varyByPrefix) || varyByCustomKeys?.Count > 0) { // Normalize order and casing of vary by rules - var normalizedVaryHeaders = GetOrderCasingNormalizedStringValues(varyHeaders); + var normalizedVaryHeaderNames = GetOrderCasingNormalizedStringValues(varyHeaderNames); + var normalizedVaryRouteValueNames = GetOrderCasingNormalizedStringValues(varyRouteValueNames); var normalizedVaryQueryKeys = GetOrderCasingNormalizedStringValues(varyQueryKeys); var normalizedVaryByCustom = GetOrderCasingNormalizedDictionary(varyByCustomKeys); // Update vary rules with normalized values - context.CacheVaryByRules = new CacheVaryByRules - { - VaryByPrefix = varyByPrefix + normalizedVaryByCustom, - Headers = normalizedVaryHeaders, - QueryKeys = normalizedVaryQueryKeys - }; + context.CacheVaryByRules.VaryByCustom.Clear(); + context.CacheVaryByRules.VaryByPrefix = varyByPrefix + normalizedVaryByCustom; + context.CacheVaryByRules.HeaderNames = normalizedVaryHeaderNames; + context.CacheVaryByRules.RouteValueNames = normalizedVaryRouteValueNames; + context.CacheVaryByRules.QueryKeys = normalizedVaryQueryKeys; // TODO: Add same condition on LogLevel in Response Caching // Always overwrite the CachedVaryByRules to update the expiry information if (_logger.IsEnabled(LogLevel.Debug)) { - _logger.VaryByRulesUpdated(normalizedVaryHeaders.ToString(), normalizedVaryQueryKeys.ToString()); + _logger.VaryByRulesUpdated(normalizedVaryHeaderNames.ToString(), normalizedVaryQueryKeys.ToString(), normalizedVaryRouteValueNames.ToString()); } } @@ -516,7 +517,7 @@ internal static void UnshimResponseStream(OutputCacheContext context) RemoveOutputCacheFeature(context.HttpContext); } - internal static bool ContentIsNotModified(OutputCacheContext context) + internal bool ContentIsNotModified(OutputCacheContext context) { var cachedResponseHeaders = context.CachedResponse.Headers; var ifNoneMatchHeader = context.HttpContext.Request.Headers.IfNoneMatch; @@ -525,7 +526,7 @@ internal static bool ContentIsNotModified(OutputCacheContext context) { if (ifNoneMatchHeader.Count == 1 && StringSegment.Equals(ifNoneMatchHeader[0], EntityTagHeaderValue.Any.Tag, StringComparison.OrdinalIgnoreCase)) { - context.Logger.NotModifiedIfNoneMatchStar(); + _logger.NotModifiedIfNoneMatchStar(); return true; } @@ -538,7 +539,7 @@ internal static bool ContentIsNotModified(OutputCacheContext context) var requestETag = ifNoneMatchEtags[i]; if (eTag.Compare(requestETag, useStrongComparison: false)) { - context.Logger.NotModifiedIfNoneMatchMatched(requestETag); + _logger.NotModifiedIfNoneMatchMatched(requestETag); return true; } } @@ -558,7 +559,7 @@ internal static bool ContentIsNotModified(OutputCacheContext context) if (HeaderUtilities.TryParseDate(ifModifiedSince.ToString(), out var modifiedSince) && modified <= modifiedSince) { - context.Logger.NotModifiedIfModifiedSinceSatisfied(modified, modifiedSince); + _logger.NotModifiedIfModifiedSinceSatisfied(modified, modifiedSince); return true; } } diff --git a/src/Middleware/OutputCaching/src/OutputCachePolicyBuilder.cs b/src/Middleware/OutputCaching/src/OutputCachePolicyBuilder.cs index e2c2e59403bb..b386083e912b 100644 --- a/src/Middleware/OutputCaching/src/OutputCachePolicyBuilder.cs +++ b/src/Middleware/OutputCaching/src/OutputCachePolicyBuilder.cs @@ -17,7 +17,7 @@ public sealed class OutputCachePolicyBuilder private IOutputCachePolicy? _builtPolicy; private readonly List _policies = new(); - private List>>? _requirements; + private List>>? _requirements; /// /// Creates a new instance. @@ -57,7 +57,7 @@ public OutputCachePolicyBuilder AddPolicy([DynamicallyAccessedMembers(Dynamicall /// Adds a requirement to the current policy. /// /// The predicate applied to the policy. - public OutputCachePolicyBuilder With(Func> predicate) + public OutputCachePolicyBuilder With(Func> predicate) { ArgumentNullException.ThrowIfNull(predicate); @@ -77,7 +77,7 @@ public OutputCachePolicyBuilder With(Func predicate) _builtPolicy = null; _requirements ??= new(); - _requirements.Add((c, t) => Task.FromResult(predicate(c))); + _requirements.Add((c, t) => ValueTask.FromResult(predicate(c))); return this; } @@ -98,12 +98,23 @@ public OutputCachePolicyBuilder VaryByQuery(params string[] queryKeys) /// /// Adds a policy to vary the cached responses by header. /// - /// The headers to vary the cached responses by. - public OutputCachePolicyBuilder VaryByHeader(params string[] headers) + /// The header names to vary the cached responses by. + public OutputCachePolicyBuilder VaryByHeader(params string[] headerNames) { - ArgumentNullException.ThrowIfNull(headers); + ArgumentNullException.ThrowIfNull(headerNames); - return AddPolicy(new VaryByHeaderPolicy(headers)); + return AddPolicy(new VaryByHeaderPolicy(headerNames)); + } + + /// + /// Adds a policy to vary the cached responses by route value. + /// + /// The route value names to vary the cached responses by. + public OutputCachePolicyBuilder VaryByRouteValue(params string[] routeValueNames) + { + ArgumentNullException.ThrowIfNull(routeValueNames); + + return AddPolicy(new VaryByRouteValuePolicy(routeValueNames)); } /// @@ -206,6 +217,20 @@ public OutputCachePolicyBuilder NoCache() return AddPolicy(EnableCachePolicy.Disabled); } + /// + /// Enables caching for the current request if not already enabled. + /// + public OutputCachePolicyBuilder Cache() + { + // If no custom policy is added, the DefaultPolicy is already "enabled". + if (_policies.Count != 1 || _policies[0] != DefaultPolicy.Instance) + { + AddPolicy(EnableCachePolicy.Enabled); + } + + return this; + } + /// /// Creates the . /// diff --git a/src/Middleware/OutputCaching/src/Policies/DefaultPolicy.cs b/src/Middleware/OutputCaching/src/Policies/DefaultPolicy.cs index a9c0c461b5d3..cc9ea67ec2dd 100644 --- a/src/Middleware/OutputCaching/src/Policies/DefaultPolicy.cs +++ b/src/Middleware/OutputCaching/src/Policies/DefaultPolicy.cs @@ -46,7 +46,6 @@ ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, Canc // Verify existence of cookie headers if (!StringValues.IsNullOrEmpty(response.Headers.SetCookie)) { - context.Logger.ResponseWithSetCookieNotCacheable(); context.AllowCacheStorage = false; return ValueTask.CompletedTask; } @@ -54,7 +53,6 @@ ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, Canc // Check response code if (response.StatusCode != StatusCodes.Status200OK) { - context.Logger.ResponseWithUnsuccessfulStatusCodeNotCacheable(response.StatusCode); context.AllowCacheStorage = false; return ValueTask.CompletedTask; } @@ -71,14 +69,12 @@ private static bool AttemptOutputCaching(OutputCacheContext context) // Verify the method if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method)) { - context.Logger.RequestMethodNotCacheable(request.Method); return false; } // Verify existence of authorization headers if (!StringValues.IsNullOrEmpty(request.Headers.Authorization) || request.HttpContext.User?.Identity?.IsAuthenticated == true) { - context.Logger.RequestWithAuthorizationNotCacheable(); return false; } diff --git a/src/Middleware/OutputCaching/src/Policies/NamedPolicy.cs b/src/Middleware/OutputCaching/src/Policies/NamedPolicy.cs index 4967d8b1b3fc..43ba24e9a017 100644 --- a/src/Middleware/OutputCaching/src/Policies/NamedPolicy.cs +++ b/src/Middleware/OutputCaching/src/Policies/NamedPolicy.cs @@ -1,6 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + namespace Microsoft.AspNetCore.OutputCaching; /// @@ -9,6 +12,8 @@ namespace Microsoft.AspNetCore.OutputCaching; internal sealed class NamedPolicy : IOutputCachePolicy { private readonly string _policyName; + private IOptions? _options; + private readonly object _synLock = new(); /// /// Create a new instance. @@ -60,7 +65,15 @@ ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, Cance internal IOutputCachePolicy? GetProfilePolicy(OutputCacheContext context) { - var policies = context.Options.NamedPolicies; + if (_options == null) + { + lock (_synLock) + { + _options ??= context.HttpContext.RequestServices.GetRequiredService>(); + } + } + + var policies = _options!.Value.NamedPolicies; return policies != null && policies.TryGetValue(_policyName, out var cacheProfile) ? cacheProfile diff --git a/src/Middleware/OutputCaching/src/Policies/TypedPolicy.cs b/src/Middleware/OutputCaching/src/Policies/TypedPolicy.cs index d01f60a24374..96dfe7e7aa15 100644 --- a/src/Middleware/OutputCaching/src/Policies/TypedPolicy.cs +++ b/src/Middleware/OutputCaching/src/Policies/TypedPolicy.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.OutputCaching.Policies; @@ -29,7 +30,8 @@ public TypedPolicy([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Pu private IOutputCachePolicy? CreatePolicy(OutputCacheContext context) { - return _instance ??= ActivatorUtilities.CreateInstance(context.Options.ApplicationServices, _policyType) as IOutputCachePolicy; + var options = context.HttpContext.RequestServices.GetRequiredService>(); + return _instance ??= ActivatorUtilities.CreateInstance(options.Value.ApplicationServices, _policyType) as IOutputCachePolicy; } /// diff --git a/src/Middleware/OutputCaching/src/Policies/VaryByHeaderPolicy.cs b/src/Middleware/OutputCaching/src/Policies/VaryByHeaderPolicy.cs index 35ed3ca16a26..a1109366787e 100644 --- a/src/Middleware/OutputCaching/src/Policies/VaryByHeaderPolicy.cs +++ b/src/Middleware/OutputCaching/src/Policies/VaryByHeaderPolicy.cs @@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.OutputCaching; /// internal sealed class VaryByHeaderPolicy : IOutputCachePolicy { - private readonly StringValues _headers; + private readonly StringValues _headerNames; /// /// Creates a policy that doesn't vary the cached content based on headers. @@ -20,36 +20,36 @@ public VaryByHeaderPolicy() } /// - /// Creates a policy that varies the cached content based on the specified header. + /// Creates a policy that varies the cached content based on the specified header name. /// public VaryByHeaderPolicy(string header) { ArgumentNullException.ThrowIfNull(header); - _headers = header; + _headerNames = header; } /// - /// Creates a policy that varies the cached content based on the specified query string keys. + /// Creates a policy that varies the cached content based on the specified header names. /// - public VaryByHeaderPolicy(params string[] headers) + public VaryByHeaderPolicy(params string[] headerNames) { - ArgumentNullException.ThrowIfNull(headers); + ArgumentNullException.ThrowIfNull(headerNames); - _headers = headers; + _headerNames = headerNames; } /// ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) { // No vary by header? - if (_headers.Count == 0) + if (_headerNames.Count == 0) { - context.CacheVaryByRules.Headers = _headers; + context.CacheVaryByRules.HeaderNames = _headerNames; return ValueTask.CompletedTask; } - context.CacheVaryByRules.Headers = StringValues.Concat(context.CacheVaryByRules.Headers, _headers); + context.CacheVaryByRules.HeaderNames = StringValues.Concat(context.CacheVaryByRules.HeaderNames, _headerNames); return ValueTask.CompletedTask; } diff --git a/src/Middleware/OutputCaching/src/Policies/VaryByRouteValuePolicy.cs b/src/Middleware/OutputCaching/src/Policies/VaryByRouteValuePolicy.cs new file mode 100644 index 000000000000..d3a3686f14a3 --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/VaryByRouteValuePolicy.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.OutputCaching; + +/// +/// When applied, the cached content will be different for every value of the provided route values. +/// +internal sealed class VaryByRouteValuePolicy : IOutputCachePolicy +{ + private readonly StringValues _routeValueNames; + + /// + /// Creates a policy that doesn't vary the cached content based on route values. + /// + public VaryByRouteValuePolicy() + { + } + + /// + /// Creates a policy that varies the cached content based on the specified route value name. + /// + public VaryByRouteValuePolicy(string routeValue) + { + ArgumentNullException.ThrowIfNull(routeValue); + + _routeValueNames = routeValue; + } + + /// + /// Creates a policy that varies the cached content based on the specified route value names. + /// + public VaryByRouteValuePolicy(params string[] routeValueNames) + { + ArgumentNullException.ThrowIfNull(routeValueNames); + + _routeValueNames = routeValueNames; + } + + /// + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + // No vary by route value? + if (_routeValueNames.Count == 0) + { + context.CacheVaryByRules.RouteValueNames = _routeValueNames; + return ValueTask.CompletedTask; + } + + context.CacheVaryByRules.RouteValueNames = StringValues.Concat(context.CacheVaryByRules.RouteValueNames, _routeValueNames); + + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + /// + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt b/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt index 779e48f3530a..fd1ea274618d 100644 --- a/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt +++ b/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt @@ -2,10 +2,12 @@ Microsoft.AspNetCore.Builder.OutputCacheApplicationBuilderExtensions Microsoft.AspNetCore.OutputCaching.CacheVaryByRules Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.CacheVaryByRules() -> void -Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.Headers.get -> Microsoft.Extensions.Primitives.StringValues -Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.Headers.set -> void +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.HeaderNames.get -> Microsoft.Extensions.Primitives.StringValues +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.HeaderNames.set -> void Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.QueryKeys.get -> Microsoft.Extensions.Primitives.StringValues Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.QueryKeys.set -> void +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.RouteValueNames.get -> Microsoft.Extensions.Primitives.StringValues +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.RouteValueNames.set -> void Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.VaryByCustom.get -> System.Collections.Generic.IDictionary! Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.VaryByPrefix.get -> Microsoft.Extensions.Primitives.StringValues Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.VaryByPrefix.set -> void @@ -27,12 +29,17 @@ Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.NoStore.init -> void Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.OutputCacheAttribute() -> void Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.PolicyName.get -> string? Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.PolicyName.init -> void -Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.VaryByHeaders.get -> string![]? -Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.VaryByHeaders.init -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.VaryByHeaderNames.get -> string![]? +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.VaryByHeaderNames.init -> void Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.VaryByQueryKeys.get -> string![]? Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.VaryByQueryKeys.init -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.VaryByRouteValueNames.get -> string![]? +Microsoft.AspNetCore.OutputCaching.OutputCacheAttribute.VaryByRouteValueNames.init -> void Microsoft.AspNetCore.OutputCaching.OutputCacheContext.EnableOutputCaching.get -> bool Microsoft.AspNetCore.OutputCaching.OutputCacheContext.EnableOutputCaching.set -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.HttpContext.init -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.OutputCacheContext() -> void +Microsoft.AspNetCore.OutputCaching.OutputCacheContext.ResponseTime.set -> void Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.AddBasePolicy(Microsoft.AspNetCore.OutputCaching.IOutputCachePolicy! policy) -> void Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.AddBasePolicy(System.Action! build) -> void Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.AddPolicy(string! name, Microsoft.AspNetCore.OutputCaching.IOutputCachePolicy! policy) -> void @@ -41,18 +48,19 @@ Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.AddPolicy(System.Type! policyType) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.AddPolicy() -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.AllowLocking(bool lockResponse = true) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.Cache() -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.Clear() -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.Expire(System.TimeSpan expiration) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.NoCache() -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.OutputCachePolicyBuilder() -> void Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.Tag(params string![]! tags) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! -Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByHeader(params string![]! headers) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByHeader(params string![]! headerNames) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByQuery(params string![]! queryKeys) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByRouteValue(params string![]! routeValueNames) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByValue(System.Func>! varyBy) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByValue(System.Func! varyBy) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByValue(System.Func>! varyBy) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByValue(System.Func>>! varyBy) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! -Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.With(System.Func!>! predicate) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCacheContext Microsoft.AspNetCore.OutputCaching.OutputCacheContext.AllowCacheLookup.get -> bool Microsoft.AspNetCore.OutputCaching.OutputCacheContext.AllowCacheLookup.set -> void @@ -75,6 +83,7 @@ Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.SizeLimit.set -> void Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.UseCaseSensitivePaths.get -> bool Microsoft.AspNetCore.OutputCaching.OutputCacheOptions.UseCaseSensitivePaths.set -> void Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.With(System.Func! predicate) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.With(System.Func>! predicate) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.Extensions.DependencyInjection.OutputCacheConventionBuilderExtensions Microsoft.Extensions.DependencyInjection.OutputCacheServiceCollectionExtensions static Microsoft.AspNetCore.Builder.OutputCacheApplicationBuilderExtensions.UseOutputCache(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! @@ -85,6 +94,5 @@ static Microsoft.Extensions.DependencyInjection.OutputCacheConventionBuilderExte static Microsoft.Extensions.DependencyInjection.OutputCacheServiceCollectionExtensions.AddOutputCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.OutputCacheServiceCollectionExtensions.AddOutputCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! Microsoft.AspNetCore.OutputCaching.OutputCacheContext.CacheVaryByRules.get -> Microsoft.AspNetCore.OutputCaching.CacheVaryByRules! -Microsoft.AspNetCore.OutputCaching.OutputCacheContext.CacheVaryByRules.set -> void Microsoft.AspNetCore.OutputCaching.OutputCacheContext.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext! Microsoft.AspNetCore.OutputCaching.OutputCacheContext.Tags.get -> System.Collections.Generic.HashSet! diff --git a/src/Middleware/OutputCaching/test/OutputCacheAttributeTests.cs b/src/Middleware/OutputCaching/test/OutputCacheAttributeTests.cs new file mode 100644 index 000000000000..cd43d2d58a34 --- /dev/null +++ b/src/Middleware/OutputCaching/test/OutputCacheAttributeTests.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Castle.Core.Internal; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.OutputCaching.Tests; + +public class OutputCacheAttributeTests +{ + [Fact] + public void Attribute_CreatesDefaultPolicy() + { + var context = TestUtils.CreateUninitializedContext(); + + var attribute = OutputCacheMethods.GetAttribute(nameof(OutputCacheMethods.Default)); + var policy = attribute.BuildPolicy(); + + Assert.Equal(DefaultPolicy.Instance, policy); + } + + [Fact] + public async Task Attribute_CreatesExpirePolicy() + { + var context = TestUtils.CreateUninitializedContext(); + + var attribute = OutputCacheMethods.GetAttribute(nameof(OutputCacheMethods.Duration)); + await attribute.BuildPolicy().CacheRequestAsync(context, cancellation: default); + + Assert.True(context.EnableOutputCaching); + Assert.Equal(42, context.ResponseExpirationTimeSpan?.TotalSeconds); + } + + [Fact] + public async Task Attribute_CreatesNoStorePolicy() + { + var context = TestUtils.CreateUninitializedContext(); + + var attribute = OutputCacheMethods.GetAttribute(nameof(OutputCacheMethods.NoStore)); + await attribute.BuildPolicy().CacheRequestAsync(context, cancellation: default); + + Assert.False(context.EnableOutputCaching); + } + + [Fact] + public async Task Attribute_CreatesNamedPolicy() + { + var options = new OutputCacheOptions(); + options.AddPolicy("MyPolicy", b => b.Expire(TimeSpan.FromSeconds(42))); + + var context = TestUtils.CreateTestContext(options: options); + + var attribute = OutputCacheMethods.GetAttribute(nameof(OutputCacheMethods.PolicyName)); + await attribute.BuildPolicy().CacheRequestAsync(context, cancellation: default); + + Assert.True(context.EnableOutputCaching); + Assert.Equal(42, context.ResponseExpirationTimeSpan?.TotalSeconds); + } + + [Fact] + public async Task Attribute_CreatesVaryByHeaderPolicy() + { + var context = TestUtils.CreateUninitializedContext(); + context.HttpContext.Request.Headers["HeaderA"] = "ValueA"; + context.HttpContext.Request.Headers["HeaderB"] = "ValueB"; + + var attribute = OutputCacheMethods.GetAttribute(nameof(OutputCacheMethods.VaryByHeaderNames)); + await attribute.BuildPolicy().CacheRequestAsync(context, cancellation: default); + + Assert.True(context.EnableOutputCaching); + Assert.Contains("HeaderA", (IEnumerable)context.CacheVaryByRules.HeaderNames); + Assert.Contains("HeaderC", (IEnumerable)context.CacheVaryByRules.HeaderNames); + Assert.DoesNotContain("HeaderB", (IEnumerable)context.CacheVaryByRules.HeaderNames); + } + + [Fact] + public async Task Attribute_CreatesVaryByQueryPolicy() + { + var context = TestUtils.CreateUninitializedContext(); + context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryB=ValueB"); + + var attribute = OutputCacheMethods.GetAttribute(nameof(OutputCacheMethods.VaryByQueryKeys)); + await attribute.BuildPolicy().CacheRequestAsync(context, cancellation: default); + + Assert.True(context.EnableOutputCaching); + Assert.Contains("QueryA", (IEnumerable)context.CacheVaryByRules.QueryKeys); + Assert.Contains("QueryC", (IEnumerable)context.CacheVaryByRules.QueryKeys); + Assert.DoesNotContain("QueryB", (IEnumerable)context.CacheVaryByRules.QueryKeys); + } + + [Fact] + public async Task Attribute_CreatesVaryByRoutePolicy() + { + var context = TestUtils.CreateUninitializedContext(); + context.HttpContext.Request.RouteValues = new Routing.RouteValueDictionary() + { + ["RouteA"] = "ValueA", + ["RouteB"] = 123.456, + }; + + var attribute = OutputCacheMethods.GetAttribute(nameof(OutputCacheMethods.VaryByRouteValueNames)); + await attribute.BuildPolicy().CacheRequestAsync(context, cancellation: default); + + Assert.True(context.EnableOutputCaching); + Assert.Contains("RouteA", (IEnumerable)context.CacheVaryByRules.RouteValueNames); + Assert.Contains("RouteC", (IEnumerable)context.CacheVaryByRules.RouteValueNames); + Assert.DoesNotContain("RouteB", (IEnumerable)context.CacheVaryByRules.RouteValueNames); + } + + private class OutputCacheMethods + { + public static OutputCacheAttribute GetAttribute(string methodName) + { + return typeof(OutputCacheMethods).GetMethod(methodName, System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public).GetAttribute(); + } + + [OutputCache()] + public static void Default() { } + + [OutputCache(Duration = 42)] + public static void Duration() { } + + [OutputCache(NoStore = true)] + public static void NoStore() { } + + [OutputCache(PolicyName = "MyPolicy")] + public static void PolicyName() { } + + [OutputCache(VaryByHeaderNames = new[] { "HeaderA", "HeaderC" })] + public static void VaryByHeaderNames() { } + + [OutputCache(VaryByQueryKeys = new[] { "QueryA", "QueryC" })] + public static void VaryByQueryKeys() { } + + [OutputCache(VaryByRouteValueNames = new[] { "RouteA", "RouteC" })] + public static void VaryByRouteValueNames() { } + } +} diff --git a/src/Middleware/OutputCaching/test/OutputCacheKeyProviderTests.cs b/src/Middleware/OutputCaching/test/OutputCacheKeyProviderTests.cs index ddeb485d73a9..92809d018fa4 100644 --- a/src/Middleware/OutputCaching/test/OutputCacheKeyProviderTests.cs +++ b/src/Middleware/OutputCaching/test/OutputCacheKeyProviderTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using Microsoft.AspNetCore.Http; namespace Microsoft.AspNetCore.OutputCaching.Tests; @@ -56,7 +57,6 @@ public void OutputCachingKeyProvider_CreateStorageKey_CaseSensitivePath_Preserve [Fact] public void OutputCachingKeyProvider_CreateStorageKey_VaryByRulesIsotNull() { - var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); Assert.NotNull(context.CacheVaryByRules); @@ -67,14 +67,45 @@ public void OutputCachingKeyProvider_CreateStorageKey_ReturnsCachedVaryByGuid_If { var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); - context.CacheVaryByRules = new CacheVaryByRules() - { - VaryByPrefix = Guid.NewGuid().ToString("n"), - }; + context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}", cacheKeyProvider.CreateStorageKey(context)); } + [Fact] + public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesListedRouteValuesOnly() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.RouteValues["RouteA"] = "ValueA"; + context.HttpContext.Request.RouteValues["RouteB"] = "ValueB"; + context.CacheVaryByRules.RouteValueNames = new string[] { "RouteA", "RouteC" }; + + Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}R{KeyDelimiter}RouteA=ValueA{KeyDelimiter}RouteC=", + cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageVaryKey_SerializeRouteValueToStringInvariantCulture() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.RouteValues["RouteA"] = 123.456; + context.CacheVaryByRules.RouteValueNames = new string[] { "RouteA", "RouteC" }; + + var culture = Thread.CurrentThread.CurrentCulture; + try + { + Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}R{KeyDelimiter}RouteA=123.456{KeyDelimiter}RouteC=", + cacheKeyProvider.CreateStorageKey(context)); + } + finally + { + Thread.CurrentThread.CurrentCulture = culture; + } + } + [Fact] public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesListedHeadersOnly() { @@ -82,10 +113,7 @@ public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesListedHeadersO var context = TestUtils.CreateTestContext(); context.HttpContext.Request.Headers["HeaderA"] = "ValueA"; context.HttpContext.Request.Headers["HeaderB"] = "ValueB"; - context.CacheVaryByRules = new CacheVaryByRules() - { - Headers = new string[] { "HeaderA", "HeaderC" } - }; + context.CacheVaryByRules.HeaderNames = new string[] { "HeaderA", "HeaderC" }; Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=", cacheKeyProvider.CreateStorageKey(context)); @@ -98,10 +126,7 @@ public void OutputCachingKeyProvider_CreateStorageVaryKey_HeaderValuesAreSorted( var context = TestUtils.CreateTestContext(); context.HttpContext.Request.Headers["HeaderA"] = "ValueB"; context.HttpContext.Request.Headers.Append("HeaderA", "ValueA"); - context.CacheVaryByRules = new CacheVaryByRules() - { - Headers = new string[] { "HeaderA", "HeaderC" } - }; + context.CacheVaryByRules.HeaderNames = new string[] { "HeaderA", "HeaderC" }; Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueAValueB{KeyDelimiter}HeaderC=", cacheKeyProvider.CreateStorageKey(context)); @@ -113,11 +138,8 @@ public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesListedQueryKey var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryB=ValueB"); - context.CacheVaryByRules = new CacheVaryByRules() - { - VaryByPrefix = Guid.NewGuid().ToString("n"), - QueryKeys = new string[] { "QueryA", "QueryC" } - }; + context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.QueryKeys = new string[] { "QueryA", "QueryC" }; Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}{KeyDelimiter}Q{KeyDelimiter}QueryA=ValueA{KeyDelimiter}QueryC=", cacheKeyProvider.CreateStorageKey(context)); @@ -129,11 +151,8 @@ public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesQueryKeys_Quer var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); context.HttpContext.Request.QueryString = new QueryString("?queryA=ValueA&queryB=ValueB"); - context.CacheVaryByRules = new CacheVaryByRules() - { - VaryByPrefix = Guid.NewGuid().ToString("n"), - QueryKeys = new string[] { "QueryA", "QueryC" } - }; + context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.QueryKeys = new string[] { "QueryA", "QueryC" }; Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}{KeyDelimiter}Q{KeyDelimiter}QueryA=ValueA{KeyDelimiter}QueryC=", cacheKeyProvider.CreateStorageKey(context)); @@ -145,11 +164,8 @@ public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesAllQueryKeysGi var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryB=ValueB"); - context.CacheVaryByRules = new CacheVaryByRules() - { - VaryByPrefix = Guid.NewGuid().ToString("n"), - QueryKeys = new string[] { "*" } - }; + context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.QueryKeys = new string[] { "*" }; // To support case insensitivity, all query keys are converted to upper case. // Explicit query keys uses the casing specified in the setting. @@ -163,12 +179,9 @@ public void OutputCachingKeyProvider_CreateStorageVaryKey_QueryKeysValuesNotCons var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryA=ValueB"); - context.CacheVaryByRules = new CacheVaryByRules() - { - VaryByPrefix = Guid.NewGuid().ToString("n"), - QueryKeys = new string[] { "*" } - }; - + context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.QueryKeys = new string[] { "*" }; + // To support case insensitivity, all query keys are converted to upper case. // Explicit query keys uses the casing specified in the setting. Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}{KeyDelimiter}Q{KeyDelimiter}QUERYA=ValueA{KeySubDelimiter}ValueB", @@ -181,11 +194,8 @@ public void OutputCachingKeyProvider_CreateStorageVaryKey_QueryKeysValuesAreSort var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueB&QueryA=ValueA"); - context.CacheVaryByRules = new CacheVaryByRules() - { - VaryByPrefix = Guid.NewGuid().ToString("n"), - QueryKeys = new string[] { "*" } - }; + context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.QueryKeys = new string[] { "*" }; // To support case insensitivity, all query keys are converted to upper case. // Explicit query keys uses the casing specified in the setting. @@ -194,21 +204,21 @@ public void OutputCachingKeyProvider_CreateStorageVaryKey_QueryKeysValuesAreSort } [Fact] - public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesListedHeadersAndQueryKeys() + public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesListedHeadersAndQueryKeysAndRouteValues() { var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); context.HttpContext.Request.Headers["HeaderA"] = "ValueA"; context.HttpContext.Request.Headers["HeaderB"] = "ValueB"; context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryB=ValueB"); - context.CacheVaryByRules = new CacheVaryByRules() - { - VaryByPrefix = Guid.NewGuid().ToString("n"), - Headers = new string[] { "HeaderA", "HeaderC" }, - QueryKeys = new string[] { "QueryA", "QueryC" } - }; - - Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC={KeyDelimiter}Q{KeyDelimiter}QueryA=ValueA{KeyDelimiter}QueryC=", + context.HttpContext.Request.RouteValues["RouteA"] = "ValueA"; + context.HttpContext.Request.RouteValues["RouteB"] = "ValueB"; + context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.HeaderNames = new string[] { "HeaderA", "HeaderC" }; + context.CacheVaryByRules.QueryKeys = new string[] { "QueryA", "QueryC" }; + context.CacheVaryByRules.RouteValueNames = new string[] { "RouteA", "RouteC" }; + + Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC={KeyDelimiter}Q{KeyDelimiter}QueryA=ValueA{KeyDelimiter}QueryC={KeyDelimiter}R{KeyDelimiter}RouteA=ValueA{KeyDelimiter}RouteC=", cacheKeyProvider.CreateStorageKey(context)); } } diff --git a/src/Middleware/OutputCaching/test/OutputCacheMiddlewareTests.cs b/src/Middleware/OutputCaching/test/OutputCacheMiddlewareTests.cs index 690031b2a78e..550e2077cb29 100644 --- a/src/Middleware/OutputCaching/test/OutputCacheMiddlewareTests.cs +++ b/src/Middleware/OutputCaching/test/OutputCacheMiddlewareTests.cs @@ -1,6 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.Metrics; +using System.Threading.Tasks; +using System; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.OutputCaching.Memory; @@ -19,7 +22,7 @@ public async Task TryServeFromCacheAsync_OnlyIfCached_Serves504() var cache = new TestOutputCache(); var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey")); - var context = TestUtils.CreateTestContext(cache); + var context = TestUtils.CreateTestContext(cache: cache); context.HttpContext.Request.Headers.CacheControl = new CacheControlHeaderValue() { OnlyIfCached = true @@ -39,7 +42,7 @@ public async Task TryServeFromCacheAsync_CachedResponseNotFound_Fails() var cache = new TestOutputCache(); var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey")); - var context = TestUtils.CreateTestContext(cache); + var context = TestUtils.CreateTestContext(cache: cache); middleware.TryGetRequestPolicies(context.HttpContext, out var policies); Assert.False(await middleware.TryServeFromCacheAsync(context, policies)); @@ -55,7 +58,7 @@ public async Task TryServeFromCacheAsync_CachedResponseFound_Succeeds() var cache = new TestOutputCache(); var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey")); - var context = TestUtils.CreateTestContext(cache); + var context = TestUtils.CreateTestContext(cache: cache); middleware.TryGetRequestPolicies(context.HttpContext, out var policies); await OutputCacheEntryFormatter.StoreAsync( @@ -82,7 +85,7 @@ public async Task TryServeFromCacheAsync_CachedResponseFound_OverwritesExistingH var cache = new TestOutputCache(); var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey")); - var context = TestUtils.CreateTestContext(cache); + var context = TestUtils.CreateTestContext(cache: cache); middleware.TryGetRequestPolicies(context.HttpContext, out var policies); context.CacheKey = "BaseKey"; @@ -114,7 +117,7 @@ public async Task TryServeFromCacheAsync_CachedResponseFound_Serves304IfPossible var cache = new TestOutputCache(); var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache, keyProvider: new TestResponseCachingKeyProvider("BaseKey")); - var context = TestUtils.CreateTestContext(cache); + var context = TestUtils.CreateTestContext(cache: cache); context.HttpContext.Request.Headers.IfNoneMatch = "*"; middleware.TryGetRequestPolicies(context.HttpContext, out var policies); @@ -132,17 +135,20 @@ await OutputCacheEntryFormatter.StoreAsync("BaseKey", Assert.Equal(1, cache.GetCount); TestUtils.AssertLoggedMessages( sink.Writes, + LoggedMessage.NotModifiedIfNoneMatchStar, LoggedMessage.NotModifiedServed); } [Fact] public void ContentIsNotModified_NotConditionalRequest_False() { + var cache = new TestOutputCache(); var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); + var context = TestUtils.CreateTestContext(testSink: sink); context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; - Assert.False(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.False(middleware.ContentIsNotModified(context)); Assert.Empty(sink.Writes); } @@ -151,24 +157,25 @@ public void ContentIsNotModified_IfModifiedSince_FallsbackToDateHeader() { var utcNow = DateTimeOffset.UtcNow; var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink); context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; context.HttpContext.Request.Headers.IfModifiedSince = HeaderUtilities.FormatDate(utcNow); // Verify modifications in the past succeeds context.CachedResponse.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10)); - Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.True(middleware.ContentIsNotModified(context)); Assert.Single(sink.Writes); // Verify modifications at present succeeds context.CachedResponse.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow); - Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.True(middleware.ContentIsNotModified(context)); Assert.Equal(2, sink.Writes.Count); // Verify modifications in the future fails context.CachedResponse.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10)); - Assert.False(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.False(middleware.ContentIsNotModified(context)); // Verify logging TestUtils.AssertLoggedMessages( @@ -182,7 +189,8 @@ public void ContentIsNotModified_IfModifiedSince_LastModifiedOverridesDateHeader { var utcNow = DateTimeOffset.UtcNow; var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; context.HttpContext.Request.Headers.IfModifiedSince = HeaderUtilities.FormatDate(utcNow); @@ -190,19 +198,19 @@ public void ContentIsNotModified_IfModifiedSince_LastModifiedOverridesDateHeader // Verify modifications in the past succeeds context.CachedResponse.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10)); context.CachedResponse.Headers[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10)); - Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.True(middleware.ContentIsNotModified(context)); Assert.Single(sink.Writes); // Verify modifications at present context.CachedResponse.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10)); context.CachedResponse.Headers[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow); - Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.True(middleware.ContentIsNotModified(context)); Assert.Equal(2, sink.Writes.Count); // Verify modifications in the future fails context.CachedResponse.Headers[HeaderNames.Date] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10)); context.CachedResponse.Headers[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10)); - Assert.False(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.False(middleware.ContentIsNotModified(context)); // Verify logging TestUtils.AssertLoggedMessages( @@ -216,7 +224,8 @@ public void ContentIsNotModified_IfNoneMatch_Overrides_IfModifiedSince_ToTrue() { var utcNow = DateTimeOffset.UtcNow; var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; // This would fail the IfModifiedSince checks @@ -224,7 +233,7 @@ public void ContentIsNotModified_IfNoneMatch_Overrides_IfModifiedSince_ToTrue() context.CachedResponse.Headers[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow + TimeSpan.FromSeconds(10)); context.HttpContext.Request.Headers.IfNoneMatch = EntityTagHeaderValue.Any.ToString(); - Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.True(middleware.ContentIsNotModified(context)); TestUtils.AssertLoggedMessages( sink.Writes, LoggedMessage.NotModifiedIfNoneMatchStar); @@ -235,7 +244,8 @@ public void ContentIsNotModified_IfNoneMatch_Overrides_IfModifiedSince_ToFalse() { var utcNow = DateTimeOffset.UtcNow; var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; // This would pass the IfModifiedSince checks @@ -243,7 +253,7 @@ public void ContentIsNotModified_IfNoneMatch_Overrides_IfModifiedSince_ToFalse() context.CachedResponse.Headers[HeaderNames.LastModified] = HeaderUtilities.FormatDate(utcNow - TimeSpan.FromSeconds(10)); context.HttpContext.Request.Headers.IfNoneMatch = "\"E1\""; - Assert.False(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.False(middleware.ContentIsNotModified(context)); Assert.Empty(sink.Writes); } @@ -251,11 +261,12 @@ public void ContentIsNotModified_IfNoneMatch_Overrides_IfModifiedSince_ToFalse() public void ContentIsNotModified_IfNoneMatch_AnyWithoutETagInResponse_False() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; context.HttpContext.Request.Headers.IfNoneMatch = "\"E1\""; - Assert.False(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.False(middleware.ContentIsNotModified(context)); Assert.Empty(sink.Writes); } @@ -278,12 +289,13 @@ public static TheoryData EquivalentW public void ContentIsNotModified_IfNoneMatch_ExplicitWithMatch_True(EntityTagHeaderValue responseETag, EntityTagHeaderValue requestETag) { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; context.CachedResponse.Headers[HeaderNames.ETag] = responseETag.ToString(); context.HttpContext.Request.Headers.IfNoneMatch = requestETag.ToString(); - Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.True(middleware.ContentIsNotModified(context)); TestUtils.AssertLoggedMessages( sink.Writes, LoggedMessage.NotModifiedIfNoneMatchMatched); @@ -293,12 +305,13 @@ public void ContentIsNotModified_IfNoneMatch_ExplicitWithMatch_True(EntityTagHea public void ContentIsNotModified_IfNoneMatch_ExplicitWithoutMatch_False() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; context.CachedResponse.Headers[HeaderNames.ETag] = "\"E2\""; context.HttpContext.Request.Headers.IfNoneMatch = "\"E1\""; - Assert.False(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.False(middleware.ContentIsNotModified(context)); Assert.Empty(sink.Writes); } @@ -306,12 +319,13 @@ public void ContentIsNotModified_IfNoneMatch_ExplicitWithoutMatch_False() public void ContentIsNotModified_IfNoneMatch_MatchesAtLeastOneValue_True() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var middleware = TestUtils.CreateTestMiddleware(testSink: sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.CachedResponse = new OutputCacheEntry { Headers = new HeaderDictionary() }; context.CachedResponse.Headers[HeaderNames.ETag] = "\"E2\""; context.HttpContext.Request.Headers.IfNoneMatch = new string[] { "\"E0\", \"E1\"", "\"E1\", \"E2\"" }; - Assert.True(OutputCacheMiddleware.ContentIsNotModified(context)); + Assert.True(middleware.ContentIsNotModified(context)); TestUtils.AssertLoggedMessages( sink.Writes, LoggedMessage.NotModifiedIfNoneMatchMatched); @@ -485,7 +499,7 @@ public void FinalizeCacheHeadersAsync_UpdateCachedVaryByRules_NullOrEmptyRules(S var cache = new TestOutputCache(); var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); - var context = TestUtils.CreateTestContext(cache); + var context = TestUtils.CreateTestContext(cache: cache); context.HttpContext.Response.Headers.Vary = vary; context.HttpContext.Features.Set(new OutputCacheFeature(context)); @@ -573,7 +587,7 @@ public async Task FinalizeCacheBody_Cache_IfContentLengthMatches() var cache = new TestOutputCache(); var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); - var context = TestUtils.CreateTestContext(cache); + var context = TestUtils.CreateTestContext(cache: cache); middleware.ShimResponseStream(context); context.HttpContext.Response.ContentLength = 20; @@ -600,7 +614,7 @@ public async Task FinalizeCacheBody_DoNotCache_IfContentLengthMismatches(string var cache = new TestOutputCache(); var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); - var context = TestUtils.CreateTestContext(cache); + var context = TestUtils.CreateTestContext(cache: cache); middleware.ShimResponseStream(context); context.HttpContext.Response.ContentLength = 9; @@ -628,7 +642,7 @@ public async Task FinalizeCacheBody_RequestHead_Cache_IfContentLengthPresent_And var cache = new TestOutputCache(); var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); - var context = TestUtils.CreateTestContext(cache); + var context = TestUtils.CreateTestContext(cache: cache); middleware.ShimResponseStream(context); context.HttpContext.Response.ContentLength = 10; @@ -658,7 +672,7 @@ public async Task FinalizeCacheBody_Cache_IfContentLengthAbsent() var cache = new TestOutputCache(); var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); - var context = TestUtils.CreateTestContext(cache); + var context = TestUtils.CreateTestContext(cache: cache); middleware.ShimResponseStream(context); @@ -682,7 +696,7 @@ public async Task FinalizeCacheBody_DoNotCache_IfIsResponseCacheableFalse() var cache = new TestOutputCache(); var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); - var context = TestUtils.CreateTestContext(cache); + var context = TestUtils.CreateTestContext(cache: cache); middleware.ShimResponseStream(context); await context.HttpContext.Response.WriteAsync(new string('0', 10)); @@ -703,7 +717,7 @@ public async Task FinalizeCacheBody_DoNotCache_IfBufferingDisabled() var cache = new TestOutputCache(); var sink = new TestSink(); var middleware = TestUtils.CreateTestMiddleware(testSink: sink, cache: cache); - var context = TestUtils.CreateTestContext(cache); + var context = TestUtils.CreateTestContext(cache: cache); middleware.ShimResponseStream(context); await context.HttpContext.Response.WriteAsync(new string('0', 10)); @@ -799,4 +813,99 @@ public void GetOrderCasingNormalizedStringValues_PreservesCommas() Assert.Equal(originalStrings, normalizedStrings); } + + [Fact] + public async Task Locking_PreventsConcurrentRequests() + { + var responseCounter = 0; + + var task1Executing = new ManualResetEventSlim(false); + var task2Executing = new ManualResetEventSlim(false); + + var options = new OutputCacheOptions(); + options.AddBasePolicy(build => build.Cache()); + + var middleware = TestUtils.CreateTestMiddleware(options: options, next: async c => + { + responseCounter++; + task1Executing.Set(); + + // Wait for the second request to start before processing the first one + task2Executing.Wait(); + + // Simluate some delay to allow for the second request to run while this one is pending + await Task.Delay(500); + + c.Response.Write("Hello" + responseCounter); + }); + + var context1 = TestUtils.CreateTestContext(); + context1.HttpContext.Request.Method = "GET"; + context1.HttpContext.Request.Path = "/"; + + var context2 = TestUtils.CreateTestContext(); + context2.HttpContext.Request.Method = "GET"; + context2.HttpContext.Request.Path = "/"; + + var task1 = Task.Run(() => middleware.Invoke(context1.HttpContext)); + + // Wait for the first request to be processed before sending a second one + task1Executing.Wait(); + + var task2 = Task.Run(() => middleware.Invoke(context2.HttpContext)); + + task2Executing.Set(); + + await Task.WhenAll(task1, task2); + + Assert.Equal(1, responseCounter); + } + + [Fact] + public async Task Locking_ExecuteAllRequestsWhenDisabled() + { + var responseCounter = 0; + + var task1Executing = new ManualResetEventSlim(false); + var task2Executing = new ManualResetEventSlim(false); + + var options = new OutputCacheOptions(); + options.AddBasePolicy(build => build.Cache().AllowLocking(false)); + + var middleware = TestUtils.CreateTestMiddleware(options: options, next: c => + { + responseCounter++; + + switch (responseCounter) + { + case 1: + task1Executing.Set(); + task2Executing.Wait(); + break; + case 2: + task1Executing.Wait(); + task2Executing.Set(); + break; + } + + c.Response.Write("Hello" + responseCounter); + return Task.CompletedTask; + }); + + var context1 = TestUtils.CreateTestContext(); + context1.HttpContext.Request.Method = "GET"; + context1.HttpContext.Request.Path = "/"; + + var context2 = TestUtils.CreateTestContext(); + context2.HttpContext.Request.Method = "GET"; + context2.HttpContext.Request.Path = "/"; + + var task1 = Task.Run(() => middleware.Invoke(context1.HttpContext)); + + var task2 = Task.Run(() => middleware.Invoke(context2.HttpContext)); + + await Task.WhenAll(task1, task2); + + Assert.Equal(2, responseCounter); + } } diff --git a/src/Middleware/OutputCaching/test/OutputCachePoliciesTests.cs b/src/Middleware/OutputCaching/test/OutputCachePoliciesTests.cs index 53acf44c2acc..a33e4f103a52 100644 --- a/src/Middleware/OutputCaching/test/OutputCachePoliciesTests.cs +++ b/src/Middleware/OutputCaching/test/OutputCachePoliciesTests.cs @@ -126,9 +126,10 @@ public async Task PredicatePolicy_Filters(bool filter, bool enabled, bool expect [Fact] public async Task ProfilePolicy_UsesNamedProfile() { - var context = TestUtils.CreateUninitializedContext(); - context.Options.AddPolicy("enabled", EnableCachePolicy.Enabled); - context.Options.AddPolicy("disabled", EnableCachePolicy.Disabled); + var options = new OutputCacheOptions(); + options.AddPolicy("enabled", EnableCachePolicy.Enabled); + options.AddPolicy("disabled", EnableCachePolicy.Disabled); + var context = TestUtils.CreateUninitializedContext(options: options); IOutputCachePolicy policy = new NamedPolicy("enabled"); @@ -165,7 +166,7 @@ public async Task VaryByHeadersPolicy_IsEmpty() await policy.CacheRequestAsync(context, default); - Assert.Empty(context.CacheVaryByRules.Headers); + Assert.Empty(context.CacheVaryByRules.HeaderNames); } [Fact] @@ -178,7 +179,7 @@ public async Task VaryByHeadersPolicy_AddsSingleHeader() await policy.CacheRequestAsync(context, default); - Assert.Equal(header, context.CacheVaryByRules.Headers); + Assert.Equal(header, context.CacheVaryByRules.HeaderNames); } [Fact] @@ -191,7 +192,7 @@ public async Task VaryByHeadersPolicy_AddsMultipleHeaders() await policy.CacheRequestAsync(context, default); - Assert.Equal(headers, context.CacheVaryByRules.Headers); + Assert.Equal(headers, context.CacheVaryByRules.HeaderNames); } [Fact] diff --git a/src/Middleware/OutputCaching/test/OutputCachePolicyBuilderTests.cs b/src/Middleware/OutputCaching/test/OutputCachePolicyBuilderTests.cs new file mode 100644 index 000000000000..6fbe8e193fe7 --- /dev/null +++ b/src/Middleware/OutputCaching/test/OutputCachePolicyBuilderTests.cs @@ -0,0 +1,276 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.OutputCaching.Tests; + +public class OutputCachePolicyBuilderTests +{ + [Fact] + public void BuildPolicy_CreatesDefaultPolicy() + { + var builder = new OutputCachePolicyBuilder(); + var policy = builder.Build(); + + Assert.Equal(DefaultPolicy.Instance, policy); + } + + [Fact] + public async Task BuildPolicy_CreatesExpirePolicy() + { + var context = TestUtils.CreateUninitializedContext(); + var duration = 42; + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.Expire(TimeSpan.FromSeconds(duration)).Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.True(context.EnableOutputCaching); + Assert.Equal(duration, context.ResponseExpirationTimeSpan?.TotalSeconds); + } + + [Fact] + public async Task BuildPolicy_CreatesNoStorePolicy() + { + var context = TestUtils.CreateUninitializedContext(); + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.NoCache().Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.False(context.EnableOutputCaching); + } + + [Fact] + public async Task BuildPolicy_AddsCustomPolicy() + { + var options = new OutputCacheOptions(); + var name = "MyPolicy"; + var duration = 42; + options.AddPolicy(name, b => b.Expire(TimeSpan.FromSeconds(duration))); + + var context = TestUtils.CreateUninitializedContext(options: options); + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.AddPolicy(new NamedPolicy(name)).Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.True(context.EnableOutputCaching); + Assert.Equal(duration, context.ResponseExpirationTimeSpan?.TotalSeconds); + } + + [Fact] + public async Task BuildPolicy_CreatesVaryByHeaderPolicy() + { + var context = TestUtils.CreateUninitializedContext(); + context.HttpContext.Request.Headers["HeaderA"] = "ValueA"; + context.HttpContext.Request.Headers["HeaderB"] = "ValueB"; + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.VaryByHeader("HeaderA", "HeaderC").Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.True(context.EnableOutputCaching); + Assert.Contains("HeaderA", (IEnumerable)context.CacheVaryByRules.HeaderNames); + Assert.Contains("HeaderC", (IEnumerable)context.CacheVaryByRules.HeaderNames); + Assert.DoesNotContain("HeaderB", (IEnumerable)context.CacheVaryByRules.HeaderNames); + } + + [Fact] + public async Task BuildPolicy_CreatesVaryByQueryPolicy() + { + var context = TestUtils.CreateUninitializedContext(); + context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryB=ValueB"); + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.VaryByQuery("QueryA", "QueryC").Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.True(context.EnableOutputCaching); + Assert.Contains("QueryA", (IEnumerable)context.CacheVaryByRules.QueryKeys); + Assert.Contains("QueryC", (IEnumerable)context.CacheVaryByRules.QueryKeys); + Assert.DoesNotContain("QueryB", (IEnumerable)context.CacheVaryByRules.QueryKeys); + } + + [Fact] + public async Task BuildPolicy_CreatesVaryByRoutePolicy() + { + var context = TestUtils.CreateUninitializedContext(); + context.HttpContext.Request.RouteValues = new Routing.RouteValueDictionary() + { + ["RouteA"] = "ValueA", + ["RouteB"] = 123.456, + }; + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.VaryByRouteValue("RouteA", "RouteC").Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.True(context.EnableOutputCaching); + Assert.Contains("RouteA", (IEnumerable)context.CacheVaryByRules.RouteValueNames); + Assert.Contains("RouteC", (IEnumerable)context.CacheVaryByRules.RouteValueNames); + Assert.DoesNotContain("RouteB", (IEnumerable)context.CacheVaryByRules.RouteValueNames); + } + + [Fact] + public async Task BuildPolicy_CreatesVaryByValuePolicy() + { + var context = TestUtils.CreateUninitializedContext(); + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.VaryByValue(context => new KeyValuePair("color", "blue")).Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.True(context.EnableOutputCaching); + Assert.Equal("blue", context.CacheVaryByRules.VaryByCustom["color"]); + } + + [Fact] + public async Task BuildPolicy_CreatesTagPolicy() + { + var context = TestUtils.CreateUninitializedContext(); + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.Tag("tag1", "tag2").Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.True(context.EnableOutputCaching); + Assert.Contains("tag1", context.Tags); + Assert.Contains("tag2", context.Tags); + } + + [Fact] + public async Task BuildPolicy_AllowsLocking() + { + var context = TestUtils.CreateUninitializedContext(); + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.True(context.AllowLocking); + } + + [Fact] + public async Task BuildPolicy_EnablesLocking() + { + var context = TestUtils.CreateUninitializedContext(); + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.AllowLocking(true).Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.True(context.AllowLocking); + } + + [Fact] + public async Task BuildPolicy_DisablesLocking() + { + var context = TestUtils.CreateUninitializedContext(); + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.AllowLocking(false).Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.False(context.AllowLocking); + } + + [Fact] + public async Task BuildPolicy_ClearsDefaultPolicy() + { + var context = TestUtils.CreateUninitializedContext(); + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.Clear().Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.False(context.AllowLocking); + Assert.False(context.AllowCacheLookup); + Assert.False(context.AllowCacheStorage); + Assert.False(context.EnableOutputCaching); + } + + [Fact] + public async Task BuildPolicy_DisablesCache() + { + var context = TestUtils.CreateUninitializedContext(); + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.NoCache().Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.False(context.EnableOutputCaching); + } + + [Fact] + public async Task BuildPolicy_EnablesCache() + { + var context = TestUtils.CreateUninitializedContext(); + + var builder = new OutputCachePolicyBuilder(); + var policy = builder.NoCache().Cache().Build(); + await policy.CacheRequestAsync(context, cancellation: default); + + Assert.True(context.EnableOutputCaching); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(1, 2)] + [InlineData(2, 3)] + public async Task BuildPolicy_ChecksWithPredicate(int source, int expected) + { + // Each predicate should override the duration from the first base policy + var options = new OutputCacheOptions(); + options.AddBasePolicy(build => build.Expire(TimeSpan.FromSeconds(1))); + options.AddBasePolicy(build => build.With(c => source == 1).Expire(TimeSpan.FromSeconds(2))); + options.AddBasePolicy(build => build.With(c => source == 2).Expire(TimeSpan.FromSeconds(3))); + + var context = TestUtils.CreateUninitializedContext(options: options); + + foreach (var policy in options.BasePolicies) + { + await policy.CacheRequestAsync(context, default); + } + + Assert.True(context.EnableOutputCaching); + Assert.Equal(expected, context.ResponseExpirationTimeSpan?.TotalSeconds); + } + + [Fact] + public async Task BuildPolicy_NoDefaultWithFalsePredicate() + { + // Each predicate should override the duration from the first base policy + var options = new OutputCacheOptions(); + options.AddBasePolicy(build => build.With(c => false).Expire(TimeSpan.FromSeconds(2))); + + var context = TestUtils.CreateUninitializedContext(options: options); + + foreach (var policy in options.BasePolicies) + { + await policy.CacheRequestAsync(context, default); + } + + Assert.False(context.EnableOutputCaching); + Assert.NotEqual(2, context.ResponseExpirationTimeSpan?.TotalSeconds); + } + + [Fact] + public async Task BuildPolicy_CacheReturnsDefault() + { + // Each predicate should override the duration from the first base policy + var options = new OutputCacheOptions(); + options.AddBasePolicy(build => build.Cache()); + + var context = TestUtils.CreateUninitializedContext(options: options); + + foreach (var policy in options.BasePolicies) + { + await policy.CacheRequestAsync(context, default); + } + + Assert.True(context.EnableOutputCaching); + } +} diff --git a/src/Middleware/OutputCaching/test/OutputCachePolicyProviderTests.cs b/src/Middleware/OutputCaching/test/OutputCachePolicyProviderTests.cs index 6238b09791aa..ce93251c1869 100644 --- a/src/Middleware/OutputCaching/test/OutputCachePolicyProviderTests.cs +++ b/src/Middleware/OutputCaching/test/OutputCachePolicyProviderTests.cs @@ -46,7 +46,7 @@ public static TheoryData NonCacheableMethods public async Task AttemptOutputCaching_CacheableMethods_IsAllowed(string method) { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); var policies = new[] { new OutputCachePolicyBuilder().Build() }; context.HttpContext.Request.Method = method; @@ -65,7 +65,7 @@ public async Task AttemptOutputCaching_CacheableMethods_IsAllowed(string method) public async Task AttemptOutputCaching_UncacheableMethods_NotAllowed(string method) { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); var policy = new OutputCachePolicyBuilder().Build(); context.HttpContext.Request.Method = method; @@ -73,16 +73,13 @@ public async Task AttemptOutputCaching_UncacheableMethods_NotAllowed(string meth Assert.False(context.AllowCacheLookup); Assert.False(context.AllowCacheStorage); - TestUtils.AssertLoggedMessages( - sink.Writes, - LoggedMessage.RequestMethodNotCacheable); } [Fact] public async Task AttemptResponseCaching_AuthorizationHeaders_NotAllowed() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Request.Method = HttpMethods.Get; context.HttpContext.Request.Headers.Authorization = "Placeholder"; @@ -92,17 +89,13 @@ public async Task AttemptResponseCaching_AuthorizationHeaders_NotAllowed() Assert.False(context.AllowCacheStorage); Assert.False(context.AllowCacheLookup); - - TestUtils.AssertLoggedMessages( - sink.Writes, - LoggedMessage.RequestWithAuthorizationNotCacheable); } [Fact] public async Task AllowCacheStorage_NoStore_IsAllowed() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Request.Method = HttpMethods.Get; context.HttpContext.Request.Headers.CacheControl = new CacheControlHeaderValue() { @@ -120,7 +113,7 @@ public async Task AllowCacheStorage_NoStore_IsAllowed() public async Task AllowCacheLookup_LegacyDirectives_OverridenByCacheControl() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Request.Method = HttpMethods.Get; context.HttpContext.Request.Headers.Pragma = "no-cache"; context.HttpContext.Request.Headers.CacheControl = "max-age=10"; @@ -136,7 +129,7 @@ public async Task AllowCacheLookup_LegacyDirectives_OverridenByCacheControl() public async Task IsResponseCacheable_NoPublic_IsAllowed() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); var policy = new OutputCachePolicyBuilder().Build(); await policy.ServeResponseAsync(context, default); @@ -150,7 +143,7 @@ public async Task IsResponseCacheable_NoPublic_IsAllowed() public async Task IsResponseCacheable_Public_IsAllowed() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() { Public = true @@ -168,7 +161,7 @@ public async Task IsResponseCacheable_Public_IsAllowed() public async Task IsResponseCacheable_NoCache_IsAllowed() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() { NoCache = true @@ -186,7 +179,7 @@ public async Task IsResponseCacheable_NoCache_IsAllowed() public async Task IsResponseCacheable_ResponseNoStore_IsAllowed() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() { NoStore = true @@ -204,7 +197,7 @@ public async Task IsResponseCacheable_ResponseNoStore_IsAllowed() public async Task IsResponseCacheable_SetCookieHeader_NotAllowed() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Response.Headers.SetCookie = "cookieName=cookieValue"; var policy = new OutputCachePolicyBuilder().Build(); @@ -212,16 +205,13 @@ public async Task IsResponseCacheable_SetCookieHeader_NotAllowed() Assert.False(context.AllowCacheStorage); Assert.True(context.AllowCacheLookup); - TestUtils.AssertLoggedMessages( - sink.Writes, - LoggedMessage.ResponseWithSetCookieNotCacheable); } [Fact] public async Task IsResponseCacheable_VaryHeaderByStar_IsAllowed() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Response.Headers.Vary = "*"; var policy = new OutputCachePolicyBuilder().Build(); await policy.ServeResponseAsync(context, default); @@ -235,7 +225,7 @@ public async Task IsResponseCacheable_VaryHeaderByStar_IsAllowed() public async Task IsResponseCacheable_Private_IsAllowed() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() { Private = true @@ -254,7 +244,7 @@ public async Task IsResponseCacheable_Private_IsAllowed() public async Task IsResponseCacheable_SuccessStatusCodes_IsAllowed(int statusCode) { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Response.StatusCode = statusCode; var policy = new OutputCachePolicyBuilder().Build(); @@ -330,7 +320,7 @@ public async Task IsResponseCacheable_SuccessStatusCodes_IsAllowed(int statusCod public async Task IsResponseCacheable_NonSuccessStatusCodes_NotAllowed(int statusCode) { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Response.StatusCode = statusCode; var policy = new OutputCachePolicyBuilder().Build(); @@ -338,16 +328,13 @@ public async Task IsResponseCacheable_NonSuccessStatusCodes_NotAllowed(int statu Assert.True(context.AllowCacheLookup); Assert.False(context.AllowCacheStorage); - TestUtils.AssertLoggedMessages( - sink.Writes, - LoggedMessage.ResponseWithUnsuccessfulStatusCodeNotCacheable); } [Fact] public async Task IsResponseCacheable_NoExpiryRequirements_IsAllowed() { var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; var utcNow = DateTimeOffset.UtcNow; @@ -367,7 +354,7 @@ public async Task IsResponseCacheable_MaxAgeOverridesExpiry_IsAllowed() { var utcNow = DateTimeOffset.UtcNow; var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() { @@ -390,7 +377,7 @@ public async Task IsResponseCacheable_SharedMaxAgeOverridesMaxAge_IsAllowed() { var utcNow = DateTimeOffset.UtcNow; var sink = new TestSink(); - var context = TestUtils.CreateTestContext(sink); + var context = TestUtils.CreateTestContext(testSink: sink); context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue() { diff --git a/src/Middleware/OutputCaching/test/TestUtils.cs b/src/Middleware/OutputCaching/test/TestUtils.cs index 33ac6f56c869..f8fe9fce581a 100644 --- a/src/Middleware/OutputCaching/test/TestUtils.cs +++ b/src/Middleware/OutputCaching/test/TestUtils.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #nullable enable +using System; using System.Net.Http; using System.Text; using Microsoft.AspNetCore.Builder; @@ -16,6 +17,7 @@ using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; +using Moq; namespace Microsoft.AspNetCore.OutputCaching.Tests; @@ -194,37 +196,24 @@ internal static OutputCacheMiddleware CreateTestMiddleware( return new OutputCacheMiddleware( next, Options.Create(options), - testSink == null ? (ILoggerFactory)NullLoggerFactory.Instance : new TestLoggerFactory(testSink, true), + testSink == null ? NullLoggerFactory.Instance : new TestLoggerFactory(testSink, true), cache, keyProvider); } - internal static OutputCacheContext CreateTestContext(IOutputCacheStore? cache = null, OutputCacheOptions? options = null) + internal static OutputCacheContext CreateTestContext(HttpContext? httpContext = null, IOutputCacheStore? cache = null, OutputCacheOptions? options = null, ITestSink? testSink = null) { - return new OutputCacheContext(new DefaultHttpContext(), cache ?? new TestOutputCache(), options ?? Options.Create(new OutputCacheOptions()).Value, NullLogger.Instance) - { - EnableOutputCaching = true, - AllowCacheStorage = true, - AllowCacheLookup = true, - ResponseTime = DateTimeOffset.UtcNow - }; - } + var serviceProvider = new Mock(); + serviceProvider.Setup(x => x.GetService(typeof(IOutputCacheStore))).Returns(cache ?? new TestOutputCache()); + serviceProvider.Setup(x => x.GetService(typeof(IOptions))).Returns(Options.Create(options ?? new OutputCacheOptions())); + serviceProvider.Setup(x => x.GetService(typeof(ILogger))).Returns(testSink == null ? NullLogger.Instance : new TestLogger("OutputCachingTests", testSink, true)); - internal static OutputCacheContext CreateTestContext(HttpContext httpContext, IOutputCacheStore? cache = null, OutputCacheOptions? options = null) - { - return new OutputCacheContext(httpContext, cache ?? new TestOutputCache(), options ?? Options.Create(new OutputCacheOptions()).Value, NullLogger.Instance) - { - EnableOutputCaching = true, - AllowCacheStorage = true, - AllowCacheLookup = true, - ResponseTime = DateTimeOffset.UtcNow - }; - } + httpContext ??= new DefaultHttpContext(); + httpContext.RequestServices = serviceProvider.Object; - internal static OutputCacheContext CreateTestContext(ITestSink testSink, IOutputCacheStore? cache = null, OutputCacheOptions? options = null) - { - return new OutputCacheContext(new DefaultHttpContext(), cache ?? new TestOutputCache(), options ?? Options.Create(new OutputCacheOptions()).Value, new TestLogger("OutputCachingTests", testSink, true)) + return new OutputCacheContext() { + HttpContext = httpContext, EnableOutputCaching = true, AllowCacheStorage = true, AllowCacheLookup = true, @@ -232,13 +221,21 @@ internal static OutputCacheContext CreateTestContext(ITestSink testSink, IOutput }; } - internal static OutputCacheContext CreateUninitializedContext(IOutputCacheStore? cache = null, OutputCacheOptions? options = null) + internal static OutputCacheContext CreateUninitializedContext(HttpContext? httpContext = null, IOutputCacheStore? cache = null, OutputCacheOptions? options = null, ITestSink? testSink = null) { - return new OutputCacheContext(new DefaultHttpContext(), cache ?? new TestOutputCache(), options ?? Options.Create(new OutputCacheOptions()).Value, NullLogger.Instance) + var serviceProvider = new Mock(); + serviceProvider.Setup(x => x.GetService(typeof(IOutputCacheStore))).Returns(cache ?? new TestOutputCache()); + serviceProvider.Setup(x => x.GetService(typeof(IOptions))).Returns(Options.Create(options ?? new OutputCacheOptions())); + serviceProvider.Setup(x => x.GetService(typeof(ILogger))).Returns(testSink == null ? NullLogger.Instance : new TestLogger("OutputCachingTests", testSink, true)); + + httpContext ??= new DefaultHttpContext(); + httpContext.RequestServices = serviceProvider.Object; + + return new OutputCacheContext() { + HttpContext = httpContext, }; } - internal static void AssertLoggedMessages(IEnumerable messages, params LoggedMessage[] expectedMessages) { var messageList = messages.ToList(); @@ -271,22 +268,18 @@ internal static void Write(this HttpResponse response, string text) internal class LoggedMessage { - internal static LoggedMessage RequestMethodNotCacheable => new LoggedMessage(1, LogLevel.Debug); - internal static LoggedMessage RequestWithAuthorizationNotCacheable => new LoggedMessage(2, LogLevel.Debug); - internal static LoggedMessage ResponseWithSetCookieNotCacheable => new LoggedMessage(3, LogLevel.Debug); - internal static LoggedMessage ResponseWithUnsuccessfulStatusCodeNotCacheable => new LoggedMessage(4, LogLevel.Debug); - internal static LoggedMessage NotModifiedIfNoneMatchStar => new LoggedMessage(5, LogLevel.Debug); - internal static LoggedMessage NotModifiedIfNoneMatchMatched => new LoggedMessage(6, LogLevel.Debug); - internal static LoggedMessage NotModifiedIfModifiedSinceSatisfied => new LoggedMessage(7, LogLevel.Debug); - internal static LoggedMessage NotModifiedServed => new LoggedMessage(8, LogLevel.Information); - internal static LoggedMessage CachedResponseServed => new LoggedMessage(9, LogLevel.Information); - internal static LoggedMessage GatewayTimeoutServed => new LoggedMessage(10, LogLevel.Information); - internal static LoggedMessage NoResponseServed => new LoggedMessage(11, LogLevel.Information); - internal static LoggedMessage VaryByRulesUpdated => new LoggedMessage(12, LogLevel.Debug); - internal static LoggedMessage ResponseCached => new LoggedMessage(13, LogLevel.Information); - internal static LoggedMessage ResponseNotCached => new LoggedMessage(14, LogLevel.Information); - internal static LoggedMessage ResponseContentLengthMismatchNotCached => new LoggedMessage(15, LogLevel.Warning); - internal static LoggedMessage ExpirationExpiresExceeded => new LoggedMessage(15, LogLevel.Debug); + internal static LoggedMessage NotModifiedIfNoneMatchStar => new LoggedMessage(1, LogLevel.Debug); + internal static LoggedMessage NotModifiedIfNoneMatchMatched => new LoggedMessage(2, LogLevel.Debug); + internal static LoggedMessage NotModifiedIfModifiedSinceSatisfied => new LoggedMessage(3, LogLevel.Debug); + internal static LoggedMessage NotModifiedServed => new LoggedMessage(4, LogLevel.Information); + internal static LoggedMessage CachedResponseServed => new LoggedMessage(5, LogLevel.Information); + internal static LoggedMessage GatewayTimeoutServed => new LoggedMessage(6, LogLevel.Information); + internal static LoggedMessage NoResponseServed => new LoggedMessage(7, LogLevel.Information); + internal static LoggedMessage VaryByRulesUpdated => new LoggedMessage(8, LogLevel.Debug); + internal static LoggedMessage ResponseCached => new LoggedMessage(9, LogLevel.Information); + internal static LoggedMessage ResponseNotCached => new LoggedMessage(10, LogLevel.Information); + internal static LoggedMessage ResponseContentLengthMismatchNotCached => new LoggedMessage(11, LogLevel.Warning); + internal static LoggedMessage ExpirationExpiresExceeded => new LoggedMessage(12, LogLevel.Debug); private LoggedMessage(int evenId, LogLevel logLevel) { @@ -318,33 +311,40 @@ internal class TestOutputCache : IOutputCacheStore private readonly Dictionary _storage = new(); public int GetCount { get; private set; } public int SetCount { get; private set; } + private readonly object synLock = new(); - public ValueTask EvictByTagAsync(string tag, CancellationToken token) + public ValueTask EvictByTagAsync(string tag, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public ValueTask GetAsync(string? key, CancellationToken token) + public ValueTask GetAsync(string? key, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(key); - GetCount++; - try - { - return ValueTask.FromResult(_storage[key]); - } - catch + lock (synLock) { - return ValueTask.FromResult(default(byte[])); + GetCount++; + try + { + return ValueTask.FromResult(_storage[key]); + } + catch + { + return ValueTask.FromResult(default(byte[])); + } } } - public ValueTask SetAsync(string key, byte[] entry, string[]? tags, TimeSpan validFor, CancellationToken token) + public ValueTask SetAsync(string key, byte[] entry, string[]? tags, TimeSpan validFor, CancellationToken cancellationToken) { - SetCount++; - _storage[key] = entry; + lock (synLock) + { + SetCount++; + _storage[key] = entry; - return ValueTask.CompletedTask; + return ValueTask.CompletedTask; + } } }