TopMenu

Handle partial content request in WebAPI Aspnet

In this post we’ll talk about downloading the full and partial content of a file from server. Then we’ll see if you can achieve it via WebAPI. Before we jump to the main discussion we’ll look at some basic things. So lets take a simple case when you try to download a file from server what actually a header contains in request and response. For e.g. you want to download a file say, data.zip. Before sending the request you can run the fiddler to trace your request and response.
When you send the request first time you’ll see the Request head will contain information something like this:
GET /files/data.zip HTTP/1.1
Accept-Language: en/us,en
User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64; rv:21.0) Gecko/20100101 Firefox/21.0


now if you check the response then It’ll look something like below:

HTTP/1.1 200 OK
Content-Type: application/x-zip-compressed
Last-Modified: Mon, 18 June 2013 07:15:20 GMT
Accept-Ranges: bytes
ETag: "e2827cfd8e57ce1:0"
Server: Microsoft-IIS/8.0
Date: Mon, 17 Jun 2013 09:01:22 GMT
Content-Length: 62887922


So if you want to know that if you server is available to serve the partial content then have look at the attribute:

Accept-Ranges: bytes


If this attribute is present with value bytes that means client can request the partial content to download. If this attribute no present in the response or have a value none that means server doesn’t accept the partial content request.

Partial content request headers

Let’s take a look at the partial content headers how they look like when a request is made for the same.

GET /files/data.zip HTTP/1.1
Accept-Language: en/us,en
User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64; rv:21.0) Gecko/20100101 Firefox/21.0
If-Match: "dfTGdsdIofQXrAY3ZddfSd=="
Range: bytes=22234-


Here if you observe the Range header have value specified in Bytes with –. It basically follows the format e.g. Range:{from}-{To}. if nothing is specified in the To part then it’ll read the file to the end and write it in the response. otherwise if a value is mentioned then It’ll read that portion only. Now let’s look at the response of this request.

HTTP/1.1 206 OK
Content-Range: bytes 22234-62887922/62887923
Content-Length: 62887922
Content-Type: application/x-zip-compressed
Last-Modified: Mon, 18 June 2013 09:15:20 GMT
ETag: "dfTGdsdIofQXrAY3ZddfSd=="
Accept-Ranges: bytes
Binary content of data.zip from byte 500,001 onwards...


If you want to learn more about the range specific request in HTTP protocol you can take a look at the HTTP Spec.

Now, Lets get back to the business why we are discussing all this stuff. I have a Asp.net MVC WebAPI which is actually serving me the file based on the query parameters. Now in this case the scenario will change. We can not send the partial request directly to the Controller. If we do need to write the custom code to handle the partial requests. If you’re thinking to write the HttpHandlers at this moment then I should tell you that “Controller are new Handlers in WebAPI”. Jeff Fritz wrote a nice article on this discussion.

So first you can start creating a simple Asp.net WebAPI project. Let say add an DownloadController in the contollers folder. We have a method say DownloadFile in it. So when we send a request to download a file then It’ll look something like this. This below sample is just reading the file from disk and returning the complete file. 

public class DownloadController : ApiController
{
    public HttpResponse DownloadFile(string fileName)
    {
        // put your logic for reading the file from disk and return
        HttpResponseMessage result = null;
        var fullFilePath = Path.Combine(this.packageDirectoryFilePath, fileName);

        // Get the complete file
        FileStream sourceStream = File.Open(fullFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
        BufferedStream bs = new BufferedStream(sourceStream);

        result = new HttpResponseMessage(HttpStatusCode.OK);
        result.Content = new StreamContent(bs);

        result.Content.Headers.ContentType = new MediaTypeHeaderValue(MimeType);
        result.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment")
            {
                FileName = fileName
            };

        return result;
    }
}


So to send the partial content request you need send a partial content request something. I’ve wrote a custom function to create and send a partial content request or so to say Range specific request.

Client_Program.cs

private static void DownloadFileWithRangeSpecificRequests(string sourceUrl, string destinationPath)
        {
            long existLen = 0;
            System.IO.FileStream saveFileStream;
            if (System.IO.File.Exists(destinationPath))
            {
                System.IO.FileInfo fINfo =
                    new System.IO.FileInfo(destinationPath);
                existLen = fINfo.Length;
            }
            if (existLen > 0)
                saveFileStream = new System.IO.FileStream(destinationPath,
                                                          System.IO.FileMode.Append, System.IO.FileAccess.Write,
                                                          System.IO.FileShare.ReadWrite);
            else
                saveFileStream = new System.IO.FileStream(destinationPath,
                                                          System.IO.FileMode.Create, System.IO.FileAccess.Write,
                                                          System.IO.FileShare.ReadWrite);
 
            System.Net.HttpWebRequest httpWebRequest;
            System.Net.HttpWebResponse httpWebResponse;
            httpWebRequest = (System.Net.HttpWebRequest) System.Net.HttpWebRequest.Create(sourceUrl);
            httpWebRequest.AddRange((int) existLen);
            System.IO.Stream smRespStream;
            httpWebResponse = (System.Net.HttpWebResponse) httpWebRequest.GetResponse();
            smRespStream = httpWebResponse.GetResponseStream();
            var abc = httpWebRequest.Timeout;
 
            smRespStream.CopyTo(saveFileStream);
            saveFileStream.Close();
        }


This will actually read the file from file system and if it’s not fully downloaded then send the request from where the last byte of the file. So if you try to run the application with the default downloader and the client you’ll always get the complete file. Since the DownloadController doesn’t know about the request if it’s a Range specific. So let’s modify the controller and put some checks to receive the RangeSpecific requests and treat them specially.

Update DownloadController.cs

/// <summary>
/// Gets the file from server.
/// </summary>
/// <param name="fileName">Name of the file.</param>
/// <returns>response content of file</returns>
public HttpResponseMessage DownloadFile(string fileName)
{
    this.LogRequestHttpHeaders(this.logFilePath, Request);

    HttpResponseMessage result = null;
    var fullFilePath = Path.Combine(this.packageDirectoryFilePath, fileName);

    if (Request.Headers.Range == null || Request.Headers.Range.Ranges.Count == 0 ||
        Request.Headers.Range.Ranges.FirstOrDefault().From.Value == 0)
    {
        // Get the complete file
        FileStream sourceStream = File.Open(fullFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
        BufferedStream bs = new BufferedStream(sourceStream);

        result = new HttpResponseMessage(HttpStatusCode.OK);
        result.Content = new StreamContent(bs);

        result.Content.Headers.ContentType = new MediaTypeHeaderValue(MimeType);
        result.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment")
            {
                FileName = fileName
            };
    }
    else
    {
        // Get the partial part
        var item = Request.Headers.Range.Ranges.FirstOrDefault();
        if (item != null && item.From.HasValue)
        {
            result = this.GetPartialContent(fileName, item.From.Value);
        }
    }

    this.LogResponseHttpHeaders(this.logFilePath, result);

    return result;
}

/// <summary>
/// Reads the partial content of physical file.
/// </summary>
/// <param name="fileName">Name of the file.</param>
/// <param name="partial">The partial.</param>
/// <returns>response content of the file</returns>
private HttpResponseMessage GetPartialContent(string fileName, long partial)
{
    var fullFilePath = Path.Combine(this.packageDirectoryFilePath, fileName);
    FileInfo fileInfo = new FileInfo(fullFilePath);
    long startByte = partial;

    Action<Stream, HttpContent, TransportContext> pushContentAction = (outputStream, content, context) =>
        {
            try
            {
                var buffer = new byte[65536];
                using (var file = File.Open(fullFilePath, FileMode.Open, FileAccess.Read, FileShare.Read))
                {
                    var bytesRead = 1;
                    file.Seek(startByte, SeekOrigin.Begin);
                    int length = Convert.ToInt32((fileInfo.Length - 1) - startByte) + 1;

                    while (length > 0 && bytesRead > 0)
                    {
                        bytesRead = file.Read(buffer, 0, Math.Min(length, buffer.Length));
                        outputStream.Write(buffer, 0, bytesRead);
                        length -= bytesRead;
                    }
                }
            }
            catch (HttpException ex)
            {
                this.LogException(ex);
            }
            finally
            {
                outputStream.Close();
            }
        };

    HttpResponseMessage result = new HttpResponseMessage(HttpStatusCode.PartialContent);
    result.Content = new PushStreamContent(pushContentAction, new MediaTypeHeaderValue(MimeType));
    result.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment")
        {
            FileName = fileName
        };

    return result;
}


So Now we have added  some conditions to check if a request is range specific or it’s a full content request. To server the partial content I’ve wrote a custom function GetPartialContent() which take the file name and the startByte as the argument. Also we have used the PushStreamContent to write the content to the response.

Now your WebAPI is ready to serve the content with Range specific request.

But to tell you the update this case is already been taken in to account my Microsoft Asp.net team and they’ve included the Byte specific request handlers in the NightlyBuilds which are available via Nugets. But I’ve to write it as updating the production with latest Pre-release could be an impact when RTM would be available.

Complete source code:

DownloadController.cs


Client.cs

3 comments:

  1. 2giga over file manage fail..
    change

    if ( length > buffer.Length )
    bytesRead = file.Read(buffer, 0, buffer.Length);
    else
    bytesRead = file.Read(buffer, 0, (int)length);

    ReplyDelete
  2. Thanks for the review and update. :)

    ReplyDelete
  3. Thanks Amit, I noticed that when I use the web API to download large files 800MB or greater, the download reaches its full size but never completes. Do you know why this occurs?

    Code: http://screencast.com/t/IDmFThlLZ

    Thanks!

    ReplyDelete