The Payload Template That Could Never Load
Two notions of module identity, a silent continue on a missing file - and a live bug the unreachable code was hiding.
There is a category of defect that never appears in a bug tracker, because nobody can tell it apart from a feature they misconfigured. The code path is entered, a guard returns early, and the request completes with a plausible response. Support closes the ticket as “check your YAML”. Two years later the feature has never worked and nobody knows, because the only way to find out is to read the source with the question already in your head.
Part 4 traced a request from endpoint match to handler dispatch. This part goes one level down, into the feature Ntrada's README calls “Custom request bodies” — and stays there, because what is at the bottom is two defects that have been protecting each other since 2019.
The feature, as advertised
The idea is good. A gateway sitting in front of a command-driven service often needs to reshape an inbound REST body into the message the service expects: add fields the caller should not supply, drop fields it should not be allowed to set, rename the rest. Ntrada does that declaratively. You put a JSON template on disk, point a route at it, and the gateway uses the template as a whitelist and a seed:
modules:
orders:
path: /orders
routes:
- upstream: /
method: POST
use: downstream
downstream: orders-service/orders
payload: create_order
schema: create_order_schema
bind:
- customerId:@user_id
with Modules/orders/Payloads/create_order.json on disk containing the template. At boot, PayloadManager walks every route, reads every template, and files it in a dictionary keyed by verb and path. At request time, PayloadTransformer looks the template up by the same key and applies it.
That is the design, and it is a reasonable one. Now the implementation.
The write key
var payloadsFolder = _options.PayloadsFolder;
var fullPath = $"{modulesPath}{module.Value.Name}/{payloadsFolder}/{route.Payload}";
var fullJsonPath = fullPath.EndsWith(".json") ? fullPath : $"{fullPath}.json";
if (!File.Exists(fullJsonPath))
{
continue;
}
— PayloadManager.cs:42-48
The path is built from module.Value.Name. Not from the module's key in the YAML mapping — from a Name property on the bound Module object, which corresponds to a name: key inside the module.
Neither ntrada.yml shipped in the repository sets it. Both use the mapping key:
modules:
orders: # <- this is module.Key
routes:
- upstream: /orders
Module.Name is therefore null in every configuration the project ships, including the one in the README's advanced-configuration block. The interpolation produces Modules//Payloads/create_order.json, File.Exists returns false, and line 47 continues. The payload-template feature does not load in any shipped configuration, and it fails by doing nothing at all.
Note what makes it invisible rather than merely broken. RouteProvider.cs:68 logs Building routes for the module: '{module.Key}', so the startup log names your module orders, exactly as you wrote it. The filesystem lookup names it the empty string. The two notions of module identity never meet in the same log line, and there is no message on the missing-file path — just continue.
The read key
Suppose you find the problem and add name: orders to your module. The template now loads. Here is the key it is filed under:
var upstream = string.IsNullOrWhiteSpace(route.Upstream) ? string.Empty : route.Upstream;
if (!string.IsNullOrWhiteSpace(module.Value.Path))
{
var modulePath = module.Value.Path.EndsWith("/") ? module.Value.Path : $"{module.Value.Path}/";
if (upstream.StartsWith("/")) { upstream = upstream.Substring(1, upstream.Length - 1); }
if (upstream.EndsWith("/")) { upstream = upstream.Substring(0, upstream.Length - 1); }
upstream = $"{modulePath}{upstream}";
}
if (string.IsNullOrWhiteSpace(upstream)) { upstream = "/"; }
payloads.Add(GetKey(route.Method, upstream), new PayloadSchema(expandoObject, schema));
— PayloadManager.cs:61-83
And here is the key the lookup uses:
private string GetPayloadKey(Route route) => _payloadManager.GetKey(route.Method, route.Upstream);
— PayloadTransformer.cs:122
Both call GetKey(method, upstream), which is just $"{method?.ToLowerInvariant()}:{upstream}". But the write side computes its own module-path join, and the read side reads route.Upstream — a property that UpstreamBuilder has since overwritten. Part 2 flagged the back-write at RouteProvider.cs:76; this is where it collects.
So the two keys are produced by two different algorithms from two snapshots of the same mutable object, at two different moments in startup. They agree in the simple case and diverge in three:
matchAll: true.UpstreamBuilder.cs:44appends/{*url};PayloadManagerdoes not. Write keypost:/orders, read keypost:/orders/{*url}. The template is in the dictionary and will never be found.- A root route.
upstream: /is stripped to the empty string byUpstreamBuilder.cs:37-40, and substituted to"/"byPayloadManager.cs:78-81. Read keypost:, write keypost:/. - Multi-method routes.
route.Methodisnullon those, so both sides produce":/orders"and happily agree — which means one template now governs every verb on that route, and the transformer will reshape the body of a GET.
Which order these run in is itself an accident. UseNtrada calls RegisterRequestHandlers() before AddRoutes(). RegisterRequestHandlers resolves DownstreamHandler from the container, whose constructor chain is DownstreamHandler → IRequestProcessor → IPayloadTransformer → IPayloadManager, and PayloadManager's constructor calls LoadPayloads() eagerly on line 17. The payload dictionary is built by a side effect of dependency-injection activation order, at a moment between two mutations of the object it reads. Swap lines 199 and 200 of NtradaExtensions.cs and the behaviour changes.
Two defects protecting each other
Now the part that makes this worth a whole article rather than a bullet. Inside the branch that the missing file makes unreachable, there is a second, entirely independent bug:
private object GetObjectFromPayload(Route route, string content)
{
var payloadValue = _payloads[GetPayloadKey(route)].Payload;
var request = JsonConvert.DeserializeObject(content, payloadValue.GetType());
var payloadValues = (IDictionary<string, object>) payloadValue;
var requestValues = (IDictionary<string, object>) request;
foreach (var key in requestValues.Keys)
{
if (!payloadValues.ContainsKey(key))
{
requestValues.Remove(key);
}
}
return request;
}
— PayloadTransformer.cs:96-112
This is the whitelist: drop any field from the caller's body that the template does not declare. The intent is exactly right — that is the security-relevant half of the feature, the part that stops a caller setting isAdmin: true on a payload the service will trust.
It removes from a live view of the collection it is enumerating. requestValues is an ExpandoObject cast to IDictionary<string, object>; requestValues.Keys is not a snapshot, it is a view. The first Remove invalidates the enumerator, and the next MoveNext throws InvalidOperationException: Collection was modified; enumeration operation may not execute. Any request carrying a single field the template does not declare — which is the entire purpose of the whitelist — becomes an unhandled 500.
The fix is one call: foreach (var key in requestValues.Keys.ToArray()).
This is the shape of the finding, and it generalises far beyond Ntrada: a feature that fails silently stops being exercised, and therefore stops being correct. The missing-file continue has been keeping the whitelist bug off the stack since the day both were written. Fix the module-identity defect on its own — add name: to your YAML, feel clever — and you convert a feature that does nothing into a feature that throws. You discover both on the same afternoon, and you discover them in production, because no shipped configuration reaches either.
One flag, four meanings
There is a smaller inconsistency in the same loop that is worth naming, because it is the same disease in a milder form. PayloadManager.LoadPayloads iterates _options.Modules directly:
foreach (var module in _options.Modules)
{
foreach (var route in module.Value.Routes)
— PayloadManager.cs:33-35
No filter. Compare RouteProvider.Build(), which does honour the flag:
foreach (var module in _options.Modules.Where(m => m.Value.Enabled != false))
— RouteProvider.cs:66
So enabled: false on a module means “do not register routes” — and does not mean “do not load payload templates” (PayloadManager.cs:33), or “do not validate handler names” (NtradaExtensions.cs:220), or “do not require the policies these routes reference” (PolicyManager.cs:34). Disable a module whose routes name a policy you also deleted, and the gateway refuses to start, citing a policy used by a module that is switched off.
A configuration key whose meaning depends on which component is reading it is not a feature flag; it is four feature flags sharing a name. The fix is the same freeze the rest of this article asks for: filter the module set once, at the top of AddNtrada, and let every consumer see only the modules that exist.
What one assertion would have bought
The prescription is unusually cheap. The whole class of defect dissolves with a single boot-time check:
if (!File.Exists(fullJsonPath))
{
throw new InvalidOperationException(
$"Payload template '{route.Payload}' for route '{route.Method} {route.Upstream}' " +
$"in module '{module.Key}' was not found at: '{fullJsonPath}'.");
}
That message names the route, the module by the key the author actually typed, and the path the gateway looked in — which would have made the Name-versus-key confusion obvious in the first five minutes of anyone using the feature. Ntrada already knows how to do this: RegisterRequestHandlers throws when a use: names an unknown handler, and PolicyManager.VerifyPolicies throws when a route names an undefined policy. Boot-time validation was applied to three things in this codebase and not applied to the four that needed it most — schema file existence, bind: syntax, @token names, and verb support. The technique was in the building.
Second, give the upstream key exactly one owner. IRouteConfigurator already exists, is already invoked once per route at exactly the right moment, and already returns a RouteConfig — and it computes precisely one string, Downstream, leaving Upstream to be back-written into the shared configuration object by somebody else. The class that would have prevented this is in the codebase, half-used.
Next, five parses to reject one request — the validation round trip, and the status code it forgets to set.