411d0c043b
- legacy flat-array "required":"false" (string) now treated as optional (matches migration) - depth ceiling (32) on InboundApiSchema Parse/Validate recursion — guards against stack-overflow from a deeply-nested stored schema (Parse throws->400, Validate adds error) - DocOptions.MaxDepth=128 so the application-level structural guard fires before the System.Text.Json reader ceiling (each schema level = ~3 JSON reader levels) - comment the intentional ParameterValidator/ReturnValueValidator early-return asymmetry - note intentional datetime->string legacy collapse in NormalizeType - tests: legacy string-false optional, parse/validate depth ceiling, scalar return schema
138 lines
6.0 KiB
C#
138 lines
6.0 KiB
C#
using System.Text.Json;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
|
|
|
/// <summary>
|
|
/// InboundAPI-014: validates a method script's return value against the method's
|
|
/// declared <c>ReturnDefinition</c>. <c>Component-InboundAPI.md</c> ("Return Value
|
|
/// Definition" / "Response Format") states the success body has "fields matching
|
|
/// the return value definition"; this is the response-side mirror of
|
|
/// <see cref="ParameterValidator"/>.
|
|
///
|
|
/// <para>
|
|
/// The return definition is JSON Schema (the canonical persisted format; the
|
|
/// legacy flat <c>[{name,type}]</c> array is still accepted for transition
|
|
/// safety). A method whose <c>ReturnDefinition</c> is null/empty is
|
|
/// unconstrained — its return value is serialized as-is (backward compatible).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// InboundAPI-M2.6: validation is RECURSIVE and type-aware — declared object
|
|
/// fields are validated against their declared (nested) types, list elements
|
|
/// against the declared element type, and scalars at any depth — with
|
|
/// path-qualified errors. The recursion is shared with
|
|
/// <see cref="ParameterValidator"/> via
|
|
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi.InboundApiSchema"/>,
|
|
/// so the inbound and outbound type checks cannot drift apart.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class ReturnValueValidator
|
|
{
|
|
/// <summary>
|
|
/// Validates the serialized script result JSON against the method's return
|
|
/// definition. Returns <see cref="ReturnValidationResult.Valid"/> when no
|
|
/// definition is configured or the result conforms to it.
|
|
/// </summary>
|
|
/// <param name="resultJson">The JSON-serialized script return value to validate.</param>
|
|
/// <param name="returnDefinition">JSON Schema describing the method's return value, or null/empty to skip validation. The legacy flat-array form is also accepted.</param>
|
|
/// <returns>A <see cref="ReturnValidationResult"/> indicating success or describing the validation failures.</returns>
|
|
public static ReturnValidationResult Validate(string? resultJson, string? returnDefinition)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(returnDefinition))
|
|
{
|
|
// No declared return shape — the script's return value is unconstrained.
|
|
return ReturnValidationResult.Valid();
|
|
}
|
|
|
|
InboundApiSchema? schema;
|
|
try
|
|
{
|
|
schema = InboundApiSchema.Parse(returnDefinition);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return ReturnValidationResult.Invalid(
|
|
"Invalid return definition in method configuration");
|
|
}
|
|
|
|
// A schema that declares no constraints (e.g. an object schema with no
|
|
// fields) leaves the return value unconstrained.
|
|
if (schema is null || (schema.Type == "object" && schema.Fields.Count == 0))
|
|
{
|
|
return ReturnValidationResult.Valid();
|
|
}
|
|
|
|
// INTENTIONAL asymmetry with ParameterValidator:
|
|
//
|
|
// ParameterValidator has an early-return guard for "schema.Type != object"
|
|
// because method parameters are ALWAYS a top-level JSON object (flat map of
|
|
// name→value); a non-object parameter schema is treated as unconstrained.
|
|
//
|
|
// ReturnValueValidator does NOT guard on schema.Type here. A method may
|
|
// declare a scalar return type (e.g. {"type":"string"} or {"type":"integer"})
|
|
// and the script is expected to return exactly that scalar JSON value.
|
|
// Guarding on type == "object" would silently bypass validation for scalar
|
|
// and array return schemas — do NOT add that guard here.
|
|
|
|
if (string.IsNullOrWhiteSpace(resultJson))
|
|
{
|
|
return ReturnValidationResult.Invalid(
|
|
"Method declares a return structure but the script returned no value");
|
|
}
|
|
|
|
JsonElement root;
|
|
JsonDocument doc;
|
|
try
|
|
{
|
|
doc = JsonDocument.Parse(resultJson);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return ReturnValidationResult.Invalid("Script return value is not valid JSON");
|
|
}
|
|
|
|
using (doc)
|
|
{
|
|
root = doc.RootElement;
|
|
|
|
// A JSON null result against a declared structure is treated as
|
|
// "no value returned" (preserves the prior contract).
|
|
if (root.ValueKind == JsonValueKind.Null)
|
|
{
|
|
return ReturnValidationResult.Invalid(
|
|
"Method declares a return structure but the script returned no value");
|
|
}
|
|
|
|
var errors = new List<string>();
|
|
schema.Validate(root, string.Empty, errors);
|
|
|
|
return errors.Count > 0
|
|
? ReturnValidationResult.Invalid(
|
|
$"Return value does not match the declared return definition: {string.Join("; ", errors)}")
|
|
: ReturnValidationResult.Valid();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of validating a script return value against a method's return definition.
|
|
/// </summary>
|
|
public sealed class ReturnValidationResult
|
|
{
|
|
/// <summary>True when the return value conforms to the declared return definition.</summary>
|
|
public bool IsValid { get; private init; }
|
|
/// <summary>Human-readable error message when <see cref="IsValid"/> is false; empty otherwise.</summary>
|
|
public string ErrorMessage { get; private init; } = string.Empty;
|
|
|
|
/// <summary>Returns a successful validation result.</summary>
|
|
/// <returns>A <see cref="ReturnValidationResult"/> with <see cref="IsValid"/> set to <c>true</c>.</returns>
|
|
public static ReturnValidationResult Valid() => new() { IsValid = true };
|
|
|
|
/// <summary>Returns a failed validation result with the specified error message.</summary>
|
|
/// <param name="message">Human-readable description of the validation failure.</param>
|
|
/// <returns>A <see cref="ReturnValidationResult"/> with <see cref="IsValid"/> set to <c>false</c> and the given error message.</returns>
|
|
public static ReturnValidationResult Invalid(string message) =>
|
|
new() { IsValid = false, ErrorMessage = message };
|
|
}
|