简体   繁体   中英

ASP.NET Core: Complex Model with comma separated values list

Our request models are growing according to the growing complexity of our APIs and we decided to use complex types instead of using simple types for the parameters of the actions.

One typical type is IEnumerable for comma-separated values, like items=1,2,3,5... and we solved the issue of converting from string to IEnumerable using the workaround provided in https://www.strathweb.com/2017/07/customizing-query-string-parameter-binding-in-asp-net-core-mvc/ where the key point is implementing the IActionModelConvention interface to identify the parameters marked with a specific attribute [CommaSeparated] .

Everything worked fine until we moved the simple parameters into a single complex parameter, now we are unable to inspect the complex parameters in the IActionModelConvention implementation. The same happens using IParameterModelConvention . Please, see the code below:

this works fine:

 public async Task<IActionResult> GetByIds(
       [FromRoute]int day,
       [BindRequired][FromQuery][CommaSeparated]IEnumerable<int> ids,
       [FromQuery]string order)
 {
        // do something
 }

while this variant does not work

 public class GetByIdsRequest
 {
    [FromRoute(Name = "day")]
    public int Day { get; set; }

    [BindRequired]
    [FromQuery(Name = "ids")]
    [CommaSeparated]
    public IEnumerable<int> Ids { get; set; }

    [FromQuery(Name = "order")]
    public string Order { get; set; }
 }

 public async Task<IActionResult> GetByIds(GetByIdsRequest request)
 {
        // do something
 }

the IActionModelConvention implementation is very simple:

public void Apply(ActionModel action)
{
   SeparatedQueryStringAttribute attribute = null;
   for (int i = 0; i < action.Parameters.Count; i++)
   {
       var parameter = action.Parameters[i];
       var commaSeparatedAttr = parameter.Attributes.OfType<CommaSeparatedAttribute>().FirstOrDefault();
       if (commaSeparatedAttr != null)
       {
           if (attribute == null)
           {
                attribute = new SeparatedQueryStringAttribute(",", commaSeparatedAttr.RemoveDuplicatedValues);
                 parameter.Action.Filters.Add(attribute);
            }

            attribute.AddKey(parameter.ParameterName);
        }
    }
 } 

As you can see, the code is inspecting the parameters marked with CommaSeparatedAttribute ...but it doesn't work with complex parameters like the one used in my second variant.

Note: I added some minor changes to the original code provided in the post above mentioned like enabling the CommaSeparatedAttribute to be used not only for parameters but also for properties, but still it doesn't work

The reason

That's because you're trying to detect the existence of the [CommaSeparated] attributes which are decorated on parameter (instead of the parameter's properties ):

var commaSeparatedAttr = parameter.Attributes.OfType().FirstOrDefault();

Note your action method looks like the following :

public async Task GetByIds(GetByIdsRequest request)

In other words, the parameter.Attributes.OfType<CommaSeparatedAttribute>() will only get those annotations decorated on the request parameter. However, there's no such a [CommaSeparatedAttribute] at all.

As a result, the SeparatedQueryStringAttribute filter is never added to parameter.Action.Filters .

How to Fix

Looks like you've made a minor tweek in the SeparatedQueryStringAttribute . As we don't get your code, suppose we have such a SeparatedQueryStringAttribute Filter (copied from the blog you mentioned above):

public class SeparatedQueryStringAttribute : Attribute, IResourceFilter
{
    private readonly SeparatedQueryStringValueProviderFactory _factory;
    public SeparatedQueryStringAttribute() : this(",") { }

    public SeparatedQueryStringAttribute(string separator) {
        _factory = new SeparatedQueryStringValueProviderFactory(separator);
    }

    public SeparatedQueryStringAttribute(string key, string separator) {
        _factory = new SeparatedQueryStringValueProviderFactory(key, separator);
    }

    public void OnResourceExecuting(ResourceExecutingContext context) {
        context.ValueProviderFactories.Insert(0, _factory);
    }

    public void OnResourceExecuted(ResourceExecutedContext context) { }
}

Actually, according to your GetByIdsRequest class, we should detect the existence of the [CommaSeparated] attribute that are decorated on parameter's properties :

// CommaSeparatedQueryStringConvention::Apply(action) 
public void Apply(ActionModel action)
{
    for (int i = 0; i < action.Parameters.Count; i++)
    {
        var parameter = action.Parameters[i];
        var props = parameter.ParameterType.GetProperties()
            .Where(pi => pi.GetCustomAttributes<CommaSeparatedAttribute>().Count() > 0)
            ;
        if (props.Count() > 0)
        {
            var attribute = new SeparatedQueryStringAttribute(",");
            parameter.Action.Filters.Add(attribute);
            break;
        }
    }
}

And now it works fine for me.

A Demo

在此输入图像描述

Based on itminus's answer I could work out my final solution. The trick was - as itminus pointed out - in the IActionModelConvention implementation. Please, see my implementation which considers other aspects like nested models and also the real name assigned to each property:

public void Apply(ActionModel action)
{
    SeparatedQueryStringAttribute attribute = null;
    for (int i = 0; i < action.Parameters.Count; i++)
    {
        var parameter = action.Parameters[i];
        var commaSeparatedAttr = parameter.Attributes.OfType<CommaSeparatedAttribute>().FirstOrDefault();
        if (commaSeparatedAttr != null)
        {
            if (attribute == null)
            {
                attribute = new SeparatedQueryStringAttribute(",", commaSeparatedAttr.RemoveDuplicatedValues);
                parameter.Action.Filters.Add(attribute);
            }

            attribute.AddKey(parameter.ParameterName);
        }
        else
        {
            // here the trick to evaluate nested models
            var props = parameter.ParameterInfo.ParameterType.GetProperties();
            if (props.Length > 0)
            {
                // start the recursive call
                EvaluateProperties(parameter, attribute, props);
            }
        }
    }
 }

the EvaluateProperties method:

private void EvaluateProperties(ParameterModel parameter, SeparatedQueryStringAttribute attribute, PropertyInfo[] properties)
{
    for (int i = 0; i < properties.Length; i++)
    {
        var prop = properties[i];
        var commaSeparatedAttr = prop.GetCustomAttributes(true).OfType<CommaSeparatedAttribute>().FirstOrDefault();
        if (commaSeparatedAttr != null)
        {
            if (attribute == null)
            {
                attribute = new SeparatedQueryStringAttribute(",", commaSeparatedAttr.RemoveDuplicatedValues);
                parameter.Action.Filters.Add(attribute);
            }

            // get the binding attribute that implements the model name provider
            var nameProvider = prop.GetCustomAttributes(true).OfType<IModelNameProvider>().FirstOrDefault(a => !IsNullOrWhiteSpace(a.Name));
            attribute.AddKey(nameProvider?.Name ?? prop.Name);
        }
        else
        {
            // nested properties
            var props = prop.PropertyType.GetProperties();
            if (props.Length > 0)
            {
               EvaluateProperties(parameter, attribute, props);
            }
        }
    }
}

I also changed the definition of the comma separated attribute

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, Inherited = true, AllowMultiple = false)]
public class CommaSeparatedAttribute : Attribute
{
    public CommaSeparatedAttribute()
       : this(true)
    { }

    /// <summary>
    /// ctor
    /// </summary>
    /// <param name="removeDuplicatedValues">remove duplicated values</param>
    public CommaSeparatedAttribute(bool removeDuplicatedValues)
    {
        RemoveDuplicatedValues = removeDuplicatedValues;
    }

    /// <summary>
    /// remove duplicated values???
    /// </summary>
    public bool RemoveDuplicatedValues { get; set; }
}

There are other moving parts I changed too...but this is basically the most important ones. Now, we can use models like this:

public class GetByIdsRequest
{
    [FromRoute(Name = "day")]
    public int Day { get; set; }

    [BindRequired]
    [FromQuery(Name = "ids")]
    [CommaSeparated]
    public IEnumerable<int> Ids { get; set; }

    [FromQuery(Name = "include")]
    [CommaSeparated]
    public IEnumerable<IncludingOption> Include { get; set; }

    [FromQuery(Name = "order")]
    public string Order { get; set; }

    [BindProperty(Name = "")]
    public NestedModel NestedModel { get; set; }
}

public class NestedModel
{
    [FromQuery(Name = "extra-include")]
    [CommaSeparated]
    public IEnumerable<IncludingOption> ExtraInclude { get; set; }

    [FromQuery(Name = "extra-ids")]
    [CommaSeparated]
    public IEnumerable<long> ExtraIds { get; set; }
}

// the controller's action
public async Task<IActionResult> GetByIds(GetByIdsRequest request)
{
    // do something
}

For a request like this one (not exactly the same as the one defined above but very similar):

http://.../vessels/algo/days/20190101/20190202/hours/1/2?page=2&size=12&filter=eq(a,b)&order=by(asc(a))&include=all,none&ids=12,34,45&extra-include=all,none&extra-ids=12,34,45

在此输入图像描述

If anyone needs the full code, please, let me know. Again, thanks to itminus for his valuable help

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM