- Series: redb ecosystem
This continues the redb.Route series. Earlier on dev.to:
- I spent a year building Apache Camel for .NET. Here's the honest state of it.
- redb.Route — Apache Camel for .NET: 22 transports, 30+ EIP patterns, compiled DSL
- Enterprise Integration Patterns in .NET, the deep-dive series — Part 1: the four in-memory channels
- redb.Route 3.0.1 — flat DSL navigation, CRTP refactor, and a silent null fix
- redb.Route 3.1.0 — LLM(AI) as just another connector
- Enterprise-grade AI integration: embedding LLMs into the business processes — redb.Route.Llm 3.1.1
Full list on the author's dev.to profile.
In redb.Route — our Apache Camel-style ESB for .NET — a route always reads the same way: From(source) → [processors] → To(sink). This installment takes one simple integration pattern and one connector and dissects both all the way down.
-
The pattern: the Content-Based Router — the most basic of the routing patterns from Hohpe & Woolf: look inside a message and decide where it goes next. In the DSL it's
.Choice().When(...).Otherwise(). -
The connector:
redb.Route.Http— built-in HTTP/HTTPS. On one side it's a producer (anHttpClient-based caller); on the other, a consumer (an embedded Kestrel server). No controllers, no[ApiController], no ASP.NET middleware pipeline you wire up by hand.
This is a long, technical piece. You'll get the five-line "hello world", but then we go into how the connector works internally: how a single Kestrel is shared across routes, how request headers and route values flow into the Exchange and back, how CORS actually works on a shared server, what happens with streaming, and why there isn't a single app.UseCors() in the codebase.
Every snippet is verified against redb.Route/src/redb.Route.Http; every example is lifted from the real redb.Route.Demo.
Part 0. The scenario everything hangs on
Take a down-to-earth task: an HTTP gateway. A POST /api/demo comes in, and inside we:
- take the body,
-
look at the
modeheader and pick a processing branch accordingly — that's the Content-Based Router; - reply synchronously over the same HTTP request (request/reply).
Here's the skeleton (full version at the end), from redb.Route.Demo/Routes/MainPipelineRoutes.cs:
From("http:0.0.0.0:5088/api/demo?inOut=true")
.RouteId("demo-http-entry")
.ConvertBody<string>()
.Choice()
.When(e => GetHeader(e, "mode") == "full")
.SetHeader("stamp.dsl", "full-branch")
.When(e => GetHeader(e, "mode") == "short")
.SetHeader("stamp.dsl", "short-branch")
.Otherwise()
.SetHeader("mode", "default")
.SetHeader("stamp.dsl", "default-branch")
.EndChoice()
.SetHeader("Content-Type", "application/json")
.SetBody(e => BuildResponse(e));
One From brings up an HTTP server on port 5088, one .Choice() decides the message's fate, one .SetBody(...) builds the reply. Now let's see how it works.
Part 1. Content-Based Router — the simple pattern, honestly dissected
What it actually is
The Content-Based Router answers one question — "where next?" — by looking at the message itself, not at external configuration. The textbook example: orders with region=EU go to one handler, region=US to another, everything else to a default.
In redb.Route this is the ChoiceProcessor (redb.Route/src/redb.Route/Processors/ChoiceProcessor.cs), and in the DSL it's a .Choice() block:
.Choice()
.When(<predicate>) // branch 1
...processors...
.When(<predicate>) // branch 2
...processors...
.Otherwise() // default branch (optional)
...processors...
.EndChoice()
Semantics are exactly a switch: predicates are checked top to bottom, the first branch whose predicate returns true runs, the rest are skipped. If none match and there's an .Otherwise(), it runs; if there's no .Otherwise(), the message passes through untouched.
Two ways to express a predicate
1. A lambda — when the condition is easier to write as code:
.Choice()
.When(e => GetHeader(e, "mode") == "full") ...
.When(e => GetHeader(e, "mode") == "short") ...
.Otherwise() ...
.EndChoice()
2. Fluent predicates over the expression engine — when you want it declarative. From redb.Route.Demo/Routes/DataObservabilityRoutes.cs:
.Choice()
.When(Header("amount").isGreaterThanOrEqualTo(1000).Matches, w => w
.SetHeader("tier", "gold"))
.When(Header("amount").isBetween(500, 999).Matches, w => w
.SetHeader("tier", "silver"))
.When(Header("amount").isLessThan(500).Matches, w => w
.SetHeader("tier", "bronze"))
.Otherwise(o => o
.SetHeader("tier", "unknown"))
Header("amount").isBetween(500, 999) isn't a closure — it's a real IPredicate, compiled once and cached as a delegate forever after. Under the hood it's the series' compiled expression engine (Tokenizer → Parser → AST → System.Linq.Expressions → IL), but that's a whole article of its own.
Why pair it with HTTP
The Content-Based Router and an HTTP gateway are made for each other. On the way in, the HTTP consumer decomposes the request into Exchange headers (more below): method, path, query params, route params, every HTTP header. Any of them is ready material for .When(...):
.Choice()
.When(e => GetHeader(e, "redbHttp.Method") == "DELETE") ... // by method
.When(e => GetHeader(e, "X-Tenant") == "acme") ... // by header
.When(e => GetHeader(e, "redbHttp.QueryParam.debug") == "1") ... // by query
.EndChoice()
The router never touches HTTP itself — it works on an already-decoded Exchange. That's the whole point of a connector: turn transport into a message so the integration patterns know nothing about transport.
Straight from production
Here's a production route from the TsUM system (delivery monitoring), verbatim. HTTP entry + Content-Based Router by method — GET and POST on one path fan out to different handlers:
From("http:0.0.0.0:5090/api/tsum/user-filters?inOut=true&cors=true&corsOrigins=*")
.RouteId("tsum-api-user-filters")
.Process(Auth.ProcessAsync) // JWT auth — just a processor
.ConvertBody<string>()
.Choice()
.When(e => e.In.Headers.TryGetValue("redbHttp.Method", out var m) && m?.ToString() == "POST")
.ProcessWithRedb((redb, exchange, ct) => HandlePost(redb, exchange))
.Otherwise()
.ProcessWithRedb((redb, exchange, ct) => HandleGet(redb, exchange))
.EndChoice();
You can see everything we're about to cover in one shot: the HTTP consumer on port 5090 (inOut=true, cors=true&corsOrigins=*), the Content-Based Router over redbHttp.Method, and authentication as an ordinary processor in the chain — no [Authorize] attributes. Now let's dissect how each piece works inside.
Part 2. The HTTP connector from 10,000 feet
The same http/https scheme yields two fundamentally different roles depending on whether it sits in From(...) or To(...):
| Role | Class | Built on | What it does |
|---|---|---|---|
Consumer (From) |
HttpConsumer |
Kestrel | Brings up an embedded HTTP server and accepts inbound requests |
Producer (To) |
HttpProducer |
HttpClient |
Sends outbound HTTP requests to a remote address |
The DSL entry points are the static Http and Https classes (redb.Route.Http/Fluent/HttpDsl.cs):
// Consumer — listen for inbound
.From(Http.Listen("/webhook").Port(8080).Cors("https://app.example.com").InOut())
// Producer — send outbound
.To(Http.Post("api.example.com/orders").BearerAuth().Timeout(5000))
Or the raw URI string (the builder compiles to exactly this):
.From("http:0.0.0.0:8080/webhook?cors=true&corsOrigins=https://app.example.com&inOut=true")
.To("http:api.example.com/orders?method=POST&timeout=5000")
HTTP methods: the consumer listens, the producer sends
Methods (GET/POST/PUT/…) are set differently for the two roles — and there's more than one way. First the producer (To) — which method to send:
// fluent — the method is chosen by the factory method:
.To(Http.Get("api.example.com/users")) // GET
.To(Http.Post("api.example.com/users")) // POST
.To(Http.Put("api.example.com/users/42")) // PUT
.To(Http.Delete("api.example.com/users/42")) // DELETE
.To(Http.Patch("api.example.com/users/42")) // PATCH
.To(Http.Head("api.example.com/users/42")) // HEAD
// the same as a URI string (singular parameter — method):
.To("http:api.example.com/users?method=POST")
// the method can be overridden per-message by a header — it wins over the option:
.SetHeader("redbHttp.Method", "PUT")
.To(Http.Post("api.example.com/users/42")) // actually sends PUT
Now the consumer (From) — which methods to accept:
// by default ALL methods are accepted:
.From(Http.Listen("/webhook").Port(8080))
// restrict the allowed set (anything else → 405 Method Not Allowed):
.From(Http.Listen("/webhook").Port(8080).Methods("POST"))
.From(Http.Listen("/orders").Port(8080).Methods("POST,PUT"))
// the same as a URI string (plural parameter — methods):
.From("http:0.0.0.0:8080/webhook?methods=POST")
// shorthand: a method prefix right in the path (for the consumer):
.From("http:POST:0.0.0.0:8080/webhook")
.From("http:GET:/health")
The terminology difference is easy to trip over:
Producer (To) |
Consumer (From) |
|
|---|---|---|
| URI parameter |
method (singular) |
methods (plural, comma-separated) |
| Meaning | which method to send | which methods to accept (empty = all) |
| Default | GET |
all methods |
| Per-message override |
redbHttp.Method header |
— (the filter is static) |
The prefix shorthand (http:POST:/...) is a special case: it sets both values at once (method and methods), because the same string can serve as either a producer or a consumer.
And the typical move when different methods hit one path: accept several and fan them out with a Content-Based Router on redbHttp.Method (this is exactly the production example from Part 1):
.From(Http.Listen("/api/tsum/user-filters").Port(5090).Methods("GET,POST").Cors("*").InOut())
.Choice()
.When(e => GetHeader(e, "redbHttp.Method") == "POST")
.ProcessWithRedb((redb, ex, ct) => HandlePost(redb, ex)) // write
.Otherwise()
.ProcessWithRedb((redb, ex, ct) => HandleGet(redb, ex)) // read
.EndChoice();
We'll dissect both roles separately — but first, the big question.
Part 3. "Where's ASP.NET?" — there isn't any, on purpose
When a .NET developer hears "embedded HTTP server", they picture WebApplication, controllers, [HttpPost], filters, model binding, app.UseRouting(), app.UseCors(), a DI middleware pipeline. The redb.Route HTTP connector has none of that. There's Kestrel — bare, with no MVC layer on top.
Here's how the server comes up (SharedHttpServerManager.StartServer, abridged):
var builder = WebApplication.CreateSlimBuilder(); // slim — no MVC, no extra services
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.Listen(IPAddress.Parse(entry.Host), entry.Port, listenOptions =>
{
listenOptions.Protocols = protocols; // HTTP/1, /2, /3 — see below
if (entry.Ssl) listenOptions.UseHttps(entry.SslCertPath, entry.SslCertPassword);
});
kestrel.Limits.MaxRequestBodySize = entry.MaxRequestBodySize > 0
? entry.MaxRequestBodySize : null;
});
builder.Logging.ClearProviders();
var app = builder.Build();
// ONE catch-all endpoint — we route from here ourselves
app.Map("/{**path}", (HttpContext ctx) => HandleCatchAll(entry, ctx));
app.MapGet("/", (HttpContext ctx) => HandleCatchAll(entry, ctx));
app.MapPost("/", (HttpContext ctx) => HandleCatchAll(entry, ctx));
// ... PUT/DELETE/PATCH/HEAD/OPTIONS on "/"
await app.StartAsync(ct);
Note the key decisions:
-
WebApplication.CreateSlimBuilder(), notCreateBuilder(). The slim builder doesn't drag in MVC, Razor, ASP.NET auth, or the rest of the scaffolding — only what Kestrel needs. -
Exactly one catch-all route
/{**path}. ASP.NET routing is used solely to intercept everything and hand it to our own dispatcher,HandleCatchAll. No controller matching. -
builder.Logging.ClearProviders()— the server stays quiet on the host console; logs go through the route's logger.
Why? Because redb.Route is an integration engine, and HTTP is just transport to it — the same as Kafka or RabbitMQ. A route shouldn't know Kestrel is behind it: it gets an Exchange. ASP.NET controllers would impose their own model (attributes, model binding, ActionResult) that's redundant and alien inside a DSL route.
One Kestrel per process, not per route — and Tsak relies on it
A frequent question: "if my app already runs on ASP.NET/Kestrel (say, inside a Tsak worker), does the connector reuse the server or spawn new ones?"
First, what the connector does not do: it doesn't graft onto an external ASP.NET pipeline. The redb.Route.Http project has zero integration points with an external host (no IApplicationBuilder, no UseEndpoints, no IServer — none of it). It stands up its own Kestrel via WebApplication.CreateSlimBuilder().
But "stands up its own" ≠ "spawns instances." The key is that SharedHttpServerManager is registered in DI as a singleton (redb.Route.Http/Extensions/ServiceCollectionExtensions.cs):
public static IServiceCollection AddRedbRouteHttp(this IServiceCollection services, ...)
{
services.AddSingleton<SharedHttpServerManager>(); // ← one server manager per process
services.AddSingleton(sp => { var c = new HttpComponent();
c.ServerManager = sp.GetRequiredService<SharedHttpServerManager>(); ... return c; });
services.AddSingleton(sp => { var c = new HttpsComponent(); ... });
...
}
One manager per process means one Kestrel pool, shared across all route contexts. And Tsak leans on exactly this. Its worker is a plain Host.CreateDefaultBuilder (not a WebApplication) — it has no Kestrel of its own. Even Tsak's own REST admin API (the _system context, port 9090 by default) is not a separate web server — it's an ordinary redb.Route HTTP route brought up through that same singleton manager (redb.Tsak.Core/Services/SystemContextBuilder.cs):
// Tsak registers the HTTP connector itself...
services.AddRedbRouteHttp(); // redb.Tsak.Core/Extensions/ServiceCollectionExtensions.cs
// ...and brings up its admin API as a regular route on the shared manager:
var listenUri = $"http:{host}:{port}/{{**path}}?host={host}&port={port}&inOut=true";
routeContext.AddRoutes(r => r.From(listenUri).Process(/* bridge → auth → dispatch */));
So nothing is "forwarded from the host's Kestrel" — on the contrary, the host (Tsak) brings up Kestrel through the connector and reuses it. Any route targeting the same (host, port) joins the already-running server instead of starting a second one. Tsak even mounts its system-echo route on the admin port — and they don't collide: the specificity ordering from Part 4 separates the concrete /api/echo from the catch-all {**path} (called out in a comment in SystemContextBuilder).
Without Tsak — same thing, you just wire the manager yourself
Tsak isn't magic here: Kestrel is always brought up by SharedHttpServerManager; Tsak is merely a host that registers that manager and routes through it. In a standalone app (no Tsak) you wire the manager by hand. Here's a bare RouteContext from the Llm.HttpShell demo — no DI, no Tsak:
var ctx = new RouteContext(sp, contextId: "llm-http-shell");
ctx.AddComponent(new HttpComponent { ServerManager = new SharedHttpServerManager() });
// ...
ctx.AddRoutes(r => r.From("http:0.0.0.0:5088/api/llm/shell?inOut=true") ... );
Or the same through DI — AddRedbRouteHttp() registers that same singleton manager for you. Either way, the first From("http:host:port/...") that starts brings up a fresh Kestrel via CreateSlimBuilder() for that (host, port) pair, and the rest of the routes on the same (host, port) join it.
Subtlety: pooling works within a single
SharedHttpServerManagerinstance. Create two separate managers and point both at one port and you get a socket-bind conflict, not sharing. The "one Kestrel per(host, port)" guarantee comes from a shared manager — a singleton out of the box under Tsak, and your responsibility standalone.
The practical takeaway: within a process (and one manager) there are exactly as many Kestrels as there are distinct (host, port) pairs. Mounting a route on a port another redb route already listens on (including Tsak's admin port) is fine — that's the whole "don't multiply" point. A socket-bind conflict only happens if a foreign, non-redb server already grabbed the port.
Why bus frameworks like MassTransit don't have this
It's worth contrasting with MassTransit — one of the most popular .NET messaging frameworks. It has no HTTP consumer, no embedded server, no "http as a transport." And that's not an omission; it follows from the architecture.
MassTransit is a message bus over brokers: RabbitMQ, Azure Service Bus, Amazon SQS, plus Kafka/Event Hubs as "riders." Its model is asynchronous broker-mediated delivery with guarantees, retries, and sagas; consumers are keyed to a message type (IConsumer<TMessage>), not a URI endpoint. HTTP doesn't fit that picture: synchronous request/reply contradicts the async/durable bus model. So MassTransit leaves HTTP ingress to ASP.NET — you stand up a controller or minimal API and Publish/Send to the bus from there. The "HTTP → message" boundary lives outside the framework, by hand, in your host code.
redb.Route (like Apache Camel, which it follows) is built differently: it's a mediation engine, and to it HTTP is just another transport, the same as Kafka or Rabbit. An HTTP request is normalized into the same Exchange a broker message becomes, and flows through the same EIP processors. That's why From("http:...") exists as a first-class route source, the connector owns Kestrel itself, and an "HTTP → Kafka → SQL → reply" bridge is one DSL chain without leaving the framework.
These are different tools for different jobs, not "better/worse": MassTransit shines at reliable broker delivery and sagas over queues; Camel-style engines shine at stitching together heterogeneous transports and routing by content. An embedded HTTP server is a natural part of the second approach and fundamentally alien to the first.
Part 3½. "But I love controllers" — redb.Route.Controllers
This is where an indignant voice usually pipes up: "Attributes, [HttpGet], model binding — I like that, I don't want to write a .Choice() per endpoint!" Fair. That's why there's a separate package: redb.Route.Controllers. It hands you back the familiar MVC-controller ergonomics, but it does not hand you back the ASP.NET hosting model. Here's the trick.
A controller that looks like ASP.NET — but isn't
Here's a working controller (from the redb.Route.Tests.Controllers tests):
[Route("modules")]
public class ModulesController : RedbController
{
[HttpGet]
public string[] GetAll() => ["module1", "module2"];
[HttpGet("{id}")]
public string GetById([FromRoute("id")] string id) => $"module-{id}";
[HttpPost]
public object Create([FromBody] CreateModuleRequest request) =>
new { created = request.Name };
[HttpPut("{id}")]
public object Update([FromRoute("id")] string id, [FromBody] CreateModuleRequest request) =>
new { updated = id, name = request.Name };
[HttpDelete("{id}")]
public void Delete([FromRoute("id")] string id) { }
}
Painfully familiar: [Route] on the class, [HttpGet]/[HttpPost]/[HttpPut]/[HttpDelete]/[HttpPatch] on methods (with an optional sub-template "{id}"), parameter binding via [FromBody], [FromRoute], [FromQuery], [FromHeader], [FromProperty]. Return an object and it goes out as JSON.
But two differences are fundamental:
- The base class is
RedbController, notControllerBase. NoHttpContext, noIActionResult, no[ApiController]. Instead, two properties:Context(the route context) andExchange(the current message). The controller sees anExchange, not HTTP. - The controller is transport-agnostic. It knows nothing about HTTP. That matters one paragraph from now.
How a controller enters a route
A controller isn't an endpoint — it's a processor inside a route. You mount it on an HTTP entry via .RedbHttpController<T>():
From("http:0.0.0.0:5088/api/{**path}?inOut=true")
.RouteId("modules-api")
.RedbHttpController<ModulesController>();
Or via a registry with several controllers (or an assembly scan):
var registry = new ControllerRegistry();
registry.RegisterController(typeof(ModulesController));
registry.RegisterController(typeof(ContextsController));
// or: registry.RegisterAssembly(typeof(ModulesController).Assembly);
From("http:0.0.0.0:5088/api/{**path}?inOut=true")
.RedbHttpController(registry);
Note the {**path} — the HTTP consumer from Part 5 catches everything under /api, drops redbHttp.Method, redbHttp.Path, redbHttp.RouteParam.*, redbHttp.QueryParam.* into the Exchange, and the HttpControllerDispatcher parses those and finds the right action. No manual header translation:
// HttpControllerDispatcher.Process (abridged)
var method = exchange.In.GetHeader<string>("redbHttp.Method");
var path = exchange.In.GetHeader<string>("redbHttp.Path");
var action = _registry.Resolve(method, normalizedPath, out var routeParams);
if (action is null) { WriteError(exchange, 404, "NotFound", ...); return; }
Routing and binding — its own, not ASP.NET's
ControllerRegistry.Resolve matches (method, path) to an action segment by segment, picking the most specific one (literals beat {param}): GET /me/sessions/current beats GET /me/sessions/{id}. Same specificity principle as the shared server in Part 4, just at the controller level.
Parameter binding (ResolveHttpParameter) is exactly what the attributes promise: [FromBody] is JSON-deserialized from the byte[], [FromRoute] comes from the template, [FromQuery] from redbHttp.QueryParam.*, [FromHeader] / [FromProperty] from the Exchange headers/properties. With no attribute, it tries a route param by name, otherwise a complex type is bound from the body.
The response is assembled like this: return an object → JSON (camelCase, with UnsafeRelaxedJsonEscaping so Cyrillic and emoji don't become А), status 200; return null/void → 204; throw → an error envelope and 500 (a route miss → 404). The dispatcher sets status.code and redbHttp.ResponseCode, and the HTTP consumer from Part 5 picks them up. The loop closes.
Why this way, not "the ASP.NET way"
Here's the whole point. The same ModulesController, without changing a line, can be invoked over something other than HTTP:
// same controller — over gRPC
From(grpcConsumer).RedbGrpcController<ModulesController>();
// same controller — over SignalR
From(signalRConsumer).RedbSignalRController<ModulesController>();
RedbController is transport-agnostic precisely because it works with an Exchange, not an HttpContext. An ASP.NET controller can't do that — it's welded to the HTTP pipeline. And since .RedbHttpController<T>() is just a processor, it composes with the rest of the DSL: put .Throttle() before it, .WireTap() after it, wrap it in OnException/TryCatch, combine it with the .Choice() from Part 1.
So: love controllers? Use controllers. Just know that under them is not ASP.NET MVC but the same Exchange and the same route pipeline. You get the ergonomics without inheriting the hosting model.
And this isn't theory — Tsak itself runs on it
The best proof the approach is battle-tested: Tsak's entire REST admin API is built exactly this way. ContextsController, RoutesController, ModulesController, AuthController, UsersController, SchedulerController, LogsController, and a dozen more (redb.Tsak.Core/Controllers) all inherit RedbController and carry the same [Route] / [HttpGet] / [HttpPost] / [FromRoute] / [FromQuery]. Here's a slice of the real ContextsController:
[Route("/api/contexts")]
public class ContextsController : RedbController
{
[HttpGet("")]
public object? ListContexts() => /* ... */;
[HttpGet("/{name}")]
public object? GetContext([FromRoute("name")] string name) => /* ... */;
[HttpPost("/{name}/stop")]
public async Task<object?> StopContext(
[FromRoute("name")] string name,
[FromQuery("timeoutSeconds")] int? timeoutSeconds = null) => /* ... */;
}
Tsak registers their assembly (ControllerRegistry.RegisterAssembly) and dispatches them via ControllerDispatcherProcessor in the _system context — on the same HTTP connector as everything else. So the controllers are as production-hardened as the HTTP consumer itself: the Tsak dashboard and CLI talk to exactly these.
A full treatment of
redb.Route.Controllers(the registry, theIControllerActionFilteraction filters, the gRPC/SignalR dispatchers, the error envelope) is its own article in the series. Here it's just to put the "where are my controllers" question to rest.
Part 4. One Kestrel per (host, port) — SharedHttpServerManager
The least obvious part of the consumer is that multiple routes can listen on one port. If you have three From(...) on 0.0.0.0:5088 with different paths (/api/demo, /api/echo, /api/llm/ask), you get one Kestrel, not three.
SharedHttpServerManager owns this. The key is the (host, port) pair:
private readonly ConcurrentDictionary<string, ServerEntry> _servers = new(...);
private static string BuildKey(string host, int port) => $"{host}:{port}";
Registering a route
When a consumer starts, it doesn't "create a server" — it registers a route on the server for its (host, port). The server is created lazily, on the first registration:
// HttpConsumer.Start (abridged)
_registration = _serverManager.RegisterRoute(
host, port, path, _options.Methods, HandleRequest,
ssl, _options.SslCertPath, _options.SslCertPassword,
corsOptions, _options.MaxRequestBodySize, _options.Protocol);
await _serverManager.EnsureStarted(host, port, ct);
BaseUrl = _serverManager.GetBaseUrl(host, port); // e.g. "http://localhost:5088"
RegisterRoute drops a RouteRegistration (path template + methods + handler + CORS) into the ServerEntry's route list. EnsureStarted brings up Kestrel if it isn't running; if it already is, it's a no-op.
Dispatch: a route table of its own
Every request lands in one HandleCatchAll, which finds the matching route itself:
private static async Task HandleCatchAll(ServerEntry entry, HttpContext ctx)
{
var match = entry.MatchRoute(ctx.Request.Path, ctx.Request.Method);
if (match.Registration is null)
{
// path matched but method didn't → 405; otherwise → 404
ctx.Response.StatusCode = match.PathMatched
? StatusCodes.Status405MethodNotAllowed
: StatusCodes.Status404NotFound;
return;
}
if (match.RouteValues is not null)
ctx.Items["__redbRouteValues"] = match.RouteValues; // {id} etc. — into the Exchange next
await match.Registration.Handler(ctx);
}
Two subtleties worth knowing:
1. 404 vs 405. If the path matched but the method isn't allowed, you get an honest 405 Method Not Allowed, not a 404. Small, but correct.
2. Path templates and specificity ordering. Paths are parsed via TemplateParser/TemplateMatcher from ASP.NET routing — {id} parameters and {**rest} catch-alls are supported. But the check order is not registration order; it's by descending specificity (ServerEntry.GetCompiled):
_compiled = built
.OrderBy(x => x.spec.HasCatchAll ? 1 : 0) // concrete paths first, catch-all last
.ThenByDescending(x => x.spec.Literals) // more literal segments = more specific
.ThenBy(x => x.spec.Parameters) // fewer parameters = more specific
.ThenBy(x => x.order) // ties: stable, by registration order
.Select(...).ToArray();
Why? So a concrete /api/echo always beats a catch-all /{**path} registered on the same port, even if the catch-all was registered first. Without this rule, a catch-all would swallow every later route. The behavior matches what you intuitively expect from ASP.NET routing.
Lifecycle: reference counting
A server lives exactly as long as it has at least one route. When a consumer stops:
// HttpConsumer.Stop
_serverManager.UnregisterRoute(_registration);
await _serverManager.StopIfEmpty(host, port, ct);
StopIfEmpty stops and unloads Kestrel only if no routes remain:
public async Task StopIfEmpty(string host, int port, ...)
{
if (entry.Routes.Count > 0) return; // someone's still listening — leave it
_servers.TryRemove(key, out _);
await StopServer(entry, ct); // graceful stop with a 5s timeout
}
So if you stop one of three routes on port 5088, the server keeps running for the other two. Stop the last one, and Kestrel goes down. This lets you add and remove routes on the fly (e.g. from a dashboard: tsak route start demo-http-echo / stop) without bouncing the whole server.
Guard against mismatched schemes
You can't bind both HTTP and HTTPS on the same (host, port) — the manager throws on registration:
if (entry.Ssl != ssl)
throw new InvalidOperationException(
$"Server on {host}:{port} is already registered as {(entry.Ssl ? "HTTPS" : "HTTP")}...");
Part 5. Consumer: how an HTTP request becomes an Exchange
This is the heart of the inbound side — HttpConsumer.BuildExchange. Let's see exactly what reaches a route.
The request body
if (request.ContentLength > 0 || request.ContentType is not null)
{
if (_options.StreamRequest)
body = request.Body; // the stream as-is (passthrough)
else
{
using var ms = new MemoryStream();
await request.Body.CopyToAsync(ms, ...);
body = ms.ToArray(); // buffer into a byte[]
}
}
var message = new Message(body);
By default the body is buffered into a byte[]. That's why you almost always see .ConvertBody<string>() right after From — to turn bytes into a string. With streamRequest=true the body stays a Stream (for large uploads), and an important detail from the code comment: that stream is owned by Kestrel, Exchange.DisposeAsync does not close it, but it stays valid until the response is written.
Headers: what the connector puts into the Exchange
This is probably the single most useful reference table in the article. The consumer decomposes the request into Exchange headers under the redbHttp. prefix (HttpHeaders.cs):
| Exchange header | What it holds | Example |
|---|---|---|
redbHttp.Method |
HTTP method | POST |
redbHttp.Path |
request path | /api/demo |
redbHttp.Url |
full URL | http://localhost:5088/api/demo?x=1 |
redbHttp.Port |
server port (int) | 5088 |
redbHttp.Query |
raw query string | x=1&y=2 |
redbHttp.QueryParam.<name> |
one query parameter |
redbHttp.QueryParam.x = 1
|
redbHttp.RouteParam.<name> |
one path-template value |
/users/{id} → redbHttp.RouteParam.id
|
redbHttp.RemoteAddress |
client IP | 127.0.0.1 |
<any HTTP header> |
as-is |
Content-Type, Authorization, X-Chat-Id… |
A few implementation details that matter:
Multi-value query/headers. If a parameter repeats (?tag=a&tag=b), the values are joined with a comma:
message.Headers[$"redbHttp.QueryParam.{qp.Key}"] = qp.Value.Count switch
{
0 => string.Empty,
1 => (object)qp.Value[0]!,
_ => string.Join(",", qp.Value!)
};
Route values from the template. Remember ctx.Items["__redbRouteValues"] from the dispatcher? Here's where they're unpacked:
if (httpContext.Items.TryGetValue("__redbRouteValues", out var rvObj)
&& rvObj is RouteValueDictionary routeValues)
{
foreach (var (key, value) in routeValues)
if (value is not null)
message.Headers[$"redbHttp.RouteParam.{key}"] = value;
}
So From("http:0.0.0.0:8080/users/{id}") gives you ${header.redbHttp.RouteParam.id} in the route.
HTTP/2 pseudo-headers are filtered out. Headers like :method, :path (HTTP/2) are skipped — if (header.Key.StartsWith(':')) continue;.
Remembering inbound header names. Subtle but important. The connector collects the names of all inbound headers into a set and stores it in the exchange properties:
exchange.Properties["redbHttp.RequestHeaderNames"] = requestHeaderNames;
Why — becomes clear on the response side: so request headers are not reflected back into the response. Without it, an inbound Host or User-Agent could accidentally ride back to the client in the reply.
Exchange pattern: InOnly vs InOut
exchange.Pattern = _options.InOut ? ExchangePattern.InOut : ExchangePattern.InOnly;
-
InOnly (default) — fire-and-forget. The server replies with an empty
200 OKimmediately, and the route runs "in the background" relative to the response. This is a webhook receiver: "got it, thanks." -
InOut (
?inOut=true) — request/reply. The server waits for the route to finish and returns the result as the HTTP response. This is an API endpoint.
In practice: to return a body in the response you need inOut=true. Otherwise the body you assembled with .SetBody(...) goes nowhere (see WriteResponse — all body writing is under if (_options.InOut)).
The response: how an Exchange becomes HTTP again
WriteResponse assembles the HTTP response. Status code resolution order (by descending priority):
var statusCode = _options.ResponseCode; // 3. options default (200)
if (responseMsg.Headers.TryGetValue("redbHttp.ResponseCode", out var rc)) ... // 1. explicit header
else if (responseMsg.Headers.TryGetValue("status.code", out var sc)) ... // 2. transport-neutral fallback
So from a route you can return, say, 404 just by setting a header:
.SetHeader("redbHttp.ResponseCode", 404)
Response Content-Type follows a similar chain: redbHttp.ResponseContentType → Message.ContentType → options default (application/json).
Carrying headers into the response. This is where that RequestHeaderNames set earns its keep. A message header makes it into the HTTP response only if it clears several filters:
foreach (var (key, value) in responseMsg.Headers)
{
if (value is null) continue;
if (requestHeaderNames?.Contains(key) == true) continue; // don't reflect request headers
if (HttpHeaders.NonBridgedHeaders.Contains(key)) continue; // hop-by-hop and internal
if (HttpHeaders.IsRedbHeader(key)) continue; // redbHttp.* — internal
if (IsInternalHeader(key)) continue; // redb*/Camel* — internal
// ... set with multi-value support
}
And a special touch for Set-Cookie and other multi-value headers — StringValues is used so ASP.NET emits multiple header lines instead of one stringified array:
StringValues sv = value switch
{
string s => s,
string[] arr => arr,
IEnumerable seq when value is not string =>
seq.Cast<object?>().Select(o => o?.ToString() ?? "").ToArray(),
_ => value.ToString()
};
if (ContainsInvalidHeaderValueCharacters(sv)) continue; // Kestrel rejects control / non-ASCII
httpContext.Response.Headers.TryAdd(key, sv);
A gotcha from the repo's history. Response headers are copied always, even when the body is empty. Otherwise body-less responses (a 302 redirect, a 204 No Content, a
Set-Cookie-only reply) would silently dropLocation/Set-Cookie. It's called out in a code comment.
Part 6. CORS on a shared server — without app.UseCors()
CORS in ASP.NET means middleware and named policies. Here there's no ASP.NET CORS middleware at all. Instead there's one dispatch layer per server that selects the policy by the matched route. Why: a single (host, port) hosts different routes, and each can carry its own CORS policy. A classic UseCors with one policy per server can't do that.
The CORS parameters
At the endpoint level (HttpEndpointOptions):
| URI parameter | Property | Meaning |
|---|---|---|
cors=true |
Cors |
enable CORS for the route |
corsOrigins=... |
CorsOrigins |
comma-separated origin whitelist, or *
|
corsCredentials=true |
CorsCredentials |
allow Access-Control-Allow-Credentials
|
| — (code only) | CorsOriginsResolver |
a HttpRequest → string? delegate for dynamic origin selection |
In the fluent DSL:
.From(Http.Listen("/api").Port(8080).Cors("https://app.example.com").CorsCredentials())
Plus global defaults for the whole component, via DI:
services.AddRedbRouteHttp(cors =>
{
cors.Enabled = true;
cors.Origins = "https://example.com";
});
Endpoint parameters always override globals (HttpComponent.ApplyCorsDefaults).
No implicit *
A deliberate decision: if cors=true, you must supply either corsOrigins (including an explicit "*" for public endpoints) or a resolver. Otherwise it throws at startup (HttpEndpointOptions.Validate):
if (Cors && string.IsNullOrEmpty(CorsOrigins) && CorsOriginsResolver is null)
throw new ArgumentException(
"Cors=true requires CorsOrigins (use \"*\" for public endpoints) or CorsOriginsResolver to be set.");
Why: the old implicit * is a classic footgun. Combined with credentials, the browser silently rejects it, and the developer burns an afternoon wondering why "CORS doesn't work." Far better to fail fast at startup with a clear message.
How the dispatcher works (CorsDispatchMiddleware)
The layer is installed once per server, and only if at least one route declared CORS (entry.CorsEnabled). For each request:
var route = entry.MatchByPath(requestPath); // NOTE: by path, method-agnostic
var cors = route?.Cors;
if (cors is null) { await next(); return; } // a route without CORS — the layer is transparent
Matching by path, ignoring method is deliberate — so an OPTIONS preflight finds the route's policy even when OPTIONS isn't in the route's allowed-methods list.
Then origin resolution:
var resolved = ResolveOrigin(cors, ctx.Request, requestOrigin);
// wildcard+credentials footgun: the browser rejects it anyway → fail closed
if (resolved == "*" && cors.AllowCredentials)
resolved = null;
if (resolved is not null)
{
ctx.Response.Headers["Access-Control-Allow-Origin"] = resolved;
AppendVary(ctx.Response.Headers, "Origin"); // mandatory, else caches mix up policies
if (cors.AllowCredentials)
ctx.Response.Headers["Access-Control-Allow-Credentials"] = "true";
// ... preflight reflection below
}
ResolveOrigin behaves like this:
-
a resolver is set → its word is final (may return a specific origin,
"*", ornull); -
static
"*"→ emit*verbatim; -
static whitelist → reflect the request's
Origin, only if it's in the list (browsers don't understand a CSV inAccess-Control-Allow-Origin, so we single-select); - otherwise →
null(origin not allowed, no CORS headers emitted).
Preflight (OPTIONS). On preflight, the layer reflects the method and headers the browser asked for:
if (HttpMethods.IsOptions(ctx.Request.Method))
{
var requestedMethod = ctx.Request.Headers["Access-Control-Request-Method"].ToString();
ctx.Response.Headers["Access-Control-Allow-Methods"] = !string.IsNullOrEmpty(requestedMethod)
? requestedMethod : (cors.AllowedMethods ?? route!.Methods ?? "*");
var requestedHeaders = ctx.Request.Headers["Access-Control-Request-Headers"].ToString();
ctx.Response.Headers["Access-Control-Allow-Headers"] = !string.IsNullOrEmpty(requestedHeaders)
? requestedHeaders : (cors.AllowCredentials ? "Content-Type, Authorization" : "*");
ctx.Response.Headers["Access-Control-Max-Age"] = cors.MaxAgeSeconds.ToString(); // default 86400
}
// OPTIONS always short-circuits to 204 — even when the origin was rejected
if (HttpMethods.IsOptions(ctx.Request.Method))
{
ctx.Response.StatusCode = StatusCodes.Status204NoContent;
return;
}
So the whole of CORS is a careful hand-rolled implementation of the spec over Kestrel, with a correct Vary: Origin, proper preflight handling, and a guard against wildcard+credentials. No app.UseCors() anywhere.
Part 7. Streaming the response: SSE and chunked
An InOut consumer can return more than a finished body — it can return a stream, an IAsyncEnumerable<string>. This is used, for example, in the LLM connector to stream tokens. The logic lives in WriteResponse:
else if (body is IAsyncEnumerable<string> asyncStrings)
{
var useSse = responseContentType?.Contains("text/event-stream", ...) == true;
httpContext.Response.Headers["Cache-Control"] = "no-cache, no-transform";
httpContext.Response.Headers["X-Accel-Buffering"] = "no"; // don't buffer at nginx/LB
httpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering(); // turn off ASP.NET buffering
if (useSse)
await WriteSseStreamAsync(...); // text/event-stream → SSE framing
else
await WriteChunkedTextStreamAsync(...); // otherwise → chunked plain text
}
The framing choice is driven by the response Content-Type (the canonical end-to-end signal, Accept ↔ Content-Type), not a private header:
-
text/event-stream→ Server-Sent Events: each yield → onedata:frame; at the end, anevent: donecarrying late-bound summary headers (llm.tokens.in/out,llm.stop_reason, …) that the producer sets only after iteration finishes. -
everything else → chunked plain text: each yield is one chunk, with a
FlushAsyncafter each.
DisableBuffering() is critical: without it, ASP.NET would accumulate chunks and flush them in a batch, killing the whole point of streaming.
Part 8. Producer: an HTTP client with every knob
Now the outbound side — HttpProducer over HttpClient. The client is created once in ConnectAsync:
_handler = new HttpClientHandler
{
AllowAutoRedirect = _options.FollowRedirects, // followRedirects (default true)
MaxAutomaticRedirections = _options.MaxRedirects // maxRedirects (default 50)
};
_httpClient = new HttpClient(_handler)
{
Timeout = _options.Timeout > 0
? TimeSpan.FromMilliseconds(_options.Timeout) // timeout (default 30000)
: Timeout.InfiniteTimeSpan
};
ConfigureAuthentication(_httpClient);
Building the URL — four layers
ResolveUrl assembles the target address layer by layer:
// 1. Base URL from the endpoint, with ${...} expressions resolved
var baseUrl = _options.ResolveOption(_endpoint.BuildProducerUrl(), exchange) ?? ...;
// 2. Substitute {name} parameters from .Param(...)
baseUrl = ResolveNamedParams(baseUrl, exchange);
// 3. Query string from the redbHttp.Query header, if present
if (exchange.In.Headers.TryGetValue("redbHttp.Query", out var query) && query is string qs && qs.Length > 0)
{
var sep = baseUrl.Contains('?') ? "&" : "?";
return $"{baseUrl}{sep}{qs}";
}
The base URL is built by HttpEndpoint.BuildProducerUrl() — simply scheme + host[:port]/path. And {name} parameters are substituted with URL escaping:
// ResolveNamedParams
var resolved = valueTemplate.Contains("${")
? (_options.ResolveOption(valueTemplate, exchange) ?? valueTemplate)
: valueTemplate;
url = url.Replace($"{{{name}}}", Uri.EscapeDataString(resolved), ...);
So:
.To(Http.Get("api.example.com/users/{id}/orders").Param("id", Header("userId")))
turns at runtime into http://api.example.com/users/42/orders, where 42 comes from the userId header and is escaped.
The request method
The method comes from options (?method=POST) but can be overridden by the redbHttp.Method header:
if (exchange.In.Headers.TryGetValue("redbHttp.Method", out var hm) && hm is string methodStr)
return new SysHttpMethod(methodStr.ToUpperInvariant());
A body is set only for POST/PUT/PATCH (HasBody). byte[] → ByteArrayContent, Stream → StreamContent, anything else → StringContent in UTF-8.
Header bridging
With bridgeHeaders=true (default), Exchange headers ride out into the HTTP request — except internal and hop-by-hop ones (NonBridgedHeaders):
foreach (var (key, value) in exchange.In.Headers)
{
if (value is null) continue;
if (HttpHeaders.NonBridgedHeaders.Contains(key)) continue; // Connection, TE, redbHttp.*, Content-* etc.
if (!request.Headers.TryAddWithoutValidation(key, strValue))
request.Content?.Headers.TryAddWithoutValidation(key, strValue); // else try as a content header
}
NonBridgedHeaders is the union of internal redbHttp.*, hop-by-hop (Connection, Keep-Alive, Transfer-Encoding, TE, Trailer, Upgrade, Proxy-*), and the content headers managed by HttpClient (Content-Type, Content-Length, Content-Encoding…).
Authentication
Three schemes (HttpAuthScheme):
-
Basic —
username/password, theAuthorization: Basic ...header is set once on the client. - Bearer, static — a constant token, also set once on the client.
-
Bearer, dynamic — a token expression (
DynamicValue<string>.IsDynamic), resolved per request from theExchange:
// ConfigurePerRequestAuth
if (_options.AuthScheme == HttpAuthScheme.Bearer && _options.AuthToken.Value.IsDynamic)
{
var token = _options.AuthToken.Value.Resolve(exchange);
if (!string.IsNullOrEmpty(token))
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
So .BearerAuth().AuthToken(Header("jwt")) injects a fresh JWT from the header on every outbound call.
Response → Exchange
MapResponse drops the response body into Out and sets the standard headers:
outMessage.Headers["redbHttp.StatusCode"] = (int)response.StatusCode;
outMessage.Headers["redbHttp.StatusText"] = response.ReasonPhrase ?? "";
outMessage.Headers["redbHttp.Url"] = response.RequestMessage?.RequestUri?.ToString() ?? "";
With copyResponseHeaders=true (default) it copies the response headers plus Content-Type/Content-Length. With streamResponse=true the body becomes a Stream, not a byte[] — and then the HttpResponseMessage is deliberately not disposed right away: Exchange.DisposeAsync closes the stream.
throwOnError
By default throwOnError=true — on 4xx/5xx the producer throws an HttpRequestException (which your OnException/TryCatch will catch). Turn it off with .NoThrowOnError() if you'd rather handle statuses by hand via ${header.redbHttp.StatusCode}.
Part 9. Putting it all together — a real route
Back to MainPipelineRoutes.cs, here's the full HTTP entry with the Content-Based Router and request/reply (redb.Route.Demo):
From("http:0.0.0.0:5088/api/demo?inOut=true")
.RouteId("demo-http-entry")
.ConvertBody<string>() // byte[] → string
.Throttle(10) // no more than 10 req/sec
.Log("[1-HTTP] ▶ body=${body}, contentType=${contentType}")
.SetHeader("traceId", e => Guid.NewGuid().ToString("N")[..12])
.Log("[1-HTTP] traceId=${header.traceId}, mode=${header.mode}")
.ValidateJsonSchema(MessageSchema) // body must be JSON with a "message" field
.IdempotentConsumer(e => GetHeader(e, "traceId") ?? "", IdempotentRepo)
// ── Content-Based Router: route by the mode header ──
.Choice()
.When(e => GetHeader(e, "mode") == "full")
.SetHeader("fastTrack", e => GetHeader(e, "priority") == "high" ? "true" : "false")
.SetHeader("stamp.dsl", "full-branch")
.When(e => GetHeader(e, "mode") == "short")
.SetHeader("fastTrack", "false")
.SetHeader("stamp.dsl", "short-branch")
.Otherwise()
.SetHeader("mode", "default")
.SetHeader("stamp.dsl", "default-branch")
.EndChoice()
// ... in the demo: a cross-transport pipeline, SQL, WireTaps ...
.SetHeader("Content-Type", "application/json")
.SetBody(e => BuildResponse(e)); // inOut=true → this is the HTTP response
Hit it with three curls — three different branches:
# full-branch
curl -X POST http://localhost:5088/api/demo \
-H "Content-Type: application/json" -H "mode: full" -H "priority: high" \
-d '{"message":"hello"}'
# short-branch
curl -X POST http://localhost:5088/api/demo \
-H "Content-Type: application/json" -H "mode: short" \
-d '{"message":"hi"}'
# default-branch (no mode)
curl -X POST http://localhost:5088/api/demo \
-H "Content-Type: application/json" \
-d '{"message":"yo"}'
And right next to it, on the same port 5088, lives an echo route (EchoRoutes.cs) — a separate From, the same shared Kestrel:
From("http:0.0.0.0:5088/api/echo?inOut=true")
.RouteId("demo-http-echo")
.AutoStart(false) // dormant until you start it by hand
.ConvertBody<string>()
.SetHeader("Content-Type", e => /* mirror the request's Content-Type */ ...)
.Log("[ECHO] ◀ Echoing back: ${body}");
.AutoStart(false) is a perfect illustration of the lifecycle from Part 4: the route is registered but doesn't start with the module. Start it by hand (tsak route start demo-http-echo) and it registers on the already-running Kestrel for port 5088. Stop it, and the server stays up — because /api/demo is still listening.
Gotcha cheat sheet
-
No body in the response? Check for
inOut=true. Without it the consumer returns an empty200 OK, no matter what you do with.SetBody(...). -
CORS "doesn't work"? With
cors=trueyou must supplycorsOrigins(or a resolver) — otherwise it throws at startup. Wildcard*+ credentials is rejected by browsers, and the connector honestly returns the response with no CORS headers (fail closed). -
Port already in use? Within a process Kestrel is shared by
(host, port)through the singletonSharedHttpServerManager— routes on one port share one server (under Tsak you can even sit on the admin API's port). A bind conflict only happens if a foreign, non-redb server already took the port. -
Request headers leaking into the response? They shouldn't — the connector tracks them (
RequestHeaderNames) and won't reflect them. But if you set a header with the same name yourself, it will go out. -
Stream arrives as one batch? SSE needs
Content-Type: text/event-streamon the response; otherwise it's chunked plain text. Buffering is disabled automatically. -
{id}didn't substitute on the producer? The parameter is supplied via.Param("id", ...); without.Param, the{id}placeholder stays in the URL as-is. -
405 instead of 404? That's a feature: the path matched, the method didn't. Narrow
methods=or check which method you're calling with.
Wrap-up
redb.Route.Http isn't a "wrapper over a controller" — it's a transport in its own right:
- The consumer brings up its own Kestrel (
CreateSlimBuilder, no MVC), shares it across routes by(host, port)through theSharedHttpServerManagersingleton, keeps its own route table with proper specificity ordering, ref-counts for lifecycle, and implements CORS by hand — one policy per route. - The producer is
HttpClientwith every knob exposed: layered URL building,{name}parameters, header bridging, Basic/Bearer (including a dynamic per-request token), response streaming, and a controllablethrowOnError. -
HTTP ↔ Exchange is a two-way bridge through
redbHttp.*headers, with honest handling of multi-values, route parameters, status codes, and body-less responses.
And the Content-Based Router (.Choice().When().Otherwise()) shows what it's all for: a route makes decisions on the content of an already-decoded message and knows nothing about whether Kestrel, Kafka, or RabbitMQ sits beneath it.
Next in the series we take the next connector and the next EIP cluster. Want a specific one? Say so in the comments.
Every snippet is verified against the sources in
redb.Route/src/redb.Route.Http(HttpConsumer,HttpProducer,HttpComponent,SharedHttpServerManager,HttpEndpointOptions,HttpHeaders,Fluent/HttpDsl) andredb.Route.Controllers. Examples come from theredb.Route.Demo/Routesdemo project (MainPipelineRoutes,EchoRoutes,DataObservabilityRoutes) and from the production TsUM system (tsum.Api/Routes,tsum.Api/Auth) — the same routes run in production on port 5090. The controllers section is production-backed: Tsak's entire REST admin API (redb.Tsak.Core/Controllers—Contexts,Routes,Modules,Auth,Users, … — ~14 controllers) is built onRedbControllerand runs in production viaControllerDispatcherProcessor. The application-level TsUM API prefers.Process/.Choice— both approaches coexist and both are battle-tested.


Top comments (0)