Loupe

Gérer les requêtes multipart avec du JSON et des uploads en ASP.NET Core

Dans cet article, nous allons voir comment gérer des requêtes multipart (dont le body contient plusieurs parties distinctes) en ASP.NET Core, de façon à pouvoir recevoir dans une même requête des uploads de fichier et un payload JSON.

Mise en situation

Supposons que nous sommes en train de créer une API pour un blog. Notre endpoint "créer un billet" doit recevoir le titre, le corps de l'article, et une image à afficher dans l'en-tête du billet. Cela pose une question : comment envoyer l'image ? Il y a au moins 3 options :

  • Mettre les données binaires de l'image en base64 dans le payload JSON, par exemple :

    {
        "title": "My first blog post",
        "body": "This is going to be the best blog EVER!!!!",
        "tags": [ "first post", "hello" ],
        "image": "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="
    }
    

    Cela fonctionne bien, mais ce n'est probablement pas une très bonne idée d'incorporer un blob de taille arbitraire dans le JSON, car cela va utiliser beaucoup de mémoire si l'image est très grande.

  • Envoyer le JSON et l'image dans des requêtes séparées. C'est simple, mais que se passe-t-il si on veut rendre l'image obligatoire ? Il n'y a aucune garantie que le client va bien envoyer l'image dans une seconde requête, donc notre billet serait dans un état invalide.

  • Envoyer le JSON et l'image dans une seule requête multipart.

Cette dernière approche semble la plus appropriée ; malheureusement c'est également la plus difficile à implémenter… En effet il n'y a pas de support intégré pour ce scénario dans ASP.NET Core.

Limitations du support natif du format multipart d'ASP.NET Core

Il y a quand même un certain niveau de support pour le format multipart/form-data ; par exemple il est possible de binder un modèle à une requête multipart, comme ceci :

public class MyRequestModel
{
    [Required]
    public string Title { get; set; }
    [Required]
    public string Body { get; set; }
    [Required]
    public IFormFile Image { get; set; }
}

public IActionResult Post([FromForm] MyRequestModel request)
{
    ...
}

La requête correspondante ressemblerait à ceci :

POST /api/blog/post HTTP/1.1
Content-Type: multipart/form-data; boundary=AaB03x

--AaB03x
Content-Disposition: form-data; name="title"

My first blog post
--AaB03x
Content-Disposition: form-data; name="body"

This is going to be the best blog EVER!!!!
--AaB03x
Content-Disposition: form-data; name="image"; filename="image.jpg"
Content-Type: image/jpeg

(... contenu du fichier image.jpg ...)
--AaB03x

Mais du coup, on abandonne complètement JSON ; chaque propriété de l'objet est mappée sur une partie de la requête.

Il existe également une classe MultipartReader qui permet de décoder manuellement la requête, mais dans ce cas on ne tire plus parti du model binding et de la validation automatique du modèle.

Modèle souhaité

Idéalement, on aimerait avoir le modèle de requête suivant :

public class CreatePostRequestModel
{
    [Required]
    public string Title { get; set; }
    [Required]
    public string Body { get; set; }
    public string[] Tags { get; set; }
    [Required]
    public IFormFile Image { get; set; }
}

Où les propriétés Title, Body et Tags viennent d'une partie de la requête contenant du JSON, et la propriété Image vient du fichier uploadé dans une autre partie de la requête.

La requête correspondante ressemblerait à ceci :

POST /api/blog/post HTTP/1.1
Content-Type: multipart/form-data; boundary=AaB03x

--AaB03x
Content-Disposition: form-data; name="json"
Content-Type: application/json

{
    "title": "My first blog post",
    "body": "This is going to be the best blog EVER!!!!",
    "tags": [ "first post", "hello" ]
}
--AaB03x
Content-Disposition: form-data; name="image"; filename="image.jpg"
Content-Type: image/jpeg

(... contenu du fichier image.jpg ...)
--AaB03x

Model binder dédié

Heureusement, ASP.NET Core est suffisamment flexible pour qu'on puisse obtenir ce résultat, en écrivant notre propre model binder.

Voici son implémentation :

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;

namespace TestMultipart.ModelBinding
{
    public class JsonWithFilesFormDataModelBinder : IModelBinder
    {
        private readonly IOptions<MvcJsonOptions> _jsonOptions;
        private readonly FormFileModelBinder _formFileModelBinder;

        public JsonWithFilesFormDataModelBinder(
            IOptions<MvcJsonOptions> jsonOptions,
            ILoggerFactory loggerFactory)
        {
            _jsonOptions = jsonOptions;
            _formFileModelBinder = new FormFileModelBinder(loggerFactory);
        }

        public async Task BindModelAsync(
            ModelBindingContext bindingContext)
        {
            if (bindingContext == null)
                throw new ArgumentNullException(nameof(bindingContext));

            // On récupère la partie contenant le JSON
            var valueResult = bindingContext
                .ValueProvider
                .GetValue(bindingContext.FieldName);

            if (valueResult == ValueProviderResult.None)
            {
                // Le JSON n'a pas été trouvé
                var message = bindingContext
                    .ModelMetadata
                    .ModelBindingMessageProvider
                    .MissingBindRequiredValueAccessor(
                            bindingContext.FieldName);

                bindingContext
                    .ModelState
                    .TryAddModelError(
                        bindingContext.ModelName,
                        message);

                return;
            }

            var rawValue = valueResult.FirstValue;

            // On désérialise the JSON
            var model = JsonConvert.DeserializeObject(
                rawValue,
                bindingContext.ModelType,
                _jsonOptions.Value.SerializerSettings);

            // Maintenant, on binde chaque propriété IFormFile
            // avec les autres parties de la requête
            foreach (var property in
                bindingContext.ModelMetadata.Properties)
            {
                if (property.ModelType != typeof(IFormFile))
                    continue;

                var fieldName = property.BinderModelName
                    ?? property.PropertyName;
                var modelName = fieldName;
                var propertyModel = property
                    .PropertyGetter(bindingContext.Model);
                ModelBindingResult propertyResult;

                using (bindingContext.EnterNestedScope(
                    property,
                    fieldName,
                    modelName,
                    propertyModel))
                {
                    await _formFileModelBinder
                        .BindModelAsync(bindingContext);
                    propertyResult = bindingContext.Result;
                }

                if (propertyResult.IsModelSet)
                {
                    // Le IFormFile a été bindé avec succès, on l'affecte
                    // à la propriété correspondante du modèle
                    property.PropertySetter(model, propertyResult.Model);
                }
                else if (property.IsBindingRequired)
                {
                    var message = property
                        .ModelBindingMessageProvider
                        .MissingBindRequiredValueAccessor(fieldName);

                    bindingContext
                        .ModelState
                        .TryAddModelError(modelName, message);
                }
            }

            // On affecte le modèle construit au résultat du binding
            bindingContext.Result = ModelBindingResult.Success(model);
        }
    }
}

Utilisation

Pour utiliser ce model binder, on applique simplement cet attribut à la classe CreatePostRequestModel ci-dessus :

[ModelBinder(typeof(JsonWithFilesFormDataModelBinder), Name = "json")]
public class CreatePostRequestModel

Cela indique à ASP.NET Core qu'il faut utiliser notre model binder pour construire une instance de CreatePostRequestModel. Le Name = "json" indique au binder dans quelle partie de la requête il doit récupérer le JSON (c'est le bindingContext.FieldName dans le code du binder).

Maintenant on n'a plus qu'à passer un CreatePostRequestModel à notre action de contrôleur :

[HttpPost]
public ActionResult<Post> CreatePost(CreatePostRequestModel post)
{
    ...
}

Cette approche nous permet d'avoir du code propre dans le modèle et le contrôleur, et de continuer à tirer parti du model binding et de la validation du modèle. Par contre la requête ne sera pas correctement représentée par Swagger/OpenAPI, mais bon, on ne peut pas tout avoir…

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus