Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 148 additions & 48 deletions Engine/Shared/Types/ResponseSerializer.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,36 @@
using System.Buffers;
using System.IO.Pipelines;
using System.Runtime.CompilerServices;

using GenHTTP.Api.Protocol;

namespace GenHTTP.Engine.Shared.Types;

public static class ResponseSerializer
{
private static ReadOnlySpan<byte> Newline => "\r\n"u8;

private static ReadOnlySpan<byte> HeaderSeparator => ": "u8;

private static ReadOnlySpan<byte> ContentTypePrefix => "Content-Type: "u8;

private static ReadOnlySpan<byte> ContentLengthPrefix => "Content-Length: "u8;

private static ReadOnlySpan<byte> ContentEncodingPrefix => "Content-Encoding: "u8;

private static ReadOnlySpan<byte> ChunkedHeader => "Transfer-Encoding: chunked\r\n"u8;

private static ReadOnlySpan<byte> EmptyContentLength => "Content-Length: 0\r\n"u8;

/// <summary>
/// Checks, whether the body of the given response needs to be written to the client.
/// </summary>
/// <remarks>
/// The headers still describe the content if no body is sent, e.g. for a HEAD request.
/// </remarks>
/// <param name="request">The request the response is written for, or null if the request could not be parsed</param>
/// <param name="response">The response to be written</param>
/// <param name="headRequest">Whether the client sent a HEAD request</param>
/// <returns>false, if there is no content, the content is known to be empty or this is a HEAD request</returns>
public static bool ShouldSendBody(IRequest? request, IResponse response, bool headRequest)
{
if (request == null)
Expand All @@ -33,97 +56,174 @@ public static bool ShouldSendBody(IRequest? request, IResponse response, bool he
/// <summary>
/// Writes the status line and the headers of the final response to the given request.
/// </summary>
/// <remarks>
/// Calculates the size of the head first, so that it can be written into a single span
/// instead of issuing a separate write for every fragment.
/// </remarks>
/// <param name="request">The request the response is written for, if any</param>
public static void WriteHead(PipeWriter writer, Request? request, IResponse response, bool keepAlive, ReadOnlyMemory<byte> serverHeader, ReadOnlyMemory<byte> dateHeader, bool isHttp10)
{
request?.ResponseStarting();

writer.Write(StatusLine.Get(response.Status));
var headers = response.Headers;

WriteHeader(writer, response, keepAlive, serverHeader, dateHeader, isHttp10);
var count = headers.Count;

writer.Write("\r\n"u8);
}
var size = 0;

private static void WriteHeader(PipeWriter writer, IResponse response, bool keepAlive, ReadOnlyMemory<byte> serverHeader, ReadOnlyMemory<byte> dateHeader, bool isHttp10)
{
var isUpgrade = response.Mode == Connection.Upgrade;
var hasServer = false;
var hasDate = false;

if (!response.Headers.ContainsKey(KnownHeaders.Server))
for (var i = 0; i < count; i++)
{
writer.Write(serverHeader.Span);
}
var header = headers.GetMemoryEntry(i);

if (!response.Headers.ContainsKey(KnownHeaders.Date))
{
writer.Write(dateHeader.Span);
}
hasServer |= header.Key == KnownHeaders.Server;
hasDate |= header.Key == KnownHeaders.Date;

if (isUpgrade)
{
writer.Write("Connection: Upgrade\r\n"u8);
size += header.Key.Length + HeaderSeparator.Length + header.Value.Length + Newline.Length;
}
else if (isHttp10)

var statusLine = StatusLine.Get(response.Status);

var server = hasServer ? ReadOnlySpan<byte>.Empty : serverHeader.Span;
var date = hasDate ? ReadOnlySpan<byte>.Empty : dateHeader.Span;

var isUpgrade = response.Mode == Connection.Upgrade;

var connection = GetConnectionHeader(isUpgrade, keepAlive, isHttp10);

var content = response.Content;

var type = content?.Type;
var length = content?.Length;
var encoding = content?.Encoding;

size += statusLine.Length + server.Length + date.Length + connection.Length + Newline.Length;

if (content != null)
{
writer.Write(keepAlive ? "Connection: Keep-Alive\r\n"u8 : "Connection: Close\r\n"u8);
if (type != null)
{
size += ContentTypePrefix.Length + type.Value.Bytes.Length + Newline.Length;
}

if (length != null)
{
size += ContentLengthPrefix.Length + CountDigits(length.Value) + Newline.Length;
}
else if (!isUpgrade)
{
size += ChunkedHeader.Length;
}

if (encoding != null)
{
size += ContentEncodingPrefix.Length + encoding.Value.Length + Newline.Length;
}
}
else if (!keepAlive)
else
{
// HTTP/1.1 connections are persistent by default so we do not need to send a Keep-Alive header
writer.Write("Connection: Close\r\n"u8);
size += EmptyContentLength.Length;
}

var content = response.Content;
var span = writer.GetSpan(size);

var offset = 0;

Append(span, ref offset, statusLine);
Append(span, ref offset, server);
Append(span, ref offset, date);
Append(span, ref offset, connection);

if (content != null)
{
var type = content.Type;

if (type != null)
{
writer.Write("Content-Type: "u8);
writer.Write(type.Value.Bytes.Span);
writer.Write("\r\n"u8);
Append(span, ref offset, ContentTypePrefix);
Append(span, ref offset, type.Value.Bytes.Span);
Append(span, ref offset, Newline);
}

var length = content.Length;

if (length != null)
{
writer.Write("Content-Length: "u8);
writer.Write(length.Value);
writer.Write("\r\n"u8);
Append(span, ref offset, ContentLengthPrefix);

if (!length.Value.TryFormat(span[offset..], out var written))
{
throw new InvalidOperationException("Unable to write number to buffer");
}

offset += written;

Append(span, ref offset, Newline);
}
else if (!isUpgrade)
{
writer.Write("Transfer-Encoding: chunked\r\n"u8);
Append(span, ref offset, ChunkedHeader);
}

var encoding = content.Encoding;

if (encoding != null)
{
writer.Write("Content-Encoding: "u8);
writer.Write(encoding.Value.Span);
writer.Write("\r\n"u8);
Append(span, ref offset, ContentEncodingPrefix);
Append(span, ref offset, encoding.Value.Span);
Append(span, ref offset, Newline);
}
}
else
{
writer.Write("Content-Length: 0\r\n"u8);
Append(span, ref offset, EmptyContentLength);
}

var headers = response.Headers;

for (var i = 0; i < headers.Count; i++)
for (var i = 0; i < count; i++)
{
var header = headers.GetMemoryEntry(i);

writer.Write(header.Key.Span);
writer.Write(": "u8);
writer.Write(header.Value.Span);
writer.Write("\r\n"u8);
Append(span, ref offset, header.Key.Span);
Append(span, ref offset, HeaderSeparator);
Append(span, ref offset, header.Value.Span);
Append(span, ref offset, Newline);
}

Append(span, ref offset, Newline);

writer.Advance(offset);
}

private static ReadOnlySpan<byte> GetConnectionHeader(bool isUpgrade, bool keepAlive, bool isHttp10)
{
if (isUpgrade)
{
return "Connection: Upgrade\r\n"u8;
}

if (isHttp10)
{
return keepAlive ? "Connection: Keep-Alive\r\n"u8 : "Connection: Close\r\n"u8;
}

// HTTP/1.1 connections are persistent by default so we do not need to send a Keep-Alive header
return keepAlive ? ReadOnlySpan<byte>.Empty : "Connection: Close\r\n"u8;
}

private static int CountDigits(ulong value)
{
var digits = 1;

while (value >= 10)
{
value /= 10;
digits++;
}

return digits;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void Append(Span<byte> target, ref int offset, ReadOnlySpan<byte> value)
{
value.CopyTo(target[offset..]);
offset += value.Length;
}

}
24 changes: 0 additions & 24 deletions Engine/Shared/Types/WriterExtensions.cs

This file was deleted.

31 changes: 30 additions & 1 deletion Testing/Acceptance/Engine/HeaderTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using GenHTTP.Api.Infrastructure;
using System.Net;

using GenHTTP.Api.Infrastructure;
using GenHTTP.Modules.IO;
using GenHTTP.Testing.Acceptance.Utilities;

namespace GenHTTP.Testing.Acceptance.Engine;
Expand All @@ -25,6 +28,32 @@ public async Task TestServerHeaderCanBeSet(ServerEngine engine)
Assert.AreEqual("TFB", response.GetHeader("Server"));
}

[TestMethod]
[MultiEngineTest]
public async Task TestLargeHeadersAreWritten(ServerEngine engine)
{
var value = new string('x', 20_000);

var handler = new FunctionalHandler(responseProvider: r =>
{
return r.Respond()
.Header("X-Large", value)
.Header("X-Small", "small")
.Content("Hello World")
.Build();
});

await using var runner = await TestHost.RunAsync(handler.Wrap(), engine: engine);

using var response = await runner.GetResponseAsync();

await response.AssertStatusAsync(HttpStatusCode.OK);

Assert.AreEqual(value, response.GetHeader("X-Large"));
Assert.AreEqual("small", response.GetHeader("X-Small"));
Assert.AreEqual("Hello World", await response.GetContentAsync());
}

[TestMethod]
[MultiEngineTest]
public async Task TestDateHeaderCanBeSet(ServerEngine engine)
Expand Down
Loading