aspnetcore: Razor Pages: Urls generated by built-in taghelpers do not honor PageRouteModelConvention

Describe the bug

In a project that utilizes Razor Pages and request localization in conjunction with an IPageRouteModelConvention, Urls generated through standard TagHelpers for anchors or forms do not honor routing conventions and route values.

To Reproduce

Current route values (page url is /de)

  • Page: /Index
  • culture: de

Markup: <a asp-page="Search">Search</a>

Expected Result: <a href="/de/search">Search</a>

Actual Result: <a href="/search">Search</a>

There’s a workaround but it is quite ugly:

<a asp-page="Search" asp-route-culture="@Request.RouteValues["culture"]">Search</a>

Startup.cs:

public class Startup
{
    public Startup(IConfiguration configuration, IWebHostEnvironment env)
    {
        Configuration = configuration;
        Env = env;

        var builder = new ConfigurationBuilder()
            .SetBasePath(Environment.CurrentDirectory)
            .AddJsonFile("appsettings.json", optional: true)
            .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true);

        if (env.IsDevelopment())
            builder.AddUserSecrets<Startup>();

        Configuration = builder.Build();
    }

    public IConfiguration Configuration { get; }
    public IWebHostEnvironment Env { get; }
    public static RequestCulture DefaultRequestCulture = new RequestCulture("en-US", "en-US");

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddOptions();
        services.AddSingleton(Configuration);

        services.AddRazorPages(options =>
        {
            options.Conventions.Add(new CultureTemplateRouteModelConvention());
        }).SetCompatibilityVersion(CompatibilityVersion.Version_3_0);

        services.AddLocalization(options => options.ResourcesPath = "Resources");

        services.Configure<RequestLocalizationOptions>(options =>
        {
            options.DefaultRequestCulture = DefaultRequestCulture;
            options.SupportedCultures = AppConstants.SupportedCultures;
            options.SupportedUICultures = AppConstants.SupportedCultures;

            options.RequestCultureProviders.Insert(0, new RouteDataRequestCultureProvider { Options = options });
        });

        services.AddRouting(options =>
        {
            options.LowercaseUrls = true;
        });

        services.AddOptions();
        services.AddLogging();
        services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
        services.AddSingleton(Configuration);
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        Container = app.ApplicationServices.GetAutofacRoot();

        app.UseStaticFiles();
        app.UseRouting();
        app.UseRequestLocalization();
        app.UseAuthorization();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapRazorPages();
    }
}

CultureTemplateRouteModelConvention.cs:

public class CultureTemplateRouteModelConvention : IPageRouteModelConvention
{
    public void Apply(PageRouteModel model)
    {
        var selectorCount = model.Selectors.Count;

        for (var i = 0; i < selectorCount; i++)
        {
            var selector = model.Selectors[i];

            model.Selectors.Add(new SelectorModel
            {
                AttributeRouteModel = new AttributeRouteModel
                {
                    Order = -1,
                    Template = AttributeRouteModel.CombineTemplates(
                          "{culture?}", selector.AttributeRouteModel.Template),
                }
            });
        }
    }
}

Further technical details

  • ASP.NET Core 3.0
  • VS 2019

About this issue

  • Original URL
  • State: open
  • Created 5 years ago
  • Reactions: 23
  • Comments: 22 (4 by maintainers)

Most upvoted comments

I am also hit by this. I wanted to add multi tenant support to an existing app by modifying all links from /{area}/{page} to /{tenant}/{area}/{page}/. I thought it is going to be trivial as adding custom IPageRouteModelConvention but unfortunately all links are broken between pages.

@gerardfoley: Yes it is definitely the ambient values behavior change after the switch to endpoint routing.

@oliverw: I’ve worked around needing to inject the culture in each caller that is associated with a HttpContext by adding a decorator class around the default LinkGenerator that’s added to the container.

public class LinkGeneratorDecorator : LinkGenerator
{
  private LinkGenerator _innerLinkGenerator;
  public LinkGeneratorDecorator(LinkGenerator inner) =>
    _innerLinkGenerator = inner ?? throw new ArgumentNullException(nameof(inner));

  public override string GetPathByAddress<TAddress>(
    HttpContext httpContext,
    TAddress address,
    RouteValueDictionary values,
    RouteValueDictionary ambientValues = null,
    PathString? pathBase = null,
    FragmentString fragment = null,
    LinkOptions options = null)
  {
    // clone the explicit route values
    var newValues = new RouteValueDictionary(values);

    // get culture from ambient route values
    if (ambientValues?.TryGetValue("culture", out var value) == true)
    {
      // add to the cloned route values to make it explicit
      // respects existing explicit value if specified
      newValues.TryAdd("culture", value);
    }

    return _innerLinkGenerator.GetPathByAddress(httpContext, address, newValues,
      ambientValues, pathBase, fragment, options);
  }

  // do the same with GetUriByAddress<TAddress> overload with ambient values,
  // straight pass-through for overloads without ambient values to use
}

If you’re using UseRequestLocalization and detecting locales using other methods besides route values, there will be instances where the ambient route values don’t have the locale. You could instead switch this code to use the IRequestCultureFeature of the HttpContext to get the resolved locale.

var requestCulture = httpContext.Features.Get<IRequestCultureFeature>().RequestCulture;
newValues.TryAdd("culture", requestCulture.Culture.Name); // or UiCulture.Name if preferred

If you’re using a IPageRouteModelConvention to take every page route and adding a new route with a culture prefix, you could make the culture required on that route and disallow the original non-prefix route from being used in any link generation to be defensive. This is how I originally stumbled onto the problem; after I disabled non-prefix routes for link generation, I was getting blank results for any link where the ambient values were not used (e.g. going from Page = “/Index” to Page = “/Search”).

public class CultureTemplateRouteModelConvention : IPageRouteModelConvention
{
    public void Apply(PageRouteModel model)
    {
        var selectorCount = model.Selectors.Count;

        for (var i = 0; i < selectorCount; i++)
        {
            var selector = model.Selectors[i];

            model.Selectors.Add(new SelectorModel
            {
                AttributeRouteModel = new AttributeRouteModel
                {
                    Order = -1,
                    Template = AttributeRouteModel.CombineTemplates(
                          "{culture}", selector.AttributeRouteModel.Template), // changed to required
                }
            });

            // suppress original route so URLs can't be made using its non-prefixed template
            selector.AttributeRouteModel.SuppressLinkGenerator = true;
        }
    }
}

@rynowak and @javiercn: It seems like the bigger theme here is that we need a seam to be able to describe/override policy behavior on the treatment of specific ambient values to allow them to continue to be route value candidates in link generation.

The DefaultLinkGenerator is too complex to re-implement, so a custom LinkGenerator implementation registered in DI doesn’t seem like the right approach. The decorator I added to wrap the initially registered DefaultLinkGenerator with my own seems hacky.

Custom IRouteConstraint or IOutboundRouteValuesTransformer is not a candidate because constraints are only checked after filtering out candidate routes that have all the required route values set (the ambient value for culture is dropped so the routes requiring that route value are not candidates, and routes with the value set as optional don’t run the constraint check because the value was not specified at all).

In conclusion, it seems that a new extensibility point is needed. Here’s some options I’ve thought about.

Option: New Constraint

Constraint to mark a route value in a template that is always allowed to be taken from ambient values: “{culture:alwaysallowambient}/Search”.

Pros:

  • Easy configuration
  • Existing convention
  • Works for callers without HttpContext

Cons:

  • No ServiceProvider to resolve services, so it can’t take any dependencies (similar to #4579)
  • Requires changes to the candidate route value computation during route selection

Option: Better LinkGenerator abstraction

A simpler LinkGenerator interface to be able to implement from scratch.

Pros:

  • Allows more extensibility for all link generation behavior
  • Works for callers without HttpContext

Cons:

  • Doesn’t provide a declarative convention
  • Requires endpoint routing to be enabled (although this is now the default behavior and the option on UseMvc is probably going to be taken out)

Alternative: DynamicRouteValueTransformer?

There are no docs about DymamicRouteValueTransformer. I’ve tried setting up a generic route for Razor Pages like endpoints.MapRazorPages(); endpoints.MapDynamicPageRoute("{page}"); with a transformer that would add the detected locale as a route value back into the RouteValuesDictionary, but I kept getting an AmbiguousMatchException.

Have a look at https://gitlab.com/NonFactors/AspNetCore.Template/-/blob/master/src/MvcTemplate.Web/Startup.cs#L152 and https://gitlab.com/NonFactors/AspNetCore.Template/-/blob/master/src/MvcTemplate.Components/Mvc/Routing/DefaultLinkGenerator.cs to get an idea on overriding LinkGneerator.

I am using this approach for links like {tenant}/SomeRazorPage with success since a long time.

The tag helpers call into UrlHelper which ultimately goes into LinkGenerator which is the actual core mechanism for generating urls from route values used by the whole ASP.net Core infrastructure. Overriding this service will change the behavior throughout the entire framework including tag helpers, url helper, html helper, everything.

Hello, The LinkGenerator abstract class is public. Use a library like scrutor to decorate the existing internal class without needing to know its type. Modify the parameters, then pass it back to the original.

[image: image.png]

On Thu, Mar 2, 2023 at 5:53 PM Lee Timmins @.***> wrote:

Thanks @rwasef1830 https://github.com/rwasef1830, I hadn’t really looked at how the LinkGenerator was implemented much before. However I have noticed that it is marked as internal. I’m not sure how comfortable I am implementing this knowing it will likely change in the future. I guess it’ll work for now, but hopefully in the future they create an easier way of doing this.

— Reply to this email directly, view it on GitHub https://github.com/dotnet/aspnetcore/issues/16960#issuecomment-1452095613, or unsubscribe https://github.com/notifications/unsubscribe-auth/ABDK5PIMZWLW6ITDDKGDRLDW2C66LANCNFSM4JLN45CA . You are receiving this because you were mentioned.Message ID: @.***>

@jeffreyrivor I have the same issue where if you suppress link generation for the non-locale route/selector then it will generate a blank string for non localized urls. There doesn’t seem to be a great way to solve this other than overriding the anchor tag helper and the url generator or by passing in the culture every time, both are extremely ugly.

@fuzl-llc - does this work for removing your index suffix?

services.AddRazorPages()
    .AddRazorPagesOptions(options =>
    {
        // Remove routes ending with /Index
        options.Conventions.AddFolderRouteModelConvention("/", model =>
        {
            var selectorCount = model.Selectors.Count;
            for (var i = selectorCount - 1; i >= 0; i--)
            {
                var selectorTemplate = model.Selectors[i].AttributeRouteModel.Template;
                if (selectorTemplate.EndsWith("Index"))
                {
                    model.Selectors.RemoveAt(i);
                }
            }
        });
    });