Building a Minimal ASP.NET Core clone

14/09/2023
C#.NETASP.NET

In this article, we will build a minimal version of what ASP.NET Core does - yes, you read right. We will create a very simplistic clone of ASP.NET Core to discuss how the whole thing works. Beginning with a simple console application, we will add the necessary components to make it work as a web server. Bonus points for our own middleware pipeline and dependency injection.

ASP.NET

ASP.NET is a web framework for building web applications on the .NET platform. It is a super feature rich, and obviously, in this small blog post, I can only cover a very tiny part of it - but I will try anyway. In this post I will create a WebAPI clone that can do the following things:

  • It starts a server and listens to a port (right now only on localhost and fixed port)
  • We will be able to create simple controllers that can handle requests (for now only HTTP POST)
  • It will deserialize the request body into a model and pass it to the controller
  • The controller will return a model that will be serialized and sent back to the client
  • We will leverage the power of dependency injection to decouple everything very nicely
  • Bonus: We will be able to add middleware to the pipeline

After all that, you hopefully have a better understanding of how ASP.NET works oversimplified. Let's get started.

The Console Application

The obvious part is to create a new console application. One small dependency we can add right away is Microsoft.Extensions.DependencyInjection because we will need it later on. Before we do anything, let's create a simple server that listens to a port:

var httpListener = new HttpListener();
httpListener.Prefixes.Add("http://localhost:5001/");
httpListener.Start();

Console.WriteLine("Listening...");

while (true)
{
    // This blocks until the next request comes in
    var context = httpListener.GetContext();
    // ... and then we handle the request

That is our "main" loop so to speak - everything will be around this code (more or less). The HttpListener is a class that is part of the .NET framework and it is used to listen to HTTP requests. We add a prefix to the listener that defines the port and the host we want to listen to. After that we start the listener and then we enter the main loop. The GetContext method blocks until the next request comes in. When that happens we get a HttpListenerContext that contains all the information about the request.

Usage

Here is the super simple code how a controller would look like and yes it looks almost the same as the one in ASP.NET Core:

public class MyController : ControllerBase
{
    [Route("api/post")]
    public MyDto Call(DtoRequest request)
    {
        return new MyDto(request.Name);
    }

    [Route("api/another")]
    public MyDto Another(DtoRequest request)
    {
        return new MyDto("Another " + request.Name);
    }
}

public record MyDto(string Name);
public record DtoRequest(string Name);

ControllerBase is our "marker" so that our system later on will know what is a controller and what is just a simple service. For the sake of this discussion, it will just stay a marker.

public abstract class ControllerBase { }

The Route attribute is used to define the route of the controller. And it is just your everyday attribute:

[AttributeUsage(AttributeTargets.Method)]
public sealed class RouteAttribute : Attribute
{
    public string Route { get; }
    public RouteAttribute(string route)
    {
        Route = route;
    }
}

As you can see, there is only a Route property that is set in the constructor, no HttpGet and so on. We will technically don't care and emulate more or less "Post" behavior.

DI Container

Earlier I talked about the Dependency Injection container, so let's create one. And more importantly, add extension methods that come in handy for the user:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddControllers(this IServiceCollection services)
    {
        var controllers = Assembly.GetExecutingAssembly()
            .GetTypes()
            .Where(t => t.IsSubclassOf(typeof(ControllerBase)));

        foreach (var controller in controllers)
        {
            services.AddTransient(controller);
        }

        return services;
    }
}

This one scans all our ControllerBase implementations and adds them as singleton to the container. If we look now in our main, we can do something like this:

var serviceProvider = new ServiceCollection()
    .AddControllers()
    .BuildServiceProvider();

Not so impressive, but the first step! The last steps are ahead of us - we need a way of mapping routes to a controller. So we will built up a registry, that does exactly that:

public class RouteRegistry
{
    internal Dictionary<string, (Type Controller, MethodInfo Method)> Routes { get; } = new();

    public RouteRegistry()
    {
        // Get all controllers
        var controllers = Assembly.GetExecutingAssembly()
            .GetTypes()
            .Where(t => t.IsSubclassOf(typeof(ControllerBase)));

        foreach (var controller in controllers)
        {
            var methods = controller.GetMethods();
            foreach (var method in methods)
            {
                var routeAttr = method.GetCustomAttribute<RouteAttribute>();
                if (routeAttr != null)
                {
                    // Map the route to the controller and method that will be invoked
                    Routes.Add(routeAttr.Route, (controller, method));
                }
            }
        }
    }
}

The registry is a simple class that scans all our controllers and their methods for the Route attribute. If it finds one, it will add it to the registry. The registry is a singleton and will be added to the DI container:

var serviceProvider = new ServiceCollection()
    .AddSingleton<RouteRegistry>()
    .AddControllers()
    .BuildServiceProvider();

Middleware

The last thing we are missing is someone that takes that information and invokes the controller with the JSON objects, we call it our RoutingMiddleware. That brings me to our Bonus Point. We want to have middleware support. Basically the request runs through a pipeline. For example we will go to the routing middleware, then to the controller and then back to the routing middleware. If we would add something before the routing middleware, that should be invoked first and has full control of the request, response and if the request continues!

So let's model all of that. First we need an interface for that:

public interface IMiddleware
{
    Task InvokeAsync(HttpListenerContext context, Func<Task> next);
}

The InvokeAsync method takes the HttpListenerContext and a Func<Task> that represents the next middleware in the pipeline. The middleware can decide if it wants to call the next middleware or not. If it does not call the next middleware, the request will not continue. With that we need two more things: A middleware pipeline and a something to add middlewares to the pipeline.

public class MiddlewarePipeline
{
    private readonly IReadOnlyList<IMiddleware> _middlewares;

    public MiddlewarePipeline(IReadOnlyList<IMiddleware> middlewares)
    {
        _middlewares = middlewares;
    }

    public Task InvokeAsync(HttpListenerContext context)
    {
        var index = -1;

        Func<Task>? nextMiddleware = null;
        nextMiddleware = () =>
        {
            index++;
            // If there are no more middlewares, return a completed task.
            // Otherwise, invoke the next middleware.
            return index < _middlewares.Count 
                ? _middlewares[index].InvokeAsync(context, nextMiddleware) 
                : Task.CompletedTask;
        };

        return nextMiddleware();
    }
}

And our way of registering middlewares:

public static IServiceCollection AddMiddleware<TMiddleware>(this IServiceCollection services)
    where TMiddleware : class, IMiddleware
{
    services.AddTransient<IMiddleware, TMiddleware>();
    return services;
}

With all the infrastructure in place, we can come to the final piece:

Routing Middleware

The routing middleware is the one that takes the request and invokes the controller. It is the last middleware in the pipeline and it will not call the next middleware. It will look like this:

public class RoutingMiddleware
{
    private readonly HttpListenerContext _context;
    private readonly IServiceProvider _serviceProvider;

    public RoutingMiddleware(HttpListenerContext context, private readonly IServiceProvider _serviceProvider;)
    {
        _context = context;
        serviceProvider = _serviceProvider;
    }

    public async Task InvokeAsync(HttpListenerContext context, Func<Task> next)
    {
        if (_routeRegistry.Routes.TryGetValue(request.RawUrl![1..], out var controllerAction))
        {
            // Read the request body and deserialize it to the appropriate type.
            using var reader = new StreamReader(context.Request.InputStream);
            var requestBody = await reader.ReadToEndAsync();

            // The type of object to deserialize to is determined by the method's first parameter.
            var parameterType = controllerAction.Method.GetParameters()[0].ParameterType;
            var requestObj = JsonSerializer.Deserialize(requestBody, parameterType);

            // Fetch the controller from the DI container.
            var controllerInstance = _serviceProvider.GetRequiredService(controllerAction.Controller);

            // Invoke the controller method and get the result.
            var actionResult = controllerAction.Method.Invoke(controllerInstance, new object[] { requestObj });

            // The type of object to serialize is determined by the method's return type.
            var resultJson = JsonSerializer.Serialize(actionResult);
            
            // Write the serialized result back to the response stream.
            await context.Response.OutputStream.WriteAsync(Encoding.UTF8.GetBytes(resultJson));
        }
        else
        {
            // Short-circuit the pipeline, handle not found.
            context.Response.StatusCode = 404;
            await context.Response.OutputStream.WriteAsync("Not Found"u8.ToArray());
        }
    }
}

For good measure let's throw in another custom one:

public class CustomMiddleware : IMiddleware
{
    public async Task InvokeAsync(HttpListenerContext context, Func<Task> next)
    {
        Console.WriteLine("Before invoking next");
        await next();
        Console.WriteLine("After invoking next");
    }
}

With that let's see our main method:

var serviceProvider = new ServiceCollection()
    .AddSingleton<RouteRegistry>()
    .AddControllers()
    .AddMiddleware<CustomMiddleware>()
    .AddMiddleware<RoutingMiddleware>()
    .BuildServiceProvider();

// Get the list of middleware from the DI container
var middlewares = serviceProvider.GetServices<IMiddleware>().ToList();

// Create a middleware container
var middlewareContainer = new MiddlewarePipeline(middlewares);

var httpListener = new HttpListener();
httpListener.Prefixes.Add("http://localhost:5001/");
httpListener.Start();

Console.WriteLine("Listening...");

while (true)
{
    var context = httpListener.GetContext();
    await middlewareContainer.InvokeAsync(context);
    context.Response.Close();
}

Wow - that was a lot of code and work! But we did it. We have a working web server that can handle requests and invoke controllers. We also have possibilities for adding middleware, that are executed in the order they were registered. Let's fire up postman:

img

And here the console log:

Listening...
Before invoking next
Inside RoutingMiddleware
Inside Controller
After invoking next

I added a few more Console.WriteLine (see the resource section if you want to check out the code). As you can see, the request goes through the middleware pipeline and the controller is invoked. The controller returns a result and the result is serialized and sent back to the client.

Overall, what a success! We do have a running server that can accept requests and return something in JSON - directly from our controller!

What I didn't show here in my example is, that you can also add services to the DI container and inject them into the controller. Yes that is also working, as the controller is retrieved from the DI container. And that obviously holds also true for your custom middleware.

Conclusion

I had tons of fun writing this article and I hope you had fun reading it. I hope you have a better understanding of how ASP.NET works and what it does. Obviously, this is a very simplistic version of ASP.NET and there is a lot more to it.

Resources

  • Source code to this blog post: here
  • All my sample code is hosted in this repository: here
10
An error has occurred. This application may no longer respond until reloaded. Reload x