Search code examples
c#.netasp.net-mvcasp.net-coreodata

OData Navigation-Routing for a Generic-Controller with ODataQueryOptions-support


I have one generic-controller (similar to this: .Net Core Override Controller Route for Generic Controller) which registers generic implementations for all dynamic types, I have.

This works very well. But while trying to implement the support navigation-routing with additional filter-values I have some issues. This example:

http://localhost/odata/EntityA(4711)/SubEntity?$filter=category eq 'ABC'

works theoretically, but I need to extract the ODataQueryOptions.

So this is what I have so far:

ExternalControllerFeatureProvider

public class ExternalControllerFeatureProvider : IApplicationFeatureProvider<ControllerFeature>
{
    public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature)
    {
        foreach (var candidate in _entityCompiler.GetTypes())
        {
            feature.Controllers.Add(
                typeof(GenericController<>).MakeGenericType(candidate).GetTypeInfo()
            );
        }
    }
}

GenericController

[Produces("application/json")]
[GenericControllerNameConvention]
[EnableQuery]
public class GenericController<T> : ODataController
{
    public async Task<IQueryable<T>> Get([FromServices] ODataQueryOptions odataQueryOptions)
    {
        var parameters = ExtractQueryParameter(odataQueryOptions);

        return await InternalGet(parameters);
    }

    public async Task<IQueryable<T>> Get([FromServices] ODataQueryOptions odataQueryOptions, [FromODataUri] object key)
    {
        var parameters = ExtractQueryParameter(odataQueryOptions);
        AppendKeyAttributeFilter(parameters, key);

        return await InternalGet(parameters);
    }

    public async Task<IActionResult> GetNavigation(Guid key, string propertyName)
    {
        var parameters = new Dictionary<string, object>();
        AppendKeyAttributeFilter(parameters, key);
        AppendExpandFilter(parameters, propertyName);

        var rootObject = await InternalGet(parameters);

        if (rootObject.Any())
        {
            var info = typeof(T).GetProperty(propertyName);

            object value = info.GetValue(rootObject.FirstOrDefault());

            return Ok(value);
        }

        return NotFound();
    }

Similar to this (http://odata.github.io/WebApi/03-04-custom-routing-convention/) I created a NavigationRoutingConvention, which extracts the navigation-property and calls the GetNavigation-method from the GenericController with the correct propertyName.

The problem is that this GenericController-method can not return IQueryable nor IEnumerable, but only some untyped types like IActionResult.

In order to manually filter my datasource in the backend I need the ODataQueryOptions, like in the both Get-methods. The problem is that it seems that the underleying framework needs to know the correct returned type.

If I add [FromServices] ODataQueryOptions to the method-head I get following exception:

System.InvalidOperationException: Cannot create an EDM model as the action 'GetNavigation' on controller 'EntityA' has a return type 'System.Threading.Tasks.Task`1[[Microsoft.AspNetCore.Mvc.IActionResult, Microsoft.AspNetCore.Mvc.Abstractions, Version=2.1.1.0, Culture=neutral, PublicKeyToken=adb9793829ddae60]]' that does not implement IEnumerable. at Microsoft.AspNet.OData.ODataQueryParameterBindingAttribute.ODataQueryParameterBinding.GetEntityClrTypeFromActionReturnType(ActionDescriptor actionDescriptor) at Microsoft.AspNet.OData.ODataQueryParameterBindingAttribute.ODataQueryParameterBinding.BindModelAsync(ModelBindingContext bindingContext) at Microsoft.AspNetCore.Mvc.ModelBinding.Binders.BinderTypeModelBinder.BindModelAsync(ModelBindingContext bindingContext) at Microsoft.AspNetCore.Mvc.ModelBinding.ParameterBinder.BindModelAsync(ActionContext actionContext, IModelBinder modelBinder, IValueProvider valueProvider, ParameterDescriptor parameter, ModelMetadata metadata, Object value)
at Microsoft.AspNetCore.Mvc.Internal.ControllerBinderDelegateProvider.<>c__DisplayClass0_0.<g__Bind|0>d.MoveNext()


Solution

  • So I found the solution. I have abstained from the idea of my own routing convention and added a further Generic-Controller especially for sub-navigation properties. Below the abstract not working code, cleaned by some private parts... :-)

    GenericSubNavigationController

    [Produces("application/json")]
    [GenericControllerNameConvention]
    [EnableQuery]
    public class GenericSubNavigationController<TBaseType, TSubType, TSubTypeDeclared> : GenericControllerBase<TBaseType>
    {
        public GenericSubNavigationController(ISubTypeEnricher subTypeEnricher) : base(subTypeEnricher)
        {
        }
    
        public async Task<IQueryable<TSubTypeDeclared>> GetNavigation([FromServices] ODataQueryOptions odataQueryOptions, Guid key)
        {
            PropertyInfo propertyInfo = typeof(TBaseType).GetProperties().FirstOrDefault(x => x.PropertyType == typeof(TSubType));
    
            string propertyName = propertyInfo.Name;
    
            var parameters = new Dictionary<string, string>();
            AppendKeyAttributeFilter(parameters, key);
            AppendExpandFilter(parameters, propertyName);
    
            var subParameters = new Tuple<string, Dictionary<string, string>>(propertyName, ExtractQueryParameter(odataQueryOptions));
    
            var rootObject = await InternalGet<TBaseType>(parameters, subParameters);
    
            if (rootObject.Any())
            {
                var info = typeof(TBaseType).GetProperty(propertyName);
    
                object value = info.GetValue(rootObject.FirstOrDefault());
    
                return new EnumerableQuery<TSubTypeDeclared>((IEnumerable<TSubTypeDeclared>) value);
            }
    
            return null;
        }
    }
    

    In order to work, you have to instantiate this controller in the ExternalControllerFeatureProvider, which was already mentioned in my initial question

    ExternalControllerFeatureProvider

    public class ExternalControllerFeatureProvider : IApplicationFeatureProvider<ControllerFeature>
    {
        private readonly IExternalCompiler _entityCompiler;
    
        public ExternalControllerFeatureProvider(IExternalCompiler entityCompiler)
        {
            _entityCompiler = entityCompiler;
        }
    
        public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature)
        {
            var types = _entityCompiler.GetTypes().ToList();
            foreach (var candidate in types)
            {
                feature.Controllers.Add(
                    typeof(GenericController<>).MakeGenericType(candidate).GetTypeInfo()
                );
    
                foreach (var propertyInfo in candidate.GetProperties())
                {
                    Type targetType = propertyInfo.PropertyType.GenericTypeArguments.Any()
                        ? propertyInfo.PropertyType.GenericTypeArguments.First()
                        : propertyInfo.PropertyType;
                    if (types.Contains(targetType))
                    {
                        var typeInfo = typeof(GenericSubNavigationController<,,>).MakeGenericType(candidate, propertyInfo.PropertyType, targetType).GetTypeInfo();
                        feature.Controllers.Add(typeInfo);
                    }
                }
            }
        }
    }
    

    And finally we have to change the used attribute GenericControllerNameConvention to change the action-name of the methods to reflact the default OData requirements

    GenericControllerNameConvention

    public class GenericControllerNameConvention : Attribute, IControllerModelConvention
    {
        public void Apply(ControllerModel controller)
        {
            if (!controller.ControllerType.IsGenericType || (controller.ControllerType.GetGenericTypeDefinition() !=
                typeof(GenericController<>) && controller.ControllerType.GetGenericTypeDefinition() !=
                typeof(GenericSubNavigationController<,,>)))
            {
                // Not a GenericController, ignore.
                return;
            }
    
            var entityType = controller.ControllerType.GenericTypeArguments[0];
            controller.ControllerName = $"{entityType.Name}";
    
            if (controller.ControllerType.GetGenericTypeDefinition() ==
                typeof(GenericSubNavigationController<,,>))
            {
                foreach (var controllerAction in controller.Actions)
                {
                    if (controllerAction.ActionName == "GetNavigation")
                    {
                        var subType = controller.ControllerType.GenericTypeArguments[1];
                        PropertyInfo propertyInfo = entityType.GetProperties().FirstOrDefault(x => x.PropertyType == subType);
    
                        controllerAction.ActionName = $"Get{propertyInfo.Name}";
                    }
                }
            }
        }
    }