Can I get a the RouteTemplate from AspNetCore FilterContext? - asp.net-core

In AspNetCore, given a FilterContext, I'm looking to get a route template e.g.
{controller}/{action}/{id?}
In Microsoft.AspNet.WebApi I could get the route template from:
HttpControllerContext.RouteData.Route.RouteTemplate
In System.Web.Mvc I could get this from:
ControllerContext.RouteData.Route as RouteBase
In AspNetCore there is:
FilterContext.ActionDescriptor.AttributeRouteInfo.Template
However, not all routes are attribute routes.
Based on inspection if the attribute is not available, default routes and/or mapped routes can be assembled from:
FilterContext.RouteData.Routers.OfType<Microsoft.AspNetCore.Routing.RouteBase>().First()
but I'm looking for a documented or a simply better approach.

Update (24 Jan 2021)
There is a much much simpler way of retrieving the RoutePattern directly via the HttpContext.
FilterContext filterContext;
var endpoint = filterContext.HttpContext.GetEndpoint() as RouteEndpoint;
var template = endpoint?.RoutePattern?.RawText;
if (template is null)
throw new Exception("No route template found, that's absurd");
Console.WriteLine(template);
GetEndpoint() is an extension method provided in EndpointHttpContextExtensions class inside Microsoft.AspNetCore.Http namespace
Old Answer (Too much work)
All the route builders for an ASP.NET Core app (at least for 3.1) are exposed and registered via IEndpointRouteBuilder, but unfortunately, this is not registered with the DI container, so you can't acquire it directly.The only places where I have seen this interface being exposed, are in the middlewares.
So you can build a collection or dictionary out of one of those middlewares, and then use that for your purposes.
e.g
Program.cs
Extension class to build your endpoint collection / dictionary
internal static class IEndpointRouteBuilderExtensions
{
internal static void BuildMap(this IEndpointRouteBuilder endpoints)
{
foreach (var item in endpoints.DataSources)
foreach (RouteEndpoint endpoint in item.Endpoints)
{
/* This is needed for controllers with overloaded actions
* Use the RoutePattern.Parameters here
* to generate a unique display name for the route
* instead of this list hack
*/
if (Program.RouteTemplateMap.TryGetValue(endpoint.DisplayName, out var overloadedRoutes))
overloadedRoutes.Add(endpoint.RoutePattern.RawText);
else
Program.RouteTemplateMap.Add(endpoint.DisplayName, new List<string>() { endpoint.RoutePattern.RawText });
}
}
}
public class Program
{
internal static readonly Dictionary<string, List<string>> RouteTemplateMap = new Dictionary<string, List<string>>();
/* Rest of things */
}
Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
/* all other middlewares */
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
//Use this at the last middlware exposing IEndpointRouteBuilder so that all the routes are built by this point
endpoints.BuildMap();
});
}
And then you can use that Dictionary or Collection, to retrieve the Route Template from the FilterContext.
FilterContext filterContext;
Program.RouteTemplateMap.TryGetValue(filterContext.ActionDescriptor.DisplayName, out var template);
if (template is null)
throw new Exception("No route template found, that's absurd");
/* Use the ActionDescriptor.Parameters here
* to figure out which overloaded action was called exactly */
Console.WriteLine(string.Join('\n', template));
To tackle the case of overloaded actions, a list of strings is used for route template (instead of just a string in the Dictionary)
You can use the ActionDescriptor.Parameters in conjunction with RoutePattern.Parameters to generate a unique display name for that route.

These are the assembled versions, but still looking for a better answer.
AspNetCore 2.0
FilterContext context;
string routeTemplate = context.ActionDescriptor.AttributeRouteInfo?.Template;
if (routeTemplate == null)
{
// manually mapped routes or default routes
// todo is there a better way, not 100% sure that this is correct either
// https://github.com/aspnet/Routing/blob/1b0258ab8fccff1306e350fd036d05c3110bbc8e/src/Microsoft.AspNetCore.Routing/Template/TemplatePart.cs
IEnumerable<string> segments = context.RouteData.Routers.OfType<Microsoft.AspNetCore.Routing.RouteBase>()
.FirstOrDefault()?.ParsedTemplate.Segments.Select(s => string.Join(string.Empty, s.Parts
.Select(p => p.IsParameter ? $"{{{(p.IsCatchAll ? "*" : string.Empty)}{p.Name}{(p.IsOptional ? "?" : string.Empty)}}}" : p.Text)));
if (segments != null)
{
routeTemplate = string.Join("/", segments);
}
}
AspNetCore 3.0 with Endpoint Routing
RoutePattern routePattern = null;
var endpointFeature = context.HttpContext.Features[typeof(Microsoft.AspNetCore.Http.Features.IEndpointFeature)]
as Microsoft.AspNetCore.Http.Features.IEndpointFeature;
var endpoint = endpointFeature?.Endpoint;
if (endpoint != null)
{
routePattern = (endpoint as RouteEndpoint)?.RoutePattern;
}
string formatRoutePart(RoutePatternPart part)
{
if (part.IsParameter)
{
RoutePatternParameterPart p = (RoutePatternParameterPart)part;
return $"{{{(p.IsCatchAll ? "*" : string.Empty)}{p.Name}{(p.IsSeparator ? " ? " : string.Empty)}}}";
}
else if (part.IsLiteral)
{
RoutePatternLiteralPart p = (RoutePatternLiteralPart)part;
return p.Content;
}
else if(part.IsSeparator)
{
RoutePatternSeparatorPart p = (RoutePatternSeparatorPart)part;
return p.Content;
}
else
{
throw new NotSupportedException("Unknown Route PatterPart");
}
}
if (routePattern != null)
{
// https://github.com/aspnet/Routing/blob/1b0258ab8fccff1306e350fd036d05c3110bbc8e/src/Microsoft.AspNetCore.Routing/Template/TemplatePart.cs
routeString = string.Join("/", routePattern.PathSegments.SelectMany(s => s.Parts).Select(p => formatRoutePart(p)));
}

Related

ASP.NET Core Resolve Controller and call Action by name

I have a generic catch all controller/action that receive files, parse the json content and find out the controller name and action name to be called from that.
Here my previous .NET Framework (old ASP) implementation which worked great:
public async Task<ActionResult> Run(PackingSlip packingSlip, IEnumerable<HttpPostedFileBase> files)
{
var controllerName = packingSlip.service_name;
var actionName = packingSlip.service_object;
// get the controller
var ctrlFactory = ControllerBuilder.Current.GetControllerFactory();
var ctrl = ctrlFactory.CreateController(this.Request.RequestContext, controllerName) as Controller;
var ctrlContext = new ControllerContext(this.Request.RequestContext, ctrl);
var ctrlDescAsync = new ReflectedAsyncControllerDescriptor(ctrl.GetType());
ctrl.ControllerContext = ctrlContext;
// get the action
var actionDesc = ctrlDescAsync.FindAction(ctrlContext, actionName);
// execute
ActionResult result;
if (actionDesc is AsyncActionDescriptor actionDescAsync)
result = await Task.Factory.FromAsync((asyncCallback, asyncState) => actionDescAsync.BeginExecute(ctrlContext, new Dictionary<string, object> { { "packingSlip", packingSlip }, { "files", files } }, asyncCallback, asyncState), asyncResult => actionDescAsync.EndExecute(asyncResult), null) as ActionResult;
else
result = actionDesc.Execute(ctrlContext, new Dictionary<string, object> { { "packingSlip", packingSlip }, { "files", files } }) as ActionResult;
// return the other action result as the current action result
return result;
}
Now with ASP.NET Core (or .NET 5), ControllerBuilder doesn't exist anymore and most of those things changed.
I tried to inject a IControllerFactory and use it, but can't find the proper way to use it to call an action knowing the "controllerName" and "actionName". It should also, like before, determine if it was an async action or not and act accordingly.
Found the answer by myself.
AspCore have an hidden barely documented extension method that registers controllers in the DI container: AddControllersAsServices.
services.AddMvc().AddControllersAsServices();
Then you can use IServiceProvider to resolve your controllers.

RequireAuthorization and Swashbuckle IOperationFilter

I am looking for a way to determine if endpoint requires authorization (.Net Core 3.1) using IOperationFilter.
If Authorization is setup via filter or explicitly as attribute, it can be found in OperationFilterContext context.ApiDescription.ActionDescriptor.FilterDescriptors.Select(filterInfo => filterInfo.Filter).Any(filter => filter is AuthorizeFilter) and context.ApiDescription.CustomAttributes().OfType<AuthorizeAttribute>().
But if authorization is set as
endpoints.MapControllers().RequireAuthorization();, which should add AuthorizationAttribute to all endpoints, it is not appeared neither in filters nor in attributes. Any thoughts on how to catch if auth is applied to endpoints in this case?
I was able to beat this today like so (swashbuckle 5.63):
Make a new class like this
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace YourNameSpace
{
public class SwaggerGlobalAuthFilter : IOperationFilter
{
public void Apply( OpenApiOperation operation, OperationFilterContext context )
{
context.ApiDescription.TryGetMethodInfo( out MethodInfo methodInfo );
if ( methodInfo == null )
{
return;
}
var hasAllowAnonymousAttribute = false;
if ( methodInfo.MemberType == MemberTypes.Method )
{
// NOTE: Check the controller or the method itself has AllowAnonymousAttribute attribute
hasAllowAnonymousAttribute =
methodInfo.DeclaringType.GetCustomAttributes( true ).OfType<AllowAnonymousAttribute>().Any() ||
methodInfo.GetCustomAttributes( true ).OfType<AllowAnonymousAttribute>().Any();
}
if ( hasAllowAnonymousAttribute )
{
return;
}
// NOTE: This adds the "Padlock" icon to the endpoint in swagger,
// we can also pass through the names of the policies in the List<string>()
// which will indicate which permission you require.
operation.Security = new List<OpenApiSecurityRequirement>
{
new OpenApiSecurityRequirement()
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "oauth2" // note this 'Id' matches the name 'oauth2' defined in the swagger extensions config section below
},
Scheme = "oauth2",
Name = "Bearer",
In = ParameterLocation.Header,
},
new List<string>()
}
}
};
}
}
}
In swagger config extensions
options.AddSecurityDefinition( "oauth2", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
Implicit = new OpenApiOAuthFlow
{
//_swaggerSettings is a custom settings object of our own
AuthorizationUrl = new Uri( _swaggerSettings.AuthorizationUrl ),
Scopes = _swaggerSettings.Scopes
}
}
} );
options.OperationFilter<SwaggerGlobalAuthFilter>();
Put together from docs, other SO and decompiled code of built-in SecurityRequirementsOperationFilter
AFAIK, it is defining a global auth setup for all your routed endpoints except those that explicitly have AllowAnonymousAttribute on controller or endpoint. since, as your original question hints at, using the extension RequireAuthorization() when setting up routing implicitly puts that attribute on all endpoints and the built-in SecurityRequirementsOperationFilter which detect the Authorize attribute fails to pick it up. Since your routing setup effectively is putting Authorize on every controller/route it seems setting up a default global filter like this that excludes AllowAnonymous would be in line with what you are configuring in the pipeline.
I suspect there may be a more 'built-in' way of doing this, but I could not find it.
Apparently, this is an open issue on the NSwag repo as well (for people like me that drive by with the same issue, but with NSwag instead of Swashbuckle):
https://github.com/RicoSuter/NSwag/issues/2817
Where there's also another example of solving the issue (not only securityrequirement, but also its scopes).
I know it's been a long time since this question was asked.
But I was facing a similar issue, and following the advice from an issue in GitHub here, managed to resolve it using this implementation of IOperationFilter (and now works like a charm):
public class AuthorizeCheckOperationFilter : IOperationFilter
{
private readonly EndpointDataSource _endpointDataSource;
public AuthorizeCheckOperationFilter(EndpointDataSource endpointDataSource)
{
_endpointDataSource = endpointDataSource;
}
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var descriptor = _endpointDataSource.Endpoints.FirstOrDefault(x =>
x.Metadata.GetMetadata<ControllerActionDescriptor>() == context.ApiDescription.ActionDescriptor);
var hasAuthorize = descriptor.Metadata.GetMetadata<AuthorizeAttribute>()!=null;
var allowAnon = descriptor.Metadata.GetMetadata<AllowAnonymousAttribute>() != null;
if (!hasAuthorize || allowAnon) return;
operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" });
operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" });
operation.Security = new List<OpenApiSecurityRequirement>
{
new()
{
[
new OpenApiSecurityScheme {Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "oauth2"}
}
] = new[] {"api1"}
}
};
}
}
The issue stated this:
ControllerActionDescriptor.EndpointMetadata only reflects the metadata
discovered on the controller action. Any metadata configured via the
endpoint APIs do not show up here. It was primarily the reason we
documented it as being infrastructure-only since it's a bit confusing
to use.
There's a couple of options you could use
a) You could decorate your controllers using [Authorize]. That should allow the metadata to show up in the property.
b) You could look up the metadata by reading from EndpointDataSource.

Razor page routing based on different domains

I'm trying to setup a single ASP.NET Core Razor Web app localized for use on multi domains. I have the localization working, with one different language for each domain. But right now I want to have the .com domain accepting a routing parameter, to make the URL path decide with language to show.
Something like:
www.mysite.pt - no custom routing - www.mysite.pt/PageA works, localized in Portuguese.
www.mysite.com - custom routing - www.mysite.com/us/PageA goes to PageA, localized in en-US. But www.mysite.com/PageA should return a 404, as for this domain every page needs the country parameter.
For MVC this could be achieved by using the MapRoute with a custom IRouteConstraint to filter by domain.
However with Razor pages, I only see the option to go with the conventions and add a class derived from IPageRouteModelConvention.
But I don't see a way on the IPageRouteModelConvention methodology to use a IRouteConstraint.
Is there a way to do this?
Not exactly the best solution... but worked this out:
On ConfigureServices added a custom convention that takes a country parameter only with two country codes US and CA:
options.Conventions.Add(new CountryTemplateRouteModelConvention());
wethe this class being:
public class CountryTemplateRouteModelConvention : IPageRouteModelConvention
{
public void Apply(PageRouteModel model)
{
var selectorCount = model.Selectors.Count;
for (var i = 0; i < selectorCount; i++)
{
var selector = model.Selectors[i];
// selector.AttributeRouteModel.SuppressLinkGeneration = false;
//we are not adding the selector, but replacing the existing one
model.Selectors.Add(new SelectorModel
{
AttributeRouteModel = new AttributeRouteModel
{
Order = -1,
Template = AttributeRouteModel.CombineTemplates(#"{country:length(2):regex(^(us|ca)$)}", selector.AttributeRouteModel.Template),
}
});
}
}
}
Then, before the UseMvc on Configure, I used two types of Rewrite rules:
var options = new RewriteOptions();
options.Add(new CountryBasedOnDomainRewriteRule(domains: GetDomainsWhereCountryComesFromDomain(Configuration)));
options.Add(new CountryBasedOnPathRewriteRule(domains: GetDomainsWhereCountryComesFromPath(Configuration)));
app.UseRewriter(options);
The methods GetDomainsWhereCountryComesFromDomain and GetDomainsWhereCountryComesFromPath just read from the appsettings the domains where I want to have a single language, and the domains where I want the language to be obtained from the URL path.
Now, the two IRule classes:
public class CountryBasedOnPathRewriteRule : IRule
{
private readonly string[] domains;
public CountryBasedOnPathRewriteRule(string[] domains)
{
this.domains = domains;
}
public void ApplyRule(RewriteContext context)
{
string hostname = context.HttpContext.Request.Host.Host.ToLower();
if (!domains.Contains(hostname)) return;
//only traffic that has the country on the path is valid. examples:
// www.mysite.com/ -> www.mysite.com/US/
// www.mysite.com/Cart -> www.mysite.com/US/Cart
var path = context.HttpContext.Request.Path.ToString().ToLower();
/* let's exclude the error page, as method UseExceptionHandler doesn't accept the country parameter */
if (path == "/" || path == "/error")
{
//redirect to language default
var response = context.HttpContext.Response;
response.StatusCode = (int)HttpStatusCode.Moved;
response.Headers[HeaderNames.Location] = "/us/"; //default language/country
context.Result = RuleResult.EndResponse;
}
string pathFirst = path.Split('/')?[1];
if (pathFirst.Length != 2) /* US and CA country parameter is already enforced by the routing */
{
var response = context.HttpContext.Response;
response.StatusCode = (int)HttpStatusCode.NotFound;
context.Result = RuleResult.EndResponse;
}
}
}
public class CountryBasedOnDomainRewriteRule : IRule
{
private readonly string[] domains;
public CountryBasedOnDomainRewriteRule(string[] domains)
{
this.domains = domains;
}
public void ApplyRule(RewriteContext context)
{
string hostname = context.HttpContext.Request.Host.Host.ToLower();
if (!domains.Contains(hostname)) return;
var path = context.HttpContext.Request.Path.ToString().ToLower();
string pathFirst = path.Split('/')?[1];
if (pathFirst.Length == 2) //we are trying to use www.mysite.co.uk/us which is not allowed
{
var response = context.HttpContext.Response;
response.StatusCode = (int)HttpStatusCode.NotFound;
context.Result = RuleResult.EndResponse;
}
}
}
And that's it.

OData service with multiple routes while using unbound functions

Does anyone know how to get OData v4 hosted in a .NET service to work with multiple routes?
I have the following:
config.MapODataServiceRoute("test1", "test1", GetEdmModelTest1());
config.MapODataServiceRoute("test2", "test2", GetEdmModelTest2());
Each of the GetEdmModel methods have mapped objects.
I can get to the service as following (this is working fine):
http://testing.com/test1/objects1()
http://testing.com/test2/objects2()
But if I try to call a function like the following (will not work):
[HttpGet]
[ODataRoute("test1/TestFunction1()")]
public int TestFunction1()
{ return 1; }
It will throw the following error:
The path template 'test1/TestFunction1()' on the action 'TestFunction1' in controller 'Testing' is not a valid OData path template. Resource not found for the segment 'test1'.
Yet if I remove the "MapODataServiceRoute" for "test2" so there is only one route, it all works.
How do I get this to work with multiple routes?
** I have posted a full example of the issue at the following **
https://github.com/OData/WebApi/issues/1223
** I have tried the OData version sample listed below with the following issues **
https://github.com/OData/ODataSamples/tree/master/WebApi/v4/ODataVersioningSample
I have tried the "OData Version" example before and it did not work.
It seems that unbound (unbound is the goal) does not follow the same routing rules are normal service calls.
Ex. If you download the "OData Version" example and do the following.
In V1 -> WebApiConfig.cs add
builder.Function(nameof(Controller.ProductsV1Controller.Test)).Returns<string>();
In V2 -> WebApiConfig.cs add
builder.Function(nameof(Controller.ProductsV2Controller.Test)).Returns<string>();
In V1 -> ProductsV1Controller.cs add
[HttpGet]
[ODataRoute("Test()")]
public string Test()
{ return "V1_Test"; }
In V2 -> ProductsV2Controller.cs add
[HttpGet]
[ODataRoute("Test()")]
public string Test()
{ return "V2_Test"; }
Now call it by this. " /versionbyroute/v1/Test() " and you will get "V2_Test"
The problem is that "GetControllerName" does not know how to get the controller when it is using unbound functions / actions.
This is why most sample code I have found fails when trying to "infer" the controller.
Have a look at OData Versioning Sample for a primer.
The key point of trouble is usually that the DefaultHttpControllerSelector maps controllers by local name, not fullname/namespace.
If your entity types and therefore controller names are unique across both EdmModels you will not have to do anything special, it should just work out of the box. The above sample takes advantage of this concept by forcing you to inject a string value into the physical names of the controller classes to make them unique and then in the ODataVersionControllerSelector GetControllerName is overridden to maps the incoming route to the customised controller names
If unique names for the controllers seems to hard, and you would prefer to use the full namespace (meaning your controller names logic remains standard) then you can of course implement your own logic to select the specific controller class instance when overriding DefaultHttpControllerSelector. simply override SelectController instead. This method will need to return an instance of HttpControllerDescriptor which is a bit more involved than the sample.
To show you what I mean, I will post the solution to a requirement from an older project, that was a little bit different to yours. I have a single WebAPI project that manages access to multiple databases, these databases have similar schema, many Entity names are the same which means that those controller classes will have the same names. The controllers are structured by folders/namespaces such that there is a root folder called DB, then there is a folder for each database, then the controllers are in there.
You can see that this project has many different schemas, they effectively map to versions of an evolving solution, the non-DB namespaces in this image are a mix of OData v4, v3 and standard REST apis. It is possible to get all these beasts to co-exist ;)
This override of the HttpControllerSelector inspects the runtime once to cache a list of all the controller classes, then maps the incoming route requests by matching the route prefix to the correct controller class.
/// <summary>
/// Customised controller for intercepting traffic for the DB Odata feeds.
/// Any route that is not prefixed with ~/DB/ will not be intercepted or processed via this controller
/// <remarks>Will instead be directed to the base class</remarks>
/// </summary>
public class DBODataHttpControllerSelector : DefaultHttpControllerSelector
{
private readonly HttpConfiguration _configuration;
public DBODataHttpControllerSelector(HttpConfiguration config)
: base(config)
{
_configuration = config;
}
// From: http://www.codeproject.com/Articles/741326/Introduction-to-Web-API-Versioning
private Dictionary<string, HttpControllerDescriptor> _controllerMap = null;
private List<string> _duplicates = new List<string>();
/// <summary>
/// Because we are interested in supporting nested namespaces similar to MVC "Area"s we need to
/// Index our available controller classes by the potential url segments that might be passed in
/// </summary>
/// <returns></returns>
private Dictionary<string, HttpControllerDescriptor> InitializeControllerDictionary()
{
if(_controllerMap != null)
return _controllerMap;
_controllerMap = new Dictionary<string, HttpControllerDescriptor>(StringComparer.OrdinalIgnoreCase);
// Create a lookup table where key is "namespace.controller". The value of "namespace" is the last
// segment of the full namespace. For example:
// MyApplication.Controllers.V1.ProductsController => "V1.Products"
IAssembliesResolver assembliesResolver = _configuration.Services.GetAssembliesResolver();
IHttpControllerTypeResolver controllersResolver = _configuration.Services.GetHttpControllerTypeResolver();
ICollection<Type> controllerTypes = controllersResolver.GetControllerTypes(assembliesResolver);
foreach (Type t in controllerTypes)
{
var segments = t.Namespace.Split(Type.Delimiter);
// For the dictionary key, strip "Controller" from the end of the type name.
// This matches the behavior of DefaultHttpControllerSelector.
var controllerName = t.Name.Remove(t.Name.Length - DefaultHttpControllerSelector.ControllerSuffix.Length);
var key = String.Format(CultureInfo.InvariantCulture, "{0}.{1}.{2}", segments[segments.Length - 2], segments[segments.Length - 1], controllerName);
// Check for duplicate keys.
if (_controllerMap.Keys.Contains(key))
{
_duplicates.Add(key);
}
else
{
_controllerMap[key] = new HttpControllerDescriptor(_configuration, t.Name, t);
}
}
// Remove any duplicates from the dictionary, because these create ambiguous matches.
// For example, "Foo.V1.ProductsController" and "Bar.V1.ProductsController" both map to "v1.products".
// CS: Ahem... thats why I've opted to go 3 levels of depth to key name, but this still applies if the duplicates are there again
foreach (string s in _duplicates)
{
_controllerMap.Remove(s);
}
return _controllerMap;
}
/// <summary>
/// Because we are interested in supporting nested namespaces we want the full route
/// to match to the full namespace (or at least the right part of it)
/// </summary>
/// <returns></returns>
private Dictionary<string, HttpControllerDescriptor> _fullControllerMap = null;
private Dictionary<string, HttpControllerDescriptor> InitializeFullControllerDictionary()
{
if(_fullControllerMap != null)
return _fullControllerMap;
_fullControllerMap = new Dictionary<string, HttpControllerDescriptor>(StringComparer.OrdinalIgnoreCase);
// Create a lookup table where key is "namespace.controller". The value of "namespace" is the last
// segment of the full namespace. For example:
// MyApplication.Controllers.V1.ProductsController => "V1.Products"
IAssembliesResolver assembliesResolver = _configuration.Services.GetAssembliesResolver();
IHttpControllerTypeResolver controllersResolver = _configuration.Services.GetHttpControllerTypeResolver();
ICollection<Type> controllerTypes = controllersResolver.GetControllerTypes(assembliesResolver);
foreach (Type t in controllerTypes)
{
var segments = t.Namespace.Split(Type.Delimiter);
// For the dictionary key, strip "Controller" from the end of the type name.
// This matches the behavior of DefaultHttpControllerSelector.
var controllerName = t.Name.Remove(t.Name.Length - DefaultHttpControllerSelector.ControllerSuffix.Length);
var key = t.FullName;// t.Namespace + "." + controllerName;
_fullControllerMap[key] = new HttpControllerDescriptor(_configuration, t.Name, t);
}
return _fullControllerMap;
}
/// <summary>
/// Select the controllers with a simulated MVC area sort of functionality, but only for the ~/DB/ route
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
public override System.Web.Http.Controllers.HttpControllerDescriptor SelectController(System.Net.Http.HttpRequestMessage request)
{
string rootPath = "db";
IHttpRouteData routeData = request.GetRouteData();
string[] uriSegments = request.RequestUri.LocalPath.Split('/');
if (uriSegments.First().ToLower() == rootPath || uriSegments[1].ToLower() == rootPath)
{
#region DB Route Selector
// If we can find a known api and a controller, then redirect to the correct controller
// Otherwise allow the standard select to work
string[] knownApis = new string[] { "tms", "srg", "cumulus" };
// Get variables from the route data.
/* support version like this:
* config.Routes.MapODataRoute(
routeName: "ODataDefault",
routePrefix: "{version}/{area}/{controller}",
model: model);
object versionName = null;
routeData.Values.TryGetValue("version", out versionName);
object apiName = null;
routeData.Values.TryGetValue("api", out apiName);
object controllerName = null;
routeData.Values.TryGetValue("controller", out controllerName);
* */
// CS: we'll just use the local path AFTER the root path
// db/tms/contact
// db/srg/contact
// Implicity parse this as
// db/{api}/{controller}
// so [0] = ""
// so [1] = "api"
// so [2] = "version" (optional)
// so [2 or 3] = "controller"
if (uriSegments.Length > 3)
{
string apiName = uriSegments[2];
if (knownApis.Contains(string.Format("{0}", apiName).ToLower()))
{
string version = "";
string controllerName = uriSegments[3];
if (controllerName.ToLower().StartsWith("v")
// and the rest of the name is numeric
&& !controllerName.Skip(1).Any(c => !Char.IsNumber(c))
)
{
version = controllerName;
controllerName = uriSegments[4];
}
// if the route has an OData item selector (#) then this needs to be trimmed from the end.
if (controllerName.Contains('('))
controllerName = controllerName.Substring(0, controllerName.IndexOf('('));
string fullName = string.Format(CultureInfo.InvariantCulture, "{0}.{1}.{2}", apiName, version, controllerName).Replace("..", ".");
// Search for the controller.
// _controllerTypes is a list of HttpControllerDescriptors
var descriptors = InitializeControllerDictionary().Where(t => t.Key.EndsWith(fullName, StringComparison.OrdinalIgnoreCase)).ToList();
if (descriptors.Any())
{
var descriptor = descriptors.First().Value;
if (descriptors.Count > 1)
{
descriptor = null;
// Assume that the version was missing, and we have implemented versioning for that controller
// If there is a row with no versioning, so no v1, v2... then use that
// if all rows are versioned, use the highest version
if (descriptors.Count(d => d.Key.Split('.').Length == 2) == 1)
descriptor = descriptors.First(d => d.Key.Split('.').Length == 2).Value;
else if (descriptors.Count(d => d.Key.Split('.').Length > 2) == descriptors.Count())
descriptor = descriptors
.Where(d => d.Key.Split('.').Length > 2)
.OrderByDescending(d => d.Key.Split('.')[1])
.First().Value;
if (descriptor == null)
throw new HttpResponseException(
request.CreateErrorResponse(HttpStatusCode.InternalServerError,
"Multiple controllers were found that match this un-versioned request."));
}
if (descriptor != null)
return descriptor;
}
if (_duplicates.Any(d => d.ToLower() == fullName.ToLower()))
throw new HttpResponseException(
request.CreateErrorResponse(HttpStatusCode.InternalServerError,
"Multiple controllers were found that match this request."));
}
}
#endregion DB Route Selector
}
else
{
// match on class names that match the route.
// So if the route is odata.tms.testController
// Then the class name must also match
// Add in an option to doing a string mapping, so that
// route otms can mapp to odata.tms
// TODO: add any other custom logic for selecting the controller that you want, alternatively try this style syntax in your route config:
//routes.MapRoute(
// name: "Default",
// url: "{controller}/{action}/{id}",
// defaults: new { controller = "Home", action = "RegisterNow", id = UrlParameter.Optional },
// namespaces: new[] { "YourCompany.Controllers" }
//);
// Because controller path mapping might be controller/navigationproperty/action
// We need to check for the following matches:
// controller.navigationproperty.actionController
// controller.navigationpropertyController
// controllerController
string searchPath = string.Join(".", uriSegments).ToLower().Split('(')[0] + "controller";
var descriptors = InitializeFullControllerDictionary().Where(t => t.Key.ToLower().Contains(searchPath)).ToList();
if (descriptors.Any())
{
var descriptor = descriptors.First().Value;
if (descriptors.Count > 1)
{
descriptor = null;
// In this mode, I think we should only ever have a single match, ready to prove me wrong?
if (descriptor == null)
throw new HttpResponseException(
request.CreateErrorResponse(HttpStatusCode.InternalServerError,
"Multiple controllers were found that match this namespace request."));
}
if (descriptor != null)
return descriptor;
}
}
return base.SelectController(request);
}
}
You can use a Custsom MapODataServiceRoute.
The below is an example from WebApiConfig.cs
The controllers are registered with the CustomMapODataServiceRoute and its a bit cumbersome having to include typeof(NameOfController) for every controller. One of my endpoints has 22 separate controllers, but thus far it's worked.
Registering Controllers - Showing two separate OData endpoints in the same project, both containing custom functions
// Continuing Education
ODataConventionModelBuilder continuingEdBuilder = new ODataConventionModelBuilder();
continuingEdBuilder.Namespace = "db_api.Models";
var continuingEdGetCourse = continuingEdBuilder.Function("GetCourse");
continuingEdGetCourse.Parameter<string>("term_code");
continuingEdGetCourse.Parameter<string>("ssts_code");
continuingEdGetCourse.Parameter<string>("ptrm_code");
continuingEdGetCourse.Parameter<string>("subj_code_prefix");
continuingEdGetCourse.Parameter<string>("crn");
continuingEdGetCourse.ReturnsCollectionFromEntitySet<ContinuingEducationCoursesDTO>("ContinuingEducationCourseDTO");
config.CustomMapODataServiceRoute(
routeName: "odata - Continuing Education",
routePrefix: "contEd",
model: continuingEdBuilder.GetEdmModel(),
controllers: new[] { typeof(ContinuingEducationController) }
);
// Active Directory OData Endpoint
ODataConventionModelBuilder adBuilder = new ODataConventionModelBuilder();
adBuilder.Namespace = "db_api.Models";
// CMS Groups
var cmsGroupFunc = adBuilder.Function("GetCMSGroups");
cmsGroupFunc.Parameter<string>("user");
cmsGroupFunc.ReturnsCollectionFromEntitySet<GenericValue>("GenericValue");
// Departments
var deptUsersFunc = adBuilder.Function("GetADDepartmentUsers");
deptUsersFunc.Parameter<string>("department");
deptUsersFunc.ReturnsCollectionFromEntitySet<ADUser>("ADUser");
var adUsersFunc = adBuilder.Function("GetADUser");
adUsersFunc.Parameter<string>("name");
adUsersFunc.ReturnsCollectionFromEntitySet<ADUser>("ADUser");
var deptFunc = adBuilder.Function("GetADDepartments");
deptFunc.ReturnsCollectionFromEntitySet<GenericValue>("GenericValue");
var instDeptFunc = adBuilder.Function("GetADInstructorDepartments");
instDeptFunc.ReturnsCollectionFromEntitySet<GenericValue>("GenericValue");
var adTitleFunc = adBuilder.Function("GetADTitles");
adTitleFunc.ReturnsCollectionFromEntitySet<GenericValue>("GenericValue");
var adOfficeFunc = adBuilder.Function("GetADOffices");
adOfficeFunc.ReturnsCollectionFromEntitySet<GenericValue>("GenericValue");
var adDistListFunc = adBuilder.Function("GetADDistributionLists");
adDistListFunc.ReturnsCollectionFromEntitySet<GenericValue>("GenericValue");
config.CustomMapODataServiceRoute(
routeName: "odata - Active Directory",
routePrefix: "ad",
model: adBuilder.GetEdmModel(),
controllers: new[] { typeof(DepartmentsController), typeof(CMSGroupsController)
});
Creating Custom Map OData Service Route
public static class HttpConfigExt
{
public static System.Web.OData.Routing.ODataRoute CustomMapODataServiceRoute(this HttpConfiguration configuration, string routeName,
string routePrefix, Microsoft.OData.Edm.IEdmModel model, IEnumerable<Type> controllers)
{
var routingConventions = ODataRoutingConventions.CreateDefault();
// Multiple Controllers with Multiple Custom Functions
routingConventions.Insert(0, new CustomAttributeRoutingConvention(routeName, configuration, controllers));
// Custom Composite Key Convention
//routingConventions.Insert(1, new CompositeKeyRoutingConvention());
return configuration.MapODataServiceRoute(routeName,
routePrefix,
model,
new System.Web.OData.Routing.DefaultODataPathHandler(),
routingConventions,
defaultHandler: System.Net.Http.HttpClientFactory.CreatePipeline( innerHandler: new System.Web.Http.Dispatcher.HttpControllerDispatcher(configuration),
handlers: new[] { new System.Web.OData.ODataNullValueMessageHandler() }));
}
}
public class CustomAttributeRoutingConvention : AttributeRoutingConvention
{
private readonly List<Type> _controllers = new List<Type> { typeof(System.Web.OData.MetadataController) };
public CustomAttributeRoutingConvention(string routeName, HttpConfiguration configuration, IEnumerable<Type> controllers)
: base(routeName, configuration)
{
_controllers.AddRange(controllers);
}
public override bool ShouldMapController(System.Web.Http.Controllers.HttpControllerDescriptor controller)
{
return _controllers.Contains(controller.ControllerType);
}
}

Naming conventions for view pages and setting controller action for view

I am unsure on how I should be naming my View pages, they are all CamelCase.cshtml, that when viewed in the browser look like "http://www.website.com/Home/CamelCase".
When I am building outside of .NET my pages are named like "this-is-not-camel-case.html". How would I go about doing this in my MVC4 project?
If I did go with this then how would I tell the view to look at the relevant controller?
Views/Home/camel-case.cshtml
Fake edit: Sorry if this has been asked before, I can't find anything via search or Google. Thanks.
There are a few ways you can do this:
Name all of your views in the style you would like them to show up in the url
This is pretty simple, you just add the ActionName attribute to all of your actions and specify them in the style you would like your url to look like, then rename your CamelCase.cshtml files to camel-case.cshtml files.
Use attribute routing
Along the same lines as above, there is a plugin on nuget to enable attribute routing which lets you specify the full url for each action as an attribute on the action. It has convention attributes to help you out with controller names and such as well. I generally prefer this approach because I like to be very explicit with the routes in my application.
A more framework-y approach
It's probably possible to do something convention based by extending the MVC framework, but it would be a decent amount of work. In order to select the correct action on a controller, you'd need to map the action name on its way in to MVC to its CamelCase equivalent before the framework uses it to locate the action on the controller. The easiest place to do this is in the Route, which is the last thing to happen before the MVC framework takes over the request. You'll also need to convert the other way on the way out so the urls generated look like you want them to.
Since you don't really want to alter the existing method to register routes, it's probably best write a function in application init that loops over all routes after they have been registered and wraps them with your new functionality.
Here is an example route and modifications to application start that achieve what you are trying to do. I'd still go with the route attribute approach however.
public class MvcApplication : System.Web.HttpApplication
{
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
WebApiConfig.Register(GlobalConfiguration.Configuration);
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
WrapRoutesWithNamingConvention(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
AuthConfig.RegisterAuth();
}
private void WrapRoutesWithNamingConvention(RouteCollection routes)
{
var wrappedRoutes = routes.Select(m => new ConventionRoute(m)).ToList();
routes.Clear();
wrappedRoutes.ForEach(routes.Add);
}
private class ConventionRoute : Route
{
private readonly RouteBase baseRoute;
public ConventionRoute(RouteBase baseRoute)
: base(null, null)
{
this.baseRoute = baseRoute;
}
public override RouteData GetRouteData(HttpContextBase httpContext)
{
var baseRouteData = baseRoute.GetRouteData(httpContext);
if (baseRouteData == null) return null;
var actionName = baseRouteData.Values["action"] as string;
var convertedActionName = ConvertHyphensToPascalCase(actionName);
baseRouteData.Values["action"] = convertedActionName;
return baseRouteData;
}
private string ConvertHyphensToPascalCase(string hyphens)
{
var capitalParts = hyphens.Split('-').Select(m => m.Substring(0, 1).ToUpper() + m.Substring(1));
var pascalCase = String.Join("", capitalParts);
return pascalCase;
}
public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
{
var valuesClone = new RouteValueDictionary(values);
var pascalAction = valuesClone["action"] as string;
var hyphens = ConvertPascalCaseToHyphens(pascalAction);
valuesClone["action"] = hyphens;
var baseRouteVirtualPath = baseRoute.GetVirtualPath(requestContext, valuesClone);
return baseRouteVirtualPath;
}
private string ConvertPascalCaseToHyphens(string pascal)
{
var pascalParts = new List<string>();
var currentPart = new StringBuilder();
foreach (var character in pascal)
{
if (char.IsUpper(character) && currentPart.Length > 0)
{
pascalParts.Add(currentPart.ToString());
currentPart.Clear();
}
currentPart.Append(character);
}
if (currentPart.Length > 0)
{
pascalParts.Add(currentPart.ToString());
}
var lowers = pascalParts.Select(m => m.ToLower());
var hyphens = String.Join("-", lowers);
return hyphens;
}
}
}