diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs index 8e343438..30e84fe7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs @@ -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 , /// arrays to . + /// + /// + /// 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 survives + /// anywhere in the returned graph. Previously the object case used + /// JsonSerializer.Deserialize<Dictionary<string, object?>>, + /// which leaves every nested field value as a — 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). + /// /// 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>(element.GetRawText()), + "object" => MaterializeObject(element, schema), "array" => MaterializeArray(element, schema.Items), - _ => JsonSerializer.Deserialize(element.GetRawText()), + // Undeclared / shape-only ("ref" placeholder or any non-extended type): + // coerce structurally so no raw JsonElement survives. + _ => MaterializeJsonValue(element), }; } + /// + /// Materializes a JSON object to a Dictionary<string, object?> of + /// typed CLR values. Each declared field's value is recursively coerced via + /// against the field's schema, so nested scalars + /// arrive as their CLR primitive (string/long/double/bool) rather than as a + /// raw . Any field present in the body but NOT declared + /// (only reachable on a shape-only {"type":"object"} node with no declared + /// fields, since the validator rejects undeclared fields where fields ARE + /// declared) is coerced structurally via . + /// + 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(StringComparer.Ordinal); + foreach (var field in schema.Fields) + { + fieldSchemas[field.Name] = field.Schema; + } + + var dict = new Dictionary(); + 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; + } + /// /// 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(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(element.GetArrayLength()); foreach (var e in element.EnumerateArray()) list.Add(MaterializeJsonValue(e)); return list; diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs index de384994..5425a3ef 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs @@ -350,6 +350,194 @@ public class ParameterValidatorTests Assert.True(result.IsValid); } + // ── Recursive materialization: nested values coerced to typed CLR (#55) ───── + + /// + /// Recursively asserts that no value anywhere in a materialized parameter + /// graph is a raw — every scalar must be a typed CLR + /// value (string/long/double/bool/null) and every container a + /// Dictionary<string, object?> / List<object?> (or a + /// typed scalar list) of likewise-coerced values. + /// + private static void AssertNoJsonElement(object? value) + { + Assert.IsNotType(value); + + switch (value) + { + case IDictionary 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>(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>(order["customer"]); + Assert.Equal("Acme", customer["name"]); + Assert.Equal(true, customer["vip"]); + Assert.IsType(customer["vip"]); + Assert.IsType(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. + var nums = Assert.IsType>(result.Parameters["nums"]); + Assert.Equal(new List { 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>(result.Parameters["items"]); + AssertNoJsonElement(items); + + var first = Assert.IsType>(items[0]); + Assert.Equal("A1", first["sku"]); + Assert.Equal((long)3, first["quantity"]); + Assert.IsType(first["quantity"]); + Assert.Equal(9.5, first["price"]); + Assert.IsType(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>(result.Parameters["order"]); + Assert.Equal((long)7, order["id"]); + + var items = Assert.IsType>(order["items"]); + var item0 = Assert.IsType>(items[0]); + Assert.Equal("A1", item0["sku"]); + + var tags = Assert.IsType>(item0["tags"]); + var tag0 = Assert.IsType>(tags[0]); + Assert.Equal("hot", tag0["key"]); + Assert.Equal(1.5, tag0["weight"]); + Assert.IsType(tag0["weight"]); + Assert.Equal(true, tag0["active"]); + Assert.IsType(tag0["active"]); + + var tag1 = Assert.IsType>(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>(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>(data["nested"]); + Assert.Equal(2d, nested["inner"]); + } + // ── Legacy flat-array backward-compat ────────────────────────────────────── [Fact]