From 1546cf9eaf5526b24a0b9ed2e02e5ea11c826958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <sebastienros@gmail.com> Date: Wed, 31 Aug 2022 14:43:54 -0700 Subject: [PATCH] Improve vary-by rules (#43257) --- .../samples/OutputCachingSample/Startup.cs | 2 +- .../OutputCaching/src/CacheVaryByRules.cs | 10 +- .../OutputCaching/src/LoggerExtensions.cs | 11 +- .../src/OutputCacheKeyProvider.cs | 293 +++++++++++------- .../src/OutputCacheMiddleware.cs | 81 ----- .../src/OutputCachePolicyBuilder.cs | 74 ++++- .../src/Policies/SetCacheKeyPrefixPolicy.cs | 40 +++ .../src/Policies/VaryByValuePolicy.cs | 48 +-- .../OutputCaching/src/PublicAPI.Unshipped.txt | 12 +- .../test/MemoryOutputCacheStoreTests.cs | 4 - .../test/OutputCacheEntryFormatterTests.cs | 4 - .../test/OutputCacheKeyProviderTests.cs | 109 +++++-- .../test/OutputCacheMiddlewareTests.cs | 37 +-- .../test/OutputCachePoliciesTests.cs | 37 +-- .../test/OutputCachePolicyBuilderTests.cs | 32 +- .../test/OutputCachePolicyProviderTests.cs | 2 - .../OutputCaching/test/TestUtils.cs | 9 +- 17 files changed, 418 insertions(+), 387 deletions(-) create mode 100644 src/Middleware/OutputCaching/src/Policies/SetCacheKeyPrefixPolicy.cs diff --git a/src/Middleware/OutputCaching/samples/OutputCachingSample/Startup.cs b/src/Middleware/OutputCaching/samples/OutputCachingSample/Startup.cs index ca91981bc6b..330f302b3f4 100644 --- a/src/Middleware/OutputCaching/samples/OutputCachingSample/Startup.cs +++ b/src/Middleware/OutputCaching/samples/OutputCachingSample/Startup.cs @@ -27,7 +27,7 @@ app.MapGet("/nocache", Gravatar.WriteGravatar).CacheOutput(x => x.NoCache()); app.MapGet("/profile", Gravatar.WriteGravatar).CacheOutput("NoCache"); -app.MapGet("/attribute", [OutputCache(PolicyName = "NoCache")] () => Gravatar.WriteGravatar); +app.MapGet("/attribute", [OutputCache(PolicyName = "NoCache")] (context) => Gravatar.WriteGravatar(context)); var blog = app.MapGroup("blog").CacheOutput(x => x.Tag("blog")); blog.MapGet("/", Gravatar.WriteGravatar); diff --git a/src/Middleware/OutputCaching/src/CacheVaryByRules.cs b/src/Middleware/OutputCaching/src/CacheVaryByRules.cs index abbd9f8d62a..fd2f66cd5eb 100644 --- a/src/Middleware/OutputCaching/src/CacheVaryByRules.cs +++ b/src/Middleware/OutputCaching/src/CacheVaryByRules.cs @@ -11,14 +11,14 @@ namespace Microsoft.AspNetCore.OutputCaching; /// </summary> public sealed class CacheVaryByRules { - private Dictionary<string, string>? _varyByCustom; + private Dictionary<string, string>? _varyByValues; - internal bool HasVaryByCustom => _varyByCustom != null && _varyByCustom.Any(); + internal bool HasVaryByValues => _varyByValues != null && _varyByValues.Any(); /// <summary> - /// Gets a dictionary of key-pair values to vary the cache by. + /// Gets a dictionary of key-pair values to vary by. /// </summary> - public IDictionary<string, string> VaryByCustom => _varyByCustom ??= new(); + public IDictionary<string, string> VaryByValues => _varyByValues ??= new Dictionary<string, string>(); /// <summary> /// Gets or sets the list of route value names to vary by. @@ -38,5 +38,5 @@ public sealed class CacheVaryByRules /// <summary> /// Gets or sets a prefix to vary by. /// </summary> - public StringValues VaryByPrefix { get; set; } + public string? CacheKeyPrefix { get; set; } } diff --git a/src/Middleware/OutputCaching/src/LoggerExtensions.cs b/src/Middleware/OutputCaching/src/LoggerExtensions.cs index 7a05e21f7c4..ab200a95b3f 100644 --- a/src/Middleware/OutputCaching/src/LoggerExtensions.cs +++ b/src/Middleware/OutputCaching/src/LoggerExtensions.cs @@ -35,20 +35,17 @@ internal static partial class LoggerExtensions [LoggerMessage(7, LogLevel.Information, "No cached response available for this request.", EventName = "NoResponseServed")] internal static partial void NoResponseServed(this ILogger logger); - [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(9, LogLevel.Information, "The response has been cached.", EventName = "ResponseCached")] + [LoggerMessage(8, LogLevel.Information, "The response has been cached.", EventName = "ResponseCached")] internal static partial void ResponseCached(this ILogger logger); - [LoggerMessage(10, LogLevel.Information, "The response could not be cached for this request.", EventName = "ResponseNotCached")] + [LoggerMessage(9, LogLevel.Information, "The response could not be cached for this request.", EventName = "ResponseNotCached")] internal static partial void ResponseNotCached(this ILogger logger); - [LoggerMessage(11, LogLevel.Warning, "The response could not be cached for this request because the 'Content-Length' did not match the body length.", + [LoggerMessage(10, 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(12, LogLevel.Debug, "The response time of the entry is {ResponseTime} and has exceeded its expiry date.", + [LoggerMessage(11, 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/OutputCacheKeyProvider.cs b/src/Middleware/OutputCaching/src/OutputCacheKeyProvider.cs index 456ff014722..673d96e4590 100644 --- a/src/Middleware/OutputCaching/src/OutputCacheKeyProvider.cs +++ b/src/Middleware/OutputCaching/src/OutputCacheKeyProvider.cs @@ -29,173 +29,236 @@ internal sealed class OutputCacheKeyProvider : IOutputCacheKeyProvider _options = options.Value; } - // GET<delimiter>SCHEME<delimiter>HOST:PORT/PATHBASE/PATH<delimiter>H<delimiter>HeaderName=HeaderValue<delimiter>Q<delimiter>QueryName=QueryValue1<subdelimiter>QueryValue2<delimiter>R<delimiter>RouteName1=RouteValue1<subdelimiter>RouteName2=RouteValue2 + // <VaryByKeyPrefix><delimiter> + // GET<delimiter>SCHEME<delimiter>HOST:PORT/PATHBASE/PATH<delimiter> + // H<delimiter>HeaderName=HeaderValue<delimiter> + // Q<delimiter>QueryName=QueryValue1<subdelimiter>QueryValue2<delimiter> + // R<delimiter>RouteName1=RouteValue1<delimiter>RouteName2=RouteValue2 + // V<delimiter>ValueName1=Value1<delimiter>ValueName2=Value2 public string CreateStorageKey(OutputCacheContext context) { ArgumentNullException.ThrowIfNull(_builderPool); + var builder = _builderPool.Get(); + + try + { + if (!string.IsNullOrEmpty(context.CacheVaryByRules.CacheKeyPrefix)) + { + builder + .Append(context.CacheVaryByRules.CacheKeyPrefix) + .Append(KeyDelimiter); + } + + AppendBaseKey(context, builder); + + AppendVaryByKey(context, builder); + + return builder.ToString(); + } + finally + { + _builderPool.Return(builder); + } + } + + // GET<delimiter>SCHEME<delimiter>HOST:PORT/PATHBASE/PATH + public void AppendBaseKey(OutputCacheContext context, StringBuilder builder) + { + var request = context.HttpContext.Request; + + builder + .AppendUpperInvariant(request.Method) + .Append(KeyDelimiter) + .AppendUpperInvariant(request.Scheme) + .Append(KeyDelimiter) + .AppendUpperInvariant(request.Host.Value); + + if (_options.UseCaseSensitivePaths) + { + builder + .Append(request.PathBase.Value) + .Append(request.Path.Value); + } + else + { + builder + .AppendUpperInvariant(request.PathBase.Value) + .AppendUpperInvariant(request.Path.Value); + } + } + + public void AppendVaryByKey(OutputCacheContext context, StringBuilder builder) + { var varyByRules = context.CacheVaryByRules; + if (varyByRules == null) { - throw new InvalidOperationException($"{nameof(CacheVaryByRules)} must not be null on the {nameof(OutputCacheContext)}"); + throw new InvalidOperationException($"{nameof(OutputCacheContext.CacheVaryByRules)} must not be null on the {nameof(OutputCacheContext)}"); } - var request = context.HttpContext.Request; - var builder = _builderPool.Get(); + var varyHeaderNames = context.CacheVaryByRules.HeaderNames; + var varyRouteValueNames = context.CacheVaryByRules.RouteValueNames; + var varyQueryKeys = context.CacheVaryByRules.QueryKeys; + var varyByValues = context.CacheVaryByRules.HasVaryByValues ? context.CacheVaryByRules.VaryByValues : null; - try + // Vary by header names + var headersCount = varyByRules.HeaderNames.Count; + + if (headersCount > 0) { + // Append a group separator for the header segment of the cache key builder - .AppendUpperInvariant(request.Method) - .Append(KeyDelimiter) - .AppendUpperInvariant(request.Scheme) .Append(KeyDelimiter) - .AppendUpperInvariant(request.Host.Value); + .Append('H'); - if (_options.UseCaseSensitivePaths) + var requestHeaders = context.HttpContext.Request.Headers; + for (var i = 0; i < headersCount; i++) { + var header = varyByRules.HeaderNames[i] ?? string.Empty; + var headerValues = requestHeaders[header]; builder - .Append(request.PathBase.Value) - .Append(request.Path.Value); - } - else - { - builder - .AppendUpperInvariant(request.PathBase.Value) - .AppendUpperInvariant(request.Path.Value); - } + .Append(KeyDelimiter) + .Append(header) + .Append('='); - // Vary by prefix and custom - var prefixCount = varyByRules?.VaryByPrefix.Count ?? 0; - if (prefixCount > 0) - { - // Append a group separator for the header segment of the cache key - builder.Append(KeyDelimiter) - .Append('C'); + var headerValuesArray = headerValues.ToArray(); + Array.Sort(headerValuesArray, StringComparer.Ordinal); - for (var i = 0; i < prefixCount; i++) + for (var j = 0; j < headerValuesArray.Length; j++) { - var value = varyByRules?.VaryByPrefix[i] ?? string.Empty; - builder.Append(KeyDelimiter).Append(value); + builder.Append(headerValuesArray[j]); } } + } + + // Vary by query keys + if (varyQueryKeys.Count > 0) + { + // Append a group separator for the query key segment of the cache key + builder + .Append(KeyDelimiter) + .Append('Q'); - // Vary by header names - var headersCount = varyByRules?.HeaderNames.Count ?? 0; - if (headersCount > 0) + if (varyQueryKeys.Count == 1 && string.Equals(varyQueryKeys[0], "*", StringComparison.Ordinal) && context.HttpContext.Request.Query.Count > 0) { - // Append a group separator for the header segment of the cache key - builder.Append(KeyDelimiter) - .Append('H'); + // Vary by all available query keys + var queryArray = context.HttpContext.Request.Query.ToArray(); + // Query keys are aggregated case-insensitively whereas the query values are compared ordinally. + Array.Sort(queryArray, QueryKeyComparer.OrdinalIgnoreCase); - var requestHeaders = context.HttpContext.Request.Headers; - for (var i = 0; i < headersCount; i++) + for (var i = 0; i < queryArray.Length; i++) { - var header = varyByRules!.HeaderNames[i] ?? string.Empty; - var headerValues = requestHeaders[header]; - builder.Append(KeyDelimiter) - .Append(header) + builder + .Append(KeyDelimiter) + .AppendUpperInvariant(queryArray[i].Key) .Append('='); - var headerValuesArray = headerValues.ToArray(); - Array.Sort(headerValuesArray, StringComparer.Ordinal); + var queryValueArray = queryArray[i].Value.ToArray(); + Array.Sort(queryValueArray, StringComparer.Ordinal); - for (var j = 0; j < headerValuesArray.Length; j++) + for (var j = 0; j < queryValueArray.Length; j++) { - builder.Append(headerValuesArray[j]); + if (j > 0) + { + builder.Append(KeySubDelimiter); + } + + builder.Append(queryValueArray[j]); } } } - - // Vary by query keys - if (varyByRules?.QueryKeys.Count > 0) + else { - // Append a group separator for the query key segment of the cache key - builder.Append(KeyDelimiter) - .Append('Q'); - - if (varyByRules.QueryKeys.Count == 1 && string.Equals(varyByRules.QueryKeys[0], "*", StringComparison.Ordinal) && context.HttpContext.Request.Query.Count > 0) + for (var i = 0; i < varyByRules.QueryKeys.Count; i++) { - // Vary by all available query keys - var queryArray = context.HttpContext.Request.Query.ToArray(); - // Query keys are aggregated case-insensitively whereas the query values are compared ordinally. - Array.Sort(queryArray, QueryKeyComparer.OrdinalIgnoreCase); - - for (var i = 0; i < queryArray.Length; i++) - { - builder.Append(KeyDelimiter) - .AppendUpperInvariant(queryArray[i].Key) - .Append('='); - - var queryValueArray = queryArray[i].Value.ToArray(); - Array.Sort(queryValueArray, StringComparer.Ordinal); + var queryKey = varyByRules.QueryKeys[i] ?? string.Empty; + var queryKeyValues = context.HttpContext.Request.Query[queryKey]; + builder + .Append(KeyDelimiter) + .Append(queryKey) + .Append('='); - for (var j = 0; j < queryValueArray.Length; j++) - { - if (j > 0) - { - builder.Append(KeySubDelimiter); - } + var queryValueArray = queryKeyValues.ToArray(); + Array.Sort(queryValueArray, StringComparer.Ordinal); - builder.Append(queryValueArray[j]); - } - } - } - else - { - for (var i = 0; i < varyByRules.QueryKeys.Count; i++) + for (var j = 0; j < queryValueArray.Length; j++) { - var queryKey = varyByRules.QueryKeys[i] ?? string.Empty; - var queryKeyValues = context.HttpContext.Request.Query[queryKey]; - builder.Append(KeyDelimiter) - .Append(queryKey) - .Append('='); - - var queryValueArray = queryKeyValues.ToArray(); - Array.Sort(queryValueArray, StringComparer.Ordinal); - - for (var j = 0; j < queryValueArray.Length; j++) + if (j > 0) { - if (j > 0) - { - builder.Append(KeySubDelimiter); - } - - builder.Append(queryValueArray[j]); + builder.Append(KeySubDelimiter); } + + builder.Append(queryValueArray[j]); } } } + } + + // Vary by route value names + var routeValueNamesCount = varyByRules.RouteValueNames.Count; + if (routeValueNamesCount > 0) + { + // Append a group separator for the route values segment of the cache key + builder + .Append(KeyDelimiter) + .Append('R'); - // Vary by route value names - var routeValueNamesCount = varyByRules?.RouteValueNames.Count ?? 0; - if (routeValueNamesCount > 0) + for (var i = 0; i < routeValueNamesCount; i++) { - // Append a group separator for the route values segment of the cache key + // 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('R'); + .Append(routeValueName) + .Append('=') + .Append(Convert.ToString(routeValueValue, CultureInfo.InvariantCulture)); + } + } - for (var i = 0; i < routeValueNamesCount; i++) - { - // The lookup key can't be null - var routeValueName = varyByRules!.RouteValueNames[i] ?? string.Empty; + // Vary by values - // RouteValueNames returns null if the key doesn't exist - var routeValueValue = context.HttpContext.Request.RouteValues[routeValueName]; + // Order keys to have a deterministic key + var orderedKeys = GetOrderDictionaryKeys(varyByValues); - builder.Append(KeyDelimiter) - .Append(routeValueName) - .Append('=') - .Append(Convert.ToString(routeValueValue, CultureInfo.InvariantCulture)); - } - } + var valueNamesCount = orderedKeys.Length; + if (valueNamesCount > 0) + { + // Append a group separator for the values segment of the cache key + builder + .Append(KeyDelimiter) + .Append('V'); - return builder.ToString(); + for (var i = 0; i < valueNamesCount; i++) + { + // The lookup key can't be null + var key = orderedKeys[i] ?? string.Empty; + + var value = varyByRules.VaryByValues[key]; + + builder.Append(KeyDelimiter) + .Append(key) + .Append('=') + .Append(value); + } } - finally + } + + internal static string[] GetOrderDictionaryKeys(IDictionary<string, string>? dictionary) + { + if (dictionary == null || dictionary.Count == 0) { - _builderPool.Return(builder); + return Array.Empty<string>(); } + + var newArray = dictionary.Keys.ToArray(); + + Array.Sort(newArray, StringComparer.OrdinalIgnoreCase); + + return newArray; } private sealed class QueryKeyComparer : IComparer<KeyValuePair<string, StringValues>> diff --git a/src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs b/src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs index 78ea55b1566..fcd584d09e7 100644 --- a/src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs +++ b/src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs @@ -341,36 +341,6 @@ internal sealed class OutputCacheMiddleware return; } - var varyHeaderNames = context.CacheVaryByRules.HeaderNames; - var varyRouteValueNames = context.CacheVaryByRules.RouteValueNames; - var varyQueryKeys = context.CacheVaryByRules.QueryKeys; - var varyByCustomKeys = context.CacheVaryByRules.HasVaryByCustom ? context.CacheVaryByRules.VaryByCustom : null; - var varyByPrefix = context.CacheVaryByRules.VaryByPrefix; - - // Check if any vary rules exist - 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 normalizedVaryHeaderNames = GetOrderCasingNormalizedStringValues(varyHeaderNames); - var normalizedVaryRouteValueNames = GetOrderCasingNormalizedStringValues(varyRouteValueNames); - var normalizedVaryQueryKeys = GetOrderCasingNormalizedStringValues(varyQueryKeys); - var normalizedVaryByCustom = GetOrderCasingNormalizedDictionary(varyByCustomKeys); - - // Update vary rules with normalized values - 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(normalizedVaryHeaderNames.ToString(), normalizedVaryQueryKeys.ToString(), normalizedVaryRouteValueNames.ToString()); - } - } - context.CacheKey = _keyProvider.CreateStorageKey(context); } @@ -567,55 +537,4 @@ internal sealed class OutputCacheMiddleware return false; } - - // Normalize order and casing - internal static StringValues GetOrderCasingNormalizedStringValues(StringValues stringValues) - { - if (stringValues.Count == 0) - { - return StringValues.Empty; - } - else if (stringValues.Count == 1) - { - return new StringValues(stringValues.ToString().ToUpperInvariant()); - } - else - { - var originalArray = stringValues.ToArray(); - var newArray = new string[originalArray.Length]; - - for (var i = 0; i < originalArray.Length; i++) - { - newArray[i] = originalArray[i]!.ToUpperInvariant(); - } - - // Since the casing has already been normalized, use Ordinal comparison - Array.Sort(newArray, StringComparer.Ordinal); - - return new StringValues(newArray); - } - } - - internal static StringValues GetOrderCasingNormalizedDictionary(IDictionary<string, string>? dictionary) - { - const char KeySubDelimiter = '\x1f'; - - if (dictionary == null || dictionary.Count == 0) - { - return StringValues.Empty; - } - - var newArray = new string[dictionary.Count]; - - var i = 0; - foreach (var (key, value) in dictionary) - { - newArray[i++] = $"{key.ToUpperInvariant()}{KeySubDelimiter}{value}"; - } - - // Since the casing has already been normalized, use Ordinal comparison - Array.Sort(newArray, StringComparer.Ordinal); - - return new StringValues(newArray); - } } diff --git a/src/Middleware/OutputCaching/src/OutputCachePolicyBuilder.cs b/src/Middleware/OutputCaching/src/OutputCachePolicyBuilder.cs index b386083e912..1eb8eaeae9e 100644 --- a/src/Middleware/OutputCaching/src/OutputCachePolicyBuilder.cs +++ b/src/Middleware/OutputCaching/src/OutputCachePolicyBuilder.cs @@ -118,36 +118,64 @@ public sealed class OutputCachePolicyBuilder } /// <summary> - /// Adds a policy to vary the cached responses by custom values. + /// Adds a policy that varies the cache key using the specified value. /// </summary> - /// <param name="varyBy">The value to vary the cached responses by.</param> - public OutputCachePolicyBuilder VaryByValue(Func<HttpContext, CancellationToken, ValueTask<string>> varyBy) + /// <param name="keyPrefix">The value to vary the cache key by.</param> + public OutputCachePolicyBuilder SetCacheKeyPrefix(string keyPrefix) { - ArgumentNullException.ThrowIfNull(varyBy); + ArgumentNullException.ThrowIfNull(keyPrefix); - return AddPolicy(new VaryByValuePolicy(varyBy)); + ValueTask<string> varyByKeyFunc(HttpContext context, CancellationToken cancellationToken) + { + return ValueTask.FromResult(keyPrefix); + } + + return AddPolicy(new SetCacheKeyPrefixPolicy(varyByKeyFunc)); } /// <summary> - /// Adds a policy to vary the cached responses by custom key/value. + /// Adds a policy that varies the cache key using the specified value. /// </summary> - /// <param name="varyBy">The key/value to vary the cached responses by.</param> - public OutputCachePolicyBuilder VaryByValue(Func<HttpContext, CancellationToken, ValueTask<KeyValuePair<string, string>>> varyBy) + /// <param name="keyPrefix">The value to vary the cache key by.</param> + public OutputCachePolicyBuilder SetCacheKeyPrefix(Func<HttpContext, string> keyPrefix) { - ArgumentNullException.ThrowIfNull(varyBy); + ArgumentNullException.ThrowIfNull(keyPrefix); - return AddPolicy(new VaryByValuePolicy(varyBy)); + ValueTask<string> varyByKeyFunc(HttpContext context, CancellationToken cancellationToken) + { + return ValueTask.FromResult(keyPrefix(context)); + } + + return AddPolicy(new SetCacheKeyPrefixPolicy(varyByKeyFunc)); } /// <summary> - /// Adds a policy to vary the cached responses by custom values. + /// Adds a policy that varies the cache key using the specified value. /// </summary> - /// <param name="varyBy">The value to vary the cached responses by.</param> - public OutputCachePolicyBuilder VaryByValue(Func<HttpContext, string> varyBy) + /// <param name="keyPrefix">The value to vary the cache key by.</param> + public OutputCachePolicyBuilder SetCacheKeyPrefix(Func<HttpContext, CancellationToken, ValueTask<string>> keyPrefix) { - ArgumentNullException.ThrowIfNull(varyBy); + ArgumentNullException.ThrowIfNull(keyPrefix); - return AddPolicy(new VaryByValuePolicy(varyBy)); + return AddPolicy(new SetCacheKeyPrefixPolicy(keyPrefix)); + } + + /// <summary> + /// Adds a policy to vary the cached responses by custom key/value. + /// </summary> + /// <param name="key">The key to vary the cached responses by.</param> + /// <param name="value">The value to vary the cached responses by.</param> + public OutputCachePolicyBuilder VaryByValue(string key, string value) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(value); + + ValueTask<KeyValuePair<string, string>> varyByFunc(HttpContext context, CancellationToken cancellationToken) + { + return ValueTask.FromResult(new KeyValuePair<string, string>(key, value)); + } + + return AddPolicy(new VaryByValuePolicy(varyByFunc)); } /// <summary> @@ -158,6 +186,22 @@ public sealed class OutputCachePolicyBuilder { ArgumentNullException.ThrowIfNull(varyBy); + ValueTask<KeyValuePair<string, string>> varyByFunc(HttpContext context, CancellationToken cancellationToken) + { + return ValueTask.FromResult(varyBy(context)); + } + + return AddPolicy(new VaryByValuePolicy(varyByFunc)); + } + + /// <summary> + /// Adds a policy that vary the cached content based on the specified value. + /// </summary> + /// <param name="varyBy">The key/value to vary the cached responses by.</param> + public OutputCachePolicyBuilder VaryByValue(Func<HttpContext, CancellationToken, ValueTask<KeyValuePair<string, string>>> varyBy) + { + ArgumentNullException.ThrowIfNull(varyBy); + return AddPolicy(new VaryByValuePolicy(varyBy)); } diff --git a/src/Middleware/OutputCaching/src/Policies/SetCacheKeyPrefixPolicy.cs b/src/Middleware/OutputCaching/src/Policies/SetCacheKeyPrefixPolicy.cs new file mode 100644 index 00000000000..8ffdbdd225d --- /dev/null +++ b/src/Middleware/OutputCaching/src/Policies/SetCacheKeyPrefixPolicy.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.OutputCaching; + +/// <summary> +/// A policy that sets the cache key prefix using the specified value. +/// </summary> +internal sealed class SetCacheKeyPrefixPolicy : IOutputCachePolicy +{ + private readonly Func<HttpContext, CacheVaryByRules, CancellationToken, ValueTask> _varyByAsync; + + /// <summary> + /// Creates a policy that varies the cache key using the specified value. + /// </summary> + public SetCacheKeyPrefixPolicy(Func<HttpContext, CancellationToken, ValueTask<string>> varyBy) + { + _varyByAsync = async (context, rules, cancellationToken) => rules.CacheKeyPrefix = await varyBy(context, cancellationToken); + } + + /// <inheritdoc/> + ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return _varyByAsync.Invoke(context.HttpContext, context.CacheVaryByRules, cancellationToken); + } + + /// <inheritdoc/> + ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + /// <inheritdoc/> + ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } +} diff --git a/src/Middleware/OutputCaching/src/Policies/VaryByValuePolicy.cs b/src/Middleware/OutputCaching/src/Policies/VaryByValuePolicy.cs index 5de5366307a..8f14866c9eb 100644 --- a/src/Middleware/OutputCaching/src/Policies/VaryByValuePolicy.cs +++ b/src/Middleware/OutputCaching/src/Policies/VaryByValuePolicy.cs @@ -10,62 +10,24 @@ namespace Microsoft.AspNetCore.OutputCaching; /// </summary> internal sealed class VaryByValuePolicy : IOutputCachePolicy { - private readonly Action<HttpContext, CacheVaryByRules>? _varyBy; - private readonly Func<HttpContext, CacheVaryByRules, CancellationToken, ValueTask>? _varyByAsync; - - /// <summary> - /// Creates a policy that doesn't vary the cached content based on values. - /// </summary> - public VaryByValuePolicy() - { - } - - /// <summary> - /// Creates a policy that vary the cached content based on the specified value. - /// </summary> - public VaryByValuePolicy(Func<HttpContext, string> varyBy) - { - _varyBy = (context, rules) => rules.VaryByPrefix += varyBy(context); - } - - /// <summary> - /// Creates a policy that vary the cached content based on the specified value. - /// </summary> - public VaryByValuePolicy(Func<HttpContext, CancellationToken, ValueTask<string>> varyBy) - { - _varyByAsync = async (context, rules, token) => rules.VaryByPrefix += await varyBy(context, token); - } - - /// <summary> - /// Creates a policy that vary the cached content based on the specified value. - /// </summary> - public VaryByValuePolicy(Func<HttpContext, KeyValuePair<string, string>> varyBy) - { - _varyBy = (context, rules) => - { - var result = varyBy(context); - rules.VaryByCustom?.TryAdd(result.Key, result.Value); - }; - } + private readonly Func<HttpContext, CacheVaryByRules, CancellationToken, ValueTask> _varyByAsync; /// <summary> /// Creates a policy that vary the cached content based on the specified value. /// </summary> public VaryByValuePolicy(Func<HttpContext, CancellationToken, ValueTask<KeyValuePair<string, string>>> varyBy) { - _varyBy = async (context, rules) => + _varyByAsync = async (context, rules, cancellationToken) => { - var result = await varyBy(context, context.RequestAborted); - rules.VaryByCustom?.TryAdd(result.Key, result.Value); + var result = await varyBy(context, cancellationToken); + rules.VaryByValues[result.Key] = result.Value; }; } /// <inheritdoc/> ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken) { - _varyBy?.Invoke(context.HttpContext, context.CacheVaryByRules); - - return _varyByAsync?.Invoke(context.HttpContext, context.CacheVaryByRules, context.HttpContext.RequestAborted) ?? ValueTask.CompletedTask; + return _varyByAsync.Invoke(context.HttpContext, context.CacheVaryByRules, cancellationToken); } /// <inheritdoc/> diff --git a/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt b/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt index fd1ea274618..59d937671c5 100644 --- a/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt +++ b/src/Middleware/OutputCaching/src/PublicAPI.Unshipped.txt @@ -1,6 +1,8 @@ #nullable enable Microsoft.AspNetCore.Builder.OutputCacheApplicationBuilderExtensions Microsoft.AspNetCore.OutputCaching.CacheVaryByRules +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.CacheKeyPrefix.get -> string? +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.CacheKeyPrefix.set -> void Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.CacheVaryByRules() -> void Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.HeaderNames.get -> Microsoft.Extensions.Primitives.StringValues Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.HeaderNames.set -> void @@ -8,9 +10,7 @@ Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.QueryKeys.get -> Microsoft.E 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<string!, string!>! -Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.VaryByPrefix.get -> Microsoft.Extensions.Primitives.StringValues -Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.VaryByPrefix.set -> void +Microsoft.AspNetCore.OutputCaching.CacheVaryByRules.VaryByValues.get -> System.Collections.Generic.IDictionary<string!, string!>! Microsoft.AspNetCore.OutputCaching.IOutputCacheFeature Microsoft.AspNetCore.OutputCaching.IOutputCacheFeature.Context.get -> Microsoft.AspNetCore.OutputCaching.OutputCacheContext! Microsoft.AspNetCore.OutputCaching.IOutputCachePolicy.CacheRequestAsync(Microsoft.AspNetCore.OutputCaching.OutputCacheContext! context, System.Threading.CancellationToken cancellation) -> System.Threading.Tasks.ValueTask @@ -53,13 +53,15 @@ Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.Clear() -> Microsoft 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.SetCacheKeyPrefix(string! keyPrefix) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.SetCacheKeyPrefix(System.Func<Microsoft.AspNetCore.Http.HttpContext!, string!>! keyPrefix) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! +Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.SetCacheKeyPrefix(System.Func<Microsoft.AspNetCore.Http.HttpContext!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask<string!>>! keyPrefix) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.Tag(params string![]! tags) -> 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(string! key, string! value) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByValue(System.Func<Microsoft.AspNetCore.Http.HttpContext!, System.Collections.Generic.KeyValuePair<string!, string!>>! varyBy) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! -Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByValue(System.Func<Microsoft.AspNetCore.Http.HttpContext!, string!>! varyBy) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! -Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByValue(System.Func<Microsoft.AspNetCore.Http.HttpContext!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask<string!>>! varyBy) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder.VaryByValue(System.Func<Microsoft.AspNetCore.Http.HttpContext!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask<System.Collections.Generic.KeyValuePair<string!, string!>>>! varyBy) -> Microsoft.AspNetCore.OutputCaching.OutputCachePolicyBuilder! Microsoft.AspNetCore.OutputCaching.OutputCacheContext Microsoft.AspNetCore.OutputCaching.OutputCacheContext.AllowCacheLookup.get -> bool diff --git a/src/Middleware/OutputCaching/test/MemoryOutputCacheStoreTests.cs b/src/Middleware/OutputCaching/test/MemoryOutputCacheStoreTests.cs index c3a8d483884..e15f4515ca1 100644 --- a/src/Middleware/OutputCaching/test/MemoryOutputCacheStoreTests.cs +++ b/src/Middleware/OutputCaching/test/MemoryOutputCacheStoreTests.cs @@ -1,12 +1,8 @@ // 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; using Microsoft.AspNetCore.OutputCaching.Memory; -using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.OutputCaching.Tests; diff --git a/src/Middleware/OutputCaching/test/OutputCacheEntryFormatterTests.cs b/src/Middleware/OutputCaching/test/OutputCacheEntryFormatterTests.cs index a0de533fc0b..c8dc7711378 100644 --- a/src/Middleware/OutputCaching/test/OutputCacheEntryFormatterTests.cs +++ b/src/Middleware/OutputCaching/test/OutputCacheEntryFormatterTests.cs @@ -1,11 +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.Net.Http; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.OutputCaching.Memory; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.OutputCaching.Tests; diff --git a/src/Middleware/OutputCaching/test/OutputCacheKeyProviderTests.cs b/src/Middleware/OutputCaching/test/OutputCacheKeyProviderTests.cs index 92809d018fa..c5bb9558c63 100644 --- a/src/Middleware/OutputCaching/test/OutputCacheKeyProviderTests.cs +++ b/src/Middleware/OutputCaching/test/OutputCacheKeyProviderTests.cs @@ -10,6 +10,7 @@ public class OutputCacheKeyProviderTests { private const char KeyDelimiter = '\x1e'; private const char KeySubDelimiter = '\x1f'; + private static readonly string EmptyBaseKey = $"{KeyDelimiter}{KeyDelimiter}"; [Fact] public void OutputCachingKeyProvider_CreateStorageKey_IncludesOnlyNormalizedMethodSchemeHostPortAndPath() @@ -67,13 +68,13 @@ public class OutputCacheKeyProviderTests { var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); - context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.CacheKeyPrefix = Guid.NewGuid().ToString("n"); - Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}C{KeyDelimiter}{context.CacheVaryByRules.VaryByPrefix}", cacheKeyProvider.CreateStorageKey(context)); + Assert.Equal($"{context.CacheVaryByRules.CacheKeyPrefix}{KeyDelimiter}{EmptyBaseKey}", cacheKeyProvider.CreateStorageKey(context)); } [Fact] - public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesListedRouteValuesOnly() + public void OutputCachingKeyProvider_CreateStorageKey_IncludesListedRouteValuesOnly() { var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); @@ -81,12 +82,12 @@ public class OutputCacheKeyProviderTests context.HttpContext.Request.RouteValues["RouteB"] = "ValueB"; context.CacheVaryByRules.RouteValueNames = new string[] { "RouteA", "RouteC" }; - Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}R{KeyDelimiter}RouteA=ValueA{KeyDelimiter}RouteC=", + Assert.Equal($"{EmptyBaseKey}{KeyDelimiter}R{KeyDelimiter}RouteA=ValueA{KeyDelimiter}RouteC=", cacheKeyProvider.CreateStorageKey(context)); } [Fact] - public void OutputCachingKeyProvider_CreateStorageVaryKey_SerializeRouteValueToStringInvariantCulture() + public void OutputCachingKeyProvider_CreateStorageKey_SerializeRouteValueToStringInvariantCulture() { var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); @@ -97,7 +98,7 @@ public class OutputCacheKeyProviderTests try { Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); - Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}R{KeyDelimiter}RouteA=123.456{KeyDelimiter}RouteC=", + Assert.Equal($"{EmptyBaseKey}{KeyDelimiter}R{KeyDelimiter}RouteA=123.456{KeyDelimiter}RouteC=", cacheKeyProvider.CreateStorageKey(context)); } finally @@ -107,7 +108,19 @@ public class OutputCacheKeyProviderTests } [Fact] - public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesListedHeadersOnly() + public void OutputCachingKeyProvider_CreateStorageKey_ValuesAreSorted() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.CacheVaryByRules.VaryByValues["b"] = "ValueB"; + context.CacheVaryByRules.VaryByValues["a"] = "ValueA"; + + Assert.Equal($"{EmptyBaseKey}{KeyDelimiter}V{KeyDelimiter}a=ValueA{KeyDelimiter}b=ValueB", + cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageKey_IncludesListedHeadersOnly() { var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); @@ -115,12 +128,24 @@ public class OutputCacheKeyProviderTests context.HttpContext.Request.Headers["HeaderB"] = "ValueB"; context.CacheVaryByRules.HeaderNames = new string[] { "HeaderA", "HeaderC" }; - Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=", + Assert.Equal($"{EmptyBaseKey}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=", + cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageKey_UsesListedHeaderKey_AsKey() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.Headers["HeaderA"] = "ValueA"; + context.CacheVaryByRules.HeaderNames = new string[] { "HEADERA" }; + + Assert.Equal($"{EmptyBaseKey}{KeyDelimiter}H{KeyDelimiter}HEADERA=ValueA", cacheKeyProvider.CreateStorageKey(context)); } [Fact] - public void OutputCachingKeyProvider_CreateStorageVaryKey_HeaderValuesAreSorted() + public void OutputCachingKeyProvider_CreateStorageKey_HeaderValuesAreSorted() { var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); @@ -128,83 +153,96 @@ public class OutputCacheKeyProviderTests context.HttpContext.Request.Headers.Append("HeaderA", "ValueA"); context.CacheVaryByRules.HeaderNames = new string[] { "HeaderA", "HeaderC" }; - Assert.Equal($"{KeyDelimiter}{KeyDelimiter}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueAValueB{KeyDelimiter}HeaderC=", + Assert.Equal($"{EmptyBaseKey}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueAValueB{KeyDelimiter}HeaderC=", cacheKeyProvider.CreateStorageKey(context)); } [Fact] - public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesListedQueryKeysOnly() + public void OutputCachingKeyProvider_CreateStorageKey_IncludesListedQueryKeysOnly() { var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryB=ValueB"); - context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.CacheKeyPrefix = 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=", + Assert.Equal($"{context.CacheVaryByRules.CacheKeyPrefix}{KeyDelimiter}{EmptyBaseKey}{KeyDelimiter}Q{KeyDelimiter}QueryA=ValueA{KeyDelimiter}QueryC=", cacheKeyProvider.CreateStorageKey(context)); } [Fact] - public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesQueryKeys_QueryKeyCaseInsensitive_UseQueryKeyCasing() + public void OutputCachingKeyProvider_CreateStorageKey_IncludesQueryKeys_QueryKeyCaseInsensitive_UseQueryKeyCasing() { var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); context.HttpContext.Request.QueryString = new QueryString("?queryA=ValueA&queryB=ValueB"); - context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.CacheKeyPrefix = 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=", + Assert.Equal($"{context.CacheVaryByRules.CacheKeyPrefix}{KeyDelimiter}{EmptyBaseKey}{KeyDelimiter}Q{KeyDelimiter}QueryA=ValueA{KeyDelimiter}QueryC=", cacheKeyProvider.CreateStorageKey(context)); } [Fact] - public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesAllQueryKeysGivenAsterisk() + public void OutputCachingKeyProvider_CreateStorageKey_UseListedQueryKeys_AsKey() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.QueryString = new QueryString("?queryA=ValueA"); + context.CacheVaryByRules.CacheKeyPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.QueryKeys = new string[] { "QUERYA" }; + + Assert.Equal($"{context.CacheVaryByRules.CacheKeyPrefix}{KeyDelimiter}{EmptyBaseKey}{KeyDelimiter}Q{KeyDelimiter}QUERYA=ValueA", + cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageKey_IncludesAllQueryKeysGivenAsterisk() { var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryB=ValueB"); - context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.CacheKeyPrefix = 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{KeyDelimiter}QUERYB=ValueB", + Assert.Equal($"{context.CacheVaryByRules.CacheKeyPrefix}{KeyDelimiter}{EmptyBaseKey}{KeyDelimiter}Q{KeyDelimiter}QUERYA=ValueA{KeyDelimiter}QUERYB=ValueB", cacheKeyProvider.CreateStorageKey(context)); } [Fact] - public void OutputCachingKeyProvider_CreateStorageVaryKey_QueryKeysValuesNotConsolidated() + public void OutputCachingKeyProvider_CreateStorageKey_QueryKeysValuesNotConsolidated() { var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryA=ValueB"); - context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.CacheKeyPrefix = 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", + Assert.Equal($"{context.CacheVaryByRules.CacheKeyPrefix}{KeyDelimiter}{EmptyBaseKey}{KeyDelimiter}Q{KeyDelimiter}QUERYA=ValueA{KeySubDelimiter}ValueB", cacheKeyProvider.CreateStorageKey(context)); } [Fact] - public void OutputCachingKeyProvider_CreateStorageVaryKey_QueryKeysValuesAreSorted() + public void OutputCachingKeyProvider_CreateStorageKey_QueryKeysValuesAreSorted() { var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueB&QueryA=ValueA"); - context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.CacheKeyPrefix = 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", + Assert.Equal($"{context.CacheVaryByRules.CacheKeyPrefix}{KeyDelimiter}{EmptyBaseKey}{KeyDelimiter}Q{KeyDelimiter}QUERYA=ValueA{KeySubDelimiter}ValueB", cacheKeyProvider.CreateStorageKey(context)); } [Fact] - public void OutputCachingKeyProvider_CreateStorageVaryKey_IncludesListedHeadersAndQueryKeysAndRouteValues() + public void OutputCachingKeyProvider_CreateStorageKey_IncludesListedHeadersAndQueryKeysAndRouteValues() { var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); var context = TestUtils.CreateTestContext(); @@ -213,12 +251,25 @@ public class OutputCacheKeyProviderTests context.HttpContext.Request.QueryString = new QueryString("?QueryA=ValueA&QueryB=ValueB"); context.HttpContext.Request.RouteValues["RouteA"] = "ValueA"; context.HttpContext.Request.RouteValues["RouteB"] = "ValueB"; - context.CacheVaryByRules.VaryByPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.CacheKeyPrefix = 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=", + Assert.Equal($"{context.CacheVaryByRules.CacheKeyPrefix}{KeyDelimiter}{EmptyBaseKey}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC={KeyDelimiter}Q{KeyDelimiter}QueryA=ValueA{KeyDelimiter}QueryC={KeyDelimiter}R{KeyDelimiter}RouteA=ValueA{KeyDelimiter}RouteC=", + cacheKeyProvider.CreateStorageKey(context)); + } + + [Fact] + public void OutputCachingKeyProvider_CreateStorageKey_UseListedRouteValueNames_AsKey() + { + var cacheKeyProvider = TestUtils.CreateTestKeyProvider(); + var context = TestUtils.CreateTestContext(); + context.HttpContext.Request.RouteValues["RouteA"] = "ValueA"; + context.CacheVaryByRules.CacheKeyPrefix = Guid.NewGuid().ToString("n"); + context.CacheVaryByRules.RouteValueNames= new string[] { "ROUTEA" }; + + Assert.Equal($"{context.CacheVaryByRules.CacheKeyPrefix}{KeyDelimiter}{EmptyBaseKey}{KeyDelimiter}R{KeyDelimiter}ROUTEA=ValueA", cacheKeyProvider.CreateStorageKey(context)); } } diff --git a/src/Middleware/OutputCaching/test/OutputCacheMiddlewareTests.cs b/src/Middleware/OutputCaching/test/OutputCacheMiddlewareTests.cs index 550e2077cb2..a4a030634ca 100644 --- a/src/Middleware/OutputCaching/test/OutputCacheMiddlewareTests.cs +++ b/src/Middleware/OutputCaching/test/OutputCacheMiddlewareTests.cs @@ -1,9 +1,6 @@ // 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; @@ -782,38 +779,6 @@ public class OutputCacheMiddlewareTests public override void OnStarting(Func<object, Task> callback, object state) { } } - [Fact] - public void GetOrderCasingNormalizedStringValues_NormalizesCasingToUpper() - { - var uppercaseStrings = new StringValues(new[] { "STRINGA", "STRINGB" }); - var lowercaseStrings = new StringValues(new[] { "stringA", "stringB" }); - - var normalizedStrings = OutputCacheMiddleware.GetOrderCasingNormalizedStringValues(lowercaseStrings); - - Assert.Equal(uppercaseStrings, normalizedStrings); - } - - [Fact] - public void GetOrderCasingNormalizedStringValues_NormalizesOrder() - { - var orderedStrings = new StringValues(new[] { "STRINGA", "STRINGB" }); - var reverseOrderStrings = new StringValues(new[] { "STRINGB", "STRINGA" }); - - var normalizedStrings = OutputCacheMiddleware.GetOrderCasingNormalizedStringValues(reverseOrderStrings); - - Assert.Equal(orderedStrings, normalizedStrings); - } - - [Fact] - public void GetOrderCasingNormalizedStringValues_PreservesCommas() - { - var originalStrings = new StringValues(new[] { "STRINGA, STRINGB" }); - - var normalizedStrings = OutputCacheMiddleware.GetOrderCasingNormalizedStringValues(originalStrings); - - Assert.Equal(originalStrings, normalizedStrings); - } - [Fact] public async Task Locking_PreventsConcurrentRequests() { @@ -887,7 +852,7 @@ public class OutputCacheMiddlewareTests task2Executing.Set(); break; } - + c.Response.Write("Hello" + responseCounter); return Task.CompletedTask; }); diff --git a/src/Middleware/OutputCaching/test/OutputCachePoliciesTests.cs b/src/Middleware/OutputCaching/test/OutputCachePoliciesTests.cs index a33e4f103a5..3611828f081 100644 --- a/src/Middleware/OutputCaching/test/OutputCachePoliciesTests.cs +++ b/src/Middleware/OutputCaching/test/OutputCachePoliciesTests.cs @@ -234,29 +234,16 @@ public class OutputCachePoliciesTests } [Fact] - public async Task VaryByValuePolicy_SingleValue() + public async Task VaryByKeyPrefixPolicy_AddsKeyPrefix() { var context = TestUtils.CreateUninitializedContext(); var value = "value"; - IOutputCachePolicy policy = new VaryByValuePolicy(context => value); + IOutputCachePolicy policy = new SetCacheKeyPrefixPolicy((context, cancellationToken) => ValueTask.FromResult(value)); await policy.CacheRequestAsync(context, default); - Assert.Equal(value, context.CacheVaryByRules.VaryByPrefix); - } - - [Fact] - public async Task VaryByValuePolicy_SingleValueAsync() - { - var context = TestUtils.CreateUninitializedContext(); - var value = "value"; - - IOutputCachePolicy policy = new VaryByValuePolicy((context, token) => ValueTask.FromResult(value)); - - await policy.CacheRequestAsync(context, default); - - Assert.Equal(value, context.CacheVaryByRules.VaryByPrefix); + Assert.Equal(value, context.CacheVaryByRules.CacheKeyPrefix); } [Fact] @@ -266,24 +253,10 @@ public class OutputCachePoliciesTests var key = "key"; var value = "value"; - IOutputCachePolicy policy = new VaryByValuePolicy(context => new KeyValuePair<string, string>(key, value)); - - await policy.CacheRequestAsync(context, default); - - Assert.Contains(new KeyValuePair<string, string>(key, value), context.CacheVaryByRules.VaryByCustom); - } - - [Fact] - public async Task VaryByValuePolicy_KeyValuePairAsync() - { - var context = TestUtils.CreateUninitializedContext(); - var key = "key"; - var value = "value"; - - IOutputCachePolicy policy = new VaryByValuePolicy((context, token) => ValueTask.FromResult(new KeyValuePair<string, string>(key, value))); + IOutputCachePolicy policy = new VaryByValuePolicy((context, CancellationToken) => ValueTask.FromResult(new KeyValuePair<string, string>(key, value))); await policy.CacheRequestAsync(context, default); - Assert.Contains(new KeyValuePair<string, string>(key, value), context.CacheVaryByRules.VaryByCustom); + Assert.Contains(new KeyValuePair<string, string>(key, value), context.CacheVaryByRules.VaryByValues); } } diff --git a/src/Middleware/OutputCaching/test/OutputCachePolicyBuilderTests.cs b/src/Middleware/OutputCaching/test/OutputCachePolicyBuilderTests.cs index 6fbe8e193fe..79762406c10 100644 --- a/src/Middleware/OutputCaching/test/OutputCachePolicyBuilderTests.cs +++ b/src/Middleware/OutputCaching/test/OutputCachePolicyBuilderTests.cs @@ -1,7 +1,6 @@ // 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; @@ -114,17 +113,44 @@ public class OutputCachePolicyBuilderTests Assert.DoesNotContain("RouteB", (IEnumerable<string>)context.CacheVaryByRules.RouteValueNames); } + [Fact] + public async Task BuildPolicy_CreatesVaryByKeyPrefixPolicy() + { + var context1 = TestUtils.CreateUninitializedContext(); + var context2 = TestUtils.CreateUninitializedContext(); + var context3 = TestUtils.CreateUninitializedContext(); + + var policy1 = new OutputCachePolicyBuilder().SetCacheKeyPrefix("tenant1").Build(); + var policy2 = new OutputCachePolicyBuilder().SetCacheKeyPrefix(context => "tenant2").Build(); + var policy3 = new OutputCachePolicyBuilder().SetCacheKeyPrefix((context, cancellationToken) => ValueTask.FromResult("tenant3")).Build(); + + await policy1.CacheRequestAsync(context1, cancellation: default); + await policy2.CacheRequestAsync(context2, cancellation: default); + await policy3.CacheRequestAsync(context3, cancellation: default); + + Assert.Equal("tenant1", context1.CacheVaryByRules.CacheKeyPrefix); + Assert.Equal("tenant2", context2.CacheVaryByRules.CacheKeyPrefix); + Assert.Equal("tenant3", context3.CacheVaryByRules.CacheKeyPrefix); + } + [Fact] public async Task BuildPolicy_CreatesVaryByValuePolicy() { var context = TestUtils.CreateUninitializedContext(); var builder = new OutputCachePolicyBuilder(); - var policy = builder.VaryByValue(context => new KeyValuePair<string, string>("color", "blue")).Build(); + var policy = builder + .VaryByValue("shape", "circle") + .VaryByValue(context => new KeyValuePair<string, string>("color", "blue")) + .VaryByValue((context, cancellationToken) => ValueTask.FromResult(new KeyValuePair<string, string>("size", "1m"))) + .Build(); + await policy.CacheRequestAsync(context, cancellation: default); Assert.True(context.EnableOutputCaching); - Assert.Equal("blue", context.CacheVaryByRules.VaryByCustom["color"]); + Assert.Equal("circle", context.CacheVaryByRules.VaryByValues["shape"]); + Assert.Equal("blue", context.CacheVaryByRules.VaryByValues["color"]); + Assert.Equal("1m", context.CacheVaryByRules.VaryByValues["size"]); } [Fact] diff --git a/src/Middleware/OutputCaching/test/OutputCachePolicyProviderTests.cs b/src/Middleware/OutputCaching/test/OutputCachePolicyProviderTests.cs index ce93251c186..a91138df142 100644 --- a/src/Middleware/OutputCaching/test/OutputCachePolicyProviderTests.cs +++ b/src/Middleware/OutputCaching/test/OutputCachePolicyProviderTests.cs @@ -2,9 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.OutputCaching.Policies; using Microsoft.Extensions.Logging.Testing; -using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.OutputCaching.Tests; diff --git a/src/Middleware/OutputCaching/test/TestUtils.cs b/src/Middleware/OutputCaching/test/TestUtils.cs index f8fe9fce581..51039bb81cd 100644 --- a/src/Middleware/OutputCaching/test/TestUtils.cs +++ b/src/Middleware/OutputCaching/test/TestUtils.cs @@ -275,11 +275,10 @@ internal class LoggedMessage 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); + internal static LoggedMessage ResponseCached => new LoggedMessage(8, LogLevel.Information); + internal static LoggedMessage ResponseNotCached => new LoggedMessage(9, LogLevel.Information); + internal static LoggedMessage ResponseContentLengthMismatchNotCached => new LoggedMessage(10, LogLevel.Warning); + internal static LoggedMessage ExpirationExpiresExceeded => new LoggedMessage(11, LogLevel.Debug); private LoggedMessage(int evenId, LogLevel logLevel) { -- GitLab