diff --git a/docs/components/InboundAPI.md b/docs/components/InboundAPI.md index 70c295cb..c0a28223 100644 --- a/docs/components/InboundAPI.md +++ b/docs/components/InboundAPI.md @@ -36,28 +36,28 @@ public class ApiMethod public int Id { get; set; } public string Name { get; set; } // route segment public string Script { get; set; } // Roslyn C# script body - public string? ParameterDefinitions { get; set; } // JSON: List - public string? ReturnDefinition { get; set; } // JSON: List + public string? ParameterDefinitions { get; set; } // JSON Schema (object) describing parameters + public string? ReturnDefinition { get; set; } // JSON Schema describing the return value public int TimeoutSeconds { get; set; } } ``` -`ParameterDefinitions` and `ReturnDefinition` are stored as JSON strings to keep the schema simple; both are deserialized on every request by `ParameterValidator` and `ReturnValueValidator`. +`ParameterDefinitions` and `ReturnDefinition` are stored as JSON Schema strings (canonical form: `{"type":"object","properties":{…},"required":[…]}`, arrays via `"items"`); both are parsed on every request by `ParameterValidator` and `ReturnValueValidator` into a shared recursive `InboundApiSchema` (Commons). The legacy flat-array form (`[{name,type,required,itemType?}]`) is still accepted on read. ### Extended type system -Parameter and return field definitions share the same six-type vocabulary: +Parameter and return definitions share the same six-type vocabulary (JSON Schema type tokens in parentheses): -| Type | JSON shape | C# value after coercion | -|-----------|----------------------|-------------------------------------| -| `Boolean` | `true` / `false` | `bool` | -| `Integer` | number (whole) | `long` | -| `Float` | number | `double` | -| `String` | string | `string` | -| `Object` | JSON object | `Dictionary` | -| `List` | JSON array | `List` | +| Type | JSON Schema token | JSON shape | C# value after coercion | +|-----------|-------------------|------------------|-------------------------------| +| `Boolean` | `boolean` | `true` / `false` | `bool` | +| `Integer` | `integer` | number (whole) | `long` | +| `Float` | `number` | number | `double` | +| `String` | `string` | string | `string` | +| `Object` | `object` | JSON object | `Dictionary` | +| `List` | `array` | JSON array | `List` | -`Object` and `List` are validated for JSON shape only — field-level or element-level type constraints are the script's responsibility. Template attributes use only the four primitive types; the extended types apply here and in the External System Gateway. +`Object` and `List` are validated **recursively**: a declared object validates each field against its declared (nested) type and rejects undeclared fields; a list validates every element against the declared `items` type. Scalars are checked at any depth and errors are path-qualified (e.g. `order.items[2].quantity`). A bare `{"type":"object"}` / `{"type":"array"}` (no `properties` / `items`) stays shape-only. Template attributes use only the four primitive types; the extended types apply here and in the External System Gateway. ## Architecture diff --git a/docs/plans/2026-06-15-stillpending-m2-implementation.md b/docs/plans/2026-06-15-stillpending-m2-implementation.md index f19062fa..a3ac6cc9 100644 --- a/docs/plans/2026-06-15-stillpending-m2-implementation.md +++ b/docs/plans/2026-06-15-stillpending-m2-implementation.md @@ -102,6 +102,7 @@ Risk-first, migration-safe ordering. `#32` first (unblocks DB-backed verificatio **Fix:** Recursive descent through the declared `Object` field schema / `List` element type, type-checking each level (scalars by extended-type, nested Object/List recursively). Reuse the existing extended-type system; keep error messages path-qualified (`field.sub[2].x`). Apply symmetrically in both validators. **Tests:** `tests/.../InboundAPI.Tests` — valid nested payload passes; wrong scalar type at depth, wrong list element type, missing required nested field → rejected with path. **DoD:** Nested type mismatches are caught at inbound validation, not at script runtime. (Satisfies the M4 cross-reference to this item.) +**Status: complete.** A shared recursive engine, `Commons.Types.InboundApi.InboundApiSchema` (parse + path-qualified `Validate`), backs both validators so they cannot drift. Key finding: the canonical persisted/authored format is **JSON Schema** (object `properties` + `required`, array `items`) — produced by the Central UI schema builder and the `MigrateParametersToJsonSchema` migration — but the validators still parsed the *legacy flat array* `[{name,type}]` and only shape-checked `Object`/`List`. They could not even consume a migrated JSON-Schema-object definition (the `Deserialize>` would fail). Rewriting both to read `InboundApiSchema` fixes that latent format mismatch *and* delivers true nested validation; the legacy flat array is still accepted on read (case-insensitive keys) for transition safety. **Undeclared-field policy: reject at every level** (a declared object rejects any field not in its `properties`, consistent with the existing top-level `InboundAPI-010` "unexpected parameter" rejection); a bare `{"type":"object"}` with no declared fields stays shape-only. A present-but-null value satisfies any type; only the *absence* of a required field is an error. ### M2.7 — #20 + #21: return-type + argument-type compatibility checks **Classification:** standard · **Files:** `src/.../TemplateEngine/Validation/SemanticValidator.cs:62-63,251-266,279-287,390-425`. diff --git a/docs/requirements/Component-InboundAPI.md b/docs/requirements/Component-InboundAPI.md index b1817118..04488172 100644 --- a/docs/requirements/Component-InboundAPI.md +++ b/docs/requirements/Component-InboundAPI.md @@ -40,9 +40,10 @@ Each API method definition includes: - **Approved API Keys**: List of API keys authorized to invoke this method. Requests from non-approved keys are rejected. - **Parameter Definitions**: Ordered list of input parameters, each with: - Parameter name. - - Data type (Boolean, Integer, Float, String — same fixed set as template attributes). + - Data type — the **extended type system** (Boolean, Integer, Float, String, plus the nestable Object and List; see [Extended Type System](#extended-type-system)). + - Whether the parameter is required. - **Return Value Definition**: Structure of the response, with: - - Field names and data types. Supports returning **lists of objects**. + - Field names and (extended-system) data types. Supports returning **lists of objects** and arbitrarily nested structures. - **Implementation Script**: C# script that executes when the method is called. Stored **inline** in the method definition. Follows standard C# authoring patterns but has no template inheritance — it is a standalone script tied to this method. - **Timeout**: Configurable per method. Defines the maximum time the method is allowed to execute (including any routed calls to sites) before returning a timeout error to the caller. @@ -99,6 +100,17 @@ Each API method definition includes: - This allows complex request/response structures (e.g., an object containing properties and a list of nested objects). - Template attributes retain the simpler four-type system. The extended types apply only to Inbound API method definitions and External System Gateway method definitions. +#### Type Definition Format & Nested Validation + +- Parameter and return type definitions are persisted as **JSON Schema** (the canonical format produced by the Central UI schema builder; see the `MigrateParametersToJsonSchema` migration). An object declares its fields via `properties` (+ a `required` array); a list declares its element type via `items`. The legacy flat-array form (`[{name,type,required,itemType?}]`) is still accepted on read for transition safety. +- Validation is **recursive and type-aware** for the extended types (request parameters and script return values alike, via a single shared engine so the two cannot drift): + - **Object**: each declared field's value is validated against its declared (possibly nested) type; a missing required field and a present-but-wrong type are both reported. + - **List**: every element is validated against the declared element type (recursing into nested objects/lists). A list whose element type is left undeclared (`array` without `items`) is shape-checked only. + - **Scalars at any depth** are checked against the extended type. + - Errors are **path-qualified** (e.g. `order.items[2].quantity`) so the caller can locate the offending field. +- **Undeclared fields are rejected** at every level (consistent with the top-level "unexpected parameter" rejection): an object that declares its fields rejects any field not in its `properties`, so a typo'd field name surfaces as a `400`/error rather than being silently ignored. A bare object schema with no declared fields (`{"type":"object"}`) stays shape-only and accepts any fields. +- A JSON `null` value satisfies any declared type (a present-but-null field is allowed); only the **absence** of a required field is an error. + ## Script Compilation & Hot-Reload API method scripts are compiled at central startup — all method definitions are loaded from the configuration database and compiled into in-memory delegates. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/InboundApiSchema.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/InboundApiSchema.cs new file mode 100644 index 00000000..d797d41d --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/InboundApiSchema.cs @@ -0,0 +1,357 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; + +/// +/// 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 ApiMethod.ParameterDefinitions / ReturnDefinition +/// (and the equivalent TemplateScript / SharedScript columns), the canonical +/// format produced by the Central UI schema builder and the +/// MigrateParametersToJsonSchema migration. +/// +/// +/// Unlike the flat (name → scalar type, no +/// nesting), an carries the FULL nested type: +/// an object node carries its declared field schemas (and which fields +/// are required); an array 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. order.items[2].quantity). +/// +/// +/// +/// The extended type vocabulary (after normalization) is the JSON Schema set: +/// boolean · integer · number · string · object · array. Legacy aliases +/// (bool, int, float, double, list, …) are +/// accepted on parse for transition safety, mirroring the Central UI +/// SchemaBuilderModel / JsonSchemaShapeParser conventions. +/// +/// +public sealed class InboundApiSchema +{ + /// Normalized JSON Schema type: one of boolean · integer · number · string · object · array. + public string Type { get; init; } = "string"; + + /// For = object: the declared fields, in declaration order. + public IReadOnlyList Fields { get; init; } = []; + + /// For = array: the schema every element must satisfy; null means element type was not declared (shape-only). + public InboundApiSchema? Items { get; init; } + + private static readonly JsonDocumentOptions DocOptions = default; + + /// + /// Parses a stored definition string into an . + /// Accepts the canonical JSON Schema object form + /// ({"type":"object","properties":{…},"required":[…]}) and, for + /// transition safety, the legacy flat-array parameter form + /// ([{name,type,required,itemType?}]) which it treats as an object + /// schema whose properties are the array entries. + /// + /// The definition JSON; null/whitespace yields null. + /// The parsed schema, or null when the input is empty. + /// The input is non-empty but not valid JSON, or is a JSON scalar/null at the root. + 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(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(); + 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(); + 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 }; + } + + /// + /// Case-insensitive object-member lookup, used only on the legacy flat-array + /// path so both PascalCase and lowercase legacy keys resolve. + /// + 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; + } + + /// + /// 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. + /// + /// The raw type token (may be null). + /// The normalized type token. + 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, + }; + + /// + /// Recursively validates a JSON value against this schema. A JSON null + /// 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. order.items[2].quantity) so the caller can + /// pinpoint the offending field. + /// + /// The JSON value to validate. + /// The path prefix for the value being validated (empty for the root). + /// Accumulator the validator appends path-qualified messages to. + public void Validate(JsonElement value, string path, List 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 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(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 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"; +} + +/// +/// One declared field of an object node: the +/// field name, whether it is required, and its (recursive) type schema. +/// +/// The field name as it appears in the JSON. +/// Whether the field must be present. +/// The recursive type schema the field's value must satisfy. +public sealed record InboundApiSchemaField(string Name, bool Required, InboundApiSchema Schema); diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs index 28b3c3b5..75790067 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs @@ -4,8 +4,23 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; namespace ZB.MOM.WW.ScadaBridge.InboundAPI; /// -/// 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. +/// +/// +/// InboundAPI-M2.6: validation is now RECURSIVE and type-aware for the +/// extended Object / List 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. order.items[2].quantity). 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 +/// +/// for the shared recursive engine that +/// also uses. +/// /// public static class ParameterValidator { @@ -14,40 +29,34 @@ public static class ParameterValidator /// Returns deserialized parameters or an error message. /// /// The parsed JSON request body; null or undefined if no body was supplied. - /// JSON-serialized list of ; null or empty means no parameters are defined. + /// 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. /// A with coerced parameter values on success, or an error message on failure. 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()); - } - - List definitions; + InboundApiSchema? schema; try { - definitions = JsonSerializer.Deserialize>( - 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()); } - 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(); + // 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(); - - // 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(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(); + 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); } /// - /// Coerces a JSON element to the declared parameter type. InboundAPI-010: the - /// Object and List extended types are validated for JSON shape - /// 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 , + /// arrays to . /// - 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>(element.GetRawText()), null) - : (null, $"Parameter '{paramName}' must be an Object"), - - "list" => element.ValueKind == JsonValueKind.Array - ? (JsonSerializer.Deserialize>(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>(element.GetRawText()), + "array" => JsonSerializer.Deserialize>(element.GetRawText()), + _ => JsonSerializer.Deserialize(element.GetRawText()), }; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ReturnValueValidator.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ReturnValueValidator.cs index 7b3b438a..62acc5b9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ReturnValueValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ReturnValueValidator.cs @@ -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; /// . /// /// -/// The return definition is a JSON array of -/// (the same {name,type} shape as a parameter definition). A method whose -/// ReturnDefinition 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 Object/List types -/// are shape-checked only (object vs. array), consistent with how -/// treats inbound extended types. +/// The return definition is JSON Schema (the canonical persisted format; the +/// legacy flat [{name,type}] array is still accepted for transition +/// safety). A method whose ReturnDefinition is null/empty is +/// unconstrained — its return value is serialized as-is (backward compatible). +/// +/// +/// +/// 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 +/// via +/// , +/// so the inbound and outbound type checks cannot drift apart. /// /// public static class ReturnValueValidator @@ -27,8 +35,8 @@ public static class ReturnValueValidator /// definition is configured or the result conforms to it. /// /// The JSON-serialized script return value to validate. - /// JSON-serialized list of entries, or null/empty to skip validation. - /// A indicating success or describing the first validation failure. + /// JSON Schema describing the method's return value, or null/empty to skip validation. The legacy flat-array form is also accepted. + /// A indicating success or describing the validation failures. public static ReturnValidationResult Validate(string? resultJson, string? returnDefinition) { if (string.IsNullOrWhiteSpace(returnDefinition)) @@ -37,13 +45,10 @@ public static class ReturnValueValidator return ReturnValidationResult.Valid(); } - List fields; + InboundApiSchema? schema; try { - fields = JsonSerializer.Deserialize>( - 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(); - 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(); + 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}"; - } -} - -/// -/// InboundAPI-014: one field of a method's declared return structure — the -/// deserialized form of an entry in ApiMethod.ReturnDefinition. Defined in -/// this module (not Commons) because the inbound API is currently its only consumer. -/// -public sealed class ReturnFieldDefinition -{ - /// Field name as it must appear in the script return object. - public string Name { get; set; } = string.Empty; - /// Expected JSON type of this field (e.g., "string", "integer", "boolean", "object", "list"). - public string Type { get; set; } = "String"; } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs index a5dd3adc..e3c63747 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ParameterValidatorTests.cs @@ -3,10 +3,27 @@ using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// -/// WP-2: Tests for parameter validation — type checking, required fields, extended type system. +/// WP-2 / InboundAPI-M2.6: tests for parameter validation — type checking, +/// required fields, the extended type system, and RECURSIVE (nested Object / +/// List element) type validation with path-qualified errors. +/// +/// +/// Definitions are expressed as JSON Schema (the canonical persisted format +/// produced by the Central UI / migration). The validator also accepts the +/// legacy flat-array form; that backward-compat path is covered by the final +/// region. +/// /// public class ParameterValidatorTests { + private static JsonElement Body(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + // ── No / empty definitions ──────────────────────────────────────────────── + [Fact] public void NoDefinitions_NoBody_ReturnsValid() { @@ -16,21 +33,27 @@ public class ParameterValidatorTests } [Fact] - public void EmptyDefinitions_ReturnsValid() + public void EmptyObjectSchema_ReturnsValid() + { + var result = ParameterValidator.Validate(null, """{"type":"object","properties":{}}"""); + Assert.True(result.IsValid); + } + + [Fact] + public void EmptyLegacyArray_ReturnsValid() { var result = ParameterValidator.Validate(null, "[]"); Assert.True(result.IsValid); } + // ── Required / body shape ────────────────────────────────────────────────── + [Fact] public void RequiredParameterMissing_ReturnsInvalid() { - var definitions = JsonSerializer.Serialize(new[] - { - new { Name = "value", Type = "Integer", Required = true } - }); + const string def = """{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"]}"""; - var result = ParameterValidator.Validate(null, definitions); + var result = ParameterValidator.Validate(null, def); Assert.False(result.IsValid); Assert.Contains("Missing required parameter", result.ErrorMessage); } @@ -38,136 +61,307 @@ public class ParameterValidatorTests [Fact] public void BodyNotObject_ReturnsInvalid() { - var definitions = JsonSerializer.Serialize(new[] - { - new { Name = "value", Type = "String", Required = true } - }); + const string def = """{"type":"object","properties":{"value":{"type":"string"}},"required":["value"]}"""; - using var doc = JsonDocument.Parse("\"just a string\""); - var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions); + var result = ParameterValidator.Validate(Body("\"just a string\""), def); Assert.False(result.IsValid); Assert.Contains("must be a JSON object", result.ErrorMessage); } - [Theory] - [InlineData("Boolean", "true", true)] - [InlineData("Integer", "42", (long)42)] - [InlineData("Float", "3.14", 3.14)] - [InlineData("String", "\"hello\"", "hello")] - public void ValidTypeCoercion_Succeeds(string type, string jsonValue, object expected) - { - var definitions = JsonSerializer.Serialize(new[] - { - new { Name = "val", Type = type, Required = true } - }); - - using var doc = JsonDocument.Parse($"{{\"val\": {jsonValue}}}"); - var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions); - Assert.True(result.IsValid); - Assert.Equal(expected, result.Parameters["val"]); - } - - [Fact] - public void WrongType_ReturnsInvalid() - { - var definitions = JsonSerializer.Serialize(new[] - { - new { Name = "count", Type = "Integer", Required = true } - }); - - using var doc = JsonDocument.Parse("{\"count\": \"not a number\"}"); - var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions); - Assert.False(result.IsValid); - Assert.Contains("must be an Integer", result.ErrorMessage); - } - - [Fact] - public void ObjectType_Parsed() - { - var definitions = JsonSerializer.Serialize(new[] - { - new { Name = "data", Type = "Object", Required = true } - }); - - using var doc = JsonDocument.Parse("{\"data\": {\"key\": \"value\"}}"); - var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions); - Assert.True(result.IsValid); - Assert.IsType>(result.Parameters["data"]); - } - - [Fact] - public void ListType_Parsed() - { - var definitions = JsonSerializer.Serialize(new[] - { - new { Name = "items", Type = "List", Required = true } - }); - - using var doc = JsonDocument.Parse("{\"items\": [1, 2, 3]}"); - var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions); - Assert.True(result.IsValid); - Assert.IsType>(result.Parameters["items"]); - } - [Fact] public void OptionalParameter_MissingBody_ReturnsValid() { - var definitions = JsonSerializer.Serialize(new[] - { - new { Name = "optional", Type = "String", Required = false } - }); + const string def = """{"type":"object","properties":{"optional":{"type":"string"}}}"""; - var result = ParameterValidator.Validate(null, definitions); + var result = ParameterValidator.Validate(null, def); Assert.True(result.IsValid); } + // ── Scalar coercion ──────────────────────────────────────────────────────── + + [Theory] + [InlineData("boolean", "true", true)] + [InlineData("integer", "42", (long)42)] + [InlineData("number", "3.14", 3.14)] + [InlineData("string", "\"hello\"", "hello")] + public void ValidTypeCoercion_Succeeds(string type, string jsonValue, object expected) + { + var def = "{\"type\":\"object\",\"properties\":{\"val\":{\"type\":\"" + type + "\"}},\"required\":[\"val\"]}"; + + var result = ParameterValidator.Validate(Body($"{{\"val\": {jsonValue}}}"), def); + Assert.True(result.IsValid); + Assert.Equal(expected, result.Parameters["val"]); + } + + [Fact] + public void WrongScalarType_ReturnsInvalid() + { + const string def = """{"type":"object","properties":{"count":{"type":"integer"}},"required":["count"]}"""; + + var result = ParameterValidator.Validate(Body("{\"count\": \"not a number\"}"), def); + Assert.False(result.IsValid); + Assert.Contains("'count'", result.ErrorMessage); + Assert.Contains("Integer", result.ErrorMessage); + } + [Fact] public void UnknownType_ReturnsInvalid() { - var definitions = JsonSerializer.Serialize(new[] - { - new { Name = "val", Type = "CustomType", Required = true } - }); + const string def = """{"type":"object","properties":{"val":{"type":"customtype"}},"required":["val"]}"""; - using var doc = JsonDocument.Parse("{\"val\": \"test\"}"); - var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions); + var result = ParameterValidator.Validate(Body("{\"val\": \"test\"}"), def); Assert.False(result.IsValid); - Assert.Contains("Unknown parameter type", result.ErrorMessage); + Assert.Contains("unknown declared type", result.ErrorMessage); } - // --- InboundAPI-010: unexpected top-level body fields must be reported so - // callers get feedback on typo'd parameter names instead of silent ignore. --- + // ── Object / List shape + materialization ────────────────────────────────── [Fact] - public void UnexpectedBodyField_ReturnsInvalid() + public void ObjectType_NoDeclaredFields_ShapeOnly_Materialized() { - var definitions = JsonSerializer.Serialize(new[] - { - new { Name = "value", Type = "Integer", Required = true } - }); + const string def = """{"type":"object","properties":{"data":{"type":"object"}},"required":["data"]}"""; + + var result = ParameterValidator.Validate(Body("{\"data\": {\"key\": \"value\"}}"), def); + Assert.True(result.IsValid); + Assert.IsType>(result.Parameters["data"]); + } + + [Fact] + public void ListType_NoDeclaredElement_ShapeOnly_Materialized() + { + const string def = """{"type":"object","properties":{"items":{"type":"array"}},"required":["items"]}"""; + + var result = ParameterValidator.Validate(Body("{\"items\": [1, 2, 3]}"), def); + Assert.True(result.IsValid); + Assert.IsType>(result.Parameters["items"]); + } + + // ── Undeclared / unexpected fields (rejected, recursively) ───────────────── + + [Fact] + public void UnexpectedTopLevelField_ReturnsInvalid() + { + const string def = """{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"]}"""; // "valeu" is a typo for "value"; the caller must be told, not ignored. - using var doc = JsonDocument.Parse("{\"value\": 1, \"valeu\": 2}"); - var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions); + var result = ParameterValidator.Validate(Body("{\"value\": 1, \"valeu\": 2}"), def); Assert.False(result.IsValid); Assert.Contains("valeu", result.ErrorMessage); + Assert.Contains("not a declared field", result.ErrorMessage); } [Fact] - public void OnlyDefinedFields_StillValid() + public void OnlyDeclaredFields_StillValid() { - // Regression guard: a body containing exactly the defined parameters - // must continue to validate. - var definitions = JsonSerializer.Serialize(new[] - { - new { Name = "value", Type = "Integer", Required = true } - }); + const string def = """{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"]}"""; - using var doc = JsonDocument.Parse("{\"value\": 1}"); - var result = ParameterValidator.Validate(doc.RootElement.Clone(), definitions); + var result = ParameterValidator.Validate(Body("{\"value\": 1}"), def); Assert.True(result.IsValid); Assert.Equal((long)1, result.Parameters["value"]); } + + [Fact] + public void UndeclaredNestedField_ReturnsInvalid_PathQualified() + { + const string def = """ + {"type":"object","properties":{ + "order":{"type":"object","properties":{"id":{"type":"integer"}},"required":["id"]} + },"required":["order"]} + """; + + var result = ParameterValidator.Validate( + Body("""{"order":{"id":1,"bogus":2}}"""), def); + + Assert.False(result.IsValid); + Assert.Contains("order.bogus", result.ErrorMessage); + Assert.Contains("not a declared field", result.ErrorMessage); + } + + // ── Nested validation: the M2.6 core ─────────────────────────────────────── + + private const string NestedDef = """ + { + "type":"object", + "properties":{ + "order":{ + "type":"object", + "properties":{ + "id":{"type":"integer"}, + "customer":{ + "type":"object", + "properties":{"name":{"type":"string"},"vip":{"type":"boolean"}}, + "required":["name"] + }, + "items":{ + "type":"array", + "items":{ + "type":"object", + "properties":{"sku":{"type":"string"},"quantity":{"type":"integer"}}, + "required":["sku","quantity"] + } + } + }, + "required":["id","customer","items"] + } + }, + "required":["order"] + } + """; + + [Fact] + public void ValidNestedPayload_Passes() + { + const string body = """ + {"order":{ + "id":7, + "customer":{"name":"Acme","vip":true}, + "items":[ + {"sku":"A1","quantity":3}, + {"sku":"B2","quantity":1} + ] + }} + """; + + var result = ParameterValidator.Validate(Body(body), NestedDef); + Assert.True(result.IsValid); + } + + [Fact] + public void WrongScalarTwoLevelsDeep_ReturnsInvalid_WithExactPath() + { + // order.customer.vip declared boolean, given a string. + const string body = """ + {"order":{ + "id":7, + "customer":{"name":"Acme","vip":"yes"}, + "items":[] + }} + """; + + var result = ParameterValidator.Validate(Body(body), NestedDef); + Assert.False(result.IsValid); + Assert.Contains("'order.customer.vip'", result.ErrorMessage); + Assert.Contains("Boolean", result.ErrorMessage); + } + + [Fact] + public void WrongScalarInsideListElement_ReturnsInvalid_WithElementIndexInPath() + { + // order.items[1].quantity declared integer, given a string. + const string body = """ + {"order":{ + "id":7, + "customer":{"name":"Acme"}, + "items":[ + {"sku":"A1","quantity":3}, + {"sku":"B2","quantity":"lots"} + ] + }} + """; + + var result = ParameterValidator.Validate(Body(body), NestedDef); + Assert.False(result.IsValid); + Assert.Contains("'order.items[1].quantity'", result.ErrorMessage); + Assert.Contains("Integer", result.ErrorMessage); + } + + [Fact] + public void ListElementWrongShape_ReturnsInvalid_WithElementIndexInPath() + { + // order.items[0] declared object, given a scalar. + const string body = """ + {"order":{ + "id":7, + "customer":{"name":"Acme"}, + "items":[ 42 ] + }} + """; + + var result = ParameterValidator.Validate(Body(body), NestedDef); + Assert.False(result.IsValid); + Assert.Contains("'order.items[0]'", result.ErrorMessage); + Assert.Contains("Object", result.ErrorMessage); + } + + [Fact] + public void MissingRequiredNestedField_ReturnsInvalid_PathQualified() + { + // order.customer.name is required but absent. + const string body = """ + {"order":{ + "id":7, + "customer":{"vip":false}, + "items":[] + }} + """; + + var result = ParameterValidator.Validate(Body(body), NestedDef); + Assert.False(result.IsValid); + Assert.Contains("missing required field", result.ErrorMessage); + Assert.Contains("'order.customer.name'", result.ErrorMessage); + } + + // ── Empty / null edge cases ──────────────────────────────────────────────── + + [Fact] + public void EmptyList_AgainstTypedElement_Passes() + { + const string body = """ + {"order":{"id":7,"customer":{"name":"Acme"},"items":[]}} + """; + + var result = ParameterValidator.Validate(Body(body), NestedDef); + Assert.True(result.IsValid); + } + + [Fact] + public void NullForOptionalNestedScalar_Passes() + { + // order.customer.vip is optional; explicit null is accepted. + const string body = """ + {"order":{ + "id":7, + "customer":{"name":"Acme","vip":null}, + "items":[] + }} + """; + + var result = ParameterValidator.Validate(Body(body), NestedDef); + Assert.True(result.IsValid); + } + + [Fact] + public void NullForRequiredNestedScalar_Passes() + { + // A PRESENT-but-null required field satisfies the type — only ABSENCE + // of a required field is an error (consistent with return-side policy). + const string body = """ + {"order":{ + "id":null, + "customer":{"name":"Acme"}, + "items":[] + }} + """; + + var result = ParameterValidator.Validate(Body(body), NestedDef); + Assert.True(result.IsValid); + } + + // ── Legacy flat-array backward-compat ────────────────────────────────────── + + [Fact] + public void LegacyFlatArrayDefinition_StillAccepted() + { + const string def = """[{"name":"count","type":"Integer","required":true}]"""; + + var ok = ParameterValidator.Validate(Body("{\"count\":5}"), def); + Assert.True(ok.IsValid); + Assert.Equal((long)5, ok.Parameters["count"]); + + var bad = ParameterValidator.Validate(Body("{\"count\":\"nope\"}"), def); + Assert.False(bad.IsValid); + Assert.Contains("'count'", bad.ErrorMessage); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ReturnValueValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ReturnValueValidatorTests.cs index 9b831b9e..eed2b7fe 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ReturnValueValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ReturnValueValidatorTests.cs @@ -1,13 +1,19 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// -/// InboundAPI-014: tests for return-value validation against a method's -/// ReturnDefinition. Previously the script's return value was serialized -/// verbatim with no checking against the declared return structure. +/// InboundAPI-014 / InboundAPI-M2.6: tests for return-value validation against a +/// method's ReturnDefinition. Mirrors +/// (shared recursive engine) — RECURSIVE nested Object / List-element type +/// validation with path-qualified errors. +/// +/// +/// Definitions are expressed as JSON Schema (the canonical persisted format); +/// the legacy flat-array form is still accepted (final region). +/// /// public class ReturnValueValidatorTests { - // --- No definition → no validation (backward compatible) --- + // ── No definition → no validation (backward compatible) ─────────────────── [Theory] [InlineData(null)] @@ -26,12 +32,17 @@ public class ReturnValueValidatorTests Assert.True(result.IsValid); } - // --- Happy path: result matches the declared field shape --- + // ── Happy path: result matches the declared object shape ────────────────── [Fact] public void ResultMatchingDefinition_IsValid() { - const string def = """[{"name":"siteName","type":"String"},{"name":"totalUnits","type":"Integer"}]"""; + const string def = """ + {"type":"object","properties":{ + "siteName":{"type":"string"}, + "totalUnits":{"type":"integer"} + },"required":["siteName","totalUnits"]} + """; const string json = """{"siteName":"Site Alpha","totalUnits":14250}"""; var result = ReturnValueValidator.Validate(json, def); @@ -40,22 +51,31 @@ public class ReturnValueValidatorTests } [Fact] - public void ResultWithListField_ShapeChecked_IsValid() + public void ResultWithListOfScalars_TypeChecked_IsValid() { - const string def = """[{"name":"lines","type":"List"}]"""; - const string json = """{"lines":[{"lineName":"Line-1","units":8200}]}"""; + const string def = """ + {"type":"object","properties":{ + "codes":{"type":"array","items":{"type":"integer"}} + }} + """; + const string json = """{"codes":[1,2,3]}"""; var result = ReturnValueValidator.Validate(json, def); Assert.True(result.IsValid); } - // --- Mismatches must be reported --- + // ── Scalar / shape mismatches must be reported ──────────────────────────── [Fact] public void ResultMissingDeclaredField_IsInvalid() { - const string def = """[{"name":"siteName","type":"String"},{"name":"totalUnits","type":"Integer"}]"""; + const string def = """ + {"type":"object","properties":{ + "siteName":{"type":"string"}, + "totalUnits":{"type":"integer"} + },"required":["siteName","totalUnits"]} + """; const string json = """{"siteName":"Site Alpha"}"""; var result = ReturnValueValidator.Validate(json, def); @@ -67,7 +87,7 @@ public class ReturnValueValidatorTests [Fact] public void ResultFieldWrongType_IsInvalid() { - const string def = """[{"name":"totalUnits","type":"Integer"}]"""; + const string def = """{"type":"object","properties":{"totalUnits":{"type":"integer"}},"required":["totalUnits"]}"""; const string json = """{"totalUnits":"not-a-number"}"""; var result = ReturnValueValidator.Validate(json, def); @@ -79,7 +99,7 @@ public class ReturnValueValidatorTests [Fact] public void NullResultWhenStructureRequired_IsInvalid() { - const string def = """[{"name":"siteName","type":"String"}]"""; + const string def = """{"type":"object","properties":{"siteName":{"type":"string"}},"required":["siteName"]}"""; var result = ReturnValueValidator.Validate(null, def); @@ -89,7 +109,7 @@ public class ReturnValueValidatorTests [Fact] public void NonObjectResultWhenStructureRequired_IsInvalid() { - const string def = """[{"name":"siteName","type":"String"}]"""; + const string def = """{"type":"object","properties":{"siteName":{"type":"string"}},"required":["siteName"]}"""; var result = ReturnValueValidator.Validate("42", def); @@ -99,7 +119,7 @@ public class ReturnValueValidatorTests [Fact] public void ListFieldGivenNonArray_IsInvalid() { - const string def = """[{"name":"lines","type":"List"}]"""; + const string def = """{"type":"object","properties":{"lines":{"type":"array","items":{"type":"object"}}}}"""; const string json = """{"lines":"not-a-list"}"""; var result = ReturnValueValidator.Validate(json, def); @@ -115,4 +135,173 @@ public class ReturnValueValidatorTests Assert.False(result.IsValid); } + + // ── Nested validation: the M2.6 core (production-report shape) ───────────── + + private const string ReportDef = """ + { + "type":"object", + "properties":{ + "siteName":{"type":"string"}, + "totalUnits":{"type":"integer"}, + "lines":{ + "type":"array", + "items":{ + "type":"object", + "properties":{ + "lineName":{"type":"string"}, + "units":{"type":"integer"}, + "efficiency":{"type":"number"} + }, + "required":["lineName","units"] + } + } + }, + "required":["siteName","totalUnits","lines"] + } + """; + + [Fact] + public void ValidNestedReturn_Passes() + { + const string json = """ + { + "siteName":"Site Alpha", + "totalUnits":14250, + "lines":[ + {"lineName":"Line-1","units":8200,"efficiency":92.5}, + {"lineName":"Line-2","units":6050,"efficiency":88.1} + ] + } + """; + + var result = ReturnValueValidator.Validate(json, ReportDef); + Assert.True(result.IsValid); + } + + [Fact] + public void WrongScalarInsideListElement_IsInvalid_WithElementIndexInPath() + { + // lines[1].units declared integer, given a string. + const string json = """ + { + "siteName":"Site Alpha", + "totalUnits":14250, + "lines":[ + {"lineName":"Line-1","units":8200}, + {"lineName":"Line-2","units":"lots"} + ] + } + """; + + var result = ReturnValueValidator.Validate(json, ReportDef); + Assert.False(result.IsValid); + Assert.Contains("'lines[1].units'", result.ErrorMessage); + Assert.Contains("Integer", result.ErrorMessage); + } + + [Fact] + public void WrongListElementType_IsInvalid_WithElementIndexInPath() + { + // lines[0] declared object, given a scalar. + const string json = """ + { + "siteName":"Site Alpha", + "totalUnits":14250, + "lines":[ 7 ] + } + """; + + var result = ReturnValueValidator.Validate(json, ReportDef); + Assert.False(result.IsValid); + Assert.Contains("'lines[0]'", result.ErrorMessage); + Assert.Contains("Object", result.ErrorMessage); + } + + [Fact] + public void MissingRequiredNestedField_IsInvalid_PathQualified() + { + // lines[0].units is required but absent. + const string json = """ + { + "siteName":"Site Alpha", + "totalUnits":14250, + "lines":[ {"lineName":"Line-1"} ] + } + """; + + var result = ReturnValueValidator.Validate(json, ReportDef); + Assert.False(result.IsValid); + Assert.Contains("missing required field", result.ErrorMessage); + Assert.Contains("'lines[0].units'", result.ErrorMessage); + } + + [Fact] + public void UndeclaredNestedField_IsInvalid_PathQualified() + { + // lines[0].bogus is not declared on the line-item schema. + const string json = """ + { + "siteName":"Site Alpha", + "totalUnits":14250, + "lines":[ {"lineName":"Line-1","units":1,"bogus":true} ] + } + """; + + var result = ReturnValueValidator.Validate(json, ReportDef); + Assert.False(result.IsValid); + Assert.Contains("'lines[0].bogus'", result.ErrorMessage); + Assert.Contains("not a declared field", result.ErrorMessage); + } + + // ── Empty / null edge cases ──────────────────────────────────────────────── + + [Fact] + public void EmptyListAgainstTypedElement_Passes() + { + const string json = """{"siteName":"S","totalUnits":0,"lines":[]}"""; + + var result = ReturnValueValidator.Validate(json, ReportDef); + Assert.True(result.IsValid); + } + + [Fact] + public void EmptyObjectSchema_AnythingIsValid() + { + const string def = """{"type":"object","properties":{}}"""; + + var result = ReturnValueValidator.Validate("""{"whatever":1}""", def); + Assert.True(result.IsValid); + } + + [Fact] + public void NullOptionalNestedScalar_Passes() + { + // lines[0].efficiency is optional; explicit null is accepted. + const string json = """ + { + "siteName":"S", + "totalUnits":1, + "lines":[ {"lineName":"L1","units":1,"efficiency":null} ] + } + """; + + var result = ReturnValueValidator.Validate(json, ReportDef); + Assert.True(result.IsValid); + } + + // ── Legacy flat-array backward-compat ────────────────────────────────────── + + [Fact] + public void LegacyFlatArrayDefinition_StillAccepted() + { + const string def = """[{"name":"siteName","type":"String"},{"name":"totalUnits","type":"Integer"}]"""; + + var ok = ReturnValueValidator.Validate("""{"siteName":"A","totalUnits":1}""", def); + Assert.True(ok.IsValid); + + var bad = ReturnValueValidator.Validate("""{"siteName":"A","totalUnits":"x"}""", def); + Assert.False(bad.IsValid); + Assert.Contains("totalUnits", bad.ErrorMessage); + } }