fix(inbound-api): recursively coerce nested Object/List values to typed CLR (#55)
This commit is contained in:
@@ -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<string, object?></c> / <c>List<object?></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]
|
||||
|
||||
Reference in New Issue
Block a user