Step-by-Step Guide to Creating an HTTP Server in C# - Part 2

Saul HernandezSaul Hernandez
9 min read

In this post, I'll be sharing my journey during the process of an HTTP Server development from scratch. The purpose of this post will be refactor the code, add HTTP Compression to the server. It only supports the gzip compression scheme. Also, I'll add support for persistent HTTP connections and explicit connection closure using the Connection: close header.

Refactoring the code

I replaced the dictionary-based approach for handling endpoints and responses with dynamic response construction in code. Additionally, I used a HashSet to store and validate the supported endpoints more efficiently.

Compression headers

In this stage, I'll add support for the Accept-Encoding and Content-Encoding headers. An HTTP client uses the Accept-Encoding header to specify the compression schemes. The server then chooses one of the compression schemes listed in Accept-Encoding and compresses the response body with it. Then, the server sends a response with the compressed body and a Content-Encoding header. Content-Encoding specifies the compression scheme that was used. If the server doesn't support any of the compression schemes specified by the client, then it will not compress the response body.

Here's a breakdown of the request that specified gzip compression:

GET /echo/foo HTTP/1.1\r\n

// Headers
Host: localhost:4221\r\n
User-Agent: curl/7.64.1\r\n
Accept: */*\r\n
Accept-Encoding: gzip\r\n  // Client specifies it supports the gzip compression scheme.

Here's the response body is compressed with gzip:

// Status line
HTTP/1.1 200 OK\r\n

// Headers (empty)
Content-Encoding: gzip\r\n // Server specifies that the response body is compressed with gzip.
Content-Type: text/plain\r\n  // Original media type of the body.
Content-Length: 23\r\n        // Size of the compressed body.
\r\n      // CRLF that marks the end of the headers

// Compressed body.

To implement this feature on the server, the first step is to look for the Accept-Encoding header in the request. If this is present and includes a valid encoding, the server response must include Content-Encoding header. Otherwise, It should send a standard response and omit the Content-Encoding header. For this project, I'll assume the server only supports the gzip compression scheme.

I started by initialising the encoding header as an empty string. Then, I searched for the Accept-Encoding header in the request. If it isn't found, the encoding header remains an empty string. If the header is present, I validated that the encoding data isn't a null or empty value and contains the : separator. Next, I extracted the value, I checked it in the HashSet of supported encodings (In this case only gzip), and generated the Content-Encoding header when applicable.

// ...
HashSet<string> validEndpoints = new HashSet<string> { "/", "/echo", "/user-agent", "/files" };
HashSet<string> validCompressions = new HashSet<string> { "gzip" };

// ...

else if (urlPath == "/echo"){
      string data = requestTarget.Replace("/echo/", "");

      string encodingHeader = ""; // default: no encoding header
      string encodingData = httpRequestParts.FirstOrDefault(item => item.StartsWith("Accept-Encoding", StringComparison.OrdinalIgnoreCase)) ?? "";

      if (!string.IsNullOrEmpty(encodingData) && encodingData.Contains(':')){
        string encodingValue = encodingData.Split(":", 2)[1].Trim().ToLower();

        if (validCompressions.Contains(encodingValue)){
          encodingHeader = $"Content-Encoding: {encodingValue}\r\n";
        }
      }

      string response = $"{httpVersion} 200 OK\r\n" +
            "Content-Type: text/plain\r\n" +
            encodingHeader +// Replacing content-encoding (when requested)
            $"Content-Length: {data.Length}\r\n\r\n" + // Replacing content-length
            data;                                      // Replacing response body
      socket.Send(Encoding.UTF8.GetBytes(response));
    }
// ...

Multiple compression schemes

In this stage, I'll add support for Accept-Encoding headers that contain multiple compression schemes. An HTTP client can specify that it supports multiple compression schemes by setting Accept-Encoding to a comma-separated list:

Accept-Encoding: encoding-1, encoding-2, encoding-3

To manage multiple compression schemes, I first extract the list of encoding types and convert them to lowercase. Then, I use a foreach loop to iterate through each encoding type. Next, I check if the encoding type exists in the HashSet of supported encodings. If gzip is detected, I update the encoding header.

// ...
    if (!string.IsNullOrEmpty(encodingData) && encodingData.Contains(':'))
      {
        string encodingValues = encodingData.Split(":", 2)[1].Trim().ToLower();

        foreach (var compressionType in encodingValues.Split(",").Select(values => values.Trim()))
        {
          if (validCompressions.Contains(compressionType))
          {
            if (compressionType == "gzip")
            {
              encodingHeader = $"Content-Encoding: {compressionType}\r\n";
              break;
            }
          }
        }
      }
// ...

Gzip compression

For this stage, I'll add support for gzip compression to the HTTP server. GZip is a format designed to compress HTTP content before it is delivered to a client. It utilizes the gzip algorithm to reduce file size and optimize transmission. By reducing file size, Gzip enhances data transmission speeds, which is why web servers often compress data before sending it to the client’s browser.

In order to achieve this task on the server. If gzip is detected, I update the encoding header and apply gzip compression to the response data. I created a function called handleGzipCompression that takes the data to be compressed. GZipStream takes an stream and a compression mode as parameters - Compress in this case. I am using a memory stream as the output stream. When data is written into the GZipStream, it goes into the output stream as compressed data. Then, I convert the result into a byte array that will be returned by the function.

Additionally, I modified how the response is constructed to ensure the compressed data is sent correctly to the client.

// ...
      string data = requestTarget.Replace("/echo/", "");
      byte[] responseBody = Encoding.UTF8.GetBytes(data); // Encode the response body using UTF-8 by default
// ...
        if (compressionType == "gzip") // If gzip is supported and requested, compress the body and update headers
            {
              encodingHeader = $"Content-Encoding: {compressionType}\r\n";
              responseBody = handleGzipCompression(data); // Apply gzip compression
              break;
            }
        }
// ...
      string responseHeaders = $"{httpVersion} 200 OK\r\n" +
                        "Content-Type: text/plain\r\n" +
                        encodingHeader + // content-encoding (when requested)
                        $"Content-Length: {responseBody.Length}\r\n\r\n";  // Replacing content-length

      socket.Send([.. Encoding.UTF8.GetBytes(responseHeaders), .. responseBody]);

// ...

byte[] handleGzipCompression(string originalData)
{
  byte[] dataToCompress = Encoding.UTF8.GetBytes(originalData);
  using (var outputStream = new MemoryStream())
  {
    using (var compressor = new GZipStream(outputStream, CompressionMode.Compress))
    {
      compressor.Write(dataToCompress, 0, dataToCompress.Length);
    }

    return outputStream.ToArray();
  }
}

Adding persistent connections

For this stage, I'll add support for persistent HTTP connections. By default, HTTP/1.1 connections are persistent, meaning the same TCP connection can be reused for multiple requests. This connection is persistent unless the client sends a Connection: close header. The server should not close the connection after each request.

To accomplish this feature, I updated the handleRequest function by adding a while loop to validate if the client´s connection remains open. Next, I check if the Connection header appear in the request. If its value is close, the shouldClose flag is set to true, otherwise it remains false. Finally, at the end of each request processed, I check the value of shouldClose, if it is true, the client connection is closed.

// ...
void handleRequest(Socket socket)
{
  Console.WriteLine($"Connection from {socket.RemoteEndPoint} has been established");

  while (socket.Connected)
  {
    // ...
    string connectionData = httpRequestParts.FirstOrDefault(item => item.StartsWith("Connection", StringComparison.OrdinalIgnoreCase)) ?? "";
    bool shouldClose = false;

    if (connectionData.Contains(':')) // Check for `Connection: close` header
    {
      string connectionValue = connectionData.Split(":", 2)[1].Trim().ToLower();
      if (connectionValue == "close")
      {
        shouldClose = true;
      }
    }
    // ...

    //Closes the socket to free up system resources
    if (shouldClose)
    {
      Console.WriteLine($"Connection from {socket.RemoteEndPoint} has been closed by the client.");
      socket.Close();
      break;
    }
  }
}

Adding connection closure

In this stage, I added support for explicit connection closure using the Connection: close header.

To implement this, I added a connectionHeader value with an empty string by default. When the Connection header appear in the request, then I update the value with the proper Connection: close header in the response. Also, I update all the responses of the endpoints in order to use this variable.

    string connectionHeader = "";
// ...
    if (connectionData.Contains(':')) // Check for `Connection: close` header
    {
      string connectionValue = connectionData.Split(":", 2)[1].Trim().ToLower();
      if (connectionValue == "close")
      {
        connectionHeader = $"Connection: close\r\n";
        shouldClose = true;
      }
    }
// ...

        string response = $"{httpVersion} 200 OK\r\n" +
                          $"{connectionHeader}\r\n";

        socket.Send(Encoding.UTF8.GetBytes(response));

Refactoring the code

I added a new function called generateHttpResponse, which is responsible for generating the proper HTTP response. It takes the HTTP version, status code, status message, a dictionary of the response headers, and the body data in bytes as parameters. The function returns a byte array representing the HTTP response, ready to be sent to the client.

// ...
    //Sends a response string to the connected client.
      if (urlPath == "/")
      {
        socket.Send(generateHttpResponse(httpVersion, 200, "OK", headers, body));
      }
// ...
byte[] generateHttpResponse(string httpVersion, int statusCode, string statusMessage, Dictionary<string, string> headers, byte[] body)
{
  string response = $"{httpVersion} {statusCode} {statusMessage}\r\n";

  foreach ((string headerName, string value) in headers)
  {
    response += $"{headerName}: {value}\r\n";
  }

  response += "\r\n";

  byte[] responseBytes = Encoding.UTF8.GetBytes(response);

  return [.. responseBytes, .. body];
}

I also created a function called extractHTTPRequest, which is responsible for extracting the HTTP Request data. This function takes HTTP Request in bytes as parameter and it returns a tuple containing three items.

  • A dictionary with the elements of the request line.

  • A dictionary with all the headers in the request.

  • A string that contains the body content.

// ...
    Dictionary<string, string> requestLine;
    Dictionary<string, string> requestHeaders;
    string requestBody;

    (requestLine, requestHeaders, requestBody) = extractHTTPRequest(httpRequest);
// ...
(Dictionary<string, string> requestLine, Dictionary<string, string> headers, string body) extractHTTPRequest(byte[] httpRequest)
{
  string[] requestParts = Encoding.UTF8.GetString(httpRequest).Split("\r\n");
  Dictionary<string, string> requestLineDict = new();
  Dictionary<string, string> headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

  string requestLine = requestParts[0];

  int indexEmptyLine = Array.IndexOf(requestParts, String.Empty); // First empty line (that separates headers from the body).
  string[] headersArray = requestParts[1..indexEmptyLine];

  // Parse request line
  string[] requestLineParts = requestLine.Split(" ");
  if (requestLineParts.Length == 3)
  {
    (requestLineDict["Method"], requestLineDict["Target"], requestLineDict["Version"]) = (requestLineParts[0], requestLineParts[1], requestLineParts[2]);
  }

  // Extract headers
  foreach (string header in headersArray)
  {
    string[] data = header.Split(":", 2);
    (string headerName, string value) = (data[0].Trim(), data[1].Trim());
    headers[headerName] = value;
  }

  // Extract body
  string body = string.Join(Environment.NewLine, requestParts[(indexEmptyLine + 1)..]);

  return (requestLineDict, headers, body);
}

If you're curious to see how everything came together, you can check out the full project on GitHub: HTTP Server (C#). The code is organized to follow each stage of the process, and I’ve tried to keep it clean and easy to follow. Feel free to explore, fork the repo, or open an issue if you have ideas or suggestions—I'd love to hear your thoughts!

Conclusion

This experience allowed me to learn and improve my back-end development skills. Build this project gave me a solid foundation for understanding how the web server works.

Resources

Here are some of the materials and references that helped me throughout this project:

0
Subscribe to my newsletter

Read articles from Saul Hernandez directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Saul Hernandez
Saul Hernandez

Continuously learning and growing in software development, with experience in web, mobile, and SQL. I love sharing my journey and exploring innovation in tech! 🚀