Skip to content

Commit

Permalink
Add support for range requests (closes #124)
Browse files Browse the repository at this point in the history
  • Loading branch information
Kaliumhexacyanoferrat committed Jul 7, 2021
1 parent a7d8a7e commit a460793
Show file tree
Hide file tree
Showing 13 changed files with 717 additions and 14 deletions.
5 changes: 1 addition & 4 deletions GenHTTP.sln
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "API", "API", "{06A861A6-D03
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Api", "API\GenHTTP.Api.csproj", "{C2D2B8D0-CA23-4790-8C71-B245A0AB81FB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{23B23225-275E-4F52-8B29-6F44C85B6ACE}"
ProjectSection(SolutionItems) = preProject
Modules\Caching\GenHTTP.Modules.Caching.csproj = Modules\Caching\GenHTTP.Modules.Caching.csproj
EndProjectSection
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GenHTTP.Modules.Caching", "Modules\Caching\GenHTTP.Modules.Caching.csproj", "{23B23225-275E-4F52-8B29-6F44C85B6ACE}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Modules.Scriban", "Modules\Scriban\GenHTTP.Modules.Scriban.csproj", "{7FA5BD2F-C093-4F30-ACD9-A870C7A0DAF2}"
EndProject
Expand Down
22 changes: 22 additions & 0 deletions Modules/IO/Extensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using GenHTTP.Api.Infrastructure;

namespace GenHTTP.Modules.IO
{

public static class Extensions
{

/// <summary>
/// Configures the server to respond with partial responses if
/// requested by the client.
/// </summary>
/// <param name="host">The host to add the feature to</param>
public static IServerHost RangeSupport(this IServerHost host)
{
host.Add(IO.RangeSupport.Create());
return host;
}

}

}
28 changes: 28 additions & 0 deletions Modules/IO/RangeSupport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using GenHTTP.Api.Content;

using GenHTTP.Modules.IO.Ranges;

namespace GenHTTP.Modules.IO
{

public static class RangeSupport
{

public static RangeSupportConcernBuilder Create() => new();

#region Extensions

/// <summary>
/// Adds range support as a concern to the given builder.
/// </summary>
public static T AddRangeSupport<T>(this T builder) where T : IHandlerBuilder<T>
{
builder.Add(Create());
return builder;
}

#endregion

}

}
150 changes: 150 additions & 0 deletions Modules/IO/Ranges/RangeSupportConcern.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

using GenHTTP.Api.Content;
using GenHTTP.Api.Protocol;

namespace GenHTTP.Modules.IO.Ranges
{

public class RangeSupportConcern : IConcern
{
private static readonly Regex _PATTERN = new(@"^\s*bytes\s*=\s*([0-9]*)-([0-9]*)\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);

#region Get-/Setters

public IHandler Content { get; }

public IHandler Parent { get; }

#endregion

#region Initialization

public RangeSupportConcern(IHandler parent, Func<IHandler, IHandler> contentFactory)
{
Parent = parent;
Content = contentFactory(this);
}

#endregion

#region Functionality

public ValueTask PrepareAsync() => ValueTask.CompletedTask;

public IEnumerable<ContentElement> GetContent(IRequest request) => Content.GetContent(request);

public async ValueTask<IResponse?> HandleAsync(IRequest request)
{
if (request.Method == RequestMethod.GET || request.Method == RequestMethod.HEAD)
{
var response = await Content.HandleAsync(request);

if (response != null)
{
if (response.Status == ResponseStatus.OK)
{
var length = response.ContentLength;

if (length != null)
{
response["Accept-Ranges"] = "bytes";

if (request.Headers.TryGetValue("Range", out var requested))
{
var match = _PATTERN.Match(requested);

if (match.Success)
{
var startString = match.Groups[1].Value;
var endString = match.Groups[2].Value;

var start = (string.IsNullOrEmpty(startString)) ? null : (ulong?)ulong.Parse(startString);
var end = (string.IsNullOrEmpty(endString)) ? null : (ulong?)ulong.Parse(endString);

if ((start != null) || (end != null))
{
if (end == null)
{
return GetRangeFromStart(request, response, length!.Value, start!.Value);
}
else if (start == null)
{
return GetRangeFromEnd(request, response, length!.Value, end!.Value);
}
else
{
return GetFullRange(request, response, length!.Value, start!.Value, end!.Value);
}
}
}
else
{
return NotSatisfiable(request, length!.Value);
}
}
}
}
}

return response;
}
else
{
return await Content.HandleAsync(request);
}
}

private static IResponse GetRangeFromStart(IRequest request, IResponse response, ulong length, ulong start)
{
if (start > length) return NotSatisfiable(request, length);

return GetRange(response, start, length - 1, length);
}

private static IResponse GetRangeFromEnd(IRequest request, IResponse response, ulong length, ulong end)
{
if (end > length) return NotSatisfiable(request, length);

return GetRange(response, length - end, length - 1, length);
}

private static IResponse GetFullRange(IRequest request, IResponse response, ulong length, ulong start, ulong end)
{
if (start > end || end >= length) return NotSatisfiable(request, length);

return GetRange(response, start, end, length);
}

private static IResponse GetRange(IResponse response, ulong actualStart, ulong actualEnd, ulong totalLength)
{
response.Status = new(ResponseStatus.PartialContent);

response["Content-Range"] = $"bytes {actualStart}-{actualEnd}/{totalLength}";

response.Content = new RangedContent(response.Content!, actualStart, actualEnd);
response.ContentLength = actualEnd - actualStart + 1;

return response;
}

private static IResponse NotSatisfiable(IRequest request, ulong totalLength)
{
var content = Resource.FromString($"Requested length cannot be satisfied (available = {totalLength} bytes)")
.Build();

return request.Respond()
.Status(ResponseStatus.RequestedRangeNotSatisfiable)
.Header("Content-Range", $"bytes */{totalLength}")
.Content(content)
.Build();
}

#endregion

}

}
22 changes: 22 additions & 0 deletions Modules/IO/Ranges/RangeSupportConcernBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System;

using GenHTTP.Api.Content;

namespace GenHTTP.Modules.IO.Ranges
{

public class RangeSupportConcernBuilder : IConcernBuilder
{

#region Functionality

public IConcern Build(IHandler parent, Func<IHandler, IHandler> contentFactory)
{
return new RangeSupportConcern(parent, contentFactory);
}

#endregion

}

}
62 changes: 62 additions & 0 deletions Modules/IO/Ranges/RangedContent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.IO;
using System.Threading.Tasks;

using GenHTTP.Api.Protocol;

namespace GenHTTP.Modules.IO.Ranges
{

public class RangedContent : IResponseContent
{

#region Get-/Setters

public ulong? Length { get; }

private IResponseContent Source { get; }

private ulong Start { get; }

private ulong End { get; }

#endregion

#region Initialization

public RangedContent(IResponseContent source, ulong start, ulong end)
{
Source = source;

Start = start;
End = end;

Length = (End - Start);
}

#endregion

#region Functionality

public async ValueTask<ulong?> CalculateChecksumAsync()
{
var checksum = await Source.CalculateChecksumAsync();

if (checksum != null)
{
unchecked
{
checksum = checksum * 17 + Start;
checksum = checksum * 17 + End;
}
}

return checksum;
}

public ValueTask WriteAsync(Stream target, uint bufferSize) => Source.WriteAsync(new RangedStream(target, Start, End), bufferSize);

#endregion

}

}
95 changes: 95 additions & 0 deletions Modules/IO/Ranges/RangedStream.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System;
using System.IO;

namespace GenHTTP.Modules.IO.Ranges
{

/// <summary>
/// A stream that can be configured to just write a specified
/// portion of the incoming data into the underlying stream.
/// </summary>
public class RangedStream : Stream
{

#region Get-/Setters

private Stream Target { get; }

private long Start { get; }

private long End { get; }

public override bool CanRead => false;

public override bool CanSeek => false;

public override bool CanWrite => true;

public override long Length => (End - Start);

public override long Position { get; set; }

#endregion

#region Initialization

/// <summary>
/// Creates a ranged stream that writes the specified portion of
/// data to the given target stream.
/// </summary>
/// <param name="target">The stream to write to</param>
/// <param name="start">The zero based index of the starting position</param>
/// <param name="end">The zero based index of the inclusive end position</param>
public RangedStream(Stream target, ulong start, ulong end)
{
Target = target;

Start = (long)start;
End = (long)end;
}

#endregion

#region Functionality

public override void Flush() => Target.Flush();

public override void Write(byte[] buffer, int offset, int count)
{
if (Position > End) return;

long actualOffset = offset;
long actualCount = count;

if (Position < Start)
{
actualOffset += (int)(Start - Position);
actualCount -= (int)(Start - Position);
}

if (Start + actualCount > End + 1)
{
actualCount = Math.Min(End - Start + 1, actualCount);
}

if (actualOffset < buffer.Length)
{
var toWrite = Math.Min(buffer.Length - actualOffset, actualCount);

Target.Write(buffer, (int)actualOffset, (int)toWrite);
}

Position += count;
}

public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();

public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();

public override void SetLength(long value) => throw new NotSupportedException();

#endregion

}

}
Loading

0 comments on commit a460793

Please sign in to comment.