/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Opc.Ua.Client;
namespace Opc.Ua.Mcp
{
///
/// Converts a closed, AOT-safe set of typed workflow inputs before invoking any remote mutation.
///
internal static class WorkflowValues
{
///
/// Bounds complete typed batches before allocating UA update payloads or sending any operation.
///
/// A source-generated workflow input type.
///
internal static void ValidateInline(T value, WorkflowOptions options)
where T : class
{
ArgumentNullException.ThrowIfNull(value);
JsonTypeInfo contract = WorkflowInputJsonContext.Default.GetTypeInfo(typeof(T)) as JsonTypeInfo ??
throw new InvalidOperationException("The request type has no generated JSON contract.");
using var buffer = new McpBoundedMemoryStream(options.MaxInlineBytes);
JsonSerializer.Serialize(buffer, value, contract);
}
///
/// Parses built-in scalar and one-dimensional values without boxed Variant access.
///
///
internal static Variant Parse(ISession session, WorkflowValueInput input, WorkflowOptions options)
{
ArgumentNullException.ThrowIfNull(input);
if (Encoding.UTF8.GetByteCount(input.Value.GetRawText()) > options.MaxInlineBytes)
{
throw new ArgumentException("The typed value exceeds the host's inline byte ceiling.");
}
JsonElement value = input.Value;
if (value.ValueKind == JsonValueKind.Array)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(value.GetArrayLength(), options.MaxItems);
return ParseArray(session, input.Type, [.. value.EnumerateArray()], options);
}
return input.Type switch
{
BuiltInType.Null when value.ValueKind == JsonValueKind.Null => Variant.Null,
BuiltInType.Boolean => Variant.From(value.GetBoolean()),
BuiltInType.SByte => Variant.From(value.GetSByte()),
BuiltInType.Byte => Variant.From(value.GetByte()),
BuiltInType.Int16 => Variant.From(value.GetInt16()),
BuiltInType.UInt16 => Variant.From(value.GetUInt16()),
BuiltInType.Int32 => Variant.From(value.GetInt32()),
BuiltInType.UInt32 => Variant.From(value.GetUInt32()),
BuiltInType.Int64 => Variant.From(value.GetInt64()),
BuiltInType.UInt64 => Variant.From(value.GetUInt64()),
BuiltInType.Float => Variant.From((float)Number(value)),
BuiltInType.Double => Variant.From(Number(value)),
BuiltInType.String => Variant.From(String(value)),
BuiltInType.DateTime => Variant.From((DateTimeUtc)WorkflowJson.Utc(String(value))),
BuiltInType.Guid => Variant.From(new Uuid(Guid.Parse(String(value)))),
BuiltInType.ByteString => Variant.From(Bytes(String(value), options.MaxInlineBytes)),
BuiltInType.NodeId => Variant.From(WorkflowJson.Node(session, String(value))),
BuiltInType.StatusCode => Variant.From(new StatusCode(value.GetUInt32())),
BuiltInType.LocalizedText => Variant.From(new LocalizedText(String(value))),
_ => throw new ArgumentException("The scalar type is not supported by this typed contract.")
};
}
///
/// Assembles explicitly indexed array values without accepting nested or JSON-encoded payloads.
///
///
internal static Dictionary Arrays(
int valueCount, Func length, ArrayOf elements,
WorkflowOptions options)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(elements.Count, options.MaxItems);
int total = 0;
var sizes = new Dictionary();
for (int index = 0; index < valueCount; index++)
{
if (length(index) is not { } size)
{
continue;
}
ArgumentOutOfRangeException.ThrowIfNegative(size);
ArgumentOutOfRangeException.ThrowIfGreaterThan(size, options.MaxItems);
if (size > options.MaxItems - total)
{
throw new ArgumentException("The total array-element count exceeds the host's item ceiling.");
}
sizes.Add(index, size);
total += size;
}
if (total != elements.Count)
{
throw new ArgumentException("arrayElements must exactly fill every declared arrayLength.");
}
var arrays = new Dictionary();
foreach ((int index, int size) in sizes)
{
arrays.Add(index, new JsonElement[size]);
}
foreach (WorkflowArrayElementInput element in elements)
{
ArgumentNullException.ThrowIfNull(element);
if (!arrays.TryGetValue(element.ValueIndex, out JsonElement[]? array) ||
element.ElementIndex < 0 ||
element.ElementIndex >= array.Length ||
array[element.ElementIndex].ValueKind != JsonValueKind.Undefined)
{
throw new ArgumentException("An array element has an invalid or duplicate value/element index.");
}
Scalar(element.Value);
array[element.ElementIndex] = element.Value;
}
return arrays;
}
///
/// Interprets one flat declaration using its discriminator and, when present, indexed array elements.
///
///
internal static Variant Flat(
ISession session, BuiltInType type, JsonElement value, int index,
Dictionary arrays, WorkflowOptions options)
{
if (arrays.TryGetValue(index, out JsonElement[]? elements))
{
if (value.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null)
{
throw new ArgumentException("A declared array uses arrayElements, not a scalar value.");
}
return ParseArray(session, type, elements, options);
}
Scalar(value);
return Parse(session, new WorkflowValueInput { Type = type, Value = value }, options);
}
///
/// Decodes a bounded base64 identifier before allocating decoded storage.
///
///
internal static ByteString Bytes(string text, int maximum)
{
WorkflowJson.Text(text, checked((maximum + 2) / 3 * 4), empty: true);
byte[] bytes = Convert.FromBase64String(text);
if (bytes.Length > maximum)
{
throw new ArgumentException("The decoded byte string exceeds its host ceiling.");
}
return ByteString.From(bytes);
}
///
/// Converts exact event field selectors, preserving namespace URIs.
///
///
internal static EventFilter Filter(
ISession session, ArrayOf fields, ArrayOf paths = default)
{
ArgumentOutOfRangeException.ThrowIfLessThan(fields.Count, 1);
ArgumentOutOfRangeException.ThrowIfGreaterThan(fields.Count, 32);
ArgumentOutOfRangeException.ThrowIfGreaterThan(paths.Count, 256);
var segments = new Dictionary>();
foreach (WorkflowEventPathInput part in paths)
{
ArgumentNullException.ThrowIfNull(part);
if (part.SelectIndex < 0 ||
part.SelectIndex >= fields.Count ||
part.SegmentIndex < 0 ||
part.SegmentIndex >= 8)
{
throw new ArgumentException("An event path index is outside its selector or eight-segment limit.");
}
WorkflowJson.Text(part.Name, 256);
WorkflowJson.Text(part.NamespaceUri);
if (!segments.TryGetValue(part.SelectIndex, out Dictionary? selected))
{
selected = [];
segments.Add(part.SelectIndex, selected);
}
if (!selected.TryAdd(part.SegmentIndex, McpModelAccess.Name(session, part.NamespaceUri, part.Name)))
{
throw new ArgumentException("Event path segment indexes must be unique within each selector.");
}
}
var clauses = new SimpleAttributeOperand[fields.Count];
for (int index = 0; index < clauses.Length; index++)
{
WorkflowEventFieldInput input = fields[index];
ArgumentNullException.ThrowIfNull(input);
if (input.AttributeId is not Attributes.Value and not Attributes.NodeId)
{
throw new ArgumentException("An event field selects only Value or NodeId.");
}
ArrayOf path;
if (input.BrowseName is not null)
{
if (segments.ContainsKey(index))
{
throw new ArgumentException("Use a single browseName or indexed paths, not both.");
}
WorkflowJson.Text(input.BrowseName, 256);
string uri = input.NamespaceUri ?? Namespaces.OpcUa;
WorkflowJson.Text(uri);
path = [McpModelAccess.Name(session, uri, input.BrowseName)];
}
else
{
if (input.NamespaceUri is not null)
{
throw new ArgumentException("A selector namespaceUri requires its inline browseName.");
}
if (!segments.TryGetValue(index, out Dictionary? selected))
{
path = [];
}
else
{
var ordered = new QualifiedName[selected.Count];
for (int part = 0; part < ordered.Length; part++)
{
if (!selected.TryGetValue(part, out ordered[part]))
{
throw new ArgumentException("Event path segments must be contiguous from zero.");
}
}
path = ordered.ToArrayOf();
}
}
clauses[index] = new SimpleAttributeOperand
{
TypeDefinitionId = WorkflowJson.Node(session, input.TypeDefinitionId),
BrowsePath = path,
AttributeId = input.AttributeId
};
}
return new EventFilter { SelectClauses = clauses.ToArrayOf() };
}
///
/// Refuses hidden objects and nested arrays where the interface promises one actual JSON scalar.
///
///
private static void Scalar(JsonElement value)
{
if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Object or JsonValueKind.Array)
{
throw new ArgumentException("A field value must be an actual JSON scalar, not an object or array.");
}
}
///
/// Converts a bounded scalar array using the declared UA element type.
///
///
private static Variant ParseArray(
ISession session, BuiltInType type, JsonElement[] values, WorkflowOptions options)
{
return type switch
{
BuiltInType.Boolean => Variant.From(values.Select(element => element.GetBoolean()).ToArrayOf()),
BuiltInType.SByte => Variant.From(values.Select(element => element.GetSByte()).ToArrayOf()),
BuiltInType.Byte => Variant.From(values.Select(element => element.GetByte()).ToArrayOf()),
BuiltInType.Int16 => Variant.From(values.Select(element => element.GetInt16()).ToArrayOf()),
BuiltInType.UInt16 => Variant.From(values.Select(element => element.GetUInt16()).ToArrayOf()),
BuiltInType.Int32 => Variant.From(values.Select(element => element.GetInt32()).ToArrayOf()),
BuiltInType.UInt32 => Variant.From(values.Select(element => element.GetUInt32()).ToArrayOf()),
BuiltInType.Int64 => Variant.From(values.Select(element => element.GetInt64()).ToArrayOf()),
BuiltInType.UInt64 => Variant.From(values.Select(element => element.GetUInt64()).ToArrayOf()),
BuiltInType.Float => Variant.From(values.Select(element => (float)Number(element)).ToArrayOf()),
BuiltInType.Double => Variant.From(values.Select(Number).ToArrayOf()),
BuiltInType.String => Variant.From(values.Select(String).ToArrayOf()),
BuiltInType.DateTime => Variant.From(values.Select(element =>
(DateTimeUtc)WorkflowJson.Utc(String(element))).ToArrayOf()),
BuiltInType.NodeId => Variant.From(values.Select(element =>
WorkflowJson.Node(session, String(element))).ToArrayOf()),
BuiltInType.ByteString => Variant.From(values.Select(element =>
Bytes(String(element), options.MaxInlineBytes)).ToArrayOf()),
BuiltInType.Guid => Variant.From(values.Select(element =>
new Uuid(Guid.Parse(String(element)))).ToArrayOf()),
BuiltInType.StatusCode => Variant.From(values.Select(element =>
new StatusCode(element.GetUInt32())).ToArrayOf()),
BuiltInType.LocalizedText => Variant.From(values.Select(element =>
new LocalizedText(String(element))).ToArrayOf()),
_ => throw new ArgumentException("The array element type is not supported by this typed contract.")
};
}
///
/// Requires a JSON string rather than silently coercing another value kind.
///
///
private static string String(JsonElement value)
{
return value.GetString() ?? throw new ArgumentException("A non-null string value is required.");
}
///
/// Accepts the standard UA JSON tokens for non-finite IEEE values, but not general string coercion.
///
///
private static double Number(JsonElement value)
{
return value.ValueKind == JsonValueKind.String ? value.GetString() switch
{
"NaN" => double.NaN,
"Infinity" => double.PositiveInfinity,
"-Infinity" => double.NegativeInfinity,
_ => throw new ArgumentException("A floating value is a JSON number or NaN/Infinity/-Infinity.")
} : value.GetDouble();
}
}
}