goroth
goroth

Reputation: 2610

OData service with multiple routes while using unbound functions

Does anyone know how to get OData v4 hosted in a .NET service to work with multiple routes?

I have the following:

config.MapODataServiceRoute("test1", "test1", GetEdmModelTest1());
config.MapODataServiceRoute("test2", "test2", GetEdmModelTest2());

Each of the GetEdmModel methods have mapped objects.
I can get to the service as following (this is working fine):

http://testing.com/test1/objects1()
http://testing.com/test2/objects2()

But if I try to call a function like the following (will not work):

[HttpGet]
[ODataRoute("test1/TestFunction1()")]
public int TestFunction1()
{ return 1; }

It will throw the following error:

The path template 'test1/TestFunction1()' on the action 'TestFunction1' in controller 'Testing' is not a valid OData path template. Resource not found for the segment 'test1'.

Yet if I remove the "MapODataServiceRoute" for "test2" so there is only one route, it all works.

How do I get this to work with multiple routes?

** I have posted a full example of the issue at the following **
https://github.com/OData/WebApi/issues/1223

** I have tried the OData version sample listed below with the following issues **
https://github.com/OData/ODataSamples/tree/master/WebApi/v4/ODataVersioningSample
I have tried the "OData Version" example before and it did not work. It seems that unbound (unbound is the goal) does not follow the same routing rules are normal service calls.

Ex. If you download the "OData Version" example and do the following.

  1. In V1 -> WebApiConfig.cs add
    builder.Function(nameof(Controller.ProductsV1Controller.Test)).Returns<string>();
  2. In V2 -> WebApiConfig.cs add
    builder.Function(nameof(Controller.ProductsV2Controller.Test)).Returns<string>();
  3. In V1 -> ProductsV1Controller.cs add
    [HttpGet] [ODataRoute("Test()")] public string Test() { return "V1_Test"; }
  4. In V2 -> ProductsV2Controller.cs add
    [HttpGet] [ODataRoute("Test()")] public string Test() { return "V2_Test"; }

Now call it by this. " /versionbyroute/v1/Test() " and you will get "V2_Test"

The problem is that "GetControllerName" does not know how to get the controller when it is using unbound functions / actions.
This is why most sample code I have found fails when trying to "infer" the controller.

Upvotes: 5

Views: 3676

Answers (2)

Pynt
Pynt

Reputation: 2288

You can use a Custsom MapODataServiceRoute. The below is an example from WebApiConfig.cs

The controllers are registered with the CustomMapODataServiceRoute and its a bit cumbersome having to include typeof(NameOfController) for every controller. One of my endpoints has 22 separate controllers, but thus far it's worked.

Registering Controllers - Showing two separate OData endpoints in the same project, both containing custom functions

        // Continuing Education
        ODataConventionModelBuilder continuingEdBuilder = new ODataConventionModelBuilder();
        continuingEdBuilder.Namespace = "db_api.Models";
        var continuingEdGetCourse = continuingEdBuilder.Function("GetCourse");
        continuingEdGetCourse.Parameter<string>("term_code");
        continuingEdGetCourse.Parameter<string>("ssts_code");
        continuingEdGetCourse.Parameter<string>("ptrm_code");
        continuingEdGetCourse.Parameter<string>("subj_code_prefix");
        continuingEdGetCourse.Parameter<string>("crn");
        continuingEdGetCourse.ReturnsCollectionFromEntitySet<ContinuingEducationCoursesDTO>("ContinuingEducationCourseDTO");
        config.CustomMapODataServiceRoute(
            routeName: "odata - Continuing Education",
            routePrefix: "contEd",
            model: continuingEdBuilder.GetEdmModel(),
            controllers: new[] { typeof(ContinuingEducationController) }
            );

    // Active Directory OData Endpoint
    ODataConventionModelBuilder adBuilder = new ODataConventionModelBuilder();
        adBuilder.Namespace = "db_api.Models";
        // CMS Groups
        var cmsGroupFunc = adBuilder.Function("GetCMSGroups");
        cmsGroupFunc.Parameter<string>("user");
        cmsGroupFunc.ReturnsCollectionFromEntitySet<GenericValue>("GenericValue");
        // Departments
        var deptUsersFunc = adBuilder.Function("GetADDepartmentUsers");
        deptUsersFunc.Parameter<string>("department");
        deptUsersFunc.ReturnsCollectionFromEntitySet<ADUser>("ADUser");
        var adUsersFunc = adBuilder.Function("GetADUser");
        adUsersFunc.Parameter<string>("name");
        adUsersFunc.ReturnsCollectionFromEntitySet<ADUser>("ADUser");
        var deptFunc = adBuilder.Function("GetADDepartments");
        deptFunc.ReturnsCollectionFromEntitySet<GenericValue>("GenericValue");
        var instDeptFunc = adBuilder.Function("GetADInstructorDepartments");
        instDeptFunc.ReturnsCollectionFromEntitySet<GenericValue>("GenericValue");
        var adTitleFunc = adBuilder.Function("GetADTitles");
        adTitleFunc.ReturnsCollectionFromEntitySet<GenericValue>("GenericValue");
        var adOfficeFunc = adBuilder.Function("GetADOffices");
        adOfficeFunc.ReturnsCollectionFromEntitySet<GenericValue>("GenericValue");
        var adDistListFunc = adBuilder.Function("GetADDistributionLists");
        adDistListFunc.ReturnsCollectionFromEntitySet<GenericValue>("GenericValue");
        config.CustomMapODataServiceRoute(
            routeName: "odata - Active Directory",
            routePrefix: "ad",
            model: adBuilder.GetEdmModel(),
            controllers: new[] { typeof(DepartmentsController), typeof(CMSGroupsController)
            });

Creating Custom Map OData Service Route

public static class HttpConfigExt
{
    public static System.Web.OData.Routing.ODataRoute CustomMapODataServiceRoute(this HttpConfiguration configuration, string routeName,
        string routePrefix, Microsoft.OData.Edm.IEdmModel model, IEnumerable<Type> controllers)
    {            
        var routingConventions = ODataRoutingConventions.CreateDefault();

        // Multiple Controllers with Multiple Custom Functions
        routingConventions.Insert(0, new CustomAttributeRoutingConvention(routeName, configuration, controllers));

        // Custom Composite Key Convention
        //routingConventions.Insert(1, new CompositeKeyRoutingConvention());

        return configuration.MapODataServiceRoute(routeName, 
                                                  routePrefix, 
                                                  model, 
                                                  new System.Web.OData.Routing.DefaultODataPathHandler(), 
                                                  routingConventions,
                                                  defaultHandler: System.Net.Http.HttpClientFactory.CreatePipeline( innerHandler: new System.Web.Http.Dispatcher.HttpControllerDispatcher(configuration), 
                                                                                                                    handlers: new[] { new System.Web.OData.ODataNullValueMessageHandler() }));
    }
}

public class CustomAttributeRoutingConvention : AttributeRoutingConvention
{
    private readonly List<Type> _controllers = new List<Type> { typeof(System.Web.OData.MetadataController) };

    public CustomAttributeRoutingConvention(string routeName, HttpConfiguration configuration, IEnumerable<Type> controllers)
        : base(routeName, configuration)
    {
        _controllers.AddRange(controllers);
    }

    public override bool ShouldMapController(System.Web.Http.Controllers.HttpControllerDescriptor controller)
    {
        return _controllers.Contains(controller.ControllerType);
    }
}

Upvotes: 1

Chris Schaller
Chris Schaller

Reputation: 16574

Have a look at OData Versioning Sample for a primer.

The key point of trouble is usually that the DefaultHttpControllerSelector maps controllers by local name, not fullname/namespace.

If your entity types and therefore controller names are unique across both EdmModels you will not have to do anything special, it should just work out of the box. The above sample takes advantage of this concept by forcing you to inject a string value into the physical names of the controller classes to make them unique and then in the ODataVersionControllerSelector GetControllerName is overridden to maps the incoming route to the customised controller names

If unique names for the controllers seems to hard, and you would prefer to use the full namespace (meaning your controller names logic remains standard) then you can of course implement your own logic to select the specific controller class instance when overriding DefaultHttpControllerSelector. simply override SelectController instead. This method will need to return an instance of HttpControllerDescriptor which is a bit more involved than the sample.

To show you what I mean, I will post the solution to a requirement from an older project, that was a little bit different to yours. I have a single WebAPI project that manages access to multiple databases, these databases have similar schema, many Entity names are the same which means that those controller classes will have the same names. The controllers are structured by folders/namespaces such that there is a root folder called DB, then there is a folder for each database, then the controllers are in there.

enter image description here

You can see that this project has many different schemas, they effectively map to versions of an evolving solution, the non-DB namespaces in this image are a mix of OData v4, v3 and standard REST apis. It is possible to get all these beasts to co-exist ;)

This override of the HttpControllerSelector inspects the runtime once to cache a list of all the controller classes, then maps the incoming route requests by matching the route prefix to the correct controller class.

/// <summary>
/// Customised controller for intercepting traffic for the DB Odata feeds.
/// Any route that is not prefixed with ~/DB/ will not be intercepted or processed via this controller
/// <remarks>Will instead be directed to the base class</remarks>
/// </summary>
public class DBODataHttpControllerSelector : DefaultHttpControllerSelector
{
    private readonly HttpConfiguration _configuration;

    public DBODataHttpControllerSelector(HttpConfiguration config)
        : base(config)
    {
        _configuration = config;
    }

    // From: http://www.codeproject.com/Articles/741326/Introduction-to-Web-API-Versioning
    private Dictionary<string, HttpControllerDescriptor> _controllerMap = null;
    private List<string> _duplicates = new List<string>();
    /// <summary>
    /// Because we are interested in supporting nested namespaces similar to MVC "Area"s we need to
    /// Index our available controller classes by the potential url segments that might be passed in
    /// </summary>
    /// <returns></returns>
    private Dictionary<string, HttpControllerDescriptor> InitializeControllerDictionary()
    {
        if(_controllerMap != null)
            return _controllerMap;

        _controllerMap = new Dictionary<string, HttpControllerDescriptor>(StringComparer.OrdinalIgnoreCase);

        // Create a lookup table where key is "namespace.controller". The value of "namespace" is the last
        // segment of the full namespace. For example:
        // MyApplication.Controllers.V1.ProductsController => "V1.Products"
        IAssembliesResolver assembliesResolver = _configuration.Services.GetAssembliesResolver();
        IHttpControllerTypeResolver controllersResolver = _configuration.Services.GetHttpControllerTypeResolver();

        ICollection<Type> controllerTypes = controllersResolver.GetControllerTypes(assembliesResolver);

        foreach (Type t in controllerTypes)
        {
            var segments = t.Namespace.Split(Type.Delimiter);

            // For the dictionary key, strip "Controller" from the end of the type name.
            // This matches the behavior of DefaultHttpControllerSelector.
            var controllerName = t.Name.Remove(t.Name.Length - DefaultHttpControllerSelector.ControllerSuffix.Length);

            var key = String.Format(CultureInfo.InvariantCulture, "{0}.{1}.{2}", segments[segments.Length - 2], segments[segments.Length - 1], controllerName);

            // Check for duplicate keys.
            if (_controllerMap.Keys.Contains(key))
            {
                _duplicates.Add(key);
            }
            else
            {
                _controllerMap[key] = new HttpControllerDescriptor(_configuration, t.Name, t);  
            }
        }

        // Remove any duplicates from the dictionary, because these create ambiguous matches. 
        // For example, "Foo.V1.ProductsController" and "Bar.V1.ProductsController" both map to "v1.products".
        // CS: Ahem... thats why I've opted to go 3 levels of depth to key name, but this still applies if the duplicates are there again
        foreach (string s in _duplicates)
        {
            _controllerMap.Remove(s);
        }
        return _controllerMap;
    }
    /// <summary>
    /// Because we are interested in supporting nested namespaces we want the full route
    /// to match to the full namespace (or at least the right part of it)
    /// </summary>
    /// <returns></returns>
    private Dictionary<string, HttpControllerDescriptor> _fullControllerMap = null;
    private Dictionary<string, HttpControllerDescriptor> InitializeFullControllerDictionary()
    {
        if(_fullControllerMap != null)
            return _fullControllerMap;

        _fullControllerMap = new Dictionary<string, HttpControllerDescriptor>(StringComparer.OrdinalIgnoreCase);

        // Create a lookup table where key is "namespace.controller". The value of "namespace" is the last
        // segment of the full namespace. For example:
        // MyApplication.Controllers.V1.ProductsController => "V1.Products"
        IAssembliesResolver assembliesResolver = _configuration.Services.GetAssembliesResolver();
        IHttpControllerTypeResolver controllersResolver = _configuration.Services.GetHttpControllerTypeResolver();

        ICollection<Type> controllerTypes = controllersResolver.GetControllerTypes(assembliesResolver);

        foreach (Type t in controllerTypes)
        {
            var segments = t.Namespace.Split(Type.Delimiter);

            // For the dictionary key, strip "Controller" from the end of the type name.
            // This matches the behavior of DefaultHttpControllerSelector.
            var controllerName = t.Name.Remove(t.Name.Length - DefaultHttpControllerSelector.ControllerSuffix.Length);

            var key = t.FullName;// t.Namespace + "." + controllerName;
            _fullControllerMap[key] = new HttpControllerDescriptor(_configuration, t.Name, t);  
        }

        return _fullControllerMap;
    }

    /// <summary>
    /// Select the controllers with a simulated MVC area sort of functionality, but only for the ~/DB/ route
    /// </summary>
    /// <param name="request"></param>
    /// <returns></returns>
    public override System.Web.Http.Controllers.HttpControllerDescriptor SelectController(System.Net.Http.HttpRequestMessage request)
    {
        string rootPath = "db";
        IHttpRouteData routeData = request.GetRouteData();
        string[] uriSegments = request.RequestUri.LocalPath.Split('/');
        if (uriSegments.First().ToLower() == rootPath || uriSegments[1].ToLower() == rootPath)
        {
            #region DB Route Selector
            // If we can find a known api and a controller, then redirect to the correct controller
            // Otherwise allow the standard select to work
            string[] knownApis = new string[] { "tms", "srg", "cumulus" };


            // Get variables from the route data.
            /* support version like this:
             * config.Routes.MapODataRoute(
                routeName: "ODataDefault",
                routePrefix: "{version}/{area}/{controller}",     
                model: model);
            object versionName = null;
            routeData.Values.TryGetValue("version", out versionName);

            object apiName = null;
            routeData.Values.TryGetValue("api", out apiName);

            object controllerName = null;
            routeData.Values.TryGetValue("controller", out controllerName);
             * */

            // CS: we'll just use the local path AFTER the root path
            // db/tms/contact
            // db/srg/contact
            // Implicity parse this as
            // db/{api}/{controller}
            // so [0] = ""
            // so [1] = "api"
            // so [2] = "version" (optional)
            // so [2 or 3] = "controller"

            if (uriSegments.Length > 3)
            {
                string apiName = uriSegments[2];
                if (knownApis.Contains(string.Format("{0}", apiName).ToLower()))
                {
                    string version = "";
                    string controllerName = uriSegments[3];
                    if (controllerName.ToLower().StartsWith("v")
                        // and the rest of the name is numeric
                        && !controllerName.Skip(1).Any(c => !Char.IsNumber(c))
                        )
                    {
                        version = controllerName;
                        controllerName = uriSegments[4];
                    }

                    // if the route has an OData item selector (#) then this needs to be trimmed from the end.
                    if (controllerName.Contains('('))
                        controllerName = controllerName.Substring(0, controllerName.IndexOf('('));

                    string fullName = string.Format(CultureInfo.InvariantCulture, "{0}.{1}.{2}", apiName, version, controllerName).Replace("..", ".");


                    // Search for the controller.
                    // _controllerTypes is a list of HttpControllerDescriptors
                    var descriptors = InitializeControllerDictionary().Where(t => t.Key.EndsWith(fullName, StringComparison.OrdinalIgnoreCase)).ToList();
                    if (descriptors.Any())
                    {
                        var descriptor = descriptors.First().Value;
                        if (descriptors.Count > 1)
                        {
                            descriptor = null;
                            // Assume that the version was missing, and we have implemented versioning for that controller
                            // If there is a row with no versioning, so no v1, v2... then use that
                            // if all rows are versioned, use the highest version
                            if (descriptors.Count(d => d.Key.Split('.').Length == 2) == 1)
                                descriptor = descriptors.First(d => d.Key.Split('.').Length == 2).Value;
                            else if (descriptors.Count(d => d.Key.Split('.').Length > 2) == descriptors.Count())
                                descriptor = descriptors
                                    .Where(d => d.Key.Split('.').Length > 2)
                                    .OrderByDescending(d => d.Key.Split('.')[1])
                                    .First().Value;
                            if (descriptor == null)
                                throw new HttpResponseException(
                                                    request.CreateErrorResponse(HttpStatusCode.InternalServerError,
                                                    "Multiple controllers were found that match this un-versioned request."));
                        }
                        if (descriptor != null)
                            return descriptor;
                    }

                    if (_duplicates.Any(d => d.ToLower() == fullName.ToLower()))
                        throw new HttpResponseException(
                                            request.CreateErrorResponse(HttpStatusCode.InternalServerError,
                                            "Multiple controllers were found that match this request."));
                }
            }
            #endregion DB Route Selector
        }
        else
        {
            // match on class names that match the route.
            // So if the route is odata.tms.testController
            // Then the class name must also match
            // Add in an option to doing a string mapping, so that
            // route otms can mapp to odata.tms

            // TODO: add any other custom logic for selecting the controller that you want, alternatively try this style syntax in your route config:
            //routes.MapRoute(
            //    name: "Default",
            //    url: "{controller}/{action}/{id}",
            //    defaults: new { controller = "Home", action = "RegisterNow", id = UrlParameter.Optional },
            //    namespaces: new[] { "YourCompany.Controllers" }
            //);

            // Because controller path mapping might be controller/navigationproperty/action
            // We need to check for the following matches:
            // controller.navigationproperty.actionController
            // controller.navigationpropertyController
            // controllerController

            string searchPath = string.Join(".", uriSegments).ToLower().Split('(')[0] + "controller";
            var descriptors = InitializeFullControllerDictionary().Where(t => t.Key.ToLower().Contains(searchPath)).ToList();
            if (descriptors.Any())
            {
                var descriptor = descriptors.First().Value;
                if (descriptors.Count > 1)
                {
                    descriptor = null;
                    // In this mode, I think we should only ever have a single match, ready to prove me wrong?
                    if (descriptor == null)
                        throw new HttpResponseException(
                                            request.CreateErrorResponse(HttpStatusCode.InternalServerError,
                                            "Multiple controllers were found that match this namespace request."));
                }
                if (descriptor != null)
                    return descriptor;
            }

        }
        return base.SelectController(request);
    }

}

Upvotes: 1

Related Questions