fix(commons): resolve Commons-013,014 — integral JSON index handling, distinguish Malformed vs Legacy OPC UA config
This commit is contained in:
@@ -68,9 +68,11 @@ public readonly record struct OpcUaConfigParseResult
|
||||
/// <summary>
|
||||
/// Serializes <see cref="OpcUaEndpointConfig"/> to/from the typed nested JSON
|
||||
/// shape stored in <c>DataConnection.PrimaryConfiguration</c> / <c>BackupConfiguration</c>.
|
||||
/// On read, falls back to the legacy flat string-dict shape for pre-refactor rows
|
||||
/// and reports <see cref="OpcUaConfigParseStatus.Legacy"/> so the form can prompt the
|
||||
/// user to re-save.
|
||||
/// On read, falls back to the legacy flat string-dict shape only for rows that are not
|
||||
/// the current typed shape (no <c>endpointUrl</c> property), reporting
|
||||
/// <see cref="OpcUaConfigParseStatus.Legacy"/> so the form can prompt the user to
|
||||
/// re-save. A row that <em>is</em> the typed shape but fails to deserialize is reported
|
||||
/// <see cref="OpcUaConfigParseStatus.Malformed"/>, never <see cref="OpcUaConfigParseStatus.Legacy"/>.
|
||||
/// </summary>
|
||||
public static class OpcUaEndpointConfigSerializer
|
||||
{
|
||||
@@ -94,9 +96,13 @@ public static class OpcUaEndpointConfigSerializer
|
||||
/// <item><see cref="OpcUaConfigParseStatus.Legacy"/> — parsed as a legacy flat object;
|
||||
/// the config is usable and the caller may prompt a re-save.</item>
|
||||
/// <item><see cref="OpcUaConfigParseStatus.Malformed"/> — the input is genuinely
|
||||
/// unparseable JSON. The config is an empty default and the original string is lost;
|
||||
/// the caller should surface an error rather than treating the empty config as the
|
||||
/// user's saved data.</item>
|
||||
/// unparseable JSON, <em>or</em> it is the current typed shape (it has an
|
||||
/// <c>endpointUrl</c> property) but typed deserialization failed — e.g. an
|
||||
/// enum-valued field holding an unrecognised string or a wrong-typed field. Such a
|
||||
/// corrupt typed row is reported <see cref="OpcUaConfigParseStatus.Malformed"/>
|
||||
/// rather than being mislabelled <see cref="OpcUaConfigParseStatus.Legacy"/>, so the
|
||||
/// offending field is not silently dropped. The config is an empty default and the
|
||||
/// caller should surface an error rather than treating it as the user's saved data.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static OpcUaConfigParseResult Deserialize(string? json)
|
||||
@@ -104,22 +110,42 @@ public static class OpcUaEndpointConfigSerializer
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return new OpcUaConfigParseResult(new OpcUaEndpointConfig(), OpcUaConfigParseStatus.Typed);
|
||||
|
||||
// First decide which shape the row is — without yet trying to materialize it.
|
||||
// A root JSON object carrying "endpointUrl" IS the current typed shape; anything
|
||||
// else (no endpointUrl) is treated as a candidate legacy flat-dict row.
|
||||
bool isTypedShape;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("endpointUrl", out _))
|
||||
isTypedShape = doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("endpointUrl", out _);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Could not even parse the document: genuinely malformed input.
|
||||
return new OpcUaConfigParseResult(new OpcUaEndpointConfig(), OpcUaConfigParseStatus.Malformed);
|
||||
}
|
||||
|
||||
if (isTypedShape)
|
||||
{
|
||||
// The row is the current typed shape. If typed deserialization fails the row
|
||||
// is a corrupt current-shape row (e.g. an invalid enum or wrong-typed field) —
|
||||
// it must NOT fall through to the legacy path and be mislabelled Legacy, which
|
||||
// would silently drop the offending field. Report Malformed instead.
|
||||
try
|
||||
{
|
||||
var typed = JsonSerializer.Deserialize<OpcUaEndpointConfig>(json, JsonOpts);
|
||||
if (typed != null)
|
||||
return new OpcUaConfigParseResult(typed, OpcUaConfigParseStatus.Typed);
|
||||
}
|
||||
catch (JsonException) { /* corrupt typed row — classified Malformed below */ }
|
||||
|
||||
return new OpcUaConfigParseResult(new OpcUaEndpointConfig(), OpcUaConfigParseStatus.Malformed);
|
||||
}
|
||||
catch (JsonException) { /* fall through to legacy */ }
|
||||
|
||||
try
|
||||
{
|
||||
return new OpcUaConfigParseResult(LoadLegacy(json!), OpcUaConfigParseStatus.Legacy);
|
||||
return new OpcUaConfigParseResult(LoadLegacy(json), OpcUaConfigParseStatus.Legacy);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace ScadaLink.Commons.Types;
|
||||
/// <summary>
|
||||
/// Wraps a JsonElement as a dynamic object for convenient property access in scripts.
|
||||
/// Supports property access (obj.name), indexing (obj.items[0]), and ToString().
|
||||
/// Array indexing accepts any integral index type (int, long, short, byte, ...), so an
|
||||
/// index derived from another wrapped JSON number — which Wrap surfaces as long — works.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The element passed to the constructor is <see cref="JsonElement.Clone()">cloned</see>
|
||||
@@ -39,13 +41,16 @@ public class DynamicJsonElement : DynamicObject
|
||||
|
||||
public override bool TryGetIndex(GetIndexBinder binder, object[] indexes, out object? result)
|
||||
{
|
||||
// Accept any integral index, not just int. DynamicJsonElement surfaces JSON
|
||||
// numbers as long (see Wrap), so an index computed from another wrapped value
|
||||
// (e.g. obj.items[obj.count - 1]) arrives as a long; byte/short widen too.
|
||||
if (_element.ValueKind == JsonValueKind.Array &&
|
||||
indexes.Length == 1 && indexes[0] is int index)
|
||||
indexes.Length == 1 && TryGetIntegralIndex(indexes[0], out var index))
|
||||
{
|
||||
var arrayLength = _element.GetArrayLength();
|
||||
if (index >= 0 && index < arrayLength)
|
||||
{
|
||||
result = Wrap(_element[index]);
|
||||
result = Wrap(_element[(int)index]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -53,6 +58,28 @@ public class DynamicJsonElement : DynamicObject
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetIntegralIndex(object? value, out long index)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case int i: index = i; return true;
|
||||
case long l: index = l; return true;
|
||||
case short s: index = s; return true;
|
||||
case byte b: index = b; return true;
|
||||
case sbyte sb: index = sb; return true;
|
||||
case ushort us: index = us; return true;
|
||||
case uint ui: index = ui; return true;
|
||||
case ulong ul when ul <= long.MaxValue: index = (long)ul; return true;
|
||||
// Whole-valued floating-point indices are accepted too: arithmetic on
|
||||
// wrapped JSON numbers can yield a double/decimal even for an integer result.
|
||||
case double d when d >= long.MinValue && d <= long.MaxValue && d == Math.Floor(d):
|
||||
index = (long)d; return true;
|
||||
case decimal m when m == Math.Floor(m):
|
||||
index = (long)m; return true;
|
||||
default: index = 0; return false;
|
||||
}
|
||||
}
|
||||
|
||||
public override bool TryConvert(ConvertBinder binder, out object? result)
|
||||
{
|
||||
// Conversion to object (or dynamic): never null out a present value. Return the
|
||||
@@ -102,11 +129,23 @@ public class DynamicJsonElement : DynamicObject
|
||||
JsonValueKind.Object => new DynamicJsonElement(element),
|
||||
JsonValueKind.Array => new DynamicJsonElement(element),
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
|
||||
JsonValueKind.Number => WrapNumber(element),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private static object WrapNumber(JsonElement element)
|
||||
{
|
||||
// An integral JSON number must box as long, not double. A ternary
|
||||
// (TryGetInt64 ? long : double) would unify both branches to double and
|
||||
// silently widen the long, so an integral index read back out of the wrapper
|
||||
// (e.g. obj.items[obj.count - 1]) would arrive as a double. Box each case
|
||||
// with its own typed return so the runtime type is preserved.
|
||||
if (element.TryGetInt64(out var l))
|
||||
return l;
|
||||
return element.GetDouble();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user