Changing controllers and actions using one route in ASP.NET Core

Published: Saturday 8 June 2019

I was presented with a challenge recently. Six different types of pages wanted to appear after a generic sub directory.

Well that's fine. In MVC, you can just set up six different routes to cope with the six different types of pages. Each of those routes can have a different default controller and action. Job done.

  • Type 1 - type/{slug}
  • Type 2 - type/{type1}/{slug}
  • Type 3 - type/{type1}/{type2}/{slug}
  • etc...

Only it was not as straight forward as that. These different types of pages would fall under the same route pattern, so it wasn't just a case of creating different routes. So I would have to create one route (say type/{slug}), and direct all these type of pages under the same route.

But each of these types of pages would need to fall under a different controller and action. How to cope with that?

In MVC, you can create a constraint against a route. The constraint acts as the meat between a route being found, based on the pattern of the URL, and being directed to the appropriate controller and action.

In older versions of MVC (well certainly MVC4), you could use the constraint to override the "controller" and "action" properties in the Microsoft.AspNetCore.Routing.

// TypeConstraint.cs    
public partial class TypeConstraint : IRouteConstraint
{
	public virtual bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
	{
		if (routeDirection == RouteDirection.UrlGeneration)
		{
			return true;
		}

		values["controller"] = "newtype"; // I've overwritten the Controller (in MVC4 anyways)

		return true;
	}
}

I'm not sure if that's what constraints were really designed for, but hey it worked! In MVC4 anyways, but not for ASP.NET Core it would seem.

So I had to come up with a way of making it work in ASP.NET Core.

Thanks to the fact that ASP.NET Core is open source and freely available on GitHub, I managed to load up the class that holds the route, and the solution is to create your own Route which would inherit from Microsoft.AspNetCore.Routing.Route. It seems that the Controller and Action are set in the RouteAsync function, so it was the case of overriding that.

I had to recreate the code that creates the RouteValueDictionary (inside RouteAsync) and then being able to set the controller and action before calling the base RouteAsync function.

After that, it's a case of registering the routes under your newly created Route class, rather than the one that comes with ASP.NET Core.

// Startup.cs    
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)
	{
		var mvcOptions = new Action<MvcOptions>(options =>
		{
			options.Filters.Add(new ActionParameterFilterAttribute());
			options.EnableEndpointRouting = false; // This throws an error is set to true.
		});

		services.AddMvc(mvcOptions).SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

		
	}

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

		app.UseHttpsRedirection();
		app.UseStaticFiles();
		app.UseCookiePolicy();

		app.UseMvc(routes =>
		{  
			// Add the route to the route builder.
			routes.MapTypeRoute();
		});
	}
}

// TypeRoute.cs
public class TypeRoute : Route
{
	protected readonly IRouteBuilder _builder;
	private TemplateMatcher _matcher;

	public TypeRoute(IRouteBuilder builder)
		: base(builder.DefaultHandler, "Type", "{*slug}", new RouteValueDictionary { { "controller", "TypeController" }, { "action", "TypeAction" } }, new Dictionary<string, object> { { "slug", new PageConstraint() } }, new RouteValueDictionary(), CreateInlineConstraintResolver(builder.ServiceProvider))
	{
		_builder = builder;
	}

	public override Task RouteAsync(RouteContext context)
	{
		var requestPath = context.HttpContext.Request.Path;

		EnsureMatcher();

		// This is where the RouteValueDictionary is created
		if (!_matcher.TryMatch(requestPath, context.RouteData.Values))
		{
			return Task.CompletedTask;
		}
	
		// You can do code here (like changing the controller and action), before calling the base RouteAsync.

		return base.RouteAsync(context);            
	}

	protected static IInlineConstraintResolver CreateInlineConstraintResolver(IServiceProvider serviceProvider)
	{
		var inlineConstraintResolver = serviceProvider
			.GetRequiredService<IInlineConstraintResolver>();

		var parameterPolicyFactory = serviceProvider
			.GetRequiredService<ParameterPolicyFactory>();

		// This inline constraint resolver will return a null constraint for non-IRouteConstraint
		// parameter policies so Route does not error
		return new BackCompatInlineConstraintResolver(inlineConstraintResolver, parameterPolicyFactory);
	}

	protected class BackCompatInlineConstraintResolver : IInlineConstraintResolver
	{
		private readonly IInlineConstraintResolver _inner;
		private readonly ParameterPolicyFactory _parameterPolicyFactory;

		public BackCompatInlineConstraintResolver(IInlineConstraintResolver inner, ParameterPolicyFactory parameterPolicyFactory)
		{
			_inner = inner;
			_parameterPolicyFactory = parameterPolicyFactory;
		}

		public IRouteConstraint ResolveConstraint(string inlineConstraint)
		{
			var routeConstraint = _inner.ResolveConstraint(inlineConstraint);
			if (routeConstraint != null)
			{
				return routeConstraint;
			}

			var parameterPolicy = _parameterPolicyFactory.Create(null, inlineConstraint);
			if (parameterPolicy != null)
			{
				// Logic inside Route will skip adding NullRouteConstraint
				return null;
			}

			return null;
		}
	}

	private void EnsureMatcher()
	{
		if (_matcher == null)
		{
			_matcher = new TemplateMatcher(ParsedTemplate, Defaults);
		}
	}        

}

   public static class MapRouteRouteBuilderExtensions
{
	public static IRouteBuilder MapTypeRoute(
 this IRouteBuilder routeBuilder)
	{
// Modifier for add the new route to the RouteBuilder
		routeBuilder.Routes.Add(new TypeRoute(routeBuilder));

		return routeBuilder;
	}
}

internal static class TreeRouterLoggerExtensions
{
	private static readonly Action<ILogger, string, string, Exception> _requestMatchedRoute;

	static TreeRouterLoggerExtensions()
	{
		_requestMatchedRoute = LoggerMessage.Define<string, string>(
			LogLevel.Debug,
			new EventId(1, "RequestMatchedRoute"),
			"Request successfully matched the route with name '{RouteName}' and template '{RouteTemplate}'");
	}

	public static void RequestMatchedRoute(
		this ILogger logger,
		string routeName,
		string routeTemplate)
	{
		_requestMatchedRoute(logger, routeName, routeTemplate, null);
	}
}

If you ever come up with a challenge like that, just remember that ASP.NET Core is open sourced and freely available onĀ GitHub. You can look and see how a particular area functions and operates.