fix(inbound-api): recursively coerce nested Object/List values to typed CLR (#55)
This commit is contained in:
@@ -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<Dictionary<string, object?>></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<string, object?></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<string>,
|
||||
/// List<long>, List<double>, List<bool>) 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<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