REST API Frameworks
Having used REST API frameworks such as ASP.NET Core, Spring Boot and Quarkus, I noticed that they tend to share a broadly similar execution model.
An incoming HTTP request is received and parsed into some form of request context. That context passes through a middleware pipeline, where each component may inspect, enrich, transform or short-circuit the request.
If processing continues, the request is routed to an endpoint handler, typically a controller action.
The handler invokes application logic and produces a response, which propagates back through the pipeline where it may undergo further processing such as logging, compression and header manipulation before being serialized and returned to the client.
The implementations differ, but the underlying architectural primitives are surprisingly familiar.
I think it is good for backend developers to understand these fundamentals.
Including myself.
Client Request Pipeline
This is an abstracted view of the common components found in a modern REST API framework:
After modelling the architecture, I decided to build it.
Constraints
To explore the architecture end-to-end, I built a basic HTTP/1.1 web server and a minimal REST API framework under a deliberately restrictive set of constraints:
- absolutely no dependency on ASP.NET Core types, no HttpContext etc.
- no borrowing infrastructure such as DI/host composition, caching, etc.
- everything from scratch, including HTTP protocol, header parsing, routing, etc.
- maintainability score ≥ 80% using the Visual Studio Maintainability Index
- time-boxed to two weeks
The objective wasn't to build an alternative to ASP.NET Core.
It was to reconstruct enough of the machinery to understand how the pieces fit together.
Flame and Phoenix
The solution consisted of two console applications:
- Flame
- Phoenix
and two class libraries:
- Flame.Http
- Flame.Utilities
Flame provided a minimal HTTP implementation and REST framework.
Phoenix rose from the flame, a simple web application.
Technologies included C#/.NET, JetBrains Rider, Visual Studio and Locust for load testing.
It's Alive!
There is something particularly satisfying about watching a web server respond to its first request:
initializing...flame is running....
server listening on port 8080
...
[1] client connection opened:
...
HTTP/1.1 200 Ok
Server: Flame/0.1
Date: Sun, 21 Apr 2024 05:44:09 UTC
Content-Type: text/html
Content-Length: 661
Connection: keep-alive
Set-Cookie: session=1; Path=/; HttpOnly; Max-Age=1200
At this point, Flame was speaking the language of the web.
The next question was how well it would behave under pressure.
Enter the Locusts
Using Locust, I wrote a custom Python script to exercise the server:
The first run used a single user to verify that requests completed without failures.
The second ramped up to 1,000 concurrent users, increasing by ten users per second. It was interesting to watch the increasing concurrency affect response times. The tests were run locally on my development laptop:
Locust and Phoenix used approximately the same amount of CPU, but memory was a different story:
The 10K Challenge
Several years ago, whilst working through Bryan Cairns's fantastic C++/Qt video series, I encountered his 10k concurrent connections challenge. I couldn't resist.
Phoenix reached 10k concurrent connections, although performance had already begun to degrade at around 7k.
Across 1,414,478 requests, 11,576 failed:
As we can see, plenty of room for improvement.
Framework Semantics
By the time we reached Phoenix, we could see the concepts emerge from the lower-level machinery in Flame.
Here we see a Phoenix controller inheriting from AppController and delegating to a service injected by Flame:
[Controller(Path = "/api/command/")]
public class CommandController(GraphicsService graphics) : AppController
{
[Get(Path = "fill/:x/:y")]
public string Fill(int x, int y) => graphics.Fill(Session.Canvas, x, y);
// ... other actions
}
The route expression fill/:x/:y is used by Flame to map the incoming path values to the action parameters.
By the time this method was invoked, Flame had received and parsed the HTTP request, constructed the request context, passed it through middleware, identified the route, created the controller, injected its dependencies and mapped the route values to the action parameters.
A small amount of application code was sitting on top of quite a lot of machinery.
Session and Application State
I initially built a sharded cache, but later pulled back and explored the semantics afforded by DynamicObject.
The Session and Application objects used a dynamic object store:
public class ObjectStore : DynamicObject, IEnumerable<(string, object?)>
{
private readonly ConcurrentDictionary<string, object?> _settings
= new(StringComparer.OrdinalIgnoreCase);
// ...
}
That allowed client code to remain extremely simple:
var pen = new Pen();
Session.Pen = pen;
Session.Canvas = new Canvas(pen, width, height);
Anything could be stored in the session, with runtime interception handling access to the underlying objects.
WebApplication
I reproduced the familiar application-builder semantics found in ASP.NET Core, building the DI container and supporting infrastructure as an exercise. Configuration and startup ultimately became:
var builder = new WebApplicationBuilder();
var app = builder.Build();
app.MapControllers();
app.Run();
Code Quality
The project remained in reasonably good shape, although Phoenix missed the maintainability target:
I wasn't particularly concerned. Phoenix was deliberately a thin consumer application; the framework machinery and architectural complexity lived in Flame.
That constraint for Flame, however, proved useful. Building infrastructure from scratch makes it very easy for implementation complexity to leak into the abstractions exposed to consumers.
Conclusion
I must say, this was fun.
I had to restrain myself from improving it, especially with the recent C#/.NET improvements.
ASP.NET Core exists, and it is exceptional. There would be little value in turning a learning exercise into an attempt to reproduce years of engineering effort embodied in a mature framework.
The value was in reconstructing the machinery.
Building Flame and Phoenix forced me to compose the architectural primitives of a web server and REST API framework from first principles: transport, parsing, request contexts, middleware, routing, controllers, state, application services, serialization and responses.
Frameworks make these things look simple.
Building one is a very effective way of discovering why.