Controlling what is returned with an $expand request
Asked Answered
K

2

9

So, using the ODataController, you get to control what gets returned if somebody does /odata/Foos(42)/Bars, because you'll be called on the FoosController like so:

public IQueryable<Bar> GetBars([FromODataUri] int key) { }

But what if you want to control what gets returned when somebody does /odata/Foos?$expand=Bars? How do you deal with that? It triggers this method:

public IQueryable<Foo> GetFoos() { }

And I assume it just does an .Include("Bars") on the IQueryable<Foo> that you return, so... how do I get more control? In particular, how do I do it in such a way that OData doesn't break (i.e. things like $select, $orderby, $top, etc. continue working.)

Kerguelen answered 29/9, 2015 at 13:25 Comment(0)
K
4

While not the solution I wanted (make this a built-in feature, guys!), I have found a way to do what I wanted, albeit in a somewhat limited manner (so far I only support direct Where() filtering).

First, I made a custom ActionFilterAttribute class. Its purpose is to take action after the EnableQueryAttribute has done its thing, as it modifies the query that EnableQueryAttribute has produced.

In your GlobalConfiguration.Configure(config => { ... }) call, add the following before the call to config.MapODataServiceRoute():

config.Filters.Add(new NavigationFilterAttribute(typeof(NavigationFilter)));

It has to be before, because the OnActionExecuted() methods are called in reverse order. You can also decorate specific controllers with this filter, although I've found it harder to ensure that it's run in the correct order that way. The NavigationFilter is a class you create yourself, I'll post an example of one farther down.

NavigationFilterAttribute, and its inner class, an ExpressionVisitor is relatively well documented with comments, so I'll just paste them without further comments below:

public class NavigationFilterAttribute : ActionFilterAttribute
{
    private readonly Type _navigationFilterType;

    class NavigationPropertyFilterExpressionVisitor : ExpressionVisitor
    {
        private Type _navigationFilterType;

        public bool ModifiedExpression { get; private set; }

        public NavigationPropertyFilterExpressionVisitor(Type navigationFilterType)
        {
            _navigationFilterType = navigationFilterType;
        }

        protected override Expression VisitMember(MemberExpression node)
        {
            // Check properties that are of type ICollection<T>.
            if (node.Member.MemberType == System.Reflection.MemberTypes.Property
                && node.Type.IsGenericType
                && node.Type.GetGenericTypeDefinition() == typeof(ICollection<>))
            {
                var collectionType = node.Type.GenericTypeArguments[0];

                // See if there is a static, public method on the _navigationFilterType
                // which has a return type of Expression<Func<T, bool>>, as that can be
                // handed to a .Where(...) call on the ICollection<T>.
                var filterMethod = (from m in _navigationFilterType.GetMethods()
                                    where m.IsStatic
                                    let rt = m.ReturnType
                                    where rt.IsGenericType && rt.GetGenericTypeDefinition() == typeof(Expression<>)
                                    let et = rt.GenericTypeArguments[0]
                                    where et.IsGenericType && et.GetGenericTypeDefinition() == typeof(Func<,>)
                                        && et.GenericTypeArguments[0] == collectionType
                                        && et.GenericTypeArguments[1] == typeof(bool)

                                    // Make sure method either has a matching PropertyDeclaringTypeAttribute or no such attribute
                                    let pda = m.GetCustomAttributes<PropertyDeclaringTypeAttribute>()
                                    where pda.Count() == 0 || pda.Any(p => p.DeclaringType == node.Member.DeclaringType)

                                    // Make sure method either has a matching PropertyNameAttribute or no such attribute
                                    let pna = m.GetCustomAttributes<PropertyNameAttribute>()
                                    where pna.Count() == 0 || pna.Any(p => p.Name == node.Member.Name)
                                    select m).SingleOrDefault();

                if (filterMethod != null)
                {
                    // <node>.Where(<expression>)
                    var expression = filterMethod.Invoke(null, new object[0]) as Expression;
                    var whereCall = Expression.Call(typeof(Enumerable), "Where", new Type[] { collectionType }, node, expression);
                    ModifiedExpression = true;
                    return whereCall;
                }
            }
            return base.VisitMember(node);
        }
    }

    public NavigationFilterAttribute(Type navigationFilterType)
    {
        _navigationFilterType = navigationFilterType;
    }

    public override void OnActionExecuted(HttpActionExecutedContext actionExecutedContext)
    {
        HttpResponseMessage response = actionExecutedContext.Response;

        if (response != null && response.IsSuccessStatusCode && response.Content != null)
        {
            ObjectContent responseContent = response.Content as ObjectContent;
            if (responseContent == null)
            {
                throw new ArgumentException("HttpRequestMessage's Content must be of type ObjectContent", "actionExecutedContext");
            }

            // Take the query returned to us by the EnableQueryAttribute and run it through out
            // NavigationPropertyFilterExpressionVisitor.
            IQueryable query = responseContent.Value as IQueryable;
            if (query != null)
            {
                var visitor = new NavigationPropertyFilterExpressionVisitor(_navigationFilterType);
                var expressionWithFilter = visitor.Visit(query.Expression);
                if (visitor.ModifiedExpression)
                    responseContent.Value = query.Provider.CreateQuery(expressionWithFilter);
            }
        }
    }
}

Next, there are a few simple attribute classes, for the purpose of narrowing down the filtering.

If you put PropertyDeclaringTypeAttribute on one of the methods on your NavigationFilter, it will only call that method if the property is on that type. For instance, given a class Foo with a property of type ICollection<Bar>, if you have a filter method with [PropertyDeclaringType(typeof(Foo))], then it will only be called for ICollection<Bar> properties on Foo, but not for any other class.

PropertyNameAttribute does something similar, but for the property's name rather than type. It can be useful if you have an entity type with multiple properties of the same ICollection<T> where you want to filter differently depending on the property name.

Here they are:

[AttributeUsage(AttributeTargets.Method, Inherited = true, AllowMultiple = true)]
public class PropertyDeclaringTypeAttribute : Attribute
{
    public PropertyDeclaringTypeAttribute(Type declaringType)
    {
        DeclaringType = declaringType;
    }

    public Type DeclaringType { get; private set; }
}

[AttributeUsage(AttributeTargets.Method, Inherited = true, AllowMultiple = true)]
public class PropertyNameAttribute : Attribute
{
    public PropertyNameAttribute(string name)
    {
        Name = name;
    }

    public string Name { get; private set; }
}

Finally, here's an example of a NavigationFilter class:

class NavigationFilter
{
    [PropertyDeclaringType(typeof(Foo))]
    [PropertyName("Bars")]
    public static Expression<Func<Bar,bool>> OnlyReturnBarsWithSpecificSomeValue()
    {
        var someValue = SomeClass.GetAValue();
        return b => b.SomeValue == someValue;
    }
}
Kerguelen answered 5/10, 2015 at 9:29 Comment(4)
I am doing something similar. I feared I would have to modify every query but doing it in the action is nice. For what it's worth, there's a FilterQueryValidator that looks promising but I'm not sure one should mutate a given query inside a *Validator. asp.net/web-api/overview/odata-support-in-aspnet-web-api/…Etiquette
ODataQueryOptions also looks promising. Either way, plus one and thanks for sharing the implementation.Etiquette
@Etiquette Yeah, I looked at both of those, but neither did what I needed. Ultimately, I found that I needed to modify the query itself, so that's what I ended up doing. If you find a less hackish way of going about it, though, let me know. :)Kerguelen
@Kerguelen Trust you're doing well. Could you share your expertise on this please #46320344Hluchy
A
-2

@Alex

1) You can add a parameter into GetBars(... int key) and use the parameter to do more controller for the query option. for example,

public IQueryable<Bar> GetBars(ODataQueryOptions<Bar> options, [FromODataUri] int key) { }

2) Or, You can add [EnableQuery] on the action GetBars to let Web API OData to do the query options.

[EnableQuery]
public IQueryable<Bar> GetBars([FromODataUri] int key) { }
Astrea answered 30/9, 2015 at 2:34 Comment(2)
Neither of those options is an answer to my question. I don't want to modify how GetBars works. That one works as expected. My issue is that I want to be able to control what is returned from Bars when somebody does /odata/Foos?$expand=Bars.Kerguelen
GetBars() is unfortunately not called in that instance.Kerguelen

© 2022 - 2024 — McMap. All rights reserved.