From d9f035ad7c4693c314e293f21bfef7ed002dc8e2 Mon Sep 17 00:00:00 2001 From: Pranav K Date: Thu, 10 May 2018 07:18:49 -0700 Subject: [PATCH] CacheTagHelper should be able to vary by culture Fixes #3398 --- .../Cache/CacheTagKey.cs | 61 +++++- .../CacheTagHelperBase.cs | 14 +- .../HtmlGenerationWithCultureTest.cs | 173 ++++++++++++++++++ .../CacheTagKeyTest.cs | 154 +++++++++++++++- .../Pages/CacheTagHelper_VaryByCulture.cshtml | 14 ++ .../Pages/_ViewImports.cshtml | 1 + .../StartupWithCultureReplace.cs | 46 +++++ 7 files changed, 449 insertions(+), 14 deletions(-) create mode 100644 test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationWithCultureTest.cs create mode 100644 test/WebSites/HtmlGenerationWebSite/Pages/CacheTagHelper_VaryByCulture.cshtml create mode 100644 test/WebSites/HtmlGenerationWebSite/Pages/_ViewImports.cshtml create mode 100644 test/WebSites/HtmlGenerationWebSite/StartupWithCultureReplace.cs diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs index f1d6730ae0..3d1912cba0 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/CacheTagKey.cs @@ -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> _routeValues; private readonly IList> _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 /// 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; } /// @@ -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); + } } /// @@ -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> ExtractCollection(string keys, TSourceCollection collection, Func accessor) + private static IList> ExtractCollection( + string keys, + TSourceCollection collection, + Func accessor) { if (string.IsNullOrEmpty(keys)) { @@ -323,4 +368,4 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache return true; } } -} \ No newline at end of file +} diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs index ca7dfdcb70..9ebd8a7717 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs @@ -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; } + /// + /// Gets or sets a value that determines if the cached result is to be varied by request culture. + /// + /// Setting this to true would result in the result to be varied by + /// and . + /// + /// + [HtmlAttributeName(VaryByCultureAttributeName)] + public bool VaryByCulture { get; set; } + /// /// Gets or sets the exact the cache entry should be evicted. /// @@ -117,4 +129,4 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlAttributeName(EnabledAttributeName)] public bool Enabled { get; set; } = true; } -} \ No newline at end of file +} diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationWithCultureTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationWithCultureTest.cs new file mode 100644 index 0000000000..9b1d1f5cbe --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationWithCultureTest.cs @@ -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> + { + public HtmlGenerationWithCultureTest(MvcTestFixture fixture) + { + var factory = fixture.WithWebHostBuilder(builder => builder.UseStartup()); + 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; + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagKeyTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagKeyTest.cs index 7cd1718b20..36ce9d6216 100644 --- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagKeyTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagKeyTest.cs @@ -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()), 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()), 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()), 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()), 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()), 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(), - new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()), - Mock.Of(), - TextWriter.Null, - new HtmlHelperOptions()); + Mock.Of(), + new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()), + Mock.Of(), + TextWriter.Null, + new HtmlHelperOptions()); } private static TagHelperContext GetTagHelperContext(string id = "testid") diff --git a/test/WebSites/HtmlGenerationWebSite/Pages/CacheTagHelper_VaryByCulture.cshtml b/test/WebSites/HtmlGenerationWebSite/Pages/CacheTagHelper_VaryByCulture.cshtml new file mode 100644 index 0000000000..bdf54e5728 --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Pages/CacheTagHelper_VaryByCulture.cshtml @@ -0,0 +1,14 @@ +@page +@using System.Globalization +@functions +{ + [BindProperty(SupportsGet = true)] + public int CorrelationId { get; set; } +} + +

@CultureInfo.CurrentCulture

+

@CultureInfo.CurrentUICulture

+@CorrelationId + + @CorrelationId + diff --git a/test/WebSites/HtmlGenerationWebSite/Pages/_ViewImports.cshtml b/test/WebSites/HtmlGenerationWebSite/Pages/_ViewImports.cshtml new file mode 100644 index 0000000000..a757b413b9 --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Pages/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/test/WebSites/HtmlGenerationWebSite/StartupWithCultureReplace.cs b/test/WebSites/HtmlGenerationWebSite/StartupWithCultureReplace.cs new file mode 100644 index 0000000000..ea197babb3 --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/StartupWithCultureReplace.cs @@ -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() + .UseKestrel() + .UseIISIntegration(); + } +}