feat(inbound-api): nested Object/List extended-type validation (#13)

Object/List parameters and return values were shape-validated only (object vs
array), with no field-level/nested type checks — type-wrong nested data passed
inbound validation and failed only at script runtime. Add recursive type
validation (declared Object field types, List element type, scalars at any depth)
with path-qualified errors, symmetric across ParameterValidator and ReturnValueValidator.

Both validators now parse the canonical JSON Schema definition format (the
Central UI / MigrateParametersToJsonSchema output) via a shared recursive engine,
Commons.Types.InboundApi.InboundApiSchema, instead of the legacy flat
[{name,type}] array which they could not even deserialize from migrated rows.
The legacy flat-array form is still accepted on read for transition safety.
Undeclared fields are rejected at every level (consistent with the existing
top-level unexpected-parameter rejection); a present-but-null value satisfies
any type, only absence of a required field is an error.
This commit is contained in:
Joseph Doherty
2026-06-15 15:04:28 -04:00
parent 3032faac0d
commit 4b6187c853
8 changed files with 982 additions and 286 deletions
@@ -4,8 +4,23 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// WP-2: Validates and deserializes JSON request body against method parameter definitions.
/// Extended type system: Boolean, Integer, Float, String, Object, List.
/// WP-2: Validates and deserializes a JSON request body against a method's
/// parameter definitions. Extended type system: Boolean, Integer, Float,
/// String, Object, List.
///
/// <para>
/// InboundAPI-M2.6: 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
{
@@ -14,40 +29,34 @@ public static class ParameterValidator
/// 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-serialized list of <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi.ParameterDefinition"/>; null or empty means no parameters are defined.</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>
/// <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)
{
if (string.IsNullOrEmpty(parameterDefinitions))
{
// No parameters defined — body should be empty or null
return ParameterValidationResult.Valid(new Dictionary<string, object?>());
}
List<ParameterDefinition> definitions;
InboundApiSchema? schema;
try
{
definitions = JsonSerializer.Deserialize<List<ParameterDefinition>>(
parameterDefinitions,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? [];
schema = InboundApiSchema.Parse(parameterDefinitions);
}
catch (JsonException)
{
return ParameterValidationResult.Invalid("Invalid parameter definitions in method configuration");
}
if (definitions.Count == 0)
// 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)
if (body == null
|| body.Value.ValueKind == JsonValueKind.Null
|| body.Value.ValueKind == JsonValueKind.Undefined)
{
// Check if all parameters are optional
var required = definitions.Where(d => d.Required).ToList();
var required = schema.Fields.Where(f => f.Required).ToList();
if (required.Count > 0)
{
return ParameterValidationResult.Invalid(
@@ -62,86 +71,51 @@ public static class ParameterValidator
return ParameterValidationResult.Invalid("Request body must be a JSON object");
}
var result = new Dictionary<string, 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>();
// InboundAPI-010: report top-level body fields that do not match any defined
// parameter, so a caller learns about a typo'd parameter name instead of
// having the field silently ignored.
var defined = new HashSet<string>(definitions.Select(d => d.Name), StringComparer.Ordinal);
var unexpected = body.Value.EnumerateObject()
.Select(p => p.Name)
.Where(name => !defined.Contains(name))
.ToList();
if (unexpected.Count > 0)
{
errors.Add($"Unexpected parameter(s): {string.Join(", ", unexpected)}");
}
foreach (var def in definitions)
{
if (body.Value.TryGetProperty(def.Name, out var prop))
{
var (value, error) = CoerceValue(prop, def.Type, def.Name);
if (error != null)
{
errors.Add(error);
}
else
{
result[def.Name] = value;
}
}
else if (def.Required)
{
errors.Add($"Missing required parameter: {def.Name}");
}
}
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>
/// Coerces a JSON element to the declared parameter type. InboundAPI-010: the
/// <c>Object</c> and <c>List</c> extended types are validated for JSON <em>shape</em>
/// only (object vs. array) — there is no field-level or element-level type
/// validation. A method script that needs a specific nested structure must
/// validate it itself; invalid nested data surfaces as a runtime script error.
/// 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}"/>.
/// </summary>
private static (object? value, string? error) CoerceValue(JsonElement element, string expectedType, string paramName)
private static object? Materialize(JsonElement element, InboundApiSchema schema)
{
return expectedType.ToLowerInvariant() switch
if (element.ValueKind == JsonValueKind.Null)
{
"boolean" => element.ValueKind == JsonValueKind.True || element.ValueKind == JsonValueKind.False
? (element.GetBoolean(), null)
: (null, $"Parameter '{paramName}' must be a Boolean"),
return null;
}
"integer" => element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out var intVal)
? (intVal, null)
: (null, $"Parameter '{paramName}' must be an Integer"),
"float" => element.ValueKind == JsonValueKind.Number
? (element.GetDouble(), null)
: (null, $"Parameter '{paramName}' must be a Float"),
"string" => element.ValueKind == JsonValueKind.String
? (element.GetString(), null)
: (null, $"Parameter '{paramName}' must be a String"),
"object" => element.ValueKind == JsonValueKind.Object
? (JsonSerializer.Deserialize<Dictionary<string, object?>>(element.GetRawText()), null)
: (null, $"Parameter '{paramName}' must be an Object"),
"list" => element.ValueKind == JsonValueKind.Array
? (JsonSerializer.Deserialize<List<object?>>(element.GetRawText()), null)
: (null, $"Parameter '{paramName}' must be a List"),
_ => (null, $"Unknown parameter type '{expectedType}' for parameter '{paramName}'")
return schema.Type switch
{
"boolean" => element.GetBoolean(),
"integer" => element.GetInt64(),
"number" => element.GetDouble(),
"string" => element.GetString(),
"object" => JsonSerializer.Deserialize<Dictionary<string, object?>>(element.GetRawText()),
"array" => JsonSerializer.Deserialize<List<object?>>(element.GetRawText()),
_ => JsonSerializer.Deserialize<object?>(element.GetRawText()),
};
}
}