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;