Search code examples
c#jsonasp.net-web-apiodataatom-feed

WebApi OData v3 OperationDescriptor returning different Title/Target URI depending on the format (atom vs json)


Consider the following simple ASP.NET Web Api with OData v3.

MyEntity.cs

public class MyEntity
{
    public Guid Id { get; set; }
    public string Name { get; set; }
}

MyEntitiesController.cs

public class MyEntitiesController : ODataController
{
    public IEnumerable<MyEntity> Get()
    {
        return new MyEntity[] { new MyEntity() { Id = Guid.NewGuid(), Name = "Name" } };
    }

    [HttpPost]
    public string MyAction()
    {
        return "Hello World!";
    }
}

WebApiConfig.cs

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        var modelBuilder = new ODataConventionModelBuilder();
        modelBuilder.Namespace = "MyNamespace";
        modelBuilder.ContainerName = "MyContainer";
        modelBuilder.EntitySet<MyEntity>("MyEntities");

        var action = modelBuilder.Entity<MyEntity>().Action("MyAction");
        action.Returns<string>();

        foreach (var structuralType in modelBuilder.StructuralTypes)
        {
            // Resets the namespace so that the service contains only 1 namespace.
            structuralType.GetType().GetProperty("Namespace").SetValue(structuralType, "MyNamespace");
        }

        var model = modelBuilder.GetEdmModel();
        config.Routes.MapODataServiceRoute("OData", "odata", model);
    }
}

On the client side, I added a simple Service Reference.

Program.cs

class Program
{
    static void Main(string[] args)
    {
        var contextAtom = new MyContainer(new Uri("http://localhost:63939/odata/"));
        contextAtom.Format.UseAtom();
        var myEntityAtom = contextAtom.MyEntities.First();

        // Outputs: http://localhost:63939/odata/MyEntities(guid'2c2431cd-4afa-422b-805b-8398b9a29fec')/MyAction
        var uriAtom = contextAtom.GetEntityDescriptor(myEntityAtom).OperationDescriptors.First().Target;
        Console.WriteLine(uriAtom);

        // Works fine using ATOM format!
        var responseAtom = contextAtom.Execute<string>(uriAtom, "POST", true);

        var contextJson = new MyContainer(new Uri("http://localhost:63939/odata/"));
        contextJson.Format.UseJson();
        var myEntityJson = contextJson.MyEntities.First();

        // Outputs: http://localhost:63939/odata/MyEntities(guid'f31a8332-025b-4dc9-9bd1-27437ae7966a')/MyContainer.MyAction
        var uriJson = contextJson.GetEntityDescriptor(myEntityJson).OperationDescriptors.First().Target;
        Console.WriteLine(uriJson);

        // Throws an exception using the JSON uri in JSON format!
        var responseJson = contextJson.Execute<string>(uriJson, "POST", true);

        // Works fine using ATOM uri in JSON format!
        var responseJson2 = contextJson.Execute<string>(uriAtom, "POST", true);
    }
}

My issue is that depending on the format used to query the entity, the operation descriptor target URI is different. The target URI coming from ATOM works fine, but the one coming from JSON always throws an exception.

Instead of manually concatenating the URI, is there a way to have operation descriptors working when using both formats (ATOM and JSON)?

Note that I'm experiencing the same issue with OData v4, but getting MyNamespace.MyAction as the Title and Target URI instead of MyContainer.MyAction.


Solution

  • As of today, the issue in the OData/odata.net github has been assigned to someone but there's still no news.

    I decided to write a custom OData path handler to support JSON action names. It's working "for me" with the following OData path templates: ~/action, ~/entityset/key/action and ~/entityset/action.

    CustomODataPathHandler.cs

    internal class CustomODataPathHandler : DefaultODataPathHandler
    {
        #region Methods
    
        protected override ODataPathSegment ParseAtEntityCollection(IEdmModel model, ODataPathSegment previous, IEdmType previousEdmType, string segment)
        {
            ODataPathSegment customActionPathSegment;
            if (TryParseCustomAction(model, previousEdmType, segment, out customActionPathSegment))
            {
                return customActionPathSegment;
            }
    
            return base.ParseAtEntityCollection(model, previous, previousEdmType, segment);
        }
    
        protected override ODataPathSegment ParseAtEntity(IEdmModel model, ODataPathSegment previous, IEdmType previousEdmType, string segment)
        {
            ODataPathSegment customActionPathSegment;
            if (TryParseCustomAction(model, previousEdmType, segment, out customActionPathSegment))
            {
                return customActionPathSegment;
            }
    
            return base.ParseAtEntity(model, previous, previousEdmType, segment);
        }
    
        protected override ODataPathSegment ParseEntrySegment(IEdmModel model, string segment)
        {
            var container = model.EntityContainers().First();
            if (CouldBeCustomAction(container, segment))
            {
                ODataPathSegment customActionPathSegment;
                if (TryParseCustomAction(model, segment, out customActionPathSegment))
                {
                    return customActionPathSegment;
                }
            }
    
            return base.ParseEntrySegment(model, segment);
        }
    
        private static bool TryParseCustomAction(IEdmModel model, IEdmType previousEdmType, string segment, out ODataPathSegment pathSegment)
        {
            var container = model.EntityContainers().First();
            if (CouldBeCustomAction(container, segment))
            {
                var actionName = segment.Split('.').Last();
                var action = (from f in container.FindFunctionImports(actionName)
                              let parameters = f.Parameters
                              where parameters.Count() >= 1 && parameters.First().Type.Definition.IsEquivalentTo(previousEdmType)
                              select f).FirstOrDefault();
    
                if (action != null)
                {
                    pathSegment = new ActionPathSegment(action);
                    return true;
                }
            }
    
            pathSegment = null;
            return false;
        }
    
        private static bool TryParseCustomAction(IEdmModel model, string segment, out ODataPathSegment pathSegment)
        {
            var container = model.EntityContainers().First();
            if (CouldBeCustomAction(container, segment))
            {
                var actionName = segment.Split('.').Last();
                var action = (from f in container.FindFunctionImports(actionName)
                              where f.EntitySet == null && !f.IsBindable
                              select f).FirstOrDefault();
    
                if (action != null)
                {
                    pathSegment = new ActionPathSegment(action);
                    return true;
                }
            }
    
            pathSegment = null;
            return false;
        }
    
        private static bool CouldBeCustomAction(IEdmEntityContainer container, string segment)
        {
            return segment.StartsWith(container.Name + ".", StringComparison.OrdinalIgnoreCase);
        }
    
        #endregion
    }
    

    Note that since JSON action names contain a dot ".", I had to add a handler in the web.config (to avoid conflicting with the static file handler):

    web.config

    <system.webServer>
      <handlers>
        <add name="UrlRoutingHandler" path="odata/*" verb="*" type="System.Web.Routing.UrlRoutingHandler, System.Web, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
      </handlers>
    </system.webServer>
    

    Also, the WebApiConfig changed to use the custom OData path handler:

    WebApiConfig.cs

    public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            var modelBuilder = new ODataConventionModelBuilder();
            modelBuilder.Namespace = "MyNamespace";
            modelBuilder.ContainerName = "MyContainer";
            modelBuilder.EntitySet<MyEntity>("MyEntities");
    
            var action = modelBuilder.Entity<MyEntity>().Action("MyAction");
            action.Returns<string>();
    
            modelBuilder.Action("Test");
    
            foreach (var structuralType in modelBuilder.StructuralTypes)
            {
                // Resets the namespace so that the service contains only 1 namespace.
                structuralType.GetType().GetProperty("Namespace").SetValue(structuralType, "MyNamespace");
            }
    
            var model = modelBuilder.GetEdmModel();
            config.Routes.MapODataServiceRoute("OData", "odata", model, new CustomODataPathHandler(), ODataRoutingConventions.CreateDefault());
        }
    }