9cff87fe85
Remove project bookkeeping citations from shipped code comments across the solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/ issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels, and C/D/K/S/T phase labels. Comment text only — no code logic, string/log literals, or XML-doc structure changed. Genuine descriptions are preserved (only the citation is stripped), and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365, UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker TaskReferenceInComment / TrackingReferenceInComment checks plus targeted grep passes; full solution builds clean, append-only guard tests pass.
318 lines
15 KiB
C#
318 lines
15 KiB
C#
using System.Text.Json;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
|
|
|
/// <summary>
|
|
/// Validates and deserializes a JSON request body against a method's
|
|
/// parameter definitions. Extended type system: Boolean, Integer, Float,
|
|
/// String, Object, List.
|
|
///
|
|
/// <para>
|
|
/// Validation is now RECURSIVE and type-aware for the
|
|
/// extended <c>Object</c> / <c>List</c> 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. <c>order.items[2].quantity</c>). 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
|
|
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi.InboundApiSchema"/>
|
|
/// for the shared recursive engine that <see cref="ReturnValueValidator"/>
|
|
/// also uses.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class ParameterValidator
|
|
{
|
|
/// <summary>
|
|
/// Validates the request body against the method's parameter definitions.
|
|
/// Returns deserialized parameters or an error message.
|
|
/// </summary>
|
|
/// <param name="body">The parsed JSON request body; null or undefined if no body was supplied.</param>
|
|
/// <param name="parameterDefinitions">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.</param>
|
|
/// <param name="resolveRef">
|
|
/// Optional JSON-Schema <c>$ref</c> resolution seam mapping a
|
|
/// <c>{"$ref":"lib:Name"}</c> reference target's name to the referenced schema JSON
|
|
/// (or <c>null</c> when the library entry does not exist). The endpoint pre-loads the
|
|
/// shared-schema library (backed by <c>ISharedSchemaRepository</c>) and supplies it
|
|
/// ONLY when the definition actually uses a <c>$ref</c>. <c>null</c> means no resolver:
|
|
/// schemas with no <c>$ref</c> behave exactly as before; a <c>$ref</c> 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 <see cref="JsonException"/>.
|
|
/// </param>
|
|
/// <returns>A <see cref="ParameterValidationResult"/> with coerced parameter values on success, or an error message on failure.</returns>
|
|
public static ParameterValidationResult Validate(
|
|
JsonElement? body,
|
|
string? parameterDefinitions,
|
|
Func<string, string?>? 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<string, object?>());
|
|
}
|
|
|
|
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<string, object?>());
|
|
}
|
|
|
|
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<string>();
|
|
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<string, object?>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the unresolved <c>{"$ref":"lib:Name"}</c> references for a clear,
|
|
/// descriptive runtime error — the bare <c>lib:</c>-qualified pointer name stays
|
|
/// separate from any parenthesised reason (cyclic/over-depth), so the message reads
|
|
/// e.g. <c>schema(s) 'lib:Foo' (cyclic reference)</c> rather than embedding the
|
|
/// annotation inside the <c>lib:</c>-looking string.
|
|
/// </summary>
|
|
/// <param name="unresolved">The references that could not be resolved.</param>
|
|
/// <returns>A human-readable description of the unresolved schema reference(s).</returns>
|
|
internal static string DescribeUnresolved(IReadOnlyList<UnresolvedSchemaRef> unresolved) =>
|
|
$"schema(s) {string.Join(", ", unresolved.Select(r => $"'{r.Describe()}'"))}";
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="Dictionary{TKey,TValue}"/>,
|
|
/// arrays to <see cref="List{T}"/>.
|
|
///
|
|
/// <para>
|
|
/// 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)
|
|
{
|
|
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),
|
|
};
|
|
}
|
|
|
|
/// <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,
|
|
/// rather than a <c>List<object?></c> of raw <see cref="JsonElement"/>. 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.
|
|
/// </summary>
|
|
private static object? MaterializeArray(JsonElement element, InboundApiSchema? items)
|
|
{
|
|
// When the element type is a declared scalar, build a STRONGLY-TYPED list
|
|
// (List<string>/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<long>(element.GetArrayLength());
|
|
foreach (var e in element.EnumerateArray()) list.Add(e.GetInt64());
|
|
return list;
|
|
}
|
|
case "number":
|
|
{
|
|
var list = new List<double>(element.GetArrayLength());
|
|
foreach (var e in element.EnumerateArray()) list.Add(e.GetDouble());
|
|
return list;
|
|
}
|
|
case "boolean":
|
|
{
|
|
var list = new List<bool>(element.GetArrayLength());
|
|
foreach (var e in element.EnumerateArray()) list.Add(e.GetBoolean());
|
|
return list;
|
|
}
|
|
case "string":
|
|
{
|
|
var list = new List<string>(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<object?>(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<object?>(element.GetArrayLength());
|
|
foreach (var e in element.EnumerateArray()) list.Add(MaterializeJsonValue(e));
|
|
return list;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Recursively converts a <see cref="JsonElement"/> to a plain CLR value
|
|
/// (string/long/double/bool/null, or nested List/Dictionary) — never a JsonElement.</summary>
|
|
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(),
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of parameter validation.
|
|
/// </summary>
|
|
public class ParameterValidationResult
|
|
{
|
|
/// <summary>Gets a value indicating whether validation succeeded.</summary>
|
|
public bool IsValid { get; private init; }
|
|
/// <summary>Gets the error message when <see cref="IsValid"/> is false; null on success.</summary>
|
|
public string? ErrorMessage { get; private init; }
|
|
/// <summary>Gets the validated and type-coerced parameter values keyed by parameter name.</summary>
|
|
public IReadOnlyDictionary<string, object?> Parameters { get; private init; } = new Dictionary<string, object?>();
|
|
|
|
/// <summary>
|
|
/// Creates a successful validation result with the given parameters.
|
|
/// </summary>
|
|
/// <param name="parameters">The validated and coerced parameter values.</param>
|
|
/// <returns>A <see cref="ParameterValidationResult"/> with <see cref="IsValid"/> set to <c>true</c>.</returns>
|
|
public static ParameterValidationResult Valid(Dictionary<string, object?> parameters) =>
|
|
new() { IsValid = true, Parameters = parameters };
|
|
|
|
/// <summary>
|
|
/// Creates a failed validation result with the given error message.
|
|
/// </summary>
|
|
/// <param name="message">Description of the validation failure.</param>
|
|
/// <returns>A <see cref="ParameterValidationResult"/> with <see cref="IsValid"/> set to <c>false</c>.</returns>
|
|
public static ParameterValidationResult Invalid(string message) =>
|
|
new() { IsValid = false, ErrorMessage = message };
|
|
}
|