fix(inbound-api): recursively coerce nested Object/List values to typed CLR (#55)

This commit is contained in:
Joseph Doherty
2026-06-19 02:02:10 -04:00
parent 2843781db4
commit 2935c41bf7
2 changed files with 256 additions and 5 deletions
@@ -350,6 +350,194 @@ public class ParameterValidatorTests
Assert.True(result.IsValid);
}
// ── Recursive materialization: nested values coerced to typed CLR (#55) ─────
/// <summary>
/// Recursively asserts that no value anywhere in a materialized parameter
/// graph is a raw <see cref="JsonElement"/> — every scalar must be a typed CLR
/// value (string/long/double/bool/null) and every container a
/// <c>Dictionary&lt;string, object?&gt;</c> / <c>List&lt;object?&gt;</c> (or a
/// typed scalar list) of likewise-coerced values.
/// </summary>
private static void AssertNoJsonElement(object? value)
{
Assert.IsNotType<JsonElement>(value);
switch (value)
{
case IDictionary<string, object?> dict:
foreach (var v in dict.Values)
{
AssertNoJsonElement(v);
}
break;
case System.Collections.IEnumerable enumerable when value is not string:
foreach (var item in enumerable)
{
AssertNoJsonElement(item);
}
break;
}
}
[Fact]
public void NestedObjectFieldScalar_IsTypedClr_NotJsonElement()
{
const string body = """
{"order":{
"id":7,
"customer":{"name":"Acme","vip":true},
"items":[]
}}
""";
var result = ParameterValidator.Validate(Body(body), NestedDef);
Assert.True(result.IsValid);
var order = Assert.IsType<Dictionary<string, object?>>(result.Parameters["order"]);
AssertNoJsonElement(order);
// The nested scalar fields are the CLR types the top-level path produces:
// integer→long, string→string, boolean→bool.
Assert.Equal((long)7, order["id"]);
var customer = Assert.IsType<Dictionary<string, object?>>(order["customer"]);
Assert.Equal("Acme", customer["name"]);
Assert.Equal(true, customer["vip"]);
Assert.IsType<bool>(customer["vip"]);
Assert.IsType<long>(order["id"]);
}
[Fact]
public void ListOfScalars_FullyCoerced_NoJsonElement()
{
const string def = """{"type":"object","properties":{"nums":{"type":"array","items":{"type":"integer"}}},"required":["nums"]}""";
var result = ParameterValidator.Validate(Body("{\"nums\":[1,2,3]}"), def);
Assert.True(result.IsValid);
// Declared scalar element → strongly-typed List<long>.
var nums = Assert.IsType<List<long>>(result.Parameters["nums"]);
Assert.Equal(new List<long> { 1, 2, 3 }, nums);
AssertNoJsonElement(nums);
}
[Fact]
public void ListOfObjects_ElementFieldsCoerced_NoJsonElement()
{
const string def = """
{"type":"object","properties":{
"items":{"type":"array","items":{
"type":"object",
"properties":{"sku":{"type":"string"},"quantity":{"type":"integer"},"price":{"type":"number"}},
"required":["sku","quantity"]
}}
},"required":["items"]}
""";
const string body = """{"items":[{"sku":"A1","quantity":3,"price":9.5},{"sku":"B2","quantity":1,"price":2.0}]}""";
var result = ParameterValidator.Validate(Body(body), def);
Assert.True(result.IsValid);
var items = Assert.IsType<List<object?>>(result.Parameters["items"]);
AssertNoJsonElement(items);
var first = Assert.IsType<Dictionary<string, object?>>(items[0]);
Assert.Equal("A1", first["sku"]);
Assert.Equal((long)3, first["quantity"]);
Assert.IsType<long>(first["quantity"]);
Assert.Equal(9.5, first["price"]);
Assert.IsType<double>(first["price"]);
}
[Fact]
public void DeeplyNested_ObjectInListInObject_FullyCoerced_NoJsonElement()
{
// order.items[*] is an object that itself carries a list of tag objects:
// object → array → object → scalar, the worst-case leak path.
const string def = """
{"type":"object","properties":{
"order":{"type":"object","properties":{
"id":{"type":"integer"},
"items":{"type":"array","items":{
"type":"object","properties":{
"sku":{"type":"string"},
"tags":{"type":"array","items":{
"type":"object","properties":{
"key":{"type":"string"},
"weight":{"type":"number"},
"active":{"type":"boolean"}
},"required":["key"]
}}
},"required":["sku"]
}}
},"required":["id","items"]}
},"required":["order"]}
""";
const string body = """
{"order":{
"id":7,
"items":[
{"sku":"A1","tags":[{"key":"hot","weight":1.5,"active":true},{"key":"cold","weight":0.25,"active":false}]}
]
}}
""";
var result = ParameterValidator.Validate(Body(body), def);
Assert.True(result.IsValid);
// The whole graph must be free of JsonElement at every depth.
AssertNoJsonElement(result.Parameters["order"]);
var order = Assert.IsType<Dictionary<string, object?>>(result.Parameters["order"]);
Assert.Equal((long)7, order["id"]);
var items = Assert.IsType<List<object?>>(order["items"]);
var item0 = Assert.IsType<Dictionary<string, object?>>(items[0]);
Assert.Equal("A1", item0["sku"]);
var tags = Assert.IsType<List<object?>>(item0["tags"]);
var tag0 = Assert.IsType<Dictionary<string, object?>>(tags[0]);
Assert.Equal("hot", tag0["key"]);
Assert.Equal(1.5, tag0["weight"]);
Assert.IsType<double>(tag0["weight"]);
Assert.Equal(true, tag0["active"]);
Assert.IsType<bool>(tag0["active"]);
var tag1 = Assert.IsType<Dictionary<string, object?>>(tags[1]);
Assert.Equal(false, tag1["active"]);
}
[Fact]
public void ShapeOnlyNestedObject_StillFullyCoerced_NoJsonElement()
{
// A declared-but-shape-only object field ({"type":"object"} with no
// properties) is still coerced structurally — no JsonElement survives.
// Without a declared field schema there is no integer/float distinction,
// so (per the pre-existing structural-coercion path) numbers come back as
// double; the point of THIS test is the absence of JsonElement, not the
// integer-vs-double choice (the declared paths cover that).
const string def = """{"type":"object","properties":{"data":{"type":"object"}},"required":["data"]}""";
const string body = """{"data":{"name":"x","count":5,"ratio":1.5,"flag":true,"nested":{"inner":2}}}""";
var result = ParameterValidator.Validate(Body(body), def);
Assert.True(result.IsValid);
var data = Assert.IsType<Dictionary<string, object?>>(result.Parameters["data"]);
AssertNoJsonElement(data);
Assert.Equal("x", data["name"]);
Assert.Equal(5d, data["count"]);
Assert.Equal(1.5, data["ratio"]);
Assert.Equal(true, data["flag"]);
var nested = Assert.IsType<Dictionary<string, object?>>(data["nested"]);
Assert.Equal(2d, nested["inner"]);
}
// ── Legacy flat-array backward-compat ──────────────────────────────────────
[Fact]