annuaire tech France

Tinyweb Series, Part 1: Getting Started

This is the first part of a short series about Tinyweb, a deliberately small web micro-framework for .NET that we will build step by step. It is a teaching device, not a product. The goal is to understand how web frameworks work by building one whose entire source fits in your head.

The series covers getting started (this part), building APIs, dependency injection and filters, views and model binding, and finally a complete to-do demo.

Design goals

Before writing code, decide what Tinyweb should be. Our rules:

  • Small. A handful of classes, each doing one job.
  • Convention first. A class ending in Controller handles requests; a public method is an action.
  • Testable. Actions are plain methods that return a result object. Nothing in them touches HttpContext directly.
  • Built on ASP.NET. We borrow the hosting and the HTTP pipeline and add only what we need.

If the idea of building such a thing seems odd, why building a web framework is the new black makes the case for it as a learning exercise.

Project setup

Create an empty ASP.NET web application project. Add a reference to System.Web if it is not there already, and a new class library or folder named Tinyweb to hold the framework code. Keeping the framework in a separate folder, or better a separate project, stops you from mixing framework code with application code, which is a habit worth building from day one.

Then tell ASP.NET to hand every request to our front handler. In web.config, register an HTTP handler for all paths (the exact section depends on whether you run the integrated or classic pipeline, so follow the documentation for your hosting mode).

The front handler

Everything starts with IHttpHandler:

public class TinywebHandler : IHttpHandler
{
    public bool IsReusable { get { return true; } }

    public void ProcessRequest(HttpContext context)
    {
        var request = new TinyRequest(context);
        var result = Tinyweb.Dispatcher.Handle(request);
        result.Execute(context);
    }
}

We wrap the context in our own TinyRequest so that the rest of the framework depends on a small type that we control and can fake in tests.

The request and the result

public class TinyRequest
{
    public string Method { get; private set; }
    public string Path { get; private set; }
    public NameValueCollection Query { get; private set; }
    public NameValueCollection Form { get; private set; }

    public TinyRequest(HttpContext context)
    {
        Method = context.Request.HttpMethod;
        Path = context.Request.AppRelativeCurrentExecutionFilePath.TrimStart('~');
        Query = context.Request.QueryString;
        Form = context.Request.Form;
    }
}

public abstract class TinyResult
{
    public abstract void Execute(HttpContext context);
}

public class TextResult : TinyResult
{
    private readonly string _text;
    public TextResult(string text) { _text = text; }

    public override void Execute(HttpContext context)
    {
        context.Response.ContentType = "text/plain";
        context.Response.Write(_text);
    }
}

The first controller

With the plumbing in place, an application controller is refreshingly ordinary:

public class HomeController
{
    public TinyResult Index()
    {
        return new TextResult("Hello from Tinyweb");
    }

    public TinyResult Greet(string name)
    {
        return new TextResult("Hello, " + name);
    }
}

Notice that neither action mentions HTTP. You can call new HomeController().Greet("Ann") in a unit test and check the returned object.

The dispatcher

The dispatcher maps the path to a controller and action using the convention: /home/greet becomes HomeController.Greet.

public class Dispatcher
{
    public TinyResult Handle(TinyRequest request)
    {
        var parts = request.Path.Trim('/').Split('/');
        var controllerName = (parts.Length > 0 && parts[0] != "" ? parts[0] : "home");
        var actionName = parts.Length > 1 ? parts[1] : "index";

        var type = FindController(controllerName);
        if (type == null) return new NotFoundResult();

        var method = type.GetMethods()
            .FirstOrDefault(m => string.Equals(m.Name, actionName, StringComparison.OrdinalIgnoreCase));
        if (method == null) return new NotFoundResult();

        var args = BindArguments(method, request);
        return (TinyResult)method.Invoke(Activator.CreateInstance(type), args);
    }
}

Here FindController scans the assembly for a type named {name}Controller (cache the result at startup), and BindArguments pulls values for each parameter from the query string. We will replace that naive binder in part 4.

Running it

Start the site and browse to the root. You should see the greeting from Index. Then try the greeting action with a query string such as /home/greet?name=Ann. If you see "Hello, Ann", the whole loop works: request, convention, reflection, result.

What we have and what is missing

  • A single entry point, a dispatcher, controllers and results.
  • No JSON or XML yet. That is the subject of part 2.
  • Controllers are created with Activator.CreateInstance, so no dependencies. Part 3 fixes that.
  • No views and a very weak binder. Part 4.

Continue with part 2, building APIs. If you want the broader testing story for code like this, take a look at isolating code with Moles and Fakes for the cases where you cannot avoid static dependencies.

More .NET guides

Looking for a software partner? Browse the independent directory of French tech companies (web agencies, IT consultancies, software publishers; the directory itself is in French).