28

I'm working on creating a UrlHelper for a background worker to create callback urls, which means it's not part of a normal request where I could just ask for it through DI.

In ASP.Net 5 I could just create a HttpRequest and give it the same HttpConfiguration I used to build my app, but in ASP.Net Core 2.0 the UrlHelper depends on a full ActionContext which is a bit harder to craft.

I have a working prototype, but it's using a nasty hack to smuggle the route data out of the application startup process. Is there a better way to do this?

public class Capture
{
    public IRouter Router { get; set; }
}

public static class Ext
{
    // Step 1: Inject smuggler when building web host
    public static IWebHostBuilder SniffRouteData(this IWebHostBuilder builder)
    {
        return builder.ConfigureServices(svc => svc.AddSingleton<Capture>());
    }

    // Step 2: Swipe the route data in application startup
    public static IApplicationBuilder UseMvcAndSniffRoutes(this IApplicationBuilder app)
    {
        var capture = app.ApplicationServices.GetRequiredService<Capture>();
        IRouteBuilder capturedRoutes = null;
        app.UseMvc(routeBuilder => capturedRoutes = routeBuilder);
        capture.Router = capturedRoutes?.Build();
        return app;
    }

    // Step 3: Build the UrlHelper using the captured routes and webhost
    public static IUrlHelper GetStaticUrlHelper(this IWebHost host, string baseUri)
        => GetStaticUrlHelper(host, new Uri(baseUri));
    public static IUrlHelper GetStaticUrlHelper(this IWebHost host, Uri baseUri)
    {
        HttpContext httpContext = new DefaultHttpContext()
        {
            RequestServices = host.Services,
            Request =
                {
                    Scheme = baseUri.Scheme,
                    Host = HostString.FromUriComponent(baseUri),
                    PathBase = PathString.FromUriComponent(baseUri),
                },
        };

        var captured = host.Services.GetRequiredService<Capture>();
        var actionContext = new ActionContext
        {
            HttpContext = httpContext,
            RouteData = new RouteData { Routers = { captured.Router }},
            ActionDescriptor = new ActionDescriptor(),
        };
        return new UrlHelper(actionContext);
    }
}

// Based on dotnet new webapi

public class Program
{
    public static void Main(string[] args)
    {
        BuildWebHost(args);//.Run();
    }

    public static IWebHost BuildWebHost(string[] args)
    {
        var captured = new Capture();
        var webhost = WebHost.CreateDefaultBuilder(args)
            .SniffRouteData()
            .UseStartup<Startup>()
            .Build();

        var urlHelper = webhost.GetStaticUrlHelper("https://my.internal.service:48923/somepath");
        Console.WriteLine("YO! " + urlHelper.Link(nameof(ValuesController), null));
        return webhost;
    }
}

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc();
    }

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IHostingEnvironment env, Capture capture)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseMvcAndSniffRoutes();
    }
}

[Route("api/[controller]", Name = nameof(ValuesController))]
public class ValuesController : Controller
{
    // GET api/values
    [HttpGet]
    public IEnumerable<string> Get()
    {
        return new string[] { "value1", "value2" };
    }

    // etc
}
Nkosi
  • 235,767
  • 35
  • 427
  • 472
Stylpe
  • 612
  • 1
  • 7
  • 16
  • This might be a terrible idea, but can’t you just save *any* UrlHelper created during a request so you can continue using it from the background thread? So, you would start that thread *after* a first request has been made? – poke Sep 21 '17 at 06:29
  • 1
    Did you have a chance to check this? https://stackoverflow.com/questions/37322076/injection-of-iurlhelper-in-asp-net-core-1-0-rc2 – Dmitry Pavlov Nov 02 '17 at 16:38
  • @poke, that would probably work, but it has obvious drawbacks, so I'd only use that as a last resort. – Stylpe Nov 05 '17 at 20:44
  • @dmitry-pavlov, IUrlHelperFactory and IActionContextAccessor depend on having an ActionContext from a request, which is exactly what I don't have, so that doesn't get me any further. – Stylpe Nov 05 '17 at 20:47
  • @stylpe IActionContextAccessor is used to access this context and is initialized by DI infrastructure. So all you need is to have this accessor. As far as I understand you need to inject services.AddSingleton(); Also you might try to use ControllerContext instead of Action one. – Dmitry Pavlov Nov 06 '17 at 12:03
  • 2
    @dmitry-pavlov nope, while it will be instantiated by DI, it's initialized in the route handler, specifically [here](https://github.com/aspnet/Mvc/blob/rel/2.0.0/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcAttributeRouteHandler.cs#L99) and [here](https://github.com/aspnet/Mvc/blob/rel/2.0.0/src/Microsoft.AspNetCore.Mvc.Core/Internal/MvcRouteHandler.cs#L87). So if you're not in a request pipeline where one of these middleware have run already, then IActionContextAccessor.ActionContext is gonna be null. I have tried this :) – Stylpe Nov 06 '17 at 21:36
  • I see. Sounds like the way you are trying to go has some troubles from architectural point of view as there is no action context in your out of request background thread. You need to get what you need other ways rather than from ActionContext. BTW, what exactly are you trying to get from ActionContext? – Dmitry Pavlov Nov 09 '17 at 10:27
  • I have a named controller action (`[Route("api/[controller]", Name = nameof(ValuesController))]` in the example code) which is a callback handler. I want to build a URI for this, which will be sent to a different service that will invoke this callback, and I want to do this on application startup. The UrlHelper is a nice tool for this, but it was changed in 2.0 to use the ActionContext to access certain things, including route data. And the route data is not exposed anywhere else as far as I can tell. – Stylpe Nov 09 '17 at 16:20
  • I could of course construct this url by hand, but it would need to be kept manually in sync with the Route attribute of the action, and anyone that has dealt with api versioning knows the risk of forgetting something like this is pretty high, and should be avoided. – Stylpe Nov 09 '17 at 16:25
  • 2
    might be a bit late, but I have kinda the same problem as yours. I need to send emails within a regular job. Thank you for your solution, to be a bit more "conformist" or "by the book" with the asp.net philosophy, I was wondering if you could, from your background worker, call a web api url inside your app. This way, you would have a http context. That only works if it's ok for you to work on your main thread server though – KitAndKat Aug 10 '18 at 07:34

2 Answers2

5

Browsing the sources it seems there is no less hacky solution.

In the UseMvc() method the IRouter object being built is passed to the RouterMiddleware, which stores it in a private field and exposes it only to the requests. So reflection would be your only other option, which is obviously out of the running.

However, if you need to generate only static paths using IUrlHelper.Content() you won't need the router as the default implementation won't use it. In this case you can create the helper like this:

var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
var urlHelper = new UrlHelper(actionContext);
Adam Simon
  • 2,762
  • 16
  • 22
  • That's the same conclusion I got to, which lead to the workaround in my question. And nice point about Content(), but we want to link to a named controller action. Thanks for corroborating this :) My next step will probably be to try to formulate an issue on Github to make this easier. – Stylpe Jan 30 '18 at 13:59
  • 2
    we also might not have httpcontext! @adam simon – AmiNadimi Dec 17 '18 at 11:46
5

With ASP.NET Core 2.2 releasing today, they've added a LinkGenerator class that sounds like it will solve this problem (the tests look promising). I'm eager to try it, but as I'm not actively working on the project where I needed this at the moment, it will have to wait a bit. But I'm optimistic enough to mark this as a new answer.

Stylpe
  • 612
  • 1
  • 7
  • 16