Summary
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).
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
".
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.
Route.Add((rq, rp, args) =>
{
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.
Route.OnBefore = (rq, rp) =>
{
Console.WriteLine($"Requested: {rq.Url.PathAndQuery}");
return false;
};
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.
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.
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,
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.
Route.Add("/myForm/", (rq, rp, args) =>
{
var files = rq.ParseBody(args);
foreach (var f in files.Values)
f.Save(f.FileName);
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.
Route.Add("/myForm/", (rq, rp, args) =>
{
var files = rq.ParseBody(args,
(name, fileName, mime) => File.OpenRead(fileName));
foreach(var f in files)
f.Dispose();
},
"POST");
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:
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);
}
};
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.
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:
var str = "My name is {name} and surname {surname}";
str = Templating.RenderString(new Dictionary<string, string>
{
{"name", "John"},
{"surname", "Smith"}
});
The replacements can also be specified with a class where variable names are interpreted as keys:
var str = "My name is {Name} and surname {Surname}";
str = Templating.RenderString(new
{
Name = "John",
Surname = "Smith"
});
Besides the RenderString
function, replacement can also be done by specifying a file instead of a template string, using the RenderFile
function.
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