What is polymorphic data binding?

We all know that in the ASP.NET Core WebApi, data binding mechanism is responsible for binding request parameters. Usually, most of the data binding can be carried out in the default Binder. But there are also a few unsupported situations, such as polymorphic data binding. It is called polymorphic data binding as the request parameter is the Json string of the subclass object, and what is defined in the action is the variable of the parent class type. What's more, ASP.NET Core WebApi doesn't support Polymorphic data binding by default, for which will result in data missing.

Here is an example.

The Person class is a parent class, and the Doctor class and the Student class are the derived classes of the Person class. The Doctor class has the property of HospitalName , and the Student class has the property of SchoolName.

Let's create a Web Api project and add a PeopleController .

In the PeopleController we add an Add api and return the request data directly to see the effect.

[Route("api/people")] public class PeopleController : Controller { [HttpPost] [Route("")] public List<Person> Add([FromBody]List<Person> people) { return people; } }

Here we use Postman to request the api. The requested Content-Type is application/json , and the requested Body is as follows.

[{ firstName: 'Mike', lastName: 'Li' }, { firstName: 'Stephie', lastName: 'Wang', schoolName: 'No.15 Middle School' }, { firstName: 'Jacky', lastName: 'Chen', hospitalName: 'Center Hospital' }]

The content returned by the request:

[ { "FirstName": "Mike", "LastName": "Li" }, { "FirstName": "Stephie", "LastName": "Wang" }, { "FirstName": "Jacky", "LastName": "Chen" } ]

The returned result is not the same as the result we hope to get; the SchoolName property held by the Student class and the HospitalName property held by the Doctor class are lost.

Now let's start up the debug mode for the project; re-use the Postman request once, and the result is as follows.

The People collection contains three objects of the People type, and there are no Student type objects and Doctor type objects that we expect. You can find that it doesn't support polymorphic data binding by default in .NET Core WebApi. If you use the parent class type variable to receive data, data binding will only instantiate the parent class object, not the derived class object, which will cause the property to be lost.

Customize JsonConverter to implement polymorphic data binding

JsonConverter , mainly used to serialize and deserialize Json objects, is a class in Json.NET.

Let's create a generic class JsonCreationConverter first, and inherit the JsonConverter class. The code is as follows:

public abstract class JsonCreationConverter<T> : JsonConverter { public override bool CanWrite { get { return false; } } protected abstract T Create(Type objectType, JObject jObject); public override bool CanConvert(Type objectType) { return typeof(T).IsAssignableFrom(objectType); } public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { if (reader == null) throw new ArgumentNullException("reader"); if (serializer == null) throw new ArgumentNullException("serializer"); if (reader.TokenType == JsonToken.Null) return null; JObject jObject = JObject.Load(reader); T target = Create(objectType, jObject); serializer.Populate(jObject.CreateReader(), target); return target; } public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { throw new NotImplementedException(); } }

Here we added an abstract method Create . The method is used to return a generic type object according to the content of the Json string. Here can return an object of the current generic type or an object of current generic type derived class. JObject is a Json string reader in Json.NET, which is used to read the property value in the Json string.

In addition, we also overridden the ReadJson method. In ReadJson , we will first call the Create method to get a current generic class object or a derived class object of the current generic class. (the default KeyValuePairConverter in Json.NET will instantiate the current parameter type object directly. This is the main reason why polymorphic data binding is not supported by default.) The serializer.Popluate method is used to map the contents of the Json string to the corresponding properties of the target object (the current generic class object or the derived class object of the current generic class).

Since we only need to read Json here, we won't need to implement the WriteJson method. And the CanWrite property is also forced to return false .

Let's create a PersonJsonConverter class in the second step, which inherits JsonCreationConverter<Person> , and the code is as follows.

public class PersonJsonConverter : JsonCreationConverter<Person> { protected override Person Create(Type objectType, JObject jObject) { if (jObject == null) throw new ArgumentNullException("jObject"); if (jObject["schoolName"] != null) { return new Student(); } else if (jObject["hospitalName"] != null) { return new Doctor(); } else { return new Person(); } } }

We have overridden the Create method in the class. And here we use JObject to get the properties owned by the Json string.

If the string contains the schoolName property, a new Student object will be returned.

property, a new object will be returned. If the string contains the hospitalName property, a new Doctor object will be returned.

property, a new object will be returned. Otherwise, a new Person object will be returned.

Finally, let's make a mark for the Person class and use PersonJsonConverter to implement Json serialization and deserialization.

[JsonConverter(typeof(PersonJsonConverter))] public class Person { public string FirstName { get; set; } public string LastName { get; set; } }

Now you can re-use the debug mode to start up the program, then use Postman to request the current api.

You will find that the derived subclass type object has been correctly bound in the people collection. And we get a response on Postman, as follows.

[ { "FirstName": "Mike", "LastName": "Li" }, { "SchoolName": "No.15 Middle School", "FirstName": "Stephie", "LastName": "Wang" }, { "HospitalName": "Center Hospital", "FirstName": "Jacky", "LastName": "Chen" } ]

At this point, polymorphic data binding is implemented.

Why

Why is polymorphic binding implemented after adding the PersonJsonConverter class?

Let's review the code of MVC Core and Json.NET.

First let's take a look at the code of MvcCoreMvcOptionsSetup .

public class MvcCoreMvcOptionsSetup : IConfigureOptions<MvcOptions> { private readonly IHttpRequestStreamReaderFactory _readerFactory; private readonly ILoggerFactory _loggerFactory; ...... public void Configure(MvcOptions options) { options.ModelBinderProviders.Add(new BinderTypeModelBinderProvider()); options.ModelBinderProviders.Add(new ServicesModelBinderProvider()); options.ModelBinderProviders.Add(new BodyModelBinderProvider(options.InputFormatters, _readerFactory, _loggerFactory, options)); ...... } ...... }

The Configure method in the MvcCoreMvcOptionsSetup class sets that the default data binding will use the Provider list.

When an api parameter is marked as [FromBody] , BodyModelBinderProvider will instantiate a BodyModelBinder object to handle the parameter and attempt data binding.

There is a BindModelAsync method in the BodyModelBinder class. And from the literal meaning of the name, we can know that the method is used to bind data.

public async Task BindModelAsync(ModelBindingContext bindingContext) { if (bindingContext == null) { throw new ArgumentNullException(nameof(bindingContext)); } …. var formatter = (IInputFormatter)null; for (var i = 0; i < _formatters.Count; i++) { if (_formatters[i].CanRead(formatterContext)) { formatter = _formatters[i]; _logger?.InputFormatterSelected(formatter, formatterContext); break; } else { logger?.InputFormatterRejected(_formatters[i], formatterContext); } } …… try { var result = await formatter.ReadAsync(formatterContext); …… } catch (Exception exception) when (exception is InputFormatterException || ShouldHandleException(formatter)) { bindingContext.ModelState.AddModelError(modelBindingKey, exception, bindingContext.ModelMetadata); } }

The method is used to try to find a matching IInputFormatter object to bind the data. Since the Content-Type of the request is application/json at this time, here the JsonInputFormatter object will be used for data binding.

Let's take a look at some of the key code of the JsonInputFormatter class.

public override async Task<InputFormatterResult> ReadRequestBodyAsync( InputFormatterContext context, Encoding encoding) { ...... using (var streamReader = context.ReaderFactory(request.Body, encoding)) { using (var jsonReader = new JsonTextReader(streamReader)) { … object model; try { model = jsonSerializer.Deserialize(jsonReader, type); } finally { jsonSerializer.Error -= ErrorHandler; ReleaseJsonSerializer(jsonSerializer); } … } } }

The ReadRequestBodyAsync method in the JsonInputFormatter class is used for data binding. The Deserialize method of the JsonSerializer class of Json.NET is used to deserialize. It shows that the underlying Mvc Core can use Json.NET directly to operate Json.

Here is part of the key code of the JsonSerializer class.

public object Deserialize(JsonReader reader, Type objectType) { return DeserializeInternal(reader, objectType); } internal virtual object DeserializeInternal(JsonReader reader, Type objectType) { …… JsonSerializerInternalReader serializerReader = new JsonSerializerInternalReader(this); object value = serializerReader.Deserialize(traceJsonReader ?? reader, objectType, CheckAdditionalContent); …… return value; }

JsonSerializer will call the JsonSerializerInternalReader class's Deserialize method to deserialize the contents of the Json string.

Finally, let's take a look at some of the key code in JsonSerializerInternalReader .

public object Deserialize(JsonReader reader, Type objectType, bool checkAdditionalContent) { … JsonConverter converter = GetConverter(contract, null, null, null); if (reader.TokenType == JsonToken.None && !reader.ReadForType(contract, converter != null)) { ...... object deserializedValue; if (converter != null && converter.CanRead) { deserializedValue = DeserializeConvertable(converter, reader, objectType, null); } else { deserializedValue = CreateValueInternal(reader, objectType, contract, null, null, null, null); } } }