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
@@ -0,0 +1,357 @@
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
/// <summary>
/// Recursive, persistence-ignorant model of an inbound-API parameter or
/// return-value type definition. This is the deserialized form of the JSON
/// Schema stored in <c>ApiMethod.ParameterDefinitions</c> / <c>ReturnDefinition</c>
/// (and the equivalent TemplateScript / SharedScript columns), the canonical
/// format produced by the Central UI schema builder and the
/// <c>MigrateParametersToJsonSchema</c> migration.
///
/// <para>
/// Unlike the flat <see cref="ParameterDefinition"/> (name → scalar type, no
/// nesting), an <see cref="InboundApiSchema"/> carries the FULL nested type:
/// an <c>object</c> node carries its declared field schemas (and which fields
/// are required); an <c>array</c> node carries its element schema. This lets
/// callers validate complex request/response structures field-by-field and
/// element-by-element to any depth, with path-qualified errors
/// (e.g. <c>order.items[2].quantity</c>).
/// </para>
///
/// <para>
/// The extended type vocabulary (after normalization) is the JSON Schema set:
/// <c>boolean · integer · number · string · object · array</c>. Legacy aliases
/// (<c>bool</c>, <c>int</c>, <c>float</c>, <c>double</c>, <c>list</c>, …) are
/// accepted on parse for transition safety, mirroring the Central UI
/// <c>SchemaBuilderModel</c> / <c>JsonSchemaShapeParser</c> conventions.
/// </para>
/// </summary>
public sealed class InboundApiSchema
{
/// <summary>Normalized JSON Schema type: one of <c>boolean · integer · number · string · object · array</c>.</summary>
public string Type { get; init; } = "string";
/// <summary>For <see cref="Type"/> = <c>object</c>: the declared fields, in declaration order.</summary>
public IReadOnlyList<InboundApiSchemaField> Fields { get; init; } = [];
/// <summary>For <see cref="Type"/> = <c>array</c>: the schema every element must satisfy; null means element type was not declared (shape-only).</summary>
public InboundApiSchema? Items { get; init; }
private static readonly JsonDocumentOptions DocOptions = default;
/// <summary>
/// Parses a stored definition string into an <see cref="InboundApiSchema"/>.
/// Accepts the canonical JSON Schema object form
/// (<c>{"type":"object","properties":{…},"required":[…]}</c>) and, for
/// transition safety, the legacy flat-array parameter form
/// (<c>[{name,type,required,itemType?}]</c>) which it treats as an object
/// schema whose properties are the array entries.
/// </summary>
/// <param name="json">The definition JSON; null/whitespace yields <c>null</c>.</param>
/// <returns>The parsed schema, or <c>null</c> when the input is empty.</returns>
/// <exception cref="JsonException">The input is non-empty but not valid JSON, or is a JSON scalar/null at the root.</exception>
public static InboundApiSchema? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
using var doc = JsonDocument.Parse(json, DocOptions);
return doc.RootElement.ValueKind switch
{
JsonValueKind.Object => ParseSchema(doc.RootElement),
JsonValueKind.Array => ParseLegacyArray(doc.RootElement),
_ => throw new JsonException("Type definition must be a JSON object (JSON Schema) or legacy parameter array."),
};
}
private static InboundApiSchema ParseSchema(JsonElement el)
{
var type = el.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
? NormalizeType(t.GetString())
: "string";
if (type == "array")
{
InboundApiSchema? items = null;
if (el.TryGetProperty("items", out var itemsEl) && itemsEl.ValueKind == JsonValueKind.Object)
{
items = ParseSchema(itemsEl);
}
return new InboundApiSchema { Type = "array", Items = items };
}
if (type == "object")
{
var requiredSet = new HashSet<string>(StringComparer.Ordinal);
if (el.TryGetProperty("required", out var req) && req.ValueKind == JsonValueKind.Array)
{
foreach (var r in req.EnumerateArray())
{
if (r.ValueKind == JsonValueKind.String)
{
var s = r.GetString();
if (!string.IsNullOrEmpty(s))
{
requiredSet.Add(s);
}
}
}
}
var fields = new List<InboundApiSchemaField>();
if (el.TryGetProperty("properties", out var props) && props.ValueKind == JsonValueKind.Object)
{
foreach (var prop in props.EnumerateObject())
{
var schema = prop.Value.ValueKind == JsonValueKind.Object
? ParseSchema(prop.Value)
: new InboundApiSchema { Type = "string" };
fields.Add(new InboundApiSchemaField(prop.Name, requiredSet.Contains(prop.Name), schema));
}
}
return new InboundApiSchema { Type = "object", Fields = fields };
}
return new InboundApiSchema { Type = type };
}
private static InboundApiSchema ParseLegacyArray(JsonElement arr)
{
var fields = new List<InboundApiSchemaField>();
foreach (var item in arr.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object)
{
continue;
}
// The legacy flat shape historically appeared with both PascalCase
// (CLI / anonymous-object serialization read back with
// PropertyNameCaseInsensitive) and lowercase (DB) keys, so the
// property lookups here are case-insensitive for compatibility.
var name = TryGetMember(item, "name", out var n) ? n.GetString() : null;
if (string.IsNullOrEmpty(name))
{
continue;
}
var rawType = TryGetMember(item, "type", out var t) ? t.GetString() : "string";
var required = !TryGetMember(item, "required", out var rq) || rq.ValueKind != JsonValueKind.False;
var normalized = NormalizeType(rawType);
InboundApiSchema schema;
if (normalized == "array")
{
var inner = TryGetMember(item, "itemType", out var it) ? it.GetString() : null;
schema = new InboundApiSchema
{
Type = "array",
Items = string.IsNullOrEmpty(inner) ? null : new InboundApiSchema { Type = NormalizeType(inner) },
};
}
else
{
schema = new InboundApiSchema { Type = normalized };
}
fields.Add(new InboundApiSchemaField(name!, required, schema));
}
return new InboundApiSchema { Type = "object", Fields = fields };
}
/// <summary>
/// Case-insensitive object-member lookup, used only on the legacy flat-array
/// path so both PascalCase and lowercase legacy keys resolve.
/// </summary>
private static bool TryGetMember(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
/// <summary>
/// Normalizes a raw type token to the canonical JSON Schema vocabulary,
/// tolerating legacy aliases. Unknown tokens are returned lowercased so the
/// validator can surface an explicit "unknown type" error.
/// </summary>
/// <param name="raw">The raw type token (may be null).</param>
/// <returns>The normalized type token.</returns>
public static string NormalizeType(string? raw) => raw?.ToLowerInvariant() switch
{
null or "" => "string",
"boolean" or "bool" => "boolean",
"integer" or "int" or "int32" or "int64" => "integer",
"number" or "float" or "double" or "decimal" => "number",
"string" or "datetime" => "string",
"object" => "object",
"array" or "list" => "array",
var other => other,
};
/// <summary>
/// Recursively validates a JSON value against this schema. A JSON <c>null</c>
/// satisfies any type (a present-but-null field is allowed; absence of a
/// required field is reported by the parent object). Errors are accumulated
/// with a path prefix (e.g. <c>order.items[2].quantity</c>) so the caller can
/// pinpoint the offending field.
/// </summary>
/// <param name="value">The JSON value to validate.</param>
/// <param name="path">The path prefix for the value being validated (empty for the root).</param>
/// <param name="errors">Accumulator the validator appends path-qualified messages to.</param>
public void Validate(JsonElement value, string path, List<string> errors)
{
ArgumentNullException.ThrowIfNull(errors);
// A null value satisfies any declared type — a present-but-null field is
// allowed; a MISSING required field is reported by the enclosing object.
if (value.ValueKind == JsonValueKind.Null)
{
return;
}
switch (Type)
{
case "boolean":
if (value.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
errors.Add(Mismatch(path, "Boolean"));
}
break;
case "integer":
if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt64(out _))
{
errors.Add(Mismatch(path, "Integer"));
}
break;
case "number":
if (value.ValueKind != JsonValueKind.Number)
{
errors.Add(Mismatch(path, "Float"));
}
break;
case "string":
if (value.ValueKind != JsonValueKind.String)
{
errors.Add(Mismatch(path, "String"));
}
break;
case "object":
ValidateObject(value, path, errors);
break;
case "array":
ValidateArray(value, path, errors);
break;
default:
errors.Add($"{Describe(path)} has unknown declared type '{Type}'");
break;
}
}
private void ValidateObject(JsonElement value, string path, List<string> errors)
{
if (value.ValueKind != JsonValueKind.Object)
{
errors.Add(Mismatch(path, "Object"));
return;
}
// Reject undeclared fields (defensive, consistent with InboundAPI-010's
// top-level "unexpected parameter" rejection) — a typo'd nested field is
// surfaced instead of silently ignored. Skipped when no fields are
// declared (a bare {"type":"object"} stays shape-only, like the legacy
// behaviour and the array-without-items case).
if (Fields.Count > 0)
{
var declared = new HashSet<string>(Fields.Select(f => f.Name), StringComparer.Ordinal);
foreach (var prop in value.EnumerateObject())
{
if (!declared.Contains(prop.Name))
{
errors.Add($"{Describe(JoinField(path, prop.Name))} is not a declared field");
}
}
}
foreach (var field in Fields)
{
var fieldPath = JoinField(path, field.Name);
if (value.TryGetProperty(field.Name, out var fieldValue))
{
field.Schema.Validate(fieldValue, fieldPath, errors);
}
else if (field.Required)
{
errors.Add($"missing required field {Describe(fieldPath)}");
}
}
}
private void ValidateArray(JsonElement value, string path, List<string> errors)
{
if (value.ValueKind != JsonValueKind.Array)
{
errors.Add(Mismatch(path, "List"));
return;
}
// No declared element type → shape-only (any elements accepted).
if (Items is null)
{
return;
}
var index = 0;
foreach (var element in value.EnumerateArray())
{
Items.Validate(element, $"{path}[{index}]", errors);
index++;
}
}
private static string Mismatch(string path, string expectedDisplayType) =>
$"{Describe(path)} must be {Article(expectedDisplayType)} {expectedDisplayType}";
private static string Describe(string path) =>
string.IsNullOrEmpty(path) ? "value" : $"'{path}'";
private static string JoinField(string path, string field) =>
string.IsNullOrEmpty(path) ? field : $"{path}.{field}";
private static string Article(string word) =>
word.Length > 0 && "AEIOU".IndexOf(char.ToUpperInvariant(word[0])) >= 0 ? "an" : "a";
}
/// <summary>
/// One declared field of an <see cref="InboundApiSchema"/> object node: the
/// field name, whether it is required, and its (recursive) type schema.
/// </summary>
/// <param name="Name">The field name as it appears in the JSON.</param>
/// <param name="Required">Whether the field must be present.</param>
/// <param name="Schema">The recursive type schema the field's value must satisfy.</param>
public sealed record InboundApiSchemaField(string Name, bool Required, InboundApiSchema Schema);
@@ -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()),
};
}
}
@@ -1,4 +1,5 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
@@ -10,13 +11,20 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <see cref="ParameterValidator"/>.
///
/// <para>
/// The return definition is a JSON array of <see cref="ReturnFieldDefinition"/>
/// (the same <c>{name,type}</c> shape as a parameter definition). A method whose
/// <c>ReturnDefinition</c> is null/empty is unconstrained — its return value is
/// serialized as-is (backward compatible). Primitive fields (Boolean / Integer /
/// Float / String) are type-checked; the extended <c>Object</c>/<c>List</c> types
/// are shape-checked only (object vs. array), consistent with how
/// <see cref="ParameterValidator"/> treats inbound extended types.
/// 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
@@ -27,8 +35,8 @@ public static class ReturnValueValidator
/// 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-serialized list of <see cref="ReturnFieldDefinition"/> entries, or null/empty to skip validation.</param>
/// <returns>A <see cref="ReturnValidationResult"/> indicating success or describing the first validation failure.</returns>
/// <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))
@@ -37,13 +45,10 @@ public static class ReturnValueValidator
return ReturnValidationResult.Valid();
}
List<ReturnFieldDefinition> fields;
InboundApiSchema? schema;
try
{
fields = JsonSerializer.Deserialize<List<ReturnFieldDefinition>>(
returnDefinition,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? [];
schema = InboundApiSchema.Parse(returnDefinition);
}
catch (JsonException)
{
@@ -51,7 +56,9 @@ public static class ReturnValueValidator
"Invalid return definition in method configuration");
}
if (fields.Count == 0)
// 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();
}
@@ -63,75 +70,37 @@ public static class ReturnValueValidator
}
JsonElement root;
JsonDocument doc;
try
{
using var doc = JsonDocument.Parse(resultJson);
root = doc.RootElement.Clone();
doc = JsonDocument.Parse(resultJson);
}
catch (JsonException)
{
return ReturnValidationResult.Invalid("Script return value is not valid JSON");
}
if (root.ValueKind != JsonValueKind.Object)
using (doc)
{
return ReturnValidationResult.Invalid(
"Method declares a return structure but the script did not return an object");
}
root = doc.RootElement;
var errors = new List<string>();
foreach (var field in fields)
{
if (!root.TryGetProperty(field.Name, out var value))
// A JSON null result against a declared structure is treated as
// "no value returned" (preserves the prior contract).
if (root.ValueKind == JsonValueKind.Null)
{
errors.Add($"missing return field '{field.Name}'");
continue;
return ReturnValidationResult.Invalid(
"Method declares a return structure but the script returned no value");
}
var typeError = CheckFieldType(value, field.Type, field.Name);
if (typeError != null)
errors.Add(typeError);
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();
}
return errors.Count > 0
? ReturnValidationResult.Invalid(
$"Return value does not match the declared return definition: {string.Join("; ", errors)}")
: ReturnValidationResult.Valid();
}
private static string? CheckFieldType(JsonElement value, string declaredType, string fieldName)
{
// A null value satisfies any field type — the script may legitimately omit
// optional data; only a missing field (handled by the caller) is an error.
if (value.ValueKind == JsonValueKind.Null)
return null;
var ok = declaredType.ToLowerInvariant() switch
{
"boolean" => value.ValueKind is JsonValueKind.True or JsonValueKind.False,
"integer" => value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out _),
"float" => value.ValueKind == JsonValueKind.Number,
"string" => value.ValueKind == JsonValueKind.String,
"object" => value.ValueKind == JsonValueKind.Object,
"list" => value.ValueKind == JsonValueKind.Array,
_ => true, // unknown declared type — do not block the response
};
return ok ? null : $"return field '{fieldName}' must be {declaredType}";
}
}
/// <summary>
/// InboundAPI-014: one field of a method's declared return structure — the
/// deserialized form of an entry in <c>ApiMethod.ReturnDefinition</c>. Defined in
/// this module (not Commons) because the inbound API is currently its only consumer.
/// </summary>
public sealed class ReturnFieldDefinition
{
/// <summary>Field name as it must appear in the script return object.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Expected JSON type of this field (e.g., "string", "integer", "boolean", "object", "list").</summary>
public string Type { get; set; } = "String";
}
/// <summary>