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:
@@ -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<ParameterDefinition>
|
||||
public string? ReturnDefinition { get; set; } // JSON: List<ReturnFieldDefinition>
|
||||
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<string, object?>` |
|
||||
| `List` | JSON array | `List<object?>` |
|
||||
| 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<string, object?>` |
|
||||
| `List` | `array` | JSON array | `List<object?>` |
|
||||
|
||||
`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
|
||||
|
||||
|
||||
@@ -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<List<…>>` 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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,10 +3,27 @@ using System.Text.Json;
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<Dictionary<string, object?>>(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<List<object?>>(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<Dictionary<string, object?>>(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<List<object?>>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-014: tests for return-value validation against a method's
|
||||
/// <c>ReturnDefinition</c>. 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 <c>ReturnDefinition</c>. Mirrors <see cref="ParameterValidatorTests"/>
|
||||
/// (shared recursive engine) — RECURSIVE nested Object / List-element type
|
||||
/// validation with path-qualified errors.
|
||||
///
|
||||
/// <para>
|
||||
/// Definitions are expressed as JSON Schema (the canonical persisted format);
|
||||
/// the legacy flat-array form is still accepted (final region).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user