fix(inbound-api): M2.6 review nits — legacy required default, recursion depth guard, return-validator comment (#13)
- legacy flat-array "required":"false" (string) now treated as optional (matches migration) - depth ceiling (32) on InboundApiSchema Parse/Validate recursion — guards against stack-overflow from a deeply-nested stored schema (Parse throws->400, Validate adds error) - DocOptions.MaxDepth=128 so the application-level structural guard fires before the System.Text.Json reader ceiling (each schema level = ~3 JSON reader levels) - comment the intentional ParameterValidator/ReturnValueValidator early-return asymmetry - note intentional datetime->string legacy collapse in NormalizeType - tests: legacy string-false optional, parse/validate depth ceiling, scalar return schema
This commit is contained in:
@@ -39,7 +39,15 @@ public sealed class InboundApiSchema
|
||||
/// <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>Maximum allowed schema nesting depth for both Parse and Validate recursion.</summary>
|
||||
private const int MaxDepth = 32;
|
||||
|
||||
// Allow the JSON reader to parse schemas up to ~3× our structural ceiling so
|
||||
// the application-level ParseSchema depth guard (MaxDepth = 32) fires before
|
||||
// the System.Text.Json reader ceiling. Each structural level contributes
|
||||
// roughly 3 JSON-reader nesting levels (object → properties-object → value),
|
||||
// so 128 reader levels comfortably accommodates 32+ structural levels.
|
||||
private static readonly JsonDocumentOptions DocOptions = new() { MaxDepth = 128 };
|
||||
|
||||
/// <summary>
|
||||
/// Parses a stored definition string into an <see cref="InboundApiSchema"/>.
|
||||
@@ -51,7 +59,7 @@ public sealed class InboundApiSchema
|
||||
/// </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>
|
||||
/// <exception cref="JsonException">The input is non-empty but not valid JSON, is a JSON scalar/null at the root, or the schema nesting exceeds <see cref="MaxDepth"/>.</exception>
|
||||
public static InboundApiSchema? Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
@@ -62,14 +70,19 @@ public sealed class InboundApiSchema
|
||||
using var doc = JsonDocument.Parse(json, DocOptions);
|
||||
return doc.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => ParseSchema(doc.RootElement),
|
||||
JsonValueKind.Object => ParseSchema(doc.RootElement, depth: 0),
|
||||
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)
|
||||
private static InboundApiSchema ParseSchema(JsonElement el, int depth)
|
||||
{
|
||||
if (depth > MaxDepth)
|
||||
{
|
||||
throw new JsonException($"Schema nesting exceeds the maximum allowed depth of {MaxDepth}.");
|
||||
}
|
||||
|
||||
var type = el.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
|
||||
? NormalizeType(t.GetString())
|
||||
: "string";
|
||||
@@ -79,7 +92,7 @@ public sealed class InboundApiSchema
|
||||
InboundApiSchema? items = null;
|
||||
if (el.TryGetProperty("items", out var itemsEl) && itemsEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
items = ParseSchema(itemsEl);
|
||||
items = ParseSchema(itemsEl, depth + 1);
|
||||
}
|
||||
|
||||
return new InboundApiSchema { Type = "array", Items = items };
|
||||
@@ -109,7 +122,7 @@ public sealed class InboundApiSchema
|
||||
foreach (var prop in props.EnumerateObject())
|
||||
{
|
||||
var schema = prop.Value.ValueKind == JsonValueKind.Object
|
||||
? ParseSchema(prop.Value)
|
||||
? ParseSchema(prop.Value, depth + 1)
|
||||
: new InboundApiSchema { Type = "string" };
|
||||
fields.Add(new InboundApiSchemaField(prop.Name, requiredSet.Contains(prop.Name), schema));
|
||||
}
|
||||
@@ -142,7 +155,18 @@ public sealed class InboundApiSchema
|
||||
}
|
||||
|
||||
var rawType = TryGetMember(item, "type", out var t) ? t.GetString() : "string";
|
||||
var required = !TryGetMember(item, "required", out var rq) || rq.ValueKind != JsonValueKind.False;
|
||||
|
||||
// A field is optional only when "required" is explicitly false.
|
||||
// The SQL migration uses a string comparison (LOWER(...) <> 'false'),
|
||||
// so we must also accept the string "false" (case-insensitive) here —
|
||||
// not only the JSON boolean false — to stay consistent with legacy rows
|
||||
// that stored "required":"false" as a string.
|
||||
var required = !TryGetMember(item, "required", out var rq)
|
||||
|| (rq.ValueKind != JsonValueKind.False
|
||||
&& !string.Equals(
|
||||
rq.ValueKind == JsonValueKind.String ? rq.GetString() : null,
|
||||
"false",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var normalized = NormalizeType(rawType);
|
||||
InboundApiSchema schema;
|
||||
@@ -198,6 +222,9 @@ public sealed class InboundApiSchema
|
||||
"boolean" or "bool" => "boolean",
|
||||
"integer" or "int" or "int32" or "int64" => "integer",
|
||||
"number" or "float" or "double" or "decimal" => "number",
|
||||
// datetime→string is intentional: the legacy migration's SQL
|
||||
// normalization function maps "datetime" to "string" (no separate
|
||||
// datetime wire type in the extended type system), so C# must match.
|
||||
"string" or "datetime" => "string",
|
||||
"object" => "object",
|
||||
"array" or "list" => "array",
|
||||
@@ -215,9 +242,18 @@ public sealed class InboundApiSchema
|
||||
/// <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)
|
||||
=> ValidateCore(value, path, errors, depth: 0);
|
||||
|
||||
private void ValidateCore(JsonElement value, string path, List<string> errors, int depth)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(errors);
|
||||
|
||||
if (depth > MaxDepth)
|
||||
{
|
||||
errors.Add($"{Describe(path)}: schema nesting too deep (max {MaxDepth})");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -260,11 +296,11 @@ public sealed class InboundApiSchema
|
||||
break;
|
||||
|
||||
case "object":
|
||||
ValidateObject(value, path, errors);
|
||||
ValidateObject(value, path, errors, depth);
|
||||
break;
|
||||
|
||||
case "array":
|
||||
ValidateArray(value, path, errors);
|
||||
ValidateArray(value, path, errors, depth);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -273,7 +309,7 @@ public sealed class InboundApiSchema
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateObject(JsonElement value, string path, List<string> errors)
|
||||
private void ValidateObject(JsonElement value, string path, List<string> errors, int depth)
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
@@ -303,7 +339,7 @@ public sealed class InboundApiSchema
|
||||
var fieldPath = JoinField(path, field.Name);
|
||||
if (value.TryGetProperty(field.Name, out var fieldValue))
|
||||
{
|
||||
field.Schema.Validate(fieldValue, fieldPath, errors);
|
||||
field.Schema.ValidateCore(fieldValue, fieldPath, errors, depth + 1);
|
||||
}
|
||||
else if (field.Required)
|
||||
{
|
||||
@@ -312,7 +348,7 @@ public sealed class InboundApiSchema
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateArray(JsonElement value, string path, List<string> errors)
|
||||
private void ValidateArray(JsonElement value, string path, List<string> errors, int depth)
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
@@ -329,7 +365,7 @@ public sealed class InboundApiSchema
|
||||
var index = 0;
|
||||
foreach (var element in value.EnumerateArray())
|
||||
{
|
||||
Items.Validate(element, $"{path}[{index}]", errors);
|
||||
Items.ValidateCore(element, $"{path}[{index}]", errors, depth + 1);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,18 @@ public static class ReturnValueValidator
|
||||
return ReturnValidationResult.Valid();
|
||||
}
|
||||
|
||||
// INTENTIONAL asymmetry with ParameterValidator:
|
||||
//
|
||||
// ParameterValidator has an early-return guard for "schema.Type != object"
|
||||
// because method parameters are ALWAYS a top-level JSON object (flat map of
|
||||
// name→value); a non-object parameter schema is treated as unconstrained.
|
||||
//
|
||||
// ReturnValueValidator does NOT guard on schema.Type here. A method may
|
||||
// declare a scalar return type (e.g. {"type":"string"} or {"type":"integer"})
|
||||
// and the script is expected to return exactly that scalar JSON value.
|
||||
// Guarding on type == "object" would silently bypass validation for scalar
|
||||
// and array return schemas — do NOT add that guard here.
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resultJson))
|
||||
{
|
||||
return ReturnValidationResult.Invalid(
|
||||
|
||||
Reference in New Issue
Block a user