Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

Introducing Simple HTTP Server for .NET

4.74/5 (10 votes)
14 Jan 2018CPOL5 min read 35.1K  
Simple HTTP server for .NET Core based on System.Net.HttpListener.

Summary

Introduction

This article presents the simple HTTP standalone server based on System.Net.HttpListener which is:

  • lightweight
    No dependencies.

  • simple
    There is only one relevant method Route.Add which associates a route with an action. Other methods are extensions on HttpListenerRequest and HttpListenerRequest classes. Zero configuration.

It supports partial file streaming, file caching (ETag), simple templating, single-pass body parsing (no temp file).

Making a Route

Each route is defined using the static method
Route.Add(<selector>, (rq, rp, args) => {/* processing function */}).
The rp and the rp correspond to the HttpListenerRequest and HttpListenerResponse objects and the args corresponds to Dictionary<string, string> object. The processing function can also be async.

Based on the selector type, routes can be formed by using a pattern or a selector function.

1. Selection by Pattern

The most common way to define a route is by using a string pattern. Variables are defined inside the parenthesis. They are automatically parsed and assigned to args parameter in a processing function.

Here, in the sample below, the processing function serves the specified file, using a current directory as the root. Specifying http method is optional and defaults to "GET".

JavaScript
Route.Add("/myPath/{file}", (rq, rp, args) => rp.AsFile(rq, args["file"]), "GET");

2. Selection by Function

Provided that a route selector cannot be expressed as a string pattern, or it requires additional verification, a route can be defined by a selector function which receives request, response and empty args dictionary which can be updated for the processing function. If a route selection is specified by a function, filtering based on the http method (GET, POST; DELETE; HEAD) has to be done manually.

In the snippet blow, the file route is verified by checking whether a path contains an extension. If the selector function returns true, the action is selected, otherwise the args variable is cleared and another route is selected for validation.

JavaScript
Route.Add((rq, rp, args) => 
{
   //true is the action needs to be processed
   return rq.HttpMethod == "GET" &&
          rq.Url.PathAndQuery.TryMatch("/myPath/{file}", args) &&
          Path.HasExtension(args["file"]);
}, 
(rq, rp, args) => rp.AsFile(rq, args["file"])); 

Pre-route Hook

In order to intercept a request before a corresponding action is executed, Route.OnBefore function has to be defined. The function receives request and response. A return value should be true if a request is handled and false to continue execution. A delegate can also serve for logging as shown in the sample below.

JavaScript
Route.OnBefore = (rq, rp) => 
{ 
   Console.WriteLine($"Requested: {rq.Url.PathAndQuery}"); 
   return false; //resume processing
};

Route Selection

Routes are validated in a order as they are defined, meaning that a route which is defined before will be verified first. The first valid route is selected. Therefore, a user should be careful when defining routes. The example below show ambiguous routes where the invocation order matters.

JavaScript
Route.Add("/hello-{word}", (rq, rp, args) => rp.AsText("1) " + args["world"]));    
    
Route.Add((rq, rp, args) => 
{
   var p = rq.Url.PathAndQuery;
   if(!p.StartsWith("/hello-")) return false;
   
   args["world"] = p.Replace("/hello-", String.Empty);
   return true;
}, 
(rq, rp, args) => rp.AsText(rq, "2) " + args["world"])); 

Depending on which route is defined earlier, the executed actions will differ.

Extensions

Most of the library is written using extension functions operating on HttpListenerRequest and HttpListenerResponse classes for the simple usage. The request extension is ParseBody. The response extensions may be grouped into:

  • With*

    Extensions that return a modified response, useful for method chaining: WithCORS, WithContentType, WithHeader, WithCode, WithCookie.

  • As*

    Extensions that execute a response, after which response modification is not possible: AsText, AsRedirect, AsFile, AsBytes, AsStream.

Partial Data Serving

The extensions AsFile, AsBytes, AsStream, support byte-range requests where only a part of a content will be served. This can be easily observed by serving a video file where only a portion of a video will be sent.

File Caching

When a file is served, ETag obtained by the file modified date is also sent. Next time, when a browser sends a request with the same ETag, NoContent response will be given meaning that the file can be used form a local cache. This way, the significant traffic reduction may be achieved. Such behavior is done automatically by the server,

Reading Request Body

A body of a request is read and parsed using the ParseBody extension function. The function parses both, the form key-value pairs and the provided files. The form key-value pairs are stored into the provided dictionary. The example below shows the extraction of body-form values and files.

JavaScript
Route.Add("/myForm/", (rq, rp, args) => 
{
    var files = rq.ParseBody(args);

    //save files
    foreach (var f in files.Values)
       f.Save(f.FileName);

    //write form-fields
    foreach (var a in args)
       Console.WriteLine(a.Key + " " + a.Value);
}, 
"POST");

By default, each file is stored into MemoryStream. In case files are large, it is advisable that files are written directly to a disc. The ParseBody function enables such behavior by providing a callback which becomes active when a file is about to be read. The function needs to return a stream. The stream is closed when a returned HttpFile is disposed.

JavaScript
Route.Add("/myForm/", (rq, rp, args) => 
{
    //files are directly saved to disc (useful for large files)
    var files = rq.ParseBody(args, 
                             (name, fileName, mime) => File.OpenRead(fileName));
               
    //close file streams if not needed              
    foreach(var f in files)
      f.Dispose();
}, 
"POST");

Error Handling

When an exception is thrown, a Route.OnError handler is invoked. The parameters are: request, response, and the thrown exception.

The default handler makes a text response where the message is an exception message. A status code is a previously set status unless its value is in range [200 .. 299] in which case the code is replaced by 400 (bad request).

A sample of a custom error handler, which displays custom messages for defined exception types, is shown below:

JavaScript
Route.OnError = (rq, rp, ex) => 
{
   if (ex is RouteNotFoundException)
   {
      rp.WithCode(HttpStatusCode.NotFound)
        .AsText("Sorry, nothing here.");
   }        
   else if(ex is FileNotFoundException)
   {
      rp.WithCode(HttpStatusCode.NotFound)
        .AsText("The requested file not found");
   }
   else
   {
      rp.WithCode(HttpStatusCode.InternalServerError)
        .AsText(ex.Message);
    }
};

HTTPS

To enable secure (HTTPS) connection, a certificate setup for HttpListener is applied. A Windows-based approach will be explained, because the generic OS support is not yet ready at the time of writing - Jan 2018. The current status may be seen in: Github Issue Tracker - .NET Core.

Windows-based solution includes importing a certificate into the local certificate storage and making the appropriate HTTPS reservation using netsh utility. The library includes two scripts, located in the Script map of the repository. The first script generates a test certificate, and the other imports the certificate into the store and makes the HTTPS reservation. Steps on how to do it manually (the non-script way) are given inside the Richard Astbury's blog post.

Templating

The library implements a simple templating engine which takes all strings defined in bracelets as keys and replaces them with specified values.

Replacements values are defined by a dictionary holding key-value pairs, as shown below:

JavaScript
var str = "My name is {name} and surname {surname}";
str = Templating.RenderString(new Dictionary<string, string>
                             {
                                {"name", "John"},
                                {"surname", "Smith"}
                             });

//str is "My name is John and surname Smith"

The replacements can also be specified with a class where variable names are interpreted as keys:

JavaScript
var str = "My name is {Name} and surname {Surname}";
str = Templating.RenderString(new
                              {
                                 Name = "John",
                                 Surname = "Smith"
                              });

//str is "My name is John and surname Smith"

Besides the RenderString function, replacement can also be done by specifying a file instead of a template string, using the RenderFile function.

Conclusion

This article presented the HTTP standalone server library written for .NET. The simplicity of the usage, as it is shown throughout the article, makes it attractive for small projects. The shown snippets should be enough for the basic understanding how things works. The full samples are in the repository just waited to be tested and put to a good use :)

History

  • 11th January, 2018 - First version released

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)