From ffadb5df6be6a5d017216ab08ba90a6d763cd792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Wed, 29 May 2024 20:20:07 +0200 Subject: [PATCH 1/8] Support for localizable routes --- src/Framework/Framework/Controls/RouteLink.cs | 30 +++++- .../Framework/Controls/RouteLinkCapability.cs | 2 + .../Framework/Controls/RouteLinkHelpers.cs | 18 +++- .../Hosting/DotvvmRequestContextExtensions.cs | 17 ++- .../Framework/Hosting/HostingConstants.cs | 2 + .../Framework/Routing/DotvvmRoute.cs | 2 +- .../Framework/Routing/DotvvmRouteTable.cs | 39 +++++-- .../Framework/Routing/LocalizedDotvvmRoute.cs | 100 ++++++++++++++++++ .../Framework/Routing/LocalizedRouteUrl.cs | 35 ++++++ src/Framework/Framework/Routing/RouteBase.cs | 2 +- .../Routing/RouteTableJsonConverter.cs | 3 +- .../Hosting/Middlewares/DotvvmMiddleware.cs | 5 +- .../PrefixRequestCultureProvider.cs | 25 +++++ src/Samples/AspNetCore/Startup.cs | 11 ++ .../PrefixRequestCultureProvider.cs | 25 +++++ src/Samples/AspNetCoreLatest/Startup.cs | 14 ++- src/Samples/Common/DotvvmStartup.cs | 6 ++ .../Localization/LocalizableRouteViewModel.cs | 16 +++ .../Localization/LocalizableRoute.dothtml | 33 ++++++ src/Samples/Owin/Startup.cs | 19 +++- .../Abstractions/SamplesRouteUrls.designer.cs | 1 + .../Tests/Tests/Feature/LocalizationTests.cs | 43 ++++++++ src/Tests/Routing/RouteTableGroupTests.cs | 18 ++-- 23 files changed, 435 insertions(+), 31 deletions(-) create mode 100644 src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs create mode 100644 src/Framework/Framework/Routing/LocalizedRouteUrl.cs create mode 100644 src/Samples/AspNetCore/PrefixRequestCultureProvider.cs create mode 100644 src/Samples/AspNetCoreLatest/PrefixRequestCultureProvider.cs create mode 100644 src/Samples/Common/ViewModels/FeatureSamples/Localization/LocalizableRouteViewModel.cs create mode 100644 src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml diff --git a/src/Framework/Framework/Controls/RouteLink.cs b/src/Framework/Framework/Controls/RouteLink.cs index d5f5b4c804..809ecfce51 100644 --- a/src/Framework/Framework/Controls/RouteLink.cs +++ b/src/Framework/Framework/Controls/RouteLink.cs @@ -8,6 +8,7 @@ using DotVVM.Framework.Compilation.Validation; using DotVVM.Framework.Configuration; using DotVVM.Framework.Hosting; +using DotVVM.Framework.Routing; using DotVVM.Framework.Runtime; using DotVVM.Framework.Utils; @@ -64,6 +65,18 @@ public string Text public static readonly DotvvmProperty TextProperty = DotvvmProperty.Register(c => c.Text, ""); + /// + /// Gets or sets the required culture of the page. This property is supported only when using localizable routes. + /// + [MarkupOptions(AllowBinding = false)] + public string Culture + { + get { return (string)GetValue(CultureProperty); } + set { SetValue(CultureProperty, value); } + } + public static readonly DotvvmProperty CultureProperty + = DotvvmProperty.Register(c => c.Culture, null); + /// /// Gets or sets a collection of parameters to be substituted in the route URL. If the current route contains a parameter with the same name, its value will be reused unless another value is specified here. /// @@ -185,7 +198,7 @@ public static IEnumerable ValidateUsage(ResolvedControl contr if (routeNameProperty is not ResolvedPropertyValue { Value: string routeName }) yield break; - if (!configuration.RouteTable.Contains(routeName)) + if (!configuration.RouteTable.TryGetValue(routeName, out var route)) { yield return new ControlUsageError( $"RouteName \"{routeName}\" does not exist.", @@ -193,7 +206,20 @@ public static IEnumerable ValidateUsage(ResolvedControl contr yield break; } - var parameterDefinitions = configuration.RouteTable[routeName].ParameterNames; + if (control.GetValue(CultureProperty) is ResolvedPropertyValue { Value: string culture } + && !string.IsNullOrEmpty(culture)) + { + if (route is not LocalizedDotvvmRoute localizedRoute) + { + yield return new ControlUsageError($"The route {routeName} must be localizable if the {nameof(Culture)} property is set!"); + } + else + { + route = localizedRoute.GetRouteForCulture(culture); + } + } + + var parameterDefinitions = route.ParameterNames; var parameterReferences = control.Properties.Where(i => i.Key is GroupedDotvvmProperty p && p.PropertyGroup == ParamsGroupDescriptor); var undefinedReferences = diff --git a/src/Framework/Framework/Controls/RouteLinkCapability.cs b/src/Framework/Framework/Controls/RouteLinkCapability.cs index e80e18bca8..294189c13c 100644 --- a/src/Framework/Framework/Controls/RouteLinkCapability.cs +++ b/src/Framework/Framework/Controls/RouteLinkCapability.cs @@ -19,5 +19,7 @@ public sealed record RouteLinkCapability [DefaultValue(null)] public ValueOrBinding? UrlSuffix { get; init; } + + public string? Culture { get; init; } } } diff --git a/src/Framework/Framework/Controls/RouteLinkHelpers.cs b/src/Framework/Framework/Controls/RouteLinkHelpers.cs index 4281866015..be8f4f7457 100644 --- a/src/Framework/Framework/Controls/RouteLinkHelpers.cs +++ b/src/Framework/Framework/Controls/RouteLinkHelpers.cs @@ -12,6 +12,7 @@ using DotVVM.Framework.Utils; using DotVVM.Framework.Configuration; using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; namespace DotVVM.Framework.Controls { @@ -87,7 +88,7 @@ public static string EvaluateRouteUrl(string routeName, RouteLink control, IDotv private static string GenerateRouteUrlCore(string routeName, RouteLink control, IDotvvmRequestContext context) { - var route = GetRoute(context, routeName); + var route = GetRoute(context, routeName, control.Culture); var parameters = ComposeNewRouteParameters(control, context, route); // evaluate bindings on server @@ -114,9 +115,18 @@ private static string GenerateUrlSuffixCore(string? urlSuffix, RouteLink control return UrlHelper.BuildUrlSuffix(urlSuffix, queryParams); } - private static RouteBase GetRoute(IDotvvmRequestContext context, string routeName) + private static RouteBase GetRoute(IDotvvmRequestContext context, string routeName, string? cultureIdentifier) { - return context.Configuration.RouteTable[routeName]; + var route = context.Configuration.RouteTable[routeName]; + if (!string.IsNullOrEmpty(cultureIdentifier)) + { + if (route is not LocalizedDotvvmRoute localizedRoute) + { + throw new DotvvmControlException($"The route {routeName} is not localizable, the Culture property cannot be used!"); + } + route = localizedRoute.GetRouteForCulture(cultureIdentifier!); + } + return route; } public static string GenerateKnockoutHrefExpression(string routeName, RouteLink control, IDotvvmRequestContext context) @@ -146,7 +156,7 @@ public static string GenerateKnockoutHrefExpression(string routeName, RouteLink private static string GenerateRouteLinkCore(string routeName, RouteLink control, IDotvvmRequestContext context) { - var route = GetRoute(context, routeName); + var route = GetRoute(context, routeName, control.Culture); var parameters = ComposeNewRouteParameters(control, context, route); var parametersExpression = parameters.Select(p => TranslateRouteParameter(control, p)).StringJoin(","); diff --git a/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs b/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs index 913347ffb7..875250cc2d 100644 --- a/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs +++ b/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs @@ -52,13 +52,22 @@ public static void ChangeCurrentCulture(this IDotvvmRequestContext context, stri [Obsolete("This method only assigns CultureInfo.CurrentCulture, which is not preserved in async methods. You should assign it manually, or use RequestLocalization middleware or LocalizablePresenter.")] public static void ChangeCurrentCulture(this IDotvvmRequestContext context, string cultureName, string uiCultureName) { + if (!string.IsNullOrEmpty(cultureName)) + { #if DotNetCore - CultureInfo.CurrentCulture = new CultureInfo(cultureName); - CultureInfo.CurrentUICulture = new CultureInfo(uiCultureName); + CultureInfo.CurrentCulture = new CultureInfo(cultureName); #else - Thread.CurrentThread.CurrentCulture = new CultureInfo(cultureName); - Thread.CurrentThread.CurrentUICulture = new CultureInfo(uiCultureName); + Thread.CurrentThread.CurrentCulture = new CultureInfo(cultureName); #endif + } + if (!string.IsNullOrEmpty(uiCultureName)) + { +#if DotNetCore + CultureInfo.CurrentUICulture = new CultureInfo(uiCultureName); +#else + Thread.CurrentThread.CurrentUICulture = new CultureInfo(uiCultureName); +#endif + } } /// diff --git a/src/Framework/Framework/Hosting/HostingConstants.cs b/src/Framework/Framework/Hosting/HostingConstants.cs index a25834d099..f2e4886d52 100644 --- a/src/Framework/Framework/Hosting/HostingConstants.cs +++ b/src/Framework/Framework/Hosting/HostingConstants.cs @@ -28,5 +28,7 @@ public class HostingConstants public const string DotvvmFileUploadAsyncHeaderName = "X-DotVVM-AsyncUpload"; public const string HostAppModeKey = "host.AppMode"; + + public const string OwinDoNotSetRequestCulture = "OwinDoNotSetRequestCulture"; } } diff --git a/src/Framework/Framework/Routing/DotvvmRoute.cs b/src/Framework/Framework/Routing/DotvvmRoute.cs index 41225a55d8..993929b848 100644 --- a/src/Framework/Framework/Routing/DotvvmRoute.cs +++ b/src/Framework/Framework/Routing/DotvvmRoute.cs @@ -123,7 +123,7 @@ public override bool IsMatch(string url, [MaybeNullWhen(false)] out IDictionary< /// /// Builds the URL core from the parameters. /// - protected override string BuildUrlCore(Dictionary values) + protected internal override string BuildUrlCore(Dictionary values) { var convertedValues = values.ToDictionary( diff --git a/src/Framework/Framework/Routing/DotvvmRouteTable.cs b/src/Framework/Framework/Routing/DotvvmRouteTable.cs index 7bf92707f9..572d071e99 100644 --- a/src/Framework/Framework/Routing/DotvvmRouteTable.cs +++ b/src/Framework/Framework/Routing/DotvvmRouteTable.cs @@ -109,10 +109,21 @@ public IDotvvmPresenter GetDefaultPresenter(IServiceProvider provider) /// The virtual path of the Dothtml file. /// The default values. /// Delegate creating the presenter handling this route - public void Add(string routeName, string? url, string virtualPath, object? defaultValues = null, Func? presenterFactory = null) + public void Add(string routeName, string? url, string virtualPath, object? defaultValues = null, Func? presenterFactory = null, LocalizedRouteUrl[]? localizedUrls = null) { ThrowIfFrozen(); - Add(group?.RouteNamePrefix + routeName, new DotvvmRoute(CombinePath(group?.UrlPrefix, url), CombinePath(group?.VirtualPathPrefix, virtualPath), defaultValues, presenterFactory ?? GetDefaultPresenter, configuration)); + + url = CombinePath(group?.UrlPrefix, url); + virtualPath = CombinePath(group?.VirtualPathPrefix, virtualPath); + presenterFactory ??= GetDefaultPresenter; + routeName = group?.RouteNamePrefix + routeName; + + RouteBase route = localizedUrls == null + ? new DotvvmRoute(url, virtualPath, defaultValues, presenterFactory, configuration) + : new LocalizedDotvvmRoute(url, + localizedUrls.Select(l => new LocalizedRouteUrl(l.CultureIdentifier, CombinePath(group?.UrlPrefix, l.RouteUrl))).ToArray(), + virtualPath, defaultValues, presenterFactory, configuration); + Add(routeName, route); } /// @@ -122,10 +133,21 @@ public void Add(string routeName, string? url, string virtualPath, object? defau /// The URL. /// The default values. /// The presenter factory. - public void Add(string routeName, string? url, Func? presenterFactory = null, object? defaultValues = null) + public void Add(string routeName, string? url, Func? presenterFactory = null, object? defaultValues = null, LocalizedRouteUrl[]? localizedUrls = null) { ThrowIfFrozen(); - Add(group?.RouteNamePrefix + routeName, new DotvvmRoute(CombinePath(group?.UrlPrefix, url), group?.VirtualPathPrefix ?? "", defaultValues, presenterFactory ?? GetDefaultPresenter, configuration)); + + url = CombinePath(group?.UrlPrefix, url); + presenterFactory ??= GetDefaultPresenter; + routeName = group?.RouteNamePrefix + routeName; + var virtualPath = group?.VirtualPathPrefix ?? ""; + + RouteBase route = localizedUrls == null + ? new DotvvmRoute(url, virtualPath, defaultValues, presenterFactory, configuration) + : new LocalizedDotvvmRoute(url, + localizedUrls.Select(l => new LocalizedRouteUrl(l.CultureIdentifier, CombinePath(group?.UrlPrefix, l.RouteUrl))).ToArray(), + virtualPath, defaultValues, presenterFactory, configuration); + Add(routeName, route); } /// @@ -203,7 +225,7 @@ public void AddRouteRedirection(string routeName, string urlPattern, FuncThe URL. /// The presenter factory. /// The default values. - public void Add(string routeName, string? url, Type presenterType, object? defaultValues = null) + public void Add(string routeName, string? url, Type presenterType, object? defaultValues = null, LocalizedRouteUrl[] localizedUrls = null) { ThrowIfFrozen(); if (!typeof(IDotvvmPresenter).IsAssignableFrom(presenterType)) @@ -211,7 +233,7 @@ public void Add(string routeName, string? url, Type presenterType, object? defau throw new ArgumentException($@"{nameof(presenterType)} has to inherit from DotVVM.Framework.Hosting.IDotvvmPresenter.", nameof(presenterType)); } Func presenterFactory = provider => (IDotvvmPresenter)provider.GetRequiredService(presenterType); - Add(routeName, url, presenterFactory, defaultValues); + Add(routeName, url, presenterFactory, defaultValues, localizedUrls); } /// @@ -239,6 +261,11 @@ public bool Contains(string routeName) return dictionary.ContainsKey(routeName); } + public bool TryGetValue(string routeName, out RouteBase? route) + { + return dictionary.TryGetValue(routeName, out route); + } + public RouteBase this[string routeName] { get diff --git a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs new file mode 100644 index 0000000000..00e4adfd9c --- /dev/null +++ b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using DotVVM.Framework.Configuration; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Framework.Routing +{ + /// + /// Represents a localizable route with different matching pattern for each culture. + /// Please note that the extraction of the culture from the URL and setting the culture must be done at the beginning of the request pipeline. + /// Therefore, the route only matches the URL for the current culture. + /// + public sealed class LocalizedDotvvmRoute : RouteBase + { + private static readonly HashSet AvailableCultureNames = CultureInfo.GetCultures(CultureTypes.AllCultures) + .Where(c => c != CultureInfo.InvariantCulture) + .Select(c => c.Name) + .ToHashSet(); + + private readonly SortedDictionary localizedRoutes = new(); + + public override string UrlWithoutTypes => GetRouteForCulture(CultureInfo.CurrentUICulture).UrlWithoutTypes; + + /// + /// Gets the names of the route parameters in the order in which they appear in the URL. + /// + public override IEnumerable ParameterNames => GetRouteForCulture(CultureInfo.CurrentUICulture).ParameterNames; + + /// + /// Initializes a new instance of the class. + /// + public LocalizedDotvvmRoute(string defaultLanguageUrl, LocalizedRouteUrl[] localizedUrls, string virtualPath, object? defaultValues, Func presenterFactory, DotvvmConfiguration configuration) + : base(defaultLanguageUrl, virtualPath, defaultValues) + { + if (!localizedUrls.Any()) + { + throw new ArgumentException("There must be at least one localized route URL!", nameof(localizedUrls)); + } + + foreach (var localizedUrl in localizedUrls) + { + var localizedRoute = new DotvvmRoute(localizedUrl.RouteUrl, virtualPath, defaultValues, presenterFactory, configuration); + localizedRoutes.Add(localizedUrl.CultureIdentifier, localizedRoute); + } + + var defaultRoute = new DotvvmRoute(defaultLanguageUrl, virtualPath, defaultValues, presenterFactory, configuration); + localizedRoutes.Add("", defaultRoute); + } + + public DotvvmRoute GetRouteForCulture(string cultureIdentifier) + { + ValidateCultureName(cultureIdentifier); + return GetRouteForCulture(CultureInfo.GetCultureInfo(cultureIdentifier)); + } + + public DotvvmRoute GetRouteForCulture(CultureInfo culture) + { + return localizedRoutes.TryGetValue(culture.Name, out var exactMatchRoute) ? exactMatchRoute + : localizedRoutes.TryGetValue(culture.TwoLetterISOLanguageName, out var languageMatchRoute) ? languageMatchRoute + : localizedRoutes.TryGetValue("", out var defaultRoute) ? defaultRoute + : throw new NotSupportedException("Invalid localized route - no default route found!"); + } + + public static void ValidateCultureName(string cultureIdentifier) + { + if (!AvailableCultureNames.Contains(cultureIdentifier)) + { + throw new ArgumentException($"Culture {cultureIdentifier} was not found!", nameof(cultureIdentifier)); + } + } + + /// + /// Processes the request. + /// + public override IDotvvmPresenter GetPresenter(IServiceProvider provider) => GetRouteForCulture(CultureInfo.CurrentCulture).GetPresenter(provider); + + /// + /// Determines whether the route matches to the specified URL and extracts the parameter values. + /// + public override bool IsMatch(string url, [MaybeNullWhen(false)] out IDictionary values) => GetRouteForCulture(CultureInfo.CurrentCulture).IsMatch(url, out values); + + protected internal override string BuildUrlCore(Dictionary values) => GetRouteForCulture(CultureInfo.CurrentCulture).BuildUrlCore(values); + + protected override void Freeze2() + { + foreach (var route in localizedRoutes) + { + route.Value.Freeze(); + } + } + } +} diff --git a/src/Framework/Framework/Routing/LocalizedRouteUrl.cs b/src/Framework/Framework/Routing/LocalizedRouteUrl.cs new file mode 100644 index 0000000000..8446b34afa --- /dev/null +++ b/src/Framework/Framework/Routing/LocalizedRouteUrl.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Linq; + +namespace DotVVM.Framework.Routing +{ + public record LocalizedRouteUrl + { + /// + /// Gets or sets the culture identifier. Allowed formats are language-REGION (e.g. en-US) or language (e.g. en). + /// + public string CultureIdentifier { get; } + + /// + /// Get or sets the corresponding route URL. + /// + public string RouteUrl { get; } + + /// + /// Represents a localized route URL. + /// + /// Culture identifier. Allowed formats are language-REGION (e.g. en-US) or language (e.g. en) + /// Corresponding route URL for the culture. + public LocalizedRouteUrl(string cultureIdentifier, string routeUrl) + { + LocalizedDotvvmRoute.ValidateCultureName(cultureIdentifier); + + CultureIdentifier = cultureIdentifier; + RouteUrl = routeUrl; + } + + } +} diff --git a/src/Framework/Framework/Routing/RouteBase.cs b/src/Framework/Framework/Routing/RouteBase.cs index 329097f68a..9c17498015 100644 --- a/src/Framework/Framework/Routing/RouteBase.cs +++ b/src/Framework/Framework/Routing/RouteBase.cs @@ -157,7 +157,7 @@ public string BuildUrl(IDictionary routeValues) /// Builds the URL core from the parameters. /// /// The default values are already included in the collection. - protected abstract string BuildUrlCore(Dictionary values); + protected internal abstract string BuildUrlCore(Dictionary values); /// /// Adds or updates the parameter collection with the specified values from the anonymous object. diff --git a/src/Framework/Framework/Routing/RouteTableJsonConverter.cs b/src/Framework/Framework/Routing/RouteTableJsonConverter.cs index bd3e6154fe..c1fa741c9c 100644 --- a/src/Framework/Framework/Routing/RouteTableJsonConverter.cs +++ b/src/Framework/Framework/Routing/RouteTableJsonConverter.cs @@ -75,7 +75,8 @@ public ErrorRoute(string? url, string? virtualPath, string? name, IDictionary values) => throw new InvalidOperationException($"Could not create route {RouteName}", error); - protected override string BuildUrlCore(Dictionary values) => throw new InvalidOperationException($"Could not create route {RouteName}", error); + protected internal override string BuildUrlCore(Dictionary values) => throw new InvalidOperationException($"Could not create route {RouteName}", error); + protected override void Freeze2() { // no mutable properties in this class diff --git a/src/Framework/Hosting.Owin/Hosting/Middlewares/DotvvmMiddleware.cs b/src/Framework/Hosting.Owin/Hosting/Middlewares/DotvvmMiddleware.cs index 4641c9160f..21d09e57d2 100644 --- a/src/Framework/Hosting.Owin/Hosting/Middlewares/DotvvmMiddleware.cs +++ b/src/Framework/Hosting.Owin/Hosting/Middlewares/DotvvmMiddleware.cs @@ -49,7 +49,10 @@ public override async Task Invoke(IOwinContext context) var dotvvmContext = CreateDotvvmContext(context, scope); dotvvmContext.Services.GetRequiredService().Context = dotvvmContext; context.Set(HostingConstants.DotvvmRequestContextKey, dotvvmContext); - dotvvmContext.ChangeCurrentCulture(Configuration.DefaultCulture); + if (context.Get("OwinDoNotSetRequestCulture") != true) + { + dotvvmContext.ChangeCurrentCulture(Configuration.DefaultCulture); + } try { diff --git a/src/Samples/AspNetCore/PrefixRequestCultureProvider.cs b/src/Samples/AspNetCore/PrefixRequestCultureProvider.cs new file mode 100644 index 0000000000..65760ccb29 --- /dev/null +++ b/src/Samples/AspNetCore/PrefixRequestCultureProvider.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Localization; + +namespace DotVVM.Samples.BasicSamples +{ + public class PrefixRequestCultureProvider : RequestCultureProvider + { + public override Task DetermineProviderCultureResult(HttpContext httpContext) + { + if (httpContext.Request.Path.StartsWithSegments("/cs")) + { + return Task.FromResult(new ProviderCultureResult("cs-CZ")); + } + else if (httpContext.Request.Path.StartsWithSegments("/de")) + { + return Task.FromResult(new ProviderCultureResult("de")); + } + else + { + return Task.FromResult(new ProviderCultureResult("en-US")); + } + } + } +} diff --git a/src/Samples/AspNetCore/Startup.cs b/src/Samples/AspNetCore/Startup.cs index 097957df9d..b1273c033e 100644 --- a/src/Samples/AspNetCore/Startup.cs +++ b/src/Samples/AspNetCore/Startup.cs @@ -68,10 +68,21 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddTransient(); + + services.Configure(options => { + var supportedCultures = new[] { "en-US", "cs-CZ", "de" }; + options + .SetDefaultCulture(supportedCultures[0]) + .AddSupportedCultures(supportedCultures) + .AddSupportedUICultures(supportedCultures) + .AddInitialRequestCultureProvider(new PrefixRequestCultureProvider()); + }); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory) { + app.UseRequestLocalization(); + app.UseRouting(); app.UseAuthentication(); diff --git a/src/Samples/AspNetCoreLatest/PrefixRequestCultureProvider.cs b/src/Samples/AspNetCoreLatest/PrefixRequestCultureProvider.cs new file mode 100644 index 0000000000..c2f08722de --- /dev/null +++ b/src/Samples/AspNetCoreLatest/PrefixRequestCultureProvider.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; + +namespace DotVVM.Samples.BasicSamples +{ + public class PrefixRequestCultureProvider : RequestCultureProvider + { + public override Task DetermineProviderCultureResult(HttpContext httpContext) + { + if (httpContext.Request.Path.StartsWithSegments("/cs")) + { + return Task.FromResult(new ProviderCultureResult("cs-CZ")); + } + else if (httpContext.Request.Path.StartsWithSegments("/de")) + { + return Task.FromResult(new ProviderCultureResult("de")); + } + else + { + return Task.FromResult(new ProviderCultureResult("en-US")); + } + } + } +} diff --git a/src/Samples/AspNetCoreLatest/Startup.cs b/src/Samples/AspNetCoreLatest/Startup.cs index 4342847d57..b73dae631f 100644 --- a/src/Samples/AspNetCoreLatest/Startup.cs +++ b/src/Samples/AspNetCoreLatest/Startup.cs @@ -59,7 +59,7 @@ public void ConfigureServices(IServiceCollection services) .AddCookie("Scheme3"); services.AddHealthChecks(); - + services.AddLocalization(o => o.ResourcesPath = "Resources"); services.AddDotVVM(); @@ -71,10 +71,22 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddScoped(); + + services.Configure(options => + { + var supportedCultures = new[] { "en-US", "cs-CZ", "de" }; + options + .SetDefaultCulture(supportedCultures[0]) + .AddSupportedCultures(supportedCultures) + .AddSupportedUICultures(supportedCultures) + .AddInitialRequestCultureProvider(new PrefixRequestCultureProvider()); + }); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory) { + app.UseRequestLocalization(); + app.UseRouting(); app.UseAuthentication(); diff --git a/src/Samples/Common/DotvvmStartup.cs b/src/Samples/Common/DotvvmStartup.cs index 00f8b02d5e..47c6b31787 100644 --- a/src/Samples/Common/DotvvmStartup.cs +++ b/src/Samples/Common/DotvvmStartup.cs @@ -234,6 +234,12 @@ private static void AddRoutes(DotvvmConfiguration config) config.RouteTable.Add("FeatureSamples_Localization_Globalize", "FeatureSamples/Localization/Globalize", "Views/FeatureSamples/Localization/Globalize.dothtml", presenterFactory: LocalizablePresenter.BasedOnQuery("lang")); config.RouteTable.Add("FeatureSamples_CustomPrimitiveTypes_Basic", "FeatureSamples/CustomPrimitiveTypes/Basic/{Id?}", "Views/FeatureSamples/CustomPrimitiveTypes/Basic.dothtml"); + config.RouteTable.Add("FeatureSamples_Localization_LocalizableRoute", "FeatureSamples/Localization/LocalizableRoute/{lang?}", "Views/FeatureSamples/Localization/LocalizableRoute.dothtml", + localizedUrls: new LocalizedRouteUrl[] { + new("cs-CZ", "cs/FeatureSamples/Localization/lokalizovana-routa"), + new("de", "de/FeatureSamples/Localization/lokalisierte-route"), + }); + config.RouteTable.AutoDiscoverRoutes(new DefaultRouteStrategy(config)); config.RouteTable.Add("ControlSamples_Repeater_RouteLinkQuery-PageDetail", "ControlSamples/Repeater/RouteLinkQuery/{Id}", "Views/ControlSamples/Repeater/RouteLinkQuery.dothtml", new { Id = 0 }); diff --git a/src/Samples/Common/ViewModels/FeatureSamples/Localization/LocalizableRouteViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/Localization/LocalizableRouteViewModel.cs new file mode 100644 index 0000000000..eeb3812e71 --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/Localization/LocalizableRouteViewModel.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Framework.ViewModel; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.Localization +{ + public class LocalizableRouteViewModel : DotvvmViewModelBase + { + + } +} + diff --git a/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml b/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml new file mode 100644 index 0000000000..23394a4f4c --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/Localization/LocalizableRoute.dothtml @@ -0,0 +1,33 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.Localization.LocalizableRouteViewModel, DotVVM.Samples.Common + + + + + + + + + + +

Localizable route test

+ +

Current culture: {{resource: System.Globalization.CultureInfo.CurrentUICulture.Name}}

+ + + + + + + + + + + + diff --git a/src/Samples/Owin/Startup.cs b/src/Samples/Owin/Startup.cs index a1dcf96596..e397ccd980 100644 --- a/src/Samples/Owin/Startup.cs +++ b/src/Samples/Owin/Startup.cs @@ -4,7 +4,6 @@ using DotVVM.Framework.Hosting; using DotVVM.Samples.BasicSamples; using DotVVM.Samples.BasicSamples.ViewModels.ComplexSamples.Auth; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Owin; using Microsoft.Owin.Security.Cookies; using Owin; @@ -17,6 +16,9 @@ using System.Configuration; using DotVVM.Framework.Utils; using System.Linq; +using System.Threading; +using System.Globalization; +using System.Runtime.Remoting.Contexts; [assembly: OwinStartup(typeof(Startup))] @@ -26,6 +28,21 @@ public class Startup { public void Configuration(IAppBuilder app) { + app.Use((context, next) => { + if (context.Request.Path.StartsWithSegments(new PathString("/cs"))) + { + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("cs-CZ"); + context.Set(HostingConstants.OwinDoNotSetRequestCulture, true); + } + else if (context.Request.Path.StartsWithSegments(new PathString("/de"))) + { + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de"); + context.Set(HostingConstants.OwinDoNotSetRequestCulture, true); + } + + return next(); + }); + app.Use( new List { new SwitchMiddlewareCase( diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs index f8761af5ea..22217bbc28 100644 --- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs +++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs @@ -283,6 +283,7 @@ public partial class SamplesRouteUrls public const string FeatureSamples_LambdaExpressions_StaticCommands = "FeatureSamples/LambdaExpressions/StaticCommands"; public const string FeatureSamples_LiteralBinding_LiteralBinding_Zero = "FeatureSamples/LiteralBinding/LiteralBinding_Zero"; public const string FeatureSamples_Localization_Globalize = "FeatureSamples/Localization/Globalize"; + public const string FeatureSamples_Localization_LocalizableRoute = "FeatureSamples/Localization/LocalizableRoute"; public const string FeatureSamples_Localization_Localization = "FeatureSamples/Localization/Localization"; public const string FeatureSamples_Localization_Localization_Control_Page = "FeatureSamples/Localization/Localization_Control_Page"; public const string FeatureSamples_Localization_Localization_FormatString = "FeatureSamples/Localization/Localization_FormatString"; diff --git a/src/Samples/Tests/Tests/Feature/LocalizationTests.cs b/src/Samples/Tests/Tests/Feature/LocalizationTests.cs index 80ebe38639..5751368528 100644 --- a/src/Samples/Tests/Tests/Feature/LocalizationTests.cs +++ b/src/Samples/Tests/Tests/Feature/LocalizationTests.cs @@ -133,6 +133,49 @@ void CheckForm(IBrowserWrapper browser) { }); } + [Fact] + public void Feature_Localization_LocalizableRoute() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_Localization_LocalizableRoute); + + var culture = browser.Single("span[data-ui=culture]"); + var links = browser.FindElements("a"); + AssertUI.TextEquals(culture, "en-US"); + AssertUI.Attribute(links[0], "href", v => v.EndsWith("/cs/FeatureSamples/Localization/lokalizovana-routa")); + AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); + AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); + AssertUI.Attribute(links[3], "href", links[2].GetAttribute("href")); + + links[0].Click().Wait(500); + culture = browser.Single("span[data-ui=culture]"); + links = browser.FindElements("a"); + AssertUI.TextEquals(culture, "cs-CZ"); + AssertUI.Attribute(links[0], "href", v => v.EndsWith("/cs/FeatureSamples/Localization/lokalizovana-routa")); + AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); + AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); + AssertUI.Attribute(links[3], "href", links[0].GetAttribute("href")); + + links[1].Click().Wait(500); + culture = browser.Single("span[data-ui=culture]"); + links = browser.FindElements("a"); + AssertUI.TextEquals(culture, "de"); + AssertUI.Attribute(links[0], "href", v => v.EndsWith("/cs/FeatureSamples/Localization/lokalizovana-routa")); + AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); + AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); + AssertUI.Attribute(links[3], "href", links[1].GetAttribute("href")); + + links[2].Click().Wait(500); + culture = browser.Single("span[data-ui=culture]"); + links = browser.FindElements("a"); + AssertUI.TextEquals(culture, "en-US"); + AssertUI.Attribute(links[0], "href", v => v.EndsWith("/cs/FeatureSamples/Localization/lokalizovana-routa")); + AssertUI.Attribute(links[1], "href", v => v.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); + AssertUI.Attribute(links[2], "href", v => v.EndsWith("/FeatureSamples/Localization/LocalizableRoute")); + AssertUI.Attribute(links[3], "href", links[2].GetAttribute("href")); + }); + } + public LocalizationTests(ITestOutputHelper output) : base(output) { } diff --git a/src/Tests/Routing/RouteTableGroupTests.cs b/src/Tests/Routing/RouteTableGroupTests.cs index 27c316c01d..32868ddf7e 100644 --- a/src/Tests/Routing/RouteTableGroupTests.cs +++ b/src/Tests/Routing/RouteTableGroupTests.cs @@ -40,7 +40,7 @@ public void RouteTableGroup_EmptyRouteName() { var table = new DotvvmRouteTable(configuration); table.AddGroup("Group", "UrlPrefix/{Id}", null, opt => { - opt.Add("Default", "", null, null, null); + opt.Add("Default", "", null, null, null, null); }); var group = table.GetGroup("Group"); @@ -56,7 +56,7 @@ public void RouteTableGroup_DefaultValues() { var table = new DotvvmRouteTable(configuration); table.AddGroup("Group", "UrlPrefix/{Id}", null, opt => { - opt.Add("Route", "Article/{Title}", null, new { Title = "test" }, null); + opt.Add("Route", "Article/{Title}", null, new { Title = "test" }, null, null); }); var group = table.GetGroup("Group"); @@ -74,8 +74,8 @@ public void RouteTableGroup_MultipleRoutes() { var table = new DotvvmRouteTable(configuration); table.AddGroup("Group", "UrlPrefix/{Id}", null, opt => { - opt.Add("Route0", "Article0/{Title}", null, null, null); - opt.Add("Route1", "Article1/{Title}", null, null, null); + opt.Add("Route0", "Article0/{Title}", null, null, null, null); + opt.Add("Route1", "Article1/{Title}", null, null, null, null); }); var group = table.GetGroup("Group"); @@ -90,8 +90,8 @@ public void RouteTableGroup_MultipleRoutesWithParameters() { var table = new DotvvmRouteTable(configuration); table.AddGroup("Group", "UrlPrefix/{Id}", null, opt => { - opt.Add("Route0", "Article0/{Title}", null, null, null); - opt.Add("Route1", "Article1/{Title}", null, null, null); + opt.Add("Route0", "Article0/{Title}", null, null, null, null); + opt.Add("Route1", "Article1/{Title}", null, null, null, null); }); var group = table.GetGroup("Group"); @@ -110,11 +110,11 @@ public void RouteTableGroup_NestedGroups() table.AddGroup("Group1", "UrlPrefix1", null, opt1 => { opt1.AddGroup("Group2", "UrlPrefix2", null, opt2 => { opt2.AddGroup("Group3", "UrlPrefix3", null, opt3 => { - opt3.Add("Route3", "Article3", null, null, null); + opt3.Add("Route3", "Article3", null, null, null, null); }); - opt2.Add("Route2", "Article2", null, null, null); + opt2.Add("Route2", "Article2", null, null, null, null); }); - opt1.Add("Route1", "Article1", null, null, null); + opt1.Add("Route1", "Article1", null, null, null, null); }); var group = table.GetGroup("Group1"); From e3a53fb913b534edc515e423db8f3a5bcc45251d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Wed, 29 May 2024 20:33:34 +0200 Subject: [PATCH 2/8] Reverted unnecessary file changes --- .../Framework/Controls/RouteLinkHelpers.cs | 1 - .../Hosting/DotvvmRequestContextExtensions.cs | 17 ++++------------- src/Samples/AspNetCoreLatest/Startup.cs | 2 +- src/Samples/Owin/Startup.cs | 3 +-- 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/Framework/Framework/Controls/RouteLinkHelpers.cs b/src/Framework/Framework/Controls/RouteLinkHelpers.cs index be8f4f7457..bf17ef36b8 100644 --- a/src/Framework/Framework/Controls/RouteLinkHelpers.cs +++ b/src/Framework/Framework/Controls/RouteLinkHelpers.cs @@ -12,7 +12,6 @@ using DotVVM.Framework.Utils; using DotVVM.Framework.Configuration; using System.Collections.Immutable; -using System.ComponentModel.DataAnnotations; namespace DotVVM.Framework.Controls { diff --git a/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs b/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs index 875250cc2d..913347ffb7 100644 --- a/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs +++ b/src/Framework/Framework/Hosting/DotvvmRequestContextExtensions.cs @@ -52,22 +52,13 @@ public static void ChangeCurrentCulture(this IDotvvmRequestContext context, stri [Obsolete("This method only assigns CultureInfo.CurrentCulture, which is not preserved in async methods. You should assign it manually, or use RequestLocalization middleware or LocalizablePresenter.")] public static void ChangeCurrentCulture(this IDotvvmRequestContext context, string cultureName, string uiCultureName) { - if (!string.IsNullOrEmpty(cultureName)) - { #if DotNetCore - CultureInfo.CurrentCulture = new CultureInfo(cultureName); + CultureInfo.CurrentCulture = new CultureInfo(cultureName); + CultureInfo.CurrentUICulture = new CultureInfo(uiCultureName); #else - Thread.CurrentThread.CurrentCulture = new CultureInfo(cultureName); + Thread.CurrentThread.CurrentCulture = new CultureInfo(cultureName); + Thread.CurrentThread.CurrentUICulture = new CultureInfo(uiCultureName); #endif - } - if (!string.IsNullOrEmpty(uiCultureName)) - { -#if DotNetCore - CultureInfo.CurrentUICulture = new CultureInfo(uiCultureName); -#else - Thread.CurrentThread.CurrentUICulture = new CultureInfo(uiCultureName); -#endif - } } /// diff --git a/src/Samples/AspNetCoreLatest/Startup.cs b/src/Samples/AspNetCoreLatest/Startup.cs index b73dae631f..43107a6baa 100644 --- a/src/Samples/AspNetCoreLatest/Startup.cs +++ b/src/Samples/AspNetCoreLatest/Startup.cs @@ -59,7 +59,7 @@ public void ConfigureServices(IServiceCollection services) .AddCookie("Scheme3"); services.AddHealthChecks(); - + services.AddLocalization(o => o.ResourcesPath = "Resources"); services.AddDotVVM(); diff --git a/src/Samples/Owin/Startup.cs b/src/Samples/Owin/Startup.cs index e397ccd980..bef3c95d16 100644 --- a/src/Samples/Owin/Startup.cs +++ b/src/Samples/Owin/Startup.cs @@ -4,6 +4,7 @@ using DotVVM.Framework.Hosting; using DotVVM.Samples.BasicSamples; using DotVVM.Samples.BasicSamples.ViewModels.ComplexSamples.Auth; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Owin; using Microsoft.Owin.Security.Cookies; using Owin; @@ -16,9 +17,7 @@ using System.Configuration; using DotVVM.Framework.Utils; using System.Linq; -using System.Threading; using System.Globalization; -using System.Runtime.Remoting.Contexts; [assembly: OwinStartup(typeof(Startup))] From 8d27f8851432040f910a6975b2cac3a96a171d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Tue, 4 Jun 2024 09:47:08 +0200 Subject: [PATCH 3/8] Fixed build errors and unit tests --- src/Framework/Framework/Controls/RouteLink.cs | 8 ++++---- src/Framework/Framework/Routing/DotvvmRouteTable.cs | 2 +- ...gurationSerializationTests.SerializeDefaultConfig.json | 4 ++++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Framework/Framework/Controls/RouteLink.cs b/src/Framework/Framework/Controls/RouteLink.cs index 809ecfce51..f4044a96c6 100644 --- a/src/Framework/Framework/Controls/RouteLink.cs +++ b/src/Framework/Framework/Controls/RouteLink.cs @@ -69,13 +69,13 @@ public string Text /// Gets or sets the required culture of the page. This property is supported only when using localizable routes. /// [MarkupOptions(AllowBinding = false)] - public string Culture + public string? Culture { - get { return (string)GetValue(CultureProperty); } + get { return (string?)GetValue(CultureProperty); } set { SetValue(CultureProperty, value); } } public static readonly DotvvmProperty CultureProperty - = DotvvmProperty.Register(c => c.Culture, null); + = DotvvmProperty.Register(c => c.Culture, null); /// /// Gets or sets a collection of parameters to be substituted in the route URL. If the current route contains a parameter with the same name, its value will be reused unless another value is specified here. @@ -219,7 +219,7 @@ public static IEnumerable ValidateUsage(ResolvedControl contr } } - var parameterDefinitions = route.ParameterNames; + var parameterDefinitions = route!.ParameterNames; var parameterReferences = control.Properties.Where(i => i.Key is GroupedDotvvmProperty p && p.PropertyGroup == ParamsGroupDescriptor); var undefinedReferences = diff --git a/src/Framework/Framework/Routing/DotvvmRouteTable.cs b/src/Framework/Framework/Routing/DotvvmRouteTable.cs index 572d071e99..aa0ccf65e7 100644 --- a/src/Framework/Framework/Routing/DotvvmRouteTable.cs +++ b/src/Framework/Framework/Routing/DotvvmRouteTable.cs @@ -225,7 +225,7 @@ public void AddRouteRedirection(string routeName, string urlPattern, FuncThe URL. /// The presenter factory. /// The default values. - public void Add(string routeName, string? url, Type presenterType, object? defaultValues = null, LocalizedRouteUrl[] localizedUrls = null) + public void Add(string routeName, string? url, Type presenterType, object? defaultValues = null, LocalizedRouteUrl[]? localizedUrls = null) { ThrowIfFrozen(); if (!typeof(IDotvvmPresenter).IsAssignableFrom(presenterType)) diff --git a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json index ecac23359d..f0b2e8f951 100644 --- a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json +++ b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json @@ -1410,6 +1410,10 @@ } }, "DotVVM.Framework.Controls.RouteLink": { + "Culture": { + "type": "System.String", + "onlyHardcoded": true + }, "Enabled": { "type": "System.Boolean", "defaultValue": true From 8f025e49fc3d3d362e1d653a8da8defe15caf718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Fri, 12 Jul 2024 10:13:46 +0200 Subject: [PATCH 4/8] Implemented partial route matching and handlers --- .../Middlewares/DotvvmRoutingMiddleware.cs | 46 +++++++++-- .../LocalResourceUrlManager.cs | 2 +- ...nonicalRedirectPartialMatchRouteHandler.cs | 18 ++++ .../Framework/Routing/DotvvmRouteTable.cs | 28 ++++--- .../Framework/Routing/IPartialMatchRoute.cs | 9 ++ .../Routing/IPartialMatchRouteHandler.cs | 14 ++++ .../Framework/Routing/LocalizedDotvvmRoute.cs | 58 ++++++++++++- src/Framework/Framework/Routing/RouteBase.cs | 2 +- .../ApplicationInsights.Owin/Web.config | 82 ++++++++++++------- src/Samples/AspNetCore/Startup.cs | 3 +- src/Samples/AspNetCoreLatest/Startup.cs | 3 +- src/Samples/Common/DotvvmStartup.cs | 1 + src/Samples/Owin/Startup.cs | 12 ++- .../Tests/Tests/Feature/LocalizationTests.cs | 13 +++ src/Tests/Routing/DotvvmRouteTests.cs | 63 ++++++++++++++ 15 files changed, 302 insertions(+), 52 deletions(-) create mode 100644 src/Framework/Framework/Routing/CanonicalRedirectPartialMatchRouteHandler.cs create mode 100644 src/Framework/Framework/Routing/IPartialMatchRoute.cs create mode 100644 src/Framework/Framework/Routing/IPartialMatchRouteHandler.cs diff --git a/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs b/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs index 2f31f4edb3..def06012d2 100644 --- a/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs +++ b/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs @@ -38,7 +38,7 @@ private static bool TryParseGooglebotHashbangEscapedFragment(string queryString, return false; } - public static RouteBase? FindMatchingRoute(IEnumerable routes, IDotvvmRequestContext context, out IDictionary? parameters) + public static RouteBase? FindMatchingRoute(IEnumerable routes, IDotvvmRequestContext context, out IDictionary? parameters, out bool isPartialMatch) { string? url; if (!TryParseGooglebotHashbangEscapedFragment(context.HttpContext.Request.Url.Query, out url)) @@ -53,12 +53,35 @@ private static bool TryParseGooglebotHashbangEscapedFragment(string queryString, url = url.Substring(HostingConstants.SpaUrlIdentifier.Length).Trim('/'); } - // find the route + RouteBase? partialMatch = null; + IDictionary? partialMatchParameters = null; + foreach (var r in routes) { - if (r.IsMatch(url, out parameters)) return r; + if (r.IsMatch(url, out parameters)) + { + isPartialMatch = false; + return r; + } + + if (partialMatch == null + && r is IPartialMatchRoute partialMatchRoute + && partialMatchRoute.IsPartialMatch(url, out var partialMatchResult, out var partialMatchParametersResult)) + { + partialMatch = partialMatchResult; + partialMatchParameters = partialMatchParametersResult; + } + } + + if (partialMatch != null) + { + isPartialMatch = true; + parameters = partialMatchParameters; + return partialMatch; } + + isPartialMatch = false; parameters = null; return null; } @@ -70,11 +93,11 @@ public async Task Handle(IDotvvmRequestContext context) await requestTracer.TraceEvent(RequestTracingConstants.BeginRequest, context); - var route = FindMatchingRoute(context.Configuration.RouteTable, context, out var parameters); + var route = FindMatchingRoute(context.Configuration.RouteTable, context, out var parameters, out var isPartialMatch); //check if route exists if (route == null) return false; - + var timer = ValueStopwatch.StartNew(); context.Route = route; @@ -83,12 +106,25 @@ public async Task Handle(IDotvvmRequestContext context) WriteSecurityHeaders(context); + var filters = ActionFilterHelper.GetActionFilters(presenter.GetType().GetTypeInfo()) .Concat(context.Configuration.Runtime.GlobalFilters.OfType()); try { foreach (var f in filters) await f.OnPresenterExecutingAsync(context); + + if (isPartialMatch) + { + foreach (var handler in context.Configuration.RouteTable.PartialMatchHandlers) + { + if (await handler.TryHandlePartialMatch(context)) + { + break; + } + } + } + await presenter.ProcessRequest(context); foreach (var f in filters) await f.OnPresenterExecutedAsync(context); } diff --git a/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs b/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs index 1790e23ed1..e968eb8e91 100644 --- a/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs +++ b/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs @@ -65,7 +65,7 @@ protected virtual string GetVersionHash(ILocalResourceLocation location, IDotvvm public ILocalResourceLocation? FindResource(string url, IDotvvmRequestContext context, out string? mimeType) { mimeType = null; - if (DotvvmRoutingMiddleware.FindMatchingRoute(new[] { resourceRoute }, context, out var parameters) == null) + if (DotvvmRoutingMiddleware.FindMatchingRoute(new[] { resourceRoute }, context, out var parameters, out _) == null) { return null; } diff --git a/src/Framework/Framework/Routing/CanonicalRedirectPartialMatchRouteHandler.cs b/src/Framework/Framework/Routing/CanonicalRedirectPartialMatchRouteHandler.cs new file mode 100644 index 0000000000..3921f75ad6 --- /dev/null +++ b/src/Framework/Framework/Routing/CanonicalRedirectPartialMatchRouteHandler.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Framework.Routing; + +public class CanonicalRedirectPartialMatchRouteHandler : IPartialMatchRouteHandler +{ + /// + /// Indicates whether a permanent redirect shall be used. + /// + public bool IsPermanentRedirect { get; set; } + + public Task TryHandlePartialMatch(IDotvvmRequestContext context) + { + context.RedirectToRoute(context.Route!.RouteName, context.Parameters); + return Task.FromResult(true); + } +} diff --git a/src/Framework/Framework/Routing/DotvvmRouteTable.cs b/src/Framework/Framework/Routing/DotvvmRouteTable.cs index aa0ccf65e7..8b477d6d0e 100644 --- a/src/Framework/Framework/Routing/DotvvmRouteTable.cs +++ b/src/Framework/Framework/Routing/DotvvmRouteTable.cs @@ -16,6 +16,7 @@ public sealed class DotvvmRouteTable : IEnumerable private readonly DotvvmConfiguration configuration; private readonly List> list = new List>(); + private List partialMatchHandlers = new List(); private readonly Dictionary dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -23,6 +24,9 @@ private readonly Dictionary routeTableGroups = new Dictionary(); private RouteTableGroup? group = null; + + public IReadOnlyList PartialMatchHandlers => partialMatchHandlers; + /// /// Initializes a new instance of the class. /// @@ -113,17 +117,8 @@ public void Add(string routeName, string? url, string virtualPath, object? defau { ThrowIfFrozen(); - url = CombinePath(group?.UrlPrefix, url); virtualPath = CombinePath(group?.VirtualPathPrefix, virtualPath); - presenterFactory ??= GetDefaultPresenter; - routeName = group?.RouteNamePrefix + routeName; - - RouteBase route = localizedUrls == null - ? new DotvvmRoute(url, virtualPath, defaultValues, presenterFactory, configuration) - : new LocalizedDotvvmRoute(url, - localizedUrls.Select(l => new LocalizedRouteUrl(l.CultureIdentifier, CombinePath(group?.UrlPrefix, l.RouteUrl))).ToArray(), - virtualPath, defaultValues, presenterFactory, configuration); - Add(routeName, route); + AddCore(routeName, url, virtualPath, defaultValues, presenterFactory, localizedUrls); } /// @@ -137,10 +132,15 @@ public void Add(string routeName, string? url, Func? presenterFactory, LocalizedRouteUrl[]? localizedUrls) + { url = CombinePath(group?.UrlPrefix, url); presenterFactory ??= GetDefaultPresenter; routeName = group?.RouteNamePrefix + routeName; - var virtualPath = group?.VirtualPathPrefix ?? ""; RouteBase route = localizedUrls == null ? new DotvvmRoute(url, virtualPath, defaultValues, presenterFactory, configuration) @@ -256,6 +256,12 @@ public void Add(string routeName, RouteBase route) dictionary.Add(routeName, route); } + public void AddPartialMatchHandler(IPartialMatchRouteHandler handler) + { + ThrowIfFrozen(); + partialMatchHandlers.Add(handler); + } + public bool Contains(string routeName) { return dictionary.ContainsKey(routeName); diff --git a/src/Framework/Framework/Routing/IPartialMatchRoute.cs b/src/Framework/Framework/Routing/IPartialMatchRoute.cs new file mode 100644 index 0000000000..d1d342f7e1 --- /dev/null +++ b/src/Framework/Framework/Routing/IPartialMatchRoute.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace DotVVM.Framework.Routing; + +public interface IPartialMatchRoute +{ + bool IsPartialMatch(string url, [MaybeNullWhen(false)] out RouteBase matchedRoute, [MaybeNullWhen(false)] out IDictionary values); +} diff --git a/src/Framework/Framework/Routing/IPartialMatchRouteHandler.cs b/src/Framework/Framework/Routing/IPartialMatchRouteHandler.cs new file mode 100644 index 0000000000..2166a97923 --- /dev/null +++ b/src/Framework/Framework/Routing/IPartialMatchRouteHandler.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Framework.Routing; + +public interface IPartialMatchRouteHandler +{ + /// + /// Handles the partial route match and returns whether the request was handled to prevent other handlers to take place. + /// + /// If true, the next partial match handlers will not be executed. + Task TryHandlePartialMatch(IDotvvmRequestContext context); +} diff --git a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs index 00e4adfd9c..2a18088360 100644 --- a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs +++ b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs @@ -18,7 +18,7 @@ namespace DotVVM.Framework.Routing /// Please note that the extraction of the culture from the URL and setting the culture must be done at the beginning of the request pipeline. /// Therefore, the route only matches the URL for the current culture. /// - public sealed class LocalizedDotvvmRoute : RouteBase + public sealed class LocalizedDotvvmRoute : RouteBase, IPartialMatchRoute { private static readonly HashSet AvailableCultureNames = CultureInfo.GetCultures(CultureTypes.AllCultures) .Where(c => c != CultureInfo.InvariantCulture) @@ -34,6 +34,22 @@ public sealed class LocalizedDotvvmRoute : RouteBase /// public override IEnumerable ParameterNames => GetRouteForCulture(CultureInfo.CurrentUICulture).ParameterNames; + public override string RouteName + { + get + { + return base.RouteName; + } + internal set + { + base.RouteName = value; + foreach (var route in localizedRoutes) + { + route.Value.RouteName = value; + } + } + } + /// /// Initializes a new instance of the class. /// @@ -87,6 +103,46 @@ public static void ValidateCultureName(string cultureIdentifier) ///
public override bool IsMatch(string url, [MaybeNullWhen(false)] out IDictionary values) => GetRouteForCulture(CultureInfo.CurrentCulture).IsMatch(url, out values); + public bool IsPartialMatch(string url, [MaybeNullWhen(false)] out RouteBase matchedRoute, [MaybeNullWhen(false)] out IDictionary values) + { + RouteBase? twoLetterCultureMatch = null; + IDictionary twoLetterCultureMatchValues = null; + + foreach (var route in localizedRoutes) + { + if (route.Value.IsMatch(url, out values)) + { + if (route.Key.Length > 2) + { + // exact culture match - return immediately + matchedRoute = route.Value; + return true; + } + else if (route.Key.Length > 0 && twoLetterCultureMatch == null) + { + // match for two-letter culture - continue searching if there is a better match + twoLetterCultureMatch = route.Value; + twoLetterCultureMatchValues = values; + } + else + { + // ignore exact match - this was done using classic IsMatch + } + } + } + + if (twoLetterCultureMatch != null) + { + matchedRoute = twoLetterCultureMatch; + values = twoLetterCultureMatchValues!; + return true; + } + + matchedRoute = null; + values = null; + return false; + } + protected internal override string BuildUrlCore(Dictionary values) => GetRouteForCulture(CultureInfo.CurrentCulture).BuildUrlCore(values); protected override void Freeze2() diff --git a/src/Framework/Framework/Routing/RouteBase.cs b/src/Framework/Framework/Routing/RouteBase.cs index 9c17498015..76df5c0506 100644 --- a/src/Framework/Framework/Routing/RouteBase.cs +++ b/src/Framework/Framework/Routing/RouteBase.cs @@ -26,7 +26,7 @@ public abstract class RouteBase /// /// Gets key of route. /// - public string RouteName { get; internal set; } + public virtual string RouteName { get; internal set; } /// /// Gets the default values of the optional parameters. diff --git a/src/Samples/ApplicationInsights.Owin/Web.config b/src/Samples/ApplicationInsights.Owin/Web.config index 49248345f6..13319db1d1 100644 --- a/src/Samples/ApplicationInsights.Owin/Web.config +++ b/src/Samples/ApplicationInsights.Owin/Web.config @@ -31,50 +31,72 @@ - - + + + + + + - - - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - + - - - + - - - - + + - - - - + + - - - - + + + + + + + + + + @@ -85,14 +107,14 @@ - - + + - - + + diff --git a/src/Samples/AspNetCore/Startup.cs b/src/Samples/AspNetCore/Startup.cs index b1273c033e..05adcc31a9 100644 --- a/src/Samples/AspNetCore/Startup.cs +++ b/src/Samples/AspNetCore/Startup.cs @@ -75,7 +75,8 @@ public void ConfigureServices(IServiceCollection services) .SetDefaultCulture(supportedCultures[0]) .AddSupportedCultures(supportedCultures) .AddSupportedUICultures(supportedCultures) - .AddInitialRequestCultureProvider(new PrefixRequestCultureProvider()); + .AddInitialRequestCultureProvider(new PrefixRequestCultureProvider()) + .AddInitialRequestCultureProvider(new QueryStringRequestCultureProvider() { UIQueryStringKey = "lang", QueryStringKey = "lang" }); ; }); } diff --git a/src/Samples/AspNetCoreLatest/Startup.cs b/src/Samples/AspNetCoreLatest/Startup.cs index 43107a6baa..d614f337ee 100644 --- a/src/Samples/AspNetCoreLatest/Startup.cs +++ b/src/Samples/AspNetCoreLatest/Startup.cs @@ -79,7 +79,8 @@ public void ConfigureServices(IServiceCollection services) .SetDefaultCulture(supportedCultures[0]) .AddSupportedCultures(supportedCultures) .AddSupportedUICultures(supportedCultures) - .AddInitialRequestCultureProvider(new PrefixRequestCultureProvider()); + .AddInitialRequestCultureProvider(new PrefixRequestCultureProvider()) + .AddInitialRequestCultureProvider(new QueryStringRequestCultureProvider() { UIQueryStringKey = "lang", QueryStringKey = "lang" }); }); } diff --git a/src/Samples/Common/DotvvmStartup.cs b/src/Samples/Common/DotvvmStartup.cs index 47c6b31787..520805a6d9 100644 --- a/src/Samples/Common/DotvvmStartup.cs +++ b/src/Samples/Common/DotvvmStartup.cs @@ -239,6 +239,7 @@ private static void AddRoutes(DotvvmConfiguration config) new("cs-CZ", "cs/FeatureSamples/Localization/lokalizovana-routa"), new("de", "de/FeatureSamples/Localization/lokalisierte-route"), }); + config.RouteTable.AddPartialMatchHandler(new CanonicalRedirectPartialMatchRouteHandler()); config.RouteTable.AutoDiscoverRoutes(new DefaultRouteStrategy(config)); diff --git a/src/Samples/Owin/Startup.cs b/src/Samples/Owin/Startup.cs index bef3c95d16..214311bbf7 100644 --- a/src/Samples/Owin/Startup.cs +++ b/src/Samples/Owin/Startup.cs @@ -28,7 +28,17 @@ public class Startup public void Configuration(IAppBuilder app) { app.Use((context, next) => { - if (context.Request.Path.StartsWithSegments(new PathString("/cs"))) + if (context.Request.Query["lang"] == "cs") + { + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("cs-CZ"); + context.Set(HostingConstants.OwinDoNotSetRequestCulture, true); + } + else if (context.Request.Query["lang"] == "de") + { + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de"); + context.Set(HostingConstants.OwinDoNotSetRequestCulture, true); + } + else if (context.Request.Path.StartsWithSegments(new PathString("/cs"))) { CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("cs-CZ"); context.Set(HostingConstants.OwinDoNotSetRequestCulture, true); diff --git a/src/Samples/Tests/Tests/Feature/LocalizationTests.cs b/src/Samples/Tests/Tests/Feature/LocalizationTests.cs index 5751368528..e3b56c741b 100644 --- a/src/Samples/Tests/Tests/Feature/LocalizationTests.cs +++ b/src/Samples/Tests/Tests/Feature/LocalizationTests.cs @@ -176,6 +176,19 @@ public void Feature_Localization_LocalizableRoute() }); } + [Fact] + public void Feature_Localization_LocalizableRoute_PartialMatchHandlers() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl("/cs/FeatureSamples/Localization/lokalizovana-routa?lang=de"); + + var culture = browser.Single("span[data-ui=culture]"); + AssertUI.TextEquals(culture, "de"); + + AssertUI.Url(browser, p => p.EndsWith("/de/FeatureSamples/Localization/lokalisierte-route")); + }); + } + public LocalizationTests(ITestOutputHelper output) : base(output) { } diff --git a/src/Tests/Routing/DotvvmRouteTests.cs b/src/Tests/Routing/DotvvmRouteTests.cs index c11403f94e..338acc4aff 100644 --- a/src/Tests/Routing/DotvvmRouteTests.cs +++ b/src/Tests/Routing/DotvvmRouteTests.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using System.Globalization; +using System.Threading; using DotVVM.Framework.Tests.Binding; namespace DotVVM.Framework.Tests.Routing @@ -262,6 +263,68 @@ public void DotvvmRoute_IsMatch_TwoParameters_OneOptional_Suffix() Assert.AreEqual(1, parameters.Count); } + [TestMethod] + public void LocalizedDotvvmRoute_IsMatch_ExactCultureMatch() + { + CultureUtils.RunWithCulture("cs-CZ", () => + { + var route = new LocalizedDotvvmRoute("cs-CZ", new [] { + new LocalizedRouteUrl("cs", "cs"), + new LocalizedRouteUrl("cs-CZ", "cs-CZ"), + new LocalizedRouteUrl("en", "en") + }, "", null, _ => null, configuration); + + var result = route.IsMatch("cs-CZ", out var parameters); + Assert.IsTrue(result); + }); + } + + [TestMethod] + public void LocalizedDotvvmRoute_IsMatch_TwoLetterCultureMatch() + { + CultureUtils.RunWithCulture("en-US", () => { + var route = new LocalizedDotvvmRoute("en", new[] { + new LocalizedRouteUrl("cs", "cs"), + new LocalizedRouteUrl("cs-CZ", "cs-CZ"), + new LocalizedRouteUrl("en", "en") + }, "", null, _ => null, configuration); + + var result = route.IsMatch("en", out var parameters); + Assert.IsTrue(result); + }); + } + + [TestMethod] + public void LocalizedDotvvmRoute_IsMatch_InvalidCultureMatch() + { + CultureUtils.RunWithCulture("en-US", () => { + var route = new LocalizedDotvvmRoute("", new[] { + new LocalizedRouteUrl("cs", "cs"), + new LocalizedRouteUrl("cs-CZ", "cs-CZ"), + new LocalizedRouteUrl("en", "en") + }, "", null, _ => null, configuration); + + var result = route.IsMatch("cs", out var parameters); + Assert.IsFalse(result); + }); + } + + [TestMethod] + public void LocalizedDotvvmRoute_IsPartialMatch() + { + CultureUtils.RunWithCulture("en-US", () => { + var route = new LocalizedDotvvmRoute("", new[] { + new LocalizedRouteUrl("cs", "cs"), + new LocalizedRouteUrl("cs-CZ", "cs-CZ"), + new LocalizedRouteUrl("en", "en") + }, "", null, _ => null, configuration); + + var result = route.IsPartialMatch("cs", out var matchedRoute, out var parameters); + Assert.IsTrue(result); + Assert.AreEqual("cs", matchedRoute.Url); + }); + } + [TestMethod] public void DotvvmRoute_BuildUrl_UrlTwoParameters() { From 511ae09b24298a5f4f845a8c1b1ce6f0735030e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Fri, 12 Jul 2024 14:06:13 +0200 Subject: [PATCH 5/8] Fixed binding redirects --- .../ApplicationInsights.Owin/Web.config | 82 +++++++------------ 1 file changed, 30 insertions(+), 52 deletions(-) diff --git a/src/Samples/ApplicationInsights.Owin/Web.config b/src/Samples/ApplicationInsights.Owin/Web.config index 13319db1d1..49248345f6 100644 --- a/src/Samples/ApplicationInsights.Owin/Web.config +++ b/src/Samples/ApplicationInsights.Owin/Web.config @@ -31,72 +31,50 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - + + + - + + + - + + + - + + + - - - - - - + + + + - - + + + + - - + + @@ -107,14 +85,14 @@ - - + + - - + + From a66689c273ff1824dec20277d2646b099f35a22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Fri, 12 Jul 2024 17:19:02 +0200 Subject: [PATCH 6/8] Review comments resolved --- .../Framework/Hosting/HostingConstants.cs | 5 ++ .../Middlewares/DotvvmRoutingMiddleware.cs | 47 ++++++++++--------- .../LocalResourceUrlManager.cs | 4 +- .../Framework/Routing/DotvvmRoute.cs | 4 ++ .../Framework/Routing/DotvvmRouteParser.cs | 13 ++++- .../Framework/Routing/DotvvmRouteTable.cs | 19 ++++++-- .../Framework/Routing/LocalizedDotvvmRoute.cs | 15 +++++- src/Framework/Framework/Routing/RouteBase.cs | 5 ++ .../Routing/RouteTableJsonConverter.cs | 4 +- src/Tests/Routing/DotvvmRouteTests.cs | 15 ++++++ 10 files changed, 100 insertions(+), 31 deletions(-) diff --git a/src/Framework/Framework/Hosting/HostingConstants.cs b/src/Framework/Framework/Hosting/HostingConstants.cs index f2e4886d52..c30a77f526 100644 --- a/src/Framework/Framework/Hosting/HostingConstants.cs +++ b/src/Framework/Framework/Hosting/HostingConstants.cs @@ -29,6 +29,11 @@ public class HostingConstants public const string HostAppModeKey = "host.AppMode"; + /// + /// When this key is set to true in the OWIN environment, the request culture will not be set by DotVVM to config.DefaultCulture. + /// Use this key when the request culture is set by the host or the middleware preceding DotVVM. + /// See https://github.com/riganti/dotvvm/blob/93107dd07127ff2bd29c2934f3aa2a26ec2ca79c/src/Samples/Owin/Startup.cs#L34 + /// public const string OwinDoNotSetRequestCulture = "OwinDoNotSetRequestCulture"; } } diff --git a/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs b/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs index def06012d2..70f94a12d5 100644 --- a/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs +++ b/src/Framework/Framework/Hosting/Middlewares/DotvvmRoutingMiddleware.cs @@ -38,10 +38,9 @@ private static bool TryParseGooglebotHashbangEscapedFragment(string queryString, return false; } - public static RouteBase? FindMatchingRoute(IEnumerable routes, IDotvvmRequestContext context, out IDictionary? parameters, out bool isPartialMatch) + public static string GetRouteMatchUrl(IDotvvmRequestContext context) { - string? url; - if (!TryParseGooglebotHashbangEscapedFragment(context.HttpContext.Request.Url.Query, out url)) + if (!TryParseGooglebotHashbangEscapedFragment(context.HttpContext.Request.Url.Query, out var url)) { url = context.HttpContext.Request.Path.Value; } @@ -52,33 +51,40 @@ private static bool TryParseGooglebotHashbangEscapedFragment(string queryString, { url = url.Substring(HostingConstants.SpaUrlIdentifier.Length).Trim('/'); } + return url; + } - // find the route - RouteBase? partialMatch = null; - IDictionary? partialMatchParameters = null; - + internal static RouteBase? FindExactMatchRoute(IEnumerable routes, string matchUrl, out IDictionary? parameters) + { foreach (var r in routes) { - if (r.IsMatch(url, out parameters)) + if (r.IsMatch(matchUrl, out parameters)) { - isPartialMatch = false; return r; } + } + parameters = null; + return null; + } - if (partialMatch == null - && r is IPartialMatchRoute partialMatchRoute - && partialMatchRoute.IsPartialMatch(url, out var partialMatchResult, out var partialMatchParametersResult)) - { - partialMatch = partialMatchResult; - partialMatchParameters = partialMatchParametersResult; - } + public static RouteBase? FindMatchingRoute(DotvvmRouteTable routes, IDotvvmRequestContext context, out IDictionary? parameters, out bool isPartialMatch) + { + var url = GetRouteMatchUrl(context); + + var route = FindExactMatchRoute(routes, url, out parameters); + if (route is { }) + { + isPartialMatch = false; + return route; } - if (partialMatch != null) + foreach (var r in routes.PartialMatchRoutes) { - isPartialMatch = true; - parameters = partialMatchParameters; - return partialMatch; + if (r.IsPartialMatch(url, out var matchedRoute, out parameters)) + { + isPartialMatch = true; + return matchedRoute; + } } isPartialMatch = false; @@ -86,7 +92,6 @@ private static bool TryParseGooglebotHashbangEscapedFragment(string queryString, return null; } - public async Task Handle(IDotvvmRequestContext context) { var requestTracer = context.Services.GetRequiredService(); diff --git a/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs b/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs index e968eb8e91..c740bbfa96 100644 --- a/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs +++ b/src/Framework/Framework/ResourceManagement/LocalResourceUrlManager.cs @@ -65,7 +65,9 @@ protected virtual string GetVersionHash(ILocalResourceLocation location, IDotvvm public ILocalResourceLocation? FindResource(string url, IDotvvmRequestContext context, out string? mimeType) { mimeType = null; - if (DotvvmRoutingMiddleware.FindMatchingRoute(new[] { resourceRoute }, context, out var parameters, out _) == null) + + var routeMatchUrl = DotvvmRoutingMiddleware.GetRouteMatchUrl(context); + if (DotvvmRoutingMiddleware.FindExactMatchRoute(new[] { resourceRoute }, routeMatchUrl, out var parameters) == null) { return null; } diff --git a/src/Framework/Framework/Routing/DotvvmRoute.cs b/src/Framework/Framework/Routing/DotvvmRoute.cs index 993929b848..fc5102c930 100644 --- a/src/Framework/Framework/Routing/DotvvmRoute.cs +++ b/src/Framework/Framework/Routing/DotvvmRoute.cs @@ -20,12 +20,15 @@ public sealed class DotvvmRoute : RouteBase private List, string>> urlBuilders; private List?>> parameters; private string urlWithoutTypes; + private List> parameterMetadata; /// /// Gets the names of the route parameters in the order in which they appear in the URL. /// public override IEnumerable ParameterNames => parameters.Select(p => p.Key); + public override IEnumerable> ParameterMetadata => parameterMetadata; + public override string UrlWithoutTypes => urlWithoutTypes; @@ -77,6 +80,7 @@ private void ParseRouteUrl(DotvvmConfiguration configuration) routeRegex = result.RouteRegex; urlBuilders = result.UrlBuilders; parameters = result.Parameters; + parameterMetadata = result.ParameterMetadata; urlWithoutTypes = result.UrlWithoutTypes; } diff --git a/src/Framework/Framework/Routing/DotvvmRouteParser.cs b/src/Framework/Framework/Routing/DotvvmRouteParser.cs index 3795668dc9..123ae0caa3 100644 --- a/src/Framework/Framework/Routing/DotvvmRouteParser.cs +++ b/src/Framework/Framework/Routing/DotvvmRouteParser.cs @@ -25,6 +25,7 @@ public UrlParserResult ParseRouteUrl(string url, IDictionary de var regex = new StringBuilder("^"); var parameters = new List?>>(); + var parameterMetadata = new List>(); var urlBuilders = new List, string>>(); urlBuilders.Add(_ => "~"); @@ -32,6 +33,7 @@ void AppendParameterParserResult(UrlParameterParserResult result) { regex.Append(result.ParameterRegexPart); parameters.Add(result.Parameter); + parameterMetadata.Add(new KeyValuePair(result.Parameter.Key, result.Metadata)); urlBuilders.Add(result.UrlBuilder); } @@ -78,6 +80,7 @@ void AppendParameterParserResult(UrlParameterParserResult result) RouteRegex = new Regex(regex.ToString(), RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), UrlBuilders = urlBuilders, Parameters = parameters, + ParameterMetadata = parameterMetadata, UrlWithoutTypes = string.Concat(urlBuilders.Skip(1).Select(b => b(fakeParameters))).TrimStart('/') }; } @@ -109,6 +112,7 @@ private UrlParameterParserResult ParseParameter(string url, string prefix, ref i // determine route parameter constraint IRouteParameterConstraint? type = null; string? parameter = null; + string? typeName = null; if (url[index] == ':') { startIndex = index + 1; @@ -118,7 +122,7 @@ private UrlParameterParserResult ParseParameter(string url, string prefix, ref i throw new ArgumentException($"The route URL '{url}' is not valid! It contains an unclosed parameter."); } - var typeName = url.Substring(startIndex, index - startIndex); + typeName = url.Substring(startIndex, index - startIndex); if (!routeConstraints.ContainsKey(typeName)) { throw new ArgumentException($"The route parameter constraint '{typeName}' is not valid!"); @@ -181,7 +185,8 @@ private UrlParameterParserResult ParseParameter(string url, string prefix, ref i { ParameterRegexPart = result, UrlBuilder = urlBuilder, - Parameter = parameterParser + Parameter = parameterParser, + Metadata = new DotvvmRouteParameterMetadata(isOptional, parameter != null ? $"{typeName}({parameter})" : typeName) }; } @@ -190,14 +195,18 @@ private struct UrlParameterParserResult public string ParameterRegexPart { get; set; } public Func, string> UrlBuilder { get; set; } public KeyValuePair?> Parameter { get; set; } + public DotvvmRouteParameterMetadata Metadata { get; set; } } } + public record DotvvmRouteParameterMetadata(bool IsOptional, string? ConstraintName); + public struct UrlParserResult { public Regex RouteRegex { get; set; } public List, string>> UrlBuilders { get; set; } public List?>> Parameters { get; set; } public string UrlWithoutTypes { get; set; } + public List> ParameterMetadata { get; set; } } } diff --git a/src/Framework/Framework/Routing/DotvvmRouteTable.cs b/src/Framework/Framework/Routing/DotvvmRouteTable.cs index 8b477d6d0e..fd0bd12417 100644 --- a/src/Framework/Framework/Routing/DotvvmRouteTable.cs +++ b/src/Framework/Framework/Routing/DotvvmRouteTable.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using DotVVM.Framework.Configuration; using DotVVM.Framework.Hosting; @@ -14,19 +15,22 @@ namespace DotVVM.Framework.Routing public sealed class DotvvmRouteTable : IEnumerable { private readonly DotvvmConfiguration configuration; - private readonly List> list - = new List>(); - private List partialMatchHandlers = new List(); + private readonly List> list = new(); + private readonly List partialMatchHandlers = new(); + private readonly List partialMatchRoutes = new(); private readonly Dictionary dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary routeTableGroups = new Dictionary(); - private RouteTableGroup? group = null; + private RouteTableGroup? group = null; public IReadOnlyList PartialMatchHandlers => partialMatchHandlers; + internal IEnumerable PartialMatchRoutes => partialMatchRoutes; + /// /// Initializes a new instance of the class. /// @@ -254,6 +258,11 @@ public void Add(string routeName, RouteBase route) // The list is used for finding the routes because it keeps the ordering, the dictionary is for checking duplicates list.Add(new KeyValuePair(routeName, route)); dictionary.Add(routeName, route); + + if (route is IPartialMatchRoute partialMatchRoute) + { + partialMatchRoutes.Add(partialMatchRoute); + } } public void AddPartialMatchHandler(IPartialMatchRouteHandler handler) @@ -267,7 +276,7 @@ public bool Contains(string routeName) return dictionary.ContainsKey(routeName); } - public bool TryGetValue(string routeName, out RouteBase? route) + public bool TryGetValue(string routeName, [MaybeNullWhen(false)] out RouteBase route) { return dictionary.TryGetValue(routeName, out route); } diff --git a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs index 2a18088360..2c7e5dd809 100644 --- a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs +++ b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs @@ -34,6 +34,8 @@ public sealed class LocalizedDotvvmRoute : RouteBase, IPartialMatchRoute /// public override IEnumerable ParameterNames => GetRouteForCulture(CultureInfo.CurrentUICulture).ParameterNames; + public override IEnumerable> ParameterMetadata => GetRouteForCulture(CultureInfo.CurrentUICulture).ParameterMetadata; + public override string RouteName { get @@ -61,13 +63,24 @@ public LocalizedDotvvmRoute(string defaultLanguageUrl, LocalizedRouteUrl[] local throw new ArgumentException("There must be at least one localized route URL!", nameof(localizedUrls)); } + var defaultRoute = new DotvvmRoute(defaultLanguageUrl, virtualPath, defaultValues, presenterFactory, configuration); + + var sortedParameters = defaultRoute.ParameterMetadata + .OrderBy(n => n.Key) + .ToArray(); + foreach (var localizedUrl in localizedUrls) { var localizedRoute = new DotvvmRoute(localizedUrl.RouteUrl, virtualPath, defaultValues, presenterFactory, configuration); + if (!localizedRoute.ParameterMetadata.OrderBy(n => n.Key) + .SequenceEqual(sortedParameters)) + { + throw new ArgumentException($"Localized route URL '{localizedUrl.RouteUrl}' must contain the same parameters with equal constraints as the default route URL!", nameof(localizedUrls)); + } + localizedRoutes.Add(localizedUrl.CultureIdentifier, localizedRoute); } - var defaultRoute = new DotvvmRoute(defaultLanguageUrl, virtualPath, defaultValues, presenterFactory, configuration); localizedRoutes.Add("", defaultRoute); } diff --git a/src/Framework/Framework/Routing/RouteBase.cs b/src/Framework/Framework/Routing/RouteBase.cs index 76df5c0506..84ddcc3d34 100644 --- a/src/Framework/Framework/Routing/RouteBase.cs +++ b/src/Framework/Framework/Routing/RouteBase.cs @@ -89,6 +89,11 @@ public RouteBase(string url, string virtualPath, IDictionary? d ///
public abstract IEnumerable ParameterNames { get; } + /// + /// Gets the metadata of the route parameters. + /// + public abstract IEnumerable> ParameterMetadata { get; } + /// /// Determines whether the route matches to the specified URL and extracts the parameter values. /// diff --git a/src/Framework/Framework/Routing/RouteTableJsonConverter.cs b/src/Framework/Framework/Routing/RouteTableJsonConverter.cs index c1fa741c9c..ba7c799227 100644 --- a/src/Framework/Framework/Routing/RouteTableJsonConverter.cs +++ b/src/Framework/Framework/Routing/RouteTableJsonConverter.cs @@ -67,7 +67,9 @@ public ErrorRoute(string? url, string? virtualPath, string? name, IDictionary ParameterNames => new string[0]; + public override IEnumerable ParameterNames { get; } = new string[0]; + + public override IEnumerable> ParameterMetadata { get; } = new KeyValuePair[0]; public override string UrlWithoutTypes => base.Url; diff --git a/src/Tests/Routing/DotvvmRouteTests.cs b/src/Tests/Routing/DotvvmRouteTests.cs index 338acc4aff..a6d7e6847a 100644 --- a/src/Tests/Routing/DotvvmRouteTests.cs +++ b/src/Tests/Routing/DotvvmRouteTests.cs @@ -325,6 +325,21 @@ public void LocalizedDotvvmRoute_IsPartialMatch() }); } + [DataTestMethod] + [DataRow("product/{id?}/{name:maxLength(5)}", "en/products/{id?}/{name:maxLength(10)}")] + [DataRow("product/{id?}/{name:maxLength(5)}", "en/products/{id?}/{name}")] + [DataRow("product/{id?}/{name:maxLength(5)}", "en/products/{Id:int?}/{name}")] + [DataRow("product/{id?}/{name:maxLength(5)}", "en/products/{abc}")] + [DataRow("product/{id?}/{name:maxLength(5)}", "en/products/{Id?}/{name:maxLength(5)}")] + public void LocalizedDotvvmRoute_RouteConstraintChecks(string defaultRoute, string localizedRoute) + { + Assert.ThrowsException(() => { + var route = new LocalizedDotvvmRoute(defaultRoute, new[] { + new LocalizedRouteUrl("en", localizedRoute) + }, "", null, _ => null, configuration); + }); + } + [TestMethod] public void DotvvmRoute_BuildUrl_UrlTwoParameters() { From 9f460d6a137fd8f06be984edcb4a4c35f5c67742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Sun, 14 Jul 2024 09:49:45 +0200 Subject: [PATCH 7/8] Fixed issues in tests --- src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs | 2 +- src/Samples/Common/DotvvmStartup.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs index 2c7e5dd809..5c82ae6c78 100644 --- a/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs +++ b/src/Framework/Framework/Routing/LocalizedDotvvmRoute.cs @@ -119,7 +119,7 @@ public static void ValidateCultureName(string cultureIdentifier) public bool IsPartialMatch(string url, [MaybeNullWhen(false)] out RouteBase matchedRoute, [MaybeNullWhen(false)] out IDictionary values) { RouteBase? twoLetterCultureMatch = null; - IDictionary twoLetterCultureMatchValues = null; + IDictionary? twoLetterCultureMatchValues = null; foreach (var route in localizedRoutes) { diff --git a/src/Samples/Common/DotvvmStartup.cs b/src/Samples/Common/DotvvmStartup.cs index 520805a6d9..98253794e7 100644 --- a/src/Samples/Common/DotvvmStartup.cs +++ b/src/Samples/Common/DotvvmStartup.cs @@ -234,7 +234,7 @@ private static void AddRoutes(DotvvmConfiguration config) config.RouteTable.Add("FeatureSamples_Localization_Globalize", "FeatureSamples/Localization/Globalize", "Views/FeatureSamples/Localization/Globalize.dothtml", presenterFactory: LocalizablePresenter.BasedOnQuery("lang")); config.RouteTable.Add("FeatureSamples_CustomPrimitiveTypes_Basic", "FeatureSamples/CustomPrimitiveTypes/Basic/{Id?}", "Views/FeatureSamples/CustomPrimitiveTypes/Basic.dothtml"); - config.RouteTable.Add("FeatureSamples_Localization_LocalizableRoute", "FeatureSamples/Localization/LocalizableRoute/{lang?}", "Views/FeatureSamples/Localization/LocalizableRoute.dothtml", + config.RouteTable.Add("FeatureSamples_Localization_LocalizableRoute", "FeatureSamples/Localization/LocalizableRoute", "Views/FeatureSamples/Localization/LocalizableRoute.dothtml", localizedUrls: new LocalizedRouteUrl[] { new("cs-CZ", "cs/FeatureSamples/Localization/lokalizovana-routa"), new("de", "de/FeatureSamples/Localization/lokalisierte-route"), From 2695c3b72e6939d07734530fba8334df53abb76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Sun, 14 Jul 2024 09:57:43 +0200 Subject: [PATCH 8/8] Removed .NET 3.1 from GitHub Actions --- .github/setup/action.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/setup/action.yml b/.github/setup/action.yml index 8b786bb560..360f10360f 100644 --- a/.github/setup/action.yml +++ b/.github/setup/action.yml @@ -37,7 +37,6 @@ runs: dotnet-version: | 8.0.x 6.0.x - 3.1.x - if: ${{ runner.os == 'Windows' }} uses: microsoft/setup-msbuild@v1.1