fix(commons): resolve Commons-005..007,009..012 — OPC UA parse status, TryConvert correctness, Result null guard, invariant formatting, doc refresh
This commit is contained in:
@@ -4,11 +4,73 @@ using ScadaLink.Commons.Types.DataConnections;
|
||||
|
||||
namespace ScadaLink.Commons.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome classification for <see cref="OpcUaEndpointConfigSerializer.Deserialize"/>.
|
||||
/// </summary>
|
||||
public enum OpcUaConfigParseStatus
|
||||
{
|
||||
/// <summary>The stored JSON parsed cleanly as the current typed shape.</summary>
|
||||
Typed,
|
||||
|
||||
/// <summary>
|
||||
/// The stored JSON parsed as the legacy flat string-dict shape. The returned
|
||||
/// config is usable; the caller may prompt the user to re-save in the new shape.
|
||||
/// </summary>
|
||||
Legacy,
|
||||
|
||||
/// <summary>
|
||||
/// The stored JSON could not be parsed at all (genuinely malformed). The returned
|
||||
/// config is an empty default and the original string was lost — the caller should
|
||||
/// surface an error rather than presenting the empty config as the user's data.
|
||||
/// </summary>
|
||||
Malformed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="OpcUaEndpointConfigSerializer.Deserialize"/>. Carries the parsed
|
||||
/// config plus an explicit <see cref="Status"/> distinguishing a recoverable legacy row
|
||||
/// from genuinely unparseable input. Deconstructs into <c>(Config, IsLegacy)</c> for
|
||||
/// backward compatibility with callers that only need those two values.
|
||||
/// </summary>
|
||||
public readonly record struct OpcUaConfigParseResult
|
||||
{
|
||||
public OpcUaConfigParseResult(OpcUaEndpointConfig config, OpcUaConfigParseStatus status)
|
||||
{
|
||||
Config = config;
|
||||
Status = status;
|
||||
}
|
||||
|
||||
/// <summary>The parsed config (an empty default when <see cref="Status"/> is Malformed).</summary>
|
||||
public OpcUaEndpointConfig Config { get; }
|
||||
|
||||
/// <summary>Classification of the parse outcome.</summary>
|
||||
public OpcUaConfigParseStatus Status { get; }
|
||||
|
||||
/// <summary>True when the source parsed as the legacy flat-dict shape.</summary>
|
||||
public bool IsLegacy => Status == OpcUaConfigParseStatus.Legacy;
|
||||
|
||||
/// <summary>True when the source could not be parsed at all.</summary>
|
||||
public bool IsMalformed => Status == OpcUaConfigParseStatus.Malformed;
|
||||
|
||||
/// <summary>
|
||||
/// Two-element deconstruction kept for backward compatibility. Note that
|
||||
/// <c>IsLegacy</c> is <c>false</c> for both <see cref="OpcUaConfigParseStatus.Typed"/>
|
||||
/// and <see cref="OpcUaConfigParseStatus.Malformed"/>; callers that need to tell those
|
||||
/// apart should read <see cref="Status"/> directly.
|
||||
/// </summary>
|
||||
public void Deconstruct(out OpcUaEndpointConfig config, out bool isLegacy)
|
||||
{
|
||||
config = Config;
|
||||
isLegacy = IsLegacy;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 returns IsLegacy=true so the form can prompt the user to re-save.
|
||||
/// and reports <see cref="OpcUaConfigParseStatus.Legacy"/> so the form can prompt the
|
||||
/// user to re-save.
|
||||
/// </summary>
|
||||
public static class OpcUaEndpointConfigSerializer
|
||||
{
|
||||
@@ -22,10 +84,25 @@ public static class OpcUaEndpointConfigSerializer
|
||||
public static string Serialize(OpcUaEndpointConfig config)
|
||||
=> JsonSerializer.Serialize(config, JsonOpts);
|
||||
|
||||
public static (OpcUaEndpointConfig Config, bool IsLegacy) Deserialize(string? json)
|
||||
/// <summary>
|
||||
/// Parses stored OPC UA endpoint JSON. Tries the current typed shape first, then the
|
||||
/// legacy flat string-dict shape. The returned <see cref="OpcUaConfigParseResult.Status"/>
|
||||
/// distinguishes three outcomes:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="OpcUaConfigParseStatus.Typed"/> — clean parse of the current shape
|
||||
/// (also returned for null/blank input, which yields a default config).</item>
|
||||
/// <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>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static OpcUaConfigParseResult Deserialize(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return (new OpcUaEndpointConfig(), false);
|
||||
return new OpcUaConfigParseResult(new OpcUaEndpointConfig(), OpcUaConfigParseStatus.Typed);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -35,18 +112,21 @@ public static class OpcUaEndpointConfigSerializer
|
||||
{
|
||||
var typed = JsonSerializer.Deserialize<OpcUaEndpointConfig>(json, JsonOpts);
|
||||
if (typed != null)
|
||||
return (typed, false);
|
||||
return new OpcUaConfigParseResult(typed, OpcUaConfigParseStatus.Typed);
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* fall through to legacy */ }
|
||||
|
||||
try
|
||||
{
|
||||
return (LoadLegacy(json!), IsLegacy: true);
|
||||
return new OpcUaConfigParseResult(LoadLegacy(json!), OpcUaConfigParseStatus.Legacy);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return (new OpcUaEndpointConfig(), IsLegacy: true);
|
||||
// Genuinely malformed input: not a recoverable legacy row. Report Malformed
|
||||
// (not Legacy) so the caller can surface an error instead of presenting an
|
||||
// empty config as if it were the user's saved configuration.
|
||||
return new OpcUaConfigParseResult(new OpcUaEndpointConfig(), OpcUaConfigParseStatus.Malformed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,8 +55,19 @@ public class DynamicJsonElement : DynamicObject
|
||||
|
||||
public override bool TryConvert(ConvertBinder binder, out object? result)
|
||||
{
|
||||
// Conversion to object (or dynamic): never null out a present value. Return the
|
||||
// unwrapped value for scalars, this wrapper for objects/arrays, and null only
|
||||
// when the element is genuinely JSON null.
|
||||
if (binder.Type == typeof(object))
|
||||
{
|
||||
result = _element.ValueKind == JsonValueKind.Null ? null : Wrap(_element);
|
||||
return true;
|
||||
}
|
||||
|
||||
result = ConvertTo(binder.Type);
|
||||
return result != null || binder.Type == typeof(object);
|
||||
// A non-object target with a null result means ConvertTo could not handle the
|
||||
// element/type pair — report failure so the binder surfaces a binding error.
|
||||
return result != null;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
|
||||
@@ -14,6 +14,9 @@ public sealed class Result<T>
|
||||
|
||||
private Result(string error)
|
||||
{
|
||||
// A failed Result must always carry a usable message — Result is the
|
||||
// system-wide error-handling type, and consumers log/display Error directly.
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(error);
|
||||
_value = default;
|
||||
_error = error;
|
||||
IsSuccess = false;
|
||||
@@ -33,6 +36,11 @@ public sealed class Result<T>
|
||||
|
||||
public static Result<T> Success(T value) => new(value);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result carrying the given error message.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="error"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="error"/> is empty or whitespace.</exception>
|
||||
public static Result<T> Failure(string error) => new(error);
|
||||
|
||||
public TResult Match<TResult>(Func<T, TResult> onSuccess, Func<string, TResult> onFailure) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
|
||||
namespace ScadaLink.Commons.Types;
|
||||
|
||||
@@ -9,21 +10,36 @@ namespace ScadaLink.Commons.Types;
|
||||
public static class ValueFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Formats a value for display as a string. Returns the value's natural
|
||||
/// string representation for scalars, and comma-separated elements for
|
||||
/// array/collection types.
|
||||
/// Formats a value as a string. Returns the value's string representation for
|
||||
/// scalars and comma-separated elements for array/collection types.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Formatting is <see cref="CultureInfo.InvariantCulture">culture-invariant</see>:
|
||||
/// numbers and <see cref="DateTime"/> values render the same regardless of the
|
||||
/// server/thread locale. This is required because the formatter feeds non-UI
|
||||
/// contexts (gRPC stream events, logs, diff display) where locale-dependent
|
||||
/// output (decimal separators, date order) would be inconsistent.
|
||||
/// </remarks>
|
||||
public static string FormatDisplayValue(object? value)
|
||||
{
|
||||
if (value is null) return "";
|
||||
if (value is string s) return s;
|
||||
if (value is IFormattable) return value.ToString() ?? "";
|
||||
if (value is IFormattable formattable)
|
||||
return formattable.ToString(null, CultureInfo.InvariantCulture) ?? "";
|
||||
|
||||
if (value is IEnumerable enumerable)
|
||||
{
|
||||
return string.Join(",", enumerable.Cast<object?>().Select(e => e?.ToString() ?? ""));
|
||||
return string.Join(",", enumerable.Cast<object?>().Select(FormatElement));
|
||||
}
|
||||
|
||||
return value.ToString() ?? "";
|
||||
}
|
||||
|
||||
private static string FormatElement(object? element) => element switch
|
||||
{
|
||||
null => "",
|
||||
string str => str,
|
||||
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture) ?? "",
|
||||
_ => element.ToString() ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user