Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for localizable routes #1824

Merged
merged 8 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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";
tomasherceg marked this conversation as resolved.
Show resolved Hide resolved
}
}
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
Loading