web-dev-qa-db-fra.com

Propriétés du type dérivé manquantes dans la réponse JSON de l'API ASP.NET Core

La réponse JSON de mon contrôleur d'API ASP.NET Core 3.1 n'a pas de propriétés. Cela se produit lorsqu'une propriété utilise un type dérivé; toutes les propriétés définies dans le type dérivé mais pas dans la base/l'interface ne seront pas sérialisées en JSON. Il semble qu'il y ait un manque de prise en charge du polymorphisme dans la réponse, comme si la sérialisation était basée sur le type défini d'une propriété au lieu de son type d'exécution. Comment puis-je modifier ce comportement pour m'assurer que toutes les propriétés publiques sont incluses dans la réponse JSON?

Exemple:

Mon contrôleur d'API Web .NET Core renvoie cet objet qui a une propriété avec un type d'interface.

    // controller returns this object
    public class Result
    {
        public IResultProperty ResultProperty { get; set; }   // property uses an interface type
    }

    public interface IResultProperty
    { }

Voici un type dérivé qui définit une nouvelle propriété publique nommée Value.

    public class StringResultProperty : IResultProperty
    {
        public string Value { get; set; }
    }

Si je retourne le type dérivé de mon contrôleur comme ceci:

    return new MainResult {
        ResultProperty = new StringResultProperty { Value = "Hi there!" }
    };

alors la réponse réelle inclut un objet vide (la propriété Value est manquante):

enter image description here

Je veux que la réponse soit:

    {
        "ResultProperty": { "Value": "Hi there!" }
    }
4
Keith

J'ai fini par créer un JsonConverter personnalisé (espace de noms System.Text.Json.Serialization) qui oblige JsonSerializer à sérialiser le type runtime de l'objet. Consultez la section Solution ci-dessous. C'est long mais cela fonctionne bien et ne m'oblige pas à sacrifier les principes orientés objet dans la conception de mon API.

Quelques informations générales: Microsoft a un guide de sérialisation System.Text.Json avec une section intitulée Sérialiser les propriétés des classes dérivées avec de bonnes informations pertinent par rapport à ma question. En particulier, il explique pourquoi les propriétés des types dérivés ne sont pas sérialisées:

Ce comportement est destiné à empêcher l'exposition accidentelle de données dans un type dérivé créé à l'exécution.

Si cela ne vous préoccupe pas, le comportement peut être remplacé lors de l'appel à JsonSerializer.Serialize soit en spécifiant explicitement le type dérivé, soit en spécifiant object, par exemple:

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

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

Pour ce faire avec ASP.NET Core, vous devez vous connecter au processus de sérialisation. J'ai fait cela avec un JsonConverter personnalisé qui appelle JsonSerializer.Serialize de l'une des manières indiquées ci-dessus. J'ai également implémenté le support pour désérialisation qui, bien que cela ne soit pas explicitement demandé dans la question d'origine, est presque toujours nécessaire de toute façon. (Curieusement, la prise en charge uniquement de la sérialisation et non de la désérialisation s'est avérée délicate de toute façon.)

Solution

J'ai créé une classe de base, DerivedTypeJsonConverter, qui contient toute la logique de sérialisation et de désérialisation. Pour chacun de vos types de base, vous créeriez une classe de convertisseur correspondante qui dérive de DerivedTypeJsonConverter. Ceci est expliqué dans les directions numérotées ci-dessous.

Cette solution suit la convention "type name handling" de Json.NET qui introduit la prise en charge du polymorphisme dans JSON. Il fonctionne en incluant une propriété supplémentaire $ type dans le JSON du type dérivé (ex: "$type":"StringResultProperty") qui indique au convertisseur quel est le vrai type de l'objet. (Une différence: dans Json.NET, la valeur de $ type est un type complet + nom d'assembly, alors que my $ type est une chaîne personnalisée qui aide à être à l'épreuve du futur contre les changements de noms d'espace de noms/d'assembly/de classe.) Les appelants d'API sont censés inclure $ type dans leurs requêtes JSON pour les types dérivés. La logique de sérialisation résout mon problème initial en s'assurant que toutes les propriétés publiques de l'objet sont sérialisées, et par souci de cohérence, la propriété $ type est également sérialisée.

Instructions:

1) Copiez la classe DerivedTypeJsonConverter ci-dessous dans votre projet.

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) Pour chacun de vos types de base, créez une classe qui dérive de DerivedTypeJsonConverter. Implémentez les 2 méthodes abstraites qui sont pour les chaînes de type mappig $ aux types réels. Voici un exemple de mon interface IResultProperty que vous pouvez suivre.

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) Enregistrez les convertisseurs dans Startup.cs.

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

        // TODO: Add each converter
    });

4) Dans les requêtes adressées à l'API, les objets de types dérivés devront inclure une propriété $ type. Exemple JSON: { "Value":"Hi!", "$type":"StringResultProperty" }

Full Gist ici

5
Keith

Bien que les réponses ci-dessus soient bonnes et résolvent le problème, si tout ce que vous voulez, c'est que le comportement général soit comme avant netcore3, vous pouvez utiliser le Microsoft.AspNetCore.Mvc.NewtonsoftJson package nuget et dans Startup.cs faire :

services.AddControllers().AddNewtonsoftJson()

Plus d'informations ici . De cette façon, vous n'avez pas besoin de créer de convertisseurs json supplémentaires.

1
Fredrik Ek

documentation montre comment sérialiser comme classe dérivée lors de l'appel direct du sérialiseur. La même technique peut également être utilisée dans un convertisseur personnalisé avec lequel nous pouvons ensuite étiqueter nos classes.

Tout d'abord, créez un convertisseur personnalisé

public class AsRuntimeTypeConverter<T> : JsonConverter<T>
{
    public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        return JsonSerializer.Deserialize<T>(ref reader, options);
    }

    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        JsonSerializer.Serialize(writer, value, value?.GetType() ?? typeof(object), options);
    }
}

Marquez ensuite les classes pertinentes à utiliser avec le nouveau convertisseur

[JsonConverter(typeof(AsRuntimeTypeConverter<MyBaseClass>))]
public class MyBaseClass
{
   ...

Alternativement, le convertisseur peut être enregistré dans startup.cs à la place

services
  .AddControllers(options =>
     .AddJsonOptions(options =>
            {
                options.JsonSerializerOptions.Converters.Add(new AsRuntimeTypeConverter<MyBaseClass>());
            }));
1
nimatt

à l'aide du package nuget Microsoft.AspNetCore.Mvc.NewtonsoftJson et

services.AddControllers().AddNewtonsoftJson()

dans mon projet api, Startup.cs a également fonctionné pour moi. aucun code supplémentaire requis et merci Fredrik Ek!

0
Bruce Holman

C'est le résultat attendu. Vous êtes upcasting lorsque vous faites cela, donc ce qui sera sérialisé est l'objet upcasted, pas le type dérivé réel. Si vous avez besoin d'éléments du type dérivé, cela doit être le type de la propriété. Vous voudrez peut-être utiliser des génériques pour cette raison. En d'autres termes:

public class Result<TResultProperty>
    where TResultProperty : IResultProperty
{
    public TResultProperty ResultProperty { get; set; }   // property uses an interface type
}

Ensuite:

return new Result<StringResultProperty> {
    ResultProperty = new StringResultProperty { Value = "Hi there!" }  
};
0
Chris Pratt