OData / WebApi

OData Web API: A server library built upon ODataLib and WebApi
https://docs.microsoft.com/odata
Other
853 stars 476 forks source link

ODataRoutePrefix Attribute is not honored as expected #1940

Closed TehWardy closed 1 year ago

TehWardy commented 4 years ago

I am using .Net Core 2.2 as my underlying framework here, so this information might not be inkeeping with how things are currently done (if so please, I welcome the feedback on how to put this right).

I have an Api App that initializes multiple OData contexts that happen to have some crossover in entity set names and thus controller names.

In this case I have a "Core" OData context + model and a "Members" context + model. the net result is that both of these OData models contains a Users entity set with controllers looking like this ...

[ODataRoutePrefix("Members/User")]
public class UserController : MembersEntityODataController<Members.User> {  }

[ODataRoutePrefix("Core/User")]
public class UserController : CoreEntityODataController<Core.User> {  }

... they essentially do the same job but refer to entities stored in the same table but in different databases. I can't seem to for the life of me figure out how to initialize / declare these controllers such that the routing actually works and instead all requests for both ~/Core/User and ~/Members/User result in a members user controller being passed on to handle the request.

My understanding is that this is the exact scenario that the ODataRoutePrefix attribute was built to solve (amongst others) and it doesn't seem to help here.

to make the process of adding new OData models to my API easier I wrapped up the model construction in my own model building but the net result is a core call to use odata during startup which looks like this ...

app.UseMvc(routeBuilder =>
{
    routeBuilder.EnableDependencyInjection();
    routeBuilder.MapRoute("default", "{controller=Home}/{action=Get}");
});

var builders = new ODataModelBuilder[] { 
   new MembersModelBuilder(), 
   new CoreModelBuilder(), 
   new B2BModelBuilder(), 
   new SearchModelBuilder() 
};

foreach (var builder in builders)
{
    var model = builder.Build();
    app.UseOData(model.Context + "Api", builder.GetType().Name.Replace("ModelBuilder", ""), model.EDMModel);
}

Do I need to do something special here to make routing work as intended?

Update

It does seem that this "type of scenario" has been considered before as I'm seeing things like this ... https://github.com/OData/WebApi/issues/1494 ... which talks about API versions ... my case isn't quite this, but it's close enough that I figure the same parts of the framework logic should apply (somewhat).

@xuzhg talks about the solution being to apply the ODataRoute Attribute on the Actions ...

[ODataRoute("Sensors", RouteName = "ODataRouteV1")]
public IActionResult Get()

[ODataRoute("Sensors", RouteName = "ODataRouteV2")]
public IActionResult Get()

... I need to do presumably be able to do the same thing at the controller level but alas this attribute cannot be used on classes only methods.

Obviously i'm sourcing my understanding from this ... https://docs.microsoft.com/en-us/odata/webapi/attribute-routing ... which talks about using the ODataRoutePrefix attribute to apply context to the routing engine for when a controller should be selected.

Have I hit an edge case here ?

TehWardy commented 4 years ago

Having dug a little deeper I think my issue is that I can't map a controller as the handler to a set explicitly.

so when my OData routes are added it says stuff like "there's a CRUD set at Core/User and a CRUD set at Members/User" but there's no way to state "but when resolving a user controller here's how we do it".

I looked at implementing a custom controller factory / controller activator but that's already given a request context which has in it the controller type expected and returning any kind of controller that doesn't match the type expected in the context values results in a casting exception in the framework code.

So I guess what i'm looking for is a means to "custom build the RequestContext from a request" during the pipeline (this feels like a middleware issue but im lost as to where / how).

I have found this ... https://stackoverflow.com/questions/34306891/restrict-route-to-controller-namespace-in-asp-net-core ... which solves the problem I essentially have for non-odata scenarios but virtually all my endpoints are OData based. Could the OData framework somehow be made to honor the Area Attribute as through testing I found it doesn't work?

Or is there a better way to handle this?

TehWardy commented 4 years ago

UPDATE: I don't know if you need this, but it may help you to see what i'm trying to achieve.

To be clear here the issue is not "Routing" or "Action Selection" it's more "ControllerSelection & Construction" that i'm trying to map as was done previously under .Net 4.x with IHttpControllerSelector.

~/Core/User => Build and route to Api.Controllers.Core.UserController
~/Members/User => Build and route to Api.Controllers.Core.UserController

Here's my .Net 4.x implementation ....

using Core.Objects;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Web.Http;
using System.Web.Http.Controllers;
using System.Web.Http.Dispatcher;

namespace Core.Api
{
    public class GenericODataControllerSelector : DefaultHttpControllerSelector, IHttpControllerSelector
    {
        readonly HttpConfiguration config;
        IDictionary<string, HttpControllerDescriptor> mappings;

        /// <summary>
        /// Initializes a new instance of the <see cref="GenericODataControllerSelector" /> class.
        /// </summary>
        /// <param name="configuration">The configuration.</param>
        public GenericODataControllerSelector(HttpConfiguration configuration) : base(configuration)
        {
            config = configuration;
        }

        /// <summary>
        /// Gets a description of the controller to be used to handle the current web request
        /// </summary>
        /// <param name="request">the web request</param>
        /// <returns>controller description</returns>
        public override HttpControllerDescriptor SelectController(HttpRequestMessage request)
        {
            // either use one of my mappings or fallback to the framework default mapping
            var controllerName = GetControllerName(request);
            if (controllerName != null && mappings.ContainsKey(controllerName))
                return mappings[controllerName];

            return base.SelectController(request);
        }

        public override string GetControllerName(HttpRequestMessage request)
        {
           // All OData contexts are mapped by name as the first part of the route, akin to MVC Areas
           // All entity sets inside OData models are direct children of that, so segment 2 in the URL
            var path = request.RequestUri.AbsolutePath.Trim('/').Split('/');
            return path.Length > 1 ? $"{path[0]}/{path[1].Split('(')[0]}" : base.GetControllerName(request);
        }

        public override IDictionary<string, HttpControllerDescriptor> GetControllerMapping()
        {
            // don't expect to hit this catch but it has happens, left in for debugging
            try { mappings = base.GetControllerMapping(); }
            catch (Exception) { /* Hmmm, ok WebApi isn't handling this very well */ }

            // ensure we definetly have a place to put our mappings
            if (mappings == null)
                mappings = new Dictionary<string, HttpControllerDescriptor>();

            // get a list of all known entity types grouped by data context
            // Essentially, the idea is to map a controller to all known entity types
            var entities = GetEntityTypes();

            // this gets a core set of my assemblies that follow a naming convention that hold all my types
            var assemblies = TypeHelper.GetWebStackAssemblies().ToList();
            assemblies.Add(Assembly.GetExecutingAssembly());

            // find controllers, including generic implementations that are the base types for all OData contexts
            var baseSet = assemblies
                .SelectMany(a => a.GetExportedTypes())
                .Where(t => t != typeof(CoreODataController) && typeof(CoreODataController).IsAssignableFrom(t) && t.GetGenericArguments().Length == 0)
                .Select(t => new { Key = t.Name.Replace("Controller", ""), Value = new HttpControllerDescriptor(config, t.Name.Replace("Controller", ""), t) })
                .ToList();

            baseSet.ForEach(m =>
            {
                if (!mappings.ContainsKey(m.Key))
                    mappings.Add(m.Key, m.Value);
            });

            // map the contexts to descriptors
            entities.ForEach(ctx => MapContext(mappings, ctx));
            return mappings;
        }

        /// <summary>
        /// Returns all the types used in Api exposable db contexts
        /// </summary>
        IDictionary<Type, Type[]> GetEntityTypes()
        {
            var entityTypes = new Dictionary<Type, Type[]>();

            foreach (var context in TypeHelper.GetContextTypes())
                entityTypes.Add(context,
                    context.GetProperties()
                        .Where(p => typeof(IQueryable).IsAssignableFrom(p.PropertyType))
                        .Select(p => p.PropertyType.GenericTypeArguments[0])
                        .ToArray()
                );

            return entityTypes;
        }

        /// <summary>
        /// Maps entity types by context (model)
        /// </summary>
        /// <param name="mappings">provided by the base (our stating mapping collection)</param>
        /// <param name="entityTypes">the entity types that context </param>
        void MapContext(IDictionary<string, HttpControllerDescriptor> mappings, KeyValuePair<Type, Type[]> context)
        {
            var prefix = context.Key.Name.Replace("DataContext", "");
            var userType = context.Key.GetProperties().First(p => p.Name == "User").PropertyType;
            var stack = TypeHelper.GetWebStackAssemblies().ToList();
            stack.AddRange(AppDomain.CurrentDomain.GetAssemblies().Where(a => a.GetName().Name == "Api"));

            var contextControllerType = stack
                .SelectMany(a => a.GetTypes().Where(t => typeof(CoreODataController).IsAssignableFrom(t)))
                .FirstOrDefault(t => t.Name.StartsWith($"{prefix}EntityODataController"));

            if (contextControllerType != null)
            {
                context.Value.ForEach(type =>
                {
                    // construct the route that this type should map to
                    var route = $"{prefix}/{type.Name}";

                    /// if no concrete controller type has been found, map the context base controller type for the route
                    if (!mappings.ContainsKey(route))
                    {
                        var controllerType = contextControllerType.MakeGenericType(type, type.GetIdProperty().PropertyType) ?? typeof(EntityODataController<,,>).MakeGenericType(type, type.GetIdProperty().PropertyType, userType);
                        controllerType = stack.SelectMany(a => a.GetTypes().Where(t => t.Name == $"{type.Name}Controller" && controllerType.IsAssignableFrom(t))).FirstOrDefault() ?? controllerType;
                        mappings.Add(route, new HttpControllerDescriptor(config, route, controllerType));
                    }
                });
            }
        }
    }
}

Further explanation of how this works (although i'm not sure if you need it I figure it might help in some way) ...

So I have multiple OData Models and each model is sat on top of an EF DbContext as per the usual Microsoft N-Tier stacking recommendations, with a layer of business logic in the middle.

I first inherit the ODataController type from the framework to create an EntityODataController then for each context I created a {ContextName}entityODataController that inherits from that.

When I need concrete implementation specifics for a given type I then take the context controller and inherit that in to a concrete type like so ...

UserController : MembersEntityODataController { ... } UserController : CoreEntityODataController { ... }

... with the routes for the OData context roots being ~/Members and ~/Core as you might expect, this mapping implementation allows me to pick the context specific generic implementation of my controllers by default if a concrete implementation hasn't been provided.

I am looking to reproduce this behaviour under AspNetCore OData.

TehWardy commented 4 years ago

@xuzhg Hey Sam ... any thoughts on this? Sorry but I really need to get a solution to this ASAP.

KenitoInc commented 1 year ago

Closing this issue due to inactivity. If this issue still persists, feel free to create a new issue.