Search code examples
c#asp.netasp.net-web-api2model-bindingaction-filter

Null Models with default arguments need to be instantiated as such


I have the following asp.net WebApi2 route using .NET 4.6 that illustrates the problem I am having:

[Route("books/{id}")]
[HttpGet]
public JsonResponse GetBooks(string id, [FromUri]DescriptorModel model)

With the following model:

public class DescriptorModel
{
    public bool Fiction { get; set; } = false;

    // other properties with default arguments here
}

I am trying to allow Fiction property to be set to a default value (if not specified during the get request).

When I specify the Fiction property explicitly it works correctly:

curl -X GET --header 'Accept: application/json' 'http://127.0.0.1:11000/api/v1/books/516.375/?Fiction=false'

However, when doing the following test (omitting the property with the default argument):

curl -X GET --header 'Accept: application/json' 'http://127.0.0.1:11000/api/v1/books/516.375'

The value of "model" is bound as null which is not what I am looking for. My question is how to simply allow models defined with default values to be instantiated as such during/after the model binding process but prior to the controller's "GetBooks" action method being called.

NOTE. the reason I use models with GET requests is that documenting in swagger is much easier as then my GET/POST actions can reuse the same models in many case via inheritance.


Solution

  • I wasn't able to figure out how to do this via model-binding but I was able to use Action Filters to accomplish the same thing.

    Here's the code I used (note it only supports one null model per action but this could easily be fixed if needed):

    public class NullModelActionFilter : ActionFilterAttribute
    {
        public override void OnActionExecuting(HttpActionContext context)
        {
            object value = null;
            string modelName = string.Empty;
    
            // are there any null models?
            if (context.ActionArguments.ContainsValue(null))
            {
                // Yes => iterate over all arguments to find them.
                foreach (var arg in context.ActionArguments)
                {
                    // Is the argument null?
                    if (arg.Value == null)
                    {
                        // Search the parameter bindings to find the matching argument....
                        foreach (var parameter in context.ActionDescriptor.ActionBinding.ParameterBindings)
                        {
                            //  Did we find a match?
                            if (parameter.Descriptor.ParameterName == arg.Key)
                            {
                                // Yes => Does the type have the 'Default' attribute?
                                var type = parameter.Descriptor.ParameterType;
                                if (type.GetCustomAttributes(typeof(DefaultAttribute), false).Length > 0)
                                {
                                    // Yes => need to instantiate it
                                    modelName = arg.Key;
                                    var constructor = parameter.Descriptor.ParameterType.GetConstructor(new Type[0]);
                                    value = constructor.Invoke(null);
    
                                    // update the model state
                                    context.ModelState.Add(arg.Key, new ModelState { Value = new ValueProviderResult(value, value.ToString(), CultureInfo.InvariantCulture) });
                                }
                            }
                        }
                    }
                }
    
                // update the action arguments
                context.ActionArguments[modelName] = value;
            }
        }
    }
    

    I created a DefaultAttribute class like so:

    [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
    public class DefaultAttribute : Attribute
    {
    }
    

    I then added that attribute to my descriptor class:

    [Default]
    public class DescriptorModel
    {
        public bool Fiction { get; set; } = false;
    
        // other properties with default arguments here
    }
    

    And finally registered the action filter in

    public void Configure(IAppBuilder appBuilder)
    {
        var config = new HttpConfiguration();
        // lots of configuration here omitted
        config.Filters.Add(new NullModelActionFilter());
        appBuilder.UseWebApi(config);
    }
    

    I definitely consider this a hack (I think I really should be doing this via model binding) but it accomplishes what I needed to do with the constraints that I was given of ASP.NET (not Core) / WebApi2 / .NET Framework so hopefully some else will benefit from this.