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:
@@ -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);
|
||||
Reference in New Issue
Block a user