dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.46k stars 10.03k forks source link

Url.RouteUrl is blank in ASP.NET Core 3.0 #14877

Open shapeh opened 5 years ago

shapeh commented 5 years ago

Describe the bug

We are upgrading an ASP.NET Core 2.2. MVC app to 3.0 but Url.RouteUrl gives blank href's and inconsistency on link creation. What are we doing wrong?

To Reproduce

Steps to reproduce the behavior:

  1. Using this version of ASP.NET Core 3.0
  2. Run this code:

Example 1 -wrong result Returns https://example.org/us/blog/some-title-6 in 2.2 but is blank in 3.0:

string url = Url.RouteUrl("blog-details", new { title = item.Title, id = item.Id });

[Route("~/{lang}/blog/{title}-{id}", Name= "blog-details")]
public async Task<IActionResult> Details(string title, int id)
{
}

We have tried these constructions but result is not satisfactory:

<a asp-action="Details" asp-controller="Blog" asp-route-title="item.Title" asp-route-id="@item.Id">Link here</a>
url = Url.Action("Details", "Blog", new { id = item.Id, title = item.Title });
url = Url.RouteUrl(new { action = "Details", controller = "Blog", id = item.Id, title = item.Title });

// Returns https://example.org/us/blog/details/6?title=some-title

Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddControllersWithViews(options =>
        {
            options.Filters.Add(new MiddlewareFilterAttribute(typeof(LocalizationPipeline)));
        })
        .AddViewLocalization(LanguageViewLocationExpanderFormat.SubFolder)
        .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
    services.AddRazorPages();

    services.AddRouting(options =>
    { 
        options.ConstraintMap.Add("lang", typeof(LanguageRouteConstraint));
        options.LowercaseUrls = true;
        options.AppendTrailingSlash = false;
    });
    ...
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ...
    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();

    app.UseResponseCompression();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers(); // attribute routing
        endpoints.MapControllerRoute("areas", "{lang:lang}/{area:exists}/{controller=Home}/{action=Index}/{id?}");
        endpoints.MapControllerRoute("LocalizedDefault", "{lang:lang}/{controller=Home}/{action=Index}/{id?}");
        endpoints.MapControllerRoute("Naked", "/{controller=Home}/{action=Index}");
        endpoints.MapHealthChecks("/health");
    });
}

Example 2 - wrong result Returns https://example.org/us/home/pricing instead of https://example.org/us/pricing

<a asp-controller="Home" asp-action="Pricing">Pricing</a>

[Route("~/{lang}/pricing")]
public async Task<IActionResult> Pricing()
{
    ...
}

Example 3 - correct result Returns correct https://example.org/us/signup/customer

<a asp-controller="Signup" asp-action="Customer">Sign up</a>

[Route("~/{lang}/signup/customer")]
public IActionResult Customer()
{
   ...
}

Additional context

dotnet --info

.NET Core SDK (reflecting any global.json): Version: 3.0.100 Commit: 04339c3a26

Runtime Environment: OS Name: Windows OS Version: 10.0.18362 OS Platform: Windows RID: win10-x64 Base Path: C:\Program Files\dotnet\sdk\3.0.100\

Host (useful for support): Version: 3.0.0 Commit: 7d57652f33

.NET Core SDKs installed: 2.1.202 [C:\Program Files\dotnet\sdk] 2.1.502 [C:\Program Files\dotnet\sdk] 2.1.504 [C:\Program Files\dotnet\sdk] 2.1.505 [C:\Program Files\dotnet\sdk] 2.1.507 [C:\Program Files\dotnet\sdk] 2.1.602 [C:\Program Files\dotnet\sdk] 2.1.604 [C:\Program Files\dotnet\sdk] 2.1.700 [C:\Program Files\dotnet\sdk] 2.1.801 [C:\Program Files\dotnet\sdk] 2.1.802 [C:\Program Files\dotnet\sdk] 2.2.100 [C:\Program Files\dotnet\sdk] 2.2.102 [C:\Program Files\dotnet\sdk] 2.2.104 [C:\Program Files\dotnet\sdk] 2.2.105 [C:\Program Files\dotnet\sdk] 2.2.203 [C:\Program Files\dotnet\sdk] 2.2.301 [C:\Program Files\dotnet\sdk] 3.0.100 [C:\Program Files\dotnet\sdk]

.NET Core runtimes installed: Microsoft.AspNetCore.All 2.1.0 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.1.1 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.1.2 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.1.5 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.1.6 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.1.8 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.1.9 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.1.11 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.1.12 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.1.13 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.2.0 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.2.1 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.2.2 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.2.3 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.2.4 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.2.6 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.App 2.1.0 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.1.1 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.1.2 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.1.5 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.1.6 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.1.8 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.1.9 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.1.11 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.1.12 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.1.13 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.2.0 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.2.1 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.2.2 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.2.3 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.2.4 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.2.6 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 3.0.0 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.NETCore.App 2.0.9 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.1.5 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.1.6 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.1.8 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.1.9 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.1.11 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.1.12 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.1.13 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.2.0 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.2.1 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.2.2 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.2.3 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.2.4 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.2.6 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 3.0.0 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.WindowsDesktop.App 3.0.0 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]

Ref https://github.com/aspnet/AspNetCore/issues/12794

javiercn commented 5 years ago

@shapeh Thanks for contacting us.

In order for us to investigate this issue, could you please provide a link to a github repo with a minimal repro project and detailed steps to reproduce the issue?

shapeh commented 5 years ago

@javiercn Thanks for getting back so quickly. It is impossible for me to do a github repo given the size of the project + confidentiality. We have a LocalizationPipeline.cs middleware responsible for reading a list of languages in appsettings.json and turning that in correct URL - e.g. "en-gb" will be mapped "/uk" in URL {lang} param.

using System.Collections.Generic;
using System.Globalization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Localization;
using example.Helpers;
using Microsoft.Extensions.Configuration;
using System.IO;

namespace example
{
    public class LocalizationPipeline
    {
        public void Configure(IApplicationBuilder app)
        {
            var options = new RequestLocalizationOptions();
            ConfigureOptions(app, options);

            app.UseRequestLocalization(options);
        }

        public static void ConfigureOptions(IApplicationBuilder app, RequestLocalizationOptions options)
        {
            var builder = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);

            IConfigurationRoot configuration = builder.Build();

            AppLanguages languageSettings = new AppLanguages();
            GeneralConfiguration generalConfiguration = new GeneralConfiguration();
            configuration.GetSection("AppLanguages").Bind(languageSettings);
            configuration.GetSection("GeneralConfiguration").Bind(generalConfiguration);

            var supportedCultures = new List<CultureInfo>();
            foreach (Language lang_in_app in languageSettings.Dict.Values)
            {
                supportedCultures.Add(new CultureInfo(lang_in_app.Culture));
            }

            options.DefaultRequestCulture = new RequestCulture(culture: "en-gb", uiCulture: "en-gb");
            options.SupportedCultures = supportedCultures;
            options.SupportedUICultures = supportedCultures;
            options.RequestCultureProviders = new[] {
                new RouteDataRequestCultureProvider(languageSettings)
                {
                    Options = options,
                    RouteDataStringKey = generalConfiguration.RouteDataStringKey
                }
            };
            app.UseRequestLocalization(options);
        }
    }
}

And this is the LanguageRouteConstraint.cs

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using example.Helpers;
using System.IO;

namespace example
{

    public class LanguageRouteConstraint : IRouteConstraint
    {
        private readonly AppLanguages _languageSettings;

        public LanguageRouteConstraint()
        {
            var builder = new ConfigurationBuilder()
                    .SetBasePath(Directory.GetCurrentDirectory())
                    .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);

            IConfigurationRoot configuration = builder.Build();

            _languageSettings = new AppLanguages();
            configuration.GetSection("AppLanguages").Bind(_languageSettings);
        }

        public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
        {
            if (!values.ContainsKey("lang"))
            {
                return false;
            }

            var lang = values["lang"].ToString();
            foreach (Language lang_in_app in _languageSettings.Dict.Values)
            {
                if (lang == lang_in_app.Icc)
                {
                    return true;
                }
            }
            return false;
        }
    }
}
mkArtakMSFT commented 5 years ago

@shapeh we are not looking for the whole app, rather a minimalistic repro - trimmed down to only the changes on top of a new project template, which demonstrates the issue.

shapeh commented 5 years ago

@mkArtakMSFT Got it / will see what I can do and revert :)

mkArtakMSFT commented 5 years ago

@shapeh we're closing this issue as we haven't heard back from you for some time now. Feel free to comment/reopen this issue when you have a repro available.

shapeh commented 5 years ago

Sorry about the late response - this is still open / I need just to free up an hour to do the repro.

joeaudette commented 5 years ago

I'm experiencing this issue as well in cloudscribe SimpleContent. I'm using named routes that expect a culture route constraint:

routes.MapControllerRoute(
           name: "pageedit-culture",
           pattern: "{culture}/editpage/{slug?}"
           , defaults: new { controller = "Page", action = "Edit" }
           , constraints: new { culture = cultureConstraint }
           );

but when I'm on a page like /fr/about which is the view url for a cms page, the culture parameter from the current url is not picked up when generating a link to the edit page like this:

 model.EditPath = Url.RouteUrl("pageedit-culture", new { slug = model.CurrentPage.Slug });

the model.EditPath ends up as null, whereas before 3.0 it would have been /fr/editpage/about

mkArtakMSFT commented 5 years ago

@joeaudette can you please provide a minimalistic repro (ideally GitHub hosted) so we can investigate this further?

joeaudette commented 5 years ago

@mkArtakMSFT cloudscribe is a pretty complex setup with route constraints for folder multi-tenancy and culture, I'm not sure I can create a small repro of the issue.

Endpoint routing was a major rewrite of the routing system so this is surely a bug. I hope you can investigate it on your end based on the description of the problem and not close the issue if I don't make a small repro for you.

For now I'm going to have to disable endpoint routing and go back to traditional mvc routing as a workaround, maybe I'll try endpoint routing again after you guys ship the next version or patch.

mkArtakMSFT commented 5 years ago

Talked to @rynowak regarding this and this is and this is one of the differences Endpoint Routing has in comparison to routing in 2.2. Specifically, Endpoint routing doesn't preserve ambient values when generating links to other actions. We'll look into solving this somehow during 5.0.

joeaudette commented 5 years ago

My observation is that it does preserve my culture route parameter and constraint when using Url.Action(...), but it does not with Url.RouteUrl(...)

ghost commented 4 years ago

Just to keep this alive and ensure it's known that this is an issue for more than one group of people, we hit this issue today as we attempted to migrate to dotnet core 3.0 and found that the implicit routing didn't work as we expected it to from 2.2. You can see a quick sample we created to see if there were any simple workarounds from having to write out many properties manually for our redirects and internal routing pieces: https://github.com/accu-jmellottlillie/WebRoutingBug

Weboholics commented 4 years ago

I have the same issue - see my report to Microsoft: https://github.com/aspnet/AspNetCore/issues/17097

I have researched the issue more and found that this is the result of change of algorithm that is used for url generation. The old algorithm did sometimes generate incorrect url so Microsoft changed the algorithm. They use something they call ambient Values for generating URL

You can read about the algoritm here: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-3.0#url-generation-reference

One solution if its possible, is to manually inject Ambient value for culture into the pipeline. I asked Microsoft if that was possible a couple of days ago, but haven't got any answer.

Weboholics commented 4 years ago

It's a bit strange that this does work: "sv-se/{action}/{id?}"

This doesn't work: "{culture}/{action}/{id?}"

We could theoretically create a loop and insert one Route for each culture, but this doesn't feel like a good design pattern.

Weboholics commented 4 years ago
I have done a HACK / Workaround that may help you until Microsoft come out with a solution.
No guaranties of course :-)

//Startup.cs
 EndPointGenerator generator = new EndPointGenerator(new List<string>(){"en-gb",'"sv-se","ru-ru"});
 app.UseEndpoints(generator.AddEndPoints);

//Separate EndPointGenerator.cs
public  class EndPointGenerator
    {
        #region Members
        private List<string> Cultures { get; set; }
        #endregion
        private EndPointGenerator() { }
        public EndPointGenerator(List<string> cultures)
        {
            Cultures = cultures;
        }
        public  void AddEndPoints(IEndpointRouteBuilder endpoints)
        {
            //add routes that shall be BEFORE culture routes    
            //endpoints.MapControllerRoute(..)
            Cultures.ForEach(culture =>
            {
                  //Example, shall contain multiple  routes
                  endpoints.MapControllerRoute(
                   name: "home_actions",
                   pattern: $"{culture}/{{action}}/{{id?}}", //
                   defaults: new { controller = "Home" });

                   //Next
                   //endpoints.MapControllerRoute(..)
                   //Next
                   //endpoints.MapControllerRoute(..)
            });
            //add routes that shall be After culture routes    
            //endpoints.MapControllerRoute(..)
        }

    }
jaketyb commented 4 years ago

Just thought I'd weigh in here too as someone who has come across a related issue with routing differences between 2.2 and 3.0. I believe the root cause is the same as this issue is describing.

To cut a long story short, RedirectToAction on 3.0 doesn't seem to resolve the URL if the action that it's redirecting to depends on a shared controller-level parameter. E.g. take this controller as an example:

[Route("redirect/{someIdentifier}")]
    public class RedirectController : Controller
    {        
        [HttpGet]
        [Route("initroute")]
        public IActionResult InitRoute(string someIdentifier)
        {          
            return RedirectToAction("NextRoute", "Redirect");
        }

        [HttpGet]
        [Route("nextroute")]
        public IActionResult NextRoute(string someIdentifier)
        {
            Debug.WriteLine(someIdentifier);

            return View();
        }
    }

In 2.2, if you navigate to the controller at redirect/123/initroute then it will redirect you correctly to redirect/123/nextroute, but doing the same with identical code in 3.0 will give you an error saying that there's no matching route. Doing some digging it appears to be because the IUrlHelper implementation (found by accessing the Url property on the controller) is a UrlHelper in 2.2, but a EndpointRoutingUrlHelper in 3.0, and these resolve the URLs differently to each other.

Here's a small sample with two web apps, one on 2.2 and one on 3.0 which repros the issue: https://github.com/jtb637/RoutingIssue

softwaremills commented 4 years ago

Figured I'd add that we ran into this issue when upgrading from 2.2 to 3.1. UrlHelper.RouteUrl, when called with a named attribute route, did not apply ambient values correctly, so a call to url.RouteUrl(name) returned null while a call to url.RouteUrl(name, new { ambientValue = "valueFromCallingUrl" }) worked.

This was not consistent; some URLs worked, and some didn't, and we couldn't find why some worked. But specifying ambient values worked.

shapeh commented 4 years ago

As stated earlier, this is a problem with ambient values not being passed on unless specified explicitly like so:

@using Microsoft.AspNetCore.Routing;
<a ... asp-route-my-ambient-value="@this.Context.GetRouteValue("my-ambient-value")">My link</a>

For us, this is a major problem as we have 500+ URLs generated with either Url.RouteUrl or link. Decorating every single link with ambient values seems the wrong design pattern.

Someone here https://stackoverflow.com/questions/59267971/using-routedatarequestcultureprovider-in-asp-net-core-3-1 created a new taghelper to solve the problem but, this means customization again. I would like to hear how other people have solved this? Also, MSFT are there any specific plans to mitigate these problems with "ambient-value-decoration"?

fourquant commented 4 years ago

Any update on this? I am on core 3.1 and neither Url.Page nor Url.RouteUrl is working.

ghost commented 4 years ago

We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.

Hazzamanic commented 4 years ago

I have also run into this issue upgrading to 3.1. We were using the ambient route values in a multi tenant app's admin screen where the tenants config was managed. We have created a tag helper that injects the currently managed tenant id and a custom UrlHelper extension. For this you can access current route values and append them to an action quite easily:

public static string TenantAdminAction(this IUrlHelper url, string action, string controller, object routeValues)
{
    var routeValueDictionary = new RouteValueDictionary(routeValues);
    routeValueDictionary["tenantId"] = url.ActionContext.RouteData.Values["tenantId"];
    return url.Action(action, controller, routeValueDictionary);
}
Atulin commented 4 years ago

I noticed that the issue also exists in my API project, when a controller doesn't have a default, GET action. Adding this bit solved the issue in my case:

[HttpGet] public string Ping() => "Pong";
ghost commented 2 years ago

Thanks for contacting us.

We're moving this issue to the .NET 8 Planning milestone for future evaluation / consideration. We would like to keep this around to collect more feedback, which can help us with prioritizing this work. We will re-evaluate this issue, during our next planning meeting(s). If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact issues. To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.