Search code examples
asp.net-mvcasp.net-web-apiodata

ASP.NET Web API OData: Navigation Links when using Composite Keys


The OData questions keep coming :)

I have an Entity with a composite key, like this one:

public class Entity
{
    public virtual Int32  FirstId  { get; set; }
    public virtual Guid   SecondId { get; set; }
    public virtual First  First    { get; set; }
    public virtual Second Second   { get; set; }
}

I created a CompositeKeyRoutingConvention that handles the composite keys for ODataControllers. Everything is working, except Navigation Links like this one:

http://localhost:51590/odata/Entities(FirstId=1,SecondId=guid'...')/First

I get the following error message in Firefox:

<?xml version="1.0" encoding="utf-8"?>
<m:error xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata">
  <m:code />
  <m:message xml:lang="en-US">No HTTP resource was found that matches the request URI 'http://localhost:51950/odata/Entities(FirstId=1,SecondId=guid'a344b92f-55dc-45aa-b92f-271d74643493')/First'.</m:message>
  <m:innererror>
    <m:message>No action was found on the controller 'Entities' that matches the request.</m:message>
    <m:type></m:type>
    <m:stacktrace></m:stacktrace>
  </m:innererror>
</m:error>

I traced the error message in the ASP.NET source code to the FindMatchingActions method in the ApiControllerActionSelector returning an empty list, but my knowledge of ASP.NET ends there.

For reference, this is the implementation of the navigation link action method (in an ODataController):

public First GetFirst(
    [FromODataUri(Name = "FirstId")] Int32 firstId, 
    [FromODataUri(Name = "SecondId")] Guid secondId)
{
    var entity = repo.Find(firstId, secondId);
    if (entity == null) throw new HttpResponseException(HttpStatusCode.NotFound);
    return entity.First;
}

I tried not setting a name at the FromODataUri attribute, setting a lowercase name, everything sensible I could think of. The only thing I noticed is when using a regular EntitySetController is that the arguments for the key value have to be named key (or the FromODataUri attribute has to have the Name property set to key), otherwise it won't work. I wonder if something like this is the case here as well...


Solution

  • I found what was missing:

    In addition to a custom EntityRoutingConvention, you will need a custom NavigationRoutingConvention.

    type CompositeKeyNavigationRoutingConvention () =
        inherit NavigationRoutingConvention ()
    
        override this.SelectAction (odataPath, controllerContext, actionMap) =
            match base.SelectAction (odataPath, controllerContext, actionMap) with
            | null -> null
            | action ->
                let routeValues = controllerContext.RouteData.Values
                match routeValues.TryGetValue ODataRouteConstants.Key with
                | true, (:? String as keyRaw) ->
                    keyRaw.Split ','
                    |> Seq.iter (fun compoundKeyPair ->
                        match compoundKeyPair.Split ([| '=' |], 2) with
                        | [| keyName; keyValue |] ->
                            routeValues.Add (keyName.Trim (), keyValue.Trim ())
                        | _ -> ()
                    )
                | _ -> ()
                action
    

    And just add this to the front of the conventions like the custom EntityRoutingConvention. Done :)


    Update for the comment below:

    You have to implement your own NavigationRoutingConvention that overrides the SelectAction method and splits the composite keys inside the controller context into key and values. Then you have to add them to the route values yourself.

    Finally, in the configuration, where you call MapODDataRoute with your custom EntityRoutingConvention already, add the new NavigationRoutingConvention to the list of conventions.

    NavigationRoutingConvention in C#:

    public class CompositeKeyNavigationRoutingConvention : NavigationRoutingConvention
    {
        public override String SelectAction(System.Web.OData.Routing.ODataPath odataPath, HttpControllerContext controllerContext, ILookup<String, HttpActionDescriptor> actionMap)
        {
            String action = base.SelectAction(odataPath, controllerContext, actionMap);
    
            // Only look for a composite key if an action could be selected.
            if (action != null)
            {
                var routeValues = controllerContext.RouteData.Values;
    
                // Try getting the OData key from the route values (looks like: "key1=value1,key2=value2,...").
                Object keyRaw;
                if (routeValues.TryGetValue(ODataRouteConstants.Key, out keyRaw))
                {
                    // Split the composite key into key/value pairs (like: "key=value").
                    foreach (var compoundKeyPair in ((String)keyRaw).Split(','))
                    {
                        // Split the key/value pair into its components.
                        var compoundKeyArray = compoundKeyPair.Split(new[] { '=' }, 2);
                        if (compoundKeyArray.Length == 2)
                            // Add the key and value of the composite key to the route values.
                            routeValues.Add(compoundKeyArray[0].Trim(), compoundKeyArray[1].Trim());
                    }
                }
            }
    
            return action;
        }
    }
    

    Finally, you have to add it to the OData route (presumably in App_Start/WebApiConfig.cs), where you already added the EntityRoutingConvention.