using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
///
/// Validates and deserializes a JSON request body against a method's
/// parameter definitions. Extended type system: Boolean, Integer, Float,
/// String, Object, List.
///
///
/// Validation is now RECURSIVE and type-aware for the
/// extended Object / List types. Declared object fields are
/// validated against their declared (nested) types, list elements against the
/// declared element type, and scalars at any depth against the extended type —
/// with path-qualified errors (e.g. order.items[2].quantity). The
/// definition is read as JSON Schema (the canonical persisted format produced
/// by the Central UI / migration); the legacy flat-array form is still
/// accepted for transition safety. See
///
/// for the shared recursive engine that
/// also uses.
///
///
public static class ParameterValidator
{
///
/// Validates the request body against the method's parameter definitions.
/// Returns deserialized parameters or an error message.
///
/// The parsed JSON request body; null or undefined if no body was supplied.
/// JSON Schema describing the method's parameters (an object schema), or null/empty when no parameters are defined. The legacy flat-array form is also accepted.
///
/// Optional JSON-Schema $ref resolution seam mapping a
/// {"$ref":"lib:Name"} reference target's name to the referenced schema JSON
/// (or null when the library entry does not exist). The endpoint pre-loads the
/// shared-schema library (backed by ISharedSchemaRepository) and supplies it
/// ONLY when the definition actually uses a $ref. null means no resolver:
/// schemas with no $ref behave exactly as before; a $ref with no resolver
/// (or a dangling one) is surfaced as a CLEAR invalid result naming the missing
/// reference — NOT an opaque parse-error 400 from a thrown .
///
/// A with coerced parameter values on success, or an error message on failure.
public static ParameterValidationResult Validate(
JsonElement? body,
string? parameterDefinitions,
Func? resolveRef = null)
{
// Parse through the ref-COLLECTING path. A {"$ref":"lib:Name"} that the
// resolver can satisfy is resolved inline; a dangling/cyclic/over-depth ref is
// collected (not thrown) so the runtime returns a descriptive "could not be
// resolved" message instead of an opaque "Invalid parameter definitions" — the
// deploy-passes/runtime-400 defect this fix closes.
SchemaParseResult parsed;
try
{
parsed = InboundApiSchema.ParseWithRefs(parameterDefinitions, resolveRef);
}
catch (JsonException)
{
return ParameterValidationResult.Invalid("Invalid parameter definitions in method configuration");
}
if (parsed.UnresolvedReferences.Count > 0)
{
return ParameterValidationResult.Invalid(
$"Parameter definitions reference {DescribeUnresolved(parsed.UnresolvedReferences)} which could not be resolved");
}
InboundApiSchema? schema = parsed.Schema;
// No parameters defined (or an object schema with no declared fields) —
// the body is unconstrained and yields an empty parameter set.
if (schema is null || schema.Type != "object" || schema.Fields.Count == 0)
{
return ParameterValidationResult.Valid(new Dictionary());
}
if (body == null
|| body.Value.ValueKind == JsonValueKind.Null
|| body.Value.ValueKind == JsonValueKind.Undefined)
{
var required = schema.Fields.Where(f => f.Required).ToList();
if (required.Count > 0)
{
return ParameterValidationResult.Invalid(
$"Missing required parameters: {string.Join(", ", required.Select(r => r.Name))}");
}
return ParameterValidationResult.Valid(new Dictionary());
}
if (body.Value.ValueKind != JsonValueKind.Object)
{
return ParameterValidationResult.Invalid("Request body must be a JSON object");
}
// Recursively type-check the whole body against the declared object
// schema (nested Object fields, List element types, scalars at any
// depth, undeclared-field rejection) with path-qualified errors.
var errors = new List();
schema.Validate(body.Value, string.Empty, errors);
if (errors.Count > 0)
{
return ParameterValidationResult.Invalid(string.Join("; ", errors));
}
// Materialize the coerced top-level parameter values for the script.
var result = new Dictionary();
foreach (var field in schema.Fields)
{
if (body.Value.TryGetProperty(field.Name, out var prop))
{
result[field.Name] = Materialize(prop, field.Schema);
}
}
return ParameterValidationResult.Valid(result);
}
///
/// Renders the unresolved {"$ref":"lib:Name"} references for a clear,
/// descriptive runtime error — the bare lib:-qualified pointer name stays
/// separate from any parenthesised reason (cyclic/over-depth), so the message reads
/// e.g. schema(s) 'lib:Foo' (cyclic reference) rather than embedding the
/// annotation inside the lib:-looking string.
///
/// The references that could not be resolved.
/// A human-readable description of the unresolved schema reference(s).
internal static string DescribeUnresolved(IReadOnlyList unresolved) =>
$"schema(s) {string.Join(", ", unresolved.Select(r => $"'{r.Describe()}'"))}";
///
/// Converts a validated JSON element to the CLR value handed to the script.
/// Validation has already passed, so this only shapes the value: scalars to
/// their primitive type, objects to ,
/// arrays to .
///
///
/// 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)
{
if (element.ValueKind == JsonValueKind.Null)
{
return null;
}
return schema.Type switch
{
"boolean" => element.GetBoolean(),
"integer" => element.GetInt64(),
"number" => element.GetDouble(),
"string" => element.GetString(),
"object" => MaterializeObject(element, schema),
"array" => MaterializeArray(element, schema.Items),
// 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,
/// rather than a List<object?> of raw . The
/// raw-JsonElement form is not cleanly Akka-serializable when the parameter is
/// routed Central→Site, and a script writing it to a List attribute stalls when
/// the attribute codec tries to JSON-serialize JsonElement items. A typed list
/// serializes cleanly across nodes and encodes to a canonical JSON array, which
/// the InstanceActor decodes back to the typed list for the device write.
///
private static object? MaterializeArray(JsonElement element, InboundApiSchema? items)
{
// When the element type is a declared scalar, build a STRONGLY-TYPED list
// (List/long/double/bool) — the cleanest form to route Central→Site
// and to encode to a canonical JSON array for a List attribute write.
switch (items?.Type)
{
case "integer":
{
var list = new List(element.GetArrayLength());
foreach (var e in element.EnumerateArray()) list.Add(e.GetInt64());
return list;
}
case "number":
{
var list = new List(element.GetArrayLength());
foreach (var e in element.EnumerateArray()) list.Add(e.GetDouble());
return list;
}
case "boolean":
{
var list = new List(element.GetArrayLength());
foreach (var e in element.EnumerateArray()) list.Add(e.GetBoolean());
return list;
}
case "string":
{
var list = new List(element.GetArrayLength());
foreach (var e in element.EnumerateArray())
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.
var list = new List