Skip to content

Commit

Permalink
Merge pull request #1824 from riganti/feature/route-localization
Browse files Browse the repository at this point in the history
Added support for localizable routes
  • Loading branch information
tomasherceg authored Aug 1, 2024
2 parents 3f85205 + 2695c3b commit d824028
Show file tree
Hide file tree
Showing 31 changed files with 734 additions and 41 deletions.
1 change: 0 additions & 1 deletion .github/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ runs:
dotnet-version: |
8.0.x
6.0.x
3.1.x
- if: ${{ runner.os == 'Windows' }}
uses: microsoft/[email protected]

Expand Down
30 changes: 28 additions & 2 deletions src/Framework/Framework/Controls/RouteLink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -64,6 +65,18 @@ public string Text
public static readonly DotvvmProperty TextProperty =
DotvvmProperty.Register<string, RouteLink>(c => c.Text, "");

/// <summary>
/// Gets or sets the required culture of the page. This property is supported only when using localizable routes.
/// </summary>
[MarkupOptions(AllowBinding = false)]
public string? Culture
{
get { return (string?)GetValue(CultureProperty); }
set { SetValue(CultureProperty, value); }
}
public static readonly DotvvmProperty CultureProperty
= DotvvmProperty.Register<string?, RouteLink>(c => c.Culture, null);

/// <summary>
/// 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.
/// </summary>
Expand Down Expand Up @@ -185,15 +198,28 @@ public static IEnumerable<ControlUsageError> 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.",
routeNameProperty.DothtmlNode);
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 =
Expand Down
2 changes: 2 additions & 0 deletions src/Framework/Framework/Controls/RouteLinkCapability.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,7 @@ public sealed record RouteLinkCapability

[DefaultValue(null)]
public ValueOrBinding<string>? UrlSuffix { get; init; }

public string? Culture { get; init; }
}
}
17 changes: 13 additions & 4 deletions src/Framework/Framework/Controls/RouteLinkHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,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
Expand All @@ -114,9 +114,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)
Expand Down Expand Up @@ -146,7 +155,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(",");
Expand Down
7 changes: 7 additions & 0 deletions src/Framework/Framework/Hosting/HostingConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,12 @@ public class HostingConstants
public const string DotvvmFileUploadAsyncHeaderName = "X-DotVVM-AsyncUpload";

public const string HostAppModeKey = "host.AppMode";

/// <summary>
/// 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
/// </summary>
public const string OwinDoNotSetRequestCulture = "OwinDoNotSetRequestCulture";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,9 @@ private static bool TryParseGooglebotHashbangEscapedFragment(string queryString,
return false;
}

public static RouteBase? FindMatchingRoute(IEnumerable<RouteBase> routes, IDotvvmRequestContext context, out IDictionary<string, object?>? parameters)
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;
}
Expand All @@ -52,29 +51,58 @@ private static bool TryParseGooglebotHashbangEscapedFragment(string queryString,
{
url = url.Substring(HostingConstants.SpaUrlIdentifier.Length).Trim('/');
}
return url;
}


// find the route
internal static RouteBase? FindExactMatchRoute(IEnumerable<RouteBase> routes, string matchUrl, out IDictionary<string, object?>? parameters)
{
foreach (var r in routes)
{
if (r.IsMatch(url, out parameters)) return r;
if (r.IsMatch(matchUrl, out parameters))
{
return r;
}
}
parameters = null;
return null;
}

public static RouteBase? FindMatchingRoute(DotvvmRouteTable routes, IDotvvmRequestContext context, out IDictionary<string, object?>? parameters, out bool isPartialMatch)
{
var url = GetRouteMatchUrl(context);

var route = FindExactMatchRoute(routes, url, out parameters);
if (route is { })
{
isPartialMatch = false;
return route;
}

foreach (var r in routes.PartialMatchRoutes)
{
if (r.IsPartialMatch(url, out var matchedRoute, out parameters))
{
isPartialMatch = true;
return matchedRoute;
}
}

isPartialMatch = false;
parameters = null;
return null;
}

public async Task<bool> Handle(IDotvvmRequestContext context)
{
var requestTracer = context.Services.GetRequiredService<AggregateRequestTracer>();

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;
Expand All @@ -83,12 +111,25 @@ public async Task<bool> Handle(IDotvvmRequestContext context)

WriteSecurityHeaders(context);


var filters =
ActionFilterHelper.GetActionFilters<IPresenterActionFilter>(presenter.GetType().GetTypeInfo())
.Concat(context.Configuration.Runtime.GlobalFilters.OfType<IPresenterActionFilter>());
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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) == null)

var routeMatchUrl = DotvvmRoutingMiddleware.GetRouteMatchUrl(context);
if (DotvvmRoutingMiddleware.FindExactMatchRoute(new[] { resourceRoute }, routeMatchUrl, out var parameters) == null)
{
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Threading.Tasks;
using DotVVM.Framework.Hosting;

namespace DotVVM.Framework.Routing;

public class CanonicalRedirectPartialMatchRouteHandler : IPartialMatchRouteHandler
{
/// <summary>
/// Indicates whether a permanent redirect shall be used.
/// </summary>
public bool IsPermanentRedirect { get; set; }

public Task<bool> TryHandlePartialMatch(IDotvvmRequestContext context)
{
context.RedirectToRoute(context.Route!.RouteName, context.Parameters);
return Task.FromResult(true);
}
}
6 changes: 5 additions & 1 deletion src/Framework/Framework/Routing/DotvvmRoute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ public sealed class DotvvmRoute : RouteBase
private List<Func<Dictionary<string, string?>, string>> urlBuilders;
private List<KeyValuePair<string, Func<string, ParameterParseResult>?>> parameters;
private string urlWithoutTypes;
private List<KeyValuePair<string, DotvvmRouteParameterMetadata>> parameterMetadata;

/// <summary>
/// Gets the names of the route parameters in the order in which they appear in the URL.
/// </summary>
public override IEnumerable<string> ParameterNames => parameters.Select(p => p.Key);

public override IEnumerable<KeyValuePair<string, DotvvmRouteParameterMetadata>> ParameterMetadata => parameterMetadata;

public override string UrlWithoutTypes => urlWithoutTypes;


Expand Down Expand Up @@ -77,6 +80,7 @@ private void ParseRouteUrl(DotvvmConfiguration configuration)
routeRegex = result.RouteRegex;
urlBuilders = result.UrlBuilders;
parameters = result.Parameters;
parameterMetadata = result.ParameterMetadata;
urlWithoutTypes = result.UrlWithoutTypes;
}

Expand Down Expand Up @@ -123,7 +127,7 @@ public override bool IsMatch(string url, [MaybeNullWhen(false)] out IDictionary<
/// <summary>
/// Builds the URL core from the parameters.
/// </summary>
protected override string BuildUrlCore(Dictionary<string, object?> values)
protected internal override string BuildUrlCore(Dictionary<string, object?> values)
{
var convertedValues =
values.ToDictionary(
Expand Down
13 changes: 11 additions & 2 deletions src/Framework/Framework/Routing/DotvvmRouteParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ public UrlParserResult ParseRouteUrl(string url, IDictionary<string, object?> de

var regex = new StringBuilder("^");
var parameters = new List<KeyValuePair<string, Func<string, ParameterParseResult>?>>();
var parameterMetadata = new List<KeyValuePair<string, DotvvmRouteParameterMetadata>>();
var urlBuilders = new List<Func<Dictionary<string, string?>, string>>();
urlBuilders.Add(_ => "~");

void AppendParameterParserResult(UrlParameterParserResult result)
{
regex.Append(result.ParameterRegexPart);
parameters.Add(result.Parameter);
parameterMetadata.Add(new KeyValuePair<string, DotvvmRouteParameterMetadata>(result.Parameter.Key, result.Metadata));
urlBuilders.Add(result.UrlBuilder);
}

Expand Down Expand Up @@ -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('/')
};
}
Expand Down Expand Up @@ -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;
Expand All @@ -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!");
Expand Down Expand Up @@ -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)
};
}

Expand All @@ -190,14 +195,18 @@ private struct UrlParameterParserResult
public string ParameterRegexPart { get; set; }
public Func<Dictionary<string, string?>, string> UrlBuilder { get; set; }
public KeyValuePair<string, Func<string, ParameterParseResult>?> 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<Func<Dictionary<string, string?>, string>> UrlBuilders { get; set; }
public List<KeyValuePair<string, Func<string, ParameterParseResult>?>> Parameters { get; set; }
public string UrlWithoutTypes { get; set; }
public List<KeyValuePair<string, DotvvmRouteParameterMetadata>> ParameterMetadata { get; set; }
}
}
Loading

0 comments on commit d824028

Please sign in to comment.