简体   繁体   中英

Abstract class model binding in asp.net core web api 2

I have been trying to figure out how to use custom model binding with .net Core 2 web api but have not been able to get it working.

I have been through some articles as below http://www.palmmedia.de/Blog/2018/5/13/aspnet-core-model-binding-of-abstract-classes Asp net core rc2. Abstract class model binding

In my case, the bindingContext.ModelName is always empty. Can anybody explain why this could be?

Sample implementation below

Controller

        public IActionResult SomeAction([ModelBinder(BinderType = typeof(BlahTypeModelBinder))][FromBody]TheBaseClass theBase)
    {
        return Ok();
    }

Models

public abstract class TheBaseClass
{
    public abstract int WhatType { get; }
}

public class A : TheBaseClass
{
    public override int WhatType { get { return 1; }  }
}

public class B : TheBaseClass
{
    public override int WhatType { get { return 2; } }
}

Provider

public class BhalTypeBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
            throw new ArgumentNullException(nameof(context));

        if (context.Metadata.ModelType == typeof(TheBaseClass))
        {
            var assembly = typeof(TheBaseClass).Assembly;
            var abstractSearchClasses = assembly.GetExportedTypes()
                .Where(t => t.BaseType.Equals(typeof(TheBaseClass)))
                .Where(t => !t.IsAbstract)
                .ToList();

            var modelBuilderByType = new Dictionary<Type, Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinder>();

            foreach (var type in abstractSearchClasses)
            {
                var propertyBinders = new Dictionary<ModelMetadata, IModelBinder>();
                var metadata = context.MetadataProvider.GetMetadataForType(type);

                foreach (var property in metadata.Properties)
                {
                    propertyBinders.Add(property, context.CreateBinder(property));
                }

                modelBuilderByType.Add(type, new Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinder(propertyBinders));
            }

            return new BlahTypeModelBinder(modelBuilderByType, context.MetadataProvider);
        }

        return null;
    }
}

Binder

public class BlahTypeModelBinder : IModelBinder
{
    private readonly IModelMetadataProvider _metadataProvider;
    private readonly IDictionary<Type, Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinder> _binders;

    public BlahTypeModelBinder(IDictionary<Type, Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexTypeModelBinder> binders, IModelMetadataProvider metadataProvider)
    {
        _metadataProvider = metadataProvider;
        _binders = binders;
    }

    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
            throw new ArgumentNullException(nameof(bindingContext));

        var modelTypeValue = bindingContext.ValueProvider.GetValue(ModelNames.CreatePropertyModelName(bindingContext.ModelName, "WhatType"));
        if (modelTypeValue != null && modelTypeValue.FirstValue != null)
        {
            Type modelType = Type.GetType(modelTypeValue.FirstValue);
            if (this._binders.TryGetValue(modelType, out var modelBinder))
            {
                ModelBindingContext innerModelBindingContext = DefaultModelBindingContext.CreateBindingContext(
                    bindingContext.ActionContext,
                    bindingContext.ValueProvider,
                    this._metadataProvider.GetMetadataForType(modelType),
                    null,
                    bindingContext.ModelName);

                /*modelBinder*/
                this._binders.First().Value.BindModelAsync(innerModelBindingContext);

                bindingContext.Result = innerModelBindingContext.Result;
                return Task.CompletedTask;
            }
        }

       //More code
    }
}

I finally managed to solve the issue. You dont need the provider. Just the following binder works

public class BlahTypeModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
            throw new ArgumentNullException(nameof(bindingContext));

        var json = ExtractRequestJson(bindingContext.ActionContext);
        var jObject = Newtonsoft.Json.Linq.JObject.Parse(json);
        var whatTypeInt = (int)jObject.SelectToken("WhatType");

        if (whatTypeInt == 1)
        {
            var obj = DeserializeObject<A>(json);
            bindingContext.Result = ModelBindingResult.Success(obj);
        }
        else if (whatTypeInt == 2)
        {
            var obj = DeserializeObject<B>(json);
            bindingContext.Result = ModelBindingResult.Success(obj);
        }
        else
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return Task.CompletedTask;
        }

        return Task.CompletedTask;
    }

    private static string ExtractRequestJson(ActionContext actionContext)
    {
        var content = actionContext.HttpContext.Request.Body;
        return new StreamReader(content).ReadToEnd();
    }

    private static T DeserializeObject<T>(string json)
    {
        return Newtonsoft.Json.JsonConvert.DeserializeObject<T>(json, new Newtonsoft.Json.JsonSerializerSettings
        {
            TypeNameHandling = Newtonsoft.Json.TypeNameHandling.Auto
        });
    }
}

The examples you linked to use an external query string parameter to determine the type.

If you call your action like this : SomeAction?WhatType=YourNamespaceName.A the binding works as expected.

bindingContext.ModelName being empty is just fine, it would be set after model binding. You can set it after setting bindingContext.Result if you want. Parameter WhatType comes from the QueryStringValueProvider , so no prefix is fine.

How to accomplish abstract model binding based on the JSON alone

To do this, we need:

  1. A value provider to read the JSON and provide us with some "WhatType" value, in place of the QueryStringValueProvider.
  2. Some reflection to map the extracted numbers to the Type -s.

1. ValueProvider

There is a detailed article on creating ValueProviders here:

As a starting point here is some code that successfully extracts the WhatType integers from the body json:

    public class BlahValueProvider : IValueProvider
{
    private readonly string _requestBody;

    public BlahValueProvider(string requestBody)
    {
        _requestBody = requestBody;
    }

    private const string PROPERTY_NAME = "WhatType";

    public bool ContainsPrefix(string prefix)
    {
        return prefix == PROPERTY_NAME;
    }

    public ValueProviderResult GetValue(string key)
    {
        if (key != PROPERTY_NAME)
            return ValueProviderResult.None;

        // parse json

        try
        {
            var json = JObject.Parse(_requestBody);
            return new ValueProviderResult(json.Value<int>("WhatType").ToString());
        }
        catch (Exception e)
        {
            // TODO: error handling
            throw;
        }
    }
}

public class BlahValueProviderFactory : IValueProviderFactory
{
    public Task CreateValueProviderAsync(ValueProviderFactoryContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        var request = context.ActionContext.HttpContext.Request;
        if (request.ContentType == "application/json")
        {
            return AddValueProviderAsync(context);
        }

        return Task.CompletedTask;
    }

    private Task AddValueProviderAsync(ValueProviderFactoryContext context)
    {
        using (StreamReader sr = new StreamReader(context.ActionContext.HttpContext.Request.Body))
        {
            string bodyString = sr.ReadToEnd();
            context.ValueProviders.Add(new BlahValueProvider(bodyString));
        }

        return Task.CompletedTask;
    }
}

Of course you have to register this factory in Startup.cs just as you registered the model binder. And this absolutely misses converting the extracted number to the actual Type (for this, see point 2 below), but if you place a breakpoint on your line staring with if (modelTypeValue != null you can see that modelTypeValue is there for you now even without the separate GET parameter.

2. Reflection

Realize that you are trying to figure out the type based on a property that is dynamically calculated on an existing instance (they are not static). While by knowing the current implementation I know that this is possible (create an empty instance of the model, check the WhatType property, throw the instance away), this is very bad practice, as nothing guarantees that an instance property is statically constant.

The clean solution for this would be an Attribute , that contains the WhatType number for that class. Then we can reflect on that attribute and build a map that maps int s to Type s. This is out of the scope if this question, but look up any custom attribute tutorial if you are not familiar, and you will be able to put it together really quickly.

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