Deserialising JSON to Derived Types in ASP.NET Web API

Deserialising JSON to derived types in Asp.Net Web API

You don't need a custom model binder. Nor do you need to muck about with the request pipeline.

Take a look at this other SO: How to implement custom JsonConverter in JSON.NET to deserialize a List of base class objects?.

I used this as the basis for my own solution to the same problem.

Starting off with the JsonCreationConverter<T> referenced in that SO (slightly modified to fix issues with serialization of types in responses):

public abstract class JsonCreationConverter<T> : JsonConverter
{
/// <summary>
/// this is very important, otherwise serialization breaks!
/// </summary>
public override bool CanWrite
{
get
{
return false;
}
}
/// <summary>
/// Create an instance of objectType, based properties in the JSON object
/// </summary>
/// <param name="objectType">type of object expected</param>
/// <param name="jObject">contents of JSON object that will be
/// deserialized</param>
/// <returns></returns>
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.TokenType == JsonToken.Null)
return null;
// Load JObject from stream
JObject jObject = JObject.Load(reader);

// Create target object based on JObject
T target = Create(objectType, jObject);

// Populate the object properties
serializer.Populate(jObject.CreateReader(), target);

return target;
}

public override void WriteJson(JsonWriter writer, object value,
JsonSerializer serializer)
{
throw new NotImplementedException();
}
}

And now you can annotate your type with the JsonConverterAttribute, pointing Json.Net to a custom converter:

[JsonConverter(typeof(MyCustomConverter))]
public abstract class BaseClass{
private class MyCustomConverter : JsonCreationConverter<BaseClass>
{
protected override BaseClass Create(Type objectType,
Newtonsoft.Json.Linq.JObject jObject)
{
//TODO: read the raw JSON object through jObject to identify the type
//e.g. here I'm reading a 'typename' property:

if("DerivedType".Equals(jObject.Value<string>("typename")))
{
return new DerivedClass();
}
return new DefaultClass();

//now the base class' code will populate the returned object.
}
}
}

public class DerivedClass : BaseClass {
public string DerivedProperty { get; set; }
}

public class DefaultClass : BaseClass {
public string DefaultProperty { get; set; }
}

Now you can use the base type as a parameter:

public Result Post(BaseClass arg) {

}

And if we were to post:

{ typename: 'DerivedType', DerivedProperty: 'hello' }

Then arg would be an instance of the DerivedClass, but if we posted:

{ DefaultProperty: 'world' }

Then you'd get an instance of the DefaultClass.

EDIT - Why I prefer this method to TypeNameHandling.Auto/All

I do believe that using the TypeNameHandling.Auto/All espoused by JotaBe is not always the ideal solution. It might well be in this case - but personally I won't do it unless:

  • My API is only ever going to be used by me or my team
  • I don't care about having a dual XML-compatible endpoint

When Json.Net TypeNameHandling.Auto or All are used, your web server will start sending out type names in the format MyNamespace.MyType, MyAssemblyName.

I have said in comments that I think this is a security concern. Mention was made of this in some documentation I read from Microsoft. It's not mentioned any more, it seems, however I still feel it's a valid concern. I don't ever want to expose namespace-qualified type names and assembly names to the outside world. It's increasing my attack surface. So, yes, I can not have Object properties/parameters my API types, but who's to say the rest of my site is completely hole-free? Who's to say a future endpoint doesn't expose the ability to exploit type names? Why take that chance just because it's easier?

Also - if you are writing a 'proper' API, i.e. specifically for consumption by third-parties and not just for yourself, and you're using Web API, then you're most likely looking to leverage the JSON/XML content-type handling (as a minimum). See how far you get trying to write documentation that's easy to consume, which refers to all your API types differently for XML and JSON formats.

By overriding how JSON.Net understands the type names, you can bring the two into line, making the choice between XML/JSON for your caller purely based on taste, rather than because the type names are easier to remember in one or the other.

JSON.NET Abstract / Derived Class Deserialization with WebAPI 2

The $type function has to be the first attribute in the object.

In the above example I did:

 {
people: [{
name: "Larry",
id: "123",
badge: "12345",
$type: "API.Models.Employee, API"
}]
}

after moving $type to the top like:

 {
people: [{
$type: "API.Models.Employee, API",
name: "Larry",
id: "123",
badge: "12345"
}]
}

the serializer was able to deseralize the object to the correct cast. Gotta love that!

Derived type's properties missing in JSON response from ASP.NET Core API

I ended up creating a custom JsonConverter (System.Text.Json.Serialization namespace) which forces JsonSerializer to serialize to the object's runtime type. See the Solution section below. It's lengthy but it works well and does not require me to sacrifice object oriented principles in my API's design. (If you need something quicker and can use Newtonsoft then check out the top voted answer instead.)

Some background: Microsoft has a System.Text.Json serialization guide with a section titled Serialize properties of derived classes with good information relevant to my question. In particular it explains why properties of derived types are not serialized:

This behavior is intended to help prevent accidental exposure of data
in a derived runtime-created type.

If that is not a concern for you then the behavior can be overridden in the call to JsonSerializer.Serialize by either explicitly specifying the derived type or by specifying object, for example:

    // by specifying the derived type
jsonString = JsonSerializer.Serialize(objToSerialize, objToSerialize.GetType(), serializeOptions);

// or specifying 'object' works too
jsonString = JsonSerializer.Serialize<object>(objToSerialize, serializeOptions);

To accomplish this with ASP.NET Core you need to hook into the serialization process. I did this with a custom JsonConverter that calls JsonSerializer.Serialize one of the ways shown above. I also implemented support for deserialization which, while not explicitly asked for in the original question, is almost always needed anyway. (Oddly, supporting only serialization and not deserialization proved to be tricky anyway.)

Solution

I created a base class, DerivedTypeJsonConverter, which contains all of the serialization & deserialization logic. For each of your base types, you would create a corresponding converter class for it that derives from DerivedTypeJsonConverter. This is explained in the numbered directions below.

This solution follows the "type name handling" convention from Json.NET which introduces support for polymorphism to JSON. It works by including an additional $type property in the derived type's JSON (ex: "$type":"StringResultProperty") that tells the converter what the object's true type is. (One difference: in Json.NET, $type's value is a fully qualified type + assembly name, whereas my $type is a custom string which helps future-proof against namespace/assembly/class name changes.) API callers are expected to include $type properties in their JSON requests for derived types. The serialization logic solves my original problem by ensuring that all of the object's public properties are serialized, and for consistency the $type property is also serialized.

Directions:

1) Copy the DerivedTypeJsonConverter class below into your project.

    using System;
using System.Collections.Generic;
using System.Dynamic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

public abstract class DerivedTypeJsonConverter<TBase> : JsonConverter<TBase>
{
protected abstract string TypeToName(Type type);

protected abstract Type NameToType(string typeName);


private const string TypePropertyName = "$type";


public override bool CanConvert(Type objectType)
{
return typeof(TBase) == objectType;
}


public override TBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// get the $type value by parsing the JSON string into a JsonDocument
JsonDocument jsonDocument = JsonDocument.ParseValue(ref reader);
jsonDocument.RootElement.TryGetProperty(TypePropertyName, out JsonElement typeNameElement);
string typeName = (typeNameElement.ValueKind == JsonValueKind.String) ? typeNameElement.GetString() : null;
if (string.IsNullOrWhiteSpace(typeName)) throw new InvalidOperationException($"Missing or invalid value for {TypePropertyName} (base type {typeof(TBase).FullName}).");

// get the JSON text that was read by the JsonDocument
string json;
using (var stream = new MemoryStream())
using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Encoder = options.Encoder })) {
jsonDocument.WriteTo(writer);
writer.Flush();
json = Encoding.UTF8.GetString(stream.ToArray());
}

// deserialize the JSON to the type specified by $type
try {
return (TBase)JsonSerializer.Deserialize(json, NameToType(typeName), options);
}
catch (Exception ex) {
throw new InvalidOperationException("Invalid JSON in request.", ex);
}
}


public override void Write(Utf8JsonWriter writer, TBase value, JsonSerializerOptions options)
{
// create an ExpandoObject from the value to serialize so we can dynamically add a $type property to it
ExpandoObject expando = ToExpandoObject(value);
expando.TryAdd(TypePropertyName, TypeToName(value.GetType()));

// serialize the expando
JsonSerializer.Serialize(writer, expando, options);
}


private static ExpandoObject ToExpandoObject(object obj)
{
var expando = new ExpandoObject();
if (obj != null) {
// copy all public properties
foreach (PropertyInfo property in obj.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(p => p.CanRead)) {
expando.TryAdd(property.Name, property.GetValue(obj));
}
}

return expando;
}
}

2) For each of your base types, create a class that derives from DerivedTypeJsonConverter. Implement the 2 abstract methods which are for mapping $type strings to actual types. Here is an example for my IResultProperty interface that you can follow.

    public class ResultPropertyJsonConverter : DerivedTypeJsonConverter<IResultProperty>
{
protected override Type NameToType(string typeName)
{
return typeName switch
{
// map string values to types
nameof(StringResultProperty) => typeof(StringResultProperty)

// TODO: Create a case for each derived type
};
}

protected override string TypeToName(Type type)
{
// map types to string values
if (type == typeof(StringResultProperty)) return nameof(StringResultProperty);

// TODO: Create a condition for each derived type
}
}

3) Register the converters in Startup.cs.

    services.AddControllers()
.AddJsonOptions(options => {
options.JsonSerializerOptions.Converters.Add(new ResultPropertyJsonConverter());

// TODO: Add each converter
});

4) In requests to the API, objects of derived types will need to include a $type property. Example JSON: { "Value":"Hi!", "$type":"StringResultProperty" }

Full gist here

Json.Net deserialize into C# derived class

It looks like the problem might be due to the fact that the $type metadata property is not the first property in the JSON for your control. Normally, Json.Net needs this property to be first in order to recognize it. Try adding

MetadataPropertyHandling = MetadataPropertyHandling.ReadAhead

to your settings when deserializing. That should allow Json.Net to find the $type property later in the JSON.

Unable to Deserialize Json in WebApi with JsonSubTypes in .NET 5, although able to in Console App

public IActionResult AddPointRule([FromBody] BasePointRule rule)
{

}

Json.net serialize/deserialize derived types?

If you are storing the type in your text (as you should be in this scenario), you can use the JsonSerializerSettings.

See: how to deserialize JSON into IEnumerable<BaseType> with Newtonsoft JSON.NET

Be careful, though. Using anything other than TypeNameHandling = TypeNameHandling.None could open yourself up to a security vulnerability.



Related Topics



Leave a reply



Submit