Loupe

Injecter un service dans un convertisseur System.Text.Json

La plupart des convertisseurs JSON sont assez simples, et généralement auto-suffisants (ils n'ont pas de dépendances externes). Mais occasionnellement, on peut avoir besoin d'implémenter un convertisseur un peu plus compliqué qui dépend d'un service. Malheureusement, il n'y a pas de mécanisme d'injection de dépendances dans les convertisseurs de System.Text.Json… Du coup, comment accéder au service dont on a besoin ?

Il y a deux variantes de ce problème. L'une a une solution simple, l'autre nécessite un peu de bricolage.

Convertisseur "global"

Un convertisseur "global" s'applique à toutes les instances du (ou des) type(s) qu'il supporte. On l'ajoute à JsonSerializerOptions.Converters, et il gère la conversion de toutes les valeurs dont le type est supporté.

Dans ce scénario, les choses sont assez simples : on crée le convertisseur manuellement en lui passant en paramètre le service dont il a besoin, et on l'ajoute aux options du convertisseur :

var options = new JsonSerializerOptions
{
    Converters =
    {
        new FooConverter(fooService)
    }
};

Cette approche fonctionne bien quand on manipule directement le sérialiseur, mais en pratique, dans une application ASP.NET Core, c'est le framework MVC qui gère la sérialisation. Pour configurer la sérialisation, on passe par la méthode AddJsonOptions, qui ne permet pas d'accéder au IServiceProvider, puisque celui-ci n'est pas encore construit. Il faut donc passer par une classe qui implémente IConfigureOptions<JsonOptions> et qui va configurer les options après la construction du conteneur de services:

services.ConfigureOptions<ConfigureJsonOptions>();

...

private class ConfigureJsonOptions : IConfigureOptions<JsonOptions>
{
    private readonly IFooService _fooService;

    public ConfigureJsonOptions(IFooService fooService)
    {
        _fooService = fooService;
    }

    public void Configure(JsonOptions options)
    {
        options.JsonSerializerOptions.Converters
            .Add(new FooConverter(_fooService));
    }
}

Convertisseur "cas par cas"

Une autre façon d'utiliser un convertisseur est d'ajouter un attribut JsonConverterAttribute sur les propriétés auxquelles le convertisseur doit s'appliquer :

[JsonConverter(typeof(FooConverter))]
public Foo Foo { get; set; }

Dans ce scénario, on n'a pas de contrôle sur l’instanciation du convertisseur, donc on ne peut pas y injecter un service. La situation semble sans issue… à moins de tricher un peu !

Les méthodes Read et Write d'un JsonConverter reçoivent en paramètre les options du sérialiseur. J'espérais un peu trouver une propriété ServiceProvider ou quelque chose comme ça sur JsonSerializerOptions, mais malheureusement il n'y a rien de tel. Par contre, on pourrait détourner une des propriétés de cet objet... La plupart des propriétés sont de type primitif ou enum, donc pas utilisables. PropertyNamingPolicy et Encoder ont un rôle bien spécifique et ne sont sans doute pas de très bons candidats. Il nous reste donc Converters : on pourrait y ajouter un convertisseur "bidon", qui ne convertit rien du tout mais donne accès au conteneur de services. On pourrait ensuite récupérer cet objet depuis les options du sérialiseur et l'utiliser pour résoudre le service dont on a besoin. Voyons donc comment faire ça !

Pour commencer, le convertisseur "bidon". On pourrait lui injecter un IServiceProvider, mais ce serait le provider racine, donc on ne pourrait pas résoudre les services "scoped". Une solution serait d'injecter plutôt un IHttpContextAccessor, à partir duquel on pourrait accéder aux services de la requête courante. Mais dans ce cas, on ne pourrait résoudre un service que dans le contexte du traitement d'une requête… On va donc combiner les deux approches :

/// <summary>
/// This isn't a real converter. It only exists as a hack to expose
/// IServiceProvider on the JsonSerializerOptions.
/// </summary>
public class ServiceProviderDummyConverter :
    JsonConverter<object>,
    IServiceProvider
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IServiceProvider _serviceProvider;

    public ServiceProviderDummyConverter(
        IHttpContextAccessor httpContextAccessor,
        IServiceProvider serviceProvider)
    {
        _httpContextAccessor = httpContextAccessor;
        _serviceProvider = serviceProvider;
    }

    public object GetService(Type serviceType)
    {
        // Use the request services, if available, to be able to resolve
        // scoped services.
        // If there isn't a current HttpContext, just use the root service
        // provider.
        var services = _httpContextAccessor.HttpContext?.RequestServices
            ?? _serviceProvider;
        return services.GetService(serviceType);
    }

    public override bool CanConvert(Type typeToConvert) => false;

    public override object Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        throw new NotSupportedException();
    }

    public override void Write(
        Utf8JsonWriter writer,
        object value,
        JsonSerializerOptions options)
    {
        throw new NotSupportedException();
    }
}

Ensuite, ajoutons ce convertisseur aux options du sérialiseur JSON, en utilisant à nouveau IConfigureOptions. Remarquez qu'il faut également enregistrer le IHttpContextAccessor, qui n'est pas enregistré par défaut.

services.AddHttpContextAccessor();
services.ConfigureOptions<ConfigureJsonOptions>();

...

private class ConfigureJsonOptions : IConfigureOptions<JsonOptions>
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IServiceProvider _serviceProvider;

    public ConfigureJsonOptions(
        IHttpContextAccessor httpContextAccessor,
        IServiceProvider serviceProvider)
    {
        _httpContextAccessor = httpContextAccessor;
        _serviceProvider = serviceProvider;
    }

    public void Configure(JsonOptions options)
    {
        options.JsonSerializerOptions.Converters.Add(
            new ServiceProviderDummyConverter(
                _httpContextAccessor,
                _serviceProvider));
    }
}

Et enfin, on crée une méthode d'extension pour récupérer facilement le IServiceProvider à partir des options JSON :

public static IServiceProvider GetServiceProvider(
    this JsonSerializerOptions options)
{
    return options.Converters.OfType<IServiceProvider>().FirstOrDefault()
        ?? throw new InvalidOperationException(
            "No service provider found in JSON converters");
}

Toutes les pièces du puzzle sont maintenant en place ! Dans notre "vrai" convertisseur (celui qui avait besoin d'un service), on peut maintenant récupérer le service comme ceci :

public override object Read(
    ref Utf8JsonReader reader,
    Type typeToConvert,
    JsonSerializerOptions options)
{
    var fooService = options.GetServiceProvider()
        .GetRequiredService<IFooService>();
    // Do something with the service...
}

Bon, et bien c'est un bricolage assez horrible… Mais ça fonctionne, et en attendant une solution officielle, c'est la meilleure approche que j'ai trouvée !

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus