diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs
new file mode 100644
index 0000000000..3abaea6627
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs
@@ -0,0 +1,324 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Internal;
+using Microsoft.Extensions.Primitives;
+
+namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
+{
+ ///
+ /// An instance of represents the state of
+ /// or keys.
+ ///
+ public class CacheTagKey : IEquatable
+ {
+ private static readonly char[] AttributeSeparator = new[] { ',' };
+ private static readonly Func CookieAcccessor = (c, key) => c[key];
+ private static readonly Func HeaderAccessor = (c, key) => c[key];
+ private static readonly Func QueryAccessor = (c, key) => c[key];
+ private static readonly Func RouteValueAccessor = (c, key) => c[key]?.ToString();
+
+ private const string CacheKeyTokenSeparator = "||";
+ private const string VaryByName = "VaryBy";
+ private const string VaryByHeaderName = "VaryByHeader";
+ private const string VaryByQueryName = "VaryByQuery";
+ private const string VaryByRouteName = "VaryByRoute";
+ private const string VaryByCookieName = "VaryByCookie";
+ private const string VaryByUserName = "VaryByUser";
+
+ private readonly string _key;
+ private readonly string _prefix;
+ private readonly string _varyBy;
+ private readonly DateTimeOffset? _expiresOn;
+ private readonly TimeSpan? _expiresAfter;
+ private readonly TimeSpan? _expiresSliding;
+ private readonly IList> _headers;
+ private readonly IList> _queries;
+ private readonly IList> _routeValues;
+ private readonly IList> _cookies;
+ private readonly bool _varyByUser;
+ private readonly string _username;
+
+ private string _generatedKey;
+ private int? _hashcode;
+
+ ///
+ /// Creates an instance of for a specific .
+ ///
+ /// The .
+ /// The .
+ /// A new .
+ public CacheTagKey(CacheTagHelper tagHelper, TagHelperContext context)
+ : this(tagHelper)
+ {
+ _key = context.UniqueId;
+ _prefix = nameof(CacheTagHelper);
+ }
+
+ ///
+ /// Creates an instance of for a specific .
+ ///
+ /// The .
+ /// A new .
+ public CacheTagKey(DistributedCacheTagHelper tagHelper)
+ : this((CacheTagHelperBase)tagHelper)
+ {
+ _key = tagHelper.Name;
+ _prefix = nameof(DistributedCacheTagHelper);
+ }
+
+ private CacheTagKey(CacheTagHelperBase tagHelper)
+ {
+ var httpContext = tagHelper.ViewContext.HttpContext;
+ var request = httpContext.Request;
+
+ _expiresAfter = tagHelper.ExpiresAfter;
+ _expiresOn = tagHelper.ExpiresOn;
+ _expiresSliding = tagHelper.ExpiresSliding;
+ _varyBy = tagHelper.VaryBy;
+ _cookies = ExtractCollection(tagHelper.VaryByCookie, request.Cookies, CookieAcccessor);
+ _headers = ExtractCollection(tagHelper.VaryByHeader, request.Headers, HeaderAccessor);
+ _queries = ExtractCollection(tagHelper.VaryByQuery, request.Query, QueryAccessor);
+ _routeValues = ExtractCollection(tagHelper.VaryByRoute, tagHelper.ViewContext.RouteData.Values, RouteValueAccessor);
+ _varyByUser = tagHelper.VaryByUser;
+
+ if (_varyByUser)
+ {
+ _username = httpContext.User?.Identity?.Name;
+ }
+ }
+
+ ///
+ /// Creates a representation of the key.
+ ///
+ /// A uniquely representing the key.
+ public string GenerateKey()
+ {
+ // Caching as the key is immutable and it can be called multiple times during a request.
+ if (_generatedKey != null)
+ {
+ return _generatedKey;
+ }
+
+ var builder = new StringBuilder(_prefix);
+ builder
+ .Append(CacheKeyTokenSeparator)
+ .Append(_key);
+
+ if (!string.IsNullOrEmpty(_varyBy))
+ {
+ builder
+ .Append(CacheKeyTokenSeparator)
+ .Append(VaryByName)
+ .Append(CacheKeyTokenSeparator)
+ .Append(_varyBy);
+ }
+
+ AddStringCollection(builder, VaryByCookieName, _cookies);
+ AddStringCollection(builder, VaryByHeaderName, _headers);
+ AddStringCollection(builder, VaryByQueryName, _queries);
+ AddStringCollection(builder, VaryByRouteName, _routeValues);
+
+ if (_varyByUser)
+ {
+ builder
+ .Append(CacheKeyTokenSeparator)
+ .Append(VaryByUserName)
+ .Append(CacheKeyTokenSeparator)
+ .Append(_username);
+ }
+
+ _generatedKey = builder.ToString();
+
+ return _generatedKey;
+ }
+
+ ///
+ /// Creates a hashed value of the key.
+ ///
+ /// A cryptographic hash of the key.
+ public string GenerateHashedKey()
+ {
+ var key = GenerateKey();
+
+ // The key is typically too long to be useful, so we use a cryptographic hash
+ // as the actual key (better randomization and key distribution, so small vary
+ // values will generate dramatically different keys).
+ using (var sha = SHA256.Create())
+ {
+ var contentBytes = Encoding.UTF8.GetBytes(key);
+ var hashedBytes = sha.ComputeHash(contentBytes);
+ return Convert.ToBase64String(hashedBytes);
+ }
+ }
+
+ ///
+ public override bool Equals(object obj)
+ {
+ var other = obj as CacheTagKey;
+ if (other == null)
+ {
+ return false;
+ }
+
+ return Equals(other);
+ }
+
+ ///
+ public bool Equals(CacheTagKey other)
+ {
+ return string.Equals(other._key, _key, StringComparison.Ordinal) &&
+ other._expiresAfter == _expiresAfter &&
+ other._expiresOn == _expiresOn &&
+ other._expiresSliding == _expiresSliding &&
+ string.Equals(other._varyBy, _varyBy, StringComparison.Ordinal) &&
+ AreSame(_cookies, other._cookies) &&
+ AreSame(_headers, other._headers) &&
+ AreSame(_queries, other._queries) &&
+ AreSame(_routeValues, other._routeValues) &&
+ _varyByUser == other._varyByUser &&
+ (!_varyByUser || string.Equals(other._username, _username, StringComparison.Ordinal));
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ // The hashcode is intentionally not using the computed
+ // stringified key in order to prevent string allocations
+ // in the common case where it's not explicitly required.
+
+ // Caching as the key is immutable and it can be called
+ // multiple times during a request.
+ if (_hashcode.HasValue)
+ {
+ return _hashcode.Value;
+ }
+
+ var hashCodeCombiner = new HashCodeCombiner();
+
+ hashCodeCombiner.Add(_key, StringComparer.Ordinal);
+ hashCodeCombiner.Add(_expiresAfter);
+ hashCodeCombiner.Add(_expiresOn);
+ hashCodeCombiner.Add(_expiresSliding);
+ hashCodeCombiner.Add(_varyBy, StringComparer.Ordinal);
+ hashCodeCombiner.Add(_username, StringComparer.Ordinal);
+
+ CombineCollectionHashCode(hashCodeCombiner, VaryByCookieName, _cookies);
+ CombineCollectionHashCode(hashCodeCombiner, VaryByHeaderName, _headers);
+ CombineCollectionHashCode(hashCodeCombiner, VaryByQueryName, _queries);
+ CombineCollectionHashCode(hashCodeCombiner, VaryByRouteName, _routeValues);
+
+ _hashcode = hashCodeCombiner.CombinedHash;
+
+ return _hashcode.Value;
+ }
+
+ private static IList> ExtractCollection(string keys, TSourceCollection collection, Func accessor)
+ {
+ if (string.IsNullOrEmpty(keys))
+ {
+ return null;
+ }
+
+ var tokenizer = new StringTokenizer(keys, AttributeSeparator);
+
+ var result = new List>();
+
+ foreach (var item in tokenizer)
+ {
+ var trimmedValue = item.Trim();
+
+ if (trimmedValue.Length != 0)
+ {
+ var value = accessor(collection, trimmedValue.Value);
+ result.Add(new KeyValuePair(trimmedValue.Value, value ?? string.Empty));
+ }
+ }
+
+ return result;
+ }
+
+ private static void AddStringCollection(
+ StringBuilder builder,
+ string collectionName,
+ IList> values)
+ {
+ if (values == null || values.Count == 0)
+ {
+ return;
+ }
+
+ // keyName(param1=value1|param2=value2)
+ builder
+ .Append(CacheKeyTokenSeparator)
+ .Append(collectionName)
+ .Append("(");
+
+ for (var i = 0; i < values.Count; i++)
+ {
+ var item = values[i];
+
+ if (i > 0)
+ {
+ builder.Append(CacheKeyTokenSeparator);
+ }
+
+ builder
+ .Append(item.Key)
+ .Append(CacheKeyTokenSeparator)
+ .Append(item.Value);
+ }
+
+ builder.Append(")");
+ }
+
+ private static void CombineCollectionHashCode(
+ HashCodeCombiner hashCodeCombiner,
+ string collectionName,
+ IList> values)
+ {
+ if (values != null)
+ {
+ hashCodeCombiner.Add(collectionName, StringComparer.Ordinal);
+
+ for (var i = 0; i < values.Count; i++)
+ {
+ var item = values[i];
+ hashCodeCombiner.Add(item.Key);
+ hashCodeCombiner.Add(item.Value);
+ }
+ }
+ }
+
+ private static bool AreSame(IList> values1, IList> values2)
+ {
+ if (values1 == values2)
+ {
+ return true;
+ }
+
+ if (values1 == null || values2 == null || values1.Count != values2.Count)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < values1.Count; i++)
+ {
+ if (!string.Equals(values1[i].Key, values2[i].Key, StringComparison.Ordinal) ||
+ !string.Equals(values1[i].Value, values2[i].Value, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs
index 91b420c248..94653073e9 100644
--- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs
@@ -1,44 +1,81 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+using System;
using System.Collections.Concurrent;
using System.IO;
+using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
+using Microsoft.AspNetCore.Mvc.TagHelpers.Internal;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
{
///
- /// Implements and ensure
+ /// Implements and ensures
/// multiple concurrent requests are gated.
+ /// The entries are stored like this:
+ ///
+ /// -
+ /// Int32 representing the hashed cache key size.
+ ///
+ /// -
+ /// The UTF8 encoded hashed cache key.
+ ///
+ /// -
+ /// The UTF8 encoded cached content.
+ ///
+ ///
///
public class DistributedCacheTagHelperService : IDistributedCacheTagHelperService
{
private readonly IDistributedCacheTagHelperStorage _storage;
private readonly IDistributedCacheTagHelperFormatter _formatter;
private readonly HtmlEncoder _htmlEncoder;
- private readonly ConcurrentDictionary> _workers;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary> _workers;
public DistributedCacheTagHelperService(
IDistributedCacheTagHelperStorage storage,
IDistributedCacheTagHelperFormatter formatter,
- HtmlEncoder HtmlEncoder
- )
+ HtmlEncoder HtmlEncoder,
+ ILoggerFactory loggerFactory)
{
+ if (storage == null)
+ {
+ throw new ArgumentNullException(nameof(storage));
+ }
+
+ if (formatter == null)
+ {
+ throw new ArgumentNullException(nameof(formatter));
+ }
+
+ if (HtmlEncoder == null)
+ {
+ throw new ArgumentNullException(nameof(HtmlEncoder));
+ }
+
+ if (loggerFactory == null)
+ {
+ throw new ArgumentNullException(nameof(loggerFactory));
+ }
+
_formatter = formatter;
_storage = storage;
_htmlEncoder = HtmlEncoder;
-
- _workers = new ConcurrentDictionary>();
+ _logger = loggerFactory.CreateLogger();
+ _workers = new ConcurrentDictionary>();
}
///
- public async Task ProcessContentAsync(TagHelperOutput output, string key, DistributedCacheEntryOptions options)
+ public async Task ProcessContentAsync(TagHelperOutput output, CacheTagKey key, DistributedCacheEntryOptions options)
{
IHtmlContent content = null;
@@ -55,10 +92,13 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
try
{
- var value = await _storage.GetAsync(key);
-
+ var serializedKey = Encoding.UTF8.GetBytes(key.GenerateKey());
+ var storageKey = key.GenerateHashedKey();
+ var value = await _storage.GetAsync(storageKey);
+
if (value == null)
{
+ // The value is not cached, we need to render the tag helper output
var processedContent = await output.GetChildContentAsync();
var stringBuilder = new StringBuilder();
@@ -72,28 +112,48 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
Html = new HtmlString(stringBuilder.ToString())
};
+ // Then cache the result
value = await _formatter.SerializeAsync(formattingContext);
- await _storage.SetAsync(key, value, options);
+ var encodeValue = Encode(value, serializedKey);
+
+ await _storage.SetAsync(storageKey, encodeValue, options);
content = formattingContext.Html;
}
else
{
- content = await _formatter.DeserializeAsync(value);
-
- // If the deserialization fails, it can return null, for instance when the
- // value is not in the expected format.
- if (content == null)
+ // The value was found in the storage, decode and ensure
+ // there is no cache key hash collision
+ byte[] decodedValue = Decode(value, serializedKey);
+
+ try
{
- content = await output.GetChildContentAsync();
+ if (decodedValue != null)
+ {
+ content = await _formatter.DeserializeAsync(decodedValue);
+ }
+ }
+ catch (Exception e)
+ {
+ _logger.DistributedFormatterDeserializationException(storageKey, e);
+ }
+ finally
+ {
+ // If the deserialization fails the content is rendered
+ if (content == null)
+ {
+ content = await output.GetChildContentAsync();
+ }
}
}
+ // Notify all other awaiters of the final content
tcs.TrySetResult(content);
}
catch
{
+ // Notify all other awaiters to render the content
tcs.TrySetResult(null);
throw;
}
@@ -112,5 +172,43 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
return content;
}
+
+ private byte[] Encode(byte[] value, byte[] serializedKey)
+ {
+ using (var buffer = new MemoryStream())
+ {
+ var keyLength = BitConverter.GetBytes(serializedKey.Length);
+
+ buffer.Write(keyLength, 0, keyLength.Length);
+ buffer.Write(serializedKey, 0, serializedKey.Length);
+ buffer.Write(value, 0, value.Length);
+
+ return buffer.ToArray();
+ }
+ }
+
+ private byte[] Decode(byte[] value, byte[] expectedKey)
+ {
+ byte[] decoded = null;
+
+ using (var buffer = new MemoryStream(value))
+ {
+ var keyLengthBuffer = new byte[sizeof(int)];
+ buffer.Read(keyLengthBuffer, 0, keyLengthBuffer.Length);
+
+ var keyLength = BitConverter.ToInt32(keyLengthBuffer, 0);
+ var serializedKeyBuffer = new byte[keyLength];
+ buffer.Read(serializedKeyBuffer, 0, serializedKeyBuffer.Length);
+
+ // Ensure we are reading the expected key before continuing
+ if (serializedKeyBuffer.SequenceEqual(expectedKey))
+ {
+ decoded = new byte[value.Length - keyLengthBuffer.Length - serializedKeyBuffer.Length];
+ buffer.Read(decoded, 0, decoded.Length);
+ }
+ }
+
+ return decoded;
+ }
}
}
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs
index 5843dbd38c..15abaab386 100644
--- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs
@@ -21,6 +21,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
/// The key in the storage.
/// The .
/// A cached or new content for the cache tag helper.
- Task ProcessContentAsync(TagHelperOutput output, string key, DistributedCacheEntryOptions options);
+ Task ProcessContentAsync(TagHelperOutput output, CacheTagKey key, DistributedCacheEntryOptions options);
}
}
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs
index 9fbf0c1ea6..3f23690c25 100644
--- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs
@@ -8,6 +8,7 @@ using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Html;
+using Microsoft.AspNetCore.Mvc.TagHelpers.Cache;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Primitives;
@@ -23,6 +24,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
/// Prefix used by instances when creating entries in .
///
public static readonly string CacheKeyPrefix = nameof(CacheTagHelper);
+
private const string CachePriorityAttributeName = "priority";
///
@@ -63,14 +65,15 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
if (Enabled)
{
- var key = GenerateKey(context);
+ var cacheKey = new CacheTagKey(this, context);
+
MemoryCacheEntryOptions options;
while (content == null)
{
Task result = null;
- if (!MemoryCache.TryGetValue(key, out result))
+ if (!MemoryCache.TryGetValue(cacheKey, out result))
{
var tokenSource = new CancellationTokenSource();
@@ -85,7 +88,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
// The returned value is ignored, we only do this so that
// the compiler doesn't complain about the returned task
// not being awaited
- var localTcs = MemoryCache.Set(key, tcs.Task, options);
+ var localTcs = MemoryCache.Set(cacheKey, tcs.Task, options);
try
{
@@ -93,7 +96,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
// task so that the expiration options are are not impacted
// by the time it took to compute it.
- using (var entry = MemoryCache.CreateEntry(key))
+ using (var entry = MemoryCache.CreateEntry(cacheKey))
{
// The result is processed inside an entry
// such that the tokens are inherited.
@@ -167,16 +170,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
return options;
}
- protected override string GetUniqueId(TagHelperContext context)
- {
- return context.UniqueId;
- }
-
- protected override string GetKeyPrefix(TagHelperContext context)
- {
- return CacheKeyPrefix;
- }
-
private async Task ProcessContentAsync(TagHelperOutput output)
{
var content = await output.GetChildContentAsync();
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs
index 929f257e56..86573abcb3 100644
--- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs
@@ -2,14 +2,10 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
-using System.Collections.Generic;
-using System.Security.Cryptography;
-using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
-using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.Mvc.TagHelpers
{
@@ -68,7 +64,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
public string VaryBy { get; set; }
///
- /// Gets or sets the name of a HTTP request header to vary the cached result by.
+ /// Gets or sets a comma-delimited set of HTTP request headers to vary the cached result by.
///
[HtmlAttributeName(VaryByHeaderAttributeName)]
public string VaryByHeader { get; set; }
@@ -122,191 +118,5 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
[HtmlAttributeName(EnabledAttributeName)]
public bool Enabled { get; set; } = true;
- // Internal for unit testing
- protected internal string GenerateKey(TagHelperContext context)
- {
- var builder = new StringBuilder(GetKeyPrefix(context));
- builder
- .Append(CacheKeyTokenSeparator)
- .Append(GetUniqueId(context));
-
- var request = ViewContext.HttpContext.Request;
-
- if (!string.IsNullOrEmpty(VaryBy))
- {
- builder
- .Append(CacheKeyTokenSeparator)
- .Append(nameof(VaryBy))
- .Append(CacheKeyTokenSeparator)
- .Append(VaryBy);
- }
-
- AddStringCollectionKey(builder, nameof(VaryByCookie), VaryByCookie, request.Cookies, (c, key) => c[key]);
- AddStringCollectionKey(builder, nameof(VaryByHeader), VaryByHeader, request.Headers, (c, key) => c[key]);
- AddStringCollectionKey(builder, nameof(VaryByQuery), VaryByQuery, request.Query, (c, key) => c[key]);
- AddVaryByRouteKey(builder);
-
- if (VaryByUser)
- {
- builder
- .Append(CacheKeyTokenSeparator)
- .Append(nameof(VaryByUser))
- .Append(CacheKeyTokenSeparator)
- .Append(ViewContext.HttpContext.User?.Identity?.Name);
- }
-
- // The key is typically too long to be useful, so we use a cryptographic hash
- // as the actual key (better randomization and key distribution, so small vary
- // values will generate dramatically different keys).
- using (var sha = SHA256.Create())
- {
- var contentBytes = Encoding.UTF8.GetBytes(builder.ToString());
- var hashedBytes = sha.ComputeHash(contentBytes);
- return Convert.ToBase64String(hashedBytes);
- }
- }
-
- protected abstract string GetUniqueId(TagHelperContext context);
-
- protected abstract string GetKeyPrefix(TagHelperContext context);
-
- protected static void AddStringCollectionKey(
- StringBuilder builder,
- string keyName,
- string value,
- IDictionary sourceCollection)
- {
- if (string.IsNullOrEmpty(value))
- {
- return;
- }
-
- // keyName(param1=value1|param2=value2)
- builder
- .Append(CacheKeyTokenSeparator)
- .Append(keyName)
- .Append("(");
-
- var values = Tokenize(value);
-
- // Perf: Avoid allocating enumerator
- for (var i = 0; i < values.Count; i++)
- {
- var item = values[i];
- builder
- .Append(item)
- .Append(CacheKeyTokenSeparator)
- .Append(sourceCollection[item])
- .Append(CacheKeyTokenSeparator);
- }
-
- if (values.Count > 0)
- {
- // Remove the trailing separator
- builder.Length -= CacheKeyTokenSeparator.Length;
- }
-
- builder.Append(")");
- }
-
- protected static void AddStringCollectionKey(
- StringBuilder builder,
- string keyName,
- string value,
- TSourceCollection sourceCollection,
- Func accessor)
- {
- if (string.IsNullOrEmpty(value))
- {
- return;
- }
-
- // keyName(param1=value1|param2=value2)
- builder
- .Append(CacheKeyTokenSeparator)
- .Append(keyName)
- .Append("(");
-
- var values = Tokenize(value);
-
- // Perf: Avoid allocating enumerator
- for (var i = 0; i < values.Count; i++)
- {
- var item = values[i];
-
- builder
- .Append(item)
- .Append(CacheKeyTokenSeparator)
- .Append(accessor(sourceCollection, item))
- .Append(CacheKeyTokenSeparator);
- }
-
- if (values.Count > 0)
- {
- // Remove the trailing separator
- builder.Length -= CacheKeyTokenSeparator.Length;
- }
-
- builder.Append(")");
- }
-
- protected static IList Tokenize(string value)
- {
- var values = value.Split(AttributeSeparator, StringSplitOptions.RemoveEmptyEntries);
- if (values.Length == 0)
- {
- return values;
- }
-
- var trimmedValues = new List();
-
- for (var i = 0; i < values.Length; i++)
- {
- var trimmedValue = values[i].Trim();
-
- if (trimmedValue.Length > 0)
- {
- trimmedValues.Add(trimmedValue);
- }
- }
-
- return trimmedValues;
- }
-
- protected void AddVaryByRouteKey(StringBuilder builder)
- {
- var tokenFound = false;
-
- if (string.IsNullOrEmpty(VaryByRoute))
- {
- return;
- }
-
- builder
- .Append(CacheKeyTokenSeparator)
- .Append(nameof(VaryByRoute))
- .Append("(");
-
- var varyByRoutes = Tokenize(VaryByRoute);
- for (var i = 0; i < varyByRoutes.Count; i++)
- {
- var route = varyByRoutes[i];
- tokenFound = true;
-
- builder
- .Append(route)
- .Append(CacheKeyTokenSeparator)
- .Append(ViewContext.RouteData.Values[route])
- .Append(CacheKeyTokenSeparator);
- }
-
- if (tokenFound)
- {
- builder.Length -= CacheKeyTokenSeparator.Length;
- }
-
- builder.Append(")");
- }
-
}
}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs
index fde61ed662..04edc01b1e 100644
--- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs
@@ -73,9 +73,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
if (Enabled)
{
- var key = GenerateKey(context);
+ var cacheKey = new CacheTagKey(this);
- content = await _distributedCacheService.ProcessContentAsync(output, key, GetDistributedCacheEntryOptions());
+ content = await _distributedCacheService.ProcessContentAsync(output, cacheKey, GetDistributedCacheEntryOptions());
}
else
{
@@ -109,15 +109,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
return options;
}
-
- protected override string GetUniqueId(TagHelperContext context)
- {
- return Name;
- }
-
- protected override string GetKeyPrefix(TagHelperContext context)
- {
- return CacheKeyPrefix;
- }
+
}
}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/MvcTagHelpersLoggerExtensions.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/MvcTagHelpersLoggerExtensions.cs
new file mode 100644
index 0000000000..1943e1bcb8
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Internal/MvcTagHelpersLoggerExtensions.cs
@@ -0,0 +1,26 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.AspNetCore.Mvc.TagHelpers.Internal
+{
+ internal static class MvcTagHelperLoggerExtensions
+ {
+ private static readonly Action _distributedFormatterDeserializedFailed;
+
+ static MvcTagHelperLoggerExtensions()
+ {
+ _distributedFormatterDeserializedFailed = LoggerMessage.Define(
+ LogLevel.Error,
+ 1,
+ "Couldn't deserialize cached value for key {Key}.");
+ }
+
+ public static void DistributedFormatterDeserializationException(this ILogger logger, string key, Exception exception)
+ {
+ _distributedFormatterDeserializedFailed(logger, key, exception);
+ }
+ }
+}
diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs
index 76f4fcd290..9f1ba25f35 100644
--- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs
+++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs
@@ -4,10 +4,6 @@
using System;
using System.Collections.Generic;
using System.IO;
-using System.Security.Claims;
-using System.Security.Cryptography;
-using System.Text;
-using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Html;
@@ -15,6 +11,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
+using Microsoft.AspNetCore.Mvc.TagHelpers.Cache;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
@@ -30,218 +27,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
{
public class CacheTagHelperTest
{
- [Fact]
- public void GenerateKey_ReturnsKeyBasedOnTagHelperUniqueId()
- {
- // Arrange
- var id = Guid.NewGuid().ToString();
- var tagHelperContext = GetTagHelperContext(id);
- var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder())
- {
- ViewContext = GetViewContext()
- };
- var expected = GetHashedBytes("CacheTagHelper||" + id);
-
- // Act
- var key = cacheTagHelper.GenerateKey(tagHelperContext);
-
- // Assert
- Assert.Equal(expected, key);
- }
-
- [Theory]
- [InlineData("Vary-By-Value")]
- [InlineData("Vary with spaces")]
- [InlineData(" Vary with more spaces ")]
- public void GenerateKey_UsesVaryByPropertyToGenerateKey(string varyBy)
- {
- // Arrange
- var tagHelperContext = GetTagHelperContext();
- var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder())
- {
- ViewContext = GetViewContext(),
- VaryBy = varyBy
- };
- var expected = GetHashedBytes("CacheTagHelper||testid||VaryBy||" + varyBy);
-
- // Act
- var key = cacheTagHelper.GenerateKey(tagHelperContext);
-
- // Assert
- Assert.Equal(expected, key);
- }
-
- [Theory]
- [InlineData("Cookie0", "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value)")]
- [InlineData("Cookie0,Cookie1",
- "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
- [InlineData("Cookie0, Cookie1",
- "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
- [InlineData(" Cookie0, , Cookie1 ",
- "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
- [InlineData(",Cookie0,,Cookie1,",
- "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
- public void GenerateKey_UsesVaryByCookieName(string varyByCookie, string expected)
- {
- // Arrange
- var tagHelperContext = GetTagHelperContext();
- var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder())
- {
- ViewContext = GetViewContext(),
- VaryByCookie = varyByCookie
- };
- cacheTagHelper.ViewContext.HttpContext.Request.Headers["Cookie"] =
- "Cookie0=Cookie0Value;Cookie1=Cookie1Value";
-
- // Act
- var key = cacheTagHelper.GenerateKey(tagHelperContext);
-
- // Assert
- Assert.Equal(GetHashedBytes(expected), key);
- }
-
- [Theory]
- [InlineData("Accept-Language", "CacheTagHelper||testid||VaryByHeader(Accept-Language||en-us;charset=utf8)")]
- [InlineData("X-CustomHeader,Accept-Encoding, NotAvailable",
- "CacheTagHelper||testid||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")]
- [InlineData("X-CustomHeader, , Accept-Encoding, NotAvailable",
- "CacheTagHelper||testid||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")]
- public void GenerateKey_UsesVaryByHeader(string varyByHeader, string expected)
- {
- // Arrange
- var tagHelperContext = GetTagHelperContext();
- var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder())
- {
- ViewContext = GetViewContext(),
- VaryByHeader = varyByHeader
- };
- var headers = cacheTagHelper.ViewContext.HttpContext.Request.Headers;
- headers["Accept-Language"] = "en-us;charset=utf8";
- headers["Accept-Encoding"] = "utf8";
- headers["X-CustomHeader"] = "Header-Value";
-
- // Act
- var key = cacheTagHelper.GenerateKey(tagHelperContext);
-
- // Assert
- Assert.Equal(GetHashedBytes(expected), key);
- }
-
- [Theory]
- [InlineData("category", "CacheTagHelper||testid||VaryByQuery(category||cats)")]
- [InlineData("Category,SortOrder,SortOption",
- "CacheTagHelper||testid||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")]
- [InlineData("Category, SortOrder, SortOption, ",
- "CacheTagHelper||testid||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")]
- public void GenerateKey_UsesVaryByQuery(string varyByQuery, string expected)
- {
- // Arrange
- var tagHelperContext = GetTagHelperContext();
- var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder())
- {
- ViewContext = GetViewContext(),
- VaryByQuery = varyByQuery
- };
- cacheTagHelper.ViewContext.HttpContext.Request.QueryString =
- new QueryString("?sortoption=Adorability&Category=cats&sortOrder=");
-
- // Act
- var key = cacheTagHelper.GenerateKey(tagHelperContext);
-
- // Assert
- Assert.Equal(GetHashedBytes(expected), key);
- }
-
- [Theory]
- [InlineData("id", "CacheTagHelper||testid||VaryByRoute(id||4)")]
- [InlineData("Category,,Id,OptionRouteValue",
- "CacheTagHelper||testid||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")]
- [InlineData(" Category, , Id, OptionRouteValue, ",
- "CacheTagHelper||testid||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")]
- public void GenerateKey_UsesVaryByRoute(string varyByRoute, string expected)
- {
- // Arrange
- var tagHelperContext = GetTagHelperContext();
- var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder())
- {
- ViewContext = GetViewContext(),
- VaryByRoute = varyByRoute
- };
- cacheTagHelper.ViewContext.RouteData.Values["id"] = 4;
- cacheTagHelper.ViewContext.RouteData.Values["category"] = "MyCategory";
-
- // Act
- var key = cacheTagHelper.GenerateKey(tagHelperContext);
-
- // Assert
- Assert.Equal(GetHashedBytes(expected), key);
- }
-
- [Fact]
- public void GenerateKey_UsesVaryByUser_WhenUserIsNotAuthenticated()
- {
- // Arrange
- var expected = "CacheTagHelper||testid||VaryByUser||";
- var tagHelperContext = GetTagHelperContext();
- var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder())
- {
- ViewContext = GetViewContext(),
- VaryByUser = true
- };
-
- // Act
- var key = cacheTagHelper.GenerateKey(tagHelperContext);
-
- // Assert
- Assert.Equal(GetHashedBytes(expected), key);
- }
-
- [Fact]
- public void GenerateKey_UsesVaryByUserAndAuthenticatedUserName()
- {
- // Arrange
- var expected = "CacheTagHelper||testid||VaryByUser||test_name";
- var tagHelperContext = GetTagHelperContext();
- var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder())
- {
- ViewContext = GetViewContext(),
- VaryByUser = true
- };
- var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "test_name") });
- cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity);
-
- // Act
- var key = cacheTagHelper.GenerateKey(tagHelperContext);
-
- // Assert
- Assert.Equal(GetHashedBytes(expected), key);
- }
-
- [Fact]
- public void GenerateKey_WithMultipleVaryByOptions_CreatesCombinedKey()
- {
- // Arrange
- var expected = GetHashedBytes("CacheTagHelper||testid||VaryBy||custom-value||" +
- "VaryByHeader(content-type||text/html)||VaryByUser||someuser");
- var tagHelperContext = GetTagHelperContext();
- var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder())
- {
- ViewContext = GetViewContext(),
- VaryByUser = true,
- VaryByHeader = "content-type",
- VaryBy = "custom-value"
- };
- cacheTagHelper.ViewContext.HttpContext.Request.Headers["Content-Type"] = "text/html";
- var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "someuser") });
- cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity);
-
- // Act
- var key = cacheTagHelper.GenerateKey(tagHelperContext);
-
- // Assert
- Assert.Equal(expected, key);
- }
-
[Fact]
public async Task ProcessAsync_DoesNotCache_IfDisabled()
{
@@ -284,21 +69,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
// Arrange
var id = "unique-id";
var childContent = "original-child-content";
- var cache = new Mock();
- var value = new Mock();
- value.Setup(c => c.Value).Returns(new DefaultTagHelperContent().SetContent("ok"));
- value.Setup(c => c.ExpirationTokens).Returns(new List());
- cache.Setup(c => c.CreateEntry(
- /*key*/ It.IsAny()))
- .Returns((object key) => value.Object);
- object cacheResult;
- cache.Setup(c => c.TryGetValue(It.IsAny(), out cacheResult))
- .Returns(false);
+ var cache = new MemoryCache(new MemoryCacheOptions());
var tagHelperContext = GetTagHelperContext(id);
var tagHelperOutput = GetTagHelperOutput(
attributes: new TagHelperAttributeList(),
childContent: childContent);
- var cacheTagHelper = new CacheTagHelper(cache.Object, new HtmlTestEncoder())
+ var cacheTagHelper = new CacheTagHelper(cache, new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
Enabled = true
@@ -312,11 +88,6 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
Assert.Empty(tagHelperOutput.PostContent.GetContent());
Assert.True(tagHelperOutput.IsContentModified);
Assert.Equal(childContent, tagHelperOutput.Content.GetContent());
-
- // There are two calls to set (for the TCS and the processed value)
- cache.Verify(c => c.CreateEntry(
- /*key*/ It.IsAny()),
- Times.Exactly(2));
}
[Fact]
@@ -686,12 +457,14 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
{
ViewContext = GetViewContext(),
};
- var key = cacheTagHelper.GenerateKey(tagHelperContext);
+
+ var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext);
+ var key = cacheTagKey.GenerateKey();
// Act - 1
await cacheTagHelper.ProcessAsync(tagHelperContext, tagHelperOutput);
Task cachedValue;
- var result = cache.TryGetValue(key, out cachedValue);
+ var result = cache.TryGetValue(cacheTagKey, out cachedValue);
// Assert - 1
Assert.Equal("HtmlEncode[[some-content]]", tagHelperOutput.Content.GetContent());
@@ -699,7 +472,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
// Act - 2
tokenSource.Cancel();
- result = cache.TryGetValue(key, out cachedValue);
+ result = cache.TryGetValue(cacheTagKey, out cachedValue);
// Assert - 2
Assert.False(result);
@@ -910,26 +683,5 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
return Task.FromResult(tagHelperContent);
});
}
-
- private static TagHelperOutput GetTagHelperOutput(
- Func> processAsync)
- {
- var attributes = new TagHelperAttributeList { { "attr", "value" } };
-
- return new TagHelperOutput(
- "cache",
- attributes,
- getChildContentAsync: processAsync);
- }
-
- private static string GetHashedBytes(string input)
- {
- using (var sha = SHA256.Create())
- {
- var contentBytes = Encoding.UTF8.GetBytes(input);
- var hashedBytes = sha.ComputeHash(contentBytes);
- return Convert.ToBase64String(hashedBytes);
- }
- }
- }
+ }
}
diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs.orig b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs.orig
new file mode 100644
index 0000000000..caa1c09ddc
--- /dev/null
+++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs.orig
@@ -0,0 +1,701 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+<<<<<<< HEAD
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Encodings.Web;
+=======
+>>>>>>> [Fixes #4246] Introducing CacheTagKey
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Html;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.Abstractions;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using Microsoft.AspNetCore.Mvc.ViewEngines;
+using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Internal;
+using Microsoft.Extensions.Primitives;
+using Microsoft.Extensions.WebEncoders.Testing;
+using Moq;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Mvc.TagHelpers
+{
+ public class CacheTagHelperTest
+ {
+ [Fact]
+ public async Task ProcessAsync_DoesNotCache_IfDisabled()
+ {
+ // Arrange
+ var id = "unique-id";
+ var childContent = "original-child-content";
+ var cache = new Mock();
+ var value = new Mock();
+ value.Setup(c => c.Value).Returns(new DefaultTagHelperContent().SetContent("ok"));
+ cache.Setup(c => c.CreateEntry(
+ /*key*/ It.IsAny()))
+ .Returns((object key) => value.Object)
+ .Verifiable();
+ object cacheResult;
+ cache.Setup(c => c.TryGetValue(It.IsAny(), out cacheResult))
+ .Returns(false);
+ var tagHelperContext = GetTagHelperContext(id);
+ var tagHelperOutput = GetTagHelperOutput(
+ attributes: new TagHelperAttributeList(),
+ childContent: childContent);
+ var cacheTagHelper = new CacheTagHelper(cache.Object, new HtmlTestEncoder())
+ {
+ ViewContext = GetViewContext(),
+ Enabled = false
+ };
+
+ // Act
+ await cacheTagHelper.ProcessAsync(tagHelperContext, tagHelperOutput);
+
+ // Assert
+ Assert.Equal(childContent, tagHelperOutput.Content.GetContent());
+ cache.Verify(c => c.CreateEntry(
+ /*key*/ It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_ReturnsCachedValue_IfEnabled()
+ {
+ // Arrange
+ var id = "unique-id";
+ var childContent = "original-child-content";
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var tagHelperContext = GetTagHelperContext(id);
+ var tagHelperOutput = GetTagHelperOutput(
+ attributes: new TagHelperAttributeList(),
+ childContent: childContent);
+ var cacheTagHelper = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ ViewContext = GetViewContext(),
+ Enabled = true
+ };
+
+ // Act
+ await cacheTagHelper.ProcessAsync(tagHelperContext, tagHelperOutput);
+
+ // Assert
+ Assert.Empty(tagHelperOutput.PreContent.GetContent());
+ Assert.Empty(tagHelperOutput.PostContent.GetContent());
+ Assert.True(tagHelperOutput.IsContentModified);
+ Assert.Equal(childContent, tagHelperOutput.Content.GetContent());
+<<<<<<< HEAD
+
+ // There are two calls to set (for the TCS and the processed value)
+ cache.Verify(c => c.CreateEntry(
+ /*key*/ It.IsAny()),
+ Times.Exactly(2));
+=======
+>>>>>>> [Fixes #4246] Introducing CacheTagKey
+ }
+
+ [Fact]
+ public async Task ProcessAsync_ReturnsCachedValue_IfVaryByParamIsUnchanged()
+ {
+ // Arrange - 1
+ var id = "unique-id";
+ var childContent = "original-child-content";
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var tagHelperContext1 = GetTagHelperContext(id);
+ var tagHelperOutput1 = GetTagHelperOutput(
+ attributes: new TagHelperAttributeList(),
+ childContent: childContent);
+ var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ VaryByQuery = "key1,key2",
+ ViewContext = GetViewContext(),
+ };
+ cacheTagHelper1.ViewContext.HttpContext.Request.QueryString = new QueryString(
+ "?key1=value1&key2=value2");
+
+ // Act - 1
+ await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
+
+ // Assert - 1
+ Assert.Empty(tagHelperOutput1.PreContent.GetContent());
+ Assert.Empty(tagHelperOutput1.PostContent.GetContent());
+ Assert.True(tagHelperOutput1.IsContentModified);
+ Assert.Equal(childContent, tagHelperOutput1.Content.GetContent());
+
+ // Arrange - 2
+ var tagHelperContext2 = GetTagHelperContext(id);
+ var tagHelperOutput2 = GetTagHelperOutput(
+ attributes: new TagHelperAttributeList(),
+ childContent: "different-content");
+ var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ VaryByQuery = "key1,key2",
+ ViewContext = GetViewContext(),
+ };
+ cacheTagHelper2.ViewContext.HttpContext.Request.QueryString = new QueryString(
+ "?key1=value1&key2=value2");
+
+ // Act - 2
+ await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
+
+ // Assert - 2
+ Assert.Empty(tagHelperOutput2.PreContent.GetContent());
+ Assert.Empty(tagHelperOutput2.PostContent.GetContent());
+ Assert.True(tagHelperOutput2.IsContentModified);
+ Assert.Equal(childContent, tagHelperOutput2.Content.GetContent());
+ }
+
+ [Fact]
+ public async Task ProcessAsync_RecalculatesValueIfCacheKeyChanges()
+ {
+ // Arrange - 1
+ var id = "unique-id";
+ var childContent1 = "original-child-content";
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var tagHelperContext1 = GetTagHelperContext(id);
+ var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1);
+ tagHelperOutput1.PreContent.Append("");
+ tagHelperOutput1.PostContent.SetContent("");
+ var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ VaryByCookie = "cookie1,cookie2",
+ ViewContext = GetViewContext(),
+ };
+ cacheTagHelper1.ViewContext.HttpContext.Request.Headers["Cookie"] = "cookie1=value1;cookie2=value2";
+
+ // Act - 1
+ await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
+
+ // Assert - 1
+ Assert.Empty(tagHelperOutput1.PreContent.GetContent());
+ Assert.Empty(tagHelperOutput1.PostContent.GetContent());
+ Assert.True(tagHelperOutput1.IsContentModified);
+ Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent());
+
+ // Arrange - 2
+ var childContent2 = "different-content";
+ var tagHelperContext2 = GetTagHelperContext(id);
+ var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2);
+ tagHelperOutput2.PreContent.SetContent("");
+ tagHelperOutput2.PostContent.SetContent("");
+ var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ VaryByCookie = "cookie1,cookie2",
+ ViewContext = GetViewContext(),
+ };
+ cacheTagHelper2.ViewContext.HttpContext.Request.Headers["Cookie"] = "cookie1=value1;cookie2=not-value2";
+
+ // Act - 2
+ await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
+
+ // Assert - 2
+ Assert.Empty(tagHelperOutput2.PreContent.GetContent());
+ Assert.Empty(tagHelperOutput2.PostContent.GetContent());
+ Assert.True(tagHelperOutput2.IsContentModified);
+ Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent());
+ }
+
+ [Fact]
+ public void UpdateCacheEntryOptions_SetsAbsoluteExpiration_IfExpiresOnIsSet()
+ {
+ // Arrange
+ var expiresOn = DateTimeOffset.UtcNow.AddMinutes(4);
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var cacheTagHelper = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ ExpiresOn = expiresOn
+ };
+
+ // Act
+ var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions();
+
+ // Assert
+ Assert.Equal(expiresOn, cacheEntryOptions.AbsoluteExpiration);
+ }
+
+ [Fact]
+ public void UpdateCacheEntryOptions_SetsAbsoluteExpiration_IfExpiresAfterIsSet()
+ {
+ // Arrange
+ var expiresAfter = TimeSpan.FromSeconds(42);
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var cacheTagHelper = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ ExpiresAfter = expiresAfter
+ };
+
+ // Act
+ var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions();
+
+ // Assert
+ Assert.Equal(expiresAfter, cacheEntryOptions.AbsoluteExpirationRelativeToNow);
+ }
+
+ [Fact]
+ public void UpdateCacheEntryOptions_SetsSlidingExpiration_IfExpiresSlidingIsSet()
+ {
+ // Arrange
+ var expiresSliding = TimeSpan.FromSeconds(37);
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var cacheTagHelper = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ ExpiresSliding = expiresSliding
+ };
+
+ // Act
+ var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions();
+
+ // Assert
+ Assert.Equal(expiresSliding, cacheEntryOptions.SlidingExpiration);
+ }
+
+ [Fact]
+ public void UpdateCacheEntryOptions_SetsCachePreservationPriority()
+ {
+ // Arrange
+ var priority = CacheItemPriority.High;
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var cacheTagHelper = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ Priority = priority
+ };
+
+ // Act
+ var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions();
+
+ // Assert
+ Assert.Equal(priority, cacheEntryOptions.Priority);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_UsesExpiresAfter_ToExpireCacheEntry()
+ {
+ // Arrange - 1
+ var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var id = "unique-id";
+ var childContent1 = "original-child-content";
+ var clock = new Mock();
+ clock.SetupGet(p => p.UtcNow)
+ .Returns(() => currentTime);
+ var cache = new MemoryCache(new MemoryCacheOptions { Clock = clock.Object });
+ var tagHelperContext1 = GetTagHelperContext(id);
+ var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1);
+ tagHelperOutput1.PreContent.SetContent("");
+ tagHelperOutput1.PostContent.SetContent("");
+ var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ ViewContext = GetViewContext(),
+ ExpiresAfter = TimeSpan.FromMinutes(10)
+ };
+
+ // Act - 1
+ await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
+
+ // Assert - 1
+ Assert.Empty(tagHelperOutput1.PreContent.GetContent());
+ Assert.Empty(tagHelperOutput1.PostContent.GetContent());
+ Assert.True(tagHelperOutput1.IsContentModified);
+ Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent());
+
+ // Arrange - 2
+ var childContent2 = "different-content";
+ var tagHelperContext2 = GetTagHelperContext(id);
+ var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2);
+ tagHelperOutput2.PreContent.SetContent("");
+ tagHelperOutput2.PostContent.SetContent("");
+ var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ ViewContext = GetViewContext(),
+ ExpiresAfter = TimeSpan.FromMinutes(10)
+ };
+ currentTime = currentTime.AddMinutes(11);
+
+ // Act - 2
+ await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
+
+ // Assert - 2
+ Assert.Empty(tagHelperOutput2.PreContent.GetContent());
+ Assert.Empty(tagHelperOutput2.PostContent.GetContent());
+ Assert.True(tagHelperOutput2.IsContentModified);
+ Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent());
+ }
+
+ [Fact]
+ public async Task ProcessAsync_UsesExpiresOn_ToExpireCacheEntry()
+ {
+ // Arrange - 1
+ var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var id = "unique-id";
+ var childContent1 = "original-child-content";
+ var clock = new Mock();
+ clock.SetupGet(p => p.UtcNow)
+ .Returns(() => currentTime);
+ var cache = new MemoryCache(new MemoryCacheOptions { Clock = clock.Object });
+ var tagHelperContext1 = GetTagHelperContext(id);
+ var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1);
+ tagHelperOutput1.PreContent.SetContent("");
+ tagHelperOutput1.PostContent.SetContent("");
+ var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ ViewContext = GetViewContext(),
+ ExpiresOn = currentTime.AddMinutes(5)
+ };
+
+ // Act - 1
+ await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
+
+ // Assert - 1
+ Assert.Empty(tagHelperOutput1.PreContent.GetContent());
+ Assert.Empty(tagHelperOutput1.PostContent.GetContent());
+ Assert.True(tagHelperOutput1.IsContentModified);
+ Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent());
+
+ // Arrange - 2
+ currentTime = currentTime.AddMinutes(5).AddSeconds(2);
+ var childContent2 = "different-content";
+ var tagHelperContext2 = GetTagHelperContext(id);
+ var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2);
+ tagHelperOutput2.PreContent.SetContent("");
+ tagHelperOutput2.PostContent.SetContent("");
+ var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ ViewContext = GetViewContext(),
+ ExpiresOn = currentTime.AddMinutes(5)
+ };
+
+ // Act - 2
+ await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
+
+ // Assert - 2
+ Assert.Empty(tagHelperOutput2.PreContent.GetContent());
+ Assert.Empty(tagHelperOutput2.PostContent.GetContent());
+ Assert.True(tagHelperOutput2.IsContentModified);
+ Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent());
+ }
+
+ [Fact]
+ public async Task ProcessAsync_UsesExpiresSliding_ToExpireCacheEntryWithSlidingExpiration()
+ {
+ // Arrange - 1
+ var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var id = "unique-id";
+ var childContent1 = "original-child-content";
+ var clock = new Mock();
+ clock.SetupGet(p => p.UtcNow)
+ .Returns(() => currentTime);
+ var cache = new MemoryCache(new MemoryCacheOptions { Clock = clock.Object });
+ var tagHelperContext1 = GetTagHelperContext(id);
+ var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1);
+ tagHelperOutput1.PreContent.SetContent("");
+ tagHelperOutput1.PostContent.SetContent("");
+ var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ ViewContext = GetViewContext(),
+ ExpiresSliding = TimeSpan.FromSeconds(30)
+ };
+
+ // Act - 1
+ await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
+
+ // Assert - 1
+ Assert.Empty(tagHelperOutput1.PreContent.GetContent());
+ Assert.Empty(tagHelperOutput1.PostContent.GetContent());
+ Assert.True(tagHelperOutput1.IsContentModified);
+ Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent());
+
+ // Arrange - 2
+ currentTime = currentTime.AddSeconds(35);
+ var childContent2 = "different-content";
+ var tagHelperContext2 = GetTagHelperContext(id);
+ var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2);
+ tagHelperOutput2.PreContent.SetContent("");
+ tagHelperOutput2.PostContent.SetContent("");
+ var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder())
+ {
+ ViewContext = GetViewContext(),
+ ExpiresSliding = TimeSpan.FromSeconds(30)
+ };
+
+ // Act - 2
+ await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
+
+ // Assert - 2
+ Assert.Empty(tagHelperOutput2.PreContent.GetContent());
+ Assert.Empty(tagHelperOutput2.PostContent.GetContent());
+ Assert.True(tagHelperOutput2.IsContentModified);
+ Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent());
+ }
+
+ [Fact]
+ public async Task ProcessAsync_FlowsEntryLinkThatAllowsAddingTriggersToAddedEntry()
+ {
+ // Arrange
+ var id = "some-id";
+ var expectedContent = new DefaultTagHelperContent();
+ expectedContent.SetContent("some-content");
+ var tokenSource = new CancellationTokenSource();
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var cacheEntryOptions = new MemoryCacheEntryOptions()
+ .AddExpirationToken(new CancellationChangeToken(tokenSource.Token));
+ var tagHelperContext = new TagHelperContext(
+ allAttributes: new TagHelperAttributeList(),
+ items: new Dictionary