Search code examples
c#asp.net-web-apiodata

OData V4 WebAPI routing convention for 'Cast'


Building an ODataController with a base Get method for the following query:

http://localhost:8080/api/Bases

is quite straightforward:

[EnableQuery]
public IHttpActionResult Get()
{
    return Ok(new List<Base>());
}

In the same style, I'm trying to implement the "cast" route ("~/entityset/cast"), which is defined in the OData V4 convention part 4.9 but this is quite undocumented. So I dug into some source code and found that for the following URL:

http://localhost:8080/api/Bases/MyNamespace.DerivedA

I could define the following method in the same controller:

[EnableQuery]
public IHttpActionResult GetFromDerivedA()
{
    return Ok(new List<DerivedA>());
}

Which works BUT I have like a dozen types that inherit from Base. Instead of declaring one method per derived type, is there a way I could use something like:

[EnableQuery]
public IHttpActionResult GetFrom<T>()
    where T : Base
{
    return Ok(new List<T>());
}

I'm using:

  • Microsoft.AspNet.WebApi 5.2.3
  • Microsoft.OData 6.13.0
  • Microsoft.AspNet.OData 5.6.0

Updates

I can create a new RoutingConvention and have the overriden SelectAction return my generic method, but it seems I'll have to forget the generic method approach:

"Cannot call action method 'System.Web.Http.IHttpActionResult GetFrom[T]()' on controller 'MyProject.Controllers.BasesController' because the action method is a generic method."

How about this then, is this possible?

[EnableQuery]
public IHttpActionResult GetFrom(Type derivedType)
{
    //snip!
}

If not, any other ideas?


Solution

  • Here's a way I've been able to accomplish this, with a bit of reflection. It's quite a long way but the resulting controller method is so simple, it's worth it.

    First, create a new RoutingConvention. Notice we'll be forwarding all cast requests to a method named GetFrom :

    public class CastRoutingConvention : EntitySetRoutingConvention
    {
        public override string SelectAction(ODataPath odataPath, HttpControllerContext controllerContext, ILookup<string, HttpActionDescriptor> actionMap)
        {
            if (odataPath.PathTemplate == "~/entityset/cast")
            {
                HttpMethod httpMethod = controllerContext.Request.Method;
                var collectionType = (IEdmCollectionType)odataPath.EdmType;
                var entityType = (IEdmEntityType)collectionType.ElementType.Definition;
    
                var type = AppDomain.CurrentDomain.GetAssemblies()
                    .Where(a => !a.IsDynamic)
                    .SelectMany(a => a.DefinedTypes)
                    .FirstOrDefault(t => t.FullName == entityType.FullTypeName());
    
                controllerContext.RouteData.Values["type"] = type;
    
                if (httpMethod == HttpMethod.Get)
                    return "GetFrom";
                else if (httpMethod == HttpMethod.Post)
                    return "PostFrom";
                else
                    return base.SelectAction(odataPath, controllerContext, actionMap);
            }
            else
                return base.SelectAction(odataPath, controllerContext, actionMap);
        }
    }
    

    Next, add it to the OData configuration:

    public static void Register(HttpConfiguration config)
    {
        config.MapHttpAttributeRoutes();
        var builder = new ODataConventionModelBuilder() { Namespace = "Default" };
        builder.DataServiceVersion = Version.Parse("4.0");
    
        //snip! entity configuration
    
        var conventions = ODataRoutingConventions.CreateDefault();
        conventions.Insert(0, new CastRoutingConvention());
    
        config.MapODataServiceRoute(
            routeName:"ODataRoute", 
            routePrefix: "api",
            routingConventions: conventions,
            pathHandler: new DefaultODataPathHandler(),
            model: builder.GetEdmModel());
    }
    

    Now, because the default model binders will not read arbitrary parameter names from the route data dictionary, we need a custom model binder for route data:

    using System;
    using System.Web.Http.Controllers;
    using System.Web.Http.ModelBinding;
    
    namespace Example
    {
        public class RouteDataModelBinder : IModelBinder
        {
            public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
            {
                object model;
    
                if (!actionContext.RequestContext.RouteData.Values.TryGetValue(bindingContext.ModelName, out model))
                {
                    bindingContext.ModelState.AddModelError(bindingContext.ModelName, $"No route data named '{bindingContext.ModelName}'.");
                    return false;
                }
                else if (!bindingContext.ModelType.IsAssignableFrom(model.GetType()))
                {
                    try
                    {
                        model = Convert.ChangeType(model, bindingContext.ModelType);
                    }
                    catch
                    {
                        bindingContext.ModelState.AddModelError(bindingContext.ModelName, $"Route data cannot be converted to type '{bindingContext.ModelType.FullName}'.");
                        return false;
                    }
                }
    
                bindingContext.Model = model;
                return true;
            }
        }
    
        [AttributeUsage(AttributeTargets.Class | AttributeTargets.Parameter, Inherited = true, AllowMultiple = false)]
        public class RouteDataAttribute : ModelBinderAttribute
        {
            public RouteDataAttribute()
            {
                this.BinderType = typeof(RouteDataModelBinder);
            }
        }
    }
    

    Finally, add the needed method in the controller. Notice how trivial it is:

    [EnableQuery]
    public IHttpActionResult GetFrom([RouteData]Type type)
    {
        var ofType = typeof(Queryable).GetMethod("OfType").MakeGenericMethod(type);
        return Ok((IQueryable<Base>)ofType.Invoke(null, new object[] { this.Context.Bases }));
    }
    

    Since I'm using Entity Framework and I can't use GetType(), I have to use another reflection trick to call OfType<T>() with a Type instance. If you're working with in-memory entities, just scrap the last part and use a plain:

    return Ok(inRamEntities.Where(e => e.GetType() == type));