Closed TehWardy closed 1 year 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?
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
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
... 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.
@xuzhg Hey Sam ... any thoughts on this? Sorry but I really need to get a solution to this ASAP.
Closing this issue due to inactivity. If this issue still persists, feel free to create a new issue.
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 ...
... 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 ...
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 ...
... 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 ?