Loupe

Exposer un type custom en tant que string JSON dans une API ASP.NET Core

Il est parfois nécessaire dans une API d'exposer un type non primitif qui a une représentation "naturelle" sous forme de chaîne de caractères. Par exemple, une représentation standard pour une durée est le format ISO 8601, dans lequel "1 mois, 2 jours, 3 heures et 4 minutes" peut être représenté sous la forme P1M2DT3H4M (remarquez que c'est différent d'un Timespan, qui n'a pas la notion de mois ou d'année calendaire). Une durée peut être représentée en C# par un type custom, comme la structure Duration de mon projet Iso8601DurationHelper. J'utiliserai ce type comme exemple pour la suite de cet article.

Sérialisation JSON

Supposons qu'on souhaite exposer la classe suivante dans une API ASP.NET Core :

public class Vacation
{
    public int Id { get; set; }
    public DateTime StartDate { get; set; }
    public Duration Duration { get; set; }
}

Si on utilise System.Text.Json pour la sérialisation JSON (ce qui est maintenant l'option par défaut dans ASP.NET Core 3.0 ou supérieur), cette classe sera sérialisée comme ceci :

{
    "id": 1,
    "startDate": "2020-08-01T00:00:00",
    "duration": {
        "years": 0,
        "months": 0,
        "weeks": 3,
        "days": 0,
        "hours": 0,
        "minutes": 0,
        "seconds": 0
    }
}

Bien qu'utilisable, cette représentation est très verbeuse et pas très lisible... Ce serait mieux si la durée était sérialisée sous la forme "P3W". Voyons donc comment arriver à ce résultat !

Option 1 : Utiliser JSON.NET pour la sérialisation

Si le type à sérialiser a un TypeConverter associé, et que celui-ci supporte la conversion depuis ou vers une chaîne de caractères (ce qui est le cas pour Iso8601DurationHelper.Duration), JSON.NET utilisera automatiquement ce convertisseur. Donc si on active le sérialiseur JSON.NET comme indiqué dans la documentation, on obtiendra le résultat voulu :

{
    "id": 1,
    "startDate": "2020-08-01T00:00:00",
    "duration": "P3W"
}

Bon, c'était facile finalement ! Sauf que... changer de sérialiseur juste pour ça n'est sans doute pas une très bonne idée. System.Text.Json a pas mal de limitations comparé à JSON.NET, mais il est aussi nettement plus performant. Le choix de l'un ou de l'autre doit donc être le fruit d'une mûre réflexion, et ce scénario particulier n'est sans doute pas le critère de choix le plus important.

Option 2 : Ajouter un convertisseur pour System.Text.Json

Même si System.Text.Json n'a pas autant de fonctionnalités que JSON.NET, il est quand même assez personnalisable. Par exemple, on peut définir des convertisseurs JSON pour contrôler la façon dont un type est sérialisé ou désérialisé.

Un convertisseur minimal pour Duration ressemble à ceci :

public class DurationJsonConverter : JsonConverter<Duration>
{
    public override Duration Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        return Duration.Parse(reader.GetString());
    }

    public override void Write(Utf8JsonWriter writer, Duration value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString());
    }
}

Pas bien méchant ! Il faut maintenant configurer System.Text.Json pour utiliser notre convertisseur. Pour cela, dans la méthode ConfigureServices de la classe Startup, on trouve l'appel à AddControllers (ou AddControllersWithViews , AddMvc ou AddRazorPages, selon ce que l'application utilise), et on y ajoute un appel à AddJsonOptions, comme ceci :

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

Avec ce convertisseur en place, la durée est maintenant sérialisée sous la forme voulue.

Notez que cette approche consistant à implémenter un convertisseur JSON fonctionne aussi avec JSON.NET, avec une implémentation un peu différente. Je n'entrerai pas dans les détails ici.

Description OpenAPI (Swagger)

Si l'API expose une documentation Swagger avec Swashbuckle, on remarque une incohérence entre la façon dont la durée est sérialisée, et sa représentation dans le schéma OpenAPI. SwaggerUI affiche ceci comme exemple de réponse :

{
    "id": 0,
    "startDate": "2020-06-27T14:36:43.417Z",
    "duration": {
        "years": 0,
        "months": 0,
        "weeks": 3,
        "days": 0,
        "hours": 0,
        "minutes": 0,
        "seconds": 0
    }
}

On dirait que notre convertisseur a été ignoré ! C'est simplement parce que Swashbuckle ne sait pas comment nous avons configuré le sérialiseur : il faut lui indiquer explicitement que Duration est sérialisé comme une chaîne de caractères. Heureusement, c'est très facile à faire ! Dans l'appel à AddSwaggerGen, il faut juste utiliser la méthode MapType comme ceci :

services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
    options.MapType(typeof(Duration), () => new OpenApiSchema
    {
        Type = "string",
        Example = new OpenApiString("P3W")
    });
});

Swashbuckle produira maintenant le bon schéma pour Duration, et l'exemple précédent sera bien cohérent avec ce que l'API renvoie :

{
  "id": 0,
  "startDate": "2020-06-27T14:36:43.417Z",
  "duration": "P3W"
}

Remarques :

  • On a dû spécifier explicitement une valeur d'exemple dans le schéma OpenAPI, sinon on aurait eu la valeur "string" au lieu d'une vraie durée ISO 8601.
  • Avec la version actuelle de Swashbuckle.AspNetCore (5.5.1 à l'heure où j'écris ces lignes), si on utilise aussi la version nullable de Duration, il faut configurer séparément le schéma pour Duration?. Il y a une issue Github à ce sujet, si elle est résolue on n'aura plus besoin de configurer séparément les types nullables.
Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus