If you're developing WebApi projects in Orchard, you're probably used to declare your API routes as a simple string array. However, we can get fully compile-time checked routes that are also easy to refactor.
If you check the Orchard Docs or any tutorials for setting up API routes you'll quickly end up with a RouteConfig like this:
public
class ApiRoutes : IHttpRouteProvider
{
public IEnumerable<RouteDescriptor> GetRoutes()
{
return new RouteDescriptor[]
{
new HttpRouteDescriptor
{
Name = "Default Api",
Priority = 0,
RouteTemplate = "api/users",
Defaults = new
{
area = "MyCustomModule",
controller = "Users"
}
},
new HttpRouteDescriptor
{
Name = "Default Api",
Priority = 0,
RouteTemplate = "api/users/{id}",
Defaults = new
{
area = "MyCustomModule",
controller = "Users",
id = RouteParameter.Optional
}
},
// etc...
};
}
// ...
}
I have some problems with this approach: It becomes quite
unwieldy pretty soon, it's hard to refactor and easy to
introduce bugs.
If you've ever searched for hours on end why a route isn't
hit, called with wrong or messed up parameters just to
find a dumb typo in your
RouteConfig, you know
what I'm talking about.
So what to do about it?
Initially, I spent some time trying to implement my own
AttributeRouting
in Orchard, but it just didn't go too well. So after a
while I ditched that idea and began from scratch. You can
see the result below:
// C#7 syntax
for brevity
public class ExampleController : ApiController
{
public IHttpActionResult GetList() => this.Ok(new string[] { "value1", "value2" });
public GetDetails(int id) => this.Ok("value");
public IHttpActionResult Post([FromBody]string value) => this.Created("example", value);
public IHttpActionResult Put(int id, [FromBody]string value) => this.Ok();
public IHttpActionResult Delete(int id) => this.Ok();
}
public
class ApiRoutes : IHttpRouteProvider
{
public IEnumerable<RouteDescriptor> GetRoutes()
{
var route = new ApiRouteFactory(Statics.ModuleAreaName, "api");
return new[] {
route.BuildRoute<ExampleController> (x
=> x.GetList, "example"),
route.BuildRoute<ExampleController, int> (x
=> x.GetDetails, "example", "{id:int}"),
route.BuildRoute<ExampleController, string> (x
=> x.Post, "example"),
route.BuildRoute<ExampleController, int, string> (x
=> x.Put, "example", "{id:int}"),
route.BuildRoute<ExampleController, int> (x
=> x.Delete, "example", "{id:int}"),
};
}
//...
}
If you're in a hurry, go grab the source from Github and get back coding. Read on for some details about the implementation.
How it works
Let's start with the call to
BuildRoute and dig
deeper from there.
//
ExampleController.cs
public IHttpActionResult GetDetails(int id) => this.Ok("value");
// ApiRoutes.cs
route.BuildRoute<ExampleController, int> (x
=> x.GetDetails, "example", "{id:int}"),
// ApiRouteFactory.cs
public HttpRouteDescriptor BuildRoute<TController, TParam>(
Expression<Func<TController,
Func<TParam,
IHttpActionResult>>>
expression,
params string[] routeParts)
where TController : ApiController
{
var actionName = ReflectOnMethod<TController>.NameOf(expression);
return this.MakeRouteDescriptor(typeof(TController), string.Join("/", routeParts), actionName);
}
If you're not familiar with
Func<T, TResult>
MSDN explains it pretty
good:
[Func] encapsulates a method that has one parameter and returns a value of the type specified by the TResult parameter.
Therefore, BuildRoutes
first parameter is an expression that takes a controller
as input and expects a function as result, which itself
takes one parameter and returns
IHttpActionResult.
(Yeah, read that again slowly.)
This expression is what brings us all the intellisense,
type safety and refactoring goodness. If we change the
signature of
GetDetails, our module
will no longer compile. The code that makes this possible
resides in
ReflectOnMethod and was
shamelessly stolen from
this
excellent StackOverflow answer. (Thanks kędrzu!)
MakeRouteDescriptor
At this point, all that remains is some string parsing and
building the
HttpRouteDescriptor.
public
HttpRouteDescriptor MakeRouteDescriptor(Type controller, string routeSlug, string action)
{
var route = this.routePrefix +
routeSlug;
var ctrlIndex = controller.Name.LastIndexOf("Controller", StringComparison.Ordinal);
var controllerName = controller.Name.Substring(0, ctrlIndex);
return MakeRouteDescriptor(
this.moduleAreaName,
route,
controllerName,
action,
httpMethod: HttpMethodOfAction(action));
}
public static HttpRouteDescriptor MakeRouteDescriptor(
string area,
string route,
string controller,
string action,
int priority = 5,
HttpMethod httpMethod =
null)
{
var defaults = new ExpandoObject();
var underlyingObject
= (IDictionary<string, object>)defaults;
underlyingObject.Add("area", area);
underlyingObject.Add("controller", controller);
underlyingObject.Add("action", action);
var constraints = ParseRouteAttributeConstraints(route, defaults, httpMethod);
return new HttpRouteDescriptor
{
RouteTemplate = CleanupRoute(route),
Defaults = defaults,
Priority = priority,
Constraints = constraints
};
}
There are two interesting things going on, but first
things first. You're probably asking yourself: WTF is the
ExpandoObject doing
here? Well, in this example absolutely nothing. You could
use a dictionary instead. It just happened that I wrote
some modules which fiddle around with the generated routes
and having an
ExpandoObject makes it
easier to do so.
With that out of the way, let's continue.
HttpMethodOfAction
Maybe you noticed that every action in the
ExampleController
starts with a HttpVerb.
This is the only important thing you'll need to remember
when using the module.
The
BuildRoute<TController>(...)
function will parse the action name for the verb and
assign it to the resulting
RouteDescriptor. Sounds
fancy, but again, there's absolutely no magic going on
there:
// The inner
workings of the ApiRouteFactory
// when determining the HTTP Verb for a controller action.
private static HttpMethod HttpMethodOfAction(string actionName)
{
if (actionName.StartsWith(HttpMethod.Post.Method,
StringComparison.OrdinalIgnoreCase))
return HttpMethod.Post;
if (actionName.StartsWith(HttpMethod.Put.Method,
StringComparison.OrdinalIgnoreCase))
return HttpMethod.Put;
// Yep, there is no official HttpMethod.Patch!
if (actionName.StartsWith("Patch", StringComparison.OrdinalIgnoreCase))
return new HttpMethod("PATCH");
if (actionName.StartsWith(HttpMethod.Delete.Method,
StringComparison.OrdinalIgnoreCase))
return HttpMethod.Delete;
return HttpMethod.Get;
}
So an actionName of
"GetSomething"
will result in
HttpMethod.Get and so
on.
ParseRouteAttributeConstraints
And once again some string parsing. When defining routes,
we can use
RouteContstraints
to narrow down the request that matches a particular
route. So if we define a route like this:
GET "api/example/{id}", any requests that fill in
anything for
the id parameter will match. This may be wanted in some
cases, but most of the time it's a good idea to restrict
parameters to a certain type, like an integer:
GET "api/example/{id:int}".
This is exactly what the following code does. It looks at
all the parts of our route and extracts the
RouteContstraints.
///
<summary>
/// <para>
/// Creates route constraints from the given route.
/// Use the return value as input for
<c>HttpRouteDescriptor.Contraints</c>.
/// </para>
/// <example>
/// "http://example.com/api/users/{id:int}" will create an integer
contstraint for the id parameter.
/// </example>
/// </summary>
/// <param name="route">The absolute route to be
parsed.</param>
/// <param name="defaults">
/// An object that will be filled with all optional parameters.
/// Should be used as value for
<c>HttpRouteDescriptor.Defaults</c>.</param>
/// <param name="httpMethod">The routes
http-verb.</param>
private static dynamic ParseRouteAttributeConstraints(string route, ExpandoObject defaults,
HttpMethod httpMethod =
null)
{
var split = route.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
var attributes = split.Where(x => x.StartsWith("{") && x.EndsWith("}"));
dynamic constraints = new ExpandoObject();
var underlyingObject
= (IDictionary<string, object>)constraints;
foreach (var attr in attributes)
{
var tmpSplit = attr.Trim('{', '}').Split(':');
var key = tmpSplit[0];
if (tmpSplit.Length == 2 && !underlyingObject.ContainsKey(key))
{
var typeName = tmpSplit[1];
var isOptional = typeName.EndsWith("?");
if (isOptional)
{
typeName = typeName.TrimEnd('?');
((IDictionary<string, object>)defaults).Add(key, RouteParameter.Optional);
}
var constraint = GetConstraintForTypename(typeName,
isOptional);
if (constraint != null)
{
underlyingObject.Add(key, constraint);
}
else
{
Debug.WriteLine("No matching IHttpRouteConstraint
found for type '{0}'", typeName);
}
}
}
if (httpMethod != null)
{
underlyingObject.Add("httpMethod", new HttpMethodConstraint(httpMethod,
HttpMethod.Options));
}
return constraints;
}
private static IHttpRouteConstraint GetConstraintForTypename(string typename,
bool isOptional)
{
/* List of all route constraints:
* https://msdn.microsoft.com/en-us/library/system.web.http.routing.constraints(v=vs.118).aspx
*/
IHttpRouteConstraint constraint = null;
switch (typename)
{
case "bool":
case "boolean":
constraint = new BoolRouteConstraint();
break;
case "int":
constraint = new IntRouteConstraint();
break;
case "long":
constraint = new LongRouteConstraint();
break;
case "float":
constraint = new FloatRouteConstraint();
break;
case "double":
constraint = new DoubleRouteConstraint();
break;
case "string":
constraint = new AlphaRouteConstraint();
break;
}
return isOptional ?
new OptionalRouteConstraint(constraint) : constraint;
}
I chose to only implement the most commonly used constraints, but feel free to extend the code above by going through this list on MSDN.
The TLDR version of this code: Take an attribute like
{id:int}, split it into
key = "id"
and
constraint = "int", and update the
RouteConstraints and
RouteDefaults
accordingly.
And that's it!
If you read until this point, thanks!
I'd warmly welcome any comments, ideas, and bug reports.
Just Shoot me a mail. Also, check out the full source on
Github.
Over and out.