.net Core API – nahrání souborů a json objektů

Dnes se podíváme na problém, se kterým se můžete setkat v .net core API.

Zadání: založte POST endpoint, který přijme pole souborů a pole objektů (informace pro každý soubor).

Zní to jednoduše, však řešení má trochu háček. pojďme si to ukázat. Založme request:

using System.ComponentModel.DataAnnotations;

namespace ApiExample.Dtos
{
    public class SnapRequest
    {
        /// <summary>
        /// Metadata collection
        /// </summary>
        [Required]
        public required IEnumerable<Metadata> Metadatas { get; set; }

        /// <summary>
        /// Files collection
        /// </summary>
        [Required]
        public required IEnumerable<IFormFile> Files { get; set; }
    }
}

Akce v kontroleru:

using ApiExample.Dtos;
using Microsoft.AspNetCore.Mvc;

namespace ApiExample.Controllers
{
    public class SnapController : Controller
    {
        [HttpPost("create")]
        public IActionResult Create(SnapRequest snapRequest)
        {
            return Ok(snapRequest);
        }
    }
}

Výsledek ve Swaggeru vypadá takto:

Metadata se automaticky vložily do query, což může být problém, protože query je limitované maximální délkou (defaultně 260znaků). Všimněte si i toho, že když vyplníte například 2 metadata a vložíte 2 soubory po zpracování požadavku v akci kontroleru máte 0 metadat a 2 soubory 🙂

FormForm

Pojďme to torchu vylepšit. Query parametry jsou vhodné pro krétké informace, nejsou vhodné pro pole objektů. Pro pole objektů je bhodnější body, ale tam už máme soubory. Pošleme si tedy metadata přes FromForm:

using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;

namespace ApiExample.Dtos
{
    public class SnapRequest
    {
        /// <summary>
        /// Metadata collection
        /// </summary>
        [Required]
        [FromForm]
        public required IEnumerable<Metadata> Metadatas { get; set; }

        /// <summary>
        /// Files collection
        /// </summary>
        [Required]
        [FromBody]
        public required IEnumerable<IFormFile> Files { get; set; }
    }
}

Kontroler:

using ApiExample.Dtos;
using Microsoft.AspNetCore.Mvc;

namespace ApiExample.Controllers
{
    public class SnapController : Controller
    {
        [HttpPost("create")]
        public IActionResult Create([FromForm] SnapRequest snapRequest)
        {
            return Ok(snapRequest);
        }
    }
}

Nyní už i Swagger vypadá trochu lépe. Všechny property se posálají v těle (body) kde máme podstatně vyšší limity než v query.

Ale stále nemáme splněno. Když zkusíte Swagger vyplnit, soubory jsou OK ale stále se do akce nedostanou žádná metada. Mám pro vás 2 možnosti řešení.

Request.Form

Problém spočívá v tom, že C# nedokáže rozparsrovat požadavek a napasovat ho na IEnumerable<Metadata>. Pojďme se podívat co nám přijde:

Ano, vidíte správně. Přišla nám informace:

{
  "name": "string1",
  "sjz": "string1"
},{
  "name": "string2",
  "sjz": "string2"
}

Vzpad8 to jako JSON ale není to JSON? Ano, přesně tak. Aby to byl JSON musely by na začátku a na konci být hranaté zévotky [ požadavek ]

Tím se dostáváme k prvnímu možnému řešení. Doplňme a začátek a konec záavorky a deseralizujme na kolekci objektů:

        [HttpPost("create")]
        public IActionResult Create([FromForm] SnapRequest snapRequest)
        {

            var metadatas = HttpContext.Request.Form["metadatas"];
            var metadataJson = $"[{metadatas}]";//Add [ to start a ] to end string -> it is true JSON array

            var options = new JsonSerializerOptions
            {
                PropertyNameCaseInsensitive = true
            };
            //deserialize with camelCaseInsensitive option and save to request object
            snapRequest.Metadatas = JsonSerializer.Deserialize<List<Metadata>>(metadataJson, options) ?? new List<Metadata>();

            return Ok(snapRequest);
        }

ModelBinder

První řešení je spíše nouzové, lepší je si napsat vlastní ModelBinder, který tuto funkcionalitu zapouzdří.

Akce v kontroleru se velice zjednoduší:

        [HttpPost("v2/create")]
        public IActionResult CreateV2([FromForm] SnapRequestV2 snapRequest)
        {
            return Ok(snapRequest);
        }

DTO požadavku jsou rozšířené o ModelBiner:

using ApiExample.Controllers;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;

namespace ApiExample.Dtos
{
    public class SnapRequestV2
    {
        /// <summary>
        /// Metadata collection
        /// </summary>
        [Required]
        [FromForm]
        [ModelBinder(BinderType = typeof(FormDataJsonArrayModelBinder))]
        public required IEnumerable<Metadata> Metadatas { get; set; }

        /// <summary>
        /// Files collection
        /// </summary>
        [Required]
        [FromBody]
        public required IEnumerable<IFormFile> Files { get; set; }
    }
}

Vytvoření vlastního ModelBinderu v ASP.NET Core je užitečné, pokud potřebujete specifickou logiku pro vazbu dat z HTTP požadavku na objekty v rámci vaší aplikace. Vlastní ModelBinder vám umožní upravit proces vazby dat podle vašich potřeb. Vytvoříme třídu FormDataJsonArrayModelBinder implementující rozhraní IModelBinder:

    public class FormDataJsonArrayModelBinder : IModelBinder
    {
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            if (bindingContext == null)
            {
                throw new ArgumentNullException(nameof(bindingContext));
            }

            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
            if (valueProviderResult != ValueProviderResult.None)
            {
                bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);

                var stringValue = $"[{valueProviderResult.Values}]";
                var result = Newtonsoft.Json.JsonConvert.DeserializeObject(stringValue, bindingContext.ModelType);
                if (result != null)
                {
                    bindingContext.Result = ModelBindingResult.Success(result);
                    return Task.CompletedTask;
                }
            }

            return Task.CompletedTask;
        }
    }

Tato třída stejně jako v prvním řešení přidá na začátek a konec dat hranaté závorky a deserializuje je na požadovaný objekt.