Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor
T
Joseph Doherty b5eb08f1a9 fix(flow-debugger): method pane takes structured parameters via ParameterValueForm + raw-JSON escape hatch (#37)
The Run-inbound-API-method pane rendered one text box per top-level schema
field and coerced the text on the page; an array/object field went through
JsonNode.Parse and, on failure, was SENT AS A STRING, while an empty box was
silently dropped. A required array-of-object parameter (MesMoveOut WorkOrders)
could not be entered at all — QA runs reached the site with "WorkOrders":[].

- The method pane now renders the same shared ParameterValueForm the script
  pane uses (typed inputs, nested groups, add/remove list item editors,
  $ref:lib: resolution, inline validation), keyed on the method id so a change
  of method remounts it. The page keeps ONE value tree and BuildParameters()
  serializes it as-is; the flat _inputs / Coerce() / SetInput path is deleted.
- Form | Raw JSON toggle: raw mode is a Monaco json editor for the whole
  request body, given the method's root schema for completion, parsed and
  schema-validated (InboundApiSchema.Validate) on every edit with the error
  inline. Entering raw mode seeds the editor from the tree; leaving it
  re-mounts the form over the last valid tree.
- Run is disabled while the form or the raw document is invalid (a required
  field left empty disables Run before the first keystroke).
- localStorage per-method entry stores the value tree; on restore it is
  validated against the schema and dropped if invalid, which also retires the
  pre-#37 flat string-per-field entries.
- ParameterValueForm.ToJsonSchema / JsonElementToObject made internal static
  for page reuse.
- Tests: 7 new bUnit tests (WorkOrders array-of-object round-trip to the
  RunMethodAsync payload, required list-item gating, invalid raw JSON blocks
  Run + shows parse/schema/non-object errors, valid raw JSON sent verbatim and
  survives the switch back to the form, method switch resets, persisted tree
  restored, legacy flat entry dropped); existing method-pane tests moved to
  the form's #param-<field> ids and fill the required field before Run.
  Playwright smoke selector moved to #param-Text.
- Docs: design doc, Component-CentralUI.md entry-points bullet, CLAUDE.md.

CentralUI.Tests 1425/1425 green; Playwright project builds.

Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
2026-09-10 14:54:21 -04:00

755 lines
30 KiB
Plaintext

@using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi
@using System.Text.Json
@inject ISchemaLibraryQueryService SchemaLibrary
@*
Renders an input row per declared parameter so the user can supply values
for a script test run, driven by the declared schema. Primitive types get
typed inputs (text / number / checkbox); OBJECT parameters render a labeled
group of per-field inputs (recursing for nested objects/lists) and ARRAY
parameters render an add/remove list of typed item editors. A
{"$ref":"lib:Name"} node resolves through ISchemaLibraryQueryService and
renders the referenced shape. Per-field validation surfaces inline via
InboundApiSchema.Validate. The emitted value shape is unchanged: scalars as
bool/long/double/string, objects as Dictionary<string,object?>, lists as
List<object?>. The companion SchemaBuilder edits the schema; this edits
values.
*@
@if (_topLevelFields.Count == 0)
{
<div class="text-muted small fst-italic">No parameters declared.</div>
}
else
{
<div class="d-flex flex-column gap-2">
@foreach (var field in _topLevelFields)
{
var shape = _shapes.FirstOrDefault(s => s.Name == field.Name);
<div class="row g-2 @(IsScalar(field.Schema) ? "align-items-center" : "align-items-start")">
<div class="col-sm-4">
<label class="form-label small mb-0" for="@FieldId(field.Name)">
<code>@field.Name</code>
<span class="text-muted ms-1">@(shape?.Type ?? DisplayType(field.Schema))@(field.Required ? "" : "?")</span>
</label>
</div>
<div class="col-sm-8">
@RenderNode(field.Schema, field.Name, FieldId(field.Name))
</div>
</div>
}
</div>
}
@code {
[Parameter] public string? ParameterDefinitions { get; set; }
[Parameter] public Dictionary<string, object?> Values { get; set; } = new();
[Parameter] public EventCallback<Dictionary<string, object?>> ValuesChanged { get; set; }
/// <summary>
/// Raised after every edit with <c>true</c> when the value tree has no parse or validation errors
/// (Flow Debugger script entry, design 2026-09-08 §5: the page gates Run on it). Additive; existing
/// callers ignore it.
/// </summary>
[Parameter] public EventCallback<bool> ValidChanged { get; set; }
private IReadOnlyList<ParameterShape> _shapes = Array.Empty<ParameterShape>();
private IReadOnlyList<InboundApiSchemaField> _topLevelFields = Array.Empty<InboundApiSchemaField>();
private InboundApiSchema? _rootSchema;
private string? _parsedFor;
// Raw text and per-field parse/validation errors keyed by canonical PATH
// (e.g. "order.id", "tags[0]") so nested inputs each carry their own state.
private readonly Dictionary<string, string> _rawText = new();
private readonly Dictionary<string, string> _parseErrors = new();
protected override async Task OnParametersSetAsync()
{
// Re-parse only when the declared schema text changes.
if (_parsedFor == ParameterDefinitions)
{
return;
}
_parsedFor = ParameterDefinitions;
_shapes = ScriptParameterNames.ParseShapes(ParameterDefinitions);
_rootSchema = await ParseRootAsync();
_topLevelFields = _rootSchema?.Type == "object" ? _rootSchema.Fields : Array.Empty<InboundApiSchemaField>();
// Report validity for the freshly-(re)parsed schema so a caller gating a Run
// button on ValidChanged never starts (or carries over from a prior script)
// a stale "valid" reading when required fields are unset. This deliberately
// does NOT call Validate()/populate _parseErrors: the component has no
// "untouched field" concept -- RenderError paints whatever is in
// _parseErrors unconditionally -- so doing that here would immediately
// paint every required field red before the user has touched anything. The
// silent check below computes the same answer without that side effect;
// _parseErrors (and the visible red text) still only appears once editing
// drives it through Emit()/Validate() as before.
await ValidChanged.InvokeAsync(ComputeIsValid());
}
// Mirrors Validate()'s schema pass (plus any raw-text parse markers already
// parked in _parseErrors) WITHOUT writing to _parseErrors, so it can run on
// every schema (re)parse without painting untouched required fields red.
private bool ComputeIsValid()
{
if (_parseErrors.Count > 0)
{
return false;
}
if (_rootSchema is null)
{
return true;
}
try
{
var element = JsonSerializer.SerializeToElement(Values);
var errors = new List<string>();
_rootSchema.Validate(element, string.Empty, errors);
return errors.Count == 0;
}
catch (JsonException)
{
return false;
}
}
/// <summary>
/// Serializes the escape-hatch FIELD's own (resolved) sub-schema to a JSON
/// Schema string for that field's Monaco editor — NOT the whole root object
/// schema. So a field's raw-JSON editor validates/completes against just that
/// field's portion of the type, not the entire object. Any
/// <c>{"$ref":"lib:Name"}</c> the parse already inlined is emitted as its
/// resolved shape; a node whose ref could NOT be resolved (Type
/// <c>"ref"</c>) — or any unknown type — has no derivable sub-schema and
/// collapses to a permissive <c>{}</c>, so the editor still validates
/// well-formed JSON without false errors. Cheap and on-demand: only the rare
/// escape-hatch nodes pay for it, and only at render time.
/// </summary>
private static string FieldSchemaJson(InboundApiSchema fieldSchema) => ToJsonSchema(fieldSchema);
/// <summary>
/// Serializes a (resolved) <see cref="InboundApiSchema"/> back to a JSON Schema
/// string for the escape-hatch Monaco editor. Any <c>{"$ref":"lib:Name"}</c> the
/// parse already inlined is emitted as its resolved shape (no <c>$ref</c> remains),
/// so Monaco's JSON language sees the full type. The shape-only / unresolved-ref
/// placeholder (Type not in the JSON Schema set) collapses to a permissive
/// <c>{}</c> so the editor still validates well-formed JSON without false errors.
/// </summary>
internal static string ToJsonSchema(InboundApiSchema schema)
{
using var stream = new System.IO.MemoryStream();
using (var writer = new System.Text.Json.Utf8JsonWriter(stream))
{
WriteSchema(writer, schema);
}
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
}
private static void WriteSchema(System.Text.Json.Utf8JsonWriter writer, InboundApiSchema schema)
{
switch (schema.Type)
{
case "boolean":
case "integer":
case "number":
case "string":
writer.WriteStartObject();
writer.WriteString("type", schema.Type);
writer.WriteEndObject();
break;
case "object":
writer.WriteStartObject();
writer.WriteString("type", "object");
writer.WritePropertyName("properties");
writer.WriteStartObject();
foreach (var field in schema.Fields)
{
writer.WritePropertyName(field.Name);
WriteSchema(writer, field.Schema);
}
writer.WriteEndObject();
var required = schema.Fields.Where(f => f.Required).Select(f => f.Name).ToList();
if (required.Count > 0)
{
writer.WritePropertyName("required");
writer.WriteStartArray();
foreach (var name in required)
{
writer.WriteStringValue(name);
}
writer.WriteEndArray();
}
writer.WriteEndObject();
break;
case "array":
writer.WriteStartObject();
writer.WriteString("type", "array");
if (schema.Items is not null)
{
writer.WritePropertyName("items");
WriteSchema(writer, schema.Items);
}
writer.WriteEndObject();
break;
default: // unresolved $ref / unknown — permissive (accept any JSON).
writer.WriteStartObject();
writer.WriteEndObject();
break;
}
}
/// <summary>
/// Parses the declared parameter definitions into an object schema, resolving
/// any {"$ref":"lib:Name"} nodes through the schema library so a referenced
/// shape renders its resolved fields. Library load is skipped entirely when
/// the definition contains no $ref token. Malformed schemas yield null (the
/// form then shows nothing, consistent with the lenient shape parser).
/// </summary>
private async Task<InboundApiSchema?> ParseRootAsync()
{
if (string.IsNullOrWhiteSpace(ParameterDefinitions))
{
return null;
}
Func<string, string?>? resolver = null;
if (InboundApiSchema.MightContainRef(ParameterDefinitions))
{
var map = await SchemaLibrary.GetSchemaMapAsync();
resolver = name => map.TryGetValue(name, out var json) ? json : null;
}
try
{
// Collect (not throw on) unresolved refs so a dangling reference still
// renders the rest of the form; the unresolved node falls through to
// the raw-JSON escape hatch.
return InboundApiSchema.ParseWithRefs(ParameterDefinitions, resolver).Schema;
}
catch (JsonException)
{
return null;
}
}
private static string FieldId(string path) => $"param-{path}";
private static bool IsScalar(InboundApiSchema schema) =>
schema.Type is "boolean" or "integer" or "number" or "string";
private static string DisplayType(InboundApiSchema schema) => schema.Type switch
{
"boolean" => "Boolean",
"integer" => "Integer",
"number" => "Float",
"string" => "String",
"object" => "Object",
"array" => schema.Items is null ? "List" : $"List<{DisplayType(schema.Items)}>",
_ => schema.Type,
};
// ---- Recursive node rendering ------------------------------------------
private RenderFragment RenderNode(InboundApiSchema schema, string path, string id) => __builder =>
{
switch (schema.Type)
{
case "boolean":
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@id"
checked="@AsBool(path)"
@onchange="e => SetBool(path, (bool)(e.Value ?? false))" />
</div>
break;
case "integer":
<input class="form-control form-control-sm" type="number" step="1" id="@id"
value="@AsRaw(path)"
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: true)" />
@RenderError(path)
break;
case "number":
<input class="form-control form-control-sm" type="number" step="any" id="@id"
value="@AsRaw(path)"
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: false)" />
@RenderError(path)
break;
case "string":
<input class="form-control form-control-sm" type="text" id="@id"
value="@AsRaw(path)"
@oninput="e => SetString(path, (string?)e.Value)" />
@RenderError(path)
break;
case "object":
@RenderObject(schema, path)
break;
case "array":
@RenderArray(schema, path)
break;
default: // unresolved $ref / unknown — raw-JSON escape hatch (Monaco).
<div class="param-json-editor" data-path="@path">
<MonacoEditor Language="json" Height="120px" ShowToolbar="false"
JsonSchema="@FieldSchemaJson(schema)"
Value="@AsRaw(path)"
ValueChanged="@(v => SetJson(path, v))" />
</div>
@RenderError(path)
break;
}
};
private RenderFragment RenderObject(InboundApiSchema schema, string path) => __builder =>
{
<div class="border-start ps-3 d-flex flex-column gap-2 param-object">
@foreach (var field in schema.Fields)
{
var childPath = JoinField(path, field.Name);
<div class="row g-2 @(IsScalar(field.Schema) ? "align-items-center" : "align-items-start")">
<div class="col-sm-4">
<label class="form-label small mb-0" for="@FieldId(childPath)">
<code>@field.Name</code>
<span class="text-muted ms-1">@DisplayType(field.Schema)@(field.Required ? "" : "?")</span>
</label>
</div>
<div class="col-sm-8">
@RenderNode(field.Schema, childPath, FieldId(childPath))
</div>
</div>
}
</div>
};
private RenderFragment RenderArray(InboundApiSchema schema, string path) => __builder =>
{
var count = ListCount(path);
var itemSchema = schema.Items ?? new InboundApiSchema { Type = "string" };
<div class="d-flex flex-column gap-2 param-list" data-path="@path">
@for (var i = 0; i < count; i++)
{
var index = i;
var itemPath = $"{path}[{index}]";
<div class="d-flex gap-2 align-items-start param-list-row">
<div class="flex-grow-1">
@RenderItem(itemSchema, itemPath)
</div>
<button type="button" class="btn btn-sm btn-outline-danger param-list-remove"
@onclick="() => RemoveListItem(path, index)" title="Remove item">&times;</button>
</div>
}
<div>
<button type="button" class="btn btn-sm btn-outline-secondary param-list-add"
@onclick="() => AddListItem(path, itemSchema)">+ Add item</button>
</div>
</div>
};
// Item editors mark their scalar input with the param-list-item class so the
// add/remove list rows are addressable in tests and styling.
private RenderFragment RenderItem(InboundApiSchema schema, string path) => __builder =>
{
switch (schema.Type)
{
case "boolean":
<div class="form-check">
<input class="form-check-input param-list-item" type="checkbox" id="@FieldId(path)"
checked="@AsBool(path)"
@onchange="e => SetBool(path, (bool)(e.Value ?? false))" />
</div>
break;
case "integer":
<input class="form-control form-control-sm param-list-item" type="number" step="1" id="@FieldId(path)"
value="@AsRaw(path)"
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: true)" />
@RenderError(path)
break;
case "number":
<input class="form-control form-control-sm param-list-item" type="number" step="any" id="@FieldId(path)"
value="@AsRaw(path)"
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: false)" />
@RenderError(path)
break;
case "string":
<input class="form-control form-control-sm param-list-item" type="text" id="@FieldId(path)"
value="@AsRaw(path)"
@oninput="e => SetString(path, (string?)e.Value)" />
@RenderError(path)
break;
default: // nested object/array/unknown item — recurse with the full renderer.
@RenderNode(schema, path, FieldId(path))
break;
}
};
private RenderFragment RenderError(string path) => __builder =>
{
if (_parseErrors.TryGetValue(path, out var err))
{
<div class="text-danger small mt-1">@err</div>
}
};
// ---- Path-addressed value tree -----------------------------------------
//
// The single source of truth is the Values dictionary holding the nested
// value tree: scalars as bool/long/double/string, objects as
// Dictionary<string,object?>, lists as List<object?> — exactly the shape the
// consuming pages serialize to JSON. Reads/writes navigate by canonical path
// ("a.b", "a.b[2]"); structural containers are created lazily on first write.
private record PathSeg(string? Field, int? Index);
private static IReadOnlyList<PathSeg> ParsePath(string path)
{
var segs = new List<PathSeg>();
var i = 0;
while (i < path.Length)
{
if (path[i] == '[')
{
var close = path.IndexOf(']', i);
var num = path.Substring(i + 1, close - i - 1);
segs.Add(new PathSeg(null, int.Parse(num, System.Globalization.CultureInfo.InvariantCulture)));
i = close + 1;
}
else
{
if (path[i] == '.') i++;
var start = i;
while (i < path.Length && path[i] != '.' && path[i] != '[') i++;
segs.Add(new PathSeg(path.Substring(start, i - start), null));
}
}
return segs;
}
private object? GetValue(string path)
{
object? node = Values;
foreach (var seg in ParsePath(path))
{
if (seg.Field is not null)
{
if (node is Dictionary<string, object?> dict && dict.TryGetValue(seg.Field, out var v)) node = v;
else return null;
}
else
{
if (node is List<object?> list && seg.Index!.Value >= 0 && seg.Index.Value < list.Count) node = list[seg.Index.Value];
else return null;
}
}
return node;
}
private void SetValue(string path, object? value)
{
var segs = ParsePath(path);
object? node = Values;
for (var s = 0; s < segs.Count; s++)
{
var seg = segs[s];
var last = s == segs.Count - 1;
if (seg.Field is not null)
{
var dict = (Dictionary<string, object?>)node!;
if (last) { dict[seg.Field] = value; return; }
if (dict.TryGetValue(seg.Field, out var child) && child is (Dictionary<string, object?> or List<object?>)) node = child;
else { var created = NextContainer(segs[s + 1]); dict[seg.Field] = created; node = created; }
}
else
{
var list = (List<object?>)node!;
var idx = seg.Index!.Value;
if (last) { while (list.Count <= idx) list.Add(null); list[idx] = value; return; }
if (idx < list.Count && list[idx] is (Dictionary<string, object?> or List<object?>)) node = list[idx];
else { while (list.Count <= idx) list.Add(null); var created = NextContainer(segs[s + 1]); list[idx] = created; node = created; }
}
}
}
private void RemoveValue(string path)
{
var segs = ParsePath(path);
object? node = Values;
for (var s = 0; s < segs.Count - 1; s++)
{
var seg = segs[s];
node = seg.Field is not null
? (node is Dictionary<string, object?> d && d.TryGetValue(seg.Field, out var v) ? v : null)
: (node is List<object?> l && seg.Index!.Value < l.Count ? l[seg.Index.Value] : null);
if (node is null) return;
}
var leaf = segs[^1];
if (leaf.Field is not null && node is Dictionary<string, object?> dict) dict.Remove(leaf.Field);
else if (node is List<object?> list && leaf.Index!.Value >= 0 && leaf.Index.Value < list.Count) list.RemoveAt(leaf.Index.Value);
}
private static object NextContainer(PathSeg next) =>
next.Field is not null ? new Dictionary<string, object?>() : new List<object?>();
// ---- List add/remove ---------------------------------------------------
private int ListCount(string path) => GetValue(path) is List<object?> list ? list.Count : 0;
private async Task AddListItem(string path, InboundApiSchema itemSchema)
{
if (GetValue(path) is not List<object?> list)
{
list = new List<object?>();
SetValue(path, list);
}
// Seed structural items with an empty container so their nested inputs render.
list.Add(itemSchema.Type switch
{
"object" => new Dictionary<string, object?>(),
"array" => new List<object?>(),
_ => null,
});
await Emit();
}
private async Task RemoveListItem(string path, int index)
{
if (GetValue(path) is List<object?> list && index >= 0 && index < list.Count)
{
list.RemoveAt(index);
// Re-index raw-text / error state for shifted item paths.
ShiftListState(path, index);
}
await Emit();
}
// After a removal at removedIndex, paths for items above that index shift down
// by one. Mirror the value-list RemoveAt by: (1) for each index > removedIndex
// found in a dict, re-key it as index-1; (2) clear the now-vacated top slot
// (the original highest index). Items below the removed index are unaffected.
private void ShiftListState(string path, int removedIndex)
{
ShiftDict(_rawText, path, removedIndex);
ShiftDict(_parseErrors, path, removedIndex);
}
private static void ShiftDict(Dictionary<string, string> dict, string path, int removedIndex)
{
var prefix = $"{path}[";
// Collect keys under this list prefix, parse the item index, and build the
// shift map in one pass so in-place mutation does not interfere with iteration.
var toShift = new List<(string Key, int ItemIndex, string Suffix)>();
foreach (var key in dict.Keys)
{
if (!key.StartsWith(prefix, StringComparison.Ordinal)) continue;
// Extract the integer after the '['.
var rest = key.AsSpan(prefix.Length); // e.g. "2]" or "2].field"
var closePos = rest.IndexOf(']');
if (closePos < 0) continue;
if (!int.TryParse(rest[..closePos], System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var idx)) continue;
// Only touch items ABOVE the removed index; items at lower indices are stable.
if (idx <= removedIndex) continue;
var suffix = rest[(closePos + 1)..].ToString(); // everything after ']'
toShift.Add((key, idx, suffix));
}
// Apply: remove old key, write shifted key. Higher indices first so we never
// overwrite a key that is itself still pending a shift (would happen if we
// iterated lowest-to-highest and the same path base appeared at consecutive
// indices). Processing highest → lowest avoids the collision.
foreach (var (key, idx, suffix) in toShift.OrderByDescending(t => t.ItemIndex))
{
var value = dict[key];
dict.Remove(key);
var shiftedKey = $"{path}[{idx - 1}]{suffix}";
dict[shiftedKey] = value;
}
}
// ---- Scalar setters -----------------------------------------------------
private string AsRaw(string path)
{
if (_rawText.TryGetValue(path, out var raw)) return raw;
var v = GetValue(path);
return v switch { null => "", bool b => b.ToString(), _ => Convert.ToString(v, System.Globalization.CultureInfo.InvariantCulture) ?? "" };
}
private bool AsBool(string path) => GetValue(path) is bool b && b;
private async Task SetString(string path, string? raw)
{
_rawText[path] = raw ?? "";
SetValue(path, raw ?? "");
await Emit();
}
private async Task SetBool(string path, bool value)
{
SetValue(path, value);
await Emit();
}
private async Task SetNumeric(string path, string? raw, bool integerOnly)
{
_rawText[path] = raw ?? "";
if (string.IsNullOrWhiteSpace(raw))
{
RemoveValue(path);
await Emit();
return;
}
if (integerOnly && long.TryParse(raw, out var i))
{
_parseErrors.Remove(path);
SetValue(path, i);
}
else if (!integerOnly && double.TryParse(raw,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var d))
{
_parseErrors.Remove(path);
SetValue(path, d);
}
else
{
// Park the unparseable raw text, drop the value, and mark the field. The
// "Not a valid" prefix tags this as a raw-parse marker so the schema
// validation pass leaves it intact.
_parseErrors[path] = integerOnly ? "Not a valid integer." : "Not a valid number.";
RemoveValue(path);
}
await Emit();
}
private async Task SetJson(string path, string? raw)
{
_rawText[path] = raw ?? "";
if (string.IsNullOrWhiteSpace(raw))
{
RemoveValue(path);
await Emit();
return;
}
try
{
using var doc = JsonDocument.Parse(raw);
_parseErrors.Remove(path);
SetValue(path, JsonElementToObject(doc.RootElement.Clone()));
}
catch (JsonException ex)
{
_parseErrors[path] = $"JSON parse error: {ex.Message}";
RemoveValue(path);
}
await Emit();
}
// ---- Emit + validate ----------------------------------------------------
private async Task Emit()
{
Validate();
await ValuesChanged.InvokeAsync(Values);
await ValidChanged.InvokeAsync(IsValid);
}
private bool IsValid => _parseErrors.Count == 0;
// Validates the whole value tree against the parsed schema and projects each
// path-qualified error onto the input that owns it. Raw-text parse failures
// (numeric / JSON) are added directly by the setters; schema validation adds
// type/required errors for the structured fields.
private void Validate()
{
// Clear schema-derived errors but keep raw-parse markers set this cycle.
foreach (var field in _topLevelFields)
{
ClearSchemaErrors(field.Name);
}
if (_rootSchema is null) return;
var errors = new List<string>();
try
{
var element = JsonSerializer.SerializeToElement(Values);
_rootSchema.Validate(element, string.Empty, errors);
}
catch (JsonException)
{
return;
}
foreach (var error in errors)
{
var path = ExtractPath(error);
if (path is not null && !_parseErrors.ContainsKey(path))
{
_parseErrors[path] = error;
}
}
}
// The validator quotes the offending path as 'a.b[2].c'; pull it back so the
// message can be attached to the input at that path. Returns null for
// whole-value / object-level messages with no quoted path.
private static string? ExtractPath(string message)
{
var open = message.IndexOf('\'');
if (open < 0) return null;
var close = message.IndexOf('\'', open + 1);
if (close < 0) return null;
var path = message.Substring(open + 1, close - open - 1);
return path.Length == 0 ? null : path;
}
private void ClearSchemaErrors(string topField)
{
foreach (var key in _parseErrors.Keys
.Where(k => k == topField || k.StartsWith(topField + ".", StringComparison.Ordinal) || k.StartsWith(topField + "[", StringComparison.Ordinal))
.ToList())
{
// Only drop schema-derived markers; raw-text parse markers carry the
// "Not a valid" / "JSON parse error" prefix and are owned by the setter.
var msg = _parseErrors[key];
if (!msg.StartsWith("Not a valid", StringComparison.Ordinal) && !msg.StartsWith("JSON parse error", StringComparison.Ordinal))
{
_parseErrors.Remove(key);
}
}
}
private static string JoinField(string path, string field) =>
string.IsNullOrEmpty(path) ? field : $"{path}.{field}";
internal static object? JsonElementToObject(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt64(out var i) ? (object)i : element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(),
JsonValueKind.Object => element.EnumerateObject()
.ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
_ => null
};
}
}