Add typed IVariableBuilder<T> fluent surface

Adds typed sub-interfaces and impls so the existing fluent surface

can expose simple Func<T> / Action<T> / Func<CancellationToken,

ValueTask<T>> overloads for variables — without losing the lower-level

Variant-based overloads on INodeBuilder.

* IVariableBuilder<TValue> + VariableBuilder<TValue> derive from

  INodeBuilder<BaseVariableState> / NodeBuilder<BaseVariableState>

  (NodeBuilder<TState> unsealed).

* Marshalling uses Variant.AsBoxedObject(BoxingBehavior.Legacy) for

  reads and Variant(object) for writes; the AOT-unsafe write path is

  scoped-suppressed with a TODO pointing at the planned per-type

  generated walker.

* INodeManagerBuilder gains Variable<T>(string),

  Variable<T>(NodeId), VariableFromTypeId<T>(NodeId),

  VariableFromTypeId<T>(NodeId, QualifiedName).

Server library builds clean across all 6 TFMs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot
2026-05-12 17:06:25 +02:00
parent 2e2efb4ad9
commit e557ecdd9c
5 changed files with 472 additions and 1 deletions
@@ -198,5 +198,43 @@ namespace Opc.Ua.Server.Fluent
/// <typeparam name="TState">See <see cref="NodeFromTypeId{TState}(NodeId)"/>.</typeparam>
INodeBuilder<TState> NodeFromTypeId<TState>(NodeId typeDefinitionId, QualifiedName browseName)
where TState : NodeState;
/// <summary>
/// Resolves a variable node by browse path and returns a typed
/// <see cref="IVariableBuilder{TValue}"/> view that exposes
/// simple <c>Func</c> / <c>Action</c> shaped
/// <c>OnRead</c>/<c>OnWrite</c> overloads.
/// </summary>
/// <typeparam name="TValue">
/// CLR type carried by the variable's <c>Value</c> attribute.
/// </typeparam>
/// <param name="browsePath">See <see cref="Node(string)"/>.</param>
/// <exception cref="ServiceResultException">
/// Thrown if the path does not resolve, or resolves to a node
/// that is not a <see cref="BaseVariableState"/>.
/// </exception>
IVariableBuilder<TValue> Variable<TValue>(string browsePath);
/// <summary>
/// Resolves a variable node by absolute <see cref="NodeId"/>
/// and returns a typed <see cref="IVariableBuilder{TValue}"/>
/// view.
/// </summary>
IVariableBuilder<TValue> Variable<TValue>(NodeId nodeId);
/// <summary>
/// Resolves the unique variable instance whose
/// <c>TypeDefinitionId</c> matches <paramref name="typeDefinitionId"/>
/// and returns a typed <see cref="IVariableBuilder{TValue}"/>
/// view. Same disambiguation semantics as
/// <see cref="NodeFromTypeId(NodeId)"/>.
/// </summary>
IVariableBuilder<TValue> VariableFromTypeId<TValue>(NodeId typeDefinitionId);
/// <summary>
/// Like <see cref="VariableFromTypeId{TValue}(NodeId)"/> but
/// disambiguates among multiple instances by browse name.
/// </summary>
IVariableBuilder<TValue> VariableFromTypeId<TValue>(NodeId typeDefinitionId, QualifiedName browseName);
}
}
@@ -0,0 +1,157 @@
/* ========================================================================
* Copyright (c) 2005-2025 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.Threading;
using System.Threading.Tasks;
namespace Opc.Ua.Server.Fluent
{
/// <summary>
/// Strongly-typed view of a variable node whose value attribute is
/// known at compile time to carry <typeparamref name="TValue"/>.
/// Surfaces convenience overloads of <c>OnRead</c>/<c>OnWrite</c>
/// that accept simple <see cref="Func{TResult}"/> /
/// <see cref="Action{T}"/> shaped lambdas — the framework marshals
/// to and from <see cref="Variant"/> automatically.
/// </summary>
/// <remarks>
/// <para>
/// Obtained via
/// <see cref="INodeManagerBuilder.Variable{TValue}(string)"/>,
/// <see cref="INodeManagerBuilder.Variable{TValue}(NodeId)"/> or by
/// calling <see cref="VariableBuilderExtensions.AsVariable{TValue}(INodeBuilder)"/>
/// on an existing <see cref="INodeBuilder"/>.
/// </para>
/// <para>
/// The simple convenience overloads do not surface index range, data
/// encoding, or per-call status code information; use the underlying
/// <see cref="INodeBuilder"/> overloads (e.g.
/// <see cref="INodeBuilder.OnRead(NodeValueEventHandlerAsync)"/>) when
/// that level of control is needed.
/// </para>
/// </remarks>
/// <typeparam name="TValue">
/// CLR type that the variable's <c>Value</c> attribute is expected to
/// carry. Reads cast the underlying <see cref="Variant"/> to
/// this type; writes wrap the supplied value in a new
/// <see cref="Variant"/>.
/// </typeparam>
public interface IVariableBuilder<TValue> : INodeBuilder<BaseVariableState>
{
/// <summary>
/// Wires a synchronous typed getter. The framework invokes
/// <paramref name="getter"/> on every read and applies index range
/// / data encoding / copy policy post-processing to the returned
/// value.
/// </summary>
IVariableBuilder<TValue> OnRead(Func<TValue> getter);
/// <summary>
/// Wires a synchronous typed getter that receives the calling
/// <see cref="ISystemContext"/>.
/// </summary>
IVariableBuilder<TValue> OnRead(Func<ISystemContext, TValue> getter);
/// <summary>
/// Wires an asynchronous typed getter. The handler runs without
/// holding <c>lock(this)</c>; the framework still applies index
/// range / data encoding / copy policy post-processing.
/// </summary>
IVariableBuilder<TValue> OnRead(
Func<CancellationToken, ValueTask<TValue>> getter);
/// <summary>
/// Wires an asynchronous typed getter that receives the calling
/// <see cref="ISystemContext"/> and a <see cref="CancellationToken"/>.
/// </summary>
IVariableBuilder<TValue> OnRead(
Func<ISystemContext, CancellationToken, ValueTask<TValue>> getter);
/// <summary>
/// Wires a synchronous typed setter. Index-range writes are not
/// supported through this overload (use the
/// <see cref="NodeValueEventHandler"/> overload on
/// <see cref="INodeBuilder.OnWrite(NodeValueEventHandler)"/>).
/// </summary>
IVariableBuilder<TValue> OnWrite(Action<TValue> setter);
/// <summary>
/// Wires a synchronous typed setter that receives the calling
/// <see cref="ISystemContext"/>.
/// </summary>
IVariableBuilder<TValue> OnWrite(Action<ISystemContext, TValue> setter);
/// <summary>
/// Wires an asynchronous typed setter. Runs without holding
/// <c>lock(this)</c>.
/// </summary>
IVariableBuilder<TValue> OnWrite(
Func<TValue, CancellationToken, ValueTask> setter);
/// <summary>
/// Wires an asynchronous typed setter that receives the calling
/// <see cref="ISystemContext"/>.
/// </summary>
IVariableBuilder<TValue> OnWrite(
Func<ISystemContext, TValue, CancellationToken, ValueTask> setter);
}
/// <summary>
/// Convenience extensions that bridge a non-generic
/// <see cref="INodeBuilder"/> into the strongly-typed
/// <see cref="IVariableBuilder{TValue}"/> view.
/// </summary>
public static class VariableBuilderExtensions
{
/// <summary>
/// Returns a <see cref="IVariableBuilder{TValue}"/> view of the
/// resolved node. Throws if the node is not a
/// <see cref="BaseVariableState"/>.
/// </summary>
public static IVariableBuilder<TValue> AsVariable<TValue>(this INodeBuilder builder)
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}
if (builder.Node is not BaseVariableState variable)
{
throw ServiceResultException.Create(
StatusCodes.BadInvalidArgument,
"Cannot get a typed variable view of node '{0}': not a BaseVariableState (actual: {1}).",
builder.Node.BrowseName,
builder.Node.GetType().Name);
}
return new VariableBuilder<TValue>(builder.Builder, variable);
}
}
}
@@ -293,7 +293,7 @@ namespace Opc.Ua.Server.Fluent
/// <see cref="Node"/> property.
/// </summary>
/// <typeparam name="TState"></typeparam>
internal sealed class NodeBuilder<TState> : NodeBuilder, INodeBuilder<TState>
internal class NodeBuilder<TState> : NodeBuilder, INodeBuilder<TState>
where TState : NodeState
{
public NodeBuilder(NodeManagerBuilder parent, TState node)
@@ -237,6 +237,68 @@ namespace Opc.Ua.Server.Fluent
return new NodeBuilder<TState>(this, typed);
}
/// <inheritdoc/>
public IVariableBuilder<TValue> Variable<TValue>(string browsePath)
{
ThrowIfSealed();
NodeState node = BrowsePathResolver.Resolve(
Context,
browsePath,
m_defaultNamespaceIndex,
m_rootResolver);
return ToVariableBuilder<TValue>(node, browsePath);
}
/// <inheritdoc/>
public IVariableBuilder<TValue> Variable<TValue>(NodeId nodeId)
{
ThrowIfSealed();
NodeState node = ResolveNodeId(nodeId);
return ToVariableBuilder<TValue>(node, FormatNodeId(nodeId));
}
/// <inheritdoc/>
public IVariableBuilder<TValue> VariableFromTypeId<TValue>(NodeId typeDefinitionId)
{
ThrowIfSealed();
NodeState node = ResolveByTypeDefinition(typeDefinitionId, (QualifiedName)null);
return ToVariableBuilder<TValue>(node, FormatNodeId(typeDefinitionId));
}
/// <inheritdoc/>
public IVariableBuilder<TValue> VariableFromTypeId<TValue>(NodeId typeDefinitionId, QualifiedName browseName)
{
ThrowIfSealed();
NodeState node = ResolveByTypeDefinition(typeDefinitionId, browseName);
return ToVariableBuilder<TValue>(
node,
CoreUtils.Format(
"{0} (browse name '{1}')",
FormatNodeId(typeDefinitionId),
browseName));
}
private VariableBuilder<TValue> ToVariableBuilder<TValue>(NodeState node, string lookupHint)
{
if (node is not BaseVariableState variable)
{
throw ServiceResultException.Create(
StatusCodes.BadTypeMismatch,
"Lookup '{0}' resolved to {1}, which is not a BaseVariableState.",
lookupHint,
node.GetType().Name);
}
return new VariableBuilder<TValue>(this, variable);
}
private static string FormatNodeId(NodeId nodeId)
{
// NodeId is a readonly struct so the caller may pass `default`;
// .IsNull guards both the default-struct case and a constructed
// NodeId with no identifier.
return nodeId.IsNull ? "(null)" : nodeId.ToString();
}
/// <inheritdoc/>
public bool TryHandleHistoryRead(
ISystemContext context,
@@ -0,0 +1,214 @@
/* ========================================================================
* Copyright (c) 2005-2025 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.Threading;
using System.Threading.Tasks;
namespace Opc.Ua.Server.Fluent
{
/// <summary>
/// Default <see cref="IVariableBuilder{TValue}"/> implementation.
/// Wraps each typed convenience overload as the appropriate slot
/// delegate on the underlying <see cref="BaseVariableState"/> and
/// performs <see cref="Variant"/> marshalling on every read / write.
/// </summary>
/// <typeparam name="TValue"></typeparam>
internal sealed class VariableBuilder<TValue> :
NodeBuilder<BaseVariableState>,
IVariableBuilder<TValue>
{
public VariableBuilder(INodeManagerBuilder parent, BaseVariableState variable)
: base((NodeManagerBuilder)parent, variable)
{
}
/// <inheritdoc/>
public IVariableBuilder<TValue> OnRead(Func<TValue> getter)
{
if (getter == null)
{
throw new ArgumentNullException(nameof(getter));
}
OnRead((ISystemContext _) => getter());
return this;
}
/// <inheritdoc/>
public IVariableBuilder<TValue> OnRead(Func<ISystemContext, TValue> getter)
{
if (getter == null)
{
throw new ArgumentNullException(nameof(getter));
}
((INodeBuilder)this).OnRead(
(ISystemContext context, NodeState _, ref Variant value) =>
{
value = ToVariant(getter(context));
return ServiceResult.Good;
});
return this;
}
/// <inheritdoc/>
public IVariableBuilder<TValue> OnRead(
Func<CancellationToken, ValueTask<TValue>> getter)
{
if (getter == null)
{
throw new ArgumentNullException(nameof(getter));
}
return OnRead(
(ISystemContext _, CancellationToken ct) => getter(ct));
}
/// <inheritdoc/>
public IVariableBuilder<TValue> OnRead(
Func<ISystemContext, CancellationToken, ValueTask<TValue>> getter)
{
if (getter == null)
{
throw new ArgumentNullException(nameof(getter));
}
((INodeBuilder)this).OnRead(
async (ISystemContext context, NodeState _, CancellationToken ct) =>
{
TValue typed = await getter(context, ct).ConfigureAwait(false);
return new AttributeSimpleReadResult(
ServiceResult.Good,
ToVariant(typed));
});
return this;
}
/// <inheritdoc/>
public IVariableBuilder<TValue> OnWrite(Action<TValue> setter)
{
if (setter == null)
{
throw new ArgumentNullException(nameof(setter));
}
OnWrite((ISystemContext _, TValue v) => setter(v));
return this;
}
/// <inheritdoc/>
public IVariableBuilder<TValue> OnWrite(Action<ISystemContext, TValue> setter)
{
if (setter == null)
{
throw new ArgumentNullException(nameof(setter));
}
((INodeBuilder)this).OnWrite(
(ISystemContext context, NodeState _, ref Variant value) =>
{
setter(context, FromVariant(value));
return ServiceResult.Good;
});
return this;
}
/// <inheritdoc/>
public IVariableBuilder<TValue> OnWrite(
Func<TValue, CancellationToken, ValueTask> setter)
{
if (setter == null)
{
throw new ArgumentNullException(nameof(setter));
}
return OnWrite((ISystemContext _, TValue v, CancellationToken ct) => setter(v, ct));
}
/// <inheritdoc/>
public IVariableBuilder<TValue> OnWrite(
Func<ISystemContext, TValue, CancellationToken, ValueTask> setter)
{
if (setter == null)
{
throw new ArgumentNullException(nameof(setter));
}
((INodeBuilder)this).OnWrite(
async (ISystemContext context, NodeState _, Variant value, CancellationToken ct) =>
{
TValue typed = FromVariant(value);
await setter(context, typed, ct).ConfigureAwait(false);
return new AttributeWriteResult(ServiceResult.Good);
});
return this;
}
/// <summary>
/// Casts the boxed <see cref="Variant"/> to
/// <typeparamref name="TValue"/>. Returns the default value when
/// the variant is null so a typed lambda never has to defend
/// against an empty variable.
/// </summary>
private static TValue FromVariant(Variant value)
{
object boxed = value.AsBoxedObject(Variant.BoxingBehavior.Legacy);
if (boxed is null)
{
return default;
}
if (boxed is TValue typed)
{
return typed;
}
// Fall through to checked cast — gives a clear InvalidCastException
// (vs. a confusing pattern-match miss) when the model and the user
// disagree about the variable type.
return (TValue)boxed;
}
// The reflection-based Variant(object) constructor is the only
// generic entry point for an open-ended TValue. AOT users should
// prefer the per-type generated walker (FluentBuilderGenerator) which
// routes through the typed Variant.From overloads instead. The
// suppression is scoped to this single call site so trim/AOT
// analysis still tracks every other path through this assembly.
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(
"Trimming",
"IL2026:RequiresUnreferencedCode",
Justification = "Generic typed-variable bridge requires the reflection-based Variant(object) constructor.")]
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(
"AOT",
"IL3050:RequiresDynamicCode",
Justification = "Generic typed-variable bridge requires the reflection-based Variant(object) constructor.")]
private static Variant ToVariant(TValue value)
{
if (value is null)
{
return Variant.Null;
}
#pragma warning disable CS0618 // Variant(object) is obsolete on net472
return new Variant(value);
#pragma warning restore CS0618
}
}
}