CacheTagHelper should be able to vary by culture

Fixes #3398
This commit is contained in:
Pranav K 2018-05-10 07:18:49 -07:00
parent 57eb52ad47
commit d9f035ad7c
No known key found for this signature in database
GPG Key ID: 1963DA6D96C3057A
7 changed files with 449 additions and 14 deletions

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.TagHelpers.Internal;
@ -32,6 +33,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
private const string VaryByRouteName = "VaryByRoute";
private const string VaryByCookieName = "VaryByCookie";
private const string VaryByUserName = "VaryByUser";
private const string VaryByCulture = "VaryByCulture";
private readonly string _prefix;
private readonly string _varyBy;
@ -43,7 +45,10 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
private readonly IList<KeyValuePair<string, string>> _routeValues;
private readonly IList<KeyValuePair<string, string>> _cookies;
private readonly bool _varyByUser;
private readonly bool _varyByCulture;
private readonly string _username;
private readonly CultureInfo _requestCulture;
private readonly CultureInfo _requestUICulture;
private string _generatedKey;
private int? _hashcode;
@ -87,11 +92,18 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
_queries = ExtractCollection(tagHelper.VaryByQuery, request.Query, QueryAccessor);
_routeValues = ExtractCollection(tagHelper.VaryByRoute, tagHelper.ViewContext.RouteData.Values, RouteValueAccessor);
_varyByUser = tagHelper.VaryByUser;
_varyByCulture = tagHelper.VaryByCulture;
if (_varyByUser)
{
_username = httpContext.User?.Identity?.Name;
}
if (_varyByCulture)
{
_requestCulture = CultureInfo.CurrentCulture;
_requestUICulture = CultureInfo.CurrentUICulture;
}
}
// Internal for unit testing.
@ -137,6 +149,17 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
.Append(_username);
}
if (_varyByCulture)
{
builder
.Append(CacheKeyTokenSeparator)
.Append(VaryByCulture)
.Append(CacheKeyTokenSeparator)
.Append(_requestCulture)
.Append(CacheKeyTokenSeparator)
.Append(_requestUICulture);
}
_generatedKey = builder.ToString();
return _generatedKey;
@ -164,13 +187,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
/// <inheritdoc />
public override bool Equals(object obj)
{
var other = obj as CacheTagKey;
if (other == null)
if (obj is CacheTagKey other)
{
return false;
return Equals(other);
}
return Equals(other);
return false;
}
/// <inheritdoc />
@ -185,8 +207,26 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
AreSame(_headers, other._headers) &&
AreSame(_queries, other._queries) &&
AreSame(_routeValues, other._routeValues) &&
_varyByUser == other._varyByUser &&
(!_varyByUser || string.Equals(other._username, _username, StringComparison.Ordinal));
(_varyByUser == other._varyByUser &&
(!_varyByUser || string.Equals(other._username, _username, StringComparison.Ordinal))) &&
CultureEquals();
bool CultureEquals()
{
if (_varyByCulture != other._varyByCulture)
{
return false;
}
if (!_varyByCulture)
{
// Neither has culture set.
return true;
}
return _requestCulture.Equals(other._requestCulture) &&
_requestUICulture.Equals(other._requestUICulture);
}
}
/// <inheritdoc />
@ -211,6 +251,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
hashCodeCombiner.Add(_expiresSliding);
hashCodeCombiner.Add(_varyBy, StringComparer.Ordinal);
hashCodeCombiner.Add(_username, StringComparer.Ordinal);
hashCodeCombiner.Add(_requestCulture);
hashCodeCombiner.Add(_requestUICulture);
CombineCollectionHashCode(hashCodeCombiner, VaryByCookieName, _cookies);
CombineCollectionHashCode(hashCodeCombiner, VaryByHeaderName, _headers);
@ -222,7 +264,10 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
return _hashcode.Value;
}
private static IList<KeyValuePair<string, string>> ExtractCollection<TSourceCollection>(string keys, TSourceCollection collection, Func<TSourceCollection, string, string> accessor)
private static IList<KeyValuePair<string, string>> ExtractCollection<TSourceCollection>(
string keys,
TSourceCollection collection,
Func<TSourceCollection, string, string> accessor)
{
if (string.IsNullOrEmpty(keys))
{
@ -323,4 +368,4 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
return true;
}
}
}
}

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Globalization;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
@ -20,6 +21,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
private const string VaryByRouteAttributeName = "vary-by-route";
private const string VaryByCookieAttributeName = "vary-by-cookie";
private const string VaryByUserAttributeName = "vary-by-user";
private const string VaryByCultureAttributeName = "vary-by-culture";
private const string ExpiresOnAttributeName = "expires-on";
private const string ExpiresAfterAttributeName = "expires-after";
private const string ExpiresSlidingAttributeName = "expires-sliding";
@ -93,6 +95,16 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
[HtmlAttributeName(VaryByUserAttributeName)]
public bool VaryByUser { get; set; }
/// <summary>
/// Gets or sets a value that determines if the cached result is to be varied by request culture.
/// <para>
/// Setting this to <c>true</c> would result in the result to be varied by <see cref="CultureInfo.CurrentCulture" />
/// and <see cref="CultureInfo.CurrentUICulture" />.
/// </para>
/// </summary>
[HtmlAttributeName(VaryByCultureAttributeName)]
public bool VaryByCulture { get; set; }
/// <summary>
/// Gets or sets the exact <see cref="DateTimeOffset"/> the cache entry should be evicted.
/// </summary>
@ -117,4 +129,4 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
[HtmlAttributeName(EnabledAttributeName)]
public bool Enabled { get; set; } = true;
}
}
}

View File

@ -0,0 +1,173 @@
// 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.Net.Http;
using System.Threading.Tasks;
using AngleSharp.Dom;
using AngleSharp.Dom.Html;
using HtmlGenerationWebSite;
using Microsoft.AspNetCore.Hosting;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
public class HtmlGenerationWithCultureTest : IClassFixture<MvcTestFixture<StartupWithCultureReplace>>
{
public HtmlGenerationWithCultureTest(MvcTestFixture<StartupWithCultureReplace> fixture)
{
var factory = fixture.WithWebHostBuilder(builder => builder.UseStartup<StartupWithCultureReplace>());
Client = factory.CreateDefaultClient();
}
public HttpClient Client { get; }
[Fact]
public async Task CacheTagHelper_AllowsVaryingByCulture()
{
// Arrange
string culture;
string correlationId;
string cachedCorrelationId;
// Act - 1
var document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&correlationId=10");
ReadValuesFromDocument();
// Assert - 1
Assert.Equal("fr-FR", culture);
Assert.Equal("10", correlationId);
Assert.Equal("10", cachedCorrelationId);
// Act - 2
document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=en-GB&correlationId=11");
ReadValuesFromDocument();
// Assert - 2
Assert.Equal("en-GB", culture);
Assert.Equal("11", correlationId);
Assert.Equal("11", cachedCorrelationId);
// Act - 3
document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&correlationId=14");
ReadValuesFromDocument();
// Assert - 3
Assert.Equal("fr-FR", culture);
Assert.Equal("14", correlationId);
// Verify we're reading a cached value
Assert.Equal("10", cachedCorrelationId);
void ReadValuesFromDocument()
{
culture = QuerySelector(document, "#culture").TextContent;
correlationId = QuerySelector(document, "#correlation-id").TextContent;
cachedCorrelationId = QuerySelector(document, "#cached-correlation-id").TextContent;
}
}
[Fact]
public async Task CacheTagHelper_AllowsVaryingByUICulture()
{
// Arrange
string culture;
string uiCulture;
string correlationId;
string cachedCorrelationId;
// Act - 1
var document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&ui-culture=fr-FR&correlationId=10");
ReadValuesFromDocument();
// Assert - 1
Assert.Equal("fr-FR", culture);
Assert.Equal("fr-FR", uiCulture);
Assert.Equal("10", correlationId);
Assert.Equal("10", cachedCorrelationId);
// Act - 2
document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&ui-culture=fr-CA&correlationId=11");
ReadValuesFromDocument();
// Assert - 2
Assert.Equal("fr-FR", culture);
Assert.Equal("fr-CA", uiCulture);
Assert.Equal("11", correlationId);
Assert.Equal("11", cachedCorrelationId);
// Act - 3
document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&ui-culture=fr-FR&correlationId=14");
ReadValuesFromDocument();
// Assert - 3
Assert.Equal("fr-FR", culture);
Assert.Equal("fr-FR", uiCulture);
Assert.Equal("14", correlationId);
// Verify we're reading a cached value
Assert.Equal("10", cachedCorrelationId);
void ReadValuesFromDocument()
{
culture = QuerySelector(document, "#culture").TextContent;
uiCulture = QuerySelector(document, "#ui-culture").TextContent;
correlationId = QuerySelector(document, "#correlation-id").TextContent;
cachedCorrelationId = QuerySelector(document, "#cached-correlation-id").TextContent;
}
}
[Fact]
public async Task CacheTagHelper_VaryByCultureComposesWithOtherVaryByOptions()
{
// Arrange
string culture;
string correlationId;
string cachedCorrelationId;
// Act - 1
var document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&correlationId=10");
ReadValuesFromDocument();
// Assert - 1
Assert.Equal("fr-FR", culture);
Assert.Equal("10", correlationId);
Assert.Equal("10", cachedCorrelationId);
// Act - 2
document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&correlationId=11&varyByQueryKey=new-key");
ReadValuesFromDocument();
// Assert - 2
// vary-by-query should produce a new cached value.
Assert.Equal("fr-FR", culture);
Assert.Equal("11", correlationId);
Assert.Equal("11", cachedCorrelationId);
// Act - 3
document = await Client.GetHtmlDocumentAsync("/CacheTagHelper_VaryByCulture?culture=fr-Fr&correlationId=14");
ReadValuesFromDocument();
// Assert - 3
Assert.Equal("fr-FR", culture);
Assert.Equal("14", correlationId);
Assert.Equal("10", cachedCorrelationId);
void ReadValuesFromDocument()
{
culture = QuerySelector(document, "#culture").TextContent;
correlationId = QuerySelector(document, "#correlation-id").TextContent;
cachedCorrelationId = QuerySelector(document, "#cached-correlation-id").TextContent;
}
}
private static IElement QuerySelector(IHtmlDocument document, string selector)
{
var element = document.QuerySelector(selector);
if (element == null)
{
throw new ArgumentException($"Document does not contain element that matches the selector {selector}: " + Environment.NewLine + document.DocumentElement.OuterHtml);
}
return element;
}
}
}

View File

@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Testing;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.WebEncoders.Testing;
using Moq;
@ -345,6 +346,27 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
Assert.Equal(expected, key);
}
[Fact]
[ReplaceCulture("fr-FR", "es-ES")]
public void GenerateKey_UsesCultureAndUICultureName_IfVaryByCulture_IsSet()
{
// Arrange
var expected = "CacheTagHelper||testid||VaryByCulture||fr-FR||es-ES";
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper(new CacheTagHelperMemoryCacheFactory(Mock.Of<IMemoryCache>()), new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
VaryByCulture = true
};
// Act
var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext);
var key = cacheTagKey.GenerateKey();
// Assert
Assert.Equal(expected, key);
}
[Fact]
public void GenerateKey_WithMultipleVaryByOptions_CreatesCombinedKey()
{
@ -371,15 +393,137 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
Assert.Equal(expected, key);
}
[Fact]
[ReplaceCulture("zh", "zh-Hans")]
public void GenerateKey_WithVaryByCulture_ComposesWithOtherOptions()
{
// Arrange
var expected = "CacheTagHelper||testid||VaryBy||custom-value||" +
"VaryByHeader(content-type||text/html)||VaryByCulture||zh||zh-Hans";
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper(new CacheTagHelperMemoryCacheFactory(Mock.Of<IMemoryCache>()), new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
VaryByCulture = true,
VaryByHeader = "content-type",
VaryBy = "custom-value"
};
cacheTagHelper.ViewContext.HttpContext.Request.Headers["Content-Type"] = "text/html";
// Act
var cacheTagKey = new CacheTagKey(cacheTagHelper, tagHelperContext);
var key = cacheTagKey.GenerateKey();
// Assert
Assert.Equal(expected, key);
}
[Fact]
public void Equality_ReturnsFalse_WhenVaryByCultureIsTrue_AndCultureIsDifferent()
{
// Arrange
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper(new CacheTagHelperMemoryCacheFactory(Mock.Of<IMemoryCache>()), new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
VaryByCulture = true,
};
// Act
CacheTagKey key1;
using (new CultureReplacer("fr-FR"))
{
key1 = new CacheTagKey(cacheTagHelper, tagHelperContext);
}
CacheTagKey key2;
using (new CultureReplacer("es-ES"))
{
key2 = new CacheTagKey(cacheTagHelper, tagHelperContext);
}
var equals = key1.Equals(key2);
var hashCode1 = key1.GetHashCode();
var hashCode2 = key2.GetHashCode();
// Assert
Assert.False(equals, "CacheTagKeys must not be equal");
Assert.NotEqual(hashCode1, hashCode2);
}
[Fact]
public void Equality_ReturnsFalse_WhenVaryByCultureIsTrue_AndUICultureIsDifferent()
{
// Arrange
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper(new CacheTagHelperMemoryCacheFactory(Mock.Of<IMemoryCache>()), new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
VaryByCulture = true,
};
// Act
CacheTagKey key1;
using (new CultureReplacer("fr", "fr-FR"))
{
key1 = new CacheTagKey(cacheTagHelper, tagHelperContext);
}
CacheTagKey key2;
using (new CultureReplacer("fr", "fr-CA"))
{
key2 = new CacheTagKey(cacheTagHelper, tagHelperContext);
}
var equals = key1.Equals(key2);
var hashCode1 = key1.GetHashCode();
var hashCode2 = key2.GetHashCode();
// Assert
Assert.False(equals, "CacheTagKeys must not be equal");
Assert.NotEqual(hashCode1, hashCode2);
}
[Fact]
public void Equality_ReturnsTrue_WhenVaryByCultureIsTrue_AndCultureIsSame()
{
// Arrange
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper(new CacheTagHelperMemoryCacheFactory(Mock.Of<IMemoryCache>()), new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
VaryByCulture = true,
};
// Act
CacheTagKey key1;
CacheTagKey key2;
using (new CultureReplacer("fr-FR", "fr-FR"))
{
key1 = new CacheTagKey(cacheTagHelper, tagHelperContext);
}
using (new CultureReplacer("fr-fr", "fr-fr"))
{
key2 = new CacheTagKey(cacheTagHelper, tagHelperContext);
}
var equals = key1.Equals(key2);
var hashCode1 = key1.GetHashCode();
var hashCode2 = key2.GetHashCode();
// Assert
Assert.True(equals, "CacheTagKeys must be equal");
Assert.Equal(hashCode1, hashCode2);
}
private static ViewContext GetViewContext()
{
var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor());
return new ViewContext(actionContext,
Mock.Of<IView>(),
new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()),
Mock.Of<ITempDataDictionary>(),
TextWriter.Null,
new HtmlHelperOptions());
Mock.Of<IView>(),
new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()),
Mock.Of<ITempDataDictionary>(),
TextWriter.Null,
new HtmlHelperOptions());
}
private static TagHelperContext GetTagHelperContext(string id = "testid")

View File

@ -0,0 +1,14 @@
@page
@using System.Globalization
@functions
{
[BindProperty(SupportsGet = true)]
public int CorrelationId { get; set; }
}
<h2 id="culture">@CultureInfo.CurrentCulture</h2>
<h2 id="ui-culture">@CultureInfo.CurrentUICulture</h2>
<span id="correlation-id">@CorrelationId</span>
<cache vary-by-culture="true" vary-by-query="varyByQueryKey">
<span id="cached-correlation-id">@CorrelationId</span>
</cache>

View File

@ -0,0 +1 @@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -0,0 +1,46 @@
// 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.Globalization;
using System.IO;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.DependencyInjection;
namespace HtmlGenerationWebSite
{
public class StartupWithCultureReplace
{
private readonly Startup Startup = new Startup();
// Set up application services
public void ConfigureServices(IServiceCollection services)
{
services.AddLocalization();
Startup.ConfigureServices(services);
}
public void Configure(IApplicationBuilder app)
{
app.UseRequestLocalization(options =>
{
options.SupportedCultures.Add(new CultureInfo("fr-FR"));
options.SupportedCultures.Add(new CultureInfo("en-GB"));
options.SupportedUICultures.Add(new CultureInfo("fr-FR"));
options.SupportedUICultures.Add(new CultureInfo("fr-CA"));
options.SupportedUICultures.Add(new CultureInfo("en-GB"));
});
Startup.Configure(app);
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
new WebHostBuilder()
.UseContentRoot(Directory.GetCurrentDirectory())
.UseStartup<StartupWithCultureReplace>()
.UseKestrel()
.UseIISIntegration();
}
}