Comment puis-je télécharger une liste de fichiers (images) et de données json sur le contrôleur API Web ASP.NET Core à l'aide du téléchargement multipart?
Je peux recevoir avec succès une liste de fichiers, chargés avec le type de contenu multipart/form-data
comme celui-ci:
public async Task<IActionResult> Upload(IList<IFormFile> files)
Et bien sûr, je peux recevoir avec succès le corps de la requête HTTP formatée pour mon objet en utilisant le formateur JSON par défaut, comme ceci:
public void Post([FromBody]SomeObject value)
Mais comment puis-je combiner ces deux dans une seule action de contrôleur? Comment télécharger des images et des données JSON et les lier à mes objets?
Apparemment, il n'y a pas de moyen construit pour faire ce que je veux. Alors j’ai fini par écrire ma propre ModelBinder
pour gérer cette situation. Je n'ai trouvé aucune documentation officielle sur la liaison des modèles personnalisés, mais j'ai utilisé ce message comme référence.
Custom ModelBinder
recherche les propriétés décorées avec l'attribut FromJson
et désérialise la chaîne issue d'une demande multipart en JSON. J'enveloppe mon modèle dans une autre classe (wrapper) qui possède les propriétés model et IFormFile
.
(IJsonAttribute.cs:} _
public interface IJsonAttribute
{
object TryConvert(string modelValue, Type targertType, out bool success);
}
FromJsonAttribute.cs:
using Newtonsoft.Json;
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class FromJsonAttribute : Attribute, IJsonAttribute
{
public object TryConvert(string modelValue, Type targetType, out bool success)
{
var value = JsonConvert.DeserializeObject(modelValue, targetType);
success = value != null;
return value;
}
}
(JsonModelBinderProvider.cs:} _
public class JsonModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null) throw new ArgumentNullException(nameof(context));
if (context.Metadata.IsComplexType)
{
var propName = context.Metadata.PropertyName;
var propInfo = context.Metadata.ContainerType?.GetProperty(propName);
if(propName == null || propInfo == null)
return null;
// Look for FromJson attributes
var attribute = propInfo.GetCustomAttributes(typeof(FromJsonAttribute), false).FirstOrDefault();
if (attribute != null)
return new JsonModelBinder(context.Metadata.ModelType, attribute as IJsonAttribute);
}
return null;
}
}
(JsonModelBinder.cs:} _
public class JsonModelBinder : IModelBinder
{
private IJsonAttribute _attribute;
private Type _targetType;
public JsonModelBinder(Type type, IJsonAttribute attribute)
{
if (type == null) throw new ArgumentNullException(nameof(type));
_attribute = attribute as IJsonAttribute;
_targetType = type;
}
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null) throw new ArgumentNullException(nameof(bindingContext));
// Check the value sent in
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult != ValueProviderResult.None)
{
bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);
// Attempt to convert the input value
var valueAsString = valueProviderResult.FirstValue;
bool success;
var result = _attribute.TryConvert(valueAsString, _targetType, out success);
if (success)
{
bindingContext.Result = ModelBindingResult.Success(result);
return Task.CompletedTask;
}
}
return Task.CompletedTask;
}
}
Utilisation:
public class MyModelWrapper
{
public IList<IFormFile> Files { get; set; }
[FromJson]
public MyModel Model { get; set; } // <-- JSON will be deserialized to this object
}
// Controller action:
public async Task<IActionResult> Upload(MyModelWrapper modelWrapper)
{
}
// Add custom binder provider in Startup.cs ConfigureServices
services.AddMvc(properties =>
{
properties.ModelBinderProviders.Insert(0, new JsonModelBinderProvider());
});
Il y a une solution plus simple, fortement inspirée par la réponse d'Andrius . En utilisant ModelBinderAttribute
vous n'avez pas à spécifier de fournisseur de modèle ou de classeur. Cela économise beaucoup de code. Votre action de contrôleur ressemblerait à ceci:
public IActionResult Upload(
[ModelBinder(BinderType = typeof(JsonModelBinder))] SomeObject value,
IList<IFormFile> files)
{
// Use serialized json object 'value'
// Use uploaded 'files'
}
Code derrière JsonModelBinder
(ou utilisez le complet paquet NuGet ):
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ModelBinding;
public class JsonModelBinder : IModelBinder {
public Task BindModelAsync(ModelBindingContext bindingContext) {
if (bindingContext == null) {
throw new ArgumentNullException(nameof(bindingContext));
}
// Check the value sent in
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult != ValueProviderResult.None) {
bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);
// Attempt to convert the input value
var valueAsString = valueProviderResult.FirstValue;
var result = Newtonsoft.Json.JsonConvert.DeserializeObject(valueAsString, bindingContext.ModelType);
if (result != null) {
bindingContext.Result = ModelBindingResult.Success(result);
return Task.CompletedTask;
}
}
return Task.CompletedTask;
}
}
Voici un exemple de demande http brute telle qu'acceptée par l'action du contrôleur Upload
ci-dessus.
Une demande multipart/form-data
est divisée en plusieurs parties, chacune étant séparée par le boundary=12345
spécifié. Un nom a été attribué à chaque partie dans son en-tête Content-Disposition
-. Avec ces noms, default ASP.Net-Core
sait quelle partie est liée à quel paramètre dans l'action du contrôleur.
Les fichiers liés à IFormFile
doivent en outre spécifier une filename
comme dans la deuxième partie de la demande. Content-Type
n'est pas requis.
Une autre chose à noter est que les parties JSON doivent être désérialisables dans les types de paramètres tels que définis dans l'action du contrôleur. Donc, dans ce cas, le type SomeObject
devrait avoir une propriété key
de type string
.
POST http://localhost:5000/home/upload HTTP/1.1
Host: localhost:5000
Content-Type: multipart/form-data; boundary=12345
Content-Length: 218
--12345
Content-Disposition: form-data; name="value"
{"key": "value"}
--12345
Content-Disposition: form-data; name="files"; filename="file.txt"
Content-Type: text/plain
This is a simple text file
--12345--
Postman peut être utilisé pour appeler l'action et tester le code côté serveur. C'est assez simple et principalement basé sur l'interface utilisateur. Créez une nouvelle demande et sélectionnez form-data dans l'onglet Body. Vous pouvez maintenant choisir entre texte et fichier pour chaque partie du dossier.
Suite à l’excellente réponse de @ bruno-zell, si vous n’avez qu’un seul fichier (je n’ai pas testé avec un IList<IFormFile>
), vous pouvez également déclarer votre contrôleur ainsi:
public async Task<IActionResult> Create([FromForm] CreateParameters parameters, IFormFile file)
{
const string filePath = "./Files/";
if (file.Length > 0)
{
using (var stream = new FileStream($"{filePath}{file.FileName}", FileMode.Create))
{
await file.CopyToAsync(stream);
}
}
// Save CreateParameters properties to database
var myThing = _mapper.Map<Models.Thing>(parameters);
myThing.FileName = file.FileName;
_efContext.Things.Add(myThing);
_efContext.SaveChanges();
return Ok(_mapper.Map<SomeObjectReturnDto>(myThing));
}
Vous pouvez ensuite utiliser la méthode Postman indiquée dans la réponse de Bruno pour appeler votre contrôleur.
Je ne suis pas sûr si vous pouvez faire les deux choses en une seule étape.
Par le passé, je l'ai fait en téléchargeant le fichier via ajax et en renvoyant l'URL du fichier dans la réponse, puis en le transmettant avec la demande de publication pour enregistrer l'enregistrement réel.
J'ai eu un problème similaire et j'ai résolu le problème en utilisant l'attribut [FromForm]
et FileUploadModelView
dans la fonction comme suit:
[HttpPost("Save")]
public async Task<IActionResult> Save([FromForm] ProfileEditViewModel model)
{
return null;
}
Je travaille avec Angular 7 sur le front-end, donc je me sers de la classe FormData
, qui vous permet d’ajouter des chaînes ou des blobs à un formulaire. Ils peuvent être extraits du formulaire dans l'action du contrôleur à l'aide de l'attribut [FromForm]
. J'ajoute le fichier à l'objet FormData
, puis je stringie les données que je souhaite envoyer avec le fichier, je les ajoute à l'objet FormData
et je désérialise la chaîne dans l'action de mon contrôleur.
Ainsi:
//front-end:
let formData: FormData = new FormData();
formData.append('File', fileToUpload);
formData.append('jsonString', JSON.stringify(myObject));
//request using a var of type HttpClient
http.post(url, formData);
//controller action
public Upload([FromForm] IFormFile File, [FromForm] string jsonString)
{
SomeType myObj = JsonConvert.DeserializeObject<SomeType>(jsonString);
//do stuff with 'File'
//do stuff with 'myObj'
}
Vous avez maintenant une poignée sur le fichier et l'objet. Notez que le nom que vous fournissez dans la liste des paramètres de votre action de contrôleur doit correspond au nom que vous avez fourni lors de l’ajout à l’objet FormData
au niveau de l’interface frontale.