feat(scripts): add typed Parameters.Get<T>() helpers for script API

Replace raw dictionary casting with ScriptParameters wrapper that provides
Get<T>, Get<T?>, Get<T[]>, and Get<List<T>> with clear error messages,
numeric conversion, and JsonElement support for Inbound API parameters.
This commit is contained in:
Joseph Doherty
2026-03-22 15:47:18 -04:00
parent a0e036fb6b
commit 161dc406ed
10 changed files with 672 additions and 9 deletions

View File

@@ -0,0 +1,242 @@
using System.Collections;
using System.Globalization;
using System.Text.Json;
namespace ScadaLink.Commons.Types;
/// <summary>
/// Typed wrapper around script parameters. Implements IReadOnlyDictionary for backward
/// compatibility (Parameters["key"]) and adds Get&lt;T&gt;() for typed access with
/// clear error messages.
/// </summary>
public class ScriptParameters : IReadOnlyDictionary<string, object?>
{
private readonly IReadOnlyDictionary<string, object?> _inner;
public ScriptParameters(IReadOnlyDictionary<string, object?> parameters)
{
_inner = parameters ?? throw new ArgumentNullException(nameof(parameters));
}
public ScriptParameters() : this(new Dictionary<string, object?>()) { }
/// <summary>
/// Gets a parameter value with typed conversion.
/// <list type="bullet">
/// <item><c>Get&lt;int&gt;("key")</c> — throws if missing, null, or unconvertible.</item>
/// <item><c>Get&lt;int?&gt;("key")</c> — returns null if missing, null, or unconvertible.</item>
/// <item><c>Get&lt;int[]&gt;("key")</c> — converts list to typed array; throws on first bad element.</item>
/// <item><c>Get&lt;List&lt;int&gt;&gt;("key")</c> — converts list to typed List; throws on first bad element.</item>
/// </list>
/// </summary>
public T Get<T>(string key)
{
var targetType = typeof(T);
// Array types: int[], string[], etc.
if (targetType.IsArray && targetType != typeof(string))
{
var elementType = targetType.GetElementType()!;
return (T)(object)ConvertToArray(key, elementType);
}
// List<T> types
if (targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(List<>))
{
var elementType = targetType.GetGenericArguments()[0];
return (T)ConvertToList(key, elementType);
}
// Nullable<T> types: int?, bool?, etc.
var underlyingType = Nullable.GetUnderlyingType(targetType);
if (underlyingType != null)
{
return GetNullable<T>(key, underlyingType);
}
// Non-nullable scalar types
return GetRequired<T>(key, targetType);
}
private T GetRequired<T>(string key, Type targetType)
{
if (!_inner.TryGetValue(key, out var value))
throw new ScriptParameterException($"Parameter '{key}' not found");
if (value is null)
throw new ScriptParameterException($"Parameter '{key}' value is null");
return (T)ConvertScalar(value, targetType, key);
}
private T GetNullable<T>(string key, Type underlyingType)
{
if (!_inner.TryGetValue(key, out var value) || value is null)
return default!; // null for Nullable<T>
try
{
var converted = ConvertScalar(value, underlyingType, key);
return (T)converted;
}
catch (ScriptParameterException)
{
return default!; // null on conversion failure for nullable
}
}
private Array ConvertToArray(string key, Type elementType)
{
var list = GetSourceList(key);
var array = Array.CreateInstance(elementType, list.Count);
for (var i = 0; i < list.Count; i++)
{
var item = list[i];
if (item is null)
throw new ScriptParameterException(
$"Parameter '{key}' element at index {i} is null");
try
{
array.SetValue(ConvertScalar(item, elementType, key), i);
}
catch (ScriptParameterException)
{
throw new ScriptParameterException(
$"Parameter '{key}' element at index {i} with value '{item}' could not be parsed as {elementType.Name}");
}
}
return array;
}
private object ConvertToList(string key, Type elementType)
{
var list = GetSourceList(key);
var listType = typeof(List<>).MakeGenericType(elementType);
var result = (IList)Activator.CreateInstance(listType, list.Count)!;
for (var i = 0; i < list.Count; i++)
{
var item = list[i];
if (item is null)
throw new ScriptParameterException(
$"Parameter '{key}' element at index {i} is null");
try
{
result.Add(ConvertScalar(item, elementType, key));
}
catch (ScriptParameterException)
{
throw new ScriptParameterException(
$"Parameter '{key}' element at index {i} with value '{item}' could not be parsed as {elementType.Name}");
}
}
return result;
}
private IList GetSourceList(string key)
{
if (!_inner.TryGetValue(key, out var value))
throw new ScriptParameterException($"Parameter '{key}' not found");
if (value is null)
throw new ScriptParameterException($"Parameter '{key}' value is null");
if (value is IList list)
return list;
throw new ScriptParameterException($"Parameter '{key}' is not a list or array");
}
private static object ConvertScalar(object value, Type targetType, string key)
{
// Direct type match
if (targetType.IsInstanceOfType(value))
return value;
// Unwrap JsonElement (from JSON deserialization of List<object?> / Dictionary<string, object?>)
if (value is JsonElement je)
return ConvertJsonElement(je, targetType, key);
// string target — use ToString
if (targetType == typeof(string))
return value.ToString() ?? string.Empty;
// DateTime from string
if (targetType == typeof(DateTime) && value is string dateStr)
{
if (DateTime.TryParse(dateStr, CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind, out var dt))
return dt;
throw new ScriptParameterException(
$"Parameter '{key}' with value '{value}' could not be parsed as DateTime");
}
// Numeric and other IConvertible conversions
try
{
return Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture);
}
catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
{
throw new ScriptParameterException(
$"Parameter '{key}' with value '{value}' could not be parsed as {targetType.Name}");
}
}
private static object ConvertJsonElement(JsonElement element, Type targetType, string key)
{
try
{
if (targetType == typeof(bool) && element.ValueKind is JsonValueKind.True or JsonValueKind.False)
return element.GetBoolean();
if (targetType == typeof(int) && element.ValueKind == JsonValueKind.Number)
return element.GetInt32();
if (targetType == typeof(long) && element.ValueKind == JsonValueKind.Number)
return element.GetInt64();
if (targetType == typeof(float) && element.ValueKind == JsonValueKind.Number)
return element.GetSingle();
if (targetType == typeof(double) && element.ValueKind == JsonValueKind.Number)
return element.GetDouble();
if (targetType == typeof(string) && element.ValueKind == JsonValueKind.String)
return element.GetString()!;
if (targetType == typeof(string))
return element.ToString();
if (targetType == typeof(DateTime) && element.ValueKind == JsonValueKind.String)
{
if (DateTime.TryParse(element.GetString(), CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind, out var dt))
return dt;
}
}
catch (Exception ex) when (ex is FormatException or OverflowException or InvalidOperationException)
{
// Fall through to error
}
throw new ScriptParameterException(
$"Parameter '{key}' with value '{element}' could not be parsed as {targetType.Name}");
}
// IReadOnlyDictionary<string, object?> implementation
public object? this[string key] => _inner[key];
public IEnumerable<string> Keys => _inner.Keys;
public IEnumerable<object?> Values => _inner.Values;
public int Count => _inner.Count;
public bool ContainsKey(string key) => _inner.ContainsKey(key);
public bool TryGetValue(string key, out object? value) => _inner.TryGetValue(key, out value);
public IEnumerator<KeyValuePair<string, object?>> GetEnumerator() => _inner.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
/// <summary>
/// Exception thrown by <see cref="ScriptParameters.Get{T}"/> when a parameter
/// cannot be retrieved or converted to the requested type.
/// </summary>
public class ScriptParameterException : Exception
{
public ScriptParameterException(string message) : base(message) { }
public ScriptParameterException(string message, Exception innerException)
: base(message, innerException) { }
}

View File

@@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Types;
namespace ScadaLink.InboundAPI;
@@ -58,6 +59,7 @@ public class InboundScriptExecutor
typeof(Enumerable).Assembly,
typeof(Dictionary<,>).Assembly,
typeof(RouteHelper).Assembly,
typeof(ScriptParameters).Assembly,
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly)
.WithImports(
"System",
@@ -152,7 +154,7 @@ public class InboundScriptExecutor
/// </summary>
public class InboundScriptContext
{
public IReadOnlyDictionary<string, object?> Parameters { get; }
public ScriptParameters Parameters { get; }
public RouteHelper Route { get; }
public CancellationToken CancellationToken { get; }
@@ -161,7 +163,7 @@ public class InboundScriptContext
RouteHelper route,
CancellationToken cancellationToken = default)
{
Parameters = parameters;
Parameters = new ScriptParameters(parameters);
Route = route;
CancellationToken = cancellationToken;
}

View File

@@ -1,6 +1,7 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Types;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.SiteRuntime.Actors;
@@ -64,7 +65,7 @@ public class AlarmExecutionActor : ReceiveActor
var globals = new ScriptGlobals
{
Instance = context,
Parameters = new Dictionary<string, object?>(),
Parameters = new ScriptParameters(),
CancellationToken = cts.Token
};

View File

@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Types;
using ScadaLink.HealthMonitoring;
using ScadaLink.SiteRuntime.Scripts;
@@ -100,7 +101,7 @@ public class ScriptExecutionActor : ReceiveActor
var globals = new ScriptGlobals
{
Instance = context,
Parameters = parameters ?? new Dictionary<string, object?>(),
Parameters = new ScriptParameters(parameters ?? new Dictionary<string, object?>()),
CancellationToken = cts.Token
};

View File

@@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Types;
namespace ScadaLink.SiteRuntime.Scripts;
@@ -177,8 +178,7 @@ public class ScriptCompilationResult
public class ScriptGlobals
{
public ScriptRuntimeContext Instance { get; set; } = null!;
public IReadOnlyDictionary<string, object?> Parameters { get; set; } =
new Dictionary<string, object?>();
public ScriptParameters Parameters { get; set; } = new ScriptParameters();
public CancellationToken CancellationToken { get; set; }
/// <summary>

View File

@@ -1,5 +1,6 @@
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Types;
namespace ScadaLink.SiteRuntime.Scripts;
@@ -82,7 +83,7 @@ public class SharedScriptLibrary
var globals = new ScriptGlobals
{
Instance = context,
Parameters = parameters ?? new Dictionary<string, object?>(),
Parameters = new ScriptParameters(parameters ?? new Dictionary<string, object?>()),
CancellationToken = cancellationToken
};