Loupe

Records C# 9 comme ids fortement typés - Partie 2 : paramètres de route et de query en ASP.NET Core

Dans mon précédent article, j'ai montré qu'il était très facile d'utiliser des types record C# 9 comme ids fortement typés :

public record ProductId(int Value);

Mais malheureusement, ce n'est pas tout à fait suffisant : il y a quelques soucis à corriger avant de pouvoir vraiment utiliser cette solution. Par exemple, ASP.NET Core ne sait pas gérer ces ids fortement typés dans les paramètres de route et de query. Dans cet article, je vais présenter une façon de régler ce problème.

Model binding des paramètres de route et de query

Supposons qu'on ait une entité comme celle-ci :

public record ProductId(int Value);

public class Product
{
    public ProductId Id { get; set; }
    public string Name { get; set; }
    public decimal UnitPrice { get; set; }
}

Et un endpoint d'API comme celui-ci :

[ApiController]
[Route("api/[controller]")]
public class ProductController : ControllerBase
{
    ...

    [HttpGet("{id}")]
    public ActionResult<Product> GetProduct(ProductId id)
    {
        // implementation not relevant...
    }
}

Si on essaie d'appeler ce endpoint avec une requête GET sur /api/product/1, on obtient ceci :

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.13",
    "title": "Unsupported Media Type",
    "status": 415,
    "traceId": "00-3600640f4e053b43b5ccefabe7eebd5a-159f5ca18d189142-00"
}

Oups ! Pas très encourageant… Le problème est qu'ASP.NET Core ne sait pas comment convertir le 1 dans l'URL en une instance de ProductId. Comme ce n'est pas un type primitif et qu'il n'a pas de convertisseur associé, ASP.NET Core suppose qu'il faut le lire comme un objet depuis le body de la requête. Mais notre requête n'a pas de body puisque c'est un GET...

Implémenter un convertisseur de type

La solution est d'implémenter un TypeConverter pour ProductId. Ce n'est pas très compliqué :

public class ProductIdConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) =>
        sourceType == typeof(string);
    public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) =>
        destinationType == typeof(string);

    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
    {
        return value switch
        {
            string s => new ProductId(int.Parse(s)),
            null => null,
            _ => throw new ArgumentException($"Cannot convert from {value} to ProductId", nameof(value))
        };
    }

    public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
    {
        if (destinationType == typeof(string))
        {
            return value switch
            {
                ProductId id => id.Value.ToString(),
                null => null,
                _ => throw new ArgumentException($"Cannot convert {value} to string", nameof(value))
            };
        }

        throw new ArgumentException($"Cannot convert {value ?? "(null)"} to {destinationType}", nameof(destinationType));
    }
}

(Remarquez que pour ne pas alourdir le code, j'ai géré seulement la conversion depuis et vers string. Dans un scénario réel, on gérerait sans doute aussi la conversion depuis et vers int.)

On associe le convertisseur au type ProductId à l'aide de l'attribut [TypeConverter] :

[TypeConverter(typeof(ProductIdConverter))]
public record ProductId(int Value);

Réessayons maintenant d'appeler notre endpoint d'API :

{
    "id": {
        "value": 1
    },
    "name": "Apple",
    "unitPrice": 0.8
}

Ça marche… plus ou moins. Le fait que l'id soit représenté comme un objet JSON est un peu embêtant, mais on réglera ça plus tard. Ce qui m'ennuie un peu plus, c'est la quantité de code qu'il a fallu écrire pour gérer la conversion d'un seul id fortement typé. S'il faut écrire tout ça pour chaque type d'id, on perd tout le bénéfice d'avoir une syntaxe concise pour les déclarer. Ce qu'il nous faudrait, c'est un convertisseur générique capable de gérer n'importe quel id fortement typé.

Type de base commun pour les ids fortement typés

Pour pouvoir écrire un convertisseur qui fonctionne pour tous les ids fortement typés, il faut que nos ids aient quelque chose en commun, comme un type de base ou une interface. Partons sur un record de base :

public abstract record StronglyTypedId<TValue>(TValue Value) where TValue : notnull;

On peut maintenant faire hériter ProductId de ce type de base :

public record ProductId(int Value) : StronglyTypedId<int>(Value);

Bon, c'est un peu plus long qu'avant à écrire, mais ça va encore… Et le fait d'avoir un type commun va offrir pas mal d'avantages par la suite.

Convertisseur générique d'ids fortement typés

Maintenant qu'on a un type de base commun, on va pouvoir écrire un convertisseur générique. Ça va être un peu plus compliqué que celui spécifique à ProductId, mais on n'aura à l'écrire qu'une seule fois.

Créons d'abord une classe helper pour

  • Tester si un type est un id fortement typé et récupérer le type de la valeur
  • Générer et mettre en cache un delegate pour créer une instance d'id fortement typé
public static class StronglyTypedIdHelper
{
    private static readonly ConcurrentDictionary<Type, Delegate> StronglyTypedIdFactories = new();

    public static Func<TValue, object> GetFactory<TValue>(Type stronglyTypedIdType)
        where TValue : notnull
    {
        return (Func<TValue, object>)StronglyTypedIdFactories.GetOrAdd(
            stronglyTypedIdType,
            CreateFactory<TValue>);
    }

    private static Func<TValue, object> CreateFactory<TValue>(Type stronglyTypedIdType)
        where TValue : notnull
    {
        if (!IsStronglyTypedId(stronglyTypedIdType))
            throw new ArgumentException($"Type '{stronglyTypedIdType}' is not a strongly-typed id type", nameof(stronglyTypedIdType));

        var ctor = stronglyTypedIdType.GetConstructor(new[] { typeof(TValue) });
        if (ctor is null)
            throw new ArgumentException($"Type '{stronglyTypedIdType}' doesn't have a constructor with one parameter of type '{typeof(TValue)}'", nameof(stronglyTypedIdType));

        var param = Expression.Parameter(typeof(TValue), "value");
        var body = Expression.New(ctor, param);
        var lambda = Expression.Lambda<Func<TValue, object>>(body, param);
        return lambda.Compile();
    }

    public static bool IsStronglyTypedId(Type type) => IsStronglyTypedId(type, out _);

    public static bool IsStronglyTypedId(Type type, [NotNullWhen(true)] out Type idType)
    {
        if (type is null)
            throw new ArgumentNullException(nameof(type));

        if (type.BaseType is Type baseType &&
            baseType.IsGenericType &&
            baseType.GetGenericTypeDefinition() == typeof(StronglyTypedId<>))
        {
            idType = baseType.GetGenericArguments()[0];
            return true;
        }

        idType = null;
        return false;
    }
}

Ce helper va nous aider pour écrire le convertisseur, et servira également à d'autres choses par la suite. La partie "compliquée" étant déjà gérée par le helper, l'écriture du convertisseur n'est plus trop méchante :

public class StronglyTypedIdConverter<TValue> : TypeConverter
    where TValue : notnull
{
    private static readonly TypeConverter IdValueConverter = GetIdValueConverter();

    private static TypeConverter GetIdValueConverter()
    {
        var converter = TypeDescriptor.GetConverter(typeof(TValue));
        if (!converter.CanConvertFrom(typeof(string)))
            throw new InvalidOperationException(
                $"Type '{typeof(TValue)}' doesn't have a converter that can convert from string");
        return converter;
    }

    private readonly Type _type;
    public StronglyTypedIdConverter(Type type)
    {
        _type = type;
    }

    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
    {
        return sourceType == typeof(string)
            || sourceType == typeof(TValue)
            || base.CanConvertFrom(context, sourceType);
    }

    public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
    {
        return destinationType == typeof(string)
            || destinationType == typeof(TValue)
            || base.CanConvertTo(context, destinationType);
    }

    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
    {
        if (value is string s)
        {
            value = IdValueConverter.ConvertFrom(s);
        }

        if (value is TValue idValue)
        {
            var factory = StronglyTypedIdHelper.GetFactory<TValue>(_type);
            return factory(idValue);
        }

        return base.ConvertFrom(context, culture, value);
    }

    public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
    {
        if (value is null)
            throw new ArgumentNullException(nameof(value));

        var stronglyTypedId = (StronglyTypedId<TValue>)value;
        TValue idValue = stronglyTypedId.Value;
        if (destinationType == typeof(string))
            return idValue.ToString()!;
        if (destinationType == typeof(TValue))
            return idValue;
        return base.ConvertTo(context, culture, value, destinationType);
    }
}

Ce convertisseur est capable de gérer les conversions depuis et vers string et TValue, ce qui devrait suffire à nos besoins.

Maintenant, comment associer ce convertisseur à tous les ids fortement typés ? Il faut tout simplement l'appliquer au type de base StronglyTypedId<TValue>. Sauf que… le convertisseur est lui-même générique, et on ne peut pas utiliser TValue dans l'attribut [TypeConverter]. On ne peut pas non plus passer StronglyTypedIdConverter<>, car le type du convertisseur ne peut pas être un type générique ouvert. On va donc devoir utiliser un convertisseur intermédiaire non-générique, qui va créer le vrai convertisseur et lui déléguer l'implémentation réelle :

public class StronglyTypedIdConverter : TypeConverter
{
    private static readonly ConcurrentDictionary<Type, TypeConverter> ActualConverters = new();

    private readonly TypeConverter _innerConverter;

    public StronglyTypedIdConverter(Type stronglyTypedIdType)
    {
        _innerConverter = ActualConverters.GetOrAdd(stronglyTypedIdType, CreateActualConverter);
    }

    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) =>
        _innerConverter.CanConvertFrom(context, sourceType);
    public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) =>
        _innerConverter.CanConvertTo(context, destinationType);
    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) =>
        _innerConverter.ConvertFrom(context, culture, value);
    public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType) =>
        _innerConverter.ConvertTo(context, culture, value, destinationType);


    private static TypeConverter CreateActualConverter(Type stronglyTypedIdType)
    {
        if (!StronglyTypedIdHelper.IsStronglyTypedId(stronglyTypedIdType, out var idType))
            throw new InvalidOperationException($"The type '{stronglyTypedIdType}' is not a strongly typed id");

        var actualConverterType = typeof(StronglyTypedIdConverter<>).MakeGenericType(idType);
        return (TypeConverter)Activator.CreateInstance(actualConverterType, stronglyTypedIdType)!;
    }
}

On peut maintenant appliquer ce convertisseur à notre type de base :

[TypeConverter(typeof(StronglyTypedIdConverter))]
public abstract record StronglyTypedId<TValue>(TValue Value) where TValue : notnull;

Et on peut supprimer ProductIdConverter, qui n'est plus nécessaire. Le model binding fonctionnera maintenant correctement dans les paramètres de route et de query, pour n'importe quel id fortement typé.

Cet article commence à être un peu long, donc je vais m'arrêter là pour aujourd'hui. La prochaine fois, on s'attaque à la sérialisation JSON !

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus