Search code examples
httpasp.net-coremultipart

How to receive `multipart/mixed` in an ASP.NET Core controller


Legacy systems will send me this:

POST /xml HTTP/1.1
Host: localhost:9000
User-Agent: curl/7.64.1
Accept: */*
Content-Length: 321
Content-Type: multipart/mixed; boundary=------------------------a9dd0ab37a224967

--------------------------a9dd0ab37a224967
Content-Disposition: attachment; name="part1"
Content-Type: text/xml

<foo>bar</foo>
--------------------------a9dd0ab37a224967
Content-Disposition: attachment; name="part2"
Content-Type: application/json

{'foo': 'bar'}
--------------------------a9dd0ab37a224967--

The first part I need to interpret as raw XElement; for the second part I would like the usual model binding.

I try this:

class Part2 { 
    public string foo { get; set; }
}
    

[HttpPost]
[Route("/xml")]
public string Post1([FromBody] XElement part1, [FromBody] Part2 part2 )
{
    return part1.ToString() + ", " + part2.foo;
}

But ASP.NET does not allow more than one parameter decorated with [FromBody].

How do I make my ASP.NET Core service receive http requests with content-type multipart/mixed?


Solution

  • There is no built-in mechanism to handle this type of post data (multipart/mixed has virtually unlimited possibilities, and it would be hard to bind to it in a generic sense), but, you can easily parse the data yourself using the MultipartReader object.

    I am going to assume that all the data that is coming in has a disposition of attachment and that only JSON and XML content-types are valid. But this should be open-ended enough for you to modify as you see fit.

    Take a look at this static helper:

    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Http.Features;
    using Microsoft.AspNetCore.WebUtilities;
    using Microsoft.Net.Http.Headers;
    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Net.Mime;
    using System.Text;
    
    namespace YourNamespace.Utilities
    {
        public static class MutipartMixedHelper
        {
            public static async IAsyncEnumerable<ParsedSection> ParseMultipartMixedRequestAsync(HttpRequest request)
            {
                // Extract, sanitize and validate boundry
                var boundary = HeaderUtilities.RemoveQuotes(
                    MediaTypeHeaderValue.Parse(request.ContentType).Boundary).Value;
    
                if (string.IsNullOrWhiteSpace(boundary) ||
                    (boundary.Length > new FormOptions().MultipartBoundaryLengthLimit))
                {
                    throw new InvalidDataException("boundry is shot");
                }
    
                // Create a new reader based on that boundry
                var reader = new MultipartReader(boundary, request.Body);
    
                // Start reading sections from the MultipartReader until there are no more
                MultipartSection section;
                while ((section = await reader.ReadNextSectionAsync()) != null)
                {
                    // parse the content type
                    var contentType = new ContentType(section.ContentType);
    
                    // create a new ParsedSecion and start filling in the details
                    var parsedSection = new ParsedSection
                    {
                        IsJson = contentType.MediaType.Equals("application/json",
                            StringComparison.OrdinalIgnoreCase),
                        IsXml = contentType.MediaType.Equals("text/xml",
                            StringComparison.OrdinalIgnoreCase),
                        Encoding = Encoding.GetEncoding(contentType.CharSet)
                    };
    
                    // Must be XML or JSON
                    if (!parsedSection.IsXml && !parsedSection.IsJson)
                    {
                        throw new InvalidDataException("only handling json/xml");
                    }
    
                    // Parse the content disosition
                    if (ContentDispositionHeaderValue.TryParse(
                            section.ContentDisposition, out var contentDisposition) &&
                            contentDisposition.DispositionType.Equals("attachment"))
                    {
                        // save the name
                        parsedSection.Name = contentDisposition.Name.Value;
    
                        // Create a new StreamReader using the proper encoding and
                        // leave the underlying stream open
                        using (var streamReader = new StreamReader(
                            section.Body, parsedSection.Encoding, leaveOpen: true))
                        {
                            parsedSection.Data = await streamReader.ReadToEndAsync();
                            yield return parsedSection;
                        }
                    }
                }
            }
        }
    
        public sealed class ParsedSection
        {
            public bool IsJson { get; set; }
            public bool IsXml { get; set; }
            public string Name { get; set; }
            public string Data { get; set; }
            public Encoding Encoding { get; set; }
        }
    }
    

    You can call this method from your endpoint, like so:

    [HttpPost, Route("TestMultipartMixedPost")]
    public async Task<IActionResult> TestMe()
    {
        await foreach (var parsedSection in MutipartMixedHelper
            .ParseMultipartMixedRequestAsync(Request))
        {
            Debug.WriteLine($"Name: {parsedSection.Name}");
            Debug.WriteLine($"Encoding: {parsedSection.Encoding.EncodingName}");
            Debug.WriteLine($"IsJson: {parsedSection.IsJson}");
            Debug.WriteLine($"IsXml: {parsedSection.IsXml}");
            Debug.WriteLine($"Data: {parsedSection.Data}");
            Debug.WriteLine("-----------------------");
        }
    
        return Ok();
    }
    

    Your endpoint would output:

    Name: part1
    Encoding: Unicode (UTF-8)
    IsJson: False
    IsXml: True
    Data: <foo>bar</foo>
    -----------------------
    Name: part2
    Encoding: Unicode (UTF-8)
    IsJson: True
    IsXml: False
    Data: {"foo": "bar"}
    -----------------------
    

    At this point, you'd have to deserialize based on the properties of the returned objects.