Welcome to MyTinyWebServer! This project is a lightweight, from-scratch web server built using modern C# and .NET 8. It is designed to be an educational tool that demonstrates the core concepts behind web frameworks like ASP.NET Core, including middleware, routing, dependency injection, and model binding.
The solution is divided into five distinct projects, each with a clear responsibility:
MyTinyWebServer: The main executable project that hosts and runs the web server. It composes the services and wires up the application.TinyWebServerLib: The core web server library. It contains all the essential components for handling HTTP requests, routing, and managing the server lifecycle.TinyWebServerLib.Tests: Comprehensive unit and integration tests for the core library, using xUnit and FluentAssertions.CustomerApi: A sample API project containing aCustomersController. It demonstrates how to build an API using theTinyWebServerLibframework.TinyLogger: A reusable, aspect-oriented logging library. It usesCastle.Core's DynamicProxy to intercept method calls and provide automatic logging, demonstrating powerful metaprogramming concepts.
This project is built on several key architectural patterns that are fundamental to modern web development.
The server is configured and created using a builder pattern. The TinyWebServerBuilder provides a fluent API to chain configuration calls for setting up the server's URL, registering middleware, and mapping controllers.
// In Program.cs
var services = new ServiceCollection();
services.AddLogging()
.AddProxiedControllers(typeof(CustomersController).Assembly);
var serviceProvider = services.BuildServiceProvider();
var builder = new TinyWebServerBuilder();
builder.UseServiceProvider(serviceProvider)
.Use(loggingMiddleware)
.MapController<CustomersController>()
.UseUrl(IPAddress.Any, 4221);
TinyWebServer server = builder.Build();The server features a middleware pipeline, just like in ASP.NET Core. Each middleware component is a function that processes a request and can either short-circuit the pipeline or pass the request to the next component (next).
Middleware is registered with the Use method. This example shows a simple request logging middleware:
builder.Use(next => async request =>
{
Console.WriteLine($"{request.Method} {request.Path}");
return await next(request); // Pass to the next middleware
});The Router is responsible for mapping an incoming HTTP request's method and path to a specific handler function. It supports parameterized routes (e.g., /customers/{id}) by compiling route templates into regular expressions to extract values.
The server integrates with Microsoft.Extensions.DependencyInjection.
- Request Scope: For every incoming HTTP request, a new DI scope is created. This ensures that "scoped" services (like controllers) are created once per request and disposed of afterward.
- Service Availability: The request-specific
IServiceProvideris attached to theHttpRequestobject, making it available throughout the request pipeline for use by the framework.
Controllers and their actions are mapped using attributes, which provides a declarative and clean way to define your API endpoints.
[ApiController]: A class-level attribute that marks a class as a controller.[HttpGet("...")]&[HttpPost("...")]: Method-level attributes that map an action to an HTTP method and route template.
The ControllerMapper uses reflection to find these attributes and wire up the routes automatically.
To keep controllers clean and focused on business logic, the framework provides automatic model binding. The ControllerMapper inspects the parameters of a controller action and automatically populates them from the request:
- From Route: Parameters like
int idare matched with route values from the URL (e.g.,/customers/123). - From Body: A complex object parameter (e.g.,
Customer customer) is automatically deserialized from the JSON request body in a POST or PUT request.
This transforms a controller action from this:
// Before: Manual parsing
public Task<HttpResponse> GetCustomerById(HttpRequest request)
{
if (!request.RouteParameters.TryGetValue("id", out var idValue) || !int.TryParse(idValue.ToString(), out var id))
{
// ... handle error
}
// ...
}...into the much cleaner and more expressive:
// After: Automatic model binding
public Task<HttpResponse> GetCustomerById(int id)
{
// The 'id' parameter is already parsed and available.
var customer = new Customer(id, $"Customer {id}");
// ...
}The TinyLogger project is a powerful example of AOP. It uses Castle.Core's ProxyGenerator to create a dynamic proxy around a registered service.
- An
IInterceptor(LoggerInterceptor) is attached to this proxy. - When a method on the controller is called, the interceptor's
Interceptmethod is invoked first, allowing us to automatically log method entry, exit, arguments, and exceptions without adding a single line of logging code to the controller itself. - Registration is handled by convention using the
AddProxiedControllersextension method, which finds all classes marked with[ApiController]in an assembly and applies the logging proxy.
Here is a step-by-step walkthrough of what happens when a POST /customers request with a JSON body hits the server:
- Connection Accepted: The
TinyWebServer'sTcpListeneraccepts an incomingTcpClientconnection. - Request Handling Begins: A new task is fired off to run
HandleClientAsyncto process the request without blocking the listener. - DI Scope Created: A new dependency injection scope is created for this specific request (
serviceProvider.CreateAsyncScope()). This ensures any scoped services live only for the duration of this request. - Request Parsing: The server reads from the
NetworkStreamline-by-line to parse the HTTP headers. It then reads the request body based on theContent-Lengthheader. The raw text is parsed into a structuredHttpRequestobject. - Middleware Execution: The
HttpRequestis passed to the first middleware in the pipeline. Each middleware runs and callsnext()to pass the request down the chain. - Routing: The final "middleware" in the pipeline is the
Router. It matches the request's method (POST) and path (/customers) to the handler that was registered by theControllerMapper. - Controller Resolution: The handler, created by
MapController, is invoked. It uses the request'sIServiceProvider(request.RequestServices) to get an instance ofCustomersController.- Because controllers were registered via the
AddProxiedControllersextension method, the DI container doesn't return a direct instance. Instead, it returns a proxy with theLoggerInterceptorattached.
- Because controllers were registered via the
- Model Binding: Before invoking the controller action, the
ControllerMapper's logic inspects the target method (CreateCustomer(Customer customer)). It sees theCustomerparameter and usesJsonSerializerto deserialize therequest.Bodyinto aCustomerobject. - AOP Interception & Action Execution:
- The call to
CreateCustomeris intercepted byLoggerInterceptor, which logs "Calling CreateCustomer...". - The actual
CreateCustomermethod on the real controller is invoked with the model-boundCustomerobject. - The method runs its validation and business logic, returning a
Task<HttpResponse>. - The interceptor's async handling logic logs the successful completion of the method after the
Taskfinishes.
- The call to
- Response Generation: The
HttpResponseobject (e.g., with status code 201) is returned up the call stack. - Response Serialization: The
HandleClientAsyncmethod serializes theHttpResponseobject into a raw HTTP response string (status line, headers, and body). - Sending the Response: The response string is converted to bytes and written back to the client's
NetworkStream. - Cleanup: The
try-finallyblock ensures theTcpClientis closed. Theawait usingstatements on the DI scope andNetworkStreamensure they are properly disposed of, cleaning up all resources for the request.
- Open the solution in Visual Studio.
- Set
MyTinyWebServeras the startup project. - Press F5 or click the "Run" button.
- The console will indicate that the server is running. You can now send requests to it using a tool like Postman, curl, or a web browser.
GET http://localhost:4221/customersGET http://localhost:4221/customers/123POST http://localhost:4221/customers(with a JSON body like{"id": 10, "name": "New Customer"})
The project includes comprehensive unit and integration tests using xUnit and FluentAssertions.
dotnet testdotnet test --collect:"XPlat Code Coverage"Coverage reports are generated in TestResults/ folder in Cobertura format.
First, install the ReportGenerator tool:
dotnet tool install -g dotnet-reportgenerator-globaltoolThen generate the report:
reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:HtmlOpen coveragereport/index.html in a browser to view the detailed coverage report.
- .NET 8 - Target framework
- Castle.Core - Dynamic proxy generation for AOP
- Microsoft.Extensions.DependencyInjection - Dependency injection container
- Microsoft.Extensions.Logging - Logging abstractions
- xUnit - Testing framework
- FluentAssertions - Fluent assertion library
- Coverlet - Code coverage collection
This project is licensed under the MIT License - see the LICENSE file for details.
This project is intended for educational purposes only. It is not designed or tested for production use. Use it to learn about web server internals, middleware patterns, and metaprogramming concepts in C#.