Match All and the Reserved Word Nobody Documented
Forty lines build every upstream path Ntrada can express, and thirty more build the downstream URL - where named segments substitute, one key appends, and one config flag runs backwards.
Reverse proxies have a signature failure mode, and it is always about the join. You configure /api/orders upstream and http://orders-svc/orders downstream, and then somebody requests /api/orders/, or /api/orders/42/items, or /api/orders?page=2&sort=-created, and one of the three arrives at the origin with a doubled slash, a lost path segment or a query string glued on with an ampersand where a question mark belonged. Every gateway has this code. Most of it is untested.
Part 2 showed Ntrada compiling YAML into ASP.NET Core endpoints. This part reads the two small classes on either side of that compilation — the one that decides what path the gateway listens on, and the one that decides what URL it calls — because between them they define the entire parameter-binding contract of the product, and neither is documented anywhere.
The upstream DSL is four rules
UpstreamBuilder.Build is seventy-odd lines, of which fifty are logging and method collection. The part that constructs a path is this:
var path = module.Path;
var upstream = string.IsNullOrWhiteSpace(route.Upstream) ? string.Empty : route.Upstream;
if (!string.IsNullOrWhiteSpace(path))
{
var modulePath = path.EndsWith("/") ? path.Substring(0, path.Length - 1) : path;
if (!upstream.StartsWith("/"))
{
upstream = $"/{upstream}";
}
upstream = $"{modulePath}{upstream}";
}
if (upstream.EndsWith("/"))
{
upstream = upstream.Substring(0, upstream.Length - 1);
}
if (route.MatchAll)
{
upstream = $"{upstream}/{{*url}}";
}
— UpstreamBuilder.cs:24-45
That is the complete grammar.
Rule one: the module's path: is a prefix. If a module declares path: /api, every route beneath it is mounted under /api, with exactly one slash between them regardless of how the author wrote either half. This is the join logic done correctly — strip a trailing slash on the left, guarantee a leading slash on the right — and it is the part most hand-rolled gateways get wrong first.
Rule two: a trailing slash on the final path is always stripped. upstream: /orders/ and upstream: /orders produce the same endpoint. Harmless and helpful — except for upstream: /, which strips to the empty string, and that empty string becomes half of a dictionary key elsewhere. Part 5 is where that bites.
Rule three: matchAll: true appends /{*url}. An ASP.NET Core catch-all parameter. /orders becomes /orders/{*url}, which matches /orders, /orders/42 and /orders/42/items/7, capturing everything after the prefix into route data under the key url.
Rule four: everything else is passed through untouched. And this is the quietly generous part. upstream: is handed to MapGet verbatim, which means the full ASP.NET Core route-template language is available to a YAML author who happens to know it:
- upstream: /orders/{id:guid}/items/{index:int:min(0)}
method: GET
use: downstream
downstream: orders-service/orders/{id}/items/{index}
Route constraints work. Optional segments work. Default values work. Literal-versus-parameter precedence works, because the framework's matcher is doing it. A large expressive surface was acquired for free, and nothing in the README tells you it exists — the word “constraint” does not appear in the repository's documentation at all. The feature list says “Routing” and “Match-all methods generic templates” and stops.
url is a reserved word
Now the downstream side. DownstreamBuilder.GetDownstream takes the configured downstream string and the request's route data and produces the URL to call:
foreach (var (key, value) in data.Values)
{
if (value is null)
{
continue;
}
if (key is "url")
{
stringBuilder.Append($"/{value}");
continue;
}
stringBuilder.Replace($"{{{key}}}", value.ToString());
}
— DownstreamBuilder.cs:38-52
Four lines that are the entire parameter-binding contract of the gateway, and they contain two different behaviours.
Named segments substitute. A route-data key id replaces the literal text {id} wherever it appears in the downstream string. That is the obvious semantics and it composes: /orders/{id}/items/{index} fills both.
The key url appends. It is not substituted into a placeholder; its value is glued onto the end of whatever has been built so far, with a leading slash. That is what makes matchAll work — /orders/42/items upstream becomes orders-service/orders plus /42/items downstream — and it is genuinely elegant. One special case buys the entire catch-all proxying feature without a second configuration key.
It is also an undocumented reserved word. Write this, which looks completely reasonable:
- upstream: /shorten/{url}
method: GET
use: downstream
downstream: link-service/expand/{url}
and the gateway will not substitute {url} in the downstream template. It will append the captured value to the end of the string, and your origin receives http://link-service/expand/{url}/abc123. There is no warning, no boot-time check on the upstream template for the token url, and no mention of the reservation in the README, the sample, or the extension docs. The failure is a 404 from a downstream service that will blame you.
To be fair to the design: reserving a name is the cheapest possible way to distinguish “the remainder” from “a named segment”, and every catch-all proxy has to distinguish them somehow. The defect is not the reservation, it is that the reservation is invisible. One line in RouteProvider.Build() — if (route.Upstream.Contains("{url}") && !route.MatchAll) throw ... — would have converted a silent misroute into a boot failure naming the route.
The commit that made match-all multi-verb
matchAll is the feature the git log is proudest of. Commit 45fc7e7 Match All forwarding for any methods did something subtler than its message suggests: it changed every site in DownstreamHandler that read executionData.Route.Method to read executionData.Context.Request.Method instead.
That is the change that makes one route proxy GET, POST and DELETE to the same downstream. Once a route can carry a methods: list, the route no longer has a method — as part 2 noted, route.Method is left null on multi-verb routes — so the incoming request's method has to be authoritative. The edit is small, correct, and touches five call sites in one pass. It is also why the outbound verb selection at DownstreamHandler.cs:104-106 reads the way it does: use the configured downstreamMethod if there is one, otherwise mirror the caller.
The one wart it left behind is cosmetic. That expression already ends in .ToLowerInvariant(), applied to a value that AddRoutes lowercased at startup — a redundant allocation on every request through a multi-verb route, and one of about ten per-route constants the gateway recomputes per request rather than caching in RouteConfig.
The one flag that runs backwards
The last third of GetDownstream handles the query string, and it is where the DSL's best idea meets its one defector.
Six configuration keys in Ntrada are bool? in C#, which gives a YAML author three states from one key: true forces the behaviour on for this route, false forces it off, and omitting the key inherits the global. The predicate that implements it appears six times, always identically:
if (routeConfig.Route.GenerateRequestId == true ||
_options.GenerateRequestId == true && routeConfig.Route.GenerateRequestId != false)
— RequestProcessor.cs:85-86
This is the best piece of API design in the codebase, and it arrived deliberately. Commit 795f717 Fixed resource/request/trace id generation based on route config shows the route == true || clause being prepended to a pre-existing global-only check on all three id flags at once. The author had noticed that a route could previously opt out of a global but never opt in, and fixed all three in a single commit. For a declarative DSL where the user cannot write code to express “inherit unless overridden”, a nullable boolean is precisely the right primitive, and getting six keys to agree on it is real discipline.
Then, in a seventh file:
if (_options.PassQueryString == false || routeConfig.Route.PassQueryString == false)
{
return stringBuilder.ToString();
}
— DownstreamBuilder.cs:54-57
Either-false-wins. With passQueryString: false at the top of the document, a route that sets passQueryString: true gets no query string, because the route-level true never appears in the predicate at all. One member of a six-key family, with inverted semantics, in the one file that none of the others live in, with no note and no test. It is the purest example I know of why a repeated expression wants to be a shared helper: five copies stayed correct because they were copied, and the sixth diverged because it was rewritten from memory.
Next, the thirteen lines that are the entire request pipeline — and the hook that a bug fix quietly blinded.