Streaming files with httpclient and multiple controllers

In a recent project I was faced with a requirement which stated that all access to databases, file system and what not, had to go via trusted endpoints. It’s not uncommon but you do hit some roadblocks. Since the application was file system intensive I had to look for a way to stream files across machine and web application boundaries. Simplified, the application looked like this:

If you want to stream files in this situation you don’t want to load the entire file in memory in the web api part, then sent it to the public facing web app which in turn loads the complete file in memory and then gives it to the client browser. To increase performance of the system the file should be able to get streamed from where its stored to the browser of the user.

The API controller in the private API part is pretty straightforward. Lookup the file and pass it along using a custom HttpActionResult: FileResult.

[RoutePrefix("api/files")]
[Authorize]
public class FilesController : ApiController
{       
    [Route("{fileId:long}")]
    [HttpGet]
    public IHttpActionResult Get(long fileId)
    {
        FileMetaData metadata = LoadFileMetaData(fileId);
        if (File.Exists(metadata.Location))
        {
            return new FileResult(File.Open(metadata.Location, FileMode.Open, FileAccess.Read), metadata.ContentType, metadata.FileName);
        }
        else
        {
            return NotFound();
        }   
    }
}

Creating a custom HttpActionResult is pretty straightforward, if you look around online you’ll find plenty of examples. This is the one I ended up with myself. What’s important in this case, apart from loading the file, is populating the mimetype and setting the contentdisposition header.

class FileResult : IHttpActionResult
{
    private readonly string _filePath;
    private readonly string _contentType;
    private readonly string _filename;
    private readonly Stream _stream;
 
    public FileResult(string filePath, string contentType = null, string filename = null)
    {
        if (filePath == null) throw new ArgumentNullException("filePath");
 
        _filePath = filePath;
        _contentType = contentType;
        _filename = filename;
    }
 
    public FileResult(Stream stream, string contentType = null, string filename = null)
    {
        if (stream == null) throw new ArgumentNullException("stream");
 
        _stream = stream;
        _contentType = contentType;
        _filename = filename;
    }
 
    public Task<HttpResponseMessage> ExecuteAsync(CancellationToken cancellationToken)
    {
        var response = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StreamContent(_stream ?? File.OpenRead(_filePath))
        };
 
        var contentType = _contentType ?? MimeMapping.GetMimeMapping(Path.GetExtension(_filePath));
        response.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
        if (!string.IsNullOrWhiteSpace(_filename))
        {
            response.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment")
            {
                FileName = _filename
            };
        }
 
        return Task.FromResult(response);
    }
}

Then comes the tricky part: streaming the file directly to the user from the public facing web application. In this case the public application was an MVC site. I’m using HttpClient to call the private API and indicate I want my code to process the headers immediately after they are received. This allows me to see if the file was found or not. If we don’t have a 401 error code I check if I might have another error, if not I asynchronously load the response stream and pass it along the standard FileActionResult. An essential part is the Response.BufferOutput part. By default it’s set to true and will force the web app to load the file completely in memory before giving it to the client.

[Route("file/{fileId:long}")]
[HttpGet]
public virtual async Task<ActionResult> File(long fileId)
{
    var httpClient = new HttpClient(new HttpClientHandler() { UseDefaultCredentials = true });
 
    var response = await httpClient.GetAsync(ConfigurationManager.AppSettings["App.PrivateApi"] + $"/{fileId}", HttpCompletionOption.ResponseHeadersRead);
    if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
    {
        return HttpNotFound();
    }
    else
    {
        response.EnsureSuccessStatusCode();
        var fileStream = await response.Content.ReadAsStreamAsync();
        Response.BufferOutput = false;
        return File(fileStream, response.Content.Headers?.ContentType?.MediaType, response.Content.Headers?.ContentDisposition?.FileName);
    }
}