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
@@ -135,6 +135,20 @@ public static class ParameterValidator
/// Validation has already passed, so this only shapes the value: scalars to
/// their primitive type, objects to <see cref="Dictionary{TKey,TValue}"/>,
/// arrays to <see cref="List{T}"/>.
///
/// <para>
/// InboundAPI-#55: coercion is RECURSIVE. Nested object fields and list
/// elements are coerced to typed CLR values too (string/long/double/bool,
/// or nested Dictionary/List), so NO raw <see cref="JsonElement"/> survives
/// anywhere in the returned graph. Previously the <c>object</c> case used
/// <c>JsonSerializer.Deserialize&lt;Dictionary&lt;string, object?&gt;&gt;</c>,
/// which leaves every nested field value as a <see cref="JsonElement"/> — so
/// nested scalars reached the script as JsonElement-backed values rather than
/// the typed CLR values the top-level scalar path already produces. The
/// declared field schemas are walked here so nested coercion matches the
/// shape the validator already verified (an Object recurses into its declared
/// fields; an array coerces each element to its element type).
/// </para>
/// </summary>
private static object? Materialize(JsonElement element, InboundApiSchema schema)
{
@@ -149,12 +163,50 @@ public static class ParameterValidator
"integer" => element.GetInt64(),
"number" => element.GetDouble(),
"string" => element.GetString(),
"object" => JsonSerializer.Deserialize<Dictionary<string, object?>>(element.GetRawText()),
"object" => MaterializeObject(element, schema),
"array" => MaterializeArray(element, schema.Items),
_ => JsonSerializer.Deserialize<object?>(element.GetRawText()),
// Undeclared / shape-only ("ref" placeholder or any non-extended type):
// coerce structurally so no raw JsonElement survives.
_ => MaterializeJsonValue(element),
};
}
/// <summary>
/// Materializes a JSON object to a <c>Dictionary&lt;string, object?&gt;</c> of
/// typed CLR values. Each declared field's value is recursively coerced via
/// <see cref="Materialize"/> against the field's schema, so nested scalars
/// arrive as their CLR primitive (string/long/double/bool) rather than as a
/// raw <see cref="JsonElement"/>. Any field present in the body but NOT declared
/// (only reachable on a shape-only <c>{"type":"object"}</c> node with no declared
/// fields, since the validator rejects undeclared fields where fields ARE
/// declared) is coerced structurally via <see cref="MaterializeJsonValue"/>.
/// </summary>
private static object? MaterializeObject(JsonElement element, InboundApiSchema schema)
{
// Shape-only object (no declared fields): coerce structurally so the whole
// graph is typed CLR with no JsonElement leakage.
if (schema.Fields.Count == 0)
{
return MaterializeJsonValue(element);
}
var fieldSchemas = new Dictionary<string, InboundApiSchema>(StringComparer.Ordinal);
foreach (var field in schema.Fields)
{
fieldSchemas[field.Name] = field.Schema;
}
var dict = new Dictionary<string, object?>();
foreach (var prop in element.EnumerateObject())
{
dict[prop.Name] = fieldSchemas.TryGetValue(prop.Name, out var fieldSchema)
? Materialize(prop.Value, fieldSchema)
: MaterializeJsonValue(prop.Value);
}
return dict;
}
/// <summary>
/// Materializes a JSON array to a STRONGLY-TYPED list (List&lt;string&gt;,
/// List&lt;long&gt;, List&lt;double&gt;, List&lt;bool&gt;) per the element schema,
@@ -197,11 +249,22 @@ public static class ParameterValidator
list.Add(e.ValueKind == JsonValueKind.Null ? string.Empty : e.GetString() ?? string.Empty);
return list;
}
case "object":
case "array":
{
// Declared object/array element: recurse via Materialize so each
// element follows its declared (nested) schema — nested scalars
// become typed CLR values, never raw JsonElement (#55).
var list = new List<object?>(element.GetArrayLength());
foreach (var e in element.EnumerateArray()) list.Add(Materialize(e, items));
return list;
}
default:
{
// No declared (or non-scalar) element type: materialize each element
// to its CLR value so no raw JsonElement survives (raw JsonElement is
// not cleanly Akka-serializable and stalls the List-attribute encode).
// No declared (or non-extended) element type: materialize each
// element structurally so no raw JsonElement survives (raw
// JsonElement is not cleanly Akka-serializable and stalls the
// List-attribute encode).
var list = new List<object?>(element.GetArrayLength());
foreach (var e in element.EnumerateArray()) list.Add(MaterializeJsonValue(e));
return list;
@@ -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]