I would like to create an action method in my ASP.NET Core controller which returns a Multipart HTTP Response containing several files. I know that using a .zip file is the reco
MSDN has a document that lists a lot of the multipart subtypes. The multipart/byteranges
seems most appropriate for sending multiple files in an HTTP Response for download by the client application. The bold part is particularly relevant.
The multipart/byteranges content type is defined as a part of the HTTP message protocol. It includes two or more parts, each with its own Content-Type and Content-Range fields. The parts are separated using a MIME boundary parameter. It allows for binary as well as 7-bit and 8-bit files to be sent as multiple parts with the lengths of the parts being specified in the header of each part. Note that while HTTP makes provisions for using MIME for HTTP documents, HTTP is not strictly MIME-compliant. (Emphasis added.)
RFC2068, section 19.2 provides a description of multipart/byteranges
. Again, the bold part is relevant. Each byterange can have its own Content-type
and it turns out can also have its own Content-disposition
.
The multipart/byteranges media type includes two or more parts, each with its own Content-Type and Content-Range fields. The parts are separated using a MIME boundary parameter. (Emphasis added.)
The RFC also provides this technical definition:
Media Type name: multipart
Media subtype name: byteranges
Required parameters: boundary
Optional parameters: none
Encoding considerations: only "7bit", "8bit", or "binary" are permitted
Security considerations: none
The best part of the RFC is its example, which the ASP.NET Core sample below illustrates.
HTTP/1.1 206 Partial content
Date: Wed, 15 Nov 1995 06:25:24 GMT
Last-modified: Wed, 15 Nov 1995 04:58:08 GMT
Content-type: multipart/byteranges; boundary=THIS_STRING_SEPARATES
--THIS_STRING_SEPARATES
Content-type: application/pdf
Content-range: bytes 500-999/8000
...the first range...
--THIS_STRING_SEPARATES
Content-type: application/pdf
Content-range: bytes 7000-7999/8000
...the second range
--THIS_STRING_SEPARATES--
Note that they are sending two PDFs! That is just what you're needing.
Here is a code sample that works on Firefox. That is, Firefox downloads three image files, which we can open with Paint. The source is on GitHub.
The sample uses app.Run()
. To adapt the sample to a controller action, inject IHttpContextAccessor
into your controller and write to _httpContextAccessor.HttpContext.Response
in your action method.
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
public class Startup
{
private const string CrLf = "\r\n";
private const string Boundary = "--THIS_STRING_SEPARATES";
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
}
public void Configure(IApplicationBuilder app)
{
app.Run(async context =>
{
var response = context.Response;
response.ContentType = $"multipart/byteranges; boundary={Boundary}";
// TODO Softcode the 'Content-length' header.
response.ContentLength = 13646;
var contentLength = response.ContentLength.Value;
await response.WriteAsync(Boundary + CrLf);
var blue = new FileInfo("./blue.jpg");
var red = new FileInfo("./red.jpg");
var green = new FileInfo("./green.jpg");
long start = 0;
long end = blue.Length;
await AddImage(response, blue, start, end, contentLength);
start = end + 1;
end = start + red.Length;
await AddImage(response, red, start, end, contentLength);
start = end + 1;
end = start + green.Length;
await AddImage(response, green, start, end, contentLength);
response.Body.Flush();
});
}
private async Task AddImage(HttpResponse response, FileInfo fileInfo,
long start, long end, long total)
{
var bytes = File.ReadAllBytes(fileInfo.FullName);
var file = new FileContentResult(bytes, "image/jpg");
await response
.WriteAsync($"Content-type: {file.ContentType.ToString()}" + CrLf);
await response
.WriteAsync($"Content-disposition: attachment; filename={fileInfo.Name}" + CrLf);
await response
.WriteAsync($"Content-range: bytes {start}-{end}/{total}" + CrLf);
await response.WriteAsync(CrLf);
await response.Body.WriteAsync(
file.FileContents,
offset: 0,
count: file.FileContents.Length);
await response.WriteAsync(CrLf);
await response.WriteAsync(Boundary + CrLf);
}
}
Note: this sample code requires refactoring before reaching production.
I've written a more generic MultipartResult
class which just inherits from ActionResult
:
[Route("[controller]")]
public class MultipartController : Controller
{
private readonly IHostingEnvironment hostingEnvironment;
public MultipartController(IHostingEnvironment hostingEnvironment)
{
this.hostingEnvironment = hostingEnvironment;
}
[HttpGet("")]
public IActionResult Get()
{
return new MultipartResult()
{
new MultipartContent()
{
ContentType = "text/plain",
FileName = "File.txt",
Stream = this.OpenFile("File.txt")
},
new MultipartContent()
{
ContentType = "application/json",
FileName = "File.json",
Stream = this.OpenFile("File.json")
}
};
}
private Stream OpenFile(string relativePath)
{
return System.IO.File.Open(
Path.Combine(this.hostingEnvironment.WebRootPath, relativePath),
FileMode.Open,
FileAccess.Read);
}
}
public class MultipartContent
{
public string ContentType { get; set; }
public string FileName { get; set; }
public Stream Stream { get; set; }
}
public class MultipartResult : Collection<MultipartContent>, IActionResult
{
private readonly System.Net.Http.MultipartContent content;
public MultipartResult(string subtype = "byteranges", string boundary = null)
{
if (boundary == null)
{
this.content = new System.Net.Http.MultipartContent(subtype);
}
else
{
this.content = new System.Net.Http.MultipartContent(subtype, boundary);
}
}
public async Task ExecuteResultAsync(ActionContext context)
{
foreach (var item in this)
{
if (item.Stream != null)
{
var content = new StreamContent(item.Stream);
if (item.ContentType != null)
{
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(item.ContentType);
}
if (item.FileName != null)
{
var contentDisposition = new ContentDispositionHeaderValue("attachment");
contentDisposition.SetHttpFileName(item.FileName);
content.Headers.ContentDisposition = new System.Net.Http.Headers.ContentDispositionHeaderValue("attachment");
content.Headers.ContentDisposition.FileName = contentDisposition.FileName;
content.Headers.ContentDisposition.FileNameStar = contentDisposition.FileNameStar;
}
this.content.Add(content);
}
}
context.HttpContext.Response.ContentLength = content.Headers.ContentLength;
context.HttpContext.Response.ContentType = content.Headers.ContentType.ToString();
await content.CopyToAsync(context.HttpContext.Response.Body);
}
}