diff --git a/src/Opc.Ua.WotCon.Server/EventIds.cs b/src/Opc.Ua.WotCon.Server/EventIds.cs index 9a0f746df9..ca03f5b0eb 100644 --- a/src/Opc.Ua.WotCon.Server/EventIds.cs +++ b/src/Opc.Ua.WotCon.Server/EventIds.cs @@ -46,5 +46,6 @@ internal static class WotConServerEventIds public const int AssetRegistry = 0; public const int WotAssetFileManager = 40; public const int WotConnectivityNodeManager = 50; + public const int WotRegistryNodeManager = 60; } } diff --git a/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs b/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs new file mode 100644 index 0000000000..93f9a2bc9c --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs @@ -0,0 +1,204 @@ +/* ======================================================================== + * 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 Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Opc.Ua; +using Opc.Ua.Server; +using Opc.Ua.Server.Hosting; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.XRegistry.Server; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// extensions that host the WoT Connectivity 1.1 + /// registry (WoTRegistry) on the OPC UA server registered via + /// .AddServer(...). The registry service, materialization coordinator, + /// binder registry and projection host are registered as singletons; the + /// stable is attached at server start. + /// + public static class OpcUaWotRegistryServerBuilderExtensions + { + /// + /// Default configuration section for the registry options. + /// + public const string DefaultConfigurationSection = "OpcUa:WotConRegistry:Server"; + + /// + /// Registers the WoT Connectivity 1.1 registry NodeManager configured by + /// . + /// + /// + /// + public static IOpcUaBuilder AddWotRegistryServer( + this IOpcUaBuilder builder, + Action? configure = null) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (configure is not null) + { + builder.Services.AddOptions().Configure(configure); + } + else + { + builder.Services.AddOptions(); + } + RegisterCommonServices(builder.Services); + return builder; + } + + /// + /// Registers the WoT Connectivity 1.1 registry NodeManager with options + /// bound from the supplied configuration section. + /// + /// + /// + public static IOpcUaBuilder AddWotRegistryServer( + this IOpcUaBuilder builder, + IConfiguration configuration) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (configuration is null) + { + throw new ArgumentNullException(nameof(configuration)); + } + return builder.AddWotRegistryServer( + configuration.GetSection(DefaultConfigurationSection)); + } + + /// + /// Registers the WoT Connectivity 1.1 registry NodeManager with options + /// bound from the supplied configuration section. + /// + /// + /// + public static IOpcUaBuilder AddWotRegistryServer( + this IOpcUaBuilder builder, + IConfigurationSection section) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (section is null) + { + throw new ArgumentNullException(nameof(section)); + } + builder.Services.AddOptions().Bind(section); + RegisterCommonServices(builder.Services); + return builder; + } + + private static void RegisterCommonServices(IServiceCollection services) + { + services.TryAddSingleton(sp => + sp.GetRequiredService>().Value + ?? new WotRegistryServerOptions()); + + services.EnsureWotBinderRegistry(); + + services.TryAddSingleton(new WotTargetVariableResolver()); + + services.TryAddSingleton(sp => + new WotProjectionBindingRuntimeFactory( + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.TryAddSingleton(sp => + { + WotRegistryServerOptions options = + sp.GetRequiredService(); + // A store registered in DI wins over one set on the options, so a deployment can + // supply a shared store the same way the xRegistry server does. + IXRegistryResourceStore? resourceStore = + sp.GetService() ?? options.ResourceStore; + IWotRegistryStore store = string.IsNullOrEmpty(options.StorageFolder) + ? new InMemoryWotRegistryStore() + : resourceStore is null + ? new FileWotRegistryStore(options.StorageFolder!) + : new FileWotRegistryStore(options.StorageFolder!, resourceStore); + return new WotRegistryService(store, options.Bounds); + }); + + services.TryAddSingleton(sp => + new LifecycleWotProjectionHost( + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.TryAddSingleton(sp => + { + WotRegistryServerOptions options = + sp.GetRequiredService(); + var converterOptions = new WotNodeSetConverterOptions + { + MaxJsonDocumentSize = options.Bounds.MaxDocumentBytes, + MaxResolverDocumentBytes = options.Bounds.MaxDocumentBytes + }; + return new WotMaterializationCoordinator( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + converterOptions, + // Both seams are optional: a deployment that registers neither gets exactly + // the previous behaviour. Registering an IWotDocumentConverter replaces the + // Thing Description to NodeSet conversion; registering IWotNodeSetContributor + // instances adds nodes (typically controller-specific StructureType DataTypes) + // to the converted NodeSet before it is materialized. + sp.GetService(), + sp.GetServices(), + sp.GetService()); + }); + + services.TryAddSingleton(sp => + new WotRegistryNodeManagerFactory( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => + new OpcUaServerNodeManagerRegistration( + sp.GetRequiredService())); + + services.AddOpcUa(); + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs new file mode 100644 index 0000000000..2f1b10cf29 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs @@ -0,0 +1,180 @@ +/* ======================================================================== + * 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.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Export; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// The result of converting one registry document to a NodeSet2 model. + /// + public sealed class WotConversionOutput + { + /// + /// Initializes a successful or failed conversion output. + /// + public WotConversionOutput( + UANodeSet? nodeSet, + ImmutableArray errors, + ExpandedNodeId rootNodeId = default) + { + NodeSet = nodeSet; + Errors = errors.IsDefault ? [] : errors; + RootNodeId = rootNodeId; + } + + /// + /// Gets the produced NodeSet2, or null on failure. + /// + public UANodeSet? NodeSet { get; } + + /// + /// Gets the conversion error messages. + /// + public ImmutableArray Errors { get; } + + /// + /// Gets the root node of the projection (the type a Thing Model + /// materializes or the top-level instance a Thing Description projects), + /// as an absolute whose namespace URI is + /// resolved from the produced NodeSet, or ExpandedNodeId.Null + /// when the document has no identifiable root. + /// + public ExpandedNodeId RootNodeId { get; } + + /// + /// Gets whether the conversion succeeded. + /// + public bool Succeeded => NodeSet is not null && Errors.IsEmpty; + + /// + /// Creates a successful output. + /// + public static WotConversionOutput Success(UANodeSet nodeSet) + { + return new WotConversionOutput( + nodeSet, + [], + WotNodeSetConverter.TrySelectProjectionRoot(nodeSet)); + } + + /// + /// Creates a failed output. + /// + public static WotConversionOutput Failure(params string[] errors) + { + return new WotConversionOutput(null, [.. errors]); + } + } + + /// + /// Converts a stored registry document to a NodeSet2 model. The default + /// implementation delegates to and resolves + /// TM references from the registry snapshot; a test double can substitute a + /// deterministic conversion. + /// + public interface IWotDocumentConverter + { + /// + /// Converts a resource's default document to a NodeSet2 model. + /// + ValueTask ConvertAsync( + WotResource resource, + ReadOnlyMemory content, + WotRegistrySnapshot snapshot, + CancellationToken cancellationToken); + } + + /// + /// The production converter over . + /// + public sealed class WotNodeSetDocumentConverter : IWotDocumentConverter + { + /// + /// Initializes a new converter with the supplied options. + /// + public WotNodeSetDocumentConverter(WotNodeSetConverterOptions? options = null) + { + m_options = options ?? new WotNodeSetConverterOptions(); + } + + /// + public async ValueTask ConvertAsync( + WotResource resource, + ReadOnlyMemory content, + WotRegistrySnapshot snapshot, + CancellationToken cancellationToken) + { + try + { + using var document = WotDocument.Parse(content, m_options); + var resolver = new SnapshotThingResolver(snapshot); + // One resolution context per top-level conversion, seeded from + // the configured converter options, so depth/document/byte + // bounds and cycle detection apply across every link resolved + // while converting this resource. + var resolution = new WotResolutionContext(m_options.ToResolverOptions()); + WotConversionResult result = await WotNodeSetConverter.ToNodeSetResultAsync( + document, m_options, resolver, resolution, cancellationToken).ConfigureAwait(false); + ImmutableArray.Builder errors = ImmutableArray.CreateBuilder(); + foreach (WotDiagnostic diagnostic in result.Diagnostics) + { + if (diagnostic.Severity == WotDiagnosticSeverity.Error) + { + errors.Add(diagnostic.ToString()); + } + } + if (result.Value is null && errors.Count == 0) + { + errors.Add("The document could not be converted to a NodeSet."); + } + if (errors.Count != 0 || result.Value is null) + { + return new WotConversionOutput(null, errors.ToImmutable()); + } + return new WotConversionOutput( + result.Value, + [], + WotNodeSetConverter.TrySelectProjectionRoot(result.Value)); + } + catch (Exception ex) when (ex is System.Text.Json.JsonException or FormatException) + { + return WotConversionOutput.Failure(ex.Message); + } + } + + private readonly WotNodeSetConverterOptions m_options; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetContributor.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetContributor.cs new file mode 100644 index 0000000000..ae29f3ef95 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetContributor.cs @@ -0,0 +1,79 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Export; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Contributes additional nodes to a converted document before it is materialized into the + /// AddressSpace — typically custom StructureType DataTypes that have no NodeSet to + /// import because they are specific to one controller program. + /// + /// + /// + /// Many industrial protocols expose user-defined types — Rockwell / Studio 5000 UDTs, TIA + /// Portal PLC data types, Beckhoff TwinCAT structured types — that must be generated from the + /// controller's own symbol table at onboarding time. A contributor runs once per resource, + /// after the Thing Description has been converted to a NodeSet and before any variable is + /// created, which is the point at which such a DataType has to exist: a + /// uav:mapByFieldPath mapping into a structured type can only resolve once that type is + /// registered. + /// + /// + /// A document that can already express its types declaratively does not need this seam — the + /// native projection (uav:NodeModel) carries DataType nodes with their + /// DataTypeDefinition directly. This interface is for types that can only be discovered + /// programmatically. + /// + /// + /// Contributors are resolved from dependency injection; registering none leaves conversion + /// unchanged. Implementations must be safe for concurrent calls across resources. + /// + /// + public interface IWotNodeSetContributor + { + /// + /// Contributes nodes for to . + /// + /// The registry resource being materialized. + /// + /// The converted NodeSet, mutated in place. A contributor adds nodes; it must not remove or + /// rewrite nodes produced by the conversion. + /// + /// The cancellation token. + ValueTask ContributeAsync( + WotResource resource, + UANodeSet nodeSet, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetResolver.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetResolver.cs new file mode 100644 index 0000000000..5dbf307ac0 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotNodeSetResolver.cs @@ -0,0 +1,80 @@ +/* ======================================================================== + * 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.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Resolves the NodeSet2 for an OPC UA namespace a Thing Description depends on but the server + /// does not already know. + /// + /// + /// + /// Thing Descriptions are uploaded at run time through the standard WoTAssetFileType + /// upload, so the set of namespaces a server will be asked to serve is not known at start-up + /// and static pre-loading is not sufficient. A resolver closes that gap: it is consulted for + /// each unknown namespace, and what it returns is loaded through the server's existing runtime + /// NodeSet support. + /// + /// + /// A document that carries its own model does not need a resolver at all — the + /// uav:nodeSet envelope embeds the NodeSet2 in the Thing Description itself. + /// + /// + /// Resolution is recursive: a resolved NodeSet's own dependencies are resolved the same way. + /// A namespace that cannot be resolved is reported as a diagnostic rather than failing the + /// whole onboarding, so an operator can see exactly what is missing. + /// + /// + /// No implementation ships with the library: resolving a namespace means reaching out to some + /// catalogue — a UA Cloud Library instance, a corporate model repository, a folder on disk — + /// which is a deployment decision. Registering none leaves behaviour unchanged. + /// Implementations must be safe for concurrent calls. + /// + /// + public interface IWotNodeSetResolver + { + /// + /// Attempts to resolve the NodeSet2 for . + /// + /// The namespace URI to resolve. + /// The cancellation token. + /// + /// A readable stream positioned at the start of the NodeSet2 XML, which the caller + /// disposes; or null when this resolver does not know the namespace. Returning + /// null is the expected way to decline — it is not an error. + /// + ValueTask TryResolveAsync( + string namespaceUri, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionBindingRuntimeFactory.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionBindingRuntimeFactory.cs new file mode 100644 index 0000000000..97254f4b4f --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionBindingRuntimeFactory.cs @@ -0,0 +1,83 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Server.Fluent; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Builds the per-generation OPC UA target-mapping binding runtime for a + /// runtime NodeSet. Injected into , + /// which invokes it from + /// once the generation's NodeSet2 content has been imported, so target + /// mappings are resolved against the freshly materialized predefined nodes. + /// The returned (if any) is owned by that + /// NodeSet generation and is disposed with it. + /// + public interface IWotProjectionBindingRuntimeFactory + { + /// + /// Wires the OPC UA target-mapping bindings declared by + /// onto the freshly imported predefined + /// nodes exposed by . + /// + /// + /// The fluent builder for the node manager generation being activated. + /// + /// + /// The prepared binding plans for the projected closure. Forms without + /// a target mapping, and non-executable forms, are ignored. + /// + /// The cancellation token. + /// + /// The generation-owned binding runtime, or null when no target + /// mapping was wired (for example an empty ). + /// + /// + /// A target mapping is missing, malformed, ambiguous, resolves to the + /// wrong node class, mismatches its declared target type, conflicts + /// with another mapping to the same target, duplicates a read or write + /// mapping, or is declared on an unsupported operation. Structured + /// (uav:mapByFieldPath) validation that depends on the + /// target's structure type being registered — the structure lookup, + /// root instance validation, and per-field path resolution — is + /// deferred past this call; see the class remarks on + /// for why, and for how a + /// first structured read or write fails deterministically instead. + /// + ValueTask CreateAsync( + INodeManagerBuilder builder, + ArrayOf bindingPlans, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionHost.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionHost.cs new file mode 100644 index 0000000000..aef9b80401 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionHost.cs @@ -0,0 +1,262 @@ +/* ======================================================================== + * 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.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Selects how the previous projection generation is retired after a + /// successful replacement. + /// + public enum WotProjectionRetirementPolicy + { + /// + /// Keep the previous generation alive until its monitored items and + /// requests drain. + /// + Graceful, + + /// + /// Invalidate its monitored items with BadNodeIdUnknown and dispose the + /// previous generation without waiting for drain. + /// + Immediate + } + + /// + /// One NodeSet2 document loaded as a runtime NodeManager source. A projection + /// closure produces one or more of these (TM type NodeSets loaded before the + /// dependent TD instance NodeSet). + /// + public sealed class WotProjectionSource + { + /// + /// Initializes a new projection source. + /// + public WotProjectionSource( + string name, + ImmutableArray modelNamespaceUris, + byte[] nodeSetXml) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + ModelNamespaceUris = modelNamespaceUris.IsDefault + ? [] : modelNamespaceUris; + NodeSetXml = nodeSetXml ?? throw new ArgumentNullException(nameof(nodeSetXml)); + } + + /// + /// Gets the diagnostic source name. + /// + public string Name { get; } + + /// + /// Gets the model namespace URIs this source owns. + /// + public ImmutableArray ModelNamespaceUris { get; } + + /// + /// Gets the serialized NodeSet2 XML bytes. + /// + public byte[] NodeSetXml { get; } + } + + /// + /// The full set of NodeSet2 sources for one projection closure to be added + /// or shadow-reloaded as a single runtime NodeManager. + /// + public sealed class WotProjectionDocument + { + /// + /// Initializes a new projection document. + /// + public WotProjectionDocument( + string closureKey, + ImmutableArray sources) + : this(closureKey, sources, []) + { + } + + /// + /// Initializes a new projection document that also carries the prepared + /// binding plans for the closure, so the projection host can wire a + /// per-generation binding runtime while it materializes the NodeSet. + /// + public WotProjectionDocument( + string closureKey, + ImmutableArray sources, + ArrayOf bindingPlans) + { + ClosureKey = closureKey ?? throw new ArgumentNullException(nameof(closureKey)); + Sources = sources.IsDefault ? [] : sources; + BindingPlans = bindingPlans; + } + + /// + /// Gets the stable closure key this document projects. + /// + public string ClosureKey { get; } + + /// + /// Gets the ordered NodeSet2 sources. + /// + public ImmutableArray Sources { get; } + + /// + /// Gets the prepared binding plans for the closure's members, in the + /// same order the members were projected. Empty when the coordinator + /// did not prepare any bindings (for example a dry run). + /// + public ArrayOf BindingPlans { get; } + } + + /// + /// Marks the host-specific registration carried by a + /// . Implementations are opaque to the + /// materialization pipeline; only the owning + /// interprets them. + /// + public interface IWotProjectionRegistration + { + /// + /// Gets the stable identifier the host assigned to this registration. It + /// is shared by every generation produced from the same closure and is + /// only used for diagnostics. + /// + Guid Id { get; } + } + + /// + /// An opaque handle to a live projection generation held by the host. It + /// wraps the underlying runtime NodeManager registration and records the + /// materialized root NodeIds and node count. + /// + public sealed class WotProjectionHandle + { + /// + /// Initializes a new projection handle. + /// + public WotProjectionHandle( + string closureKey, + long generation, + IWotProjectionRegistration? registration, + ImmutableArray rootNodeIds, + int materializedNodeCount, + string warning = "") + { + ClosureKey = closureKey ?? string.Empty; + Generation = generation; + Registration = registration; + RootNodeIds = rootNodeIds.IsDefault ? [] : rootNodeIds; + MaterializedNodeCount = materializedNodeCount; + Warning = warning ?? string.Empty; + } + + /// + /// Gets the closure key. + /// + public string ClosureKey { get; } + + /// + /// Gets the projection generation. + /// + public long Generation { get; } + + /// + /// Gets the underlying runtime registration (host-specific). + /// + public IWotProjectionRegistration? Registration { get; } + + /// + /// Gets the materialized root NodeIds. + /// + public ImmutableArray RootNodeIds { get; } + + /// + /// Gets the materialized node count. + /// + public int MaterializedNodeCount { get; } + + /// + /// Gets a non-fatal host warning produced after the replacement generation + /// was committed, for example deferred cleanup of the previous generation. + /// + public string Warning { get; } + } + + /// + /// The seam between the materialization coordinator and the live server's + /// NodeManager lifecycle. The production implementation adds a runtime + /// NodeSet on first activation and shadow-reloads it on update, keeping the + /// stable registry NodeManager separate. A test double records the sequence + /// of add/shadow-reload/remove operations without a running server. + /// + public interface IWotProjectionHost + { + /// + /// Adds a projection for its first activation and returns a handle to the + /// new live generation. + /// + ValueTask AddAsync( + WotProjectionDocument document, + CancellationToken cancellationToken = default); + + /// + /// Shadow-reloads a live projection: new service requests are routed to + /// the replacement generation while the previous generation keeps serving + /// its existing monitored items until they drain. + /// + ValueTask ShadowReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default); + + /// + /// Reloads a live projection and immediately retires the previous + /// generation. Affected data-change monitored items report + /// . + /// + ValueTask ImmediateReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default); + + /// + /// Removes a live projection after its monitored items drain, without + /// disconnecting clients. + /// + ValueTask RemoveAsync( + WotProjectionHandle handle, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotTargetVariableResolver.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotTargetVariableResolver.cs new file mode 100644 index 0000000000..d0ef538aa0 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotTargetVariableResolver.cs @@ -0,0 +1,166 @@ +/* ======================================================================== + * 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 Opc.Ua.Server.Fluent; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Resolves the OPC UA target variable an OPC 10101 §6.5.4 target-mapping + /// descriptor declares, against a node manager's freshly imported + /// predefined nodes. Injectable so a host application can supply an + /// alternate resolution strategy (for example one that also consults an + /// external addressing table); is + /// the default, spec-compliant implementation and is always available via + /// direct construction. + /// + public interface IWotTargetVariableResolver + { + /// + /// Resolves the target variable declared by . + /// + /// + /// The fluent builder for the node manager generation whose predefined + /// nodes (including the freshly imported NodeSet2 content) the mapping + /// is resolved against. + /// + /// The target-mapping descriptor to resolve. + /// The resolved target variable. + /// + /// The mapping is missing, malformed, ambiguous, resolves to a node + /// that is not a , or (for a mapping + /// that declares both terms) resolves to a variable whose + /// DataType does not equal the declared target type. + /// + BaseVariableState Resolve(INodeManagerBuilder builder, WotTargetMappingDescriptor mapping); + } + + /// + /// The default . It implements the + /// OPC 10101 §6.5.4 target-mapping resolution rules by reusing the fluent + /// lookup surface (so lookup failures + /// throw the same deterministic + /// statuses the builder already defines for missing, ambiguous, or + /// wrong-node-class lookups): + /// + /// + /// uav:mapToNodeId alone resolves the exact target and requires it + /// to be a . + /// + /// + /// uav:mapToType alone resolves the unique variable whose + /// DataType equals the target type. + /// + /// + /// Both terms resolve the exact target and additionally validate that its + /// DataType equals the declared target type. + /// + /// + /// + public sealed class WotTargetVariableResolver : IWotTargetVariableResolver + { + /// + public BaseVariableState Resolve(INodeManagerBuilder builder, WotTargetMappingDescriptor mapping) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (mapping is null) + { + throw new ArgumentNullException(nameof(mapping)); + } + + bool hasTargetNodeId = !string.IsNullOrWhiteSpace(mapping.TargetNodeId); + bool hasTargetType = !string.IsNullOrWhiteSpace(mapping.TargetTypeNodeId); + + if (!hasTargetNodeId && !hasTargetType) + { + throw ServiceResultException.Create( + StatusCodes.BadNodeIdInvalid, + "The target mapping declares neither 'uav:mapToNodeId' nor 'uav:mapToType'."); + } + + if (hasTargetNodeId) + { + NodeId targetNodeId = ParsePortableNodeId(builder, mapping.TargetNodeId!, "uav:mapToNodeId"); + BaseVariableState variable = builder.Variable(targetNodeId).Node; + + if (hasTargetType) + { + NodeId targetTypeId = ParsePortableNodeId(builder, mapping.TargetTypeNodeId!, "uav:mapToType"); + if (variable.DataType != targetTypeId) + { + throw ServiceResultException.Create( + StatusCodes.BadTypeMismatch, + "Target variable '{0}' has DataType '{1}', which does not match the " + + "declared 'uav:mapToType' target type '{2}'.", + targetNodeId, + variable.DataType, + targetTypeId); + } + } + return variable; + } + + NodeId dataTypeId = ParsePortableNodeId(builder, mapping.TargetTypeNodeId!, "uav:mapToType"); + return builder.VariableFromDataTypeId(dataTypeId).Node; + } + + /// + /// Parses a portable NodeId (including nsu= forms) against the + /// builder's namespace table, translating every parse failure — + /// including a raised by the + /// parser itself — into a deterministic + /// naming the offending + /// term, so callers never need to special-case the parser's own + /// exception shape. + /// + /// + private static NodeId ParsePortableNodeId(INodeManagerBuilder builder, string text, string term) + { + try + { + return ExpandedNodeId.Parse(text, builder.Context.NamespaceUris); + } + catch (Exception ex) when ( + ex is not OperationCanceledException and not OutOfMemoryException) + { + throw ServiceResultException.Create( + StatusCodes.BadNodeIdInvalid, + "'{0}' value '{1}' is not a valid portable NodeId: {2}", + term, + text, + ex.Message); + } + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs b/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs new file mode 100644 index 0000000000..43be66fa9a --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs @@ -0,0 +1,224 @@ +/* ======================================================================== + * 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.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Server; +using Opc.Ua.Server.RuntimeNodeSet; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// The production that projects WoT closures + /// onto the live server through the public NodeManager lifecycle. First + /// activation uses + /// ; + /// updates use + /// , + /// so the previous generation keeps serving its existing monitored items + /// until they drain. The stable WoT registry NodeManager is never touched. + /// + public sealed class LifecycleWotProjectionHost : IWotProjectionHost + { + /// + /// Initializes a new host over the supplied lifecycle. + /// + /// The node manager lifecycle to project onto. + /// + /// The optional projection binding runtime factory. When supplied, each + /// runtime NodeSet generation created for a document that carries + /// prepared owns its own + /// binding runtime: it is created after the NodeSet is imported (via + /// ) and disposed with + /// the generation. When null, no binding runtime is wired (the + /// NodeSet is materialized as data only). + /// + public LifecycleWotProjectionHost( + INodeManagerLifecycle lifecycle, + IWotProjectionBindingRuntimeFactory? runtimeFactory = null) + { + m_lifecycle = lifecycle ?? throw new ArgumentNullException(nameof(lifecycle)); + m_runtimeFactory = runtimeFactory; + } + + /// + public async ValueTask AddAsync( + WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + RuntimeNodeSetOptions options = BuildOptions(document); + NodeManagerRegistration registration = await m_lifecycle + .AddRuntimeNodeSetAsync(options, callerContext: null, cancellationToken) + .ConfigureAwait(false); + return new WotProjectionHandle( + document.ClosureKey, + registration.Generation, + new NodeManagerProjectionRegistration(registration), + [], + 0); + } + + /// + public async ValueTask ShadowReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + if (current?.Registration is not NodeManagerProjectionRegistration wrapper) + { + // No live registration to reload; fall back to a fresh add. + return await AddAsync(document, cancellationToken).ConfigureAwait(false); + } + RuntimeNodeSetOptions options = BuildOptions(document); + NodeManagerRegistration next; + string warning = string.Empty; + try + { + next = await m_lifecycle + .ShadowReloadRuntimeNodeSetAsync(wrapper.Registration, options, cancellationToken) + .ConfigureAwait(false); + } + catch (NodeManagerReloadCommittedException ex) + { + next = ex.Registration; + warning = "The replacement is active, but prior-generation cleanup is pending: " + + ex.Message; + } + return new WotProjectionHandle( + document.ClosureKey, + next.Generation, + new NodeManagerProjectionRegistration(next), + [], + 0, + warning); + } + + /// + public async ValueTask ImmediateReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + if (current?.Registration is not NodeManagerProjectionRegistration wrapper) + { + return await AddAsync(document, cancellationToken).ConfigureAwait(false); + } + RuntimeNodeSetOptions options = BuildOptions(document); + NodeManagerRegistration next; + string warning = string.Empty; + try + { + next = await m_lifecycle + .ImmediateReloadRuntimeNodeSetAsync(wrapper.Registration, options, cancellationToken) + .ConfigureAwait(false); + } + catch (NodeManagerReloadCommittedException ex) + { + next = ex.Registration; + warning = "The replacement is active, but prior-generation cleanup is pending: " + + ex.Message; + } + return new WotProjectionHandle( + document.ClosureKey, + next.Generation, + new NodeManagerProjectionRegistration(next), + [], + 0, + warning); + } + + /// + public async ValueTask RemoveAsync( + WotProjectionHandle handle, + CancellationToken cancellationToken = default) + { + if (m_lifecycle.IsShuttingDown) + { + return; + } + + if (handle?.Registration is NodeManagerProjectionRegistration wrapper) + { + await m_lifecycle + .RemoveAsync(wrapper.Registration, callerContext: null, cancellationToken) + .ConfigureAwait(false); + } + } + + private RuntimeNodeSetOptions BuildOptions(WotProjectionDocument document) + { + var sources = new RuntimeNodeSetSource[document.Sources.Length]; + for (int i = 0; i < document.Sources.Length; i++) + { + WotProjectionSource source = document.Sources[i]; + byte[] xml = source.NodeSetXml; + var uris = new ArrayOf(source.ModelNamespaceUris.ToArray()); + sources[i] = RuntimeNodeSetSource.FromStream( + source.Name, + _ => new ValueTask(new MemoryStream(xml, writable: false)), + uris); + } + var options = new RuntimeNodeSetOptions + { + Sources = new ArrayOf(sources), + AllowLifecycleFromRequestCallback = true + }; + if (m_runtimeFactory is { } runtimeFactory) + { + ArrayOf bindingPlans = document.BindingPlans; + options.ConfigureAsync = (builder, cancellationToken) + => runtimeFactory.CreateAsync(builder, bindingPlans, cancellationToken); + } + return options; + } + + private readonly INodeManagerLifecycle m_lifecycle; + private readonly IWotProjectionBindingRuntimeFactory? m_runtimeFactory; + + /// + /// Carries the lifecycle registration owned by this host through the + /// host-agnostic . + /// + private sealed class NodeManagerProjectionRegistration : IWotProjectionRegistration + { + public NodeManagerProjectionRegistration(NodeManagerRegistration registration) + { + Registration = registration; + } + + public Guid Id => Registration.Id; + + public NodeManagerRegistration Registration { get; } + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotBindingChannelSlot.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotBindingChannelSlot.cs new file mode 100644 index 0000000000..2497b06e2f --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotBindingChannelSlot.cs @@ -0,0 +1,152 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Lazily opens and caches a single live channel for one compiled form, + /// shared by every reader/writer wired against that form for the lifetime + /// of a projection binding runtime generation. Concurrent first use opens + /// the channel exactly once; a failed open is evicted immediately so a + /// later call can retry. Disposing the slot disposes the channel only if + /// it was opened successfully — a faulted open leaves nothing to dispose + /// and never re-surfaces the original open failure. Once disposed, the + /// slot never opens another channel: racing with or + /// occurring after either observes the channel + /// already claimed for disposal or is rejected outright, so no channel + /// this slot opens can ever escape disposal. + /// + internal sealed class WotBindingChannelSlot + { + /// + /// Initializes a new channel slot for a compiled form. + /// + public WotBindingChannelSlot(WotCompiledForm form, IWotBindingChannelFactory channelFactory) + { + m_form = form ?? throw new ArgumentNullException(nameof(form)); + m_channelFactory = channelFactory ?? throw new ArgumentNullException(nameof(channelFactory)); + } + + /// + /// Gets the shared channel, opening it on first use. The channel open + /// itself is not bound to any single caller's cancellation token — it + /// is a generation-scoped resource shared by every reader/writer wired + /// against the same compiled form, so one caller cancelling must not + /// tear down the open for concurrent callers. + /// + /// + /// The slot has already been disposed, or is disposed concurrently + /// before this call is able to reuse or start an open. + /// + public ValueTask GetAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Task task; + lock (m_gate) + { + if (m_disposed) + { + throw new ObjectDisposedException(nameof(WotBindingChannelSlot)); + } + task = m_channelTask ??= OpenAsync(); + } + return WaitAsync(task); + } + + /// + /// Marks the slot disposed so no later call can + /// start a new open, then disposes the cached channel if one was + /// successfully opened (including one still opening concurrently — the + /// disposal awaits it and disposes the result). A faulted or + /// never-started open has no resource to release and is silently + /// ignored so cleanup never re-reports the original open failure. + /// Safe to call more than once; only the first call finds a channel to + /// dispose. + /// + public async ValueTask DisposeAsync() + { + Task? task; + lock (m_gate) + { + task = m_channelTask; + m_channelTask = null; + m_disposed = true; + } + if (task is null) + { + return; + } + IWotBindingChannel channel; + try + { + channel = await task.ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return; + } + await channel.DisposeAsync().ConfigureAwait(false); + } + + private Task OpenAsync() + { + return m_channelFactory.OpenChannelAsync(m_form, CancellationToken.None).AsTask(); + } + + private async ValueTask WaitAsync(Task task) + { + try + { + return await task.ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + lock (m_gate) + { + if (ReferenceEquals(m_channelTask, task)) + { + m_channelTask = null; + } + } + throw; + } + } + + private readonly WotCompiledForm m_form; + private readonly IWotBindingChannelFactory m_channelFactory; + private readonly Lock m_gate = new(); + private Task? m_channelTask; + private bool m_disposed; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotDependencyGraph.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotDependencyGraph.cs new file mode 100644 index 0000000000..1d6e1861eb --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotDependencyGraph.cs @@ -0,0 +1,565 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Linq; +using System.Text.Json; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// One resolved (or unresolved) dependency edge between two documents. + /// + public sealed class WotDependency + { + /// + /// Initializes a new dependency edge. + /// + public WotDependency( + string sourceXid, + string targetHref, + string? targetXid, + string refType, + bool resolved) + { + SourceXid = sourceXid; + TargetHref = targetHref; + TargetXid = targetXid; + RefType = refType; + Resolved = resolved; + } + + /// + /// Gets the xid of the dependent document. + /// + public string SourceXid { get; } + + /// + /// Gets the raw href/URI of the dependency. + /// + public string TargetHref { get; } + + /// + /// Gets the xid of the resolved target document, if any. + /// + public string? TargetXid { get; } + + /// + /// Gets the dependency kind (tm:extends / tm:ref / links.rel=type). + /// + public string RefType { get; } + + /// + /// Gets whether the dependency resolved to a stored document. + /// + public bool Resolved { get; } + } + + /// + /// A dependency closure: a set of resources that must be materialized + /// together, with Thing Models topologically ordered before the Thing + /// Descriptions that depend on them. A closure is the default unit of + /// atomicity for a refresh. + /// + public sealed class WotDependencyClosure + { + internal WotDependencyClosure( + string key, + ImmutableArray members, + ImmutableArray orderedResources, + ImmutableArray dependencies, + ImmutableArray diagnostics, + bool hasCycle, + bool hasMissingDependency) + { + Key = key; + Members = members; + OrderedResources = orderedResources; + Dependencies = dependencies; + Diagnostics = diagnostics; + HasCycle = hasCycle; + HasMissingDependency = hasMissingDependency; + } + + /// + /// Gets the stable closure key (sorted member xids). + /// + public string Key { get; } + + /// + /// Gets every member of the closure (populated even on a cycle). + /// + public ImmutableArray Members { get; } + + /// + /// Gets the resources in topological (dependency-first) order. + /// + public ImmutableArray OrderedResources { get; } + + /// + /// Gets the dependency edges within the closure. + /// + public ImmutableArray Dependencies { get; } + + /// + /// Gets the diagnostics for the closure. + /// + public ImmutableArray Diagnostics { get; } + + /// + /// Gets whether the closure contains a dependency cycle. + /// + public bool HasCycle { get; } + + /// + /// Gets whether the closure has an unresolved dependency. + /// + public bool HasMissingDependency { get; } + + /// + /// Gets whether the closure is projectable (no cycle, no missing dependency). + /// + public bool IsProjectable => !HasCycle && !HasMissingDependency; + } + + /// + /// Builds the TD/TM dependency graph from a registry snapshot and partitions + /// it into deterministic dependency closures. References are extracted from + /// links (rel = tm:extends / type / tm:submodel), a top-level + /// tm:extends, and tm:ref pointers, then resolved against the + /// registry by Thing id, xid, or resource id. + /// + public static class WotDependencyGraph + { + /// + /// Resolves a WoT reference href to a stored resource, or null. + /// + public static WotResource? Resolve(WotRegistrySnapshot snapshot, string href) + { + if (snapshot is null || string.IsNullOrWhiteSpace(href)) + { + return null; + } + string trimmed = TrimFragment(href); + // Prefer Thing Models, then any resource, matching by thing id, xid or resource id. + return MatchIn(snapshot.ResourcesOfKind(WoTDocumentKindEnum.ThingModel), trimmed) + ?? MatchIn(snapshot.AllResources(), trimmed); + } + + /// + /// Extracts the outgoing dependency references of a single document. + /// + public static IReadOnlyList<(string Href, string RefType)> ExtractReferences( + ReadOnlyMemory document, + int maxJsonDepth) + { + var references = new List<(string, string)>(); + try + { + var options = new JsonDocumentOptions { MaxDepth = maxJsonDepth }; + using var json = JsonDocument.Parse(document, options); + JsonElement root = json.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return references; + } + CollectLinks(root, references); + CollectExtends(root, references); + CollectTmRefs(root, references, 0, maxJsonDepth); + } + catch (JsonException) + { + // A document that cannot be parsed contributes no edges; its own + // projection reports the parse failure. + } + return references; + } + + /// + /// Builds the dependency closures for the selected resources. Selected + /// resources are grouped into weakly-connected components (so a shared + /// Thing Model lands in a single closure), then each component is + /// topologically ordered. + /// + public static ImmutableArray BuildClosures( + WotRegistrySnapshot snapshot, + IReadOnlyCollection selected, + int maxJsonDepth) + { + if (selected.Count == 0) + { + return []; + } + + // Expand the selection to include resolvable transitive dependencies. + var byXid = new Dictionary(StringComparer.Ordinal); + var queue = new Queue(); + foreach (WotResource resource in selected) + { + if (!byXid.ContainsKey(resource.Xid)) + { + byXid[resource.Xid] = resource; + queue.Enqueue(resource); + } + } + + var edges = new Dictionary>(StringComparer.Ordinal); + while (queue.Count > 0) + { + WotResource resource = queue.Dequeue(); + var list = new List(); + edges[resource.Xid] = list; + WotResourceVersion? version = resource.DefaultVersion; + if (version is null) + { + continue; + } + foreach ((string href, string refType) in ExtractReferences( + version.Content, maxJsonDepth)) + { + WotResource? target = Resolve(snapshot, href); + list.Add(new WotDependency( + resource.Xid, href, target?.Xid, refType, target is not null)); + if (target is not null && !byXid.ContainsKey(target.Xid)) + { + byXid[target.Xid] = target; + queue.Enqueue(target); + } + } + } + + // Weakly-connected components via union-find over resolved edges. + var parent = new Dictionary(StringComparer.Ordinal); + foreach (string xid in byXid.Keys) + { + parent[xid] = xid; + } + foreach (List list in edges.Values) + { + foreach (WotDependency edge in list) + { + if (edge.Resolved && + edge.TargetXid is not null && + byXid.ContainsKey(edge.TargetXid)) + { + Union(parent, edge.SourceXid, edge.TargetXid); + } + } + } + + var components = new Dictionary>(StringComparer.Ordinal); + foreach (KeyValuePair entry in byXid) + { + string root = Find(parent, entry.Key); + if (!components.TryGetValue(root, out List? members)) + { + members = []; + components[root] = members; + } + members.Add(entry.Value); + } + + ImmutableArray.Builder closures = + ImmutableArray.CreateBuilder(); + foreach (List members in components.Values) + { + closures.Add(BuildClosure(members, edges, byXid)); + } + // Deterministic order by closure key. + return [.. closures.OrderBy(c => c.Key, StringComparer.Ordinal)]; + } + + private static WotDependencyClosure BuildClosure( + List members, + Dictionary> edges, + Dictionary byXid) + { + var memberXids = new HashSet(members.Select(m => m.Xid), StringComparer.Ordinal); + ImmutableArray.Builder dependencies = ImmutableArray.CreateBuilder(); + ImmutableArray.Builder diagnostics = ImmutableArray.CreateBuilder(); + bool missing = false; + + // Adjacency (source depends on target): target must be ordered first. + var adjacency = new Dictionary>(StringComparer.Ordinal); + foreach (WotResource member in members) + { + adjacency[member.Xid] = []; + } + foreach (WotResource member in members) + { + if (!edges.TryGetValue(member.Xid, out List? list)) + { + continue; + } + foreach (WotDependency edge in list) + { + dependencies.Add(edge); + if (!edge.Resolved) + { + missing = true; + diagnostics.Add( + $"Unresolved {edge.RefType} dependency '{edge.TargetHref}' " + + $"referenced by '{edge.SourceXid}'."); + } + else if (edge.TargetXid is not null && memberXids.Contains(edge.TargetXid)) + { + adjacency[member.Xid].Add(edge.TargetXid); + } + } + } + + (ImmutableArray ordered, bool hasCycle) = TopologicalSort( + members, adjacency, byXid); + if (hasCycle) + { + diagnostics.Add( + "Dependency cycle detected among: " + + string.Join(", ", members.Select(m => m.Xid).OrderBy(x => x, StringComparer.Ordinal))); + } + + string key = string.Join( + "|", members.Select(m => m.Xid).OrderBy(x => x, StringComparer.Ordinal)); + var memberArray = members + .OrderBy(m => m.Xid, StringComparer.Ordinal) + .ToImmutableArray(); + return new WotDependencyClosure( + key, + memberArray, + ordered, + dependencies.ToImmutable(), + diagnostics.ToImmutable(), + hasCycle, + missing); + } + + private static (ImmutableArray Ordered, bool HasCycle) TopologicalSort( + List members, + Dictionary> adjacency, + Dictionary byXid) + { + // 0 = unvisited, 1 = in-progress, 2 = done. + var color = new Dictionary(StringComparer.Ordinal); + var ordered = new List(); + bool hasCycle = false; + + // Deterministic iteration order. + IEnumerable roots = members + .Select(m => m.Xid) + .OrderBy(x => x, StringComparer.Ordinal); + + void Visit(string xid) + { + if (hasCycle) + { + return; + } + color.TryGetValue(xid, out int state); + if (state == 2) + { + return; + } + if (state == 1) + { + hasCycle = true; + return; + } + color[xid] = 1; + foreach (string dependency in adjacency[xid] + .OrderBy(x => x, StringComparer.Ordinal)) + { + Visit(dependency); + if (hasCycle) + { + return; + } + } + color[xid] = 2; + ordered.Add(byXid[xid]); + } + + foreach (string root in roots) + { + Visit(root); + } + + return hasCycle + ? (ImmutableArray.Empty, true) + : ([.. ordered], false); + } + + private static WotResource? MatchIn(IEnumerable resources, string href) + { + foreach (WotResource resource in resources) + { + if (string.Equals(resource.ThingId, href, StringComparison.Ordinal) || + string.Equals(resource.Xid, href, StringComparison.Ordinal) || + string.Equals(RegistryUri(resource), href, StringComparison.Ordinal) || + string.Equals(resource.ResourceId, href, StringComparison.Ordinal) || + href.EndsWith("/" + resource.ResourceId, StringComparison.Ordinal)) + { + return resource; + } + } + return null; + } + + private static string RegistryUri(WotResource resource) + { + return $"urn:wot:{resource.GroupId}/{resource.ResourceId}"; + } + + private static string TrimFragment(string href) + { + int hash = href.AsSpan().IndexOf('#'); + return hash >= 0 ? href[..hash] : href; + } + + private static void CollectLinks( + JsonElement root, List<(string, string)> references) + { + if (!root.TryGetProperty("links", out JsonElement links) || + links.ValueKind != JsonValueKind.Array) + { + return; + } + foreach (JsonElement link in links.EnumerateArray()) + { + if (link.ValueKind != JsonValueKind.Object || + !link.TryGetProperty("href", out JsonElement hrefElement) || + hrefElement.ValueKind != JsonValueKind.String) + { + continue; + } + string rel = link.TryGetProperty("rel", out JsonElement relElement) && + relElement.ValueKind == JsonValueKind.String + ? relElement.GetString() ?? string.Empty + : string.Empty; + if (rel is "tm:extends" or "type" or "tm:submodel" or "collection" or "item") + { + references.Add((hrefElement.GetString() ?? string.Empty, rel)); + } + } + } + + private static void CollectExtends( + JsonElement root, List<(string, string)> references) + { + if (!root.TryGetProperty("tm:extends", out JsonElement extends)) + { + return; + } + switch (extends.ValueKind) + { + case JsonValueKind.String: + references.Add((extends.GetString() ?? string.Empty, "tm:extends")); + break; + case JsonValueKind.Array: + foreach (JsonElement item in extends.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + references.Add((item.GetString() ?? string.Empty, "tm:extends")); + } + else if (item.ValueKind == JsonValueKind.Object && + item.TryGetProperty("href", out JsonElement href) && + href.ValueKind == JsonValueKind.String) + { + references.Add((href.GetString() ?? string.Empty, "tm:extends")); + } + } + break; + } + } + + private static void CollectTmRefs( + JsonElement element, + List<(string, string)> references, + int depth, + int maxDepth) + { + if (depth > maxDepth) + { + return; + } + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (JsonProperty property in element.EnumerateObject()) + { + if (string.Equals(property.Name, "tm:ref", StringComparison.Ordinal) && + property.Value.ValueKind == JsonValueKind.String) + { + references.Add((property.Value.GetString() ?? string.Empty, "tm:ref")); + } + else + { + CollectTmRefs(property.Value, references, depth + 1, maxDepth); + } + } + break; + case JsonValueKind.Array: + foreach (JsonElement item in element.EnumerateArray()) + { + CollectTmRefs(item, references, depth + 1, maxDepth); + } + break; + } + } + + private static string Find(Dictionary parent, string node) + { + string root = node; + while (!string.Equals(parent[root], root, StringComparison.Ordinal)) + { + root = parent[root]; + } + // Path compression. + while (!string.Equals(parent[node], root, StringComparison.Ordinal)) + { + string next = parent[node]; + parent[node] = root; + node = next; + } + return root; + } + + private static void Union(Dictionary parent, string a, string b) + { + string rootA = Find(parent, a); + string rootB = Find(parent, b); + if (!string.Equals(rootA, rootB, StringComparison.Ordinal)) + { + parent[rootB] = rootA; + } + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs new file mode 100644 index 0000000000..dde3cdf5b6 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs @@ -0,0 +1,1387 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Export; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Coordinates projecting registry documents into the AddressSpace. It parses + /// and validates each document with , builds the TD/TM + /// dependency closures, converts each closure to one or more NodeSet2 + /// documents and projects them through the + /// (runtime NodeSet Add for first activation, ShadowReload for updates). The + /// stable registry NodeManager is kept separate. Independent closures commit + /// independently; a failed or invalid closure retains its previous active + /// generation. An unchanged closure (same digest, options and binder version) + /// returns and emits no model change. + /// + public sealed class WotMaterializationCoordinator : IDisposable + { + /// + /// Initializes a new coordinator. + /// + public WotMaterializationCoordinator( + IWotRegistryService registry, + IWotProjectionHost projectionHost, + IWotBinderRegistry? binderRegistry = null, + WotNodeSetConverterOptions? converterOptions = null, + IWotDocumentConverter? documentConverter = null, + IEnumerable? nodeSetContributors = null, + IWotNodeSetResolver? nodeSetResolver = null) + { + m_registry = registry ?? throw new ArgumentNullException(nameof(registry)); + m_host = projectionHost ?? throw new ArgumentNullException(nameof(projectionHost)); + m_binders = binderRegistry ?? NullWotBinderRegistry.Instance; + m_converterOptions = converterOptions ?? new WotNodeSetConverterOptions(); + m_converter = documentConverter + ?? new WotNodeSetDocumentConverter(m_converterOptions); + m_nodeSetContributors = nodeSetContributors is null + ? [] + : [.. nodeSetContributors]; + m_nodeSetResolver = nodeSetResolver; + } + + /// + /// Raised for each materialization event (resource / validation / load / refresh). + /// + public event EventHandler? Event; + + /// + /// Gets the current refresh generation. + /// + public uint Generation => m_generation; + + /// + /// Refreshes (re-projects) the registry into the AddressSpace and returns + /// the detailed result. + /// + /// + public async ValueTask RefreshAsync( + WotRefreshRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (!TryBeginOperation(allowDisposed: false)) + { + throw new ObjectDisposedException(nameof(WotMaterializationCoordinator)); + } + + try + { + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + DateTime start = DateTime.UtcNow; + WotRegistrySnapshot snapshot = m_registry.Current; + + if (request.ExpectedGeneration != 0 && + request.ExpectedGeneration != m_generation) + { + return RejectedResult(request, start); + } + + bool dryRun = request.Options?.DryRun ?? false; + bool force = request.Options?.Force ?? false; + bool strict = StrictBindings; + HashSet selectedXids = ResolveSelection(snapshot, request.Selection); + + var enabled = snapshot.AllResources() + .Where(r => r.Enabled && r.DefaultVersion is not null) + .ToList(); + ImmutableArray closures = + WotDependencyGraph.BuildClosures( + snapshot, enabled, m_converterOptions.MaxJsonDepth); + + var targetKeys = new HashSet( + closures.Select(c => c.Key), StringComparer.Ordinal); + + uint newGeneration = m_generation + 1; + ImmutableArray.Builder results = + ImmutableArray.CreateBuilder(); + var projections = new List(); + int succeeded = 0; + int unchanged = 0; + int failed = 0; + int skipped = 0; + int retired = 0; + + // Retire tracked closures no longer desired (deleted / disabled / + // membership changed) after their monitored items drain. + (int retiredCount, ImmutableArray retiredResults) = + await ReconcileRetirementsAsync( + targetKeys, newGeneration, dryRun, cancellationToken).ConfigureAwait(false); + retired += retiredCount; + skipped += retiredResults.Length; + results.AddRange(retiredResults); + + foreach (WotDependencyClosure closure in closures) + { + cancellationToken.ThrowIfCancellationRequested(); + bool inScope = selectedXids.Count == 0 || + closure.OrderedResources.Any(r => selectedXids.Contains(r.Xid)) || + MembersOf(closure).Any(r => selectedXids.Contains(r.Xid)); + + ClosureOutcome outcome = await ProcessClosureAsync( + snapshot, closure, newGeneration, force && inScope, + dryRun, strict, cancellationToken).ConfigureAwait(false); + + foreach (WoTResourceLoadResultDataType result in outcome.Results) + { + string resultXid = result.Xid ?? string.Empty; + if (selectedXids.Count != 0 && !selectedXids.Contains(resultXid)) + { + continue; + } + results.Add(result); + switch (result.Outcome) + { + case WoTOutcomeEnum.Success: + case WoTOutcomeEnum.Warning: + succeeded++; + break; + case WoTOutcomeEnum.Unchanged: + unchanged++; + break; + case WoTOutcomeEnum.Skipped: + skipped++; + break; + default: + failed++; + break; + } + } + projections.AddRange(outcome.Projections); + } + + if (!dryRun && projections.Count > 0) + { + await m_registry.ApplyProjectionResultsAsync( + projections, cancellationToken).ConfigureAwait(false); + } + if (!dryRun) + { + m_generation = newGeneration; + } + + WoTOutcomeEnum overall = failed > 0 + ? (succeeded > 0 ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Failed) + : (succeeded > 0 ? WoTOutcomeEnum.Success : WoTOutcomeEnum.Unchanged); + + var summary = new WoTRefreshSummaryDataType + { + RequestId = request.RequestId ?? string.Empty, + Generation = dryRun ? 0 : newGeneration, + Outcome = overall, + Atomicity = request.Options?.Atomicity ?? WoTAtomicityEnum.PerClosure, + StartTime = start, + EndTime = DateTime.UtcNow, + Total = (uint)results.Count, + Succeeded = (uint)succeeded, + Unchanged = (uint)unchanged, + Failed = (uint)failed, + Skipped = (uint)skipped, + Retired = (uint)retired + }; + + RaiseEvent(new WotMaterializationEventArgs( + WotMaterializationEventKind.RefreshCompleted) + { + Generation = newGeneration, + RequestId = request.RequestId ?? string.Empty, + Outcome = overall, + Summary = summary + }); + + return new WotRefreshResult( + summary, results.ToImmutable(), dryRun ? 0u : newGeneration); + } + finally + { + m_mutex.Release(); + } + } + finally + { + EndOperation(); + } + } + + /// + /// Removes all live projections (used during NodeManager shutdown). + /// + public async ValueTask RemoveAllAsync(CancellationToken cancellationToken = default) + { + if (!TryBeginOperation(allowDisposed: true)) + { + return; + } + + try + { + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + foreach (ClosureState state in m_closures.Values) + { + // Deactivate bindings before removing the projection (before + // retirement / unload), then release the projection handle. + foreach (WotBindingPlan plan in state.BindingPlans) + { + await m_binders.DeactivateAsync(plan, cancellationToken) + .ConfigureAwait(false); + } + if (state.Handle is not null) + { + await m_host.RemoveAsync(state.Handle, cancellationToken) + .ConfigureAwait(false); + } + } + m_closures.Clear(); + } + finally + { + m_mutex.Release(); + } + } + finally + { + EndOperation(); + } + } + + /// + /// Gets or sets whether unsupported forms fail a strict closure. + /// + public bool StrictBindings { get; set; } + + /// + /// Gets or sets how previous projection generations are retired after a + /// successful version switch. + /// + public WotProjectionRetirementPolicy RetirementPolicy { get; set; } = + WotProjectionRetirementPolicy.Graceful; + + /// + /// Gets the binding capability snapshots advertised by the registered + /// binders. These populate the registry SelectedBindings node and + /// contribute to refresh unchanged-detection. + /// + public IReadOnlyList BindingCapabilities => m_binders.Capabilities; + + /// + /// Gets or sets the live server namespace table used to resolve a + /// projection's recorded root into a + /// concrete server NodeId after its owning namespace is registered by + /// the projection host. When null, materialized root NodeIds are + /// not reported. + /// + public NamespaceTable? ServerNamespaceUris { get; set; } + + /// + /// Releases the mutex used to serialise refreshes. + /// + public void Dispose() + { + bool disposeMutex; + lock (m_lifetimeLock) + { + if (m_disposed != 0) + { + return; + } + m_disposed = 1; + disposeMutex = TryReserveMutexDisposal(); + } + if (disposeMutex) + { + m_mutex.Dispose(); + } + } + + private bool TryBeginOperation(bool allowDisposed) + { + lock (m_lifetimeLock) + { + if (m_mutexDisposed || (!allowDisposed && m_disposed != 0)) + { + return false; + } + m_activeOperations++; + return true; + } + } + + private void EndOperation() + { + bool disposeMutex; + lock (m_lifetimeLock) + { + m_activeOperations--; + disposeMutex = TryReserveMutexDisposal(); + } + if (disposeMutex) + { + m_mutex.Dispose(); + } + } + + private bool TryReserveMutexDisposal() + { + if (m_disposed == 0 || + m_activeOperations != 0 || + m_closures.Count != 0 || + m_mutexDisposed) + { + return false; + } + m_mutexDisposed = true; + return true; + } + + private static ByteString DigestOf(WotResource resource) + { + return (ByteString)(resource.DefaultVersion?.Digest ?? []); + } + + private async ValueTask ProcessClosureAsync( + WotRegistrySnapshot snapshot, + WotDependencyClosure closure, + uint generation, + bool force, + bool dryRun, + bool strict, + CancellationToken cancellationToken) + { + ImmutableArray.Builder results = + ImmutableArray.CreateBuilder(); + var projections = new List(); + IReadOnlyList members = MembersOf(closure); + + // Unprojectable closure: cycle or missing dependency. Retain the + // previous active generation and mark members failed. + if (!closure.IsProjectable) + { + WoTPhaseEnum phase = closure.HasMissingDependency + ? WoTPhaseEnum.DependencyResolution + : WoTPhaseEnum.DependencyResolution; + string reason = string.Join("; ", closure.Diagnostics); + foreach (WotResource member in members) + { + results.Add(FailResult(member, generation, phase, reason)); + projections.Add(FailProjection(member, reason)); + RaiseLoadFailure(member, generation, reason); + } + return new ClosureOutcome(results.ToImmutable(), projections); + } + + // Project in topological (dependency-first) order. + members = closure.OrderedResources; + + byte[] aggregateDigest = ComputeAggregateDigest(members); + m_closures.TryGetValue(closure.Key, out ClosureState? tracked); + + // Unchanged: same digest/options/binder version, and not forced. + if (tracked?.Handle is not null && + !force && + WotContentDigest.Equal(tracked.AggregateDigest, aggregateDigest)) + { + foreach (WotResource member in members) + { + results.Add(UnchangedResult(member, tracked.Generation)); + } + return new ClosureOutcome(results.ToImmutable(), projections); + } + + // Convert every member to a NodeSet2 source in dependency order. + ImmutableArray.Builder sources = ImmutableArray.CreateBuilder(); + var perMemberNodeCount = new Dictionary(StringComparer.Ordinal); + var perMemberRoot = new Dictionary(StringComparer.Ordinal); + var bindingPlans = new List(); + bool degraded = false; + var requiredNamespaces = new HashSet(StringComparer.Ordinal); + var ownedNamespaces = new HashSet(StringComparer.Ordinal); + + foreach (WotResource member in members) + { + WotResourceVersion? version = member.DefaultVersion; + if (version is null) + { + const string reason = "Resource has no default version."; + results.Add(FailResult(member, generation, WoTPhaseEnum.Fetch, reason)); + projections.Add(FailProjection(member, reason)); + RaiseLoadFailure(member, generation, reason); + return new ClosureOutcome(results.ToImmutable(), projections); + } + + (UANodeSet? nodeSet, ExpandedNodeId root, string? conversionError) = + await TryConvertAsync(member, snapshot, cancellationToken) + .ConfigureAwait(false); + if (nodeSet is not null && m_nodeSetContributors.Length > 0) + { + // Contributors run after conversion and before any variable is created, which + // is when a programmatically discovered DataType (a controller UDT, say) has to + // exist for a uav:mapByFieldPath mapping to resolve against it. + foreach (IWotNodeSetContributor contributor in m_nodeSetContributors) + { + await contributor + .ContributeAsync(member, nodeSet, cancellationToken) + .ConfigureAwait(false); + } + } + if (nodeSet is null) + { + WoTValidationOutcomeDataType validation = FormatFailure(conversionError); + results.Add(FailResult( + member, generation, WoTPhaseEnum.FormatValidation, conversionError)); + projections.Add(FailProjection(member, conversionError, validation)); + RaiseValidationFailure(member, generation, validation, conversionError); + return new ClosureOutcome(results.ToImmutable(), projections); + } + + WotBindingPlan plan = m_binders.Prepare(BuildPlanRequest(member, version)); + bindingPlans.Add(plan); + if (!plan.FullySupported) + { + if (strict) + { + const string reason = "Unsupported binding forms in a strict closure."; + results.Add(FailResult( + member, generation, WoTPhaseEnum.Projection, reason)); + projections.Add(FailProjection(member, reason)); + RaiseBindingFailure(member, reason); + return new ClosureOutcome(results.ToImmutable(), projections); + } + degraded = true; + RaiseBindingFailure(member, + "Unsupported binding forms materialized as degraded nodes."); + } + else if (plan.HasNonExecutableForms) + { + // A validated plan whose binding has no runtime executor (for + // example a planner-only protocol): materialize the nodes but + // flag the closure as degraded so callers know they cannot be + // driven yet. + degraded = true; + } + + byte[] xml = SerializeNodeSet(nodeSet); + perMemberNodeCount[member.Xid] = nodeSet.Items?.Length ?? 0; + if (!root.IsNull) + { + perMemberRoot[member.Xid] = root; + } + sources.Add(new WotProjectionSource( + member.ResourceId, OwnedModelUris(nodeSet), xml)); + CollectRequiredNamespaces(nodeSet, requiredNamespaces, ownedNamespaces); + } + + // Resolve any companion-specification namespace the closure depends on that neither the + // closure itself nor the server already provides. Resolved models are prepended to the + // sources so they materialize before the documents that reference them. A namespace + // that stays unresolved is reported, never silently dropped: the projection then fails + // with a message naming exactly what is missing. + (ImmutableArray resolved, ImmutableArray unresolved) = + await ResolveDependencyModelsAsync( + requiredNamespaces, ownedNamespaces, cancellationToken) + .ConfigureAwait(false); + if (!resolved.IsDefaultOrEmpty) + { + sources.InsertRange(0, resolved); + } + if (!unresolved.IsDefaultOrEmpty) + { + degraded = true; + foreach (WotResource member in members) + { + RaiseBindingFailure( + member, + "Unresolved dependency namespace(s): " + string.Join(", ", unresolved)); + } + } + + if (dryRun) + { + foreach (WotResource member in members) + { + results.Add(new WoTResourceLoadResultDataType + { + Xid = member.Xid, + GroupId = member.GroupId, + ResourceId = member.ResourceId, + VersionId = member.DefaultVersionId ?? string.Empty, + Kind = member.Kind, + Outcome = degraded ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Success, + Phase = WoTPhaseEnum.Projection, + LoadState = member.LoadState, + Generation = generation, + MaterializedNodeCount = (uint)(perMemberNodeCount.TryGetValue( + member.Xid, out int c) ? c : 0), + ContentDigest = DigestOf(member), + Message = "Dry run; no projection committed. Candidate generation " + + generation.ToString(CultureInfo.InvariantCulture) + "." + }); + } + return new ClosureOutcome(results.ToImmutable(), projections); + } + + var document = new WotProjectionDocument( + closure.Key, sources.ToImmutable(), bindingPlans.ToArrayOf()); + WotProjectionHandle handle; + try + { + if (tracked?.Handle is null) + { + handle = await m_host.AddAsync(document, cancellationToken) + .ConfigureAwait(false); + } + else if (RetirementPolicy == WotProjectionRetirementPolicy.Immediate) + { + handle = await m_host.ImmediateReloadAsync( + tracked.Handle, document, cancellationToken).ConfigureAwait(false); + } + else + { + handle = await m_host.ShadowReloadAsync( + tracked.Handle, document, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Projection failed: retain the previous active generation and its + // tracked binding plans. The shadow switch never happened, so the + // old plans remain active and no deactivation is performed + // (rollback: old plans survive when the new switch fails). + foreach (WotResource member in members) + { + results.Add(FailResult( + member, generation, WoTPhaseEnum.Activation, ex.Message)); + projections.Add(FailProjection(member, ex.Message)); + RaiseLoadFailure(member, generation, ex.Message); + } + return new ClosureOutcome(results.ToImmutable(), projections); + } + + string projectionWarning = handle.Warning; + if (projectionWarning.Length != 0) + { + degraded = true; + } + + // The shadow switch (or first add) succeeded. On an update, retire the + // previously tracked binding plans before publishing the new closure + // state so they are not leaked. This runs after the successful switch + // and before the closure state is replaced; deactivating the old plans + // first (then activating the new plans below) keeps a resource that is + // shared between the old and new plan sets continuously bound. + if (tracked is not null) + { + foreach (WotBindingPlan plan in tracked.BindingPlans) + { + await m_binders.DeactivateAsync(plan, cancellationToken).ConfigureAwait(false); + } + } + + m_closures[closure.Key] = new ClosureState + { + Key = closure.Key, + Handle = handle, + AggregateDigest = aggregateDigest, + Generation = generation, + MemberXids = [.. members.Select(m => m.Xid)], + Members = [.. members.Select(m => new ClosureMemberState + { + Xid = m.Xid, + GroupId = m.GroupId, + ResourceId = m.ResourceId, + VersionId = m.DefaultVersionId ?? string.Empty, + Kind = m.Kind, + ContentDigest = DigestOf(m) + })], + ModelNamespaceUris = [.. sources.SelectMany(s => s.ModelNamespaceUris)], + BindingPlans = [.. bindingPlans] + }; + foreach (string namespaceUri in sources.SelectMany(s => s.ModelNamespaceUris)) + { + m_projectionNamespaceUris.Add(namespaceUri); + } + + foreach (WotBindingPlan plan in bindingPlans) + { + await m_binders.ActivateAsync(plan, cancellationToken).ConfigureAwait(false); + } + + WoTOutcomeEnum memberOutcome = degraded ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Success; + foreach (WotResource member in members) + { + int nodeCount = perMemberNodeCount.TryGetValue(member.Xid, out int c) ? c : 0; + NodeId rootNodeId = perMemberRoot.TryGetValue(member.Xid, out ExpandedNodeId root) + ? ResolveRootNodeId(root) + : NodeId.Null; + WoTValidationOutcomeDataType validation = SuccessValidation(); + results.Add(new WoTResourceLoadResultDataType + { + Xid = member.Xid, + GroupId = member.GroupId, + ResourceId = member.ResourceId, + VersionId = member.DefaultVersionId ?? string.Empty, + Kind = member.Kind, + Outcome = memberOutcome, + Phase = WoTPhaseEnum.Activation, + LoadState = WoTLoadStateEnum.Active, + Generation = generation, + MaterializedNodeCount = (uint)nodeCount, + RootNodeId = rootNodeId, + ContentDigest = DigestOf(member), + Message = projectionWarning.Length != 0 + ? "Projected with warning: " + projectionWarning + : degraded ? "Projected with degraded bindings." : "Projected." + }); + projections.Add(new WotResourceProjection( + member.GroupId, + member.ResourceId, + WoTLoadStateEnum.Active, + member.DefaultVersionId, + generation, + nodeCount, + rootNodeId, + validation, + projectionWarning.Length == 0 + ? [] + : [projectionWarning], + DateTime.UtcNow)); + RaiseResource(member, generation, memberOutcome, WoTLoadStateEnum.Active); + } + + return new ClosureOutcome(results.ToImmutable(), projections); + } + + private async ValueTask<(int Retired, ImmutableArray Results)> + ReconcileRetirementsAsync( + HashSet targetKeys, + uint generation, + bool dryRun, + CancellationToken cancellationToken) + { + ImmutableArray.Builder results = + ImmutableArray.CreateBuilder(); + int retired = 0; + foreach (string key in (List)[.. m_closures.Keys.Where(k => !targetKeys.Contains(k))]) + { + if (m_closures.TryGetValue(key, out ClosureState? state)) + { + if (state.Handle is not null) + { + retired++; + foreach (ClosureMemberState member in state.Members) + { + results.Add(RetiredResult(member, generation)); + } + if (dryRun) + { + continue; + } + // Deactivate bindings before retiring the projection. + foreach (WotBindingPlan plan in state.BindingPlans) + { + await m_binders.DeactivateAsync(plan, cancellationToken) + .ConfigureAwait(false); + } + await m_host.RemoveAsync(state.Handle, cancellationToken) + .ConfigureAwait(false); + } + if (!dryRun) + { + m_closures.Remove(key); + } + } + } + return (retired, dryRun ? results.ToImmutable() : []); + } + + private async ValueTask<(UANodeSet? NodeSet, ExpandedNodeId Root, string? Error)> TryConvertAsync( + WotResource resource, WotRegistrySnapshot snapshot, CancellationToken cancellationToken) + { + WotResourceVersion? version = resource.DefaultVersion; + if (version is null) + { + return (null, default, "Resource has no default version."); + } + WotConversionOutput output = await m_converter + .ConvertAsync(resource, version.Content, snapshot, cancellationToken) + .ConfigureAwait(false); + if (!output.Succeeded) + { + return (null, default, output.Errors.IsDefaultOrEmpty + ? "The document could not be converted to a NodeSet." + : string.Join("; ", output.Errors)); + } + return (output.NodeSet, output.RootNodeId, null); + } + + /// + /// Resolves a projection root, recorded before lifecycle add as an + /// absolute , into a concrete server NodeId + /// once its owning namespace has been registered by the projection host. + /// Returns NodeId.Null when there is no root or the namespace table is + /// unavailable or does not yet contain the owning namespace. + /// + private NodeId ResolveRootNodeId(ExpandedNodeId root) + { + if (root.IsNull) + { + return NodeId.Null; + } + NamespaceTable? namespaces = ServerNamespaceUris; + if (namespaces is null) + { + return NodeId.Null; + } + var resolved = ExpandedNodeId.ToNodeId(root, namespaces); + return resolved.IsNull ? NodeId.Null : resolved; + } + + private WotBindingPlanRequest BuildPlanRequest( + WotResource resource, WotResourceVersion version) + { + return WotBindingPlanRequest.FromDocument( + resource.Xid, resource.Kind, version.Content, m_converterOptions.MaxJsonDepth); + } + + private byte[] ComputeAggregateDigest(IReadOnlyList members) + { + using var sha = SHA256.Create(); + using var buffer = new MemoryStream(); + using (var writer = new BinaryWriter(buffer, Encoding.UTF8, leaveOpen: true)) + { + foreach (WotResource member in members + .OrderBy(m => m.Xid, StringComparer.Ordinal)) + { + writer.Write(member.Xid); + writer.Write(member.DefaultVersionId ?? string.Empty); + byte[] digest = member.DefaultVersion?.Digest ?? []; + writer.Write(digest.Length); + writer.Write(digest); + } + writer.Write(m_converterOptions.MaxJsonDepth); + writer.Write(BinderVersion); + } + buffer.Position = 0; + // TODO: SHA256.HashData(ReadOnlySpan) is only available on .NET 5+; + // this project also targets net472/net48/netstandard2.1, where the instance + // ComputeHash API is the portable equivalent. Revisit if the minimum TFM + // floor is ever raised to drop those targets. +#pragma warning disable CA1850 + return sha.ComputeHash(buffer.ToArray()); +#pragma warning restore CA1850 + } + + private string BinderVersion + { + get + { + IReadOnlyList caps = m_binders.Capabilities; + if (caps.Count == 0) + { + return "none"; + } + var builder = new StringBuilder(); + foreach (WoTBindingCapabilityDataType cap in caps) + { + builder.Append(cap.BindingUri).Append(';').Append(cap.ProfileVersion).Append('|'); + } + return builder.ToString(); + } + } + + private static byte[] SerializeNodeSet(UANodeSet nodeSet) + { + using var stream = new MemoryStream(); + nodeSet.Write(stream); + return stream.ToArray(); + } + + private static ImmutableArray OwnedModelUris(UANodeSet nodeSet) + { + if (nodeSet.Models is { Length: > 0 }) + { + var uris = new List(nodeSet.Models.Length); + foreach (ModelTableEntry model in nodeSet.Models) + { + if (!string.IsNullOrEmpty(model.ModelUri)) + { + uris.Add(model.ModelUri); + } + } + if (uris.Count > 0) + { + return [.. uris]; + } + } + if (nodeSet.NamespaceUris is { Length: > 0 }) + { + return + [ + .. nodeSet.NamespaceUris + .Where(u => !string.Equals(u, Ua.Namespaces.OpcUa, StringComparison.Ordinal)) + ]; + } + return []; + } + + /// + /// Records the namespaces a converted NodeSet owns and the ones it declares a dependency + /// on, so the closure's unmet dependencies can be resolved once for the whole projection. + /// + private static void CollectRequiredNamespaces( + UANodeSet nodeSet, + HashSet required, + HashSet owned) + { + foreach (string uri in OwnedModelUris(nodeSet)) + { + owned.Add(uri); + } + if (nodeSet.Models is null) + { + return; + } + foreach (ModelTableEntry model in nodeSet.Models) + { + if (model?.RequiredModel is null) + { + continue; + } + foreach (ModelTableEntry dependency in model.RequiredModel) + { + if (!string.IsNullOrEmpty(dependency?.ModelUri) && + !string.Equals( + dependency!.ModelUri, Ua.Namespaces.OpcUa, StringComparison.Ordinal)) + { + required.Add(dependency.ModelUri); + } + } + } + } + + /// + /// Asks the configured for every dependency namespace the + /// closure needs but neither owns nor finds on the server, recursing into whatever it gets + /// back. Returns the resolved models in dependency order together with the namespaces that + /// stayed unresolved. + /// + private async ValueTask<(ImmutableArray Resolved, + ImmutableArray Unresolved)> ResolveDependencyModelsAsync( + HashSet required, + HashSet owned, + CancellationToken cancellationToken) + { + var pending = new Queue(); + foreach (string uri in required) + { + if (!owned.Contains(uri) && !IsKnownToServer(uri)) + { + pending.Enqueue(uri); + } + } + if (pending.Count == 0) + { + return ([], []); + } + if (m_nodeSetResolver is null) + { + return ([], [.. pending]); + } + + ImmutableArray.Builder resolved = + ImmutableArray.CreateBuilder(); + ImmutableArray.Builder unresolved = ImmutableArray.CreateBuilder(); + var seen = new HashSet(pending, StringComparer.Ordinal); + while (pending.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + string uri = pending.Dequeue(); + Stream? stream = await m_nodeSetResolver + .TryResolveAsync(uri, cancellationToken) + .ConfigureAwait(false); + if (stream is null) + { + unresolved.Add(uri); + continue; + } + + byte[] xml; + UANodeSet? dependency; + using (stream) + { + MemoryStream? buffer = await CopyResolverDocumentAsync( + stream, uri, unresolved, cancellationToken) + .ConfigureAwait(false); + if (buffer is null) + { + continue; + } + using (buffer) + { + xml = buffer.ToArray(); + buffer.Position = 0; + dependency = UANodeSet.Read(buffer); + } + } + if (dependency is null) + { + // A resolver that hands back something unreadable is treated exactly like one + // that declined, so the namespace is reported rather than faulting onboarding. + unresolved.Add(uri); + continue; + } + + foreach (string ownedUri in OwnedModelUris(dependency)) + { + owned.Add(ownedUri); + } + // A resolved model may itself depend on further namespaces. + var nested = new HashSet(StringComparer.Ordinal); + CollectRequiredNamespaces(dependency, nested, owned); + foreach (string nestedUri in nested) + { + if (!owned.Contains(nestedUri) && + !IsKnownToServer(nestedUri) && + seen.Add(nestedUri)) + { + pending.Enqueue(nestedUri); + } + } + resolved.Add(new WotProjectionSource(uri, OwnedModelUris(dependency), xml)); + } + + // Dependencies are appended in resolution order, so reverse to put the deepest model + // first: a model must be materialized before the model that requires it. + resolved.Reverse(); + return (resolved.ToImmutable(), unresolved.ToImmutable()); + } + + private bool IsKnownToServer(string namespaceUri) + { + foreach (ClosureState closure in m_closures.Values) + { + if (closure.ModelNamespaceUris.Contains(namespaceUri, StringComparer.Ordinal)) + { + return true; + } + } + if (m_projectionNamespaceUris.Contains(namespaceUri)) + { + return false; + } + NamespaceTable? namespaces = ServerNamespaceUris; + return namespaces is not null && namespaces.GetIndex(namespaceUri) >= 0; + } + + private async ValueTask CopyResolverDocumentAsync( + Stream stream, + string namespaceUri, + ImmutableArray.Builder unresolved, + CancellationToken cancellationToken) + { + int maxBytes = m_converterOptions.MaxResolverDocumentBytes; + var buffer = new MemoryStream(); + var chunk = new byte[81920]; + bool keepBuffer = false; + try + { + while (true) + { + int read = await ReadBlockAsync(stream, chunk, cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + buffer.Position = 0; + keepBuffer = true; + return buffer; + } + if (buffer.Length + read > maxBytes) + { + unresolved.Add( + namespaceUri + + " (resolver response exceeded " + + maxBytes.ToString(CultureInfo.InvariantCulture) + + " bytes)"); + return null; + } + buffer.Write(chunk, 0, read); + } + } + finally + { + if (!keepBuffer) + { + buffer.Dispose(); + } + } + } + + private static async ValueTask ReadBlockAsync( + Stream stream, + byte[] buffer, + CancellationToken cancellationToken) + { +#if NETFRAMEWORK || NETSTANDARD2_0 + return await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken) + .ConfigureAwait(false); +#else + return await stream.ReadAsync(buffer.AsMemory(), cancellationToken) + .ConfigureAwait(false); +#endif + } + + private static IReadOnlyList MembersOf(WotDependencyClosure closure) + { + return closure.Members.IsDefaultOrEmpty + ? Array.Empty() + : closure.Members; + } + + private HashSet ResolveSelection( + WotRegistrySnapshot snapshot, + ImmutableArray selectors) + { + var set = new HashSet(StringComparer.Ordinal); + if (selectors.IsDefaultOrEmpty) + { + return set; + } + foreach (WoTResourceSelectorDataType selector in selectors) + { + foreach (WotResource resource in snapshot.AllResources()) + { + if (Matches(resource, selector)) + { + set.Add(resource.Xid); + } + } + } + return set; + } + + private static bool Matches(WotResource resource, WoTResourceSelectorDataType selector) + { + if (!string.IsNullOrEmpty(selector.Xid) && + !string.Equals(selector.Xid, resource.Xid, StringComparison.Ordinal)) + { + return false; + } + if (!string.IsNullOrEmpty(selector.GroupId) && + !string.Equals(selector.GroupId, resource.GroupId, StringComparison.Ordinal)) + { + return false; + } + if (!string.IsNullOrEmpty(selector.ResourceId) && + !string.Equals(selector.ResourceId, resource.ResourceId, StringComparison.Ordinal)) + { + return false; + } + return true; + } + + private static WoTResourceLoadResultDataType FailResult( + WotResource resource, uint generation, WoTPhaseEnum phase, string? message) + { + return new() + { + Xid = resource.Xid, + GroupId = resource.GroupId, + ResourceId = resource.ResourceId, + VersionId = resource.DefaultVersionId ?? string.Empty, + Kind = resource.Kind, + Outcome = WoTOutcomeEnum.Failed, + Phase = phase, + LoadState = WoTLoadStateEnum.Failed, + Generation = generation, + MaterializedNodeCount = 0, + ContentDigest = DigestOf(resource), + Message = message ?? string.Empty + }; + } + + private static WoTResourceLoadResultDataType UnchangedResult( + WotResource resource, uint generation) + { + return new() + { + Xid = resource.Xid, + GroupId = resource.GroupId, + ResourceId = resource.ResourceId, + VersionId = resource.ActiveVersionId ?? resource.DefaultVersionId ?? string.Empty, + Kind = resource.Kind, + Outcome = WoTOutcomeEnum.Unchanged, + Phase = WoTPhaseEnum.Activation, + LoadState = WoTLoadStateEnum.Active, + Generation = generation, + MaterializedNodeCount = (uint)resource.MaterializedNodeCount, + ContentDigest = DigestOf(resource), + Message = "Content digest unchanged." + }; + } + + private static WoTResourceLoadResultDataType RetiredResult( + ClosureMemberState member, uint generation) + { + return new() + { + Xid = member.Xid, + GroupId = member.GroupId, + ResourceId = member.ResourceId, + VersionId = member.VersionId, + Kind = member.Kind, + Outcome = WoTOutcomeEnum.Skipped, + Phase = WoTPhaseEnum.Activation, + LoadState = WoTLoadStateEnum.Unloaded, + Generation = generation, + MaterializedNodeCount = 0, + ContentDigest = member.ContentDigest, + Message = "Dry run; projection would be retired at candidate generation " + + generation.ToString(CultureInfo.InvariantCulture) + "." + }; + } + + private static WotResourceProjection FailProjection( + WotResource resource, string? message, WoTValidationOutcomeDataType? validation = null) + { + return new( + resource.GroupId, + resource.ResourceId, + WoTLoadStateEnum.Failed, + activeVersionId: null, + resource.RefreshGeneration, + resource.MaterializedNodeCount, + rootNodeId: NodeId.Null, + validation, + string.IsNullOrEmpty(message) + ? [] + : [message!], + DateTime.UtcNow) + { + // Keep the previous active projection when a refresh fails. + RetainPreviousActiveVersion = true + }; + } + + private static WoTValidationOutcomeDataType SuccessValidation() + { + return new() + { + FormatValidated = true, + FormatOutcome = WoTOutcomeEnum.Success, + CompatibilityValidated = true, + CompatibilityOutcome = WoTOutcomeEnum.Success, + ValidatedAt = DateTime.UtcNow, + VocabularyVersion = WotNodeSetConverter.VocabularyNamespace + }; + } + + private static WoTValidationOutcomeDataType FormatFailure(string? reason) + { + return new() + { + FormatValidated = true, + FormatOutcome = WoTOutcomeEnum.Failed, + FormatReason = reason ?? string.Empty, + CompatibilityValidated = false, + CompatibilityOutcome = WoTOutcomeEnum.Skipped, + ValidatedAt = DateTime.UtcNow, + VocabularyVersion = WotNodeSetConverter.VocabularyNamespace + }; + } + + private void RaiseResource( + WotResource resource, uint generation, WoTOutcomeEnum outcome, WoTLoadStateEnum state) + { + RaiseEvent(new WotMaterializationEventArgs(WotMaterializationEventKind.Resource) + { + Xid = resource.Xid, + ResourceId = resource.ResourceId, + VersionId = resource.DefaultVersionId ?? string.Empty, + DocumentKind = resource.Kind, + Generation = generation, + Phase = WoTPhaseEnum.Activation, + Outcome = outcome, + LoadState = state + }); + } + + private void RaiseLoadFailure(WotResource resource, uint generation, string? reason) + { + RaiseEvent(new WotMaterializationEventArgs(WotMaterializationEventKind.LoadFailure) + { + Xid = resource.Xid, + ResourceId = resource.ResourceId, + VersionId = resource.DefaultVersionId ?? string.Empty, + DocumentKind = resource.Kind, + Generation = generation, + Phase = WoTPhaseEnum.Projection, + Outcome = WoTOutcomeEnum.Failed, + LoadState = WoTLoadStateEnum.Failed, + Reason = reason ?? string.Empty + }); + } + + private void RaiseValidationFailure( + WotResource resource, uint generation, + WoTValidationOutcomeDataType validation, string? reason) + { + RaiseEvent(new WotMaterializationEventArgs( + WotMaterializationEventKind.ValidationFailure) + { + Xid = resource.Xid, + ResourceId = resource.ResourceId, + VersionId = resource.DefaultVersionId ?? string.Empty, + DocumentKind = resource.Kind, + Generation = generation, + Phase = WoTPhaseEnum.FormatValidation, + Outcome = WoTOutcomeEnum.Failed, + LoadState = WoTLoadStateEnum.Failed, + Validation = validation, + Reason = reason ?? string.Empty + }); + } + + private void RaiseBindingFailure(WotResource resource, string? reason) + { + RaiseEvent(new WotMaterializationEventArgs( + WotMaterializationEventKind.BindingFailure) + { + Xid = resource.Xid, + ResourceId = resource.ResourceId, + DocumentKind = resource.Kind, + Outcome = WoTOutcomeEnum.Failed, + LoadState = WoTLoadStateEnum.Failed, + Reason = reason ?? string.Empty + }); + } + + private void RaiseEvent(WotMaterializationEventArgs args) + { + Event?.Invoke(this, args); + } + + private WotRefreshResult RejectedResult( + WotRefreshRequest request, DateTime start) + { + var summary = new WoTRefreshSummaryDataType + { + RequestId = request.RequestId ?? string.Empty, + Generation = 0, + Outcome = WoTOutcomeEnum.Rejected, + StartTime = start, + EndTime = DateTime.UtcNow + }; + return new WotRefreshResult( + summary, [], + m_generation); + } + + private sealed class ClosureMemberState + { + public string Xid { get; set; } = string.Empty; + public string GroupId { get; set; } = string.Empty; + public string ResourceId { get; set; } = string.Empty; + public string VersionId { get; set; } = string.Empty; + public WoTDocumentKindEnum Kind { get; set; } + public ByteString ContentDigest { get; set; } = []; + } + + private sealed class ClosureState + { + public string Key { get; set; } = string.Empty; + public WotProjectionHandle? Handle { get; set; } + public byte[] AggregateDigest { get; set; } = []; + public uint Generation { get; set; } + public ImmutableArray MemberXids { get; set; } = []; + + public ImmutableArray Members { get; set; } = []; + + public ImmutableArray ModelNamespaceUris { get; set; } = []; + + public ImmutableArray BindingPlans { get; set; } + = []; + } + + private sealed class ClosureOutcome + { + public ClosureOutcome( + ImmutableArray results, + List projections) + { + Results = results; + Projections = projections; + } + + public ImmutableArray Results { get; } + public List Projections { get; } + } + + private readonly IWotRegistryService m_registry; + private readonly IWotProjectionHost m_host; + private readonly IWotBinderRegistry m_binders; + private readonly IWotDocumentConverter m_converter; + private readonly ImmutableArray m_nodeSetContributors; + private readonly IWotNodeSetResolver? m_nodeSetResolver; + private readonly WotNodeSetConverterOptions m_converterOptions; + private readonly SemaphoreSlim m_mutex = new(1, 1); + private readonly System.Threading.Lock m_lifetimeLock = new(); + + private readonly Dictionary m_closures = + new(StringComparer.Ordinal); + private readonly HashSet m_projectionNamespaceUris = + new(StringComparer.Ordinal); + + private uint m_generation; + private int m_activeOperations; + private int m_disposed; + private bool m_mutexDisposed; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs new file mode 100644 index 0000000000..b47bcd6ce3 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs @@ -0,0 +1,251 @@ +/* ======================================================================== + * 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.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// A request to refresh (re-project) the registry into the AddressSpace. + /// Mirrors the generated WoTRegistryType.Refresh Method signature. + /// + public sealed class WotRefreshRequest + { + /// + /// Gets or sets the resource selectors; empty selects all resources. + /// + public ImmutableArray Selection { get; set; } + = []; + + /// + /// Gets or sets the refresh options. + /// + public WoTRefreshOptionsDataType Options { get; set; } = new WoTRefreshOptionsDataType(); + + /// + /// Gets or sets the caller's expected refresh generation. When non-zero + /// and it does not match the current generation, the refresh is rejected. + /// + public uint ExpectedGeneration { get; set; } + + /// + /// Gets or sets an opaque request id echoed in the summary. + /// + public string RequestId { get; set; } = string.Empty; + } + + /// + /// The detailed result of a refresh, matching the generated + /// WoTRegistryType.Refresh output arguments. + /// + public sealed class WotRefreshResult + { + internal WotRefreshResult( + WoTRefreshSummaryDataType summary, + ImmutableArray results, + uint newGeneration) + { + Summary = summary; + Results = results; + NewGeneration = newGeneration; + } + + /// + /// Gets the overall refresh summary. + /// + public WoTRefreshSummaryDataType Summary { get; } + + /// + /// Gets the per-resource results. + /// + public ImmutableArray Results { get; } + + /// + /// Gets the committed refresh generation. + /// + public uint NewGeneration { get; } + } + + /// + /// The kind of materialization event emitted by the coordinator. + /// + public enum WotMaterializationEventKind + { + /// + /// A refresh completed. + /// + RefreshCompleted, + + /// + /// A resource projection changed state. + /// + Resource, + + /// + /// A resource failed format/compatibility validation. + /// + ValidationFailure, + + /// + /// A resource failed to load/project. + /// + LoadFailure, + + /// + /// A binding failed. + /// + BindingFailure + } + + /// + /// The payload the coordinator raises for each material event. The NodeManager + /// maps it to the generated WoTResourceEventType / + /// WoTValidationFailureEventType / WoTLoadFailureEventType / + /// WoTBindingFailureEventType / WoTRefreshCompletedEventType. + /// + public sealed class WotMaterializationEventArgs : EventArgs + { + internal WotMaterializationEventArgs(WotMaterializationEventKind kind) + { + Kind = kind; + } + + /// + /// Gets the event kind. + /// + public WotMaterializationEventKind Kind { get; } + + /// + /// Gets or sets the affected resource xid. + /// + public string Xid { get; init; } = string.Empty; + + /// + /// Gets or sets the resource id. + /// + public string ResourceId { get; init; } = string.Empty; + + /// + /// Gets or sets the version id. + /// + public string VersionId { get; init; } = string.Empty; + + /// + /// Gets or sets the document kind. + /// + public WoTDocumentKindEnum DocumentKind { get; init; } + + /// + /// Gets or sets the refresh generation. + /// + public uint Generation { get; init; } + + /// + /// Gets or sets the phase reached. + /// + public WoTPhaseEnum Phase { get; init; } + + /// + /// Gets or sets the outcome. + /// + public WoTOutcomeEnum Outcome { get; init; } + + /// + /// Gets or sets the resulting load state. + /// + public WoTLoadStateEnum LoadState { get; init; } + + /// + /// Gets or sets the validation outcome, if any. + /// + public WoTValidationOutcomeDataType? Validation { get; init; } + + /// + /// Gets or sets the failing node id, if any. + /// + public NodeId FailedNodeId { get; init; } + + /// + /// Gets or sets the binding URI, if any. + /// + public string BindingUri { get; init; } = string.Empty; + + /// + /// Gets or sets a human-readable reason/message. + /// + public string Reason { get; init; } = string.Empty; + + /// + /// Gets or sets the refresh summary (RefreshCompleted only). + /// + public WoTRefreshSummaryDataType? Summary { get; init; } + + /// + /// Gets or sets the request id (RefreshCompleted only). + /// + public string RequestId { get; init; } = string.Empty; + } + + /// + /// An that resolves referenced TD/TM + /// documents from a registry snapshot, so a Thing Description synthesized by + /// the converter can pull in the Thing Models it depends on. + /// + internal sealed class SnapshotThingResolver : IWotThingResolver + { + public SnapshotThingResolver(WotRegistrySnapshot snapshot) + { + m_snapshot = snapshot; + } + + /// + /// Resolves a Thing Description or Thing Model reference from the registry snapshot. + /// + public ValueTask ResolveThingAsync( + string reference, + WotResolutionContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + WotResource? resource = WotDependencyGraph.Resolve(m_snapshot, reference); + WotResourceVersion? version = resource?.DefaultVersion; + WotResolverResult result = version is null + ? WotResolverResult.NotFound + : WotResolverResult.FromBytes(version.Content); + return new ValueTask(result); + } + + private readonly WotRegistrySnapshot m_snapshot; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntime.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntime.cs new file mode 100644 index 0000000000..81bb0f3c6f --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntime.cs @@ -0,0 +1,546 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Server.Fluent; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// The per-generation OPC UA target-mapping binding runtime wired onto a + /// freshly imported NodeSet by . + /// It groups executable, target-mapped compiled forms by their resolved + /// target variable, wires either a direct (whole-value) or a structured + /// (field-by-field) handler per group, and owns every channel it lazily + /// opens for the lifetime of the generation. + /// + public sealed class WotProjectionBindingRuntime : IAsyncDisposable + { + internal WotProjectionBindingRuntime( + INodeManagerBuilder builder, + IWotBindingChannelFactory channelFactory, + IWotTargetVariableResolver resolver) + { + m_builder = builder; + m_channelFactory = channelFactory; + m_resolver = resolver; + } + + /// + /// Groups the closure's target-mapped, executable compiled forms by + /// resolved target variable and wires each group. Runs entirely + /// synchronously against the address space (no transport I/O); channel + /// opens are deferred to first use. + /// + /// + /// See . + /// + internal void Wire(ArrayOf bindingPlans) + { + var groups = new Dictionary(); + for (int p = 0; p < bindingPlans.Count; p++) + { + WotBindingPlan plan = bindingPlans[p]; + if (plan is null) + { + continue; + } + foreach (WotCompiledForm form in plan.CompiledForms) + { + if (form is null || form.TargetMapping.IsEmpty || !form.IsExecutable) + { + // Not target-mapped, or validated but not executable: + // out of scope for this runtime. + continue; + } + + if (form.Operation is not (WoTBindingCapabilityEnum.ReadProperty + or WoTBindingCapabilityEnum.WriteProperty or WoTBindingCapabilityEnum.ObserveProperty)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Affordance '{0}' carries an OPC UA target mapping on its '{1}' form, but " + + "only readproperty, writeproperty and observeproperty support target binding.", + form.AffordanceName, + form.OpToken); + } + + BaseVariableState variable = m_resolver.Resolve(m_builder, form.TargetMapping); + if (!groups.TryGetValue(variable.NodeId, out VariableGroup? group)) + { + group = new VariableGroup(variable); + groups.Add(variable.NodeId, group); + } + group.Entries.Add(form); + } + } + + foreach (VariableGroup group in groups.Values) + { + WireGroup(group); + } + } + + /// + /// Disposes every channel this generation successfully opened. + /// Faulted opens have no resource and are silently skipped by + /// ; actual disposal + /// failures are aggregated. + /// + /// + public async ValueTask DisposeAsync() + { + List? errors = null; + foreach (WotBindingChannelSlot slot in m_slots.Values) + { + try + { + await slot.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + (errors ??= []).Add(ex); + } + } + if (errors is { Count: > 0 }) + { + throw new AggregateException( + "One or more WoT projection binding channels failed to dispose.", errors); + } + } + + private void WireGroup(VariableGroup group) + { + bool hasDirect = false; + bool hasField = false; + foreach (WotCompiledForm entry in group.Entries) + { + if (string.IsNullOrEmpty(entry.TargetMapping.FieldPath)) + { + hasDirect = true; + } + else + { + hasField = true; + } + } + if (hasDirect && hasField) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Target '{0}' is mapped both directly ('uav:mapToNodeId' / 'uav:mapToType' alone) and " + + "by field path ('uav:mapByFieldPath'); a target cannot be both a whole-value and a " + + "structured-field target.", + group.Variable.NodeId); + } + + INodeBuilder nodeBuilder = m_builder.Node(group.Variable.NodeId); + if (hasField) + { + WireStructuredGroup(group, nodeBuilder); + } + else + { + WireDirectGroup(group, nodeBuilder); + } + } + + private void WireDirectGroup(VariableGroup group, INodeBuilder nodeBuilder) + { + WotCompiledForm? read = null; + WotCompiledForm? write = null; + foreach (WotCompiledForm entry in group.Entries) + { + switch (entry.Operation) + { + case WoTBindingCapabilityEnum.ObserveProperty: + // Local monitored items sample the async read handler; + // no separate observe bridge is created. + continue; + case WoTBindingCapabilityEnum.ReadProperty: + if (read is not null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Target '{0}' has more than one readproperty target mapping.", + group.Variable.NodeId); + } + read = entry; + break; + case WoTBindingCapabilityEnum.WriteProperty: + if (write is not null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Target '{0}' has more than one writeproperty target mapping.", + group.Variable.NodeId); + } + write = entry; + break; + } + } + + if (read is not null) + { + nodeBuilder.OnRead(BuildDirectReadHandler(GetOrCreateSlot(read))); + } + if (write is not null) + { + nodeBuilder.OnWrite(BuildDirectWriteHandler(GetOrCreateSlot(write))); + } + } + + /// + /// Wires a structured (field-mapped) group. Target-variable resolution + /// and read/write duplicate-path detection run now, synchronously + /// against the address space; the structure encodeable lookup, root + /// instance validation and per-field + /// calls are + /// deferred to on + /// the first structured read or write, because the target's structure + /// type is not guaranteed to be registered in the shared + /// yet at wiring time (see the class + /// remarks on ). + /// + /// + private void WireStructuredGroup(VariableGroup group, INodeBuilder nodeBuilder) + { + NodeId targetNodeId = group.Variable.NodeId; + + var readByPath = new Dictionary(StringComparer.Ordinal); + var writeByPath = new Dictionary(StringComparer.Ordinal); + foreach (WotCompiledForm entry in group.Entries) + { + string fieldPath = entry.TargetMapping.FieldPath ?? string.Empty; + switch (entry.Operation) + { + case WoTBindingCapabilityEnum.ObserveProperty: + continue; + case WoTBindingCapabilityEnum.ReadProperty: + if (readByPath.ContainsKey(fieldPath)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Target '{0}' field '{1}' has more than one readproperty mapping.", + targetNodeId, + fieldPath); + } + readByPath.Add(fieldPath, entry); + break; + case WoTBindingCapabilityEnum.WriteProperty: + if (writeByPath.ContainsKey(fieldPath)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Target '{0}' field '{1}' has more than one writeproperty mapping.", + targetNodeId, + fieldPath); + } + writeByPath.Add(fieldPath, entry); + break; + } + } + + List<(string Path, WotBindingChannelSlot Slot)> readSlots = [.. readByPath + .OrderBy(kv => kv.Key, StringComparer.Ordinal) + .Select(kv => (kv.Key, GetOrCreateSlot(kv.Value)))]; + List<(string Path, WotBindingChannelSlot Slot)> writeSlots = [.. writeByPath + .OrderBy(kv => kv.Key, StringComparer.Ordinal) + .Select(kv => (kv.Key, GetOrCreateSlot(kv.Value)))]; + + var state = new WotStructuredGroupState( + m_builder.Context.EncodeableFactory, + m_builder.Context.NamespaceUris, + group.Variable.DataType, + targetNodeId, + readSlots, + writeSlots); + + if (readSlots.Count > 0) + { + nodeBuilder.OnRead(BuildStructuredReadHandler(state)); + } + if (writeSlots.Count > 0) + { + nodeBuilder.OnWrite(BuildStructuredWriteHandler(state)); + } + } + + private WotBindingChannelSlot GetOrCreateSlot(WotCompiledForm form) + { + if (!m_slots.TryGetValue(form, out WotBindingChannelSlot? slot)) + { + slot = new WotBindingChannelSlot(form, m_channelFactory); + m_slots.Add(form, slot); + } + return slot; + } + + private static NodeValueEventHandlerAsync BuildDirectReadHandler(WotBindingChannelSlot slot) + { + return async (context, node, indexRange, dataEncoding, cancellationToken) => + { + IWotBindingChannel channel = await slot.GetAsync(cancellationToken).ConfigureAwait(false); + WotReadResult result = await channel.ReadAsync(cancellationToken).ConfigureAwait(false); + if (!result.Success) + { + DateTimeUtc failedTimestamp = result.Value.SourceTimestamp != DateTimeUtc.MinValue + ? result.Value.SourceTimestamp + : DateTimeUtc.Now; + if (node is BaseVariableState failedVariable) + { + failedVariable.Value = Variant.Null; + failedVariable.StatusCode = result.Status; + failedVariable.Timestamp = failedTimestamp; + } + return new AttributeReadResult( + new ServiceResult(result.Status), + Variant.Null, + result.Status, + failedTimestamp); + } + DataValue value = result.Value; + DateTimeUtc timestamp = value.SourceTimestamp != DateTimeUtc.MinValue + ? value.SourceTimestamp + : DateTimeUtc.Now; + if (node is BaseVariableState variable) + { + variable.Value = value.WrappedValue; + variable.StatusCode = value.StatusCode; + variable.Timestamp = timestamp; + } + return new AttributeReadResult( + ServiceResult.Good, + value.WrappedValue, + value.StatusCode, + timestamp); + }; + } + + private static NodeValueWriteEventHandlerAsync BuildDirectWriteHandler(WotBindingChannelSlot slot) + { + return async (context, node, indexRange, value, cancellationToken) => + { + IWotBindingChannel channel = await slot.GetAsync(cancellationToken).ConfigureAwait(false); + WotWriteResult result = await channel + .WriteAsync(new DataValue(value), cancellationToken) + .ConfigureAwait(false); + return new AttributeWriteResult( + result.Success ? ServiceResult.Good : new ServiceResult(result.Status)); + }; + } + + private static NodeValueEventHandlerAsync BuildStructuredReadHandler(WotStructuredGroupState state) + { + return async (context, node, indexRange, dataEncoding, cancellationToken) => + { + WotStructuredGroupResolution resolution = state.EnsureResolved(); + if (!resolution.Success) + { + return new AttributeReadResult( + resolution.Error, Variant.Null, resolution.Error.StatusCode, DateTimeUtc.Now); + } + + IEncodeable rootEncodeable = resolution.RootType!.CreateInstance(); + if (rootEncodeable is not IStructure root) + { + return new AttributeReadResult( + new ServiceResult(StatusCodes.BadConfigurationError), + Variant.Null, + StatusCodes.BadConfigurationError, + DateTimeUtc.Now); + } + + List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> fields = resolution.ReadFields; + var tasks = new Task<(WotFieldPathPlan Plan, WotReadResult Result)>[fields.Count]; + for (int i = 0; i < fields.Count; i++) + { + tasks[i] = ReadFieldAsync(fields[i].Plan, fields[i].Slot, cancellationToken); + } + (WotFieldPathPlan Plan, WotReadResult Result)[] results = await Task.WhenAll(tasks) + .ConfigureAwait(false); + + foreach ((WotFieldPathPlan _, WotReadResult result) in results) + { + if (!result.Success) + { + DateTimeUtc failedTimestamp = result.Value.SourceTimestamp != DateTimeUtc.MinValue + ? result.Value.SourceTimestamp + : DateTimeUtc.Now; + return new AttributeReadResult( + new ServiceResult(result.Status), Variant.Null, result.Status, failedTimestamp); + } + } + + foreach ((WotFieldPathPlan plan, WotReadResult result) in results) + { + IStructure parent = WotStructuredFieldNavigator.CreateOrGetChild(root, plan.IntermediateSegments); + parent[plan.LeafFieldName] = result.Value.WrappedValue; + } + + (StatusCode status, DateTimeUtc timestamp) = AggregateFieldMetadata(results); + return new AttributeReadResult( + ServiceResult.Good, + new Variant(new ExtensionObject(rootEncodeable)), + status, + timestamp); + }; + } + + private static NodeValueWriteEventHandlerAsync BuildStructuredWriteHandler(WotStructuredGroupState state) + { + return async (context, node, indexRange, value, cancellationToken) => + { + WotStructuredGroupResolution resolution = state.EnsureResolved(); + if (!resolution.Success) + { + return new AttributeWriteResult(resolution.Error); + } + + IServiceMessageContext messageContext = context.AsMessageContext(); + if (!value.TryGetValue(out ExtensionObject extensionObject) || + !extensionObject.TryGetValue(out IEncodeable? rootEncodeable, messageContext) || + rootEncodeable is not IStructure root) + { + return new AttributeWriteResult(new ServiceResult(StatusCodes.BadTypeMismatch)); + } + + List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> fields = resolution.WriteFields; + var tasks = new Task[fields.Count]; + for (int i = 0; i < fields.Count; i++) + { + tasks[i] = WriteFieldAsync( + root, + fields[i].Plan, + fields[i].Slot, + state.TargetNodeId, + messageContext, + cancellationToken); + } + WotWriteResult[] results = await Task.WhenAll(tasks).ConfigureAwait(false); + + foreach (WotWriteResult result in results) + { + if (!result.Success) + { + return new AttributeWriteResult(new ServiceResult(result.Status)); + } + } + return new AttributeWriteResult(ServiceResult.Good); + }; + } + + /// + /// Aggregates the per-field metadata of an all-succeeded structured + /// read into a single status/timestamp pair for the composed value: + /// the first non-default Good status found across the fields (or + /// plain if every field reported it), + /// and the oldest non- source + /// timestamp across the fields (or now, if none carried one). + /// + private static (StatusCode Status, DateTimeUtc Timestamp) AggregateFieldMetadata( + (WotFieldPathPlan Plan, WotReadResult Result)[] results) + { + StatusCode status = StatusCodes.Good; + DateTimeUtc oldest = DateTimeUtc.MinValue; + foreach ((WotFieldPathPlan _, WotReadResult result) in results) + { + StatusCode fieldStatus = result.Value.StatusCode; + if (status == StatusCodes.Good && fieldStatus != StatusCodes.Good) + { + status = fieldStatus; + } + + DateTimeUtc fieldTimestamp = result.Value.SourceTimestamp; + if (fieldTimestamp != DateTimeUtc.MinValue && + (oldest == DateTimeUtc.MinValue || fieldTimestamp < oldest)) + { + oldest = fieldTimestamp; + } + } + return (status, oldest == DateTimeUtc.MinValue ? DateTimeUtc.Now : oldest); + } + + private static async Task<(WotFieldPathPlan Plan, WotReadResult Result)> ReadFieldAsync( + WotFieldPathPlan plan, WotBindingChannelSlot slot, CancellationToken cancellationToken) + { + IWotBindingChannel channel = await slot.GetAsync(cancellationToken).ConfigureAwait(false); + WotReadResult result = await channel.ReadAsync(cancellationToken).ConfigureAwait(false); + return (plan, result); + } + + private static async Task WriteFieldAsync( + IStructure root, + WotFieldPathPlan plan, + WotBindingChannelSlot slot, + NodeId targetNodeId, + IServiceMessageContext messageContext, + CancellationToken cancellationToken) + { + IStructure parent; + try + { + parent = WotStructuredFieldNavigator.GetExistingChild( + root, + plan.IntermediateSegments, + targetNodeId, + messageContext); + } + catch (ServiceResultException ex) + { + return new WotWriteResult(ex.StatusCode, ex.Message); + } + Variant fieldValue = parent[plan.LeafFieldName]; + IWotBindingChannel channel = await slot.GetAsync(cancellationToken).ConfigureAwait(false); + return await channel.WriteAsync(new DataValue(fieldValue), cancellationToken).ConfigureAwait(false); + } + + private readonly INodeManagerBuilder m_builder; + private readonly IWotBindingChannelFactory m_channelFactory; + private readonly IWotTargetVariableResolver m_resolver; + private readonly Dictionary m_slots = []; + + private sealed class VariableGroup + { + public VariableGroup(BaseVariableState variable) + { + Variable = variable; + } + + public BaseVariableState Variable { get; } + + public List Entries { get; } = []; + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntimeFactory.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntimeFactory.cs new file mode 100644 index 0000000000..611eedece6 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotProjectionBindingRuntimeFactory.cs @@ -0,0 +1,107 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Server.Fluent; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// The default . It builds + /// a for the closure's prepared + /// binding plans and wires it against the freshly imported predefined + /// nodes. Always available via direct construction (no dependency + /// injection container required) given an + /// — typically the same + /// WotProtocolBinderRegistry instance exposed as + /// . + /// + public sealed class WotProjectionBindingRuntimeFactory : IWotProjectionBindingRuntimeFactory + { + /// + /// Initializes a new projection binding runtime factory. + /// + /// The channel factory used to open live channels. + /// + /// The target-variable resolver. Defaults to a new + /// when null. + /// + public WotProjectionBindingRuntimeFactory( + IWotBindingChannelFactory channelFactory, + IWotTargetVariableResolver? resolver = null) + { + m_channelFactory = channelFactory ?? throw new ArgumentNullException(nameof(channelFactory)); + m_resolver = resolver ?? new WotTargetVariableResolver(); + } + + /// + public async ValueTask CreateAsync( + INodeManagerBuilder builder, + ArrayOf bindingPlans, + CancellationToken cancellationToken = default) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (bindingPlans.IsEmpty) + { + return null; + } + return await CreateWiredRuntimeAsync(builder, bindingPlans).ConfigureAwait(false); + } + + /// + /// Creates the runtime and wires it, disposing it again if wiring + /// fails so ownership never escapes unwired: the returned instance is + /// always the fully-wired runtime the caller is meant to own. + /// + private async ValueTask CreateWiredRuntimeAsync( + INodeManagerBuilder builder, ArrayOf bindingPlans) + { + var runtime = new WotProjectionBindingRuntime(builder, m_channelFactory, m_resolver); + try + { + runtime.Wire(bindingPlans); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + await runtime.DisposeAsync().ConfigureAwait(false); + throw; + } + return runtime; + } + + private readonly IWotBindingChannelFactory m_channelFactory; + private readonly IWotTargetVariableResolver m_resolver; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs new file mode 100644 index 0000000000..7ea847076e --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs @@ -0,0 +1,364 @@ +/* ======================================================================== + * 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; +using System.Collections.Generic; +using System.Collections.Immutable; +using Opc.Ua.Encoders; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Decodes the four WoTRegistryType.Refresh input arguments - + /// Selection ([]), + /// Options (), + /// ExpectedGeneration () and RequestId + /// () - into a . Structured + /// arguments are accepted in every form a caller may present them: an already + /// decoded encodeable, an wrapping the + /// encodeable, a binary-encoded ExtensionObject body, and both plain-array and + /// array containers. A value whose type does not match + /// the argument's schema is rejected with + /// rather than silently ignored. + /// + internal static class WotRefreshArguments + { + /// + /// Decodes the Refresh input arguments into a . + /// + /// The raw input argument variants. + /// The message context used to decode encoded bodies. + /// The decoded request on success. + /// + /// on success, or + /// when an argument is present + /// but has the wrong type. + /// + public static ServiceResult TryDecode( + ArrayOf inputArguments, + IServiceMessageContext context, + out WotRefreshRequest request) + { + request = new WotRefreshRequest(); + + ServiceResult selection = TryDecodeSelection( + ArgumentAt(inputArguments, 0), context, + out ImmutableArray selectors); + if (ServiceResult.IsBad(selection)) + { + return selection; + } + + ServiceResult options = TryDecodeStructure( + ArgumentAt(inputArguments, 1), context, + out WoTRefreshOptionsDataType? decodedOptions); + if (ServiceResult.IsBad(options)) + { + return options; + } + + ServiceResult generation = TryDecodeUInt32( + ArgumentAt(inputArguments, 2), out uint expectedGeneration); + if (ServiceResult.IsBad(generation)) + { + return generation; + } + + ServiceResult requestId = TryDecodeString( + ArgumentAt(inputArguments, 3), out string? id); + if (ServiceResult.IsBad(requestId)) + { + return requestId; + } + + request = new WotRefreshRequest + { + Selection = selectors, + Options = decodedOptions ?? new WoTRefreshOptionsDataType(), + ExpectedGeneration = expectedGeneration, + RequestId = id ?? string.Empty + }; + return ServiceResult.Good; + } + + private static Variant ArgumentAt(ArrayOf inputArguments, int index) + { + return index < inputArguments.Count ? inputArguments[index] : Variant.Null; + } + + private static ServiceResult TryDecodeSelection( + Variant value, + IServiceMessageContext context, + out ImmutableArray selectors) + { + selectors = []; + if (value.IsNull) + { + return ServiceResult.Good; + } + + ImmutableArray.Builder builder = + ImmutableArray.CreateBuilder(); + foreach (object? element in Enumerate(value.AsBoxedObject(Variant.BoxingBehavior.Legacy))) + { + if (element is null) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The Selection argument must be an array of WoTResourceSelectorDataType."); + } + ServiceResult status = TryCoerce( + element, context, out WoTResourceSelectorDataType? selector); + if (ServiceResult.IsBad(status) || selector is null) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The Selection argument must be an array of WoTResourceSelectorDataType."); + } + builder.Add(selector); + } + selectors = builder.ToImmutable(); + return ServiceResult.Good; + } + + private static ServiceResult TryDecodeStructure( + Variant value, + IServiceMessageContext context, + out WoTRefreshOptionsDataType? options) + { + options = null; + if (value.IsNull) + { + return ServiceResult.Good; + } + ServiceResult status = TryCoerce( + value.AsBoxedObject(Variant.BoxingBehavior.Legacy), context, out options); + if (ServiceResult.IsBad(status) || options is null) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The Options argument must be a single WoTRefreshOptionsDataType."); + } + return ServiceResult.Good; + } + + private static ServiceResult TryDecodeUInt32(Variant value, out uint result) + { + result = 0; + if (value.IsNull) + { + return ServiceResult.Good; + } + switch (value.AsBoxedObject(Variant.BoxingBehavior.Legacy)) + { + case uint u: + result = u; + return ServiceResult.Good; + case int i when i >= 0: + result = (uint)i; + return ServiceResult.Good; + case long l when l is >= 0 and <= uint.MaxValue: + result = (uint)l; + return ServiceResult.Good; + case ushort us: + result = us; + return ServiceResult.Good; + case byte b: + result = b; + return ServiceResult.Good; + default: + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The ExpectedGeneration argument must be a UInt32."); + } + } + + private static ServiceResult TryDecodeString(Variant value, out string? result) + { + result = null; + if (value.IsNull) + { + return ServiceResult.Good; + } + if (value.AsBoxedObject(Variant.BoxingBehavior.Legacy) is string s) + { + result = s; + return ServiceResult.Good; + } + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The RequestId argument must be a String."); + } + + private static IEnumerable Enumerate(object? boxed) + { + switch (boxed) + { + case null: + yield break; + case ExtensionObject single: + yield return single; + break; + case IConvertableToArray convertible: + var array = convertible.ToArray(); + if (array is not null) + { + foreach (object? item in array) + { + yield return item; + } + } + break; + case IEnumerable enumerable when boxed is not string: + foreach (object? item in enumerable) + { + yield return item; + } + break; + default: + yield return boxed; + break; + } + } + + private static ServiceResult TryCoerce( + object? element, + IServiceMessageContext context, + out T? value) + where T : class, IEncodeable, new() + { + value = null; + switch (element) + { + case T typed: + value = typed; + return ServiceResult.Good; + case ExtensionObject extension: + return TryDecodeExtensionObject(extension, context, out value); + case IEncodeable encodeable: + return TryDecodeExtensionObject( + new ExtensionObject(encodeable), + context, + out value); + default: + return StatusCodes.BadInvalidArgument; + } + } + + private static ServiceResult TryDecodeExtensionObject( + ExtensionObject extension, + IServiceMessageContext context, + out T? value) + where T : class, IEncodeable, new() + { + value = null; + if (extension.IsNull) + { + return StatusCodes.BadInvalidArgument; + } +#pragma warning disable IDE0018, IDE0059 // must stay a separate, non-inlined out-var (see IDE0001 note below) + T? typed = default; +#pragma warning disable IDE0001 // explicit avoids CS8631/CS8634: inference from a nullable out-var picks 'T?' + if (new Variant(extension).TryGetStructure(context, out typed)) +#pragma warning restore IDE0001, IDE0018, IDE0059 + { + value = typed; + return ServiceResult.Good; + } + if (typeof(T) == typeof(WoTRefreshOptionsDataType) && + extension.TryGetValue(out Structure? structure, context) && + structure is not null && + TryDecodeDynamicOptions(structure, out WoTRefreshOptionsDataType? options)) + { + value = (T)(IEncodeable)options!; + return ServiceResult.Good; + } + if (extension.TryGetAsBinary(out ByteString body, context) && !body.IsNull) + { + try + { + using var decoder = new BinaryDecoder(body.Span.ToArray(), context); + value = new T(); + value.Decode(decoder); + return ServiceResult.Good; + } + catch (Exception ex) when (ex is ServiceResultException or FormatException) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The encoded argument body could not be decoded."); + } + } + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The encoded argument body could not be decoded."); + } + + private static bool TryDecodeDynamicOptions( + Structure structure, + out WoTRefreshOptionsDataType? options) + { + options = null; + if (structure.TypeId != DataTypeIds.WoTRefreshOptionsDataType || + structure.BinaryEncodingId != ObjectIds.WoTRefreshOptionsDataType_Encoding_DefaultBinary) + { + return false; + } + options = new WoTRefreshOptionsDataType + { + Atomicity = GetEnum(structure, "Atomicity"), + Force = GetBoolean(structure, "Force"), + DryRun = GetBoolean(structure, "DryRun"), + IncludeDependents = GetBoolean(structure, "IncludeDependents"), + DeletePolicy = GetEnum(structure, "DeletePolicy"), + MaxParallelism = GetUInt32(structure, "MaxParallelism"), + Timeout = GetDouble(structure, "Timeout") + }; + return true; + } + + private static bool GetBoolean(Structure structure, string fieldName) + => structure[fieldName].TryGetValue(out bool value) && value; + + private static double GetDouble(Structure structure, string fieldName) + => structure[fieldName].TryGetValue(out double value) ? value : 0; + + private static TEnum GetEnum(Structure structure, string fieldName) + where TEnum : struct + { + return structure[fieldName].TryGetValue(out int value) + ? (TEnum)(object)value + : default; + } + + private static uint GetUInt32(Structure structure, string fieldName) + => structure[fieldName].TryGetValue(out uint value) ? value : 0; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotStructuredFieldBinding.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotStructuredFieldBinding.cs new file mode 100644 index 0000000000..473c6090e8 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotStructuredFieldBinding.cs @@ -0,0 +1,275 @@ +/* ======================================================================== + * 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.Immutable; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// One non-leaf segment of a validated uav:mapByFieldPath path: the + /// field name to descend through and the encodeable type of the nested + /// structure it holds. + /// + internal sealed class WotFieldPathSegment + { + public WotFieldPathSegment(string name, IEncodeableType nestedType) + { + Name = name; + NestedType = nestedType; + } + + public string Name { get; } + + public IEncodeableType NestedType { get; } + } + + /// + /// A validated, pre-resolved walk from a structured target's root down to + /// one leaf field, built once during activation so repeated reads/writes + /// never re-walk metadata. + /// + internal sealed class WotFieldPathPlan + { + public WotFieldPathPlan(ImmutableArray intermediateSegments, string leafFieldName) + { + IntermediateSegments = intermediateSegments; + LeafFieldName = leafFieldName; + } + + public ImmutableArray IntermediateSegments { get; } + + public string LeafFieldName { get; } + } + + /// + /// Validates uav:mapByFieldPath paths against + /// metadata and navigates + /// instances while composing (read direction) or + /// extracting (write direction) a structured target value. No reflection + /// and no public API are used: nested structures are + /// read and written exclusively through and + /// . + /// + internal static class WotStructuredFieldNavigator + { + /// + /// Validates a slash-separated field path against the root type's + /// structure definition tree and returns the pre-resolved plan. + /// + /// + /// The path is empty, contains an empty segment, references an unknown + /// field, traverses an array-valued intermediate field, or traverses an + /// intermediate field whose DataType is not a structure type. + /// + /// + public static WotFieldPathPlan BuildPlan( + IEncodeableFactory factory, + NamespaceTable namespaceUris, + IEncodeableType rootType, + string fieldPath, + NodeId targetNodeId) + { + if (string.IsNullOrWhiteSpace(fieldPath)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "'uav:mapByFieldPath' for target '{0}' must not be empty.", + targetNodeId); + } + + string[] segments = fieldPath.Split('/'); + ImmutableArray.Builder intermediate = + ImmutableArray.CreateBuilder(); + IEncodeableType currentType = rootType; + + for (int i = 0; i < segments.Length; i++) + { + string segment = segments[i]; + if (string.IsNullOrEmpty(segment)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "'uav:mapByFieldPath' value '{0}' for target '{1}' contains an empty segment.", + fieldPath, + targetNodeId); + } + + StructureDefinition definition = GetStructureDefinition( + currentType, namespaceUris, fieldPath, targetNodeId); + StructureField? field = FindField(definition, segment) ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "'uav:mapByFieldPath' value '{0}' for target '{1}' references unknown field '{2}'.", + fieldPath, + targetNodeId, + segment); + + bool isLast = i == segments.Length - 1; + if (isLast) + { + return new WotFieldPathPlan(intermediate.ToImmutable(), segment); + } + + if (field.ValueRank != ValueRanks.Scalar) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "'uav:mapByFieldPath' value '{0}' for target '{1}' traverses array-valued field " + + "'{2}' (ValueRank {3}); only scalar structures can be traversed.", + fieldPath, + targetNodeId, + segment, + field.ValueRank); + } + + var nestedTypeId = NodeId.ToExpandedNodeId(field.DataType, namespaceUris); + if (!factory.TryGetEncodeableType(nestedTypeId, out IEncodeableType? nestedType) || + nestedType.CreateInstance() is not IStructure) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "'uav:mapByFieldPath' value '{0}' for target '{1}' traverses field '{2}' whose " + + "DataType '{3}' is not a registered structure type.", + fieldPath, + targetNodeId, + segment, + field.DataType); + } + + intermediate.Add(new WotFieldPathSegment(segment, nestedType)); + currentType = nestedType; + } + + // Unreachable: segments.Length > 0 is guaranteed by the empty-path + // check above, so the loop always returns via the isLast branch. + throw new InvalidOperationException("Field path resolution did not reach a leaf segment."); + } + + /// + /// Navigates from to the parent of the leaf + /// field, creating any missing nested structure instance along the + /// way. Used when composing a fresh structured value for a read. + /// + /// + public static IStructure CreateOrGetChild( + IStructure root, ImmutableArray intermediateSegments) + { + IStructure current = root; + foreach (WotFieldPathSegment segment in intermediateSegments) + { + Variant existing = current[segment.Name]; + if (existing.TryGetValue(out ExtensionObject extensionObject) && + extensionObject.TryGetValue(out IEncodeable? existingEncodeable) && + existingEncodeable is IStructure existingChild) + { + current = existingChild; + continue; + } + + IEncodeable createdChild = segment.NestedType.CreateInstance(); + if (createdChild is not IStructure child) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Field '{0}' could not be instantiated as a structure.", + segment.Name); + } + current[segment.Name] = new Variant(new ExtensionObject(createdChild)); + current = child; + } + return current; + } + + /// + /// Navigates from to the parent of the leaf + /// field without creating anything. Used when extracting a field from + /// an incoming structured value for a write. + /// + /// + /// An intermediate field is missing, null, or not the expected + /// structure type. + /// + public static IStructure GetExistingChild( + IStructure root, + ImmutableArray intermediateSegments, + NodeId targetNodeId, + IServiceMessageContext messageContext) + { + IStructure current = root; + foreach (WotFieldPathSegment segment in intermediateSegments) + { + Variant existing = current[segment.Name]; + if (!existing.TryGetValue(out ExtensionObject extensionObject) || + !extensionObject.TryGetValue(out IEncodeable? existingEncodeable, messageContext) || + existingEncodeable is not IStructure existingChild) + { + throw ServiceResultException.Create( + StatusCodes.BadStructureMissing, + "Target '{0}' is missing the nested structure at field '{1}'.", + targetNodeId, + segment.Name); + } + current = existingChild; + } + return current; + } + + private static StructureDefinition GetStructureDefinition( + IEncodeableType type, NamespaceTable namespaceUris, string fieldPath, NodeId targetNodeId) + { + if (type is not IDataTypeDefinitionSource source || + source.GetDataTypeDefinition(namespaceUris) is not StructureDefinition definition) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "'uav:mapByFieldPath' value '{0}' for target '{1}' traverses type '{2}', which does " + + "not expose a structure definition.", + fieldPath, + targetNodeId, + type.XmlName); + } + return definition; + } + + private static StructureField? FindField(StructureDefinition definition, string name) + { + ArrayOf fields = definition.Fields; + for (int i = 0; i < fields.Count; i++) + { + StructureField field = fields[i]; + if (string.Equals(field.Name, name, StringComparison.Ordinal)) + { + return field; + } + } + return null; + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotStructuredGroupState.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotStructuredGroupState.cs new file mode 100644 index 0000000000..a26a3d82ec --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotStructuredGroupState.cs @@ -0,0 +1,239 @@ +/* ======================================================================== + * 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.Threading; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Holds the not-yet-resolved slots for one structured (field-mapped) + /// target variable and lazily resolves the target's structure + /// plus every field's + /// on first use. + /// + /// Resolution cannot run while a runtime NodeSet is being wired: the + /// generation's binding runtime is wired by + /// , + /// which completes before NodeManagerLifecycle.RefreshComplexTypesAsync + /// registers the server's custom structure types into the shared + /// . Deferring resolution to the first + /// structured read or write lets that same factory instance — mutated in + /// place by RefreshComplexTypesAsync before the NodeManager is + /// published — already carry the type by the time it is needed. + /// + /// + /// Thread-safe and retryable: concurrent first use resolves at most once + /// under a single lock; a successful resolution is cached forever, and a + /// failed resolution (the type is still unavailable, or validation still + /// fails) is retried, uncached, on every subsequent call. + /// + /// + internal sealed class WotStructuredGroupState + { + /// + /// Initializes a new lazily-resolved structured group. + /// + /// + /// The server's . Captured by reference + /// so a later mutation (type registration) is visible to resolution. + /// + /// The node manager's namespace table. + /// The target variable's declared DataType. + /// The target variable's NodeId, for diagnostics. + /// + /// The read-direction field paths and their channel slots, already + /// duplicate-checked and ordered by . + /// + /// + /// The write-direction field paths and their channel slots, already + /// duplicate-checked and ordered by . + /// + public WotStructuredGroupState( + IEncodeableFactory factory, + NamespaceTable namespaceUris, + NodeId dataTypeId, + NodeId targetNodeId, + List<(string Path, WotBindingChannelSlot Slot)> readSlots, + List<(string Path, WotBindingChannelSlot Slot)> writeSlots) + { + m_factory = factory; + m_namespaceUris = namespaceUris; + m_dataTypeId = dataTypeId; + TargetNodeId = targetNodeId; + m_readSlots = readSlots; + m_writeSlots = writeSlots; + } + + /// + /// Gets the target variable's NodeId. + /// + public NodeId TargetNodeId { get; } + + /// + /// Resolves the structure type and every field's navigation plan on + /// first call, and returns the cached result on every later call. + /// A failed attempt resolves nothing permanently: the next call + /// retries from scratch against the (possibly by-then-populated) + /// factory. + /// + /// + /// On success, together with the + /// resolved root type and field plans. On failure, the deterministic + /// describing why resolution could not + /// complete, with empty field plan lists. + /// + public WotStructuredGroupResolution EnsureResolved() + { + lock (m_gate) + { + if (m_resolution is { } cached) + { + return cached; + } + + try + { + var rootTypeId = NodeId.ToExpandedNodeId(m_dataTypeId, m_namespaceUris); + if (!m_factory.TryGetEncodeableType(rootTypeId, out IEncodeableType? rootType) || + rootType.CreateInstance() is not IStructure) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Target '{0}' has DataType '{1}', which is not a registered structure type; " + + "'uav:mapByFieldPath' requires a structured target.", + TargetNodeId, + m_dataTypeId); + } + + var resolution = new WotStructuredGroupResolution( + ServiceResult.Good, + rootType, + BuildFieldPlans(rootType, m_readSlots), + BuildFieldPlans(rootType, m_writeSlots)); + + // Cache only the successful resolution; a failure below is + // never cached so the next first use retries. + m_resolution = resolution; + return resolution; + } + catch (ServiceResultException ex) + { + return WotStructuredGroupResolution.Failed(new ServiceResult(ex)); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return WotStructuredGroupResolution.Failed(new ServiceResult( + StatusCodes.BadConfigurationError, + new LocalizedText( + $"Target '{TargetNodeId}' structure mapping could not be resolved: {ex.Message}"))); + } + } + } + + private List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> BuildFieldPlans( + IEncodeableType rootType, List<(string Path, WotBindingChannelSlot Slot)> slots) + { + var plans = new List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)>(slots.Count); + foreach ((string path, WotBindingChannelSlot slot) in slots) + { + plans.Add(( + WotStructuredFieldNavigator.BuildPlan(m_factory, m_namespaceUris, rootType, path, TargetNodeId), + slot)); + } + return plans; + } + + private readonly IEncodeableFactory m_factory; + private readonly NamespaceTable m_namespaceUris; + private readonly NodeId m_dataTypeId; + private readonly List<(string Path, WotBindingChannelSlot Slot)> m_readSlots; + private readonly List<(string Path, WotBindingChannelSlot Slot)> m_writeSlots; + private readonly Lock m_gate = new(); + private WotStructuredGroupResolution? m_resolution; + } + + /// + /// The outcome of : + /// either the resolved structure type with its field plans, or the + /// deterministic failure status naming why resolution could not complete. + /// + internal sealed class WotStructuredGroupResolution + { + /// + /// Initializes a new resolution outcome. + /// + public WotStructuredGroupResolution( + ServiceResult error, + IEncodeableType? rootType, + List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> readFields, + List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> writeFields) + { + Error = error; + RootType = rootType; + ReadFields = readFields; + WriteFields = writeFields; + } + + /// + /// Gets the failure status, or when + /// resolution succeeded. + /// + public ServiceResult Error { get; } + + /// + /// Gets whether resolution succeeded. + /// + public bool Success => ServiceResult.IsGood(Error); + + /// + /// Gets the resolved structure type, or null on failure. + /// + public IEncodeableType? RootType { get; } + + /// + /// Gets the resolved read-direction field plans; empty on failure. + /// + public List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> ReadFields { get; } + + /// + /// Gets the resolved write-direction field plans; empty on failure. + /// + public List<(WotFieldPathPlan Plan, WotBindingChannelSlot Slot)> WriteFields { get; } + + /// + /// Creates a failed resolution outcome carrying only the error. + /// + public static WotStructuredGroupResolution Failed(ServiceResult error) + { + return new(error, null, [], []); + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Opc.Ua.WotCon.Server.csproj b/src/Opc.Ua.WotCon.Server/Opc.Ua.WotCon.Server.csproj index 9cd80c1d6e..8c7189aa07 100644 --- a/src/Opc.Ua.WotCon.Server/Opc.Ua.WotCon.Server.csproj +++ b/src/Opc.Ua.WotCon.Server/Opc.Ua.WotCon.Server.csproj @@ -5,24 +5,34 @@ $(LibTargetFrameworks) $(PackagePrefix).Opc.Ua.WotCon.Server Opc.Ua.WotCon.Server - $(NoWarn);CS1591 enable - OPC UA WoT Connectivity (OPC 10100-1) server class library + OPC UA WoT Connectivity 1.1 server class library — hosts the deprecated OPC 10100-1 v1.02 asset-management surface and the registry-first materialization runtime. true NugetREADME.md true true true $(NoWarn);SYSLIB1100;SYSLIB1101;IL2026;IL3050 @@ -36,6 +46,7 @@ + diff --git a/src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs new file mode 100644 index 0000000000..c62502f4f6 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs @@ -0,0 +1,590 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Opc.Ua.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.XRegistry; + +namespace Opc.Ua.WotCon.Server +{ + /// + /// The stable NodeManager that exposes the WoT Connectivity 1.1 registry + /// (WoTRegistry) and its xRegistry-derived group structure. It hosts + /// the injected and + /// : content mutations trigger a + /// coordinator refresh that projects TD/TM closures as separate runtime + /// NodeManagers, so this manager stays stable while projections come and go. + /// The generated Refresh Method is wired to the coordinator; the + /// coordinator's events are re-emitted as the generated registry event types. + /// + public sealed class WotRegistryNodeManager : AsyncCustomNodeManager + { + /// + /// Initializes a new registry NodeManager. + /// + public WotRegistryNodeManager( + IServerInternal server, + ApplicationConfiguration configuration, + WotRegistryServerOptions options, + IWotRegistryService registry, + WotMaterializationCoordinator coordinator) + : base( + server, + configuration, + server.Telemetry.CreateLogger(), + Namespaces.WotCon, + XRegistryWellKnown.XRegistryNamespaceUri) + { + m_options = options ?? throw new ArgumentNullException(nameof(options)); + Registry = registry ?? throw new ArgumentNullException(nameof(registry)); + Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); + Coordinator.StrictBindings = options.StrictBindings; + Coordinator.RetirementPolicy = options.RetirementPolicy; + Coordinator.ServerNamespaceUris = server.NamespaceUris; + m_projection = new WotRegistryProjection(this, Registry, m_options); + } + + /// + /// Gets the hosted registry service. + /// + public IWotRegistryService Registry { get; } + + /// + /// Gets the hosted materialization coordinator. + /// + public WotMaterializationCoordinator Coordinator { get; } + + /// + protected override ValueTask LoadPredefinedNodesAsync( + ISystemContext context, + CancellationToken cancellationToken = default) + { + // Load the xRegistry base plus the combined WoT-Con model, then keep + // only the additive registry slice. The incorporated (deprecated) + // OPC 10100-1 v1.02 nodes are owned by WotConnectivityNodeManager, so + // the two managers never claim the same static model node twice. + NodeStateCollection nodes = new NodeStateCollection() + .AddOpcUaXRegistry(context) + .AddOpcUaWotCon(context); + WotConModelPartition.RetainRegistryNodes(nodes, context); + return new ValueTask(nodes); + } + + /// + protected override ValueTask AddBehaviourToPredefinedNodeAsync( + ISystemContext context, + NodeState predefinedNode, + CancellationToken cancellationToken = default) + { + var registryNodeId = ExpandedNodeId.ToNodeId( + ObjectIds.WoTRegistry, Server.NamespaceUris); + if (predefinedNode is BaseObjectState registry && + registry.NodeId == registryNodeId) + { + m_registryNode = registry; + registry.EventNotifier = EventNotifiers.SubscribeToEvents; + EnsureRegistryManagementMethods(context, registry); + WireRefreshMethod(registry); + ApplyRegistrySettings(context, registry); + } + return new ValueTask(predefinedNode); + } + + private void EnsureRegistryManagementMethods( + ISystemContext context, BaseObjectState registry) + { + if (registry is not RegistryState typed) + { + return; + } + // Instantiate the optional xRegistry CreateGroup/GetOrCreateGroup + // Methods on the well-known singleton. The generated Add helpers mint + // fresh per-instance NodeIds (through the NodeManager's NodeIdFactory) + // and rebase the argument references so the Methods never collide with + // the RegistryType Method declarations. + typed.AddCreateGroup(context) + .AddGetOrCreateGroup(context); + WotRegistryProjection.LinkMethodArguments(typed.CreateGroup, context); + WotRegistryProjection.LinkMethodArguments(typed.GetOrCreateGroup, context); + + // Instantiate the optional Labels (AttributesType) container and its + // AddAttribute/RemoveAttribute Methods here, before this predefined + // node's subtree is registered by the base class's + // CreateAddressSpaceAsync: only children present at that point are + // swept into the NodeManager's node table. WotRegistryProjection + // wires the actual Method handlers later (see AttachAsync). + typed.AddLabels(context); + if (typed.Labels is not null) + { + typed.Labels.AddAddAttribute(context); + typed.Labels.AddRemoveAttribute(context); + WotRegistryProjection.LinkMethodArguments(typed.Labels, context); + } + } + + /// + public override async ValueTask CreateAddressSpaceAsync( + IDictionary> externalReferences, + CancellationToken cancellationToken = default) + { + await base.CreateAddressSpaceAsync(externalReferences, cancellationToken) + .ConfigureAwait(false); + + // Chain WoTRegistry into the Server's notifier tree so its events + // reach subscribing clients. The generated WoTRegistry Object already + // declares the inverse HasNotifier to the Server object, so only the + // forward reference on the Server side is added here. + if (m_registryNode is not null) + { + if (externalReferences.TryGetValue( + Ua.ObjectIds.Server, out IList? serverRefs) || + (serverRefs = EnsureList(externalReferences, Ua.ObjectIds.Server)) != null) + { + serverRefs.Add(new NodeStateReference( + Ua.ReferenceTypeIds.HasNotifier, false, m_registryNode.NodeId)); + } + } + + await Registry.InitializeAsync(cancellationToken).ConfigureAwait(false); + Registry.Changed += OnRegistryChanged; + Coordinator.Event += OnCoordinatorEvent; + + // Materialize the browseable group/resource projection, then project + // whatever is already persisted into the AddressSpace. + if (m_registryNode is not null) + { + await m_projection.AttachAsync(m_registryNode, cancellationToken) + .ConfigureAwait(false); + } + await SafeRefreshAsync("startup").ConfigureAwait(false); + await m_projection.ReconcileAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask DeleteAddressSpaceAsync( + CancellationToken cancellationToken = default) + { + Registry.Changed -= OnRegistryChanged; + Coordinator.Event -= OnCoordinatorEvent; + await Coordinator.RemoveAllAsync(cancellationToken).ConfigureAwait(false); + m_projection.Dispose(); + await base.DeleteAddressSpaceAsync(cancellationToken).ConfigureAwait(false); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + m_projection.Dispose(); + m_refreshGate.Dispose(); + } + base.Dispose(disposing); + } + + private void WireRefreshMethod(BaseObjectState registry) + { + ushort ns = (ushort)Server.NamespaceUris.GetIndex(Namespaces.WotCon); + if (registry.FindChild(SystemContext, new QualifiedName(BrowseNames.Refresh, ns)) + is MethodState refresh) + { + refresh.OnCallMethod2Async = OnRefreshAsync; + } + } + + private void ApplyRegistrySettings(ISystemContext context, BaseObjectState registry) + { + SetChildValue(registry, "AutoRefresh", new Variant(m_options.AutoRefresh)); + SetChildValue(registry, "RefreshMode", + new Variant((int)WoTRefreshModeEnum.EventDriven)); + SetChildValue(registry, "VocabularyVersion", + new Variant(Wot.WotNodeSetConverter.VocabularyNamespace)); + ApplyBindingCapabilities(registry); + } + + private void ApplyBindingCapabilities(BaseObjectState registry) + { + IReadOnlyList caps = Coordinator.BindingCapabilities; + if (caps.Count == 0) + { + return; + } + var encoded = new ExtensionObject[caps.Count]; + for (int i = 0; i < caps.Count; i++) + { + encoded[i] = new ExtensionObject(caps[i]); + } + SetChildValue(registry, "SelectedBindings", + new Variant(new ArrayOf(encoded))); + } + + private async ValueTask OnRefreshAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + ArrayOf inputArguments, + List outputArguments, + CancellationToken cancellationToken) + { + ServiceResult access = CheckManagementAccess(context, "Refresh"); + if (ServiceResult.IsBad(access)) + { + return access; + } + + ServiceResult decoded = WotRefreshArguments.TryDecode( + inputArguments, Server.MessageContext, out WotRefreshRequest request); + if (ServiceResult.IsBad(decoded)) + { + return decoded; + } + + if (!await m_refreshGate + .WaitAsync(0, cancellationToken) + .ConfigureAwait(false)) + { + return StatusCodes.BadServerTooBusy; + } + try + { + WotRefreshResult result = await Coordinator + .RefreshAsync(request, cancellationToken).ConfigureAwait(false); + + outputArguments.Clear(); + outputArguments.Add(Variant.FromStructure(result.Summary)); + outputArguments.Add(Variant.FromStructure(result.Results.ToArrayOf())); + outputArguments.Add(new Variant(result.NewGeneration)); + return ServiceResult.Good; + } + finally + { + m_refreshGate.Release(); + } + } + + private void OnRegistryChanged(object? sender, WotRegistryChangedEventArgs e) + { + // Keep the browseable projection synchronized on every change, + // including projection-only callbacks (which must never re-trigger + // materialization). + _ = SafeReconcileAsync(); + if (e.ProjectionOnly || !m_options.AutoRefresh) + { + return; + } + // Content mutation: re-project asynchronously without blocking the caller. + _ = SafeRefreshAsync("auto"); + } + + private void OnCoordinatorEvent(object? sender, WotMaterializationEventArgs e) + { + if (m_registryNode is null) + { + return; + } + try + { + NodeState source = EventSourceFor(e); + BaseEventState? evt = BuildEvent(e, source); + if (evt is not null) + { + source.ReportEvent(SystemContext, evt); + } + } + catch (Exception ex) + { + m_logger.FailedToReportMaterializationEvent(ex); + } + } + + private NodeState EventSourceFor(WotMaterializationEventArgs e) + { + // Resource lifecycle failures are sourced at the specific resource + // node; the registry object remains the summary source for the + // refresh-completed event. + if (e.Kind == WotMaterializationEventKind.RefreshCompleted) + { + return m_registryNode!; + } + return m_projection.EventSourceFor(e.Xid); + } + + private BaseEventState? BuildEvent(WotMaterializationEventArgs e, NodeState source) + { + switch (e.Kind) + { + case WotMaterializationEventKind.RefreshCompleted: + { + var evt = new WoTRefreshCompletedEventState(m_registryNode); + InitializeEvent(evt, source, "RefreshCompleted"); + // Summary/RequestId/NewGeneration come from the coordinator's + // refresh summary, which is produced from the registry snapshot. + if (e.Summary is not null) + { + SetEventStruct(evt, BrowseNames.Summary, e.Summary); + } + SetEventValue(evt, BrowseNames.RequestId, new Variant(e.RequestId)); + SetEventValue(evt, BrowseNames.Generation, new Variant(e.Generation)); + return evt; + } + case WotMaterializationEventKind.ValidationFailure: + { + var evt = new WoTValidationFailureEventState(source); + InitializeEvent(evt, source, "ValidationFailure: " + e.Reason); + PopulateResourceEventFields(evt, e); + if (e.Validation is not null) + { + SetEventStruct(evt, BrowseNames.ValidationOutcome, e.Validation); + } + return evt; + } + case WotMaterializationEventKind.LoadFailure: + { + var evt = new WoTLoadFailureEventState(source); + InitializeEvent(evt, source, "LoadFailure: " + e.Reason); + PopulateResourceEventFields(evt, e); + SetEventEnum(evt, BrowseNames.LoadState, e.LoadState); + SetEventValue( + evt, BrowseNames.FailedNodeId, new Variant(e.FailedNodeId)); + SetEventValue(evt, BrowseNames.Reason, new Variant(e.Reason)); + return evt; + } + case WotMaterializationEventKind.BindingFailure: + { + var evt = new WoTBindingFailureEventState(source); + InitializeEvent(evt, source, "BindingFailure: " + e.Reason); + PopulateResourceEventFields(evt, e); + SetEventValue(evt, BrowseNames.BindingUri, new Variant(e.BindingUri)); + SetEventValue(evt, BrowseNames.Reason, new Variant(e.Reason)); + return evt; + } + default: + { + var evt = new WoTResourceEventState(source); + InitializeEvent(evt, source, "Resource: " + e.ResourceId); + PopulateResourceEventFields(evt, e); + return evt; + } + } + } + + private void InitializeEvent(BaseEventState evt, NodeState source, string message) + { + evt.Initialize( + SystemContext, + source: source, + EventSeverity.Medium, + new LocalizedText(message)); + evt.SetChildValue( + SystemContext, Ua.BrowseNames.SourceName, + source.DisplayName.Text ?? "WoTRegistry", false); + } + + /// + /// Populates the identity/lifecycle fields shared by every + /// WoTResourceEventType (and its concrete subtypes) from the + /// coordinator's event arguments. + /// + private void PopulateResourceEventFields( + BaseEventState evt, WotMaterializationEventArgs e) + { + SetEventValue(evt, BrowseNames.Xid, new Variant(e.Xid)); + SetEventValue(evt, BrowseNames.ResourceId, new Variant(e.ResourceId)); + SetEventValue(evt, BrowseNames.VersionId, new Variant(e.VersionId)); + SetEventEnum(evt, BrowseNames.DocumentKind, e.DocumentKind); + SetEventValue(evt, BrowseNames.Generation, new Variant(e.Generation)); + SetEventEnum(evt, BrowseNames.Phase, e.Phase); + SetEventEnum(evt, BrowseNames.Outcome, e.Outcome); + } + + private void SetEventValue(BaseEventState evt, string browseName, Variant value) + { + evt.SetChildValue(SystemContext, WoTQualifiedName(browseName), value, false); + } + + private void SetEventEnum(BaseEventState evt, string browseName, TEnum value) + where TEnum : struct, Enum + { + evt.SetChildValue(SystemContext, WoTQualifiedName(browseName), value); + } + + private void SetEventStruct(BaseEventState evt, string browseName, TStruct value) + where TStruct : IEncodeable + { + evt.SetChildValue(SystemContext, WoTQualifiedName(browseName), value, false); + } + + private QualifiedName WoTQualifiedName(string browseName) + { + return new(browseName, (ushort)Server.NamespaceUris.GetIndex(Namespaces.WotCon)); + } + + private async Task SafeReconcileAsync() + { + try + { + await m_projection.ReconcileAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + m_logger.RegistryProjectionReconcileFailed(ex); + } + } + + private async Task SafeRefreshAsync(string reason) + { + try + { + await Coordinator.RefreshAsync(new WotRefreshRequest { RequestId = reason }) + .ConfigureAwait(false); + } + catch (Exception ex) + { + m_logger.RegistryRefreshFailed(ex, reason); + } + } + + internal ServiceResult CheckManagementAccess(ISystemContext context, string operation) + { + if (context is not SessionSystemContext { OperationContext: OperationContext operationContext }) + { + // Local / programmatic call: allowed. + return ServiceResult.Good; + } + WotManagementAccessPolicy policy = m_options.ManagementAccess; + MessageSecurityMode securityMode = operationContext.ChannelContext? + .EndpointDescription?.SecurityMode ?? + MessageSecurityMode.None; + // MinimumSecurityMode is a floor, not an exact match: MessageSecurityMode is ordered by + // strength (Invalid < None < Sign < SignAndEncrypt), so a channel at or above the + // configured mode is accepted and Invalid is always rejected. + if (securityMode < policy.MinimumSecurityMode) + { + m_logger.ManagementCallDeniedSecurityMode(operation, securityMode); + return StatusCodes.BadUserAccessDenied; + } + IUserIdentity? identity = operationContext.UserIdentity; + if (identity is null || + (!policy.AllowAnonymous && identity.TokenType == UserTokenType.Anonymous)) + { + m_logger.ManagementCallDeniedAnonymousIdentity(operation); + return StatusCodes.BadUserAccessDenied; + } + if (!identity.GrantedRoleIds.Contains(policy.RequiredRoleId)) + { + m_logger.ManagementCallDeniedMissingRole(operation); + return StatusCodes.BadUserAccessDenied; + } + return ServiceResult.Good; + } + + private static IList EnsureList( + IDictionary> externalReferences, NodeId nodeId) + { + if (!externalReferences.TryGetValue(nodeId, out IList? list)) + { + list = []; + externalReferences[nodeId] = list; + } + return list; + } + + private void SetChildValue(BaseObjectState parent, string browseName, Variant value) + { + ushort ns = (ushort)Server.NamespaceUris.GetIndex(Namespaces.WotCon); + if (parent.FindChild(SystemContext, new QualifiedName(browseName, ns)) + is BaseVariableState variable) + { + variable.Value = value; + } + } + + private readonly WotRegistryServerOptions m_options; + private readonly WotRegistryProjection m_projection; + private readonly SemaphoreSlim m_refreshGate = new(1, 1); + private BaseObjectState? m_registryNode; + } + + /// + /// Holds source-generated log messages emitted by the WoT registry NodeManager component. + /// + internal static partial class WotRegistryNodeManagerLog + { + /// + /// Logs that raising a WoT materialization event failed. + /// + [LoggerMessage(EventId = WotConServerEventIds.WotRegistryNodeManager + 0, Level = LogLevel.Warning, + Message = "Failed to report WoT materialization event.")] + public static partial void FailedToReportMaterializationEvent(this ILogger logger, Exception ex); + + /// + /// Logs that reconciling the registry projection failed. + /// + [LoggerMessage(EventId = WotConServerEventIds.WotRegistryNodeManager + 1, Level = LogLevel.Warning, + Message = "WoT registry projection reconcile failed.")] + public static partial void RegistryProjectionReconcileFailed(this ILogger logger, Exception ex); + + /// + /// Logs that a registry refresh failed for the supplied reason. + /// + [LoggerMessage(EventId = WotConServerEventIds.WotRegistryNodeManager + 2, Level = LogLevel.Warning, + Message = "WoT registry refresh ({Reason}) failed.")] + public static partial void RegistryRefreshFailed(this ILogger logger, Exception ex, string reason); + + /// + /// Logs that a registry management call was denied because channel security was too weak. + /// + [LoggerMessage(EventId = WotConServerEventIds.WotRegistryNodeManager + 3, Level = LogLevel.Warning, + Message = "Denied WoT registry '{Operation}': channel security mode {Mode} is too low.")] + public static partial void ManagementCallDeniedSecurityMode( + this ILogger logger, + string operation, + MessageSecurityMode mode); + + /// + /// Logs that a registry management call was denied because the caller was anonymous. + /// + [LoggerMessage(EventId = WotConServerEventIds.WotRegistryNodeManager + 4, Level = LogLevel.Warning, + Message = "Denied WoT registry '{Operation}': anonymous or missing identity.")] + public static partial void ManagementCallDeniedAnonymousIdentity(this ILogger logger, string operation); + + /// + /// Logs that a registry management call was denied because the caller lacks the required role. + /// + [LoggerMessage(EventId = WotConServerEventIds.WotRegistryNodeManager + 5, Level = LogLevel.Warning, + Message = "Denied WoT registry '{Operation}': caller lacks required role.")] + public static partial void ManagementCallDeniedMissingRole(this ILogger logger, string operation); + } +} diff --git a/src/Opc.Ua.WotCon.Server/WotRegistryNodeManagerFactory.cs b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManagerFactory.cs new file mode 100644 index 0000000000..8ea2865875 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManagerFactory.cs @@ -0,0 +1,90 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.XRegistry; + +namespace Opc.Ua.WotCon.Server +{ + /// + /// that produces the stable + /// configured with the shared registry + /// service and materialization coordinator. + /// + public sealed class WotRegistryNodeManagerFactory : IAsyncNodeManagerFactory + { + /// + /// Creates a new factory. + /// + public WotRegistryNodeManagerFactory( + WotRegistryServerOptions options, + IWotRegistryService registry, + WotMaterializationCoordinator coordinator) + { + m_options = options ?? throw new ArgumentNullException(nameof(options)); + m_registry = registry ?? throw new ArgumentNullException(nameof(registry)); + m_coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); + } + + /// + public ArrayOf NamespacesUris => new string[] + { + Namespaces.WotCon, + XRegistryWellKnown.XRegistryNamespaceUri + }; + + /// + public ValueTask CreateAsync( + IServerInternal server, + ApplicationConfiguration configuration, + CancellationToken cancellationToken = default) + { + // CA2000 cannot model ownership transfer through ValueTask. + // TODO: Remove this suppression when CA2000 recognizes factory ownership transfer. +#pragma warning disable CA2000 + IAsyncNodeManager nodeManager = new WotRegistryNodeManager( + server, + configuration, + m_options, + m_registry, + m_coordinator); +#pragma warning restore CA2000 + return new ValueTask(nodeManager); + } + + private readonly WotRegistryServerOptions m_options; + private readonly IWotRegistryService m_registry; + private readonly WotMaterializationCoordinator m_coordinator; + } +} diff --git a/src/Opc.Ua.WotCon.Server/WotRegistryProjection.cs b/src/Opc.Ua.WotCon.Server/WotRegistryProjection.cs new file mode 100644 index 0000000000..7c6d0081a0 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/WotRegistryProjection.cs @@ -0,0 +1,1160 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.XRegistry; + +namespace Opc.Ua.WotCon.Server +{ + /// + /// Materializes the WoT Connectivity 1.1 registry snapshot as browseable + /// xRegistry Objects beneath the stable WoTRegistry node: + /// ThingDescriptionGroupType/ThingModelGroupType group Objects + /// and their ThingDescriptionFileType/ThingModelFileType + /// document resources. Every group and resource is (re)created, updated and + /// removed to mirror the immutable snapshot, with deterministic NodeIds + /// derived from the registry Xid, notifier references up the + /// registry → group → resource chain, and the xRegistry CRUD / + /// FileType / document Methods wired to the injected registry service. + /// + internal sealed class WotRegistryProjection : IDisposable + { + public WotRegistryProjection( + WotRegistryNodeManager manager, + IWotRegistryService registry, + WotRegistryServerOptions options) + { + m_manager = manager ?? throw new ArgumentNullException(nameof(manager)); + m_registry = registry ?? throw new ArgumentNullException(nameof(registry)); + m_options = options ?? throw new ArgumentNullException(nameof(options)); + m_modelNs = (ushort)manager.Server.NamespaceUris.GetIndex(Namespaces.WotCon); + } + + /// + /// Binds the projection to the well-known registry Object, wires the + /// registry-level CreateGroup/GetOrCreateGroup Methods, materializes + /// and wires its Labels (AttributesType) container, and performs the + /// first reconcile. + /// + /// + public async ValueTask AttachAsync(BaseObjectState registryNode, CancellationToken ct) + { + m_registryNode = registryNode ?? throw new ArgumentNullException(nameof(registryNode)); + registryNode.EventNotifier = EventNotifiers.SubscribeToEvents; + WireMethod(registryNode, XRegistry.BrowseNames.CreateGroup, OnCreateGroupAsync); + WireMethod(registryNode, XRegistry.BrowseNames.GetOrCreateGroup, OnGetOrCreateGroupAsync); + if (registryNode is RegistryState registryTyped) + { + registryTyped.AddLabels(m_manager.SystemContext); + WireLabelsContainer( + registryTyped.Labels, OnAddRegistryLabelAsync, OnRemoveRegistryLabelAsync); + LinkMethodArguments(registryTyped.Labels, m_manager.SystemContext); + } + await ReconcileAsync(ct).ConfigureAwait(false); + } + + /// + /// Finds the browseable resource node used as an event source, or the + /// registry node when the resource is unknown. + /// + public NodeState EventSourceFor(string? xid) + { + if (!string.IsNullOrEmpty(xid) && + m_resourcesByXid.TryGetValue(xid!, out WoTDocumentState? node)) + { + return node; + } + return m_registryNode!; + } + + /// + /// Reconciles the browseable projection with the current registry + /// snapshot: creates, updates and removes group and resource nodes. + /// Never re-triggers materialization. + /// + public async ValueTask ReconcileAsync(CancellationToken ct) + { + if (m_registryNode is null) + { + return; + } + await m_gate.WaitAsync(ct).ConfigureAwait(false); + try + { + WotRegistrySnapshot snapshot = m_registry.Current; + + if (m_registryNode is RegistryState registryTyped && registryTyped.Labels is not null) + { + await SyncLabelPropertiesAsync( + registryTyped.Labels, RegistryNodeIdPath, snapshot.Labels, ct) + .ConfigureAwait(false); + } + + var seenGroups = new HashSet(StringComparer.Ordinal); + foreach (WotResourceGroup group in snapshot.Groups.Values) + { + seenGroups.Add(group.GroupId); + if (!m_groups.TryGetValue(group.GroupId, out GroupEntry? entry)) + { + entry = await CreateGroupNodeAsync(group, ct).ConfigureAwait(false); + m_groups[group.GroupId] = entry; + } + else + { + ApplyGroupProperties(entry.Node, group); + if (entry.Node.Labels is not null) + { + await SyncLabelPropertiesAsync( + entry.Node.Labels, GroupNodeIdPath(group.GroupId), group.Labels, ct) + .ConfigureAwait(false); + } + entry.Node.ClearChangeMasks(m_manager.SystemContext, includeChildren: true); + } + + var seenResources = new HashSet(StringComparer.Ordinal); + foreach (WotResource resource in group.Resources.Values) + { + seenResources.Add(resource.ResourceId); + if (!entry.Resources.TryGetValue(resource.ResourceId, out ResourceEntry? res)) + { + res = await CreateResourceNodeAsync(entry, resource, ct) + .ConfigureAwait(false); + entry.Resources[resource.ResourceId] = res; + } + else + { + ApplyResourceProperties(res, resource); + if (res.Node.Labels is not null) + { + await SyncLabelPropertiesAsync( + res.Node.Labels, + ResourceNodeIdPath(resource.GroupId, resource.ResourceId), + resource.Labels, + ct).ConfigureAwait(false); + } + res.Node.ClearChangeMasks(m_manager.SystemContext, includeChildren: true); + } + } + + foreach (string resourceId in entry.Resources.Keys + .Where(id => !seenResources.Contains(id)).ToList()) + { + await RemoveResourceNodeAsync(entry, resourceId, ct).ConfigureAwait(false); + } + } + + foreach (string groupId in m_groups.Keys + .Where(id => !seenGroups.Contains(id)).ToList()) + { + await RemoveGroupNodeAsync(groupId, ct).ConfigureAwait(false); + } + } + finally + { + m_gate.Release(); + } + } + + public void Dispose() + { + if (m_disposed) + { + return; + } + m_disposed = true; + foreach (GroupEntry group in m_groups.Values) + { + foreach (ResourceEntry resource in group.Resources.Values) + { + resource.File?.Dispose(); + } + } + m_groups.Clear(); + m_resourcesByXid.Clear(); + m_gate.Dispose(); + } + + private async ValueTask CreateGroupNodeAsync( + WotResourceGroup group, CancellationToken ct) + { + bool tm = group.Kind == WoTDocumentKindEnum.ThingModel; + GroupState node = tm + ? new ThingModelGroupState(m_registryNode) + : new ThingDescriptionGroupState(m_registryNode); + NodeId nodeId = GroupNodeId(group.GroupId); + node.ReferenceTypeId = Ua.ReferenceTypeIds.Organizes; + node.TypeDefinitionId = ExpandedNodeId.ToNodeId( + tm ? ObjectTypeIds.ThingModelGroupType : ObjectTypeIds.ThingDescriptionGroupType, + m_manager.Server.NamespaceUris); + node.Create( + m_manager.SystemContext, nodeId, + new QualifiedName(group.GroupId, m_modelNs), new LocalizedText(group.Name), + assignNodeIds: false); + + node.AddCreateResource(m_manager.SystemContext) + .AddGetOrCreateResource(m_manager.SystemContext) + .AddDelete(m_manager.SystemContext) + .AddXid(m_manager.SystemContext) + .AddEpoch(m_manager.SystemContext) + .AddName(m_manager.SystemContext) + .AddDescription(m_manager.SystemContext) + .AddCreatedAt(m_manager.SystemContext) + .AddModifiedAt(m_manager.SystemContext) + .AddLabels(m_manager.SystemContext); + node.EventNotifier = EventNotifiers.SubscribeToEvents; + + string groupId = group.GroupId; + WoTDocumentKindEnum kind = group.Kind; + node.CreateResource?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnCreateResourceAsync(groupId, kind, c, i, ot, t); + node.GetOrCreateResource?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnGetOrCreateResourceAsync(groupId, kind, c, i, ot, t); + node.Delete?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnDeleteGroupAsync(groupId, c, i, t); + WireLabelsContainer( + node.Labels, + (c, i, t) => OnAddGroupLabelAsync(groupId, c, i, t), + (c, i, t) => OnRemoveGroupLabelAsync(groupId, c, i, t)); + + ApplyGroupProperties(node, group); + DateTime createdAt = DateTime.UtcNow; + SetValue(node.CreatedAt, (DateTimeUtc)createdAt); + SetValue(node.ModifiedAt, (DateTimeUtc)createdAt); + m_manager.SystemContext.AssignInstanceChildNodeIds(node); + LinkMethodArguments(node, m_manager.SystemContext); + + m_registryNode!.AddChild(node); + m_registryNode.AddReference(Ua.ReferenceTypeIds.HasNotifier, false, nodeId); + node.AddReference(Ua.ReferenceTypeIds.HasNotifier, true, m_registryNode.NodeId); + + await m_manager.AddPredefinedNodeAsync(node, ct).ConfigureAwait(false); + var entry = new GroupEntry(node, group.Kind); + await SyncLabelPropertiesAsync( + node.Labels!, GroupNodeIdPath(group.GroupId), group.Labels, ct).ConfigureAwait(false); + return entry; + } + + private void ApplyGroupProperties(GroupState node, WotResourceGroup group) + { + SetValue(node.GroupId, group.GroupId); + SetValue(node.Xid, group.Xid); + SetValue(node.Epoch, (uint)group.Epoch); + SetValue(node.Name, group.Name); + SetValue(node.Description, group.Description); + } + + private async ValueTask RemoveGroupNodeAsync(string groupId, CancellationToken ct) + { + if (!m_groups.TryGetValue(groupId, out GroupEntry? entry)) + { + return; + } + foreach (string resourceId in entry.Resources.Keys.ToList()) + { + await RemoveResourceNodeAsync(entry, resourceId, ct).ConfigureAwait(false); + } + m_registryNode!.RemoveReference(Ua.ReferenceTypeIds.HasNotifier, false, entry.Node.NodeId); + m_registryNode.RemoveChild(entry.Node); + await m_manager.DeleteNodeAsync(m_manager.SystemContext, entry.Node.NodeId, ct) + .ConfigureAwait(false); + m_groups.Remove(groupId); + } + + private async ValueTask CreateResourceNodeAsync( + GroupEntry group, WotResource resource, CancellationToken ct) + { + bool tm = resource.Kind == WoTDocumentKindEnum.ThingModel; + WoTDocumentState node = tm + ? new ThingModelFileState(group.Node) + : new ThingDescriptionFileState(group.Node); + NodeId nodeId = ResourceNodeId(resource.GroupId, resource.ResourceId); + node.ReferenceTypeId = Ua.ReferenceTypeIds.Organizes; + node.TypeDefinitionId = ExpandedNodeId.ToNodeId( + tm ? ObjectTypeIds.ThingModelFileType : ObjectTypeIds.ThingDescriptionFileType, + m_manager.Server.NamespaceUris); + node.Create( + m_manager.SystemContext, nodeId, + new QualifiedName(resource.ResourceId, m_modelNs), + new LocalizedText(resource.Name), assignNodeIds: false); + + // Optional xRegistry registry metadata children. + node.AddVersionId(m_manager.SystemContext) + .AddFormat(m_manager.SystemContext) + .AddContentType(m_manager.SystemContext) + .AddXid(m_manager.SystemContext) + .AddEpoch(m_manager.SystemContext) + .AddName(m_manager.SystemContext) + .AddDescription(m_manager.SystemContext) + .AddCreatedAt(m_manager.SystemContext) + .AddModifiedAt(m_manager.SystemContext); + node.AddDesiredVersionId(m_manager.SystemContext) + .AddActiveVersionId(m_manager.SystemContext) + .AddIsDefault(m_manager.SystemContext) + .AddContentDigest(m_manager.SystemContext) + .AddValidationOutcome(m_manager.SystemContext) + .AddMaterializedNodeCount(m_manager.SystemContext) + .AddRootNodeId(m_manager.SystemContext) + .AddRefreshGeneration(m_manager.SystemContext) + .AddLastRefreshTime(m_manager.SystemContext); + node.AddDelete(m_manager.SystemContext); + node.AddValidate(m_manager.SystemContext); + node.AddSetEnabled(m_manager.SystemContext); + node.AddSetDefaultVersion(m_manager.SystemContext); + node.AddLabels(m_manager.SystemContext); + node.EventNotifier = EventNotifiers.SubscribeToEvents; + + if (node is ThingDescriptionFileState td) + { + td.AddThingId(m_manager.SystemContext) + .AddThingTitle(m_manager.SystemContext) + .AddBaseUri(m_manager.SystemContext); + } + else if (node is ThingModelFileState tmNode) + { + tmNode.AddModelTitle(m_manager.SystemContext) + .AddModelVersion(m_manager.SystemContext) + .AddDerivedTypeNodeId(m_manager.SystemContext); + } + + string groupId = resource.GroupId; + string resourceId = resource.ResourceId; + WoTDocumentKindEnum kind = resource.Kind; + node.Delete?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnDeleteResourceAsync(groupId, resourceId, c, i, t); + node.Validate?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnValidateAsync(groupId, resourceId, c, ot, t); + node.SetEnabled?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnSetEnabledAsync(groupId, resourceId, c, i, t); + node.SetDefaultVersion?.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnSetDefaultVersionAsync(groupId, resourceId, c, i, t); + WireLabelsContainer( + node.Labels, + (c, i, t) => OnAddResourceLabelAsync(groupId, resourceId, c, i, t), + (c, i, t) => OnRemoveResourceLabelAsync(groupId, resourceId, c, i, t)); + + // FileType transfer for the document body (commit-on-close). + var file = new WotResourceFileManager( + node, + m_options.Bounds.MaxOpenFileHandles, + m_options.Bounds.MaxDocumentBytes, + m_manager.CheckManagementAccess, + (bytes, session, token) => CommitDocumentAsync(groupId, resourceId, kind, bytes, token)); + + ApplyResourceProperties(new ResourceEntry(node, file, groupId, resourceId, kind), resource); + m_manager.SystemContext.AssignInstanceChildNodeIds(node); + LinkMethodArguments(node, m_manager.SystemContext); + + group.Node.AddChild(node); + group.Node.AddReference(Ua.ReferenceTypeIds.HasNotifier, false, nodeId); + node.AddReference(Ua.ReferenceTypeIds.HasNotifier, true, group.Node.NodeId); + + await m_manager.AddPredefinedNodeAsync(node, ct).ConfigureAwait(false); + m_resourcesByXid[BuildXid(resource.GroupId, resource.ResourceId)] = node; + var entry = new ResourceEntry(node, file, groupId, resourceId, kind); + await SyncLabelPropertiesAsync( + node.Labels!, ResourceNodeIdPath(groupId, resourceId), resource.Labels, ct) + .ConfigureAwait(false); + return entry; + } + + private void ApplyResourceProperties(ResourceEntry entry, WotResource resource) + { + WoTDocumentState node = entry.Node; + WotResourceVersion? version = resource.DefaultVersion; + WotResourceVersion? active = resource.ActiveVersion ?? version; + + SetValue(node.ResourceId, resource.ResourceId); + SetValue(node.VersionId, version?.VersionId ?? string.Empty); + SetValue(node.Format, version?.Format ?? string.Empty); + SetValue(node.ContentType, version?.ContentType ?? "application/td+json"); + SetValue(node.Xid, resource.Xid); + SetValue(node.Epoch, (uint)resource.Epoch); + SetValue(node.Name, resource.Name); + SetValue(node.Description, resource.Description); + if (version is not null) + { + SetValue(node.CreatedAt, (DateTimeUtc)version.CreatedAt); + } + SetValue(node.ModifiedAt, (DateTimeUtc)(version?.ModifiedAt ?? DateTime.UtcNow)); + + SetValue(node.DocumentKind, resource.Kind); + SetValue(node.Enabled, resource.Enabled); + SetValue(node.LoadState, resource.LoadState); + SetValue(node.DesiredVersionId, resource.DesiredVersionId ?? string.Empty); + SetValue(node.ActiveVersionId, resource.ActiveVersionId ?? string.Empty); + SetValue(node.IsDefault, version is not null && + string.Equals(version.VersionId, resource.DefaultVersionId, StringComparison.Ordinal)); + SetValue(node.ContentDigest, (ByteString)(version?.Digest ?? [])); + if (resource.Validation is not null) + { + SetValue(node.ValidationOutcome, resource.Validation); + } + SetValue(node.MaterializedNodeCount, (uint)resource.MaterializedNodeCount); + SetValue(node.RootNodeId, resource.RootNodeId); + SetValue(node.RefreshGeneration, resource.RefreshGeneration); + SetValue(node.LastRefreshTime, (DateTimeUtc)resource.LastRefreshTime); + + if (node is ThingDescriptionFileState td) + { + SetValue(td.ThingId, resource.ThingId ?? string.Empty); + SetValue(td.ThingTitle, resource.Title ?? string.Empty); + } + else if (node is ThingModelFileState tmNode) + { + SetValue(tmNode.ModelTitle, resource.Title ?? string.Empty); + SetValue(tmNode.DerivedTypeNodeId, resource.RootNodeId); + } + + byte[] content = active?.Content.ToArray() ?? []; + entry.File?.UpdatePersistedContent(content, version?.ContentType); + } + + private async ValueTask RemoveResourceNodeAsync( + GroupEntry group, string resourceId, CancellationToken ct) + { + if (!group.Resources.TryGetValue(resourceId, out ResourceEntry? entry)) + { + return; + } + entry.File?.Dispose(); + m_resourcesByXid.TryRemove(BuildXid(entry.GroupId, entry.ResourceId), out _); + group.Node.RemoveReference(Ua.ReferenceTypeIds.HasNotifier, false, entry.Node.NodeId); + group.Node.RemoveChild(entry.Node); + await m_manager.DeleteNodeAsync(m_manager.SystemContext, entry.Node.NodeId, ct) + .ConfigureAwait(false); + group.Resources.Remove(resourceId); + } + + private async ValueTask OnCreateGroupAsync( + ISystemContext context, MethodState method, NodeId objectId, + ArrayOf input, List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "CreateGroup"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? groupId = GetString(input, 0); + if (string.IsNullOrWhiteSpace(groupId)) + { + return StatusCodes.BadInvalidArgument; + } + WotResourceGroup? group = await m_registry + .TryCreateGroupAsync(groupId!, KindForGroup(groupId!), cancellationToken: ct) + .ConfigureAwait(false); + if (group is null) + { + return ServiceResult.Create( + StatusCodes.BadNodeIdExists, $"Group '{groupId}' already exists."); + } + await ReconcileAsync(ct).ConfigureAwait(false); + output.Clear(); + output.Add(new Variant(GroupNodeId(group.GroupId))); + return ServiceResult.Good; + } + + private async ValueTask OnGetOrCreateGroupAsync( + ISystemContext context, MethodState method, NodeId objectId, + ArrayOf input, List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "GetOrCreateGroup"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? groupId = GetString(input, 0); + if (string.IsNullOrWhiteSpace(groupId)) + { + return StatusCodes.BadInvalidArgument; + } + bool existed = m_registry.Current.FindGroup(NormalizeId(groupId!)) is not null; + WotResourceGroup group = await m_registry + .GetOrCreateGroupAsync(groupId!, KindForGroup(groupId!), cancellationToken: ct) + .ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + output.Clear(); + output.Add(new Variant(GroupNodeId(group.GroupId))); + output.Add(new Variant(!existed)); + return ServiceResult.Good; + } + + private async ValueTask OnCreateResourceAsync( + string groupId, WoTDocumentKindEnum kind, ISystemContext context, + ArrayOf input, List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "CreateResource"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? resourceId = GetString(input, 0); + bool requestOpen = GetBool(input, 2, false); + if (string.IsNullOrWhiteSpace(resourceId)) + { + return StatusCodes.BadInvalidArgument; + } + WotResource? resource = await m_registry + .TryCreateResourceAsync(groupId, resourceId!, kind, ct).ConfigureAwait(false); + if (resource is null) + { + return ServiceResult.Create( + StatusCodes.BadNodeIdExists, + $"Resource '{resourceId}' already exists in group '{groupId}'."); + } + await ReconcileAsync(ct).ConfigureAwait(false); + return await CompleteResourceOutputAsync( + resource.GroupId, resource.ResourceId, requestOpen, context, output, created: null, ct) + .ConfigureAwait(false); + } + + private async ValueTask OnGetOrCreateResourceAsync( + string groupId, WoTDocumentKindEnum kind, ISystemContext context, + ArrayOf input, List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "GetOrCreateResource"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? resourceId = GetString(input, 0); + bool requestOpen = GetBool(input, 2, false); + if (string.IsNullOrWhiteSpace(resourceId)) + { + return StatusCodes.BadInvalidArgument; + } + (WotResource resource, bool created) = await m_registry + .GetOrCreateResourceAsync(groupId, resourceId!, kind, ct).ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + return await CompleteResourceOutputAsync( + resource.GroupId, resource.ResourceId, requestOpen, context, output, created, ct) + .ConfigureAwait(false); + } + + private async ValueTask CompleteResourceOutputAsync( + string groupId, string resourceId, bool requestOpen, + ISystemContext context, List output, bool? created, CancellationToken ct) + { + NodeId nodeId = ResourceNodeId(groupId, resourceId); + uint fileHandle = 0; + await m_gate.WaitAsync(ct).ConfigureAwait(false); + try + { + if (requestOpen && + m_groups.TryGetValue(groupId, out GroupEntry? group) && + group.Resources.TryGetValue(resourceId, out ResourceEntry? entry) && + entry.File is not null) + { + ServiceResult open = entry.File.TryOpenWriteHandle( + context is ISessionSystemContext sessionContext + ? sessionContext.SessionId.GetValueOrDefault() + : NodeId.Null, + out fileHandle); + if (ServiceResult.IsBad(open)) + { + return open; + } + } + } + finally + { + m_gate.Release(); + } + + WotResource? resource = m_registry.Current.FindResource(groupId, resourceId); + output.Clear(); + output.Add(new Variant(nodeId)); + output.Add(new Variant(resource?.DefaultVersionId ?? string.Empty)); + output.Add(new Variant(fileHandle)); + if (created is { } wasCreated) + { + output.Add(new Variant(wasCreated)); + } + return ServiceResult.Good; + } + + private async ValueTask OnDeleteGroupAsync( + string groupId, ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "DeleteGroup"); + if (ServiceResult.IsBad(access)) + { + return access; + } + long? epoch = OptionalEpoch(input, 0); + WotRegistryMutationResult result = await m_registry + .DeleteGroupAsync(groupId, epoch, ct).ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnDeleteResourceAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "DeleteResource"); + if (ServiceResult.IsBad(access)) + { + return access; + } + long? epoch = OptionalEpoch(input, 0); + WotRegistryMutationResult result = await m_registry + .DeleteResourceAsync(groupId, resourceId, epoch, ct).ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnValidateAsync( + string groupId, string resourceId, ISystemContext context, + List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "Validate"); + if (ServiceResult.IsBad(access)) + { + return access; + } + WoTValidationOutcomeDataType outcome; + try + { + outcome = await m_registry.ValidateResourceAsync(groupId, resourceId, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + output.Clear(); +#pragma warning disable CS0618 // Validate generated proxy expects a direct structure Variant. + output.Add(new Variant(outcome)); +#pragma warning restore CS0618 + return ServiceResult.Good; + } + + private async ValueTask OnSetEnabledAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "SetEnabled"); + if (ServiceResult.IsBad(access)) + { + return access; + } + if (GetBoolOrNull(input, 0) is not { } enabled) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, "The Enabled argument is required."); + } + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result = await m_registry + .SetEnabledAsync(groupId, resourceId, enabled, epoch, ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnSetDefaultVersionAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "SetDefaultVersion"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? versionId = GetString(input, 0); + if (string.IsNullOrEmpty(versionId)) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, "The VersionId argument is required."); + } + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result = await m_registry + .SetDefaultVersionAsync(groupId, resourceId, versionId!, epoch, ct) + .ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask CommitDocumentAsync( + string groupId, string resourceId, WoTDocumentKindEnum kind, + byte[] content, CancellationToken ct) + { + var request = new WotUpsertResourceRequest + { + GroupId = groupId, + ResourceId = resourceId, + Kind = kind, + Content = content, + ContentType = kind == WoTDocumentKindEnum.ThingModel + ? "application/tm+json" + : "application/td+json", + Format = kind == WoTDocumentKindEnum.ThingModel ? "WoT-TM/1.1" : "WoT-TD/1.1", + SetAsDefault = true + }; + WotRegistryMutationResult result = await m_registry + .UpsertResourceAsync(request, ct).ConfigureAwait(false); + // A validation failure still stores the version (Warning): the bytes + // are never lost and the previous active projection is retained. + return result.Outcome is WoTOutcomeEnum.Rejected or WoTOutcomeEnum.Failed + ? ServiceResult.Create(StatusCodes.BadInvalidState, result.Message) + : ServiceResult.Good; + } + + /// + /// Wires the AddAttribute/RemoveAttribute Method handlers on a + /// materialized Labels (AttributesType) container, instantiating the + /// two optional Method children when not already present. + /// + private void WireLabelsContainer( + AttributesState? labels, + Func, CancellationToken, ValueTask> onAdd, + Func, CancellationToken, ValueTask> onRemove) + { + if (labels is null) + { + return; + } + labels.AddAddAttribute(m_manager.SystemContext) + .AddRemoveAttribute(m_manager.SystemContext); + labels.AddAttribute?.OnCallMethod2Async = (c, m, o, i, ot, t) => onAdd(c, i, t); + labels.RemoveAttribute?.OnCallMethod2Async = (c, m, o, i, ot, t) => onRemove(c, i, t); + } + + /// + /// Reconciles the browsable label Property children of a Labels + /// container against the desired dictionary: adds/updates changed + /// values, and removes labels no longer present. Ordinal enumeration + /// of keeps materialization order + /// deterministic. + /// + private async ValueTask SyncLabelPropertiesAsync( + AttributesState labels, + string basePath, + ImmutableSortedDictionary desired, + CancellationToken ct) + { + ISystemContext context = m_manager.SystemContext; + var existing = new Dictionary>(StringComparer.Ordinal); + var children = new List(); + labels.GetChildren(context, children); + foreach (BaseInstanceState child in children) + { + if (child is PropertyState property && property.BrowseName.Name is string name) + { + existing[name] = property; + } + } + + foreach (KeyValuePair label in desired) + { + if (existing.TryGetValue(label.Key, out PropertyState? property)) + { + if (!string.Equals(property.Value, label.Value, StringComparison.Ordinal)) + { + property.Value = label.Value; + property.ClearChangeMasks(context, includeChildren: false); + } + continue; + } + PropertyState created = labels.AddAttribute_Placeholder( + context, new QualifiedName(label.Key, m_modelNs)); + created.NodeId = LabelNodeId(basePath, label.Key); + created.Value = label.Value; + await m_manager.AddPredefinedNodeAsync(created, ct).ConfigureAwait(false); + } + + foreach (KeyValuePair> stale in existing + .Where(kv => !desired.ContainsKey(kv.Key)).ToList()) + { + labels.RemoveChild(stale.Value); + await m_manager.DeleteNodeAsync(m_manager.SystemContext, stale.Value.NodeId, ct) + .ConfigureAwait(false); + } + } + + private NodeId LabelNodeId(string basePath, string key) + { + return new NodeId($"{basePath}/labels/{key}", m_modelNs); + } + + private async ValueTask OnAddRegistryLabelAsync( + ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "AddAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + string value = GetString(input, 1) ?? string.Empty; + long? epoch = OptionalEpoch(input, 2); + WotRegistryMutationResult result; + try + { + result = await m_registry + .AddRegistryLabelAsync(key ?? string.Empty, value, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnRemoveRegistryLabelAsync( + ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "RemoveAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result; + try + { + result = await m_registry + .RemoveRegistryLabelAsync(key ?? string.Empty, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnAddGroupLabelAsync( + string groupId, ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "AddAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + string value = GetString(input, 1) ?? string.Empty; + long? epoch = OptionalEpoch(input, 2); + WotRegistryMutationResult result; + try + { + result = await m_registry + .AddGroupLabelAsync(groupId, key ?? string.Empty, value, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnRemoveGroupLabelAsync( + string groupId, ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "RemoveAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result; + try + { + result = await m_registry + .RemoveGroupLabelAsync(groupId, key ?? string.Empty, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnAddResourceLabelAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "AddAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + string value = GetString(input, 1) ?? string.Empty; + long? epoch = OptionalEpoch(input, 2); + WotRegistryMutationResult result; + try + { + result = await m_registry + .AddResourceLabelAsync(groupId, resourceId, key ?? string.Empty, value, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnRemoveResourceLabelAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "RemoveAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result; + try + { + result = await m_registry + .RemoveResourceLabelAsync(groupId, resourceId, key ?? string.Empty, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private WoTDocumentKindEnum KindForGroup(string groupId) + { + return string.Equals(NormalizeId(groupId), WotRegistryGroups.ThingModels, StringComparison.Ordinal) + ? WoTDocumentKindEnum.ThingModel + : WoTDocumentKindEnum.ThingDescription; + } + + private static string NormalizeId(string id) + { + return WotRegistryService.NormalizeSegment(id, nameof(id)); + } + + private static string BuildXid(string groupId, string resourceId) + { + return $"/groups/{groupId}/resources/{resourceId}"; + } + + private const string RegistryNodeIdPath = "WoTRegistry"; + + private static string GroupNodeIdPath(string groupId) + { + return "WoTRegistry/groups/" + groupId; + } + + private static string ResourceNodeIdPath(string groupId, string resourceId) + { + return $"WoTRegistry/groups/{groupId}/resources/{resourceId}"; + } + + private NodeId GroupNodeId(string groupId) + { + return new NodeId(GroupNodeIdPath(groupId), m_modelNs); + } + + private NodeId ResourceNodeId(string groupId, string resourceId) + { + return new NodeId(ResourceNodeIdPath(groupId, resourceId), m_modelNs); + } + + private void WireMethod( + BaseObjectState parent, string browseName, GenericMethodCalledEventHandler2Async handler) + { + MethodState? method = + parent.FindChild(m_manager.SystemContext, new QualifiedName(browseName, XRegistryNs)) as + MethodState + ?? parent.FindChild(m_manager.SystemContext, new QualifiedName(browseName, m_modelNs)) as + MethodState; + method?.OnCallMethod2Async = handler; + } + + private ushort XRegistryNs + => (ushort)m_manager.Server.NamespaceUris.GetIndex(XRegistryWellKnown.XRegistryNamespaceUri); + + /// + /// Links the / + /// Properties of every Method in + /// the subtree from their materialized child nodes. The generated + /// instance factories add the argument nodes as plain children without + /// setting these Properties, which the server's Call argument validation + /// requires. + /// + internal static void LinkMethodArguments(NodeState? node, ISystemContext context) + { + if (node is null) + { + return; + } + if (node is MethodState method) + { + var arguments = new List(); + method.GetChildren(context, arguments); + foreach (BaseInstanceState child in arguments) + { + if (child is not PropertyState> args) + { + continue; + } + if (method.InputArguments is null && + string.Equals(args.BrowseName.Name, Ua.BrowseNames.InputArguments, + StringComparison.Ordinal)) + { + method.InputArguments = args; + } + else if (method.OutputArguments is null && + string.Equals(args.BrowseName.Name, Ua.BrowseNames.OutputArguments, + StringComparison.Ordinal)) + { + method.OutputArguments = args; + } + } + } + var children = new List(); + node.GetChildren(context, children); + foreach (BaseInstanceState child in children) + { + LinkMethodArguments(child, context); + } + } + + private static void SetValue(PropertyState? property, T value) + { + property?.Value = value; + } + + private static string? GetString(ArrayOf input, int index) + { + return index < input.Count && input[index].AsBoxedObject(Variant.BoxingBehavior.Legacy) is string s + ? s : null; + } + + private static bool GetBool(ArrayOf input, int index, bool fallback) + { + return GetBoolOrNull(input, index) ?? fallback; + } + + private static bool? GetBoolOrNull(ArrayOf input, int index) + { + return index < input.Count && input[index].AsBoxedObject(Variant.BoxingBehavior.Legacy) is bool b + ? b : null; + } + + private static long? OptionalEpoch(ArrayOf input, int index) + { + if (index >= input.Count) + { + return null; + } + return input[index].AsBoxedObject(Variant.BoxingBehavior.Legacy) switch + { + uint u => u == 0 ? null : u, + int i => i == 0 ? null : i, + long l => l == 0 ? null : l, + _ => null + }; + } + + private static ServiceResult ToServiceResult(WotRegistryMutationResult result) + { + return result.Outcome switch + { + WoTOutcomeEnum.Success or WoTOutcomeEnum.Warning or WoTOutcomeEnum.Unchanged + => ServiceResult.Good, + WoTOutcomeEnum.Rejected + => ServiceResult.Create(StatusCodes.BadInvalidState, result.Message), + _ => ServiceResult.Create(StatusCodes.BadNodeIdUnknown, result.Message) + }; + } + + private sealed class GroupEntry + { + public GroupEntry(GroupState node, WoTDocumentKindEnum kind) + { + Node = node; + Kind = kind; + } + + public GroupState Node { get; } + public WoTDocumentKindEnum Kind { get; } + + public Dictionary Resources { get; } + = new(StringComparer.Ordinal); + } + + private sealed class ResourceEntry + { + public ResourceEntry( + WoTDocumentState node, WotResourceFileManager? file, + string groupId, string resourceId, WoTDocumentKindEnum kind) + { + Node = node; + File = file; + GroupId = groupId; + ResourceId = resourceId; + Kind = kind; + } + + public WoTDocumentState Node { get; } + public WotResourceFileManager? File { get; } + public string GroupId { get; } + public string ResourceId { get; } + public WoTDocumentKindEnum Kind { get; } + } + + private readonly WotRegistryNodeManager m_manager; + private readonly IWotRegistryService m_registry; + private readonly WotRegistryServerOptions m_options; + private readonly ushort m_modelNs; + private readonly SemaphoreSlim m_gate = new(1, 1); + private readonly Dictionary m_groups = new(StringComparer.Ordinal); + + private readonly System.Collections.Concurrent.ConcurrentDictionary + m_resourcesByXid = new(StringComparer.Ordinal); + + private BaseObjectState? m_registryNode; + private bool m_disposed; + } +} diff --git a/src/Opc.Ua.WotCon.Server/WotRegistryServerOptions.cs b/src/Opc.Ua.WotCon.Server/WotRegistryServerOptions.cs new file mode 100644 index 0000000000..b775a3d955 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/WotRegistryServerOptions.cs @@ -0,0 +1,112 @@ +/* ======================================================================== + * 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.Collections.Generic; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.XRegistry.Server; + +namespace Opc.Ua.WotCon.Server +{ + /// + /// Options for the WoT Connectivity 1.1 registry NodeManager. These are + /// bindable from configuration (only the simple-typed members) and augmented + /// at runtime with the persistence store, binder registry and management + /// access policy. + /// + public sealed class WotRegistryServerOptions + { + /// + /// Gets or sets the folder used by the file-backed registry store. When + /// null the registry is kept in memory only. + /// + public string? StorageFolder { get; set; } + + /// + /// Gets or sets the store that holds the document bytes. When null the documents + /// live wherever the registry store puts them — in the server process for an in-memory + /// registry, or in the registry folder for a file-backed one. + /// + /// Substituting a store is what lets a registry run in a high-availability or distributed + /// deployment, because the documents then live somewhere every node can reach. The store is + /// keyed by the SHA-256 content digest of the document, so writes are idempotent and two + /// resource versions with identical bytes share one entry. A supplied store owns the + /// durability of the bytes it holds; the registry still writes and switches its own + /// manifest atomically, which is safe because content-addressed documents are immutable and + /// are always written before the manifest that references them. + /// + /// + public IXRegistryResourceStore? ResourceStore { get; set; } + + /// + /// Gets or sets whether the registry automatically re-projects after every + /// content mutation. Defaults to true. + /// + public bool AutoRefresh { get; set; } = true; + + /// + /// Gets or sets whether unsupported binding forms fail a strict closure + /// (rather than materializing degraded nodes). + /// + public bool StrictBindings { get; set; } + + /// + /// Gets or sets how a superseded projection generation is retired after + /// a successful version switch. + /// + public WotProjectionRetirementPolicy RetirementPolicy { get; set; } = + WotProjectionRetirementPolicy.Graceful; + + /// + /// Gets or sets the id of the group into which legacy 1.02 assets are + /// registered as Thing Description resources. + /// + public string LegacyGroupId { get; set; } = WotRegistryGroups.ThingDescriptions; + + /// + /// Gets the resource bounds enforced by the registry service. + /// + public WotRegistryPersistenceBounds Bounds { get; } = new WotRegistryPersistenceBounds(); + + /// + /// Gets or sets the management access policy used to secure the registry + /// management Methods. + /// + public WotManagementAccessPolicy ManagementAccess { get; set; } + = new WotManagementAccessPolicy(); + + /// + /// Gets the WoT binding capabilities advertised by the registry + /// SupportedBindings object. Empty in this phase (no concrete + /// protocol binders are registered). + /// + public IList SupportedBindings { get; } + = []; + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsCoverageTests.cs b/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsCoverageTests.cs new file mode 100644 index 0000000000..978bb8bcba --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsCoverageTests.cs @@ -0,0 +1,342 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NUnit.Framework; +using Opc.Ua.Server.Hosting; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Hosting +{ + /// + /// Exercises every overload of + /// IOpcUaBuilder.AddWotRegistryServer(...) defined in + /// OpcUaWotRegistryServerBuilderExtensions: null-guard validation, + /// options binding, and the set of singleton services that must be + /// resolvable from the built without a + /// running OPC UA server. + /// + [TestFixture] + [Category("WotCon")] + [Category("Builder")] + [Parallelizable] + public sealed class OpcUaWotRegistryServerBuilderExtensionsCoverageTests + { + [Test] + public void AddWotRegistryServerWithNoConfigureRegistersServices() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + builder.AddWotRegistryServer(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + Assert.That(sp.GetRequiredService(), Is.Not.Null); + Assert.That(sp.GetRequiredService(), Is.Not.Null); + } + + [Test] + public void AddWotRegistryServerWithConfigureActionAppliesOptions() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + builder.AddWotRegistryServer(o => o.StorageFolder = "custom-folder"); + + using ServiceProvider sp = services.BuildServiceProvider(); + + WotRegistryServerOptions options = + sp.GetRequiredService>().Value; + Assert.That(options.StorageFolder, Is.EqualTo("custom-folder")); + } + + [Test] + public void AddWotRegistryServerWithNullConfigureActionDoesNotThrow() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + Assert.That( + () => builder.AddWotRegistryServer(configure: null), + Throws.Nothing); + } + + [Test] + public void AddWotRegistryServerWithNullBuilderThrowsArgumentNull() + { + Assert.That( + () => OpcUaWotRegistryServerBuilderExtensions.AddWotRegistryServer( + null!, configure: null), + Throws.ArgumentNullException); + } + + [Test] + public void AddWotRegistryServerWithConfigurationBindsOptions() + { + var configData = new Dictionary + { + [$"{OpcUaWotRegistryServerBuilderExtensions.DefaultConfigurationSection}:StorageFolder"] = + "my-registry-data" + }; + + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configData) + .Build(); + + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + builder.AddWotRegistryServer(configuration); + + using ServiceProvider sp = services.BuildServiceProvider(); + + WotRegistryServerOptions options = + sp.GetRequiredService>().Value; + Assert.That(options.StorageFolder, Is.EqualTo("my-registry-data")); + } + + [Test] + public void AddWotRegistryServerWithNullConfigurationThrowsArgumentNull() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + Assert.That( + () => builder.AddWotRegistryServer((IConfiguration)null!), + Throws.ArgumentNullException); + } + + [Test] + public void AddWotRegistryServerWithNullBuilderAndConfigurationThrowsArgumentNull() + { + IConfiguration configuration = new ConfigurationBuilder().Build(); + + Assert.That( + () => OpcUaWotRegistryServerBuilderExtensions.AddWotRegistryServer( + null!, configuration), + Throws.ArgumentNullException); + } + + [Test] + public void AddWotRegistryServerWithConfigurationSectionBindsOptions() + { + var configData = new Dictionary + { + [$"{OpcUaWotRegistryServerBuilderExtensions.DefaultConfigurationSection}:StorageFolder"] = + "section-folder" + }; + + IConfigurationSection section = new ConfigurationBuilder() + .AddInMemoryCollection(configData) + .Build() + .GetSection(OpcUaWotRegistryServerBuilderExtensions.DefaultConfigurationSection); + + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + builder.AddWotRegistryServer(section); + + using ServiceProvider sp = services.BuildServiceProvider(); + + WotRegistryServerOptions options = + sp.GetRequiredService>().Value; + Assert.That(options.StorageFolder, Is.EqualTo("section-folder")); + } + + [Test] + public void AddWotRegistryServerWithNullSectionThrowsArgumentNull() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + Assert.That( + () => builder.AddWotRegistryServer((IConfigurationSection)null!), + Throws.ArgumentNullException); + } + + [Test] + public void AddWotRegistryServerWithNullBuilderAndSectionThrowsArgumentNull() + { + IConfigurationSection section = new ConfigurationBuilder() + .Build() + .GetSection("OpcUa"); + + Assert.That( + () => OpcUaWotRegistryServerBuilderExtensions.AddWotRegistryServer( + null!, section), + Throws.ArgumentNullException); + } + + [Test] + public void AddWotRegistryServerReturnsTheSameBuilder() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + IOpcUaBuilder returned = builder.AddWotRegistryServer(); + + Assert.That(returned, Is.SameAs(builder)); + } + + [Test] + public void AddWotRegistryServerRegistersIWotRegistryServiceAsSingleton() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotRegistryService first = sp.GetRequiredService(); + IWotRegistryService second = sp.GetRequiredService(); + + Assert.That(first, Is.SameAs(second), + "IWotRegistryService must be registered as a singleton."); + } + + [Test] + public void AddWotRegistryServerRegistersWotMaterializationCoordinator() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + Assert.That( + services.Any(s => s.ServiceType == typeof(WotMaterializationCoordinator)), + Is.True, + "WotMaterializationCoordinator must be registered in the service collection."); + } + + [Test] + public void AddWotRegistryServerRegistersWotRegistryNodeManagerFactory() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + Assert.That( + services.Any(s => s.ServiceType == typeof(WotRegistryNodeManagerFactory)), + Is.True, + "WotRegistryNodeManagerFactory must be registered in the service collection."); + } + + [Test] + public void AddWotRegistryServerRegistersOpcUaServerNodeManagerRegistration() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + Assert.That( + services.Any(s => s.ServiceType == typeof(OpcUaServerNodeManagerRegistration)), + Is.True, + "OpcUaServerNodeManagerRegistration must be registered in the service collection."); + } + + [Test] + public void AddWotRegistryServerRegistersBinderRegistryInterfaces() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotBinderRegistry registry = sp.GetRequiredService(); + IWotBindingChannelFactory channelFactory = + sp.GetRequiredService(); + WotProtocolBinderRegistry concrete = + sp.GetRequiredService(); + + Assert.That(registry, Is.SameAs(concrete)); + Assert.That(channelFactory, Is.SameAs(concrete)); + } + + [Test] + public void AddWotRegistryServerRegistersTargetVariableResolver() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + Assert.That( + sp.GetRequiredService(), + Is.Not.Null); + } + + [Test] + public void AddWotRegistryServerCalledTwiceDoesNotDoubleRegisterSingleton() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + + builder.AddWotRegistryServer(o => o.StorageFolder = "first"); + builder.AddWotRegistryServer(o => o.StorageFolder = "second"); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotRegistryService first = sp.GetRequiredService(); + IWotRegistryService second = sp.GetRequiredService(); + + Assert.That(first, Is.SameAs(second), + "Calling AddWotRegistryServer twice must still yield a single singleton."); + } + + [Test] + public void AddWotRegistryServerUsesInMemoryStoreWhenNoFolderConfigured() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(o => o.StorageFolder = null); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotRegistryService svc = sp.GetRequiredService(); + Assert.That(svc, Is.InstanceOf()); + } + + [Test] + public void AddWotRegistryServerDefaultConfigSectionIsConstant() + { + Assert.That( + OpcUaWotRegistryServerBuilderExtensions.DefaultConfigurationSection, + Is.EqualTo("OpcUa:WotConRegistry:Server")); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsTests.cs b/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsTests.cs new file mode 100644 index 0000000000..d4e88b01e4 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Hosting/OpcUaWotRegistryServerBuilderExtensionsTests.cs @@ -0,0 +1,102 @@ +/* ======================================================================== + * 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 Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Tests.Hosting +{ + /// + /// Verifies that AddWotRegistryServer and + /// AddWotProtocolBinders/AddWotBinder/AddWotBindingExecutor + /// register the aggregating exactly + /// once and expose the same singleton instance as both + /// and , + /// regardless of registration order. + /// + [TestFixture] + public sealed class OpcUaWotRegistryServerBuilderExtensionsTests + { + [Test] + public void RegistryThenBindersExposesTheSameSingletonForBothInterfaces() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + builder.AddWotProtocolBinders(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotBinderRegistry registry = sp.GetRequiredService(); + IWotBindingChannelFactory channelFactory = sp.GetRequiredService(); + WotProtocolBinderRegistry concrete = sp.GetRequiredService(); + + Assert.That(registry, Is.SameAs(concrete)); + Assert.That(channelFactory, Is.SameAs(concrete)); + } + + [Test] + public void BindersThenRegistryExposesTheSameSingletonForBothInterfaces() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotProtocolBinders(); + builder.AddWotRegistryServer(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotBinderRegistry registry = sp.GetRequiredService(); + IWotBindingChannelFactory channelFactory = sp.GetRequiredService(); + WotProtocolBinderRegistry concrete = sp.GetRequiredService(); + + Assert.That(registry, Is.SameAs(concrete)); + Assert.That(channelFactory, Is.SameAs(concrete)); + } + + [Test] + public void RegistryOnlyStillExposesAWorkingRegistryAndChannelFactory() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = services.AddOpcUa(); + builder.AddWotRegistryServer(); + + using ServiceProvider sp = services.BuildServiceProvider(); + + IWotBinderRegistry registry = sp.GetRequiredService(); + IWotBindingChannelFactory channelFactory = sp.GetRequiredService(); + WotProtocolBinderRegistry concrete = sp.GetRequiredService(); + + Assert.That(registry, Is.SameAs(concrete)); + Assert.That(channelFactory, Is.SameAs(concrete)); + Assert.That(registry.Capabilities, Is.Empty, + "With no binders registered, the shared registry advertises no capabilities."); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/LifecycleWotProjectionHostBindingTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/LifecycleWotProjectionHostBindingTests.cs new file mode 100644 index 0000000000..784b4e7d8b --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/LifecycleWotProjectionHostBindingTests.cs @@ -0,0 +1,122 @@ +/* ======================================================================== + * 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.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.Server; +using Opc.Ua.Server.Fluent; +using Opc.Ua.Server.RuntimeNodeSet; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server.Materialization; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Verifies that installs + /// so each runtime + /// NodeSet generation owns its own binding runtime, built from the + /// injected and the + /// document's prepared binding plans. + /// + [TestFixture] + public sealed class LifecycleWotProjectionHostBindingTests + { + [Test] + public async Task ConfigureAsyncIsInstalledAndForwardsBindingPlansToTheRuntimeFactory() + { + var runtimeFactory = new RecordingProjectionBindingRuntimeFactory(); + var host = new LifecycleWotProjectionHost(Mock.Of(), runtimeFactory); + + var harness = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: harness.ScalarNodeIdText)); + ArrayOf plans = new[] + { + WotProjectionBindingRuntimeTestHarness.Plan(form) + }.ToArrayOf(); + var document = new WotProjectionDocument( + "closure-a", [], plans); + + RuntimeNodeSetOptions options = InvokeBuildOptions(host, document); + + Assert.That(options.ConfigureAsync, Is.Not.Null, + "The host must install ConfigureAsync when a runtime factory is supplied."); + + IAsyncDisposable? result = await options.ConfigureAsync!( + harness.Builder, CancellationToken.None).ConfigureAwait(false); + + Assert.That(runtimeFactory.LastBuilder, Is.SameAs(harness.Builder)); + Assert.That(runtimeFactory.LastBindingPlans.Count, Is.EqualTo(1)); + Assert.That(runtimeFactory.LastBindingPlans[0], Is.SameAs(plans[0])); + Assert.That(result, Is.Null); + } + + [Test] + public void ConfigureAsyncIsNotInstalledWhenNoRuntimeFactoryIsSupplied() + { + var host = new LifecycleWotProjectionHost(Mock.Of()); + var document = new WotProjectionDocument("closure-a", []); + + RuntimeNodeSetOptions options = InvokeBuildOptions(host, document); + + Assert.That(options.ConfigureAsync, Is.Null, + "Without a runtime factory the host must preserve the pre-existing (data-only) behavior."); + } + + private static RuntimeNodeSetOptions InvokeBuildOptions( + LifecycleWotProjectionHost host, WotProjectionDocument document) + { + MethodInfo method = typeof(LifecycleWotProjectionHost).GetMethod( + "BuildOptions", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (RuntimeNodeSetOptions)method.Invoke(host, [document])!; + } + + private sealed class RecordingProjectionBindingRuntimeFactory : IWotProjectionBindingRuntimeFactory + { + public INodeManagerBuilder? LastBuilder { get; private set; } + + public ArrayOf LastBindingPlans { get; private set; } + + public ValueTask CreateAsync( + INodeManagerBuilder builder, + ArrayOf bindingPlans, + CancellationToken cancellationToken = default) + { + LastBuilder = builder; + LastBindingPlans = bindingPlans; + return new ValueTask((IAsyncDisposable?)null); + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingChannelSlotTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingChannelSlotTests.cs new file mode 100644 index 0000000000..04f326c3a7 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingChannelSlotTests.cs @@ -0,0 +1,138 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server.Materialization; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises directly: single-flight + /// open caching, failed-open eviction (already covered end-to-end through + /// ), and — the focus here — + /// hardening against GetAsync racing with, or being called after, + /// DisposeAsync, so a channel this slot opens can never escape + /// disposal. + /// + [TestFixture] + public sealed class WotBindingChannelSlotTests + { + [Test] + public async Task GetAsyncAfterDisposeThrowsObjectDisposedExceptionAndNeverOpensAChannel() + { + var factory = new FakeWotBindingChannelFactory(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, new WotTargetMappingDescriptor(targetNodeId: "ns=1;s=x")); + var slot = new WotBindingChannelSlot(form, factory); + + await slot.DisposeAsync().ConfigureAwait(false); + + Assert.ThrowsAsync( + async () => await slot.GetAsync(CancellationToken.None).ConfigureAwait(false)); + Assert.That(factory.OpenCount, Is.Zero, "A disposed slot must never open a channel."); + } + + [Test] + public async Task GetAsyncAfterDisposeOfAnOpenedSlotThrowsAndNeverOpensASecondChannel() + { + var factory = new FakeWotBindingChannelFactory(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, new WotTargetMappingDescriptor(targetNodeId: "ns=1;s=x")); + var channel = new FakeWotBindingChannel(form); + factory.SetChannel(form, channel); + var slot = new WotBindingChannelSlot(form, factory); + + await slot.GetAsync(CancellationToken.None).ConfigureAwait(false); + await slot.DisposeAsync().ConfigureAwait(false); + + Assert.ThrowsAsync( + async () => await slot.GetAsync(CancellationToken.None).ConfigureAwait(false)); + Assert.That(factory.OpenCount, Is.EqualTo(1), "A disposed slot must never open a second channel."); + Assert.That(channel.DisposeCount, Is.EqualTo(1)); + } + + [Test] + public async Task DisposeAsyncRacingAnInFlightGetAsyncDisposesTheOpenedChannelExactlyOnceNoLeak() + { + var factory = new FakeWotBindingChannelFactory(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, new WotTargetMappingDescriptor(targetNodeId: "ns=1;s=x")); + var channel = new FakeWotBindingChannel(form); + var openGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + factory.SetOpener(form, async () => + { + await openGate.Task.ConfigureAwait(false); + return channel; + }); + var slot = new WotBindingChannelSlot(form, factory); + + // GetAsync starts the open (which will not complete until the + // gate is released) and DisposeAsync races it while the open is + // still in flight. + Task getTask = slot.GetAsync(CancellationToken.None).AsTask(); + Task disposeTask = slot.DisposeAsync().AsTask(); + + openGate.SetResult(true); + IWotBindingChannel got = await getTask.ConfigureAwait(false); + await disposeTask.ConfigureAwait(false); + + Assert.That(got, Is.SameAs(channel)); + Assert.That(factory.OpenCount, Is.EqualTo(1), "Single-flight must hold even when racing dispose."); + Assert.That(channel.DisposeCount, Is.EqualTo(1), + "The channel opened concurrently with dispose must still be disposed exactly once — no leak."); + + // Post-race: the slot is disposed, so no later GetAsync may open + // another channel. + Assert.ThrowsAsync( + async () => await slot.GetAsync(CancellationToken.None).ConfigureAwait(false)); + Assert.That(factory.OpenCount, Is.EqualTo(1)); + } + + [Test] + public async Task DisposeAsyncIsIdempotentSecondCallDoesNotRedisposeOrThrow() + { + var factory = new FakeWotBindingChannelFactory(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, new WotTargetMappingDescriptor(targetNodeId: "ns=1;s=x")); + var channel = new FakeWotBindingChannel(form); + factory.SetChannel(form, channel); + var slot = new WotBindingChannelSlot(form, factory); + await slot.GetAsync(CancellationToken.None).ConfigureAwait(false); + + await slot.DisposeAsync().ConfigureAwait(false); + await slot.DisposeAsync().ConfigureAwait(false); + + Assert.That(channel.DisposeCount, Is.EqualTo(1)); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingCoordinatorTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingCoordinatorTests.cs new file mode 100644 index 0000000000..e64322a5d7 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingCoordinatorTests.cs @@ -0,0 +1,490 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Bindings.Planners; +using Opc.Ua.WotCon.Tests.Support; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises the materialization coordinator's binding lifecycle: strict vs + /// degraded closure selection, non-executable degradation, and the + /// activate-after-commit / deactivate-before-retire ordering. + /// + [TestFixture] + public sealed class WotBindingCoordinatorTests + { + private static byte[] Td(string id, string href, string extraTerms = "") + { + string terms = string.IsNullOrEmpty(extraTerms) ? string.Empty : "," + extraTerms; + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"@type\":\"uav:object\"," + + "\"id\":\"" + + id + + "\",\"title\":\"t\"," + + "\"properties\":{\"value\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + + href + + "\"" + + terms + + "}]}}}"; + return Encoding.UTF8.GetBytes(td); + } + + private static WotRegistryService Registry() + { + return new(); + } + + private static Task Upsert( + WotRegistryService registry, string resourceId, byte[] content) + { + return registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = content + }).AsTask(); + } + + [Test] + public async Task StrictUnsupportedFormFailsClosure() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()) + { + StrictBindings = true + }; + await Upsert(registry, "td-a", Td("urn:td-a", "ftp://legacy/x")).ConfigureAwait(false); + + WotRefreshResult result = await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(host.AddCount, Is.Zero, "A strict closure with unsupported forms must not project."); + Assert.That(result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Failed)); + } + + [Test] + public async Task DegradedUnsupportedFormMaterializesWithWarningAndBindingFailure() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()) + { + StrictBindings = false + }; + var events = new List(); + coordinator.Event += (_, e) => events.Add(e); + await Upsert(registry, "td-a", Td("urn:td-a", "ftp://legacy/x")).ConfigureAwait(false); + + WotRefreshResult result = await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(host.AddCount, Is.EqualTo(1), "A degraded closure still materializes nodes."); + Assert.That(result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Warning)); + Assert.That(events.Any(e => e.Kind == WotMaterializationEventKind.BindingFailure), Is.True, + "Degraded mode must emit a binding failure event."); + } + + [Test] + public async Task NonExecutableFormDegradesClosure() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + await Upsert( + registry, "td-a", Td("urn:td-a", "coap://d/temp", "\"cov:method\":\"GET\"")).ConfigureAwait(false); + + WotRefreshResult result = await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(host.AddCount, Is.EqualTo(1)); + Assert.That(result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Warning), + "A validated but non-executable binding degrades the closure."); + } + + [Test] + public async Task ExecutableFormIsNotDegraded() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry( + [new MemoryWotBinder()], + [new MemoryWotBindingExecutor(new MemoryWotStore())]); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/value")).ConfigureAwait(false); + + WotRefreshResult result = await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Success), + "A fully executable binding is not degraded."); + } + + [Test] + public async Task RefreshPassesPreparedBindingPlansIntoTheProjectionDocument() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry( + [new MemoryWotBinder()], + [new MemoryWotBindingExecutor(new MemoryWotStore())]); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/value")).ConfigureAwait(false); + + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + HostOperation operation = host.Operations.Single(o => o.Op == "add"); + Assert.That(operation.Document, Is.Not.Null); + Assert.That(operation.Document!.BindingPlans.Count, Is.EqualTo(1), + "The coordinator must pass exactly one prepared plan per projected member."); + Assert.That(operation.Document.BindingPlans[0].HasExecutableForms, Is.True, + "The plan passed to the host must be the executable plan the coordinator prepared."); + } + + [Test] + public async Task LifecycleActivatesAfterCommitDeactivatesBeforeRetire() + { + WotRegistryService registry = Registry(); + var timeline = new List(); + var host = new RecordingProjectionHost(timeline); + var binders = new RecordingBinderRegistry(timeline); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/value")).ConfigureAwait(false); + + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + await registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "td-a").ConfigureAwait(false); + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + int add = timeline.IndexOf("add"); + int activate = timeline.IndexOf("activate"); + int deactivate = timeline.IndexOf("deactivate"); + int remove = timeline.IndexOf("remove"); + + Assert.That(add, Is.GreaterThanOrEqualTo(0)); + Assert.That(activate, Is.GreaterThan(add), "Activate must follow the projection commit."); + Assert.That(deactivate, Is.GreaterThanOrEqualTo(0)); + Assert.That(remove, Is.GreaterThan(deactivate), "Deactivate must precede retirement."); + } + + [Test] + public async Task UpdateDeactivatesExactlyOldPlansInCorrectOrder() + { + WotRegistryService registry = Registry(); + var recorder = new PlanRecorder(); + var host = new PlanRecordingHost(recorder); + var binders = new PlanRecordingBinderRegistry(recorder); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/v1")).ConfigureAwait(false); + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + // A content change triggers a shadow reload (an update, not a first add). + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/v2")).ConfigureAwait(false); + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + WotBindingPlan planV1 = binders.ActivatedPlans[0]; + WotBindingPlan planV2 = binders.ActivatedPlans[1]; + Assert.That(planV2, Is.Not.SameAs(planV1), "The update must prepare a new plan."); + + // Exactly one deactivation, and it is the old plan (never the new one). + Assert.That(binders.DeactivatedPlans, Has.Count.EqualTo(1)); + Assert.That(binders.DeactivatedPlans[0], Is.SameAs(planV1), + "Only the previously tracked plan may be deactivated on update."); + + // Order: the shadow switch happens first, then the old plan is + // deactivated, then the new plan is activated. + int shadow = recorder.IndexOf("shadow"); + int deactivateOld = recorder.IndexOf("deactivate", planV1); + int activateNew = recorder.IndexOf("activate", planV2); + Assert.That(shadow, Is.GreaterThanOrEqualTo(0), "The update must shadow-reload the projection."); + Assert.That(deactivateOld, Is.GreaterThan(shadow), + "The old plan must be deactivated only after the shadow switch succeeds."); + Assert.That(activateNew, Is.GreaterThan(deactivateOld), + "The new plan must be activated after the old plan is deactivated."); + } + + [Test] + public async Task UpdateShadowReloadFailsOldPlansRemainActive() + { + WotRegistryService registry = Registry(); + var recorder = new PlanRecorder(); + var host = new PlanRecordingHost(recorder); + var binders = new PlanRecordingBinderRegistry(recorder); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/v1")).ConfigureAwait(false); + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + WotBindingPlan planV1 = binders.ActivatedPlans[0]; + + // The shadow switch fails: the old plans must remain active (no + // deactivation) and no new plan may be activated (rollback ordering). + host.FailShadowReload = true; + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/v2")).ConfigureAwait(false); + await coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(binders.DeactivatedPlans, Is.Empty, + "A failed shadow switch must not deactivate the still-active old plan."); + Assert.That(binders.ActivatedPlans, Has.Count.EqualTo(1), + "A failed shadow switch must not activate the new plan."); + Assert.That(binders.ActivatedPlans[0], Is.SameAs(planV1)); + } + + private sealed class PlanRecorder + { + public List<(string Action, WotBindingPlan? Plan)> Events { get; } = []; + + public void Record(string action, WotBindingPlan? plan = null) + { + lock (Events) + { + Events.Add((action, plan)); + } + } + + public int IndexOf(string action, WotBindingPlan? plan = null) + { + lock (Events) + { + return Events.FindIndex(e => + e.Action == action && (plan is null || ReferenceEquals(e.Plan, plan))); + } + } + } + + private sealed class PlanRecordingHost : IWotProjectionHost + { + public PlanRecordingHost(PlanRecorder recorder) + { + m_recorder = recorder; + } + + public bool FailShadowReload { get; set; } + + public ValueTask AddAsync( + WotProjectionDocument document, CancellationToken cancellationToken = default) + { + m_recorder.Record("add"); + return new ValueTask(Handle(document)); + } + + public ValueTask ShadowReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + if (FailShadowReload) + { + throw new System.IO.IOException("Injected shadow reload failure."); + } + m_recorder.Record("shadow"); + return new ValueTask(Handle(document)); + } + + public ValueTask ImmediateReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + m_recorder.Record("immediate"); + return new ValueTask(Handle(document)); + } + + public ValueTask RemoveAsync(WotProjectionHandle handle, CancellationToken cancellationToken = default) + { + m_recorder.Record("remove"); + return default; + } + + private static WotProjectionHandle Handle(WotProjectionDocument document) + { + return new(document.ClosureKey, 1, new FakeWotProjectionRegistration(), [], 0); + } + + private readonly PlanRecorder m_recorder; + } + + private sealed class PlanRecordingBinderRegistry : IWotBinderRegistry + { + public PlanRecordingBinderRegistry(PlanRecorder recorder) + { + m_recorder = recorder; + } + + public List ActivatedPlans { get; } = []; + public List DeactivatedPlans { get; } = []; + + public IReadOnlyList Capabilities { get; } + = []; + + public WotBindingPlan Prepare(WotBindingPlanRequest request) + { + var entry = new WotCompiledForm( + new WotBindingIdentity("rec", "1.0", "urn:rec"), + WotAffordanceKind.Property, "value", "/properties/value/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor("rec", null, -1, "rec://x"), + new WotAddressingDescriptor("value"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + [], isExecutable: true); + // A fresh plan instance per Prepare so old and new plans are + // distinguishable by reference identity. + return new WotBindingPlan(request.ResourceXid, + [], + [entry], + [], + []); + } + + public ValueTask ActivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + ActivatedPlans.Add(plan); + m_recorder.Record("activate", plan); + return default; + } + + public ValueTask DeactivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + DeactivatedPlans.Add(plan); + m_recorder.Record("deactivate", plan); + return default; + } + + private readonly PlanRecorder m_recorder; + } + + private sealed class RecordingProjectionHost : IWotProjectionHost + { + public RecordingProjectionHost(List timeline) + { + m_timeline = timeline; + } + + public ValueTask AddAsync( + WotProjectionDocument document, CancellationToken cancellationToken = default) + { + m_timeline.Add("add"); + return new ValueTask(Handle(document)); + } + + public ValueTask ShadowReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + m_timeline.Add("shadow"); + return new ValueTask(Handle(document)); + } + + public ValueTask ImmediateReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + m_timeline.Add("immediate"); + return new ValueTask(Handle(document)); + } + + public ValueTask RemoveAsync(WotProjectionHandle handle, CancellationToken cancellationToken = default) + { + m_timeline.Add("remove"); + return default; + } + + private static WotProjectionHandle Handle(WotProjectionDocument document) + { + return new(document.ClosureKey, 1, new FakeWotProjectionRegistration(), [], 0); + } + + private readonly List m_timeline; + } + + private sealed class RecordingBinderRegistry : IWotBinderRegistry + { + public RecordingBinderRegistry(List timeline) + { + m_timeline = timeline; + } + + public IReadOnlyList Capabilities { get; } + = []; + + public WotBindingPlan Prepare(WotBindingPlanRequest request) + { + var entry = new WotCompiledForm( + new WotBindingIdentity("rec", "1.0", "urn:rec"), + WotAffordanceKind.Property, "value", "/properties/value/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor("rec", null, -1, "rec://x"), + new WotAddressingDescriptor("value"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + [], isExecutable: true); + return new WotBindingPlan(request.ResourceXid, + [], + [entry], + [], + []); + } + + public ValueTask ActivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + m_timeline.Add("activate"); + return default; + } + + public ValueTask DeactivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + m_timeline.Add("deactivate"); + return default; + } + + private readonly List m_timeline; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphExtendedTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphExtendedTests.cs new file mode 100644 index 0000000000..d1c2a2144d --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphExtendedTests.cs @@ -0,0 +1,436 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Supplemental tests for covering + /// Resolve, ExtractReferences edge cases, closure + /// diagnostics, and the and + /// value objects. + /// + [TestFixture] + [Category("WotCon")] + [Parallelizable(ParallelScope.All)] + public sealed class WotDependencyGraphExtendedTests + { + private static async Task SnapshotAsync( + params (WoTDocumentKindEnum Kind, string Id, string ThingId, byte[] Content)[] docs) + { + using var service = new WotRegistryService(); + foreach ((WoTDocumentKindEnum kind, string id, _, byte[] content) in docs) + { + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = kind == WoTDocumentKindEnum.ThingModel + ? WotRegistryGroups.ThingModels + : WotRegistryGroups.ThingDescriptions, + ResourceId = id, + Kind = kind, + Content = content + }); + } + return service.Current; + } + + private static async Task SnapshotAsync( + params (WoTDocumentKindEnum Kind, string Id, byte[] Content)[] docs) + { + using var service = new WotRegistryService(); + foreach ((WoTDocumentKindEnum kind, string id, byte[] content) in docs) + { + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = kind == WoTDocumentKindEnum.ThingModel + ? WotRegistryGroups.ThingModels + : WotRegistryGroups.ThingDescriptions, + ResourceId = id, + Kind = kind, + Content = content + }); + } + return service.Current; + } + + [Test] + public void ResolveWithNullSnapshotReturnsNull() + { + WotResource? result = WotDependencyGraph.Resolve(null!, "urn:some"); + Assert.That(result, Is.Null); + } + + [Test] + public async Task ResolveWithNullHrefReturnsNull() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm", TestMaterialization.Tm("urn:tm"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, null!); + Assert.That(result, Is.Null); + } + + [Test] + public async Task ResolveWithEmptyHrefReturnsNull() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm", TestMaterialization.Tm("urn:tm"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, string.Empty); + Assert.That(result, Is.Null); + } + + [Test] + public async Task ResolveWithWhitespaceHrefReturnsNull() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm", TestMaterialization.Tm("urn:tm"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, " "); + Assert.That(result, Is.Null); + } + + [Test] + public async Task ResolveByThingIdFindsResource() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm-res", TestMaterialization.Tm("urn:my-thing"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, "urn:my-thing"); + Assert.That(result, Is.Not.Null); + Assert.That(result!.ResourceId, Is.EqualTo("tm-res")); + } + + [Test] + public async Task ResolveByXidFindsResource() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "my-tm", + TestMaterialization.Tm("urn:other"))); + + string xid = snapshot.AllResources().First().Xid; + WotResource? result = WotDependencyGraph.Resolve(snapshot, xid); + Assert.That(result, Is.Not.Null); + Assert.That(result!.ResourceId, Is.EqualTo("my-tm")); + } + + [Test] + public async Task ResolveByResourceIdFindsResource() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingDescription, "my-td", + TestMaterialization.Td("urn:td"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, "my-td"); + Assert.That(result, Is.Not.Null); + Assert.That(result!.ResourceId, Is.EqualTo("my-td")); + } + + [Test] + public async Task ResolveWithFragmentTrimsFragment() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm-frag", + TestMaterialization.Tm("urn:frag-thing"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, "urn:frag-thing#someProperty"); + Assert.That(result, Is.Not.Null); + Assert.That(result!.ResourceId, Is.EqualTo("tm-frag")); + } + + [Test] + public async Task ResolveByRegistryUriFindsResource() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "myresource", + TestMaterialization.Tm("urn:id"))); + + string group = WotRegistryGroups.ThingModels; + string uri = $"urn:wot:{group}/myresource"; + WotResource? result = WotDependencyGraph.Resolve(snapshot, uri); + Assert.That(result, Is.Not.Null); + Assert.That(result!.ResourceId, Is.EqualTo("myresource")); + } + + [Test] + public async Task ResolvePrefersTmOverTd() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tmx", TestMaterialization.Tm("urn:shared-id")), + (WoTDocumentKindEnum.ThingDescription, "tdx", + TestMaterialization.Td("urn:shared-id"))); + + WotResource? result = WotDependencyGraph.Resolve(snapshot, "urn:shared-id"); + Assert.That(result, Is.Not.Null); + Assert.That(result!.Kind, Is.EqualTo(WoTDocumentKindEnum.ThingModel), + "TM should be preferred over TD when both match the same href."); + } + + [Test] + public void ExtractReferencesReturnsEmptyForInvalidJson() + { + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(TestMaterialization.InvalidJson(), 64); + Assert.That(refs, Is.Empty); + } + + [Test] + public void ExtractReferencesReturnsEmptyForNonObjectJson() + { + byte[] arrayJson = Encoding.UTF8.GetBytes("[1,2,3]"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(arrayJson, 64); + Assert.That(refs, Is.Empty); + } + + [Test] + public void ExtractReferencesFindsLinksRelType() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"links\":[{\"rel\":\"type\",\"href\":\"urn:tm-base\"}]}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs.Any(r => r.Href == "urn:tm-base" && r.RefType == "type"), Is.True); + } + + [Test] + public void ExtractReferencesFindsLinksRelTmSubmodel() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"links\":[{\"rel\":\"tm:submodel\",\"href\":\"urn:sub\"}]}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs.Any(r => r.Href == "urn:sub" && r.RefType == "tm:submodel"), Is.True); + } + + [Test] + public void ExtractReferencesIgnoresLinksWithUnrecognizedRel() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"links\":[{\"rel\":\"related\",\"href\":\"urn:other\"}]}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs, Is.Empty); + } + + [Test] + public void ExtractReferencesIgnoresLinksWithoutHref() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"links\":[{\"rel\":\"tm:extends\"}]}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs, Is.Empty); + } + + [Test] + public void ExtractReferencesFindsTmRef() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"properties\":{\"p1\":{\"tm:ref\":\"urn:base#/properties/p1\"}}}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs.Any(r => r.RefType == "tm:ref"), Is.True); + } + + [Test] + public void ExtractReferencesFindsTmExtendsArray() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"tm:extends\":[\"urn:base1\",\"urn:base2\"]}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs.Count(r => r.RefType == "tm:extends"), Is.EqualTo(2)); + } + + [Test] + public void ExtractReferencesFindsTmExtendsObjectWithHref() + { + byte[] doc = Encoding.UTF8.GetBytes( + "{\"tm:extends\":[{\"href\":\"urn:obj-base\"}]}"); + IReadOnlyList<(string Href, string RefType)> refs = + WotDependencyGraph.ExtractReferences(doc, 64); + Assert.That(refs.Any(r => r.Href == "urn:obj-base"), Is.True); + } + + [Test] + public async Task BuildClosuresEmptySelectionReturnsEmpty() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [], 64); + Assert.That(closures, Is.Empty); + } + + [Test] + public async Task BuildClosuresDiagnosticsIncludesMissingDependencyMessage() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td", extendsHrefs: "urn:nonexistent"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + WotDependencyClosure closure = closures[0]; + Assert.That(closure.HasMissingDependency, Is.True); + Assert.That(closure.Diagnostics, Has.Length.GreaterThan(0)); + Assert.That(closure.Diagnostics.Any(d => d.Contains("urn:nonexistent", StringComparison.Ordinal)), Is.True); + } + + [Test] + public async Task BuildClosuresDiagnosticsIncludesCycleMessage() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "a", + TestMaterialization.Tm("urn:a", extendsHrefs: "urn:b")), + (WoTDocumentKindEnum.ThingModel, "b", + TestMaterialization.Tm("urn:b", extendsHrefs: "urn:a"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + WotDependencyClosure closure = closures[0]; + Assert.That(closure.HasCycle, Is.True); + Assert.That(closure.Diagnostics, Has.Length.GreaterThan(0)); + Assert.That(closure.Diagnostics.Any(d => d.Contains("cycle", StringComparison.Ordinal)), Is.True); + } + + [Test] + public async Task BuildClosuresClosureKeyIsJoinedSortedXids() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm", + TestMaterialization.Tm("urn:tm")), + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td", extendsHrefs: "urn:tm"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + WotDependencyClosure closure = closures[0]; + string expectedKey = string.Join("|", + closure.Members.OrderBy(m => m.Xid, StringComparer.Ordinal).Select(m => m.Xid)); + Assert.That(closure.Key, Is.EqualTo(expectedKey)); + } + + [Test] + public async Task BuildClosuresDependenciesAreRecorded() + { + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "tm", + TestMaterialization.Tm("urn:tm")), + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td", extendsHrefs: "urn:tm"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + WotDependencyClosure closure = closures[0]; + Assert.That(closure.Dependencies, Is.Not.Empty); + WotDependency dep = closure.Dependencies[0]; + Assert.That(dep.Resolved, Is.True); + Assert.That(dep.TargetHref, Is.EqualTo("urn:tm")); + Assert.That(dep.RefType, Is.EqualTo("tm:extends")); + } + + [Test] + public async Task BuildClosuresDiamondPatternYieldsSingleClosure() + { + // td → tmA and td → tmB, tmA → tmBase, tmB → tmBase + // All four in one closure (all reachable from td). + byte[] tmBase = TestMaterialization.Tm("urn:base"); + byte[] tmA = TestMaterialization.Tm("urn:a", extendsHrefs: "urn:base"); + byte[] tmB = TestMaterialization.Tm("urn:b", extendsHrefs: "urn:base"); + byte[] td = Encoding.UTF8.GetBytes( + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"id\":\"urn:td\"," + + "\"title\":\"td\"," + + "\"links\":[{\"rel\":\"tm:extends\",\"href\":\"urn:a\"}," + + "{\"rel\":\"tm:extends\",\"href\":\"urn:b\"}]}"); + + WotRegistrySnapshot snapshot = await SnapshotAsync( + (WoTDocumentKindEnum.ThingModel, "base", tmBase), + (WoTDocumentKindEnum.ThingModel, "a", tmA), + (WoTDocumentKindEnum.ThingModel, "b", tmB), + (WoTDocumentKindEnum.ThingDescription, "td", td)); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + Assert.That(closures, Has.Length.EqualTo(1), + "Diamond dependency should group all four resources into one closure."); + Assert.That(closures[0].IsProjectable, Is.True); + Assert.That(closures[0].Members, Has.Length.EqualTo(4)); + } + + [Test] + public void WotDependencyPropertiesAreCorrect() + { + var dep = new WotDependency( + sourceXid: "/groups/g/resources/r1", + targetHref: "urn:target", + targetXid: "/groups/g/resources/r2", + refType: "tm:extends", + resolved: true); + + Assert.That(dep.SourceXid, Is.EqualTo("/groups/g/resources/r1")); + Assert.That(dep.TargetHref, Is.EqualTo("urn:target")); + Assert.That(dep.TargetXid, Is.EqualTo("/groups/g/resources/r2")); + Assert.That(dep.RefType, Is.EqualTo("tm:extends")); + Assert.That(dep.Resolved, Is.True); + } + + [Test] + public void WotDependencyUnresolvedPropertiesAreCorrect() + { + var dep = new WotDependency( + sourceXid: "/groups/g/resources/r1", + targetHref: "urn:missing", + targetXid: null, + refType: "tm:extends", + resolved: false); + + Assert.That(dep.TargetXid, Is.Null); + Assert.That(dep.Resolved, Is.False); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphTests.cs new file mode 100644 index 0000000000..5faa5d87f0 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphTests.cs @@ -0,0 +1,147 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises the TD/TM dependency graph: reference extraction, closure + /// partitioning (weakly-connected components), topological ordering, and + /// missing-dependency and cycle detection. + /// + [TestFixture] + public sealed class WotDependencyGraphTests + { + private static readonly string[] s_tmTdResourceIds = ["tm", "td"]; + + private async Task Snapshot( + params (WoTDocumentKindEnum Kind, string Id, byte[] Content)[] docs) + { + using var service = new WotRegistryService(); + foreach ((WoTDocumentKindEnum kind, string id, byte[] content) in docs) + { + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = kind == WoTDocumentKindEnum.ThingModel + ? WotRegistryGroups.ThingModels + : WotRegistryGroups.ThingDescriptions, + ResourceId = id, + Kind = kind, + Content = content + }); + } + return service.Current; + } + + [Test] + public void ExtractReferencesFindsTmExtendsLinks() + { + byte[] doc = TestMaterialization.Td("urn:td", extendsHrefs: "urn:tm-1"); + + IReadOnlyList<(string Href, string RefType)> references = + WotDependencyGraph.ExtractReferences(doc, 64); + + Assert.That(references.Any(r => r.Href == "urn:tm-1" && r.RefType == "tm:extends"), + Is.True); + } + + [Test] + public async Task BuildClosuresSharedModelYieldsSingleClosureTmFirst() + { + WotRegistrySnapshot snapshot = await Snapshot( + (WoTDocumentKindEnum.ThingModel, "tm", TestMaterialization.Tm("urn:tm")), + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td", extendsHrefs: "urn:tm"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + Assert.That(closures, Has.Length.EqualTo(1)); + Assert.That(closures[0].IsProjectable, Is.True); + Assert.That( + closures[0].OrderedResources.Select(r => r.ResourceId), + Is.EqualTo(s_tmTdResourceIds)); + } + + [Test] + public async Task BuildClosuresIndependentResourcesYieldSeparateClosures() + { + WotRegistrySnapshot snapshot = await Snapshot( + (WoTDocumentKindEnum.ThingDescription, "a", TestMaterialization.Td("urn:a")), + (WoTDocumentKindEnum.ThingDescription, "b", TestMaterialization.Td("urn:b"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + Assert.That(closures, Has.Length.EqualTo(2)); + Assert.That(closures.All(c => c.OrderedResources.Length == 1), Is.True); + } + + [Test] + public async Task BuildClosuresMissingDependencyIsFlagged() + { + WotRegistrySnapshot snapshot = await Snapshot( + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td", extendsHrefs: "urn:missing"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + Assert.That(closures, Has.Length.EqualTo(1)); + Assert.That(closures[0].HasMissingDependency, Is.True); + Assert.That(closures[0].IsProjectable, Is.False); + } + + [Test] + public async Task BuildClosuresCycleIsDetected() + { + WotRegistrySnapshot snapshot = await Snapshot( + (WoTDocumentKindEnum.ThingModel, "a", + TestMaterialization.Tm("urn:a", extendsHrefs: "urn:b")), + (WoTDocumentKindEnum.ThingModel, "b", + TestMaterialization.Tm("urn:b", extendsHrefs: "urn:a"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, [.. snapshot.AllResources()], 64); + + Assert.That(closures, Has.Length.EqualTo(1)); + Assert.That(closures[0].HasCycle, Is.True); + Assert.That(closures[0].IsProjectable, Is.False); + Assert.That(closures[0].Members, Has.Length.EqualTo(2), + "A cyclic closure must still report its members for diagnostics."); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotDocumentConverterTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDocumentConverterTests.cs new file mode 100644 index 0000000000..5acdb53b02 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDocumentConverterTests.cs @@ -0,0 +1,185 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Tests for factory methods and the + /// production implementation. + /// + [TestFixture] + [Category("WotCon")] + [Parallelizable(ParallelScope.All)] + public sealed class WotDocumentConverterTests + { + [Test] + public void FailureOutputSucceededIsFalse() + { + WotConversionOutput output = WotConversionOutput.Failure("Something went wrong."); + + Assert.That(output.Succeeded, Is.False); + Assert.That(output.NodeSet, Is.Null); + Assert.That(output.Errors, Has.Length.EqualTo(1)); + Assert.That(output.Errors[0], Does.Contain("Something went wrong.")); + } + + [Test] + public void FailureOutputWithMultipleErrors() + { + WotConversionOutput output = WotConversionOutput.Failure("error1", "error2"); + + Assert.That(output.Errors, Has.Length.EqualTo(2)); + } + + [Test] + public void SuccessOutputSucceededIsTrue() + { + var nodeSet = new UANodeSet(); + WotConversionOutput output = WotConversionOutput.Success(nodeSet); + + Assert.That(output.Succeeded, Is.True); + Assert.That(output.NodeSet, Is.SameAs(nodeSet)); + Assert.That(output.Errors, Is.Empty); + } + + [Test] + public void ConstructorWithDefaultErrorsIsEmpty() + { + var output = new WotConversionOutput(null, default); + + Assert.That(output.Errors, Is.Empty); + Assert.That(output.Succeeded, Is.False); + } + + [Test] + public async Task NodeSetDocumentConverterConvertsValidThingModel() + { + var converter = new WotNodeSetDocumentConverter(); + byte[] content = TestMaterialization.Tm("urn:test-tm"); + + var version = new WotResourceVersion( + versionId: "v1", + content: content, + contentType: "application/tm+json", + format: "WoT-TM/1.0", + createdAt: default, + modifiedAt: default); + var resource = new WotResource( + groupId: WotRegistryGroups.ThingModels, + resourceId: "test-tm", + kind: WoTDocumentKindEnum.ThingModel, + versions: ImmutableArray.Create(version), + defaultVersionId: "v1"); + + using var service = new WotRegistryService(); + WotRegistrySnapshot snapshot = service.Current; + + WotConversionOutput output = await converter + .ConvertAsync(resource, content, snapshot, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(output, Is.Not.Null); + } + + [Test] + public async Task NodeSetDocumentConverterFailsOnInvalidJson() + { + var converter = new WotNodeSetDocumentConverter(); + byte[] invalidContent = TestMaterialization.InvalidJson(); + + var version = new WotResourceVersion( + versionId: "v1", + content: invalidContent, + contentType: "application/td+json", + format: "WoT-TD/1.1", + createdAt: default, + modifiedAt: default); + var resource = new WotResource( + groupId: WotRegistryGroups.ThingDescriptions, + resourceId: "bad", + kind: WoTDocumentKindEnum.ThingDescription, + versions: ImmutableArray.Create(version), + defaultVersionId: "v1"); + + using var service = new WotRegistryService(); + WotRegistrySnapshot snapshot = service.Current; + + WotConversionOutput output = await converter + .ConvertAsync(resource, invalidContent, snapshot, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(output.Succeeded, Is.False); + Assert.That(output.Errors, Is.Not.Empty); + } + + [Test] + public void NodeSetDocumentConverterCanBeInstantiatedWithoutOptions() + { + var converter = new WotNodeSetDocumentConverter(); + Assert.That(converter, Is.Not.Null); + } + + [Test] + public void NodeSetDocumentConverterCanBeInstantiatedWithOptions() + { + var options = new WotNodeSetConverterOptions(); + var converter = new WotNodeSetDocumentConverter(options); + Assert.That(converter, Is.Not.Null); + } + + [Test] + public void SuccessOutputFromConstructorWithNonDefaultErrors() + { + var nodeSet = new UANodeSet(); + var errors = ImmutableArray.Empty; + var output = new WotConversionOutput(nodeSet, errors); + + Assert.That(output.Succeeded, Is.True); + Assert.That(output.Errors, Is.Empty); + } + + [Test] + public void FailureOutputRootNodeIdIsNull() + { + WotConversionOutput output = WotConversionOutput.Failure("fail"); + + Assert.That(output.RootNodeId.IsNull, Is.True); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorExtendedTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorExtendedTests.cs new file mode 100644 index 0000000000..2d9ce126a8 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorExtendedTests.cs @@ -0,0 +1,375 @@ +/* ======================================================================== + * 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.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Supplemental tests for covering + /// cycle detection, RemoveAllAsync, selection filters, force refresh, + /// and the LoadFailure and BindingFailure event kinds. + /// + [TestFixture] + [Category("WotCon")] + public sealed class WotMaterializationCoordinatorExtendedTests + { + private WotRegistryService m_registry = null!; + private FakeWotProjectionHost m_host = null!; + private FakeWotDocumentConverter m_converter = null!; + private WotMaterializationCoordinator m_coordinator = null!; + + [SetUp] + public void SetUp() + { + m_registry = new WotRegistryService(); + m_host = new FakeWotProjectionHost(); + m_converter = new FakeWotDocumentConverter(); + m_coordinator = new WotMaterializationCoordinator( + m_registry, m_host, documentConverter: m_converter); + } + + [TearDown] + public void TearDown() + { + m_coordinator.Dispose(); + m_registry.Dispose(); + } + + private Task RegisterTd(string resourceId, byte[] content) + { + return m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = content + }).AsTask(); + } + + private Task RegisterTm(string resourceId, byte[] content) + { + return m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingModels, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingModel, + Content = content + }).AsTask(); + } + + [Test] + public async Task CyclicTmsAreNotProjectedAndEmitLoadFailureEvent() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTm("tm-a", TestMaterialization.Tm("urn:tm-a", extendsHrefs: "urn:tm-b")); + await RegisterTm("tm-b", TestMaterialization.Tm("urn:tm-b", extendsHrefs: "urn:tm-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.Zero, + "A cyclic closure must not project."); + Assert.That(result.Results, Has.Length.EqualTo(2)); + Assert.That(result.Results.All(r => r.Outcome == WoTOutcomeEnum.Failed), Is.True); + Assert.That(result.Results.All(r => r.Phase == WoTPhaseEnum.DependencyResolution), + Is.True); + Assert.That( + events.Any(e => e.Kind == WotMaterializationEventKind.LoadFailure), + Is.True, "Each cyclic closure member must raise a LoadFailure event."); + } + + [Test] + public async Task RemoveAllAsyncRetiresPreviouslyProjectedClosures() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + + await m_coordinator.RemoveAllAsync(); + + Assert.That(m_host.RemoveCount, Is.EqualTo(1), + "RemoveAllAsync must retire the live projection."); + } + + [Test] + public async Task ForceRefreshReprocessesUnchangedContent() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + + WotRefreshResult second = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + Options = new WoTRefreshOptionsDataType { Force = true } + }); + + Assert.That(m_host.ShadowCount, Is.EqualTo(1), + "A forced refresh must shadow-reload even when the content is unchanged."); + Assert.That( + second.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.Not.EqualTo(WoTOutcomeEnum.Unchanged)); + } + + [Test] + public async Task SelectionFilterLimitsResultsToSelectedResources() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await RegisterTd("td-b", TestMaterialization.Td("urn:td-b")); + + string tdAXid = m_registry.Current + .FindResource(WotRegistryGroups.ThingDescriptions, "td-a")!.Xid; + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + Selection = [new WoTResourceSelectorDataType + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "td-a" + }] + }); + + Assert.That(result.Results.All(r => r.ResourceId == "td-a"), Is.True, + "Filtered refresh must only return results for selected resources."); + Assert.That(result.Results.Single().Xid, Is.EqualTo(tdAXid)); + } + + [Test] + public async Task RefreshAllEmptyResourceRegistryReturnsEmptyResults() + { + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(result.Results, Is.Empty); + Assert.That(result.Summary.Outcome, Is.EqualTo(WoTOutcomeEnum.Unchanged)); + } + + [Test] + public async Task RefreshGenerationIncrements() + { + uint before = m_coordinator.Generation; + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_coordinator.Generation, Is.GreaterThan(before)); + } + + [Test] + public async Task RefreshCompletedEventIsRaisedAfterRefresh() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That( + events.Any(e => e.Kind == WotMaterializationEventKind.RefreshCompleted), + Is.True, "A RefreshCompleted event must be raised after every refresh."); + } + + [Test] + public async Task ValidationFailureEventIsRaisedForInvalidContent() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + m_converter.MarkInvalid("td-a"); + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That( + events.Any(e => e.Kind == WotMaterializationEventKind.ValidationFailure), + Is.True, "A ValidationFailure event must be raised for invalid content."); + } + + [Test] + public async Task LoadFailureEventIsRaisedForMissingDependency() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTd("td-a", + TestMaterialization.Td("urn:td-a", extendsHrefs: "urn:missing-tm")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That( + events.Any(e => e.Kind == WotMaterializationEventKind.LoadFailure), + Is.True, "A LoadFailure event must be raised for each resource in an unresolvable closure."); + } + + [Test] + public async Task TwoIndependentClosuresProjectAsTwoSeparateRegistrations() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await RegisterTd("td-b", TestMaterialization.Td("urn:td-b")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(2), + "Two independent closures must each be added as a separate projection."); + Assert.That(result.Results, Has.Length.EqualTo(2)); + } + + [Test] + public async Task ResourceEventIsRaisedAfterSuccessfulProjection() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That( + events.Any(e => e.Kind == WotMaterializationEventKind.Resource && + e.ResourceId == "td-a"), + Is.True, "A Resource event must be raised for each successfully projected resource."); + } + + [Test] + public async Task OversizedResolverResponseIsReportedAsBindingFailure() + { + var events = new List(); + var resolver = new RecordingResolver(_ => new MemoryStream(new byte[16])); + m_coordinator.Dispose(); + m_coordinator = new WotMaterializationCoordinator( + m_registry, + m_host, + converterOptions: new WotNodeSetConverterOptions { MaxResolverDocumentBytes = 8 }, + documentConverter: m_converter, + nodeSetResolver: resolver); + m_converter.RequiredNamespace = "urn:oversized"; + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.Multiple(() => + { + Assert.That(result.Results.Single().Outcome, Is.EqualTo(WoTOutcomeEnum.Warning)); + Assert.That(resolver.RequestedNamespaces, Does.Contain("urn:oversized")); + Assert.That( + events.Any(e => + e.Kind == WotMaterializationEventKind.BindingFailure && + e.Reason.Contains("exceeded", StringComparison.Ordinal)), + Is.True); + }); + } + + [Test] + public async Task RetiredProjectionNamespaceIsResolvedAgainDespiteStaleNamespaceTableEntry() + { + var namespaces = new NamespaceTable(); + namespaces.GetIndexOrAppend("urn:wot:thingdescriptions/old"); + var resolver = new RecordingResolver( + uri => new MemoryStream(TestNodeSets.XmlBytes(uri))); + m_coordinator.Dispose(); + m_coordinator = new WotMaterializationCoordinator( + m_registry, + m_host, + documentConverter: m_converter, + nodeSetResolver: resolver) + { + ServerNamespaceUris = namespaces + }; + + await RegisterTd("old", TestMaterialization.Td("urn:old")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + await m_registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "old"); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + m_converter.RequiredNamespace = "urn:wot:thingdescriptions/old"; + await RegisterTd("new", TestMaterialization.Td("urn:new")); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(resolver.RequestedNamespaces, Does.Contain("urn:wot:thingdescriptions/old")); + } + + private sealed class RecordingResolver : IWotNodeSetResolver + { + public RecordingResolver(Func resolve) + { + m_resolve = resolve; + } + + public List RequestedNamespaces { get; } = []; + + public ValueTask TryResolveAsync( + string namespaceUri, + CancellationToken cancellationToken = default) + { + RequestedNamespaces.Add(namespaceUri); + return new ValueTask(m_resolve(namespaceUri)); + } + + private readonly Func m_resolve; + } + + [Test] + public void BindingCapabilitiesIsNonNullEvenWithNoBinders() + { + Assert.That(m_coordinator.BindingCapabilities, Is.Not.Null); + Assert.That(m_coordinator.BindingCapabilities, Is.Empty); + } + + [Test] + public async Task RemoveAllAsyncOnEmptyCoordinatorDoesNotThrow() + { + await m_coordinator.RemoveAllAsync(); + } + + [Test] + public async Task CyclicClosureEmitsLoadFailureForEachMember() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTm("tm-x", TestMaterialization.Tm("urn:tm-x", extendsHrefs: "urn:tm-y")); + await RegisterTm("tm-y", TestMaterialization.Tm("urn:tm-y", extendsHrefs: "urn:tm-x")); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + List failures = events + .Where(e => e.Kind == WotMaterializationEventKind.LoadFailure) + .ToList(); + Assert.That(failures, Has.Count.EqualTo(2), + "Each member of a cyclic closure must receive its own LoadFailure event."); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs new file mode 100644 index 0000000000..d1f67a26c2 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs @@ -0,0 +1,544 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises the materialization coordinator against a recording projection + /// host and a deterministic converter, covering the dependency-closure, + /// unchanged-refresh, invalid-retention, shadow-reload and retirement + /// behaviours required by the WoT Connectivity V2 runtime. + /// + [TestFixture] + public sealed class WotMaterializationCoordinatorTests + { + private static readonly string[] s_tmTdSourceNames = ["tm-a", "td-a"]; + + private WotRegistryService m_registry = null!; + private FakeWotProjectionHost m_host = null!; + private FakeWotDocumentConverter m_converter = null!; + private WotMaterializationCoordinator m_coordinator = null!; + + [SetUp] + public void SetUp() + { + m_registry = new WotRegistryService(); + m_host = new FakeWotProjectionHost(); + m_converter = new FakeWotDocumentConverter(); + m_coordinator = new WotMaterializationCoordinator( + m_registry, m_host, documentConverter: m_converter); + } + + [TearDown] + public void TearDown() + { + m_coordinator.Dispose(); + m_registry.Dispose(); + } + + private Task RegisterTd(string resourceId, byte[] content) + { + return m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = content + }).AsTask(); + } + + private Task RegisterTm(string resourceId, byte[] content) + { + return m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingModels, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingModel, + Content = content + }).AsTask(); + } + + [Test] + public async Task TmBeforeTdCreatesSingleClosureTmOrderedFirst() + { + await RegisterTm("tm-a", TestMaterialization.Tm("urn:tm-a")); + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", extendsHrefs: "urn:tm-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), + "A shared closure must project as one runtime NodeManager."); + HostOperation op = m_host.Operations.Single(o => o.Op == "add"); + Assert.That(op.SourceNames, Is.EqualTo(s_tmTdSourceNames), + "Thing Models must be ordered before the Thing Descriptions that extend them."); + // With the default (no-op) binder, affordance forms have no binder and + // materialize as degraded nodes, so the projected outcome is Warning; + // both members nonetheless reach the Active load state. + Assert.That( + result.Results.Count(r => + r.Outcome is WoTOutcomeEnum.Success or WoTOutcomeEnum.Warning), + Is.EqualTo(2)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingModels, "tm-a")!.LoadState, + Is.EqualTo(WoTLoadStateEnum.Active)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Active)); + } + + [Test] + public async Task TdBeforeTmFailsThenSucceedsAfterTmRegistration() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", extendsHrefs: "urn:tm-a")); + + WotRefreshResult first = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.Zero, + "A Thing Description with a missing model dependency must not project."); + WoTResourceLoadResultDataType tdResult = + first.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(tdResult.Outcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + Assert.That(tdResult.Phase, Is.EqualTo(WoTPhaseEnum.DependencyResolution)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Failed)); + + await RegisterTm("tm-a", TestMaterialization.Tm("urn:tm-a")); + WotRefreshResult second = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), + "Registering the missing model must let the closure project."); + Assert.That( + second.Results.Count(r => + r.Outcome is WoTOutcomeEnum.Success or WoTOutcomeEnum.Warning), + Is.EqualTo(2)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Active)); + } + + [Test] + public async Task ExternalWebDependencyIsNotResolvedOutsideRegistry() + { + await RegisterTd( + "td-a", + TestMaterialization.Td( + "urn:td-a", + extendsHrefs: "https://example.invalid/models/pump.tm.jsonld")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.Zero); + WoTResourceLoadResultDataType tdResult = + result.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(tdResult.Outcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + Assert.That(tdResult.Phase, Is.EqualTo(WoTPhaseEnum.DependencyResolution)); + } + + [Test] + public async Task UnchangedRefreshPreservesRegistrationNoModelEvent() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + + WotRefreshResult second = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), "No new add on an unchanged refresh."); + Assert.That(m_host.ShadowCount, Is.Zero, "No shadow reload on an unchanged refresh."); + Assert.That(m_host.ImmediateCount, Is.Zero, + "No immediate reload on an unchanged refresh."); + Assert.That( + second.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Unchanged)); + } + + [Test] + public async Task InvalidVersionFailureRetainsPreviousActiveProjection() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v1")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + WotResource afterFirst = + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")!; + string activeBefore = afterFirst.ActiveVersionId!; + Assert.That(afterFirst.LoadState, Is.EqualTo(WoTLoadStateEnum.Active)); + + // A new version whose conversion fails. + m_converter.MarkInvalid("td-a"); + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v2")); + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.RemoveCount, Is.Zero, + "A failed refresh must retain the previous active projection."); + WotResource afterFail = + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")!; + Assert.That(afterFail.LoadState, Is.EqualTo(WoTLoadStateEnum.Failed)); + Assert.That(afterFail.ActiveVersionId, Is.EqualTo(activeBefore), + "The previously active version must be retained on failure."); + Assert.That( + result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Failed)); + Assert.That( + events.Any(e => e.Kind == WotMaterializationEventKind.ValidationFailure), + Is.True, "A validation failure event must be emitted."); + } + + [Test] + public async Task VersionSwitchUsesShadowReload() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v1")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v2")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), "A version switch must not re-add."); + Assert.That(m_host.ShadowCount, Is.EqualTo(1), + "A version switch must shadow-reload the projection."); + } + + [Test] + public async Task VersionSwitchUsesImmediateReloadWhenConfigured() + { + m_coordinator.RetirementPolicy = WotProjectionRetirementPolicy.Immediate; + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v1")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v2")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.ShadowCount, Is.Zero); + Assert.That(m_host.ImmediateCount, Is.EqualTo(1), + "Immediate retirement must use the host's immediate reload path."); + } + + [Test] + public async Task VersionSwitchCleanupWarningTracksCommittedReplacement() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v1")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + m_host.NextReloadWarning = "Prior-generation cleanup is pending."; + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v2")); + WotRefreshResult switched = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + WoTResourceLoadResultDataType result = + switched.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Warning)); + Assert.That(result.Message, Does.Contain("cleanup is pending")); + + WotRefreshResult unchanged = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.ShadowCount, Is.EqualTo(1), + "The committed replacement handle must remain tracked after a cleanup warning."); + Assert.That( + unchanged.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Unchanged)); + } + + [Test] + public async Task DeleteRetiresProjection() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + + await m_registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "td-a"); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.RemoveCount, Is.EqualTo(1), + "A deleted resource's projection must be retired."); + } + + [Test] + public async Task IndependentClosuresPartialSuccess() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await RegisterTd("td-b", TestMaterialization.Td("urn:td-b")); + m_converter.MarkInvalid("td-b"); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), + "Only the projectable closure commits."); + Assert.That(result.Summary.Succeeded, Is.EqualTo(1u)); + Assert.That(result.Summary.Failed, Is.EqualTo(1u)); + Assert.That(result.Summary.Outcome, Is.EqualTo(WoTOutcomeEnum.Warning)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Active)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-b")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Failed)); + } + + [Test] + public async Task RefreshExpectedGenerationMismatchIsRejected() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + ExpectedGeneration = 99999 + }); + + Assert.That(result.Summary.Outcome, Is.EqualTo(WoTOutcomeEnum.Rejected)); + Assert.That(m_host.AddCount, Is.Zero); + } + + [Test] + public async Task DryRunDoesNotCommit() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + Options = new WoTRefreshOptionsDataType { DryRun = true } + }); + + Assert.That(m_host.AddCount, Is.Zero, "A dry run must not project."); + Assert.That(result.NewGeneration, Is.Zero); + Assert.That(m_coordinator.Generation, Is.Zero); + Assert.That(result.Results.Single().Generation, Is.EqualTo(1u)); + } + + [Test] + public async Task DryRunRetirementDoesNotTearDownProjection() + { + var binders = new RecordingBinderRegistry(); + m_coordinator.Dispose(); + m_coordinator = new WotMaterializationCoordinator( + m_registry, m_host, binders, documentConverter: m_converter); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + Assert.That(binders.ActivatedPlans, Has.Count.EqualTo(1)); + + await m_registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "td-a"); + WotRefreshResult dryRun = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + Options = new WoTRefreshOptionsDataType { DryRun = true } + }); + + Assert.That(m_host.RemoveCount, Is.Zero, "A dry-run retirement must not remove the projection."); + Assert.That(binders.DeactivatedPlans, Is.Empty, + "A dry-run retirement must not deactivate active binding plans."); + Assert.That(dryRun.Summary.Retired, Is.EqualTo(1u)); + WoTResourceLoadResultDataType retired = dryRun.Results.Single(); + Assert.That(retired.ResourceId, Is.EqualTo("td-a")); + Assert.That(retired.Outcome, Is.EqualTo(WoTOutcomeEnum.Skipped)); + Assert.That(retired.LoadState, Is.EqualTo(WoTLoadStateEnum.Unloaded)); + Assert.That(retired.Message, Does.Contain("would be retired")); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.RemoveCount, Is.EqualTo(1), + "The committed retirement must still find the tracked closure after the dry run."); + Assert.That(binders.DeactivatedPlans, Has.Count.EqualTo(1)); + } + + [Test] + public async Task DryRunsDoNotAdvanceExpectedGeneration() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + WotRefreshResult committed = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + uint expectedGeneration = committed.NewGeneration; + Assert.That(expectedGeneration, Is.EqualTo(m_coordinator.Generation)); + + for (int i = 0; i < 2; i++) + { + WotRefreshResult dryRun = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + Options = new WoTRefreshOptionsDataType { DryRun = true } + }); + + Assert.That(dryRun.NewGeneration, Is.Zero); + Assert.That(m_coordinator.Generation, Is.EqualTo(expectedGeneration)); + } + + WotRefreshResult afterDryRuns = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + ExpectedGeneration = expectedGeneration + }); + + Assert.That(afterDryRuns.Summary.Outcome, Is.Not.EqualTo(WoTOutcomeEnum.Rejected)); + Assert.That(m_coordinator.Generation, Is.EqualTo(expectedGeneration + 1)); + } + + [Test] + public async Task DetailedResultsCarryNodeCountAndDigest() + { + m_converter.SetNodeCount("td-a", 7); + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + RequestId = "req-1" + }); + + WoTResourceLoadResultDataType td = result.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(td.MaterializedNodeCount, Is.EqualTo(7u)); + Assert.That(td.ContentDigest.Length, Is.GreaterThan(0)); + Assert.That(result.Summary.RequestId, Is.EqualTo("req-1")); + } + + [Test] + public async Task RootNodeIdIsRecordedFromGeneratedNodeSet() + { + // The fake converter emits a NodeSet whose model namespace is + // urn:wot:{group}/{resource}; register it so the coordinator can + // resolve the recorded projection root into a server NodeId. + var namespaces = new NamespaceTable(); + string modelUri = $"urn:wot:{WotRegistryGroups.ThingDescriptions}/td-a"; + namespaces.Append(modelUri); + m_coordinator.ServerNamespaceUris = namespaces; + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + WoTResourceLoadResultDataType td = result.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(td.RootNodeId.IsNull, Is.False, + "A document with a root must report a non-null RootNodeId."); + Assert.That(td.RootNodeId.NamespaceIndex, + Is.EqualTo((ushort)namespaces.GetIndex(modelUri))); + WotResource resource = m_registry.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "td-a")!; + Assert.That(resource.RootNodeId.IsNull, Is.False); + } + + [Test] + public async Task RootNodeIdIsNullWhenNamespaceCannotBeResolved() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + WoTResourceLoadResultDataType td = result.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(td.RootNodeId.IsNull, Is.True, + "A document with an unresolved root namespace must report NodeId.Null."); + WotResource resource = m_registry.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "td-a")!; + Assert.That(resource.RootNodeId.IsNull, Is.True); + } + + [Test] + public async Task LoadFailureEventFailedNodeIdDefaultsToNullNodeId() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + m_converter.MarkInvalid("td-a"); + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + WotMaterializationEventArgs failure = events.Single( + e => e.Kind == WotMaterializationEventKind.ValidationFailure); + Assert.That(failure.FailedNodeId.IsNull, Is.True); + } + + [Test] + public void FailedNodeIdCanCarryConcreteNodeId() + { + var args = new WotMaterializationEventArgs(WotMaterializationEventKind.LoadFailure) + { + FailedNodeId = new NodeId(1234, 2) + }; + + Assert.That(args.FailedNodeId.IsNull, Is.False); + Assert.That(args.FailedNodeId, Is.EqualTo(new NodeId(1234, 2))); + } + + [Test] + public async Task PlaceholderResourceWithoutVersionIsNotProjected() + { + await m_registry.TryCreateResourceAsync( + WotRegistryGroups.ThingDescriptions, "empty", + WoTDocumentKindEnum.ThingDescription); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.Zero, + "A content-less placeholder resource must not project."); + Assert.That(result.Results, Is.Empty); + } + + private sealed class RecordingBinderRegistry : IWotBinderRegistry + { + public List ActivatedPlans { get; } = []; + public List DeactivatedPlans { get; } = []; + + public IReadOnlyList Capabilities { get; } + = []; + + public WotBindingPlan Prepare(WotBindingPlanRequest request) + { + var entry = new WotCompiledForm( + new WotBindingIdentity("rec", "1.0", "urn:rec"), + WotAffordanceKind.Property, "value", "/properties/value/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor("rec", null, -1, "rec://x"), + new WotAddressingDescriptor("value"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + [], isExecutable: true); + return new WotBindingPlan(request.ResourceXid, [], [entry], [], []); + } + + public ValueTask ActivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + ActivatedPlans.Add(plan); + return default; + } + + public ValueTask DeactivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + DeactivatedPlans.Add(plan); + return default; + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationExtensibilityTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationExtensibilityTests.cs new file mode 100644 index 0000000000..c4c2b56dbb --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationExtensibilityTests.cs @@ -0,0 +1,270 @@ +/* ======================================================================== + * 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.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Tests the two materialization extension points a protocol driver needs: contributing custom + /// DataTypes that have no NodeSet to import, and resolving companion-specification NodeSets a + /// Thing Description depends on but the server does not already know. + /// + [TestFixture] + [Category("WotCon")] + public sealed class WotMaterializationExtensibilityTests + { + [SetUp] + public void SetUp() + { + m_registry = new WotRegistryService(); + m_host = new FakeWotProjectionHost(); + m_converter = new FakeWotDocumentConverter(); + } + + [TearDown] + public void TearDown() + { + m_coordinator?.Dispose(); + m_registry.Dispose(); + } + + [Test] + public async Task AContributorAddsNodesToEveryConvertedDocumentAsync() + { + var contributor = new RecordingContributor(); + m_coordinator = new WotMaterializationCoordinator( + m_registry, + m_host, + documentConverter: m_converter, + nodeSetContributors: [contributor]); + + await RegisterAsync("a").ConfigureAwait(false); + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(contributor.Resources, Does.Contain("a"), + "A contributor must run once for the resource being materialized."); + Assert.That(contributor.SawNodesBeforeContributing, Is.True, + "A contributor must run after conversion, so the converted nodes are present."); + }); + } + + [Test] + public async Task NoContributorLeavesMaterializationUnchangedAsync() + { + m_coordinator = new WotMaterializationCoordinator( + m_registry, m_host, documentConverter: m_converter); + + await RegisterAsync("a").ConfigureAwait(false); + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(m_host.AddCount, Is.EqualTo(1), + "Registering no contributor must leave the previous behaviour untouched."); + } + + [Test] + public async Task AContributedDataTypeReachesTheProjectionAsync() + { + m_coordinator = new WotMaterializationCoordinator( + m_registry, + m_host, + documentConverter: m_converter, + nodeSetContributors: [new DataTypeContributor()]); + + await RegisterAsync("a").ConfigureAwait(false); + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + string projected = string.Join( + "\n", + m_host.Operations.Where(o => o.Document is not null).SelectMany(o => o.Document!.Sources) + .Select(s => Encoding.UTF8.GetString(s.NodeSetXml))); + + Assert.That(projected, Does.Contain(DataTypeContributor.BrowseName), + "A DataType contributed before materialization must reach the projection, which " + + "is what lets a uav:mapByFieldPath mapping resolve against a controller UDT."); + } + + [Test] + public async Task AnUnresolvedDependencyNamespaceIsReportedNotSilentlyDroppedAsync() + { + m_converter.RequiredNamespace = kDependencyNamespace; + m_coordinator = new WotMaterializationCoordinator( + m_registry, + m_host, + documentConverter: m_converter, + nodeSetResolver: new DecliningResolver()); + + await RegisterAsync("a").ConfigureAwait(false); + WotRefreshResult result = await m_coordinator + .RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That( + result.Results.Any(r => r.Outcome != WoTOutcomeEnum.Success), + Is.True, + "A namespace nothing can resolve must surface, so an operator sees what is missing."); + } + + [Test] + public async Task AResolvedDependencyIsProjectedBeforeTheDocumentThatNeedsItAsync() + { + m_converter.RequiredNamespace = kDependencyNamespace; + m_coordinator = new WotMaterializationCoordinator( + m_registry, + m_host, + documentConverter: m_converter, + nodeSetResolver: new StubResolver(kDependencyNamespace)); + + await RegisterAsync("a").ConfigureAwait(false); + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + WotProjectionDocument? document = m_host.Operations.LastOrDefault(o => o.Document is not null)?.Document; + Assert.That(document, Is.Not.Null); + Assert.That(document!.Sources[0].Name, Is.EqualTo(kDependencyNamespace), + "A resolved dependency must be materialized before the document that requires it."); + } + + [Test] + public async Task NoResolverLeavesAKnownDocumentUnaffectedAsync() + { + m_coordinator = new WotMaterializationCoordinator( + m_registry, m_host, documentConverter: m_converter); + + await RegisterAsync("a").ConfigureAwait(false); + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + Assert.That(m_host.AddCount, Is.EqualTo(1), + "A document with no unmet dependency must not need a resolver at all."); + } + + private ValueTask RegisterAsync(string resourceId) + { + return m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = TestMaterialization.Td("urn:" + resourceId) + }); + } + + private const string kDependencyNamespace = "urn:test:dependency"; + + private WotRegistryService m_registry = null!; + private FakeWotProjectionHost m_host = null!; + private FakeWotDocumentConverter m_converter = null!; + private WotMaterializationCoordinator? m_coordinator; + + private sealed class RecordingContributor : IWotNodeSetContributor + { + public List Resources { get; } = []; + + public bool SawNodesBeforeContributing { get; private set; } + + public ValueTask ContributeAsync( + WotResource resource, + UANodeSet nodeSet, + CancellationToken cancellationToken = default) + { + Resources.Add(resource.ResourceId); + SawNodesBeforeContributing = nodeSet.Items is { Length: > 0 }; + return default; + } + } + + private sealed class DataTypeContributor : IWotNodeSetContributor + { + public const string BrowseName = "ContributedUdt"; + + public ValueTask ContributeAsync( + WotResource resource, + UANodeSet nodeSet, + CancellationToken cancellationToken = default) + { + var dataType = new UADataType + { + NodeId = "ns=1;i=9000", + BrowseName = "1:" + BrowseName, + DisplayName = [new Opc.Ua.Export.LocalizedText { Value = BrowseName }] + }; + nodeSet.Items = [.. nodeSet.Items ?? [], dataType]; + return default; + } + } + + private sealed class DecliningResolver : IWotNodeSetResolver + { + public ValueTask TryResolveAsync( + string namespaceUri, CancellationToken cancellationToken = default) + { + // Returning null is the contract's way of declining; it is not an error. + return new ValueTask((Stream?)null); + } + } + + private sealed class StubResolver : IWotNodeSetResolver + { + public StubResolver(string namespaceUri) + { + m_namespaceUri = namespaceUri; + } + + public ValueTask TryResolveAsync( + string namespaceUri, CancellationToken cancellationToken = default) + { + if (!string.Equals(namespaceUri, m_namespaceUri, StringComparison.Ordinal)) + { + return new ValueTask((Stream?)null); + } + string xml = + "" + + "" + + "" + m_namespaceUri + "" + + "" + + ""; + return new ValueTask( + (Stream?)new MemoryStream(Encoding.UTF8.GetBytes(xml))); + } + + private readonly string m_namespaceUri; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationTestSupport.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationTestSupport.cs new file mode 100644 index 0000000000..7ed6a22f23 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationTestSupport.cs @@ -0,0 +1,246 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Export; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// A test that records the add / shadow / + /// remove operations without a running server. + /// + internal sealed class FakeWotProjectionHost : IWotProjectionHost + { + public List Operations { get; } = new(); + + public int AddCount { get; private set; } + public int ShadowCount { get; private set; } + public int ImmediateCount { get; private set; } + public int RemoveCount { get; private set; } + public string NextReloadWarning { get; set; } = string.Empty; + + public ValueTask AddAsync( + WotProjectionDocument document, CancellationToken cancellationToken = default) + { + AddCount++; + Operations.Add(new HostOperation("add", document)); + return new ValueTask(MakeHandle(document)); + } + + public ValueTask ShadowReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + ShadowCount++; + Operations.Add(new HostOperation("shadow", document)); + long gen = (current?.Generation ?? 0) + 1; + string warning = NextReloadWarning; + NextReloadWarning = string.Empty; + return new ValueTask(MakeHandle(document, gen, warning)); + } + + public ValueTask ImmediateReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + ImmediateCount++; + Operations.Add(new HostOperation("immediate", document)); + long gen = (current?.Generation ?? 0) + 1; + string warning = NextReloadWarning; + NextReloadWarning = string.Empty; + return new ValueTask(MakeHandle(document, gen, warning)); + } + + public ValueTask RemoveAsync( + WotProjectionHandle handle, CancellationToken cancellationToken = default) + { + RemoveCount++; + Operations.Add(new HostOperation("remove", null, handle?.ClosureKey ?? string.Empty)); + return default; + } + + private static WotProjectionHandle MakeHandle( + WotProjectionDocument document, + long gen = 1, + string warning = "") + { + return new WotProjectionHandle( + document.ClosureKey, + gen, + new FakeProjectionRegistration(), + ImmutableArray.Empty, + 0, + warning); + } + + private sealed class FakeProjectionRegistration : IWotProjectionRegistration + { + public Guid Id { get; } = Guid.NewGuid(); + } + } + + /// + /// Stands in for a host-specific projection registration in tests that do + /// not exercise a real NodeManager lifecycle. + /// + internal sealed class FakeWotProjectionRegistration : IWotProjectionRegistration + { + /// + public Guid Id { get; } = Guid.NewGuid(); + } + + internal sealed class HostOperation + { + public HostOperation(string op, WotProjectionDocument? document, string closureKey = "") + { + Op = op; + Document = document; + ClosureKey = document?.ClosureKey ?? closureKey; + } + + public string Op { get; } + public WotProjectionDocument? Document { get; } + public string ClosureKey { get; } + + public IReadOnlyList SourceNames + { + get + { + var names = new List(); + if (Document is not null) + { + foreach (WotProjectionSource source in Document.Sources) + { + names.Add(source.Name); + } + } + return names; + } + } + } + + /// + /// A deterministic that returns a canned + /// NodeSet2 per resource id, or a failure for ids marked invalid. + /// + internal sealed class FakeWotDocumentConverter : IWotDocumentConverter + { + private readonly Dictionary m_nodeCounts = new(StringComparer.Ordinal); + private readonly HashSet m_invalid = new(StringComparer.Ordinal); + + public void SetNodeCount(string resourceId, int nodeCount) + => m_nodeCounts[resourceId] = nodeCount; + + public void MarkInvalid(string resourceId) => m_invalid.Add(resourceId); + + public void ClearInvalid(string resourceId) => m_invalid.Remove(resourceId); + + /// + /// When set, every converted NodeSet declares a dependency on this namespace, so the + /// coordinator has an unmet dependency to resolve. + /// + public string? RequiredNamespace { get; set; } + + public ValueTask ConvertAsync( + WotResource resource, + ReadOnlyMemory content, + WotRegistrySnapshot snapshot, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (m_invalid.Contains(resource.ResourceId)) + { + return new ValueTask( + WotConversionOutput.Failure($"Injected conversion failure for '{resource.ResourceId}'.")); + } + int nodeCount = m_nodeCounts.TryGetValue(resource.ResourceId, out int c) ? c : 2; + UANodeSet nodeSet = TestNodeSets.Make( + $"urn:wot:{resource.GroupId}/{resource.ResourceId}", nodeCount, RequiredNamespace); + return new ValueTask(WotConversionOutput.Success(nodeSet)); + } + } + + internal static class TestNodeSets + { + public static byte[] XmlBytes(string modelUri, string? requiredNamespace = null) + { + return Encoding.UTF8.GetBytes(Xml(modelUri, requiredNamespace)); + } + + public static UANodeSet Make( + string modelUri, int nodeCount, string? requiredNamespace = null) + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(Xml(modelUri, requiredNamespace, nodeCount))); + return UANodeSet.Read(stream)!; + } + + private static string Xml( + string modelUri, + string? requiredNamespace = null, + int nodeCount = 1) + { + var builder = new StringBuilder(); + builder.Append(""); + builder.Append(""); + builder.Append("").Append(modelUri).Append(""); + builder.Append(""); + } + else + { + builder.Append(">") + .Append(""); + } + for (int i = 0; i < nodeCount; i++) + { + int id = 5000 + i; + builder.Append("Node") + .Append(i).Append(""); + } + builder.Append(""); + return builder.ToString(); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTestSupport.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTestSupport.cs new file mode 100644 index 0000000000..bf08a4aaf2 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTestSupport.cs @@ -0,0 +1,554 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using System.Xml; +using Moq; +using Opc.Ua.Server; +using Opc.Ua.Server.Fluent; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// A test that opens pre-configured + /// fake channels (or a configurable opener delegate) per compiled form, and + /// records how many times each form was opened. + /// + internal sealed class FakeWotBindingChannelFactory : IWotBindingChannelFactory + { + public int OpenCount { get; private set; } + + public List OpenedForms { get; } = []; + + public void SetChannel(WotCompiledForm form, IWotBindingChannel channel) + { + m_openers[form] = () => new ValueTask(channel); + } + + public void SetOpener(WotCompiledForm form, Func> opener) + { + m_openers[form] = opener; + } + + public ValueTask OpenChannelAsync( + WotCompiledForm form, CancellationToken cancellationToken = default) + { + OpenCount++; + OpenedForms.Add(form); + if (m_openers.TryGetValue(form, out Func>? opener)) + { + return opener(); + } + throw new InvalidOperationException($"No fake channel configured for form '{form.AffordanceName}'."); + } + + private readonly Dictionary>> m_openers = []; + } + + /// + /// A test with configurable read/write/dispose behavior. + /// + internal sealed class FakeWotBindingChannel : IWotBindingChannel + { + public FakeWotBindingChannel(WotCompiledForm form) + { + Form = form; + } + + public WotCompiledForm Form { get; } + + public Func>? OnRead { get; set; } + + public Func>? OnWrite { get; set; } + + public Func? OnDispose { get; set; } + + public int ReadCount { get; private set; } + + public int WriteCount { get; private set; } + + public int DisposeCount { get; private set; } + + public ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + ReadCount++; + return OnRead?.Invoke(cancellationToken) + ?? new ValueTask(new WotReadResult(StatusCodes.Good, new DataValue(Variant.Null))); + } + + public ValueTask WriteAsync(DataValue value, CancellationToken cancellationToken = default) + { + WriteCount++; + return OnWrite?.Invoke(value, cancellationToken) + ?? new ValueTask(new WotWriteResult(StatusCodes.Good)); + } + + public ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public async ValueTask DisposeAsync() + { + DisposeCount++; + if (OnDispose is not null) + { + await OnDispose().ConfigureAwait(false); + } + } + } + + /// + /// A hand-crafted nested structure used to exercise uav:mapByFieldPath nesting. + /// + internal sealed class TestChildStructure : IEncodeable, IStructure + { + public int X { get; set; } + + public ExpandedNodeId TypeId => TestChildType.EncodingId; + + public ExpandedNodeId BinaryEncodingId => TestChildType.EncodingId; + + public ExpandedNodeId XmlEncodingId => TestChildType.EncodingId; + + public void Encode(IEncoder encoder) + { + } + + public void Decode(IDecoder decoder) + { + } + + public bool IsEqual(IEncodeable? encodeable) + { + return encodeable is TestChildStructure other && other.X == X; + } + + public object Clone() + { + return new TestChildStructure { X = X }; + } + + public IReadOnlyList GetFields() + { + return []; + } + + public Variant this[int index] + { + get => index == 0 ? new Variant(X) : throw new ArgumentOutOfRangeException(nameof(index)); + set + { + if (index == 0 && value.TryGetValue(out int x)) + { + X = x; + } + } + } + + public Variant this[string name] + { + get => name == "X" ? new Variant(X) : throw new ArgumentOutOfRangeException(nameof(name)); + set + { + if (name == "X" && value.TryGetValue(out int x)) + { + X = x; + } + } + } + } + + /// + /// The activator for . + /// + internal sealed class TestChildType : EncodeableType + { + public const uint NumericId = 9101; + + public static ExpandedNodeId EncodingId { get; } = new ExpandedNodeId(NumericId, TestStructureNamespace.Uri); + + public override XmlQualifiedName XmlName => new("TestChildStructure", Ua.Namespaces.OpcUaXsd); + + public override IEncodeable CreateInstance() + { + return new TestChildStructure(); + } + + public override DataTypeDefinition GetDataTypeDefinition(NamespaceTable namespaceUris) + { + return new StructureDefinition + { + BaseDataType = Ua.DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = + [ + new StructureField + { + Name = "X", + DataType = Ua.DataTypeIds.Int32, + ValueRank = ValueRanks.Scalar + } + ] + }; + } + } + + /// + /// A hand-crafted root structure with a scalar field, an array field and a + /// nested field, used to exercise one-level + /// and nested uav:mapByFieldPath composition. + /// + internal sealed class TestRootStructure : IEncodeable, IStructure + { + public int A { get; set; } + + public Variant ChildValue { get; set; } = Variant.Null; + + public Variant ArrayValue { get; set; } = Variant.Null; + + public ExpandedNodeId TypeId => TestRootType.EncodingId; + + public ExpandedNodeId BinaryEncodingId => TestRootType.EncodingId; + + public ExpandedNodeId XmlEncodingId => TestRootType.EncodingId; + + public void Encode(IEncoder encoder) + { + } + + public void Decode(IDecoder decoder) + { + } + + public bool IsEqual(IEncodeable? encodeable) + { + return ReferenceEquals(this, encodeable); + } + + public object Clone() + { + return new TestRootStructure { A = A, ChildValue = ChildValue, ArrayValue = ArrayValue }; + } + + public IReadOnlyList GetFields() + { + return []; + } + + public Variant this[int index] + { + get => index switch + { + 0 => new Variant(A), + 1 => ChildValue, + 2 => ArrayValue, + _ => throw new ArgumentOutOfRangeException(nameof(index)) + }; + set + { + switch (index) + { + case 0: + if (value.TryGetValue(out int a)) + { + A = a; + } + break; + case 1: + ChildValue = value; + break; + case 2: + ArrayValue = value; + break; + } + } + } + + public Variant this[string name] + { + get => name switch + { + "A" => new Variant(A), + "Child" => ChildValue, + "ArrayField" => ArrayValue, + _ => throw new ArgumentOutOfRangeException(nameof(name)) + }; + set + { + switch (name) + { + case "A": + if (value.TryGetValue(out int a)) + { + A = a; + } + break; + case "Child": + ChildValue = value; + break; + case "ArrayField": + ArrayValue = value; + break; + } + } + } + } + + /// + /// The activator for . + /// + internal sealed class TestRootType : EncodeableType + { + public const uint NumericId = 9100; + + public static ExpandedNodeId EncodingId { get; } = new ExpandedNodeId(NumericId, TestStructureNamespace.Uri); + + public override XmlQualifiedName XmlName => new("TestRootStructure", Ua.Namespaces.OpcUaXsd); + + public override IEncodeable CreateInstance() + { + return new TestRootStructure(); + } + + public override DataTypeDefinition GetDataTypeDefinition(NamespaceTable namespaceUris) + { + return new StructureDefinition + { + BaseDataType = Ua.DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = + [ + new StructureField + { + Name = "A", + DataType = Ua.DataTypeIds.Int32, + ValueRank = ValueRanks.Scalar + }, + new StructureField + { + Name = "Child", + DataType = ExpandedNodeId.ToNodeId(TestChildType.EncodingId, namespaceUris), + ValueRank = ValueRanks.Scalar + }, + new StructureField + { + Name = "ArrayField", + DataType = Ua.DataTypeIds.Int32, + ValueRank = ValueRanks.OneDimension + } + ] + }; + } + } + + /// + /// The shared namespace URI the hand-crafted test structure types are registered under. + /// + internal static class TestStructureNamespace + { + public const string Uri = "http://test.org/UA/WotProjectionBindingRuntimeTests/"; + } + + /// + /// Builds a minimal graph (no running + /// server) with a scalar Int32 variable and a + /// typed variable, for exercising + /// directly. + /// + internal sealed class WotProjectionBindingRuntimeTestHarness + { + public NodeManagerBuilder Builder { get; } + + public ushort Ns { get; } + + public BaseDataVariableState ScalarVar { get; } + + public BaseDataVariableState StructVar { get; } + + public FakeWotBindingChannelFactory ChannelFactory { get; } = new(); + + /// + /// Initializes a new harness. + /// + /// + /// When true (the default), and + /// are registered into the harness's + /// up front, matching a NodeManager + /// activated after NodeManagerLifecycle.RefreshComplexTypesAsync + /// already populated it. When false, neither type is + /// registered, matching activation before that refresh runs; the test + /// can register them later against the same + /// instance (via + /// Builder.Context.EncodeableFactory) to simulate the refresh + /// completing after the runtime was wired. + /// + public WotProjectionBindingRuntimeTestHarness(bool registerStructureTypes = true) + { + var namespaceUris = new NamespaceTable(); + Ns = (ushort)namespaceUris.Append(TestStructureNamespace.Uri); + + IEncodeableFactory factory = ServiceMessageContext.CreateEmpty(null!).Factory; + if (registerStructureTypes) + { + factory.Builder + .AddEncodeableType(TestChildType.EncodingId, new TestChildType()) + .AddEncodeableType(TestRootType.EncodingId, new TestRootType()) + .Commit(); + } + + var ctx = new SystemContext(telemetry: null!) + { + NamespaceUris = namespaceUris, + EncodeableFactory = factory + }; + + var root = new BaseObjectState(parent: null) + { + NodeId = new NodeId("Root", Ns), + BrowseName = new QualifiedName("Root", Ns), + DisplayName = new LocalizedText("Root") + }; + + ScalarVar = new BaseDataVariableState(root) + { + NodeId = new NodeId("Scalar", Ns), + BrowseName = new QualifiedName("Scalar", Ns), + DisplayName = new LocalizedText("Scalar"), + DataType = Ua.DataTypeIds.Int32, + ValueRank = ValueRanks.Scalar, + AccessLevel = AccessLevels.CurrentReadOrWrite, + UserAccessLevel = AccessLevels.CurrentReadOrWrite + }; + root.AddChild(ScalarVar); + + StructVar = new BaseDataVariableState(root) + { + NodeId = new NodeId("Struct", Ns), + BrowseName = new QualifiedName("Struct", Ns), + DisplayName = new LocalizedText("Struct"), + DataType = new NodeId(TestRootType.NumericId, Ns), + ValueRank = ValueRanks.Scalar, + AccessLevel = AccessLevels.CurrentReadOrWrite, + UserAccessLevel = AccessLevels.CurrentReadOrWrite + }; + root.AddChild(StructVar); + + var byId = new Dictionary + { + [root.NodeId] = root, + [ScalarVar.NodeId] = ScalarVar, + [StructVar.NodeId] = StructVar + }; + + Builder = new NodeManagerBuilder( + ctx, + Mock.Of(), + Ns, + rootResolver: q => q == root.BrowseName ? root : null!, + nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n : null!, + typeIdResolver: _ => [], + dataTypeIdResolver: dataTypeId => + { + var matches = new List(); + foreach (NodeState node in byId.Values) + { + if (node is BaseVariableState v && v.DataType == dataTypeId) + { + matches.Add(node); + } + } + return matches.ToArrayOf(); + }); + } + + public string ScalarNodeIdText => $"ns={Ns};s=Scalar"; + + public string StructNodeIdText => $"ns={Ns};s=Struct"; + + public string StructTypeNodeIdText => $"ns={Ns};i={TestRootType.NumericId}"; + + public static WotCompiledForm Form( + WoTBindingCapabilityEnum operation, + WotTargetMappingDescriptor mapping, + bool executable = true, + string affordanceName = "value") + { + string opToken = operation switch + { + WoTBindingCapabilityEnum.ReadProperty => "readproperty", + WoTBindingCapabilityEnum.WriteProperty => "writeproperty", + WoTBindingCapabilityEnum.ObserveProperty => "observeproperty", + WoTBindingCapabilityEnum.InvokeAction => "invokeaction", + _ => "unknown" + }; + return new WotCompiledForm( + new WotBindingIdentity("test", "1.0", "urn:test"), + WotAffordanceKind.Property, + affordanceName, + "/properties/" + affordanceName + "/forms/0", + operation, + opToken, + new WotEndpointDescriptor("test", null, -1, "test://x"), + new WotAddressingDescriptor(affordanceName), + new WotOperationDescriptor(operation, opToken, "GET"), + new WotPayloadDescriptor("application/json", "json"), + [], + isExecutable: executable, + targetMapping: mapping); + } + + public static WotBindingPlan Plan(params WotCompiledForm[] forms) + { + return new( + "res", + [], + [.. forms], + [], + []); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTests.cs new file mode 100644 index 0000000000..01338e72e0 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotProjectionBindingRuntimeTests.cs @@ -0,0 +1,866 @@ +/* ======================================================================== + * 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.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server.Materialization; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises and + /// directly against a lightweight + /// graph (no running + /// server): direct scalar read/write, ignore rules, conflict/duplicate + /// diagnostics, lazy single-open channel caching, failed-open retry, + /// disposal, structured field composition, and generation isolation. + /// + [TestFixture] + public sealed class WotProjectionBindingRuntimeTests + { + [Test] + public async Task DirectReadReturnsChannelValuePreservingStatusAndTimestamp() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readForm = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + var channel = new FakeWotBindingChannel(readForm); + var timestamp = new DateTimeUtc(2026, 1, 1, 0, 0, 0); + channel.OnRead = _ => new ValueTask(new WotReadResult( + StatusCodes.Good, + new DataValue(new Variant(42), StatusCodes.UncertainInitialValue, timestamp))); + h.ChannelFactory.SetChannel(readForm, channel); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readForm)]).ConfigureAwait(false); + + (ServiceResult result, DataValue value) = await h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(ServiceResult.IsGoodOrUncertain(result), Is.True); + Assert.That(value.WrappedValue.TryGetValue(out int read) && read == 42, Is.True); + Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.UncertainInitialValue)); + Assert.That(value.SourceTimestamp, Is.EqualTo(timestamp)); + + Assert.That(runtime, Is.Not.Null); + await runtime!.DisposeAsync().ConfigureAwait(false); + Assert.That(channel.DisposeCount, Is.EqualTo(1)); + } + + [Test] + public async Task DirectWriteWritesThroughChannel() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm writeForm = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + var channel = new FakeWotBindingChannel(writeForm); + DataValue? written = null; + channel.OnWrite = (value, _) => + { + written = value; + return new ValueTask(new WotWriteResult(StatusCodes.Good)); + }; + h.ChannelFactory.SetChannel(writeForm, channel); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(writeForm)]).ConfigureAwait(false); + + ServiceResult result = await h.ScalarVar.WriteAttributeAsync( + h.Builder.Context, Attributes.Value, default, new DataValue(new Variant(7))).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(written, Is.Not.Null); + Assert.That(written!.Value.WrappedValue.TryGetValue(out int w) && w == 7, Is.True); + } + + [Test] + public async Task FormsWithoutTargetMappingAreIgnored() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, WotTargetMappingDescriptor.Empty); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form)]).ConfigureAwait(false); + + Assert.That(h.ScalarVar.OnReadValueAsync, Is.Null); + Assert.That(h.ChannelFactory.OpenCount, Is.Zero); + } + + [Test] + public async Task NonExecutableFormsAreIgnored() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + executable: false); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form)]).ConfigureAwait(false); + + Assert.That(h.ScalarVar.OnReadValueAsync, Is.Null); + Assert.That(h.ChannelFactory.OpenCount, Is.Zero); + } + + [Test] + public async Task ObserveOnlyDoesNotWireASeparateBridge() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ObserveProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form)]).ConfigureAwait(false); + + Assert.That(h.ScalarVar.OnReadValueAsync, Is.Null); + Assert.That(h.ScalarVar.OnWriteValueAsync, Is.Null); + Assert.That(h.ChannelFactory.OpenCount, Is.Zero); + } + + [Test] + public void ConflictingDirectAndFieldMappingThrowsDuringWire() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm direct = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "direct"); + WotCompiledForm field = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText, fieldPath: "X"), + affordanceName: "field"); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + + ServiceResultException? ex = Assert.ThrowsAsync(async () => + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(direct, field)]).ConfigureAwait(false)); + Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public void DuplicateReadMappingThrowsDuringWire() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm read1 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "read1"); + WotCompiledForm read2 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "read2"); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + + ServiceResultException? ex = Assert.ThrowsAsync(async () => + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(read1, read2)]).ConfigureAwait(false)); + Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public void DuplicateWriteMappingThrowsDuringWire() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm write1 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "write1"); + WotCompiledForm write2 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "write2"); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + + ServiceResultException? ex = Assert.ThrowsAsync(async () => + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(write1, write2)]).ConfigureAwait(false)); + Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public void UnsupportedOperationThrowsDuringWire() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm invoke = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.InvokeAction, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + + ServiceResultException? ex = Assert.ThrowsAsync(async () => + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(invoke)]).ConfigureAwait(false)); + Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public async Task LazyOpenConcurrentFirstUseOpensChannelOnce() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readForm = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + var channel = new FakeWotBindingChannel(readForm); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + h.ChannelFactory.SetOpener(readForm, async () => + { + await gate.Task.ConfigureAwait(false); + return channel; + }); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readForm)]).ConfigureAwait(false); + + Task<(ServiceResult, DataValue)> read1 = h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()).AsTask(); + Task<(ServiceResult, DataValue)> read2 = h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()).AsTask(); + + gate.SetResult(true); + await Task.WhenAll(read1, read2).ConfigureAwait(false); + + Assert.That(h.ChannelFactory.OpenCount, Is.EqualTo(1), + "Concurrent first use must open the shared channel exactly once."); + } + + [Test] + public async Task FailedOpenIsEvictedRetrySucceeds() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readForm = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + var channel = new FakeWotBindingChannel(readForm) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(1)))) + }; + int attempt = 0; + h.ChannelFactory.SetOpener(readForm, () => + { + attempt++; + if (attempt == 1) + { + return new ValueTask( + Task.FromException(new InvalidOperationException("open failed"))); + } + return new ValueTask(channel); + }); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readForm)]).ConfigureAwait(false); + + (ServiceResult firstResult, _) = await h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + Assert.That(ServiceResult.IsBad(firstResult), Is.True, "The first (faulted) open must fail the read."); + + (ServiceResult secondResult, DataValue secondValue) = await h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(secondResult), Is.True, "A retry after a faulted open must succeed."); + Assert.That(secondValue.WrappedValue.TryGetValue(out int v) && v == 1, Is.True); + Assert.That(attempt, Is.EqualTo(2), "The faulted open must be evicted so the retry opens again."); + } + + [Test] + public async Task DisposeDisposesOpenedChannelsAggregatesFailures() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readForm = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "read"); + WotCompiledForm writeForm = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText), + affordanceName: "write"); + var readChannel = new FakeWotBindingChannel(readForm); + var writeChannel = new FakeWotBindingChannel(writeForm) + { + OnDispose = () => throw new InvalidOperationException("dispose failed") + }; + h.ChannelFactory.SetChannel(readForm, readChannel); + h.ChannelFactory.SetChannel(writeForm, writeChannel); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readForm, writeForm)]).ConfigureAwait(false); + + // Open both channels. + await h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + await h.ScalarVar.WriteAttributeAsync( + h.Builder.Context, Attributes.Value, default, new DataValue(new Variant(1))).ConfigureAwait(false); + + AggregateException? ex = Assert.ThrowsAsync( + async () => await runtime!.DisposeAsync().ConfigureAwait(false)); + Assert.That(ex!.InnerExceptions, Has.Count.EqualTo(1)); + Assert.That(readChannel.DisposeCount, Is.EqualTo(1), + "A sibling channel's dispose failure must not prevent other channels from being disposed."); + Assert.That(writeChannel.DisposeCount, Is.EqualTo(1)); + } + + [Test] + public async Task GenerationIsolationTwoRuntimesHaveIndependentChannelsAndDisposal() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form1 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h.ScalarNodeIdText)); + var channel1 = new FakeWotBindingChannel(form1); + h.ChannelFactory.SetChannel(form1, channel1); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime1 = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form1)]).ConfigureAwait(false); + await h.ScalarVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + // A second, independent generation with its own compiled form (and + // hence its own channel) targeting the same variable. + var h2 = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form2 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetNodeId: h2.ScalarNodeIdText)); + var channel2 = new FakeWotBindingChannel(form2); + h2.ChannelFactory.SetChannel(form2, channel2); + var factory2 = new WotProjectionBindingRuntimeFactory(h2.ChannelFactory); + IAsyncDisposable? runtime2 = await factory2.CreateAsync( + h2.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form2)]).ConfigureAwait(false); + await h2.ScalarVar.ReadAttributeAsync( + h2.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + await runtime1!.DisposeAsync().ConfigureAwait(false); + + Assert.That(channel1.DisposeCount, Is.EqualTo(1)); + Assert.That(channel2.DisposeCount, Is.Zero, + "Disposing one generation's runtime must not affect a different generation's channels."); + + await runtime2!.DisposeAsync().ConfigureAwait(false); + Assert.That(channel2.DisposeCount, Is.EqualTo(1)); + } + + [Test] + public async Task StructuredOneLevelReadComposesFields() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA"); + var channelA = new FakeWotBindingChannel(readA) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(11)))) + }; + h.ChannelFactory.SetChannel(readA, channelA); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readA)]).ConfigureAwait(false); + + (ServiceResult result, DataValue value) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(value.WrappedValue.TryGetValue(out ExtensionObject ext), Is.True); + Assert.That(ext.TryGetValue(out IEncodeable? encodeable), Is.True); + var root = (TestRootStructure)encodeable!; + Assert.That(root.A, Is.EqualTo(11)); + } + + [Test] + public async Task StructuredOneLevelWriteExtractsFields() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm writeA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "writeA"); + var channelA = new FakeWotBindingChannel(writeA); + Variant written = default; + channelA.OnWrite = (value, _) => + { + written = value.WrappedValue; + return new ValueTask(new WotWriteResult(StatusCodes.Good)); + }; + h.ChannelFactory.SetChannel(writeA, channelA); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(writeA)]).ConfigureAwait(false); + + var incoming = new TestRootStructure { A = 55 }; + ServiceResult result = await h.StructVar.WriteAttributeAsync( + h.Builder.Context, + Attributes.Value, + default, + new DataValue(new Variant(new ExtensionObject(incoming)))).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(written.IsNull, Is.False); + Assert.That(written.TryGetValue(out int a) && a == 55, Is.True); + } + + [Test] + public async Task StructuredNestedReadComposesNestedStructure() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readChildX = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child/X"), + affordanceName: "readChildX"); + var channel = new FakeWotBindingChannel(readChildX) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(99)))) + }; + h.ChannelFactory.SetChannel(readChildX, channel); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readChildX)]).ConfigureAwait(false); + + (ServiceResult result, DataValue value) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(value.WrappedValue.TryGetValue(out ExtensionObject ext), Is.True); + Assert.That(ext.TryGetValue(out IEncodeable? rootEncodeable), Is.True); + var root = (TestRootStructure)rootEncodeable!; + Assert.That(root.ChildValue.TryGetValue(out ExtensionObject childExt), Is.True); + Assert.That(childExt.TryGetValue(out IEncodeable? childEncodeable), Is.True); + var child = (TestChildStructure)childEncodeable!; + Assert.That(child.X, Is.EqualTo(99)); + } + + [Test] + public async Task StructuredNestedWriteExtractsNestedField() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm writeChildX = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child/X"), + affordanceName: "writeChildX"); + var channel = new FakeWotBindingChannel(writeChildX); + Variant written = default; + channel.OnWrite = (value, _) => + { + written = value.WrappedValue; + return new ValueTask(new WotWriteResult(StatusCodes.Good)); + }; + h.ChannelFactory.SetChannel(writeChildX, channel); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(writeChildX)]).ConfigureAwait(false); + + var incomingChild = new TestChildStructure { X = 77 }; + var incoming = new TestRootStructure { ChildValue = new Variant(new ExtensionObject(incomingChild)) }; + ServiceResult result = await h.StructVar.WriteAttributeAsync( + h.Builder.Context, + Attributes.Value, + default, + new DataValue(new Variant(new ExtensionObject(incoming)))).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(written.IsNull, Is.False); + Assert.That(written.TryGetValue(out int x) && x == 77, Is.True); + } + + [Test] + public async Task StructuredUnknownFieldFirstReadFailsDeterministically() + { + // BuildPlan validation is deferred to first use (see class remarks + // on WotStructuredGroupState): CreateAsync itself must not throw. + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "NoSuchField")); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form)]).ConfigureAwait(false); + Assert.That(runtime, Is.Not.Null, "Wiring must succeed; field-path validation is deferred."); + + (ServiceResult result, _) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public async Task StructuredArrayValuedIntermediateFieldFirstReadFailsDeterministically() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "ArrayField/X")); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form)]).ConfigureAwait(false); + Assert.That(runtime, Is.Not.Null, "Wiring must succeed; field-path validation is deferred."); + + (ServiceResult result, _) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public async Task StructuredEmptyPathSegmentFirstReadFailsDeterministically() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm form = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child//X")); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(form)]).ConfigureAwait(false); + Assert.That(runtime, Is.Not.Null, "Wiring must succeed; field-path validation is deferred."); + + (ServiceResult result, _) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public async Task StructuredUnregisteredTypeFirstReadFailsDeterministicallyThenRetrySucceedsAfterRegistration() + { + // The structure type is not yet registered when the runtime is + // wired (mirrors ConfigureAsync running before + // NodeManagerLifecycle.RefreshComplexTypesAsync). Wiring, and a + // first read attempted before registration, must both fail + // deterministically without throwing out of the request pipeline; + // once the type is registered into the same factory instance + // (simulating RefreshComplexTypesAsync) a later read must succeed. + var h = new WotProjectionBindingRuntimeTestHarness(registerStructureTypes: false); + WotCompiledForm readA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA"); + var channelA = new FakeWotBindingChannel(readA) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(11)))) + }; + h.ChannelFactory.SetChannel(readA, channelA); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + IAsyncDisposable? runtime = await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readA)]).ConfigureAwait(false); + Assert.That(runtime, Is.Not.Null, + "Wiring must succeed even though the structure type is not registered yet."); + + (ServiceResult beforeResult, _) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + Assert.That(ServiceResult.IsBad(beforeResult), Is.True, + "A read before the type is registered must fail deterministically, not throw."); + Assert.That(beforeResult.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + + // Simulate NodeManagerLifecycle.RefreshComplexTypesAsync completing: + // register the type into the very same factory instance the + // runtime captured at wiring time. + h.Builder.Context.EncodeableFactory.Builder + .AddEncodeableType(TestRootType.EncodingId, new TestRootType()) + .Commit(); + + (ServiceResult afterResult, DataValue afterValue) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(afterResult), Is.True, + "A read after the type is registered must retry resolution and succeed."); + Assert.That(afterValue.WrappedValue.TryGetValue(out ExtensionObject ext), Is.True); + Assert.That(ext.TryGetValue(out IEncodeable? encodeable), Is.True); + Assert.That(((TestRootStructure)encodeable!).A, Is.EqualTo(11)); + } + + [Test] + public async Task StructuredUnregisteredTypeFirstWriteFailsDeterministicallyThenRetrySucceedsAfterRegistration() + { + var h = new WotProjectionBindingRuntimeTestHarness(registerStructureTypes: false); + WotCompiledForm writeA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "writeA"); + var channelA = new FakeWotBindingChannel(writeA); + Variant written = default; + channelA.OnWrite = (value, _) => + { + written = value.WrappedValue; + return new ValueTask(new WotWriteResult(StatusCodes.Good)); + }; + h.ChannelFactory.SetChannel(writeA, channelA); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(writeA)]).ConfigureAwait(false); + + var incomingBefore = new TestRootStructure { A = 1 }; + ServiceResult beforeResult = await h.StructVar.WriteAttributeAsync( + h.Builder.Context, + Attributes.Value, + default, + new DataValue(new Variant(new ExtensionObject(incomingBefore)))).ConfigureAwait(false); + Assert.That(ServiceResult.IsBad(beforeResult), Is.True, + "A write before the type is registered must fail deterministically, not throw."); + Assert.That(written.IsNull, Is.True); + + h.Builder.Context.EncodeableFactory.Builder + .AddEncodeableType(TestRootType.EncodingId, new TestRootType()) + .Commit(); + + var incomingAfter = new TestRootStructure { A = 55 }; + ServiceResult afterResult = await h.StructVar.WriteAttributeAsync( + h.Builder.Context, + Attributes.Value, + default, + new DataValue(new Variant(new ExtensionObject(incomingAfter)))).ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(afterResult), Is.True, + "A write after the type is registered must retry resolution and succeed."); + Assert.That(written.IsNull, Is.False); + Assert.That(written.TryGetValue(out int a) && a == 55, Is.True); + } + + [Test] + public async Task StructuredPartialReadFailureReturnsBadStatus() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA"); + WotCompiledForm readChildX = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child/X"), + affordanceName: "readChildX"); + var channelA = new FakeWotBindingChannel(readA) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(1)))) + }; + var channelChildX = new FakeWotBindingChannel(readChildX) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.BadNotConnected, DataValue.Null, "not connected")) + }; + h.ChannelFactory.SetChannel(readA, channelA); + h.ChannelFactory.SetChannel(readChildX, channelChildX); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readA, readChildX)]).ConfigureAwait(false); + + (ServiceResult result, _) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(ServiceResult.IsBad(result), Is.True, + "A single failing field must fail the whole structured read."); + } + + [Test] + public async Task StructuredPartialReadFailureReturnsFailedFieldStatusAndTimestampWhenAvailable() + { + // A failed field's own status and timestamp — not a hardcoded + // Bad/Now pair — must surface on the overall structured read. + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA"); + WotCompiledForm readChildX = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child/X"), + affordanceName: "readChildX"); + var channelA = new FakeWotBindingChannel(readA) + { + OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(1)))) + }; + var channelChildX = new FakeWotBindingChannel(readChildX); + var staleTimestamp = new DateTimeUtc(2020, 1, 1, 0, 0, 0); + channelChildX.OnRead = _ => new ValueTask( + new WotReadResult( + StatusCodes.BadNotConnected, + new DataValue(Variant.Null, StatusCodes.BadNotConnected, staleTimestamp), + "not connected")); + h.ChannelFactory.SetChannel(readA, channelA); + h.ChannelFactory.SetChannel(readChildX, channelChildX); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readA, readChildX)]).ConfigureAwait(false); + + (ServiceResult result, DataValue value) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNotConnected)); + Assert.That(value.SourceTimestamp, Is.EqualTo(staleTimestamp)); + } + + [Test] + public async Task StructuredReadPreservesNonDefaultGoodStatusAndUsesOldestSourceTimestamp() + { + // The composed value must not collapse every field's metadata to + // a hardcoded Good/Now: a non-default Good status among the + // fields must survive, and the oldest field timestamp must win. + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA"); + WotCompiledForm readChildX = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child/X"), + affordanceName: "readChildX"); + var channelA = new FakeWotBindingChannel(readA); + var newerTimestamp = new DateTimeUtc(2026, 1, 2, 0, 0, 0); + channelA.OnRead = _ => new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(new Variant(1), StatusCodes.Good, newerTimestamp))); + var channelChildX = new FakeWotBindingChannel(readChildX); + var olderTimestamp = new DateTimeUtc(2026, 1, 1, 0, 0, 0); + channelChildX.OnRead = _ => new ValueTask( + new WotReadResult( + StatusCodes.GoodClamped, + new DataValue(new Variant(2), StatusCodes.GoodClamped, olderTimestamp))); + h.ChannelFactory.SetChannel(readA, channelA); + h.ChannelFactory.SetChannel(readChildX, channelChildX); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readA, readChildX)]).ConfigureAwait(false); + + (ServiceResult result, DataValue value) = await h.StructVar.ReadAttributeAsync( + h.Builder.Context, Attributes.Value, default, QualifiedName.Null, new DataValue()) + .ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.GoodClamped)); + Assert.That(value.SourceTimestamp, Is.EqualTo(olderTimestamp), + "The oldest non-MinValue field timestamp must be used, not the current time."); + } + + [Test] + public async Task StructuredPartialWriteFailureReturnsBadStatus() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm writeA = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "writeA"); + WotCompiledForm writeChildX = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.WriteProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "Child/X"), + affordanceName: "writeChildX"); + var channelA = new FakeWotBindingChannel(writeA) + { + OnWrite = (_, _) => new ValueTask(new WotWriteResult(StatusCodes.Good)) + }; + var channelChildX = new FakeWotBindingChannel(writeChildX) + { + OnWrite = (_, _) => + new ValueTask(new WotWriteResult(StatusCodes.BadNotConnected, "not connected")) + }; + h.ChannelFactory.SetChannel(writeA, channelA); + h.ChannelFactory.SetChannel(writeChildX, channelChildX); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(writeA, writeChildX)]).ConfigureAwait(false); + + var incomingChild = new TestChildStructure { X = 1 }; + var incoming = new TestRootStructure + { + A = 2, + ChildValue = new Variant(new ExtensionObject(incomingChild)) + }; + ServiceResult result = await h.StructVar.WriteAttributeAsync( + h.Builder.Context, + Attributes.Value, + default, + new DataValue(new Variant(new ExtensionObject(incoming)))).ConfigureAwait(false); + + Assert.That(ServiceResult.IsBad(result), Is.True, + "A single failing field write must fail the whole structured write."); + } + + [Test] + public void StructuredDuplicateFieldMappingThrowsDuringWire() + { + var h = new WotProjectionBindingRuntimeTestHarness(); + WotCompiledForm readA1 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA1"); + WotCompiledForm readA2 = WotProjectionBindingRuntimeTestHarness.Form( + WoTBindingCapabilityEnum.ReadProperty, + new WotTargetMappingDescriptor(targetTypeNodeId: h.StructTypeNodeIdText, fieldPath: "A"), + affordanceName: "readA2"); + + var factory = new WotProjectionBindingRuntimeFactory(h.ChannelFactory); + + ServiceResultException? ex = Assert.ThrowsAsync(async () => + await factory.CreateAsync( + h.Builder, [WotProjectionBindingRuntimeTestHarness.Plan(readA1, readA2)]).ConfigureAwait(false)); + Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsExtendedTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsExtendedTests.cs new file mode 100644 index 0000000000..103a302fc8 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsExtendedTests.cs @@ -0,0 +1,211 @@ +/* ======================================================================== + * 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 NUnit.Framework; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Supplemental tests for covering the + /// numeric type coercions (long, ushort, byte, negative + /// int) and the Enumerate / TryCoerce edge-cases (single + /// , wrapped , and null + /// extension body). + /// + [TestFixture] + [Category("WotCon")] + [Parallelizable(ParallelScope.All)] + public sealed class WotRefreshArgumentsExtendedTests + { + private static IServiceMessageContext Context => ServiceMessageContext.CreateEmpty(null!); + + private static ArrayOf Args(params Variant[] values) + { + return values; + } + + [Test] + public void AcceptsExpectedGenerationAsLong() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant((long)42)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.ExpectedGeneration, Is.EqualTo(42u)); + } + + [Test] + public void AcceptsExpectedGenerationAsZeroLong() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant((long)0)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.ExpectedGeneration, Is.Zero); + } + + [Test] + public void AcceptsExpectedGenerationAsUshort() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant((ushort)100)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.ExpectedGeneration, Is.EqualTo(100u)); + } + + [Test] + public void AcceptsExpectedGenerationAsByte() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant((byte)7)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.ExpectedGeneration, Is.EqualTo(7u)); + } + + [Test] + public void RejectsNegativeIntForExpectedGeneration() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant(-1)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void RejectsLongOutOfRangeForExpectedGeneration() + { + long overflow = (long)uint.MaxValue + 1; + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant(overflow)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void AcceptsSingleExtensionObjectAsSelection() + { + var selector = new WoTResourceSelectorDataType + { + ResourceId = "single-eo", + GroupId = WotRegistryGroups.ThingDescriptions + }; + var extension = new ExtensionObject(selector); + + ArrayOf input = Args(new Variant(extension)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Has.Length.EqualTo(1)); + Assert.That(request.Selection[0].ResourceId, Is.EqualTo("single-eo")); + } + + [Test] + public void RejectsNullExtensionObjectInSelection() + { + var nullExtension = new ExtensionObject(); + ArrayOf input = Args(new Variant(new[] { nullExtension })); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void AcceptsIEncodeableWrappedInExtensionObjectAsOptions() + { + var options = new WoTRefreshOptionsDataType + { + Force = true, + DryRun = false + }; + var extension = new ExtensionObject(options); + + ArrayOf input = Args( + Variant.Null, + new Variant(extension)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Options.Force, Is.True); + } + + [Test] + public void FullyNullArgumentsDefaultsAreNonNull() + { + ServiceResult status = WotRefreshArguments.TryDecode( + Args(Variant.Null, Variant.Null, Variant.Null, Variant.Null), + Context, + out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Options, Is.Not.Null); + Assert.That(request.Selection, Is.Empty); + Assert.That(request.RequestId, Is.EqualTo(string.Empty)); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs new file mode 100644 index 0000000000..cd80b0c9f5 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs @@ -0,0 +1,327 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Xml; +using NUnit.Framework; +using Opc.Ua.Encoders; +using Opc.Ua.WotCon.Server.Materialization; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Unit tests for , the decoder for the + /// generated WoTRegistryType.Refresh Method's Selection / Options / + /// ExpectedGeneration / RequestId arguments. + /// + [TestFixture] + [Category("WotCon")] + public sealed class WotRefreshArgumentsTests + { + private static IServiceMessageContext Context => ServiceMessageContext.CreateEmpty(null!); + + private static ArrayOf Args(params Variant[] values) + { + return values; + } + + [Test] + public void EmptyArgumentsDecodeToFullRefreshWithDefaults() + { + ServiceResult status = WotRefreshArguments.TryDecode( + Args(), Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Is.Empty); + Assert.That(request.ExpectedGeneration, Is.Zero); + Assert.That(request.RequestId, Is.EqualTo(string.Empty)); + Assert.That(request.Options, Is.Not.Null); + } + + [Test] + public void DecodesSelectionArrayOptionsGenerationAndRequestId() + { + var selector = new WoTResourceSelectorDataType + { + GroupId = "thingdescriptions", + ResourceId = "sensor", + Kind = WoTDocumentKindEnum.ThingDescription + }; + var options = new WoTRefreshOptionsDataType + { + Force = true, + DryRun = true, + Atomicity = WoTAtomicityEnum.PerGroup, + DeletePolicy = WoTDeletePolicyEnum.Retire, + IncludeDependents = true + }; + ArrayOf input = Args( + new Variant(new ExtensionObject[] { new(selector) }), + new Variant(new ExtensionObject(options)), + new Variant(7u), + new Variant("req-42")); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Has.Length.EqualTo(1)); + Assert.That(request.Selection[0].ResourceId, Is.EqualTo("sensor")); + Assert.That(request.Options.Force, Is.True); + Assert.That(request.Options.DryRun, Is.True); + Assert.That(request.Options.Atomicity, Is.EqualTo(WoTAtomicityEnum.PerGroup)); + Assert.That(request.Options.DeletePolicy, Is.EqualTo(WoTDeletePolicyEnum.Retire)); + Assert.That(request.Options.IncludeDependents, Is.True); + Assert.That(request.ExpectedGeneration, Is.EqualTo(7u)); + Assert.That(request.RequestId, Is.EqualTo("req-42")); + } + + [Test] + public void DecodesSelectionFromArrayOfExtensionObject() + { + var selectors = new ArrayOf(new[] + { + new ExtensionObject(new WoTResourceSelectorDataType { Xid = "/groups/g/resources/a" }), + new ExtensionObject(new WoTResourceSelectorDataType { Xid = "/groups/g/resources/b" }) + }); + ArrayOf input = Args(new Variant(selectors)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Has.Length.EqualTo(2)); + Assert.That(request.Selection[1].Xid, Is.EqualTo("/groups/g/resources/b")); + } + + [Test] + public void RejectsSelectionOfWrongElementType() + { + string[] wrongSelection = ["not-a-selector"]; + ArrayOf input = Args(new Variant(wrongSelection)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void RejectsNullElementInSelectionArray() + { + ArrayOf selection = [null!]; + ArrayOf input = Args(Variant.From(selection)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void RejectsOptionsOfWrongType() + { + ArrayOf input = Args( + Variant.Null, + new Variant("not-an-options-structure")); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void RejectsExpectedGenerationOfWrongType() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant("five")); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void AcceptsExpectedGenerationAsInt32() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant(9)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.ExpectedGeneration, Is.EqualTo(9u)); + } + + [Test] + public void RejectsRequestIdOfWrongType() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + Variant.Null, + new Variant(123)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void DecodesBinaryEncodedSelectionBody() + { + IServiceMessageContext context = Context; + var selector = new WoTResourceSelectorDataType { ResourceId = "encoded" }; + byte[] encoded; + using (var encoder = new BinaryEncoder(context)) + { + selector.Encode(encoder); + encoded = encoder.CloseAndReturnBuffer()!; + } + var extension = new ExtensionObject( + DataTypeIds.WoTResourceSelectorDataType, ByteString.From(encoded)); + ArrayOf input = Args(new Variant(new[] { extension })); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Has.Length.EqualTo(1)); + Assert.That(request.Selection[0].ResourceId, Is.EqualTo("encoded")); + } + + [Test] + public void DecodesDynamicStructureOptionsBody() + { + Structure options = CreateDynamicOptions( + DataTypeIds.WoTRefreshOptionsDataType, + ObjectIds.WoTRefreshOptionsDataType_Encoding_DefaultBinary); + ArrayOf input = Args( + Variant.Null, + new Variant(new ExtensionObject( + ObjectIds.WoTRefreshOptionsDataType_Encoding_DefaultBinary, + options))); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, + Context, + out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Options.Atomicity, Is.EqualTo(WoTAtomicityEnum.PerGroup)); + Assert.That(request.Options.Force, Is.True); + Assert.That(request.Options.DryRun, Is.True); + Assert.That(request.Options.IncludeDependents, Is.True); + Assert.That(request.Options.DeletePolicy, Is.EqualTo(WoTDeletePolicyEnum.Retire)); + Assert.That(request.Options.MaxParallelism, Is.EqualTo(4u)); + Assert.That(request.Options.Timeout, Is.EqualTo(2.5)); + } + + [Test] + public void RejectsDynamicStructureWithDifferentTypeIdentity() + { + var wrongTypeId = new ExpandedNodeId( + 9001u, + 0, + "urn:opcfoundation.org:UA:WotAggregation:WrongType", + 0); + var wrongEncodingId = new ExpandedNodeId( + 9002u, + 0, + "urn:opcfoundation.org:UA:WotAggregation:WrongType", + 0); + Structure options = CreateDynamicOptions(wrongTypeId, wrongEncodingId); + ArrayOf input = Args( + Variant.Null, + new Variant(new ExtensionObject(wrongEncodingId, options))); + + ServiceResult status = WotRefreshArguments.TryDecode(input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + private static Structure CreateDynamicOptions( + ExpandedNodeId typeId, + ExpandedNodeId binaryEncodingId) + { + StructureField[] fields = + [ + new StructureField { Name = "Atomicity", DataType = Ua.DataTypeIds.Int32 }, + new StructureField { Name = "Force", DataType = Ua.DataTypeIds.Boolean }, + new StructureField { Name = "DryRun", DataType = Ua.DataTypeIds.Boolean }, + new StructureField { Name = "IncludeDependents", DataType = Ua.DataTypeIds.Boolean }, + new StructureField { Name = "DeletePolicy", DataType = Ua.DataTypeIds.Int32 }, + new StructureField { Name = "MaxParallelism", DataType = Ua.DataTypeIds.UInt32 }, + new StructureField { Name = "Timeout", DataType = Ua.DataTypeIds.Double } + ]; + var definition = new StructureDefinition + { + BaseDataType = Ua.DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = fields + }; + var fieldTypes = new Dictionary + { + ["Atomicity"] = BuiltInType.Enumeration, + ["Force"] = BuiltInType.Boolean, + ["DryRun"] = BuiltInType.Boolean, + ["IncludeDependents"] = BuiltInType.Boolean, + ["DeletePolicy"] = BuiltInType.Enumeration, + ["MaxParallelism"] = BuiltInType.UInt32, + ["Timeout"] = BuiltInType.Double + }; + return new Structure( + new XmlQualifiedName( + nameof(WoTRefreshOptionsDataType), + Namespaces.WotCon), + typeId, + binaryEncodingId, + ExpandedNodeId.Null, + definition, + fieldTypes) + { + ["Atomicity"] = Variant.From(WoTAtomicityEnum.PerGroup), + ["Force"] = Variant.From(true), + ["DryRun"] = Variant.From(true), + ["IncludeDependents"] = Variant.From(true), + ["DeletePolicy"] = Variant.From(WoTDeletePolicyEnum.Retire), + ["MaxParallelism"] = Variant.From(4u), + ["Timeout"] = Variant.From(2.5) + }; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotStructuredGroupStateTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotStructuredGroupStateTests.cs new file mode 100644 index 0000000000..5b6b086823 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotStructuredGroupStateTests.cs @@ -0,0 +1,166 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using NUnit.Framework; +using Opc.Ua.WotCon.Server.Materialization; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises directly: a failed + /// resolution is never cached and retries against the (possibly + /// by-then-populated) factory, and concurrent first use resolves exactly + /// once under the lock. + /// + [TestFixture] + public sealed class WotStructuredGroupStateTests + { + [Test] + public void EnsureResolvedTypeUnavailableFailsWithoutCachingThenSucceedsAfterRegistration() + { + var namespaceUris = new NamespaceTable(); + ushort ns = (ushort)namespaceUris.Append(TestStructureNamespace.Uri); + IEncodeableFactory factory = ServiceMessageContext.CreateEmpty(null!).Factory; + var dataTypeId = new NodeId(TestRootType.NumericId, ns); + var targetNodeId = new NodeId("Struct", ns); + + var state = new WotStructuredGroupState( + factory, + namespaceUris, + dataTypeId, + targetNodeId, + readSlots: [], + writeSlots: []); + + WotStructuredGroupResolution first = state.EnsureResolved(); + Assert.That(first.Success, Is.False); + Assert.That(first.Error.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That(first.RootType, Is.Null); + + // Simulate NodeManagerLifecycle.RefreshComplexTypesAsync completing + // against the very same factory instance. + factory.Builder.AddEncodeableType(TestRootType.EncodingId, new TestRootType()).Commit(); + + WotStructuredGroupResolution second = state.EnsureResolved(); + Assert.That( + second.Success, Is.True, "The failed attempt must not have been cached; a retry must resolve now."); + Assert.That(second.RootType, Is.Not.Null); + } + + [Test] + public async Task EnsureResolvedConcurrentFirstCallsResolveExactlyOnceAndAllSucceed() + { + var namespaceUris = new NamespaceTable(); + ushort ns = (ushort)namespaceUris.Append(TestStructureNamespace.Uri); + IEncodeableFactory inner = ServiceMessageContext.CreateEmpty(null!).Factory; + inner.Builder.AddEncodeableType(TestRootType.EncodingId, new TestRootType()).Commit(); + var counting = new CountingEncodeableFactory(inner); + var dataTypeId = new NodeId(TestRootType.NumericId, ns); + var targetNodeId = new NodeId("Struct", ns); + + var state = new WotStructuredGroupState( + counting, + namespaceUris, + dataTypeId, + targetNodeId, + readSlots: [], + writeSlots: []); + + const int callerCount = 8; + var barrier = new Barrier(callerCount); + var tasks = new Task[callerCount]; + for (int i = 0; i < callerCount; i++) + { + tasks[i] = Task.Run(() => + { + barrier.SignalAndWait(); + return state.EnsureResolved(); + }); + } + WotStructuredGroupResolution[] results = await Task.WhenAll(tasks).ConfigureAwait(false); + + foreach (WotStructuredGroupResolution result in results) + { + Assert.That(result.Success, Is.True); + } + Assert.That(counting.TryGetEncodeableTypeCallCount, Is.EqualTo(1), + "Concurrent first use must resolve exactly once under the lock, not once per caller."); + } + + /// + /// A minimal decorator that forwards + /// every lookup to an inner factory while counting + /// calls, so a test can assert how + /// many times resolution actually ran. + /// + private sealed class CountingEncodeableFactory : IEncodeableFactory + { + public CountingEncodeableFactory(IEncodeableFactory inner) + { + m_inner = inner; + } + + public IEnumerable KnownTypeIds => m_inner.KnownTypeIds; + + public IEncodeableFactoryBuilder Builder => m_inner.Builder; + + public int TryGetEncodeableTypeCallCount => m_count; + + public bool TryGetEncodeableType( + ExpandedNodeId typeId, + [NotNullWhen(true)] out IEncodeableType? encodeableType) + { + Interlocked.Increment(ref m_count); + return m_inner.TryGetEncodeableType(typeId, out encodeableType); + } + + public bool TryGetEnumeratedType( + ExpandedNodeId typeId, + [NotNullWhen(true)] out IEnumeratedType? enumeratedType) + { + return m_inner.TryGetEnumeratedType(typeId, out enumeratedType); + } + + public bool TryGetType( + XmlQualifiedName xmlName, + [NotNullWhen(true)] out IType? type) + { + return m_inner.TryGetType(xmlName, out type); + } + + private readonly IEncodeableFactory m_inner; + private int m_count; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotTargetVariableResolverTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotTargetVariableResolverTests.cs new file mode 100644 index 0000000000..17ddb2a3e3 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotTargetVariableResolverTests.cs @@ -0,0 +1,301 @@ +/* ======================================================================== + * 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.Collections.Generic; +using Moq; +using NUnit.Framework; +using Opc.Ua.Server; +using Opc.Ua.Server.Fluent; +using Opc.Ua.WotCon.Bindings; +using Opc.Ua.WotCon.Server.Materialization; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises against a lightweight + /// graph (no running server): exact NodeId + /// resolution, type-only unique/ambiguous resolution, exact+type validation, + /// and the deterministic failure statuses for missing, malformed, ambiguous, + /// wrong-node-class and type-mismatch mappings. + /// + [TestFixture] + public sealed class WotTargetVariableResolverTests + { + private const string NsUri = "http://test.org/UA/Wot/"; + + [Test] + public void MapToNodeIdResolvesExactVariable() + { + (NodeManagerBuilder builder, ushort ns, BaseDataVariableState var1, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + BaseVariableState resolved = resolver.Resolve( + builder, new WotTargetMappingDescriptor(targetNodeId: $"ns={ns};s=Var1")); + + Assert.That(resolved, Is.SameAs(var1)); + } + + [Test] + public void MapToNodeIdPortableNsuFormResolvesAgainstNamespaceUris() + { + (NodeManagerBuilder builder, _, BaseDataVariableState var1, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + BaseVariableState resolved = resolver.Resolve( + builder, new WotTargetMappingDescriptor(targetNodeId: $"nsu={NsUri};s=Var1")); + + Assert.That(resolved, Is.SameAs(var1)); + } + + [Test] + public void MapToNodeIdMissingNodeThrowsBadNodeIdUnknown() + { + (NodeManagerBuilder builder, ushort ns, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, new WotTargetMappingDescriptor(targetNodeId: $"ns={ns};s=NoSuchVar"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + } + + [Test] + public void MapToNodeIdMalformedTextThrowsBadNodeIdInvalid() + { + (NodeManagerBuilder builder, _, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, new WotTargetMappingDescriptor(targetNodeId: "not a node id"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdInvalid)); + } + + [Test] + public void MapToNodeIdMalformedTextMessageNamesTheOffendingTerm() + { + // The parser's own ServiceResultException (BadNodeIdInvalid) must + // be wrapped, not rethrown verbatim, so the message names which + // target-mapping term ('uav:mapToNodeId') carried the bad text. + (NodeManagerBuilder builder, _, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, new WotTargetMappingDescriptor(targetNodeId: "not a node id"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdInvalid)); + Assert.That(ex.Message, Does.Contain("uav:mapToNodeId")); + } + + [Test] + public void MapToTypeUnresolvableNamespaceUriWrapsServiceResultExceptionAsBadNodeIdInvalid() + { + // 'nsu=' referencing a namespace absent from the builder's table + // makes the parser itself throw a ServiceResultException; that + // must still be wrapped as BadNodeIdInvalid naming 'uav:mapToType', + // not rethrown with the parser's own (unrelated) message. + (NodeManagerBuilder builder, _, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve( + builder, + new WotTargetMappingDescriptor(targetTypeNodeId: "nsu=http://no.such.namespace/;i=1"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdInvalid)); + Assert.That(ex.Message, Does.Contain("uav:mapToType")); + } + + [Test] + public void MapToNodeIdWrongNodeClassThrowsBadTypeMismatch() + { + (NodeManagerBuilder builder, ushort ns, _, _, BaseObjectState obj) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, new WotTargetMappingDescriptor( + targetNodeId: $"ns={ns};s={obj.NodeId.IdentifierAsString}"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch)); + } + + [Test] + public void MapToTypeUniqueResolvesVariable() + { + (NodeManagerBuilder builder, ushort ns, BaseDataVariableState var1, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + BaseVariableState resolved = resolver.Resolve( + builder, new WotTargetMappingDescriptor(targetTypeNodeId: $"ns={ns};i=1")); + + Assert.That(resolved, Is.SameAs(var1)); + } + + [Test] + public void MapToTypeAmbiguousThrowsBadBrowseNameDuplicated() + { + (NodeManagerBuilder builder, ushort ns, _, _, _) = CreateGraph(secondVariableSameType: true); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, new WotTargetMappingDescriptor(targetTypeNodeId: $"ns={ns};i=1"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameDuplicated)); + } + + [Test] + public void MapToTypeNoMatchThrowsBadNodeIdUnknown() + { + (NodeManagerBuilder builder, ushort ns, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, new WotTargetMappingDescriptor(targetTypeNodeId: $"ns={ns};i=999"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + } + + [Test] + public void BothMatchingDataTypeResolvesAndValidates() + { + (NodeManagerBuilder builder, ushort ns, BaseDataVariableState var1, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + BaseVariableState resolved = resolver.Resolve( + builder, + new WotTargetMappingDescriptor(targetNodeId: $"ns={ns};s=Var1", targetTypeNodeId: $"ns={ns};i=1")); + + Assert.That(resolved, Is.SameAs(var1)); + } + + [Test] + public void BothMismatchingDataTypeThrowsBadTypeMismatch() + { + (NodeManagerBuilder builder, ushort ns, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve( + builder, + new WotTargetMappingDescriptor(targetNodeId: $"ns={ns};s=Var1", targetTypeNodeId: $"ns={ns};i=2"))); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch)); + } + + [Test] + public void NeitherTermPresentThrowsBadNodeIdInvalid() + { + (NodeManagerBuilder builder, _, _, _, _) = CreateGraph(); + var resolver = new WotTargetVariableResolver(); + + ServiceResultException ex = Assert.Throws(() => + resolver.Resolve(builder, WotTargetMappingDescriptor.Empty)); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdInvalid)); + } + + private static (NodeManagerBuilder Builder, ushort Ns, BaseDataVariableState Var1, + BaseDataVariableState? Var2, BaseObjectState Obj) CreateGraph(bool secondVariableSameType = false) + { + var namespaceUris = new NamespaceTable(); + ushort ns = (ushort)namespaceUris.Append(NsUri); + + var ctx = new SystemContext(telemetry: null!) + { + NamespaceUris = namespaceUris + }; + + var dataType1 = new NodeId(1, ns); + var dataType2 = new NodeId(2, ns); + + var root = new BaseObjectState(parent: null) + { + NodeId = new NodeId("Root", ns), + BrowseName = new QualifiedName("Root", ns), + DisplayName = new LocalizedText("Root") + }; + + var var1 = new BaseDataVariableState(root) + { + NodeId = new NodeId("Var1", ns), + BrowseName = new QualifiedName("Var1", ns), + DisplayName = new LocalizedText("Var1"), + DataType = dataType1, + ValueRank = ValueRanks.Scalar + }; + root.AddChild(var1); + + BaseDataVariableState? var2 = null; + if (secondVariableSameType) + { + var2 = new BaseDataVariableState(root) + { + NodeId = new NodeId("Var2", ns), + BrowseName = new QualifiedName("Var2", ns), + DisplayName = new LocalizedText("Var2"), + DataType = dataType1, + ValueRank = ValueRanks.Scalar + }; + root.AddChild(var2); + } + + var byId = new Dictionary + { + [root.NodeId] = root, + [var1.NodeId] = var1 + }; + if (var2 is not null) + { + byId[var2.NodeId] = var2; + } + + var builder = new NodeManagerBuilder( + ctx, + nodeManager: Mock.Of(), + defaultNamespaceIndex: ns, + rootResolver: q => q == root.BrowseName ? root : null!, + nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n : null!, + typeIdResolver: _ => [], + dataTypeIdResolver: dataTypeId => + { + var matches = new List(); + foreach (NodeState node in byId.Values) + { + if (node is BaseVariableState v && v.DataType == dataTypeId) + { + matches.Add(node); + } + } + return matches.ToArrayOf(); + }); + + return (builder, ns, var1, var2, root); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Opc.Ua.WotCon.Tests.csproj b/tests/Opc.Ua.WotCon.Tests/Opc.Ua.WotCon.Tests.csproj index ca9d37fdc7..aa9aed8372 100644 --- a/tests/Opc.Ua.WotCon.Tests/Opc.Ua.WotCon.Tests.csproj +++ b/tests/Opc.Ua.WotCon.Tests/Opc.Ua.WotCon.Tests.csproj @@ -34,7 +34,11 @@ + + + + diff --git a/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs new file mode 100644 index 0000000000..a0d78dc54c --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs @@ -0,0 +1,501 @@ +/* ======================================================================== + * 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.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Server.TestFramework; +using Opc.Ua.WotCon; +using Opc.Ua.WotCon.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Quickstarts.ReferenceServer; +using WotConModel = Opc.Ua.WotCon; +using UaBrowseNames = global::Opc.Ua.BrowseNames; +using UaObjectIds = global::Opc.Ua.ObjectIds; +using UaObjectTypeIds = global::Opc.Ua.ObjectTypeIds; + +#nullable disable warnings + +namespace Opc.Ua.WotCon.Tests.RuntimeNodeSet +{ + /// + /// End-to-end tests that a real subscription with a real + /// receives the generated WoT V2 event types through + /// the running server's notifier chain, and that every typed event field + /// populated by from the coordinator's + /// event arguments is delivered and resolvable via the filter's + /// select clauses. + /// + [TestFixture] + [Category("RuntimeNodeSet")] + [Category("WotCon")] + [Category("Server")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [NonParallelizable] + public sealed class WotRegistryEventIntegrationTests + { + private string m_pkiRoot = null!; + private ServerFixture m_fixture = null!; + private ReferenceServer m_server = null!; + private RequestHeader m_requestHeader = null!; + private SecureChannelContext m_secureChannelContext = null!; + private WotRegistryService m_registry = null!; + private WotMaterializationCoordinator m_coordinator = null!; + + [SetUp] + public async Task SetUpAsync() + { + m_pkiRoot = Path.Combine( + TestContext.CurrentContext.WorkDirectory, + nameof(WotRegistryEventIntegrationTests), + Guid.NewGuid().ToString("N")); + + m_fixture = new ServerFixture(t => new ReferenceServer(t)) + { + UriScheme = Utils.UriSchemeOpcTcp, + SecurityNone = true, + AutoAccept = true + }; + m_server = await m_fixture.StartAsync(m_pkiRoot).ConfigureAwait(false); + + (m_requestHeader, m_secureChannelContext) = await m_server + .CreateAndActivateSessionAsync(TestContext.CurrentContext.Test.Name) + .ConfigureAwait(false); + m_requestHeader.Timestamp = DateTimeUtc.Now; + + var options = new WotRegistryServerOptions + { + // Refreshes are triggered explicitly by the tests so the raised + // events are deterministic. + AutoRefresh = false, + ManagementAccess = new WotManagementAccessPolicy + { + MinimumSecurityMode = MessageSecurityMode.None, + AllowAnonymous = true, + RequiredRoleId = UaObjectIds.WellKnownRole_Anonymous + } + }; + m_registry = new WotRegistryService(); + var host = new LifecycleWotProjectionHost(m_server.NodeManagerLifecycle); + m_coordinator = new WotMaterializationCoordinator( + m_registry, host, documentConverter: new SelectiveConverter()); + var factory = new WotRegistryNodeManagerFactory(options, m_registry, m_coordinator); + await m_server.NodeManagerLifecycle.AddAsync(factory, callerContext: null).ConfigureAwait(false); + } + + [TearDown] + public async Task TearDownAsync() + { + if (m_requestHeader is not null) + { + m_requestHeader.Timestamp = DateTimeUtc.Now; + await m_server + .CloseSessionAsync(m_secureChannelContext, m_requestHeader, true, RequestLifetime.None) + .ConfigureAwait(false); + } + + if (m_fixture is not null) + { + await m_fixture.StopAsync().ConfigureAwait(false); + } + + m_coordinator?.Dispose(); + m_registry?.Dispose(); + m_server?.Dispose(); + + if (!string.IsNullOrEmpty(m_pkiRoot) && Directory.Exists(m_pkiRoot)) + { + Directory.Delete(m_pkiRoot, recursive: true); + } + } + + [Test] + public async Task RefreshCompletedEventDeliversPopulatedSummaryFieldsThroughNotifierChain() + { + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, m_server.CurrentInstance.NamespaceUris); + + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateEventSubscriptionAsync(services, registryNodeId) + .ConfigureAwait(false); + + // Trigger a refresh: the registry raises a RefreshCompleted event with + // the request id, the committed generation and the refresh summary. + const string RequestId = "req-42"; + WotRefreshResult result = await m_coordinator + .RefreshAsync(new WotRefreshRequest { RequestId = RequestId }) + .ConfigureAwait(false); + + var refreshCompletedType = ExpandedNodeId.ToNodeId( + WotConModel.ObjectTypeIds.WoTRefreshCompletedEventType, m_server.CurrentInstance.NamespaceUris); + + EventFieldList? evt = await CollectEventAsync( + services, subscriptionId, + efl => EventTypeOf(efl) == refreshCompletedType).ConfigureAwait(false); + + Assert.That(evt, Is.Not.Null, + "The RefreshCompleted event must be delivered through the notifier chain."); + ArrayOf fields = evt!.EventFields; + Assert.That(AsString(fields[Field.RequestId]), Is.EqualTo(RequestId), + "RequestId must be populated from the materialization event arguments."); + Assert.That(AsUInt32(fields[Field.Generation]), Is.EqualTo(result.NewGeneration), + "The event's Generation must match the committed refresh generation."); + Assert.That( + fields[Field.Summary].TryGetValue(out ExtensionObject summaryEo), Is.True, + "The refresh Summary structure field must be populated."); + Assert.That(summaryEo.TryGetValue(out WoTRefreshSummaryDataType? summary), Is.True); + Assert.That(summary!.RequestId, Is.EqualTo(RequestId), + "The Summary must carry the originating refresh request id."); + + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + [Test] + public async Task ResourceEventDeliversPopulatedIdentityFieldsThroughNotifierChain() + { + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, m_server.CurrentInstance.NamespaceUris); + + await m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "sensor", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = SelectiveConverter.ValidTd("sensor") + }).ConfigureAwait(false); + + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateEventSubscriptionAsync(services, registryNodeId) + .ConfigureAwait(false); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + var resourceType = ExpandedNodeId.ToNodeId( + WotConModel.ObjectTypeIds.WoTResourceEventType, m_server.CurrentInstance.NamespaceUris); + + EventFieldList? evt = await CollectEventAsync( + services, subscriptionId, + efl => EventTypeOf(efl) == resourceType).ConfigureAwait(false); + + Assert.That(evt, Is.Not.Null, + "The resource activation event must be delivered through the notifier chain."); + ArrayOf fields = evt!.EventFields; + Assert.That(AsString(fields[Field.ResourceId]), Is.EqualTo("sensor")); + Assert.That(AsString(fields[Field.Xid]), Does.Contain("sensor")); + Assert.That( + fields[Field.DocumentKind].TryGetValue(out WoTDocumentKindEnum kind), Is.True, + "DocumentKind must be populated from the resource kind."); + Assert.That(kind, Is.EqualTo(WoTDocumentKindEnum.ThingDescription)); + Assert.That( + fields[Field.Outcome].TryGetValue(out WoTOutcomeEnum _), Is.True, + "Outcome must be populated for a resource lifecycle event."); + + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + [Test] + public async Task ValidationFailureEventDeliversValidationOutcomeThroughNotifierChain() + { + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, m_server.CurrentInstance.NamespaceUris); + + // The selective converter fails conversion for ids containing 'bad', + // which the coordinator surfaces as a validation failure event. + await m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "bad-thing", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = SelectiveConverter.ValidTd("bad-thing") + }).ConfigureAwait(false); + + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateEventSubscriptionAsync(services, registryNodeId) + .ConfigureAwait(false); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + var validationFailureType = ExpandedNodeId.ToNodeId( + WotConModel.ObjectTypeIds.WoTValidationFailureEventType, m_server.CurrentInstance.NamespaceUris); + + EventFieldList? evt = await CollectEventAsync( + services, subscriptionId, + efl => EventTypeOf(efl) == validationFailureType).ConfigureAwait(false); + + Assert.That(evt, Is.Not.Null, + "The validation failure event must be delivered through the notifier chain."); + ArrayOf fields = evt!.EventFields; + Assert.That(AsString(fields[Field.ResourceId]), Is.EqualTo("bad-thing")); + Assert.That( + fields[Field.ValidationOutcome].TryGetValue(out ExtensionObject outcomeEo), Is.True, + "The ValidationOutcome structure field must be populated."); + Assert.That(outcomeEo.TryGetValue(out WoTValidationOutcomeDataType? outcome), Is.True); + Assert.That(outcome!.FormatOutcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + private static class Field + { + public const int EventType = 0; + public const int Xid = 1; + public const int ResourceId = 2; + public const int VersionId = 3; + public const int DocumentKind = 4; + public const int Generation = 5; + public const int Phase = 6; + public const int Outcome = 7; + public const int ValidationOutcome = 8; + public const int LoadState = 9; + public const int FailedNodeId = 10; + public const int Reason = 11; + public const int BindingUri = 12; + public const int Summary = 13; + public const int RequestId = 14; + } + + private EventFilter BuildWotEventFilter() + { + ushort v2 = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex( + WotConModel.Namespaces.WotCon); + + SimpleAttributeOperand Wot(string name) + => new() + { + AttributeId = Attributes.Value, + TypeDefinitionId = UaObjectTypeIds.BaseEventType, + BrowsePath = [new QualifiedName(name, v2)] + }; + + SimpleAttributeOperand Base(string name) + => new() + { + AttributeId = Attributes.Value, + TypeDefinitionId = UaObjectTypeIds.BaseEventType, + BrowsePath = [QualifiedName.From(name)] + }; + + return new EventFilter + { + SelectClauses = + [ + Base(UaBrowseNames.EventType), // 0 + Wot(WotConModel.BrowseNames.Xid), // 1 + Wot(WotConModel.BrowseNames.ResourceId), // 2 + Wot(WotConModel.BrowseNames.VersionId), // 3 + Wot(WotConModel.BrowseNames.DocumentKind), // 4 + Wot(WotConModel.BrowseNames.Generation), // 5 + Wot(WotConModel.BrowseNames.Phase), // 6 + Wot(WotConModel.BrowseNames.Outcome), // 7 + Wot(WotConModel.BrowseNames.ValidationOutcome), // 8 + Wot(WotConModel.BrowseNames.LoadState), // 9 + Wot(WotConModel.BrowseNames.FailedNodeId), // 10 + Wot(WotConModel.BrowseNames.Reason), // 11 + Wot(WotConModel.BrowseNames.BindingUri), // 12 + Wot(WotConModel.BrowseNames.Summary), // 13 + Wot(WotConModel.BrowseNames.RequestId) // 14 + ], + WhereClause = new ContentFilter() + }; + } + + private async Task CreateEventSubscriptionAsync( + ServerTestServices services, NodeId sourceNodeId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateSubscriptionResponse subscription = await services + .CreateSubscriptionAsync(requestHeader, 100, 1200, 20, 0, true, 0) + .ConfigureAwait(false); + uint subscriptionId = subscription.SubscriptionId; + + ArrayOf items = + [ + new MonitoredItemCreateRequest + { + ItemToMonitor = new ReadValueId + { + NodeId = sourceNodeId, + AttributeId = Attributes.EventNotifier + }, + MonitoringMode = MonitoringMode.Reporting, + RequestedParameters = new MonitoringParameters + { + ClientHandle = ClientHandle, + SamplingInterval = 0, + QueueSize = 100, + DiscardOldest = true, + Filter = new ExtensionObject(BuildWotEventFilter()) + } + } + ]; + requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateMonitoredItemsResponse created = await services + .CreateMonitoredItemsAsync( + requestHeader, subscriptionId, TimestampsToReturn.Neither, items) + .ConfigureAwait(false); + Assert.That(created.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good), + "The event monitored item must be created on the WoTRegistry notifier."); + return subscriptionId; + } + + private async Task CollectEventAsync( + ServerTestServices services, uint subscriptionId, Func predicate) + { + ArrayOf acks = default; + for (int attempt = 0; attempt < 40; attempt++) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + PublishResponse response = await services + .PublishAsync(requestHeader, acks, timeoutCts.Token).ConfigureAwait(false); + acks = response.AvailableSequenceNumbers.ToArrayOf( + sequenceNumber => new SubscriptionAcknowledgement + { + SubscriptionId = subscriptionId, + SequenceNumber = sequenceNumber + }); + + if (response.NotificationMessage is { } message) + { + ArrayOf notifications = message.NotificationData; + for (int n = 0; n < notifications.Count; n++) + { + if (!notifications[n].TryGetValue(out EventNotificationList? events)) + { + continue; + } + for (int i = 0; i < events.Events.Count; i++) + { + EventFieldList efl = events.Events[i]; + if (efl.ClientHandle == ClientHandle && predicate(efl)) + { + return efl; + } + } + } + } + } + return null; + } + + private async Task DeleteSubscriptionAsync(ServerTestServices services, uint subscriptionId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + ArrayOf ids = [subscriptionId]; + await services.DeleteSubscriptionsAsync(requestHeader, ids).ConfigureAwait(false); + } + + private static NodeId EventTypeOf(EventFieldList efl) + { + return efl.EventFields[Field.EventType].TryGetValue(out NodeId n) ? n : NodeId.Null; + } + + private static string AsString(Variant variant) + { + return variant.TryGetValue(out string s) ? s : string.Empty; + } + + private static uint AsUInt32(Variant variant) + { + return variant.TryGetValue(out uint u) ? u : 0u; + } + + private const uint ClientHandle = 77; + + /// + /// A converter that emits a minimal valid projection for a resource, but + /// fails conversion for any resource id containing "bad" so a validation + /// failure event can be exercised deterministically. + /// + private sealed class SelectiveConverter : IWotDocumentConverter + { + private const string ModelUri = "urn:wot:events:model"; + + public static byte[] ValidTd(string id) + { + return Encoding.UTF8.GetBytes( + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"@type\":\"uav:object\",\"id\":\"urn:" + + id + + "\",\"title\":\"" + + id + + "\"}"); + } + + public ValueTask ConvertAsync( + WotResource resource, + ReadOnlyMemory content, + WotRegistrySnapshot snapshot, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(Convert(resource)); + } + + private static WotConversionOutput Convert(WotResource resource) + { + if (resource.ResourceId.Contains("bad", StringComparison.Ordinal)) + { + return WotConversionOutput.Failure( + $"Injected conversion failure for '{resource.ResourceId}'."); + } + + string ns = ModelUri + "/" + resource.ResourceId; + string xml = $""" + + + {ns} + + + Root + + i=58 + i=85 + + + + """; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + UANodeSet nodeSet = UANodeSet.Read(stream)!; + return WotConversionOutput.Success(nodeSet); + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs new file mode 100644 index 0000000000..4960277021 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs @@ -0,0 +1,923 @@ +/* ======================================================================== + * 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.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Server; +using Opc.Ua.Server.TestFramework; +using Opc.Ua.WotCon; +using Opc.Ua.WotCon.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Quickstarts.ReferenceServer; +using WotConModel = Opc.Ua.WotCon; +using UaObjectIds = global::Opc.Ua.ObjectIds; +using UaReferenceTypeIds = global::Opc.Ua.ReferenceTypeIds; + +#nullable disable warnings + +namespace Opc.Ua.WotCon.Tests.RuntimeNodeSet +{ + /// + /// End-to-end lifecycle test for the WoT Connectivity V2 registry hosted on a + /// real running . It registers a Thing + /// Description, materializes it as a shadow-reloadable runtime projection, + /// creates a real subscription and monitored item on the projected value, + /// registers a compatible new version, refreshes into a new generation, and + /// verifies that new Read/Browse observe the new generation while the existing + /// monitored item is kept alive on the retained generation until the + /// subscription is deleted and the retired projection is cleaned up. + /// + [TestFixture] + [Category("NodeManagerLifecycle")] + [Category("RuntimeNodeSet")] + [Category("WotCon")] + [Category("Server")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [NonParallelizable] + public sealed class WotRegistryLifecycleTests + { + private const double kMaxAge = 10000; + private const string kModelNamespaceUri = "urn:wot:e2e:sensor"; + private const uint kRootNodeId = 5000; + private const uint kValueNodeId = 5001; + private const uint kGenChildBaseNodeId = 5100; + private const string kValueBrowseName = "Value"; + + private string m_pkiRoot = null!; + private ServerFixture m_fixture = null!; + private ReferenceServer m_server = null!; + private RequestHeader m_requestHeader = null!; + private SecureChannelContext m_secureChannelContext = null!; + + private WotRegistryService m_registry = null!; + private WotMaterializationCoordinator m_coordinator = null!; + private WotRegistryServerOptions m_options = null!; + + [SetUp] + public async Task SetUpAsync() + { + m_pkiRoot = Path.Combine( + TestContext.CurrentContext.WorkDirectory, + nameof(WotRegistryLifecycleTests), + Guid.NewGuid().ToString("N")); + + m_fixture = new ServerFixture(t => new ReferenceServer(t)) + { + UriScheme = Utils.UriSchemeOpcTcp, + SecurityNone = true, + AutoAccept = true + }; + + m_server = await m_fixture.StartAsync(m_pkiRoot).ConfigureAwait(false); + + (m_requestHeader, m_secureChannelContext) = await m_server + .CreateAndActivateSessionAsync(TestContext.CurrentContext.Test.Name) + .ConfigureAwait(false); + m_requestHeader.Timestamp = DateTimeUtc.Now; + + // Host the WoT registry NodeManager on the running server with a + // deterministic converter so the projected value node is predictable. + m_options = new WotRegistryServerOptions + { + AutoRefresh = false, + ManagementAccess = new WotManagementAccessPolicy + { + MinimumSecurityMode = MessageSecurityMode.None, + AllowAnonymous = true, + RequiredRoleId = UaObjectIds.WellKnownRole_Anonymous + } + }; + m_registry = new WotRegistryService(); + var host = new LifecycleWotProjectionHost(m_server.NodeManagerLifecycle); + m_coordinator = new WotMaterializationCoordinator( + m_registry, host, documentConverter: new SensorConverter()); + var factory = new WotRegistryNodeManagerFactory(m_options, m_registry, m_coordinator); + await m_server.NodeManagerLifecycle.AddAsync(factory, callerContext: null).ConfigureAwait(false); + } + + [TearDown] + public async Task TearDownAsync() + { + await CloseActiveSessionAsync().ConfigureAwait(false); + + if (m_fixture is not null) + { + await m_fixture.StopAsync().ConfigureAwait(false); + } + + m_coordinator?.Dispose(); + m_registry?.Dispose(); + m_server?.Dispose(); + + if (!string.IsNullOrEmpty(m_pkiRoot) && Directory.Exists(m_pkiRoot)) + { + Directory.Delete(m_pkiRoot, recursive: true); + } + } + + [Test] + public async Task RegisterMaterializeSubscribeRefreshAndRetireAsync() + { + IServerInternal server = m_server.CurrentInstance; + + // 1. Register and materialize the first generation of the Thing Description. + await UpsertSensorAsync("sensor", generation: 1).ConfigureAwait(false); + WotRefreshResult first = await m_coordinator + .RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + Assert.That(first.Results.Any(r => r.LoadState == WoTLoadStateEnum.Active), Is.True, + "The registered Thing Description must materialize into an active projection."); + + // The browseable registry projection exposes the group and resource. + await AssertRegistryProjectionAsync(server).ConfigureAwait(false); + + ushort ns = (ushort)server.NamespaceUris.GetIndex(kModelNamespaceUri); + Assert.That(ns, Is.GreaterThan(0), "The projected model namespace must be registered."); + var valueNodeId = new NodeId(kValueNodeId, ns); + var rootNodeId = new NodeId(kRootNodeId, ns); + var gen1ChildNodeId = new NodeId(kGenChildBaseNodeId + 1u, ns); + var gen2ChildNodeId = new NodeId(kGenChildBaseNodeId + 2u, ns); + + DataValue value1 = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(value1.StatusCode, Is.EqualTo(StatusCodes.Good), + "The projected value node must be materialized and readable."); + DataValue gen1Read = await ReadValueAsync(gen1ChildNodeId).ConfigureAwait(false); + Assert.That(gen1Read.StatusCode, Is.EqualTo(StatusCodes.Good), + "The first generation's node must be present after materialization."); + + // 2. Create a subscription and monitored item on the projected value. + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateSubscriptionWithMonitoredItemAsync(services, valueNodeId) + .ConfigureAwait(false); + ArrayOf acks = default; + (DataValue? initial, acks) = await PublishForDataChangeAsync( + services, subscriptionId, acks, clientHandle: 1).ConfigureAwait(false); + Assert.That(initial, Is.Not.Null, + "The monitored item must deliver an initial data-change notification."); + + try + { + // 3. Register a compatible new version and refresh into a new generation. + await UpsertSensorAsync("sensor", generation: 2).ConfigureAwait(false); + WotRefreshResult second = await m_coordinator + .RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + Assert.That(second.NewGeneration, Is.GreaterThan(first.NewGeneration), + "A compatible new version must advance the refresh generation."); + + // 4. New Read/Browse observe the new generation: new service requests + // route to the replacement generation (which exposes the Gen2 node and + // no longer the Gen1 node), while the value node persists across both. + DataValue value2 = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(value2.StatusCode, Is.EqualTo(StatusCodes.Good)); + DataValue gen2Read = await ReadValueAsync(gen2ChildNodeId).ConfigureAwait(false); + Assert.That(gen2Read.StatusCode, Is.EqualTo(StatusCodes.Good), + "A Read after the refresh must observe the new generation's node."); + DataValue gen1AfterSwitch = await ReadValueAsync(gen1ChildNodeId).ConfigureAwait(false); + Assert.That( + gen1AfterSwitch.StatusCode.Code, + Is.EqualTo(StatusCodes.BadNodeIdUnknown).Or.EqualTo(StatusCodes.BadNodeIdInvalid), + "New requests must no longer resolve the retired generation's node."); + + BrowseResponse rootBrowse = await BrowseAsync(rootNodeId).ConfigureAwait(false); + ArrayOf references = rootBrowse.Results[0].References; + Assert.That( + references.Contains(r => r.BrowseName.Equals( + new QualifiedName(GenChildBrowseName(2), ns))), + Is.True, "Browse must observe the new generation's child."); + Assert.That( + references.Contains(r => r.BrowseName.Equals( + new QualifiedName(GenChildBrowseName(1), ns))), + Is.False, "Browse must no longer observe the retired generation's child."); + + // 5. The existing monitored item remains alive across the switch: the + // subscription still services publishes without invalidating the item. + (_, acks) = await PublishKeepAliveAsync(services, subscriptionId, acks) + .ConfigureAwait(false); + } + finally + { + // 6. Delete the subscription, releasing the monitored item. + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + // 7. Remove the resource and refresh: the retired projection is cleaned up. + await m_registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "sensor") + .ConfigureAwait(false); + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + DataValue removed = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That( + removed.StatusCode.Code, + Is.EqualTo(StatusCodes.BadNodeIdUnknown).Or.EqualTo(StatusCodes.BadNodeIdInvalid), + "The retired projection's nodes must be cleaned up after the resource is removed."); + } + + [Test] + public async Task ShutdownAfterCoordinatorDisposeRemovesMaterializedAddressSpaceAsync() + { + await UpsertSensorAsync("sensor", generation: 1).ConfigureAwait(false); + WotRefreshResult refresh = await m_coordinator + .RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + Assert.That(refresh.Results.Any(r => r.LoadState == WoTLoadStateEnum.Active), Is.True, + "The test must exercise shutdown with a live materialized projection."); + + await CloseActiveSessionAsync().ConfigureAwait(false); + m_coordinator.Dispose(); + m_coordinator = null!; + + Assert.That( + () => m_server.Dispose(), + Throws.Nothing, + "Server disposal must still delete the WoT address space after coordinator disposal."); + } + + private async Task AssertRegistryProjectionAsync(IServerInternal server) + { + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, server.NamespaceUris); + ushort v2Ns = (ushort)server.NamespaceUris.GetIndex(WotConModel.Namespaces.WotCon); + var groupNodeId = new NodeId( + "WoTRegistry/groups/" + WotRegistryGroups.ThingDescriptions, v2Ns); + var resourceNodeId = new NodeId( + $"WoTRegistry/groups/{WotRegistryGroups.ThingDescriptions}/resources/sensor", v2Ns); + + bool groupVisible = await WaitForConditionAsync(async () => + { + BrowseResponse browse = await BrowseAsync(registryNodeId).ConfigureAwait(false); + return browse.Results[0].References.Contains(r => + ExpandedNodeId.ToNodeId(r.NodeId, server.NamespaceUris) == groupNodeId); + }).ConfigureAwait(false); + Assert.That(groupVisible, Is.True, "WoTRegistry must expose the Thing Description group."); + + bool resourceVisible = await WaitForConditionAsync(async () => + { + BrowseResponse browse = await BrowseAsync(groupNodeId).ConfigureAwait(false); + return browse.Results[0].References.Contains(r => + ExpandedNodeId.ToNodeId(r.NodeId, server.NamespaceUris) == resourceNodeId); + }).ConfigureAwait(false); + Assert.That(resourceVisible, Is.True, + "The group must expose the registered resource document."); + } + + private async Task CloseActiveSessionAsync() + { + if (m_requestHeader is null) + { + return; + } + + m_requestHeader.Timestamp = DateTimeUtc.Now; + await m_server + .CloseSessionAsync(m_secureChannelContext, m_requestHeader, true, RequestLifetime.None) + .ConfigureAwait(false); + m_requestHeader = null!; + m_secureChannelContext = null!; + } + + [Test] + public async Task CrudMethodsAndFileUploadDriveTheRegistryAsync() + { + IServerInternal server = m_server.CurrentInstance; + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, server.NamespaceUris); + + // 1. CreateGroup via the xRegistry CreateGroup Method on WoTRegistry. + NodeId createGroupId = await FindChildAsync(registryNodeId, "CreateGroup") + .ConfigureAwait(false); + CallMethodResult createGroup = await CallAsync( + registryNodeId, createGroupId, new Variant("sensors")).ConfigureAwait(false); + Assert.That(createGroup.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(m_registry.Current.FindGroup("sensors"), Is.Not.Null); + var groupNodeId = (NodeId)createGroup.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + + // 2. GetOrCreateResource with RequestFileOpen returns a write FileHandle. + NodeId getOrCreateResourceId = await FindChildAsync(groupNodeId, "GetOrCreateResource") + .ConfigureAwait(false); + CallMethodResult createResource = await CallAsync( + groupNodeId, getOrCreateResourceId, + new Variant("thing1"), new Variant(string.Empty), new Variant(true)) + .ConfigureAwait(false); + Assert.That(createResource.StatusCode, Is.EqualTo(StatusCodes.Good)); + var resourceNodeId = (NodeId)createResource.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + uint fileHandle = createResource.OutputArguments[2].GetUInt32(); + Assert.That(fileHandle, Is.Not.Zero, + "RequestFileOpen must return a non-zero write FileHandle."); + + byte[] td = Encoding.UTF8.GetBytes( + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"@type\":\"uav:object\",\"id\":\"urn:thing1\",\"title\":\"thing1\"}"); + + // 3. Write the document body through the inherited FileType and commit on Close. + NodeId writeId = await FindChildAsync(resourceNodeId, "Write").ConfigureAwait(false); + NodeId closeId = await FindChildAsync(resourceNodeId, "Close").ConfigureAwait(false); + CallMethodResult write = await CallAsync( + resourceNodeId, writeId, + new Variant(fileHandle), new Variant(ByteString.From(td))).ConfigureAwait(false); + Assert.That(write.StatusCode, Is.EqualTo(StatusCodes.Good)); + + CallMethodResult close = await CallAsync( + resourceNodeId, closeId, new Variant(fileHandle)).ConfigureAwait(false); + Assert.That(close.StatusCode, Is.EqualTo(StatusCodes.Good)); + + WotResource stored = m_registry.Current.FindResource("sensors", "thing1"); + Assert.That(stored?.DefaultVersion, Is.Not.Null, + "Closing the write handle must commit the buffered document as a version."); + + // 4. Validate the stored document. + NodeId validateId = await FindChildAsync(resourceNodeId, "Validate").ConfigureAwait(false); + CallMethodResult validate = await CallAsync(resourceNodeId, validateId) + .ConfigureAwait(false); + Assert.That(validate.StatusCode, Is.EqualTo(StatusCodes.Good)); + object outcomeBoxed = validate.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + Assert.That(outcomeBoxed, Is.InstanceOf()); + Assert.That(((ExtensionObject)outcomeBoxed).TryGetValue( + out WoTValidationOutcomeDataType outcome), Is.True); + Assert.That(outcome.FormatOutcome, Is.EqualTo(WoTOutcomeEnum.Success)); + + // 5. SetEnabled(false) through the document Method. + NodeId setEnabledId = await FindChildAsync(resourceNodeId, "SetEnabled") + .ConfigureAwait(false); + CallMethodResult setEnabled = await CallAsync( + resourceNodeId, setEnabledId, + new Variant(false), new Variant(0u)).ConfigureAwait(false); + Assert.That(setEnabled.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(m_registry.Current.FindResource("sensors", "thing1")!.Enabled, Is.False); + + // 6. Delete the resource through the xRegistry Delete Method. + NodeId deleteId = await FindChildAsync(resourceNodeId, "Delete").ConfigureAwait(false); + CallMethodResult delete = await CallAsync( + resourceNodeId, deleteId, new Variant(0u)).ConfigureAwait(false); + Assert.That(delete.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(m_registry.Current.FindResource("sensors", "thing1"), Is.Null); + } + + [Test] + public async Task FileWriteRequiresConfiguredSecureChannelWhileReadMayUseNoneAsync() + { + IServerInternal server = m_server.CurrentInstance; + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, + server.NamespaceUris); + + NodeId createGroupId = await FindChildAsync(registryNodeId, "CreateGroup") + .ConfigureAwait(false); + CallMethodResult createGroup = await CallAsync( + registryNodeId, + createGroupId, + new Variant("secure-files")).ConfigureAwait(false); + var groupNodeId = (NodeId)createGroup.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + + NodeId createResourceId = await FindChildAsync(groupNodeId, "GetOrCreateResource") + .ConfigureAwait(false); + CallMethodResult createResource = await CallAsync( + groupNodeId, + createResourceId, + new Variant("thing1"), + new Variant(string.Empty), + new Variant(true)).ConfigureAwait(false); + var resourceNodeId = (NodeId)createResource.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + uint writeHandle = createResource.OutputArguments[2].GetUInt32(); + + m_options.ManagementAccess = new WotManagementAccessPolicy + { + AllowAnonymous = true, + RequiredRoleId = UaObjectIds.WellKnownRole_Anonymous + }; + + NodeId writeId = await FindChildAsync(resourceNodeId, "Write").ConfigureAwait(false); + CallMethodResult write = await CallAsync( + resourceNodeId, + writeId, + new Variant(writeHandle), + new Variant(ByteString.From(new byte[] { 1, 2, 3 }))).ConfigureAwait(false); + Assert.That(write.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); + + NodeId closeId = await FindChildAsync(resourceNodeId, "Close").ConfigureAwait(false); + CallMethodResult writeClose = await CallAsync( + resourceNodeId, + closeId, + new Variant(writeHandle)).ConfigureAwait(false); + Assert.That(writeClose.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); + + m_options.ManagementAccess = new WotManagementAccessPolicy + { + MinimumSecurityMode = MessageSecurityMode.None, + AllowAnonymous = true, + RequiredRoleId = UaObjectIds.WellKnownRole_Anonymous + }; + NodeId openId = await FindChildAsync(resourceNodeId, "Open").ConfigureAwait(false); + CallMethodResult authorizedWriteOpen = await CallAsync( + resourceNodeId, + openId, + new Variant((byte)6)).ConfigureAwait(false); + Assert.That(authorizedWriteOpen.StatusCode, Is.EqualTo(StatusCodes.Good), + "A denied close must discard and release the prior writer handle."); + uint authorizedWriteHandle = authorizedWriteOpen.OutputArguments[0].GetUInt32(); + CallMethodResult authorizedWriteClose = await CallAsync( + resourceNodeId, + closeId, + new Variant(authorizedWriteHandle)).ConfigureAwait(false); + Assert.That(authorizedWriteClose.StatusCode, Is.EqualTo(StatusCodes.Good)); + + m_options.ManagementAccess = new WotManagementAccessPolicy + { + AllowAnonymous = true, + RequiredRoleId = UaObjectIds.WellKnownRole_Anonymous + }; + CallMethodResult writeOpen = await CallAsync( + resourceNodeId, + openId, + new Variant((byte)6)).ConfigureAwait(false); + Assert.That(writeOpen.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); + + CallMethodResult readOpen = await CallAsync( + resourceNodeId, + openId, + new Variant((byte)1)).ConfigureAwait(false); + Assert.That(readOpen.StatusCode, Is.EqualTo(StatusCodes.Good)); + uint readHandle = readOpen.OutputArguments[0].GetUInt32(); + + CallMethodResult close = await CallAsync( + resourceNodeId, + closeId, + new Variant(readHandle)).ConfigureAwait(false); + Assert.That(close.StatusCode, Is.EqualTo(StatusCodes.Good)); + } + + [Test] + public async Task LabelsAddUpdateRemoveViaRealNodeManagerAsync() + { + IServerInternal server = m_server.CurrentInstance; + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, server.NamespaceUris); + + // Registry-level Labels. + NodeId registryLabelsId = await FindChildAsync(registryNodeId, "Labels") + .ConfigureAwait(false); + NodeId registryAddId = await FindChildAsync(registryLabelsId, "AddAttribute") + .ConfigureAwait(false); + NodeId registryRemoveId = await FindChildAsync(registryLabelsId, "RemoveAttribute") + .ConfigureAwait(false); + + CallMethodResult addRegistry = await CallAsync( + registryLabelsId, registryAddId, + new Variant("environment"), new Variant("production"), new Variant(0u)) + .ConfigureAwait(false); + Assert.That(addRegistry.StatusCode, Is.EqualTo(StatusCodes.Good)); + + NodeId envNodeId = await FindChildAsync(registryLabelsId, "environment") + .ConfigureAwait(false); + DataValue envValue = await ReadValueAsync(envNodeId).ConfigureAwait(false); + Assert.That(envValue.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(envValue.GetValue(null), Is.EqualTo("production")); + + // Group-level Labels. + NodeId createGroupId = await FindChildAsync(registryNodeId, "CreateGroup") + .ConfigureAwait(false); + CallMethodResult createGroup = await CallAsync( + registryNodeId, createGroupId, new Variant("labelgroup")).ConfigureAwait(false); + Assert.That(createGroup.StatusCode, Is.EqualTo(StatusCodes.Good)); + var groupNodeId = (NodeId)createGroup.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + + NodeId groupLabelsId = await FindChildAsync(groupNodeId, "Labels").ConfigureAwait(false); + NodeId groupAddId = await FindChildAsync(groupLabelsId, "AddAttribute") + .ConfigureAwait(false); + + CallMethodResult addGroupLabel = await CallAsync( + groupLabelsId, groupAddId, + new Variant("owner"), new Variant("team-iot"), new Variant(0u)) + .ConfigureAwait(false); + Assert.That(addGroupLabel.StatusCode, Is.EqualTo(StatusCodes.Good)); + NodeId ownerNodeId = await FindChildAsync(groupLabelsId, "owner").ConfigureAwait(false); + Assert.That( + (await ReadValueAsync(ownerNodeId).ConfigureAwait(false)).GetValue(null), + Is.EqualTo("team-iot")); + + // Epoch mismatch is rejected with Bad_InvalidState and makes no change. + NodeId groupEpochId = await FindChildAsync(groupNodeId, "Epoch").ConfigureAwait(false); + uint groupEpoch = (await ReadValueAsync(groupEpochId).ConfigureAwait(false)) + .GetValue(0); + CallMethodResult mismatchedGroup = await CallAsync( + groupLabelsId, groupAddId, + new Variant("owner"), new Variant("team-other"), new Variant(groupEpoch + 999)) + .ConfigureAwait(false); + Assert.That(mismatchedGroup.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + Assert.That( + (await ReadValueAsync(ownerNodeId).ConfigureAwait(false)).GetValue(null), + Is.EqualTo("team-iot"), "A rejected epoch mismatch must not change the label value."); + + // A key colliding with a fixed Labels container member is rejected. + CallMethodResult collision = await CallAsync( + groupLabelsId, groupAddId, + new Variant("AddAttribute"), new Variant("x"), new Variant(0u)) + .ConfigureAwait(false); + Assert.That(collision.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameDuplicated)); + + // A key with a path-separator character is rejected. + CallMethodResult invalidKey = await CallAsync( + groupLabelsId, groupAddId, + new Variant("a/b"), new Variant("x"), new Variant(0u)).ConfigureAwait(false); + Assert.That(invalidKey.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); + + // Resource-level Labels. + NodeId getOrCreateResourceId = await FindChildAsync(groupNodeId, "GetOrCreateResource") + .ConfigureAwait(false); + CallMethodResult createResource = await CallAsync( + groupNodeId, getOrCreateResourceId, + new Variant("thing1"), new Variant(string.Empty), new Variant(false)) + .ConfigureAwait(false); + Assert.That(createResource.StatusCode, Is.EqualTo(StatusCodes.Good)); + var resourceNodeId = (NodeId)createResource.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + + NodeId resourceLabelsId = await FindChildAsync(resourceNodeId, "Labels") + .ConfigureAwait(false); + NodeId resourceAddId = await FindChildAsync(resourceLabelsId, "AddAttribute") + .ConfigureAwait(false); + NodeId resourceRemoveId = await FindChildAsync(resourceLabelsId, "RemoveAttribute") + .ConfigureAwait(false); + + CallMethodResult addResourceLabel = await CallAsync( + resourceLabelsId, resourceAddId, + new Variant("site"), new Variant("seattle"), new Variant(0u)) + .ConfigureAwait(false); + Assert.That(addResourceLabel.StatusCode, Is.EqualTo(StatusCodes.Good)); + NodeId siteNodeId = await FindChildAsync(resourceLabelsId, "site").ConfigureAwait(false); + Assert.That( + (await ReadValueAsync(siteNodeId).ConfigureAwait(false)).GetValue(null), + Is.EqualTo("seattle")); + + // Remove the resource label; it must disappear from Browse. + CallMethodResult removeResourceLabel = await CallAsync( + resourceLabelsId, resourceRemoveId, + new Variant("site"), new Variant(0u)).ConfigureAwait(false); + Assert.That(removeResourceLabel.StatusCode, Is.EqualTo(StatusCodes.Good)); + BrowseResponse afterRemove = await BrowseAsync(resourceLabelsId).ConfigureAwait(false); + Assert.That( + afterRemove.Results[0].References.Contains( + r => string.Equals(r.BrowseName.Name, "site", StringComparison.Ordinal)), + Is.False); + + // Removing an unknown label fails with a precise StatusCode. + CallMethodResult removeUnknown = await CallAsync( + resourceLabelsId, resourceRemoveId, + new Variant("missing"), new Variant(0u)).ConfigureAwait(false); + Assert.That(removeUnknown.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + + // Registry-level remove. + CallMethodResult removeRegistry = await CallAsync( + registryLabelsId, registryRemoveId, + new Variant("environment"), new Variant(0u)).ConfigureAwait(false); + Assert.That(removeRegistry.StatusCode, Is.EqualTo(StatusCodes.Good)); + BrowseResponse registryAfterRemove = await BrowseAsync(registryLabelsId) + .ConfigureAwait(false); + Assert.That( + registryAfterRemove.Results[0].References.Contains( + r => string.Equals(r.BrowseName.Name, "environment", StringComparison.Ordinal)), + Is.False); + } + + private async Task FindChildAsync(NodeId parent, string browseName) + { + BrowseResponse browse = await BrowseAsync(parent).ConfigureAwait(false); + foreach (ReferenceDescription reference in browse.Results[0].References) + { + if (string.Equals(reference.BrowseName.Name, browseName, StringComparison.Ordinal)) + { + return ExpandedNodeId.ToNodeId( + reference.NodeId, m_server.CurrentInstance.NamespaceUris); + } + } + Assert.Fail($"Child '{browseName}' was not found under {parent}."); + return NodeId.Null; + } + + private async Task CallAsync( + NodeId objectId, NodeId methodId, params Variant[] inputs) + { + ArrayOf methods = + [ + new CallMethodRequest + { + ObjectId = objectId, + MethodId = methodId, + InputArguments = inputs + } + ]; + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CallResponse response = await m_server + .CallAsync(m_secureChannelContext, requestHeader, methods, RequestLifetime.None) + .ConfigureAwait(false); + return response.Results[0]; + } + + private static async Task WaitForConditionAsync(Func> condition) + { + for (int attempt = 0; attempt < 50; attempt++) + { + if (await condition().ConfigureAwait(false)) + { + return true; + } + await Task.Delay(100).ConfigureAwait(false); + } + return false; + } + + private async Task UpsertSensorAsync(string resourceId, int generation) + { + await m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = SensorConverter.BuildContent(generation) + }).ConfigureAwait(false); + } + + private static string GenChildBrowseName(int generation) + { + return "Gen" + generation.ToString(CultureInfo.InvariantCulture); + } + + /// + /// Emits a NodeSet2 whose model namespace is fixed and whose value node + /// carries the generation number parsed from the document, plus a + /// generation-specific child so a Browse can distinguish generations. + /// + private sealed class SensorConverter : IWotDocumentConverter + { + public static byte[] BuildContent(int generation) + { + return Encoding.UTF8.GetBytes( + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"@type\":\"uav:object\",\"id\":\"" + + kModelNamespaceUri + + "\"," + + "\"title\":\"sensor\",\"gen\":" + + generation.ToString(CultureInfo.InvariantCulture) + + "}"); + } + + public ValueTask ConvertAsync( + WotResource resource, + ReadOnlyMemory content, + WotRegistrySnapshot snapshot, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(Convert(content)); + } + + private static WotConversionOutput Convert(ReadOnlyMemory content) + { + int generation = ParseGeneration(content.Span); + uint childId = kGenChildBaseNodeId + (uint)generation; + string childName = "Gen" + generation.ToString(CultureInfo.InvariantCulture); + string xml = $""" + + + + {kModelNamespaceUri} + + + + + + Sensor + + i=58 + ns=1;i={kValueNodeId} + ns=1;i={childId} + i=85 + + + + {kValueBrowseName} + + i=63 + ns=1;i={kRootNodeId} + + {generation} + + + {childName} + + i=63 + ns=1;i={kRootNodeId} + + {generation} + + + """; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + UANodeSet nodeSet = UANodeSet.Read(stream)!; + return WotConversionOutput.Success(nodeSet); + } + + private static int ParseGeneration(ReadOnlySpan content) + { + try + { + var reader = new System.Text.Json.Utf8JsonReader(content); + while (reader.Read()) + { + if (reader.TokenType == System.Text.Json.JsonTokenType.PropertyName && + reader.GetString() == "gen" && + reader.Read()) + { + return reader.GetInt32(); + } + } + } + catch (System.Text.Json.JsonException) + { + // fall through + } + return 1; + } + } + + private async Task ReadValueAsync(NodeId nodeId, uint attributeId = Attributes.Value) + { + ArrayOf readIds = + [new ReadValueId { NodeId = nodeId, AttributeId = attributeId }]; + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + ReadResponse response = await m_server.ReadAsync( + m_secureChannelContext, requestHeader, kMaxAge, + TimestampsToReturn.Neither, readIds, RequestLifetime.None).ConfigureAwait(false); + return response.Results[0]; + } + + private async Task BrowseAsync(NodeId nodeId) + { + var services = new ServerTestServices(m_server, m_secureChannelContext); + var template = new BrowseDescription + { + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = UaReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + NodeClassMask = 0, + ResultMask = (uint)BrowseResultMask.All + }; + ArrayOf nodesToBrowse = + ServerFixtureUtils.CreateBrowseDescriptionCollectionFromNodeId([nodeId], template); + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + return await services + .BrowseAsync(requestHeader, view: null, requestedMaxReferencesPerNode: 0, nodesToBrowse) + .ConfigureAwait(false); + } + + private async Task CreateSubscriptionWithMonitoredItemAsync( + ServerTestServices services, NodeId nodeId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateSubscriptionResponse subscriptionResponse = await services + .CreateSubscriptionAsync(requestHeader, 100, 100, 10, 0, true, 0).ConfigureAwait(false); + uint subscriptionId = subscriptionResponse.SubscriptionId; + + ArrayOf monitoredItems = + [ + new MonitoredItemCreateRequest + { + ItemToMonitor = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }, + MonitoringMode = MonitoringMode.Reporting, + RequestedParameters = new MonitoringParameters + { + ClientHandle = 1, SamplingInterval = 0, QueueSize = 1, DiscardOldest = true + } + } + ]; + requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateMonitoredItemsResponse createItemsResponse = await services + .CreateMonitoredItemsAsync( + requestHeader, subscriptionId, TimestampsToReturn.Both, monitoredItems) + .ConfigureAwait(false); + Assert.That(createItemsResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good)); + return subscriptionId; + } + + private async Task DeleteSubscriptionAsync(ServerTestServices services, uint subscriptionId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + ArrayOf subscriptionIds = [subscriptionId]; + DeleteSubscriptionsResponse response = await services + .DeleteSubscriptionsAsync(requestHeader, subscriptionIds).ConfigureAwait(false); + Assert.That(response.Results[0], Is.EqualTo(StatusCodes.Good)); + } + + private async Task<(DataValue? Value, ArrayOf Acknowledgements)> + PublishForDataChangeAsync( + ServerTestServices services, uint subscriptionId, + ArrayOf acknowledgements, uint clientHandle) + { + const int MaxPublishAttempts = 20; + DataValue? value = null; + for (int attempt = 0; attempt < MaxPublishAttempts && value is null; attempt++) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + PublishResponse response = await services + .PublishAsync(requestHeader, acknowledgements, timeoutCts.Token).ConfigureAwait(false); + acknowledgements = response.AvailableSequenceNumbers.ToArrayOf( + sequenceNumber => new SubscriptionAcknowledgement + { + SubscriptionId = subscriptionId, + SequenceNumber = sequenceNumber + }); + if (response.NotificationMessage is { } message) + { + foreach (ExtensionObject notificationData in message.NotificationData) + { + if (notificationData.TryGetValue(out DataChangeNotification dcn)) + { + foreach (MonitoredItemNotification item in dcn.MonitoredItems) + { + if (item.ClientHandle == clientHandle) + { + value = item.Value; + } + } + } + } + } + } + Assert.That(value, Is.Not.Null, + $"No data-change notification for client handle {clientHandle} arrived."); + return (value, acknowledgements); + } + + private async Task<(bool Alive, ArrayOf Acknowledgements)> + PublishKeepAliveAsync( + ServerTestServices services, uint subscriptionId, + ArrayOf acknowledgements) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + PublishResponse response = await services + .PublishAsync(requestHeader, acknowledgements, timeoutCts.Token).ConfigureAwait(false); + Assert.That(response.SubscriptionId, Is.EqualTo(subscriptionId), + "The subscription and its monitored item must stay alive across the shadow reload."); + var acks = response.AvailableSequenceNumbers.ToArrayOf( + sequenceNumber => new SubscriptionAcknowledgement + { + SubscriptionId = subscriptionId, + SequenceNumber = sequenceNumber + }); + return (true, acks); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Support/MemoryWotBinding.cs b/tests/Opc.Ua.WotCon.Tests/Support/MemoryWotBinding.cs new file mode 100644 index 0000000000..879c468e31 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Support/MemoryWotBinding.cs @@ -0,0 +1,253 @@ +/* ======================================================================== + * 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.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.WotCon.Bindings; + +namespace Opc.Ua.WotCon.Tests.Support +{ + /// + /// A worked sample showing how a third party contributes a replaceable + /// protocol binder as pure code-behind. The fictitious mem protocol + /// binds property affordances to an in-process key/value store, demonstrating + /// the full extension surface: identity, capability, deterministic + /// identification, a planner and an executor with a live channel. Register it + /// with builder.AddWotBinder(new MemoryWotBinder()) and + /// builder.AddWotBindingExecutor(new MemoryWotBindingExecutor(store)). + /// + public sealed class MemoryWotBinder : WotProtocolBinderBase + { + /// + /// The sample binding vocabulary URI. + /// + public const string BindingUri = "urn:example:wot:mem"; + + private static readonly string[] s_schemes = ["mem"]; + + /// + public override WotBindingIdentity Identity { get; } = + new WotBindingIdentity("example.mem", "1.0", BindingUri, "Sample In-Memory Binding"); + + /// + public override WotBindingCapability Capability { get; } = new WotBindingCapability( + BindingUri, + "Sample In-Memory Binding", + new WotBindingSource("urn:example:wot:mem", "1.0", WotBindingMaturity.UnofficialDraft, + note: "A sample custom binding for documentation and tests."), + [ + WoTBindingCapabilityEnum.ReadProperty, + WoTBindingCapabilityEnum.WriteProperty, + WoTBindingCapabilityEnum.ObserveProperty + ], + ["application/json", "text/plain"], + isExecutable: true); + + /// + protected override IReadOnlyCollection Schemes => s_schemes; + + /// + public override WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context) + { + return MatchStandard(form, context, "memv:"); + } + + /// + public override WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context) + { + var diagnostics = new List(); + if (!RequireHref(form, context, diagnostics, out string href) || + !TryParseUri(href, out Uri uri) || + !string.Equals(uri.Scheme, "mem", StringComparison.OrdinalIgnoreCase)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidHref, + "The href is not a valid mem:// URI.", form.Pointer("href"))); + return WotBindingCompilation.Unsupported([.. diagnostics]); + } + + string key = uri.AbsolutePath.Trim('/'); + if (!ResolveCodec(form, context, diagnostics, out WotPayloadDescriptor payload)) + { + return WotBindingCompilation.Unsupported([.. diagnostics]); + } + WotEndpointDescriptor endpoint = MakeEndpoint(uri); + var addressing = new WotAddressingDescriptor(key); + + ImmutableArray.Builder entries = ImmutableArray.CreateBuilder(); + foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) + { + var operation = new WotOperationDescriptor(capability, op, capability.ToString()); + entries.Add(new WotCompiledForm( + Identity, form.Kind, form.AffordanceName, form.JsonPointer, capability, op, + endpoint, addressing, operation, payload, + [], Capability.IsExecutable)); + } + + return entries.Count == 0 + ? WotBindingCompilation.Unsupported([.. diagnostics]) + : WotBindingCompilation.Supported(entries.ToImmutable(), [.. diagnostics]); + } + } + + /// + /// The in-process key/value store the sample binding reads and writes. + /// + public sealed class MemoryWotStore + { + /// + /// Gets the value stored under a key. + /// + public DataValue Get(string key) + { + return m_values.TryGetValue(key, out DataValue value) ? value : new DataValue(Variant.Null); + } + + /// + /// Sets the value stored under a key. + /// + public void Set(string key, DataValue value) + { + m_values[key] = value; + } + + private readonly ConcurrentDictionary m_values = + new(StringComparer.Ordinal); + } + + /// + /// The executor for the sample in-memory binding. + /// + public sealed class MemoryWotBindingExecutor : IWotBindingExecutor + { + /// + /// Initializes a new sample executor over the supplied store. + /// + public MemoryWotBindingExecutor(MemoryWotStore store) + { + m_store = store ?? throw new ArgumentNullException(nameof(store)); + } + + /// + public WotBindingIdentity Identity { get; } = + new WotBindingIdentity("example.mem", "1.0", MemoryWotBinder.BindingUri, "Sample In-Memory Executor"); + + /// + public bool CanExecute(WotCompiledForm form) + { + return form is not null && string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal); + } + + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The channel is owned by the caller, who disposes it via DisposeAsync.")] + public ValueTask ActivateAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + IWotBindingChannel channel = new MemoryWotBindingChannel(m_store, form); + return new ValueTask(channel); + } + + private readonly MemoryWotStore m_store; + } + + /// + /// The live channel for the sample in-memory binding. + /// + internal sealed class MemoryWotBindingChannel : IWotBindingChannel + { + public MemoryWotBindingChannel(MemoryWotStore store, WotCompiledForm form) + { + m_store = store; + Form = form; + m_key = form.Addressing.Target; + } + + public WotCompiledForm Form { get; } + + public ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + return new ValueTask(new WotReadResult(StatusCodes.Good, m_store.Get(m_key))); + } + + public ValueTask WriteAsync(DataValue value, CancellationToken cancellationToken = default) + { + m_store.Set(m_key, value); + return new ValueTask(new WotWriteResult(StatusCodes.Good)); + } + + public ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + { + return new ValueTask(new WotInvokeResult( + StatusCodes.BadNotSupported, null, "The sample binding has no actions.")); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the subscription is transferred to the caller, who disposes it.")] + public ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + if (onNotification is null) + { + throw new ArgumentNullException(nameof(onNotification)); + } + var subscription = new PollingWotSubscription(Form, token => + { + onNotification(new WotNotification(m_store.Get(m_key))); + return new ValueTask(true); + }, TimeSpan.FromMilliseconds(200)); + return new ValueTask(subscription); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + { + return ObserveAsync(onEvent, cancellationToken); + } + + public ValueTask DisposeAsync() + { + return default; + } + + private readonly MemoryWotStore m_store; + private readonly string m_key; + } +}