diff --git a/Engine/Shared/Types/ResponseSerializer.cs b/Engine/Shared/Types/ResponseSerializer.cs index b1c267f40..7e141c4f4 100644 --- a/Engine/Shared/Types/ResponseSerializer.cs +++ b/Engine/Shared/Types/ResponseSerializer.cs @@ -1,5 +1,5 @@ -using System.Buffers; using System.IO.Pipelines; +using System.Runtime.CompilerServices; using GenHTTP.Api.Protocol; @@ -7,7 +7,30 @@ namespace GenHTTP.Engine.Shared.Types; public static class ResponseSerializer { + private static ReadOnlySpan Newline => "\r\n"u8; + private static ReadOnlySpan HeaderSeparator => ": "u8; + + private static ReadOnlySpan ContentTypePrefix => "Content-Type: "u8; + + private static ReadOnlySpan ContentLengthPrefix => "Content-Length: "u8; + + private static ReadOnlySpan ContentEncodingPrefix => "Content-Encoding: "u8; + + private static ReadOnlySpan ChunkedHeader => "Transfer-Encoding: chunked\r\n"u8; + + private static ReadOnlySpan EmptyContentLength => "Content-Length: 0\r\n"u8; + + /// + /// Checks, whether the body of the given response needs to be written to the client. + /// + /// + /// The headers still describe the content if no body is sent, e.g. for a HEAD request. + /// + /// The request the response is written for, or null if the request could not be parsed + /// The response to be written + /// Whether the client sent a HEAD request + /// false, if there is no content, the content is known to be empty or this is a HEAD request public static bool ShouldSendBody(IRequest? request, IResponse response, bool headRequest) { if (request == null) @@ -33,97 +56,174 @@ public static bool ShouldSendBody(IRequest? request, IResponse response, bool he /// /// Writes the status line and the headers of the final response to the given request. /// + /// + /// 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. + /// /// The request the response is written for, if any public static void WriteHead(PipeWriter writer, Request? request, IResponse response, bool keepAlive, ReadOnlyMemory serverHeader, ReadOnlyMemory 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 serverHeader, ReadOnlyMemory 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.Empty : serverHeader.Span; + var date = hasDate ? ReadOnlySpan.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 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.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 target, ref int offset, ReadOnlySpan value) + { + value.CopyTo(target[offset..]); + offset += value.Length; } } diff --git a/Engine/Shared/Types/WriterExtensions.cs b/Engine/Shared/Types/WriterExtensions.cs deleted file mode 100644 index 72362070d..000000000 --- a/Engine/Shared/Types/WriterExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Buffers; -using System.Runtime.CompilerServices; - -namespace GenHTTP.Engine.Shared.Types; - -internal static class BufferWriterExtensions -{ - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static void Write(this IBufferWriter writer, ulong number) - { - Span buffer = writer.GetSpan(20); - - if (number.TryFormat(buffer, out var written)) - { - writer.Advance(written); - } - else - { - throw new InvalidOperationException("Unable to write number to buffer"); - } - } - -} diff --git a/Testing/Acceptance/Engine/HeaderTests.cs b/Testing/Acceptance/Engine/HeaderTests.cs index add734c75..c57acb778 100644 --- a/Testing/Acceptance/Engine/HeaderTests.cs +++ b/Testing/Acceptance/Engine/HeaderTests.cs @@ -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; @@ -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)