Search code examples
asp.net-web-apiasp.net-web-api2odataasp.net-web-api-routing

ASP.NET WebApi2 OData handling of queries with slash /


I have made a "standard" Web Api 2 OData project with convention model routing. Following OData queries are working:

/odata/Users

/odata/Users(123)

/odata/$metadata

/odata/Users?$select=Username

So everything seemed to be fine until I tried this, which I think is also a legal OData query:

/odata/Users(123)/Username

Slash / in query breaks everything and it does not hit the controller class and OData authentication flow at all. Should this be supported at all in Microsoft ASP.NET OData implementation? Or is this supported only if I define explicit methods with correct routes for every single property like Username? Any suggestions to fix this? I have tried explicit {*rest} routes etc.


Solution

  • AFAIK, the built-in routing conventions don't include one for property access. You'd be required to add many actions for every property access.

    However, based on this resource here, it's not all that difficult to add a custom routing convention to handle the property access path template: ~/entityset/key/property

    Here's a custom routing convention adapted from the link I shared above

    Assembly used: Microsoft.AspNet.OData 7.4.1 - the approach would be the same for any other OData Web API library you might be using

    Class used for illustration

    public class Product
    {
        [Key]
        public int Id { get; set; }
        public string Name { get; set; }
    }
    

    Add routing convention for property access

    // Usings
    using Microsoft.AspNet.OData.Routing;
    using Microsoft.AspNet.OData.Routing.Conventions;
    using System;
    using System.Linq;
    using System.Web.Http.Controllers;
    // ...
    
    public class CustomPropertyRoutingConvention : NavigationSourceRoutingConvention
    {
        private const string ActionName = "GetProperty";
    
        public override string SelectAction(ODataPath odataPath, HttpControllerContext controllerContext, ILookup<string, HttpActionDescriptor> actionMap)
        {
            if (odataPath == null || controllerContext == null || actionMap == null)
            {
                return null;
            }
    
            if (odataPath.PathTemplate == "~/entityset/key/property" ||
                odataPath.PathTemplate == "~/entityset/key/cast/property" ||
                odataPath.PathTemplate == "~/singleton/property" ||
                odataPath.PathTemplate == "~/singleton/cast/property")
            {
                var segment = odataPath.Segments.OfType<Microsoft.OData.UriParser.PropertySegment>().LastOrDefault();
    
                if (segment != null)
                {
                    string actionName = FindMatchingAction(actionMap, ActionName);
    
                    if (actionName != null)
                    {
                        if (odataPath.PathTemplate.StartsWith("~/entityset/key", StringComparison.Ordinal))
                        {
                            var keySegment = odataPath.Segments.OfType<Microsoft.OData.UriParser.KeySegment>().FirstOrDefault();
                            if (keySegment == null || !keySegment.Keys.Any())
                                throw new InvalidOperationException("This link does not contain a key.");
    
                            controllerContext.RouteData.Values[ODataRouteConstants.Key] = keySegment.Keys.First().Value;
                        }
    
                        controllerContext.RouteData.Values["propertyName"] = segment.Property.Name;
    
                        return actionName;
                    }
                }
            }
    
            return null;
        }
    
        public static string FindMatchingAction(ILookup<string, HttpActionDescriptor> actionMap, params string[] targetActionNames)
        {
            foreach (string targetActionName in targetActionNames)
            {
                if (actionMap.Contains(targetActionName))
                {
                    return targetActionName;
                }
            }
    
            return null;
        }
    }
    

    Add single method in your controller to handle request for any property

    public class ProductsController : ODataController
    {
        // ...
        [HttpGet]
        public IHttpActionResult GetProperty(int key, string propertyName)
        {
            var product = _db.Products.FirstOrDefault(d => d.Id.Equals(key));
            if (product == null)
            {
                return NotFound();
            }
    
            PropertyInfo info = typeof(Product).GetProperty(propertyName);
    
            object value = info.GetValue(product);
    
            return Ok(value, value.GetType());
        }
    
        private IHttpActionResult Ok(object content, Type type)
        {
            var resultType = typeof(OkNegotiatedContentResult<>).MakeGenericType(type);
            return Activator.CreateInstance(resultType, content, this) as IHttpActionResult;
        }
        // ...
    }
    

    In your WebApiConfig.cs (or equivalent place where you configure the service)

    var modelBuilder = new ODataConventionModelBuilder();
    modelBuilder.EntitySet<Product>("Products");
    
    var routingConventions = ODataRoutingConventions.CreateDefaultWithAttributeRouting("odata", configuration);
    routingConventions.Insert(0, new CustomPropertyRoutingConvention());
    
    configuration.MapODataServiceRoute("odata", "odata", modelBuilder.GetEdmModel(), new DefaultODataPathHandler(), routingConventions);
    configuration.Count().Filter().OrderBy().Expand().Select().MaxTop(null);
    configuration.EnsureInitialized();
    

    Request for Name property: /Products(1)/Name

    Request for Id property: /Products(1)/Id