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(element.GetArrayLength()); foreach (var e in element.EnumerateArray()) list.Add(Materialize(e, items)); return list; } default: { // 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; } } } /// Recursively converts a to a plain CLR value /// (string/long/double/bool/null, or nested List/Dictionary) — never a JsonElement. private static object? MaterializeJsonValue(JsonElement e) => e.ValueKind switch { JsonValueKind.String => e.GetString(), JsonValueKind.Number => e.TryGetInt64(out var l) ? l : e.GetDouble(), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null => null, JsonValueKind.Array => e.EnumerateArray().Select(MaterializeJsonValue).ToList(), JsonValueKind.Object => e.EnumerateObject().ToDictionary(p => p.Name, p => MaterializeJsonValue(p.Value)), _ => e.GetRawText(), }; } /// /// Result of parameter validation. /// public class ParameterValidationResult { /// Gets a value indicating whether validation succeeded. public bool IsValid { get; private init; } /// Gets the error message when is false; null on success. public string? ErrorMessage { get; private init; } /// Gets the validated and type-coerced parameter values keyed by parameter name. public IReadOnlyDictionary Parameters { get; private init; } = new Dictionary(); /// /// Creates a successful validation result with the given parameters. /// /// The validated and coerced parameter values. /// A with set to true. public static ParameterValidationResult Valid(Dictionary parameters) => new() { IsValid = true, Parameters = parameters }; /// /// Creates a failed validation result with the given error message. /// /// Description of the validation failure. /// A with set to false. public static ParameterValidationResult Invalid(string message) => new() { IsValid = false, ErrorMessage = message }; }