diff --git a/Directory.Packages.props b/Directory.Packages.props index 138ee009cb..15ff6d8014 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,7 @@ + @@ -117,4 +118,4 @@ - \ No newline at end of file + diff --git a/Docs/README.md b/Docs/README.md index 97f2836b8b..5aadd2c65f 100644 --- a/Docs/README.md +++ b/Docs/README.md @@ -20,6 +20,7 @@ Here is a list of available documentation for different topics: * Working with [ComplexTypes](ComplexTypes.md) - Custom structures and enumerations. * Client-based [NodeSet Export](NodeSetExport.md) - Export server address space to NodeSet2 XML. * Source generated [DataTypes] - How to annotate POCO classes and let the source generator generate the `IEncodeable` implementation. +* Runtime [Schema Generation](SchemaGeneration.md) - Produce XSD, OPC Binary (BSD) and JSON Schema (Part 6 Annex C, compact + verbose) for generated encodeable types and dynamically added complex types via the injectable `ISchemaProvider`; schemas are built as object models in code (trimmable, NativeAOT compatible). * Source generated [NodeManagers](SourceGeneratedNodeManagers.md) - Emit an `AsyncCustomNodeManager` from a model design XML and wire callbacks via the fluent `INodeManagerBuilder` API; supports NativeAOT single-file servers (samples: [MinimalBoilerServer](../Applications/MinimalBoilerServer), [PumpDeviceIntegrationServer](../Applications/PumpDeviceIntegrationServer)). Covers engineering units, property initialisation, alarms, simulation timers, instance creation, NAMUR-style supervision, multi-model composition, and the fluent state-machine builder on top of any `FiniteStateMachineState` subclass. Cross-assembly model references are tracked via the [ModelDependencyAttribute](ModelDependencies.md). Companion-spec packaging — model + server + client library trios — is covered end-to-end by the [Device Integration developer guide](DeviceIntegration.md) using the `Opc.Ua.Di` / `Opc.Ua.Di.Server` / `Opc.Ua.Di.Client` trio as the worked example. * [Device Integration (DI) developer guide](DeviceIntegration.md) - End-to-end documentation for the `Opc.Ua.Di*` library trio: fluent `IDeviceBuilder`, device sub-type extensions (`AddSoftware`, `AddBlock`, `AddConfigurableObject`, `AddLifetimeIndication`, `WithSupportInfo`), hosting integration (`AddOpcUaDi` / `ConfigureDevicesFor`), lock service, software-update package store, and client helpers (`DiLockClient`, `DiTopologyClient`, `SoftwareUpdateClient`). Includes a section enumerating supported OPC 10000-100 features against the spec. * [Alias Names](AliasNames.md) - Full server + client support for the OPC UA Part 17 alias-name model (`AliasNameType`, `AliasNameCategoryType`, `FindAlias`, `FindAliasVerbose`, `AddAliasesToCategory`, `DeleteAliasesFromCategory`, `LastChange`). diff --git a/Docs/SchemaGeneration.md b/Docs/SchemaGeneration.md new file mode 100644 index 0000000000..7ae5ff1a1a --- /dev/null +++ b/Docs/SchemaGeneration.md @@ -0,0 +1,123 @@ +# Runtime Schema Generation + +The `Opc.Ua.Core.Schema` library generates schemas for OPC UA data types at runtime. It works for the encodeable types emitted by the source generators and for complex types that are added dynamically by the complex-type client. Schemas are produced in every supported encoding: + +- **XSD** for the XML encoding. +- **BSD** (OPC Binary, Part 6) for the binary encoding. +- **JSON Schema** (Part 6 Annex C, draft 2020-12) for the JSON encoding, in both the **compact** (reversible, BrowseName-keyed) and **verbose** flavors. + +Schemas are built as strongly-typed object models in code — there are no embedded schema strings — so unused generation paths are trimmed away and the whole library is NativeAOT compatible. The XSD object model is the in-box `System.Xml.Schema.XmlSchema`, the BSD object model is the existing `Opc.Ua.Schema.Binary.TypeDictionary`, and the JSON object model is `System.Text.Json.Nodes.JsonObject`. + +## Concepts + +Generation is driven by a type's runtime structure definition (`StructureDefinition` or `EnumDefinition`), which already captures every field, data type, value rank and optionality. The pieces fit together as follows: + +- `ISchemaProvider` is the entry point. It produces an `IUaSchema` for a requested `UaSchemaFormat` and `UaSchemaScope`. +- `IUaSchema` is the generated document. It exposes the strongly-typed object model (for example `JsonSchemaDocument.Root`, `XmlSchemaDocument.Schema`, `BinarySchemaDocument.Dictionary`) and can serialize itself with `WriteTo(Stream)`, `WriteTo(TextWriter)` or `ToSchemaString()`. +- `IDataTypeDefinitionResolver` maps a data type id to its `UaTypeDescription` (the type id, browse name and definition). The default implementation, `DataTypeDefinitionRegistry`, is an in-memory registry that generated and dynamically built types register their definitions with. The resolver is also used to follow field references and to enumerate the types of a namespace. + +`UaSchemaFormat` selects the encoding (`Xsd`, `Bsd`, `JsonCompact`, `JsonVerbose`). `UaSchemaScope` selects the document granularity: `Type` produces a document for a single type and the closure of the types it depends on; `Namespace` produces a dictionary document for all types in a namespace. + +## Registration + +The services are registered through the standard OPC UA dependency-injection surface: + +```csharp +IServiceProvider services = new ServiceCollection() + .AddOpcUa() + .AddSchemaGeneration() + .Services + .BuildServiceProvider(); + +ISchemaProvider provider = services.GetRequiredService(); +``` + +The provider can also be constructed directly when dependency injection is not used: + +```csharp +var registry = new DataTypeDefinitionRegistry(); +registry.Add(new UaTypeDescription(typeId, browseName, structureDefinition, namespaceUri)); + +ISchemaProvider provider = new DefaultSchemaProvider( + registry, + new IUaSchemaGenerator[] { new JsonSchemaGenerator() }); +``` + +## Registering data types + +Schema generation needs the runtime definition of a type. A type's `StructureDefinition` / `EnumDefinition` is registered with the resolver from whichever source has it: + +- **Server / browsed types** — a `DataTypeNode` obtained from a server (or the client node cache) carries its definition in `DataTypeNode.DataTypeDefinition`. Register it directly: + +```csharp +var registry = serviceProvider.GetRequiredService(); +registry.TryAddDataType(dataTypeNode, session.NamespaceUris); +``` + +- **Source-generated types** — the generated types expose their definition through the generated `DataTypeDefinitions.Create(namespaceUris)` factory. Wrap it in a `UaTypeDescription` and add it: + +```csharp +registry.Add(new UaTypeDescription(typeId, browseName, definition, namespaceUri)); +``` + +- **Dynamic complex types** — complex types built by the complex-type client carry a `StructureDefinition` (via `IStructureTypeInfo` / the structure-definition attribute) that can likewise be wrapped in a `UaTypeDescription` and registered. + +Once registered, fields that reference other registered types are resolved automatically and included in the generated document. + +## Generating a schema + +Once a type's definition is registered with the resolver, a schema can be produced from its type id: + +```csharp +if (provider.TryGetSchema(typeId, UaSchemaFormat.JsonCompact, UaSchemaScope.Type, out IUaSchema? schema)) +{ + string json = schema.ToSchemaString(); +} +``` + +The convenience extension methods read more naturally and make a type "expose" its schema: + +```csharp +IUaSchema xsd = provider.GetXmlSchema(type); +IUaSchema bsd = provider.GetBinarySchema(type); +IUaSchema jsonCompact = provider.GetJsonSchema(type); +IUaSchema jsonVerbose = provider.GetJsonSchema(type, verbose: true); + +// Resolve by type id and produce JSON in one call. +provider.TryGetJsonSchema(typeId, out IUaSchema? schema); +``` + +## Working with the object model + +Because the schema is an object model, callers can inspect or post-process it before serializing. For JSON: + +```csharp +var document = (JsonSchemaDocument)provider.GetJsonSchema(type); +JsonObject root = document.Root; // the draft 2020-12 schema +document.WriteTo(stream); // UTF-8, indented +``` + +## JSON encoding notes (Part 6) + +The JSON schemas follow the Part 6 JSON encoding faithfully, matching what the stack's `JsonEncoder` produces: + +- `Int64` and `UInt64` are encoded as JSON strings (to avoid precision loss), so they are typed as `string`. +- `Float`/`Double` accept the special string values `Infinity`, `-Infinity` and `NaN`, so they are typed as `["number", "string"]`. +- `ByteString` is a base64 `string`; `DateTime` is a `date-time` string; `Guid` is a `uuid` string. +- The standard structured built-ins (`NodeId`, `Variant`, `ExtensionObject`, `DataValue`, ...) are described once per document in the `$defs` section and referenced. +- Compact enums are integers (with the allowed values listed via `oneOf`); verbose enums are the `Name_Value` strings. + +## PubSub schemas + +The `Opc.Ua.PubSub.Schema` library generates JSON Schemas for the PubSub JSON message formats. It is registered with `services.AddOpcUa().AddPubSubSchema()` and exposes `IPubSubSchemaProvider`: + +- `CreateDataSetSchema(metaData, fieldContentMask, verbose)` — the per-DataSet payload object, one property per `FieldMetaData`. The field value shape follows the same Part 6 JSON rules as the core library, and `DataSetFieldContentMask` controls whether each field is the raw value or a `DataValue` object (with the mask-selected `StatusCode` / `SourceTimestamp` / ... members). +- `CreateDataSetMessageSchema(metaData, messageContentMask, fieldContentMask, verbose)` — a single DataSetMessage whose header fields are gated by `JsonDataSetMessageContentMask` and whose `Payload` is the DataSet schema above. +- `CreateNetworkMessageSchema(metaData, networkContentMask, messageContentMask, fieldContentMask, verbose)` — the `ua-data` NetworkMessage envelope, gated by `JsonNetworkMessageContentMask`; `Messages` is an array of DataSetMessage schemas, or a single object when `SingleDataSetMessage` is set. +- `CreateMetaDataMessageSchema(metaData, verbose)` — the `ua-metadata` message. + +The provider reuses the core `ISchemaProvider` to resolve complex (structured/enum) field data types, embedding them into the document `$defs` section. + +## Trimming and NativeAOT + +The library opts into `IsAotCompatible` and avoids reflection-based serialization. XSD is written with `System.Xml.Schema.XmlSchema`, BSD with a direct `System.Xml.XmlWriter`, and JSON with `System.Text.Json.Nodes` / `Utf8JsonWriter`. Schema generation is a configuration-time activity, not a hot path; documents are built lazily and can be cached by the caller. Because the generation logic lives in its own assembly, it is trimmed away entirely when an application does not generate schemas. diff --git a/Libraries/Opc.Ua.Client.ComplexTypes/Opc.Ua.Client.ComplexTypes.csproj b/Libraries/Opc.Ua.Client.ComplexTypes/Opc.Ua.Client.ComplexTypes.csproj index 61e363cef0..f95396548d 100644 --- a/Libraries/Opc.Ua.Client.ComplexTypes/Opc.Ua.Client.ComplexTypes.csproj +++ b/Libraries/Opc.Ua.Client.ComplexTypes/Opc.Ua.Client.ComplexTypes.csproj @@ -11,6 +11,14 @@ true enable + + + true + diff --git a/Libraries/Opc.Ua.Client/ComplexTypes/ComplexTypeSystem.cs b/Libraries/Opc.Ua.Client/ComplexTypes/ComplexTypeSystem.cs index 3f6672f9b0..2c66df8ce9 100644 --- a/Libraries/Opc.Ua.Client/ComplexTypes/ComplexTypeSystem.cs +++ b/Libraries/Opc.Ua.Client/ComplexTypes/ComplexTypeSystem.cs @@ -35,6 +35,7 @@ using System.Threading.Tasks; using System.Xml; using Microsoft.Extensions.Logging; +using Opc.Ua.Schema; namespace Opc.Ua.Client.ComplexTypes { @@ -396,6 +397,40 @@ public IEnumerable GetDefinedDataTypeIds() NodeId.ToExpandedNodeId(nodeId, m_complexTypeResolver.NamespaceUris)); } + /// + /// Registers the data type definitions loaded by this complex type system for schema generation. + /// + /// The registry to populate. + /// The registry to allow chaining. + /// is null. + public DataTypeDefinitionRegistry RegisterDataTypeDefinitions(DataTypeDefinitionRegistry registry) + { + if (registry == null) + { + throw new ArgumentNullException(nameof(registry)); + } + + foreach (KeyValuePair entry in m_dataTypeDefinitionCache) + { + NodeId nodeId = entry.Key; + string namespaceUri = m_complexTypeResolver.NamespaceUris.GetString(nodeId.NamespaceIndex) ?? + string.Empty; + QualifiedName browseName = m_dataTypeBrowseNameCache.TryGetValue( + nodeId, + out QualifiedName cachedBrowseName) + ? cachedBrowseName + : new QualifiedName(nodeId.ToString(), nodeId.NamespaceIndex); + + registry.Add(new UaTypeDescription( + new ExpandedNodeId(nodeId), + browseName, + entry.Value, + namespaceUri)); + } + + return registry; + } + /// /// Get the data type definition and dependent definitions for a data type node id. /// Recursive through the cache to find all dependent types for structures fields @@ -452,6 +487,7 @@ void CollectAllDataTypeDefinitions( public void ClearDataTypeCache() { m_dataTypeDefinitionCache.Clear(); + m_dataTypeBrowseNameCache.Clear(); } /// @@ -1242,7 +1278,10 @@ private async Task AddEnumTypesAsync( if (enumDefinition != null) { // Add EnumDefinition to cache - m_dataTypeDefinitionCache[enumType.NodeId] = enumDefinition; + AddDataTypeDefinitionToCache( + enumType.NodeId, + enumType.BrowseName, + enumDefinition); newType = complexTypeBuilder.AddEnumType( QualifiedName.From(enumeratedObject.Name!), @@ -1357,7 +1396,10 @@ private void AddEncodeableType(ExpandedNodeId nodeId, IType type) if (enumDefinition != null) { // Add EnumDefinition to cache - m_dataTypeDefinitionCache[enumTypeNode.NodeId] = enumDefinition; + AddDataTypeDefinitionToCache( + enumTypeNode.NodeId, + name, + enumDefinition); newType = complexTypeBuilder.AddEnumType(name, enumDefinition); } @@ -1410,7 +1452,7 @@ private void AddEncodeableType(ExpandedNodeId nodeId, IType type) enumDefinition.IsOptionSet = true; // Add EnumDefinition to cache - m_dataTypeDefinitionCache[dataTypeNode.NodeId] = enumDefinition; + AddDataTypeDefinitionToCache(dataTypeNode.NodeId, name, enumDefinition); return complexTypeBuilder.AddOptionSetType( name, @@ -1499,7 +1541,7 @@ private async Task IsOptionSetSubtypeAsync( } // Add StructureDefinition to cache - m_dataTypeDefinitionCache[localDataTypeId] = structureDefinition; + AddDataTypeDefinitionToCache(localDataTypeId, typeName, structureDefinition); IComplexTypeFieldBuilder fieldBuilder = complexTypeBuilder.AddStructuredType( typeName, @@ -1541,6 +1583,15 @@ private async Task IsOptionSetSubtypeAsync( return (fieldBuilder.CreateType(), missingTypes); } + private void AddDataTypeDefinitionToCache( + NodeId typeId, + QualifiedName browseName, + DataTypeDefinition definition) + { + m_dataTypeDefinitionCache[typeId] = definition; + m_dataTypeBrowseNameCache[typeId] = browseName; + } + private static bool IsAllowSubTypes(StructureDefinition structureDefinition) { switch (structureDefinition.StructureType) @@ -1741,6 +1792,7 @@ private static void SplitAndSortDictionary( private readonly IComplexTypeResolver m_complexTypeResolver; private readonly IComplexTypeFactory m_complexTypeBuilderFactory; private readonly NodeIdDictionary m_dataTypeDefinitionCache = []; + private readonly NodeIdDictionary m_dataTypeBrowseNameCache = []; private static readonly string[] s_supportedEncodings = [ diff --git a/Libraries/Opc.Ua.Client/Opc.Ua.Client.csproj b/Libraries/Opc.Ua.Client/Opc.Ua.Client.csproj index c8db51418c..e674c5a16d 100644 --- a/Libraries/Opc.Ua.Client/Opc.Ua.Client.csproj +++ b/Libraries/Opc.Ua.Client/Opc.Ua.Client.csproj @@ -50,6 +50,7 @@ + diff --git a/Libraries/Opc.Ua.PubSub.Schema/DependencyInjection/PubSubSchemaServiceCollectionExtensions.cs b/Libraries/Opc.Ua.PubSub.Schema/DependencyInjection/PubSubSchemaServiceCollectionExtensions.cs new file mode 100644 index 0000000000..4453c840b9 --- /dev/null +++ b/Libraries/Opc.Ua.PubSub.Schema/DependencyInjection/PubSubSchemaServiceCollectionExtensions.cs @@ -0,0 +1,61 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Opc.Ua; +using Opc.Ua.PubSub.Schema; +using Opc.Ua.Schema; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Dependency injection extensions for OPC UA PubSub schema generation. + /// + public static class PubSubSchemaServiceCollectionExtensions + { + /// + /// Registers PubSub DataSet schema generation services. + /// + /// The OPC UA builder. + /// The same instance. + /// is null. + public static IOpcUaBuilder AddPubSubSchema(this IOpcUaBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.AddSchemaGeneration(); + builder.Services.TryAddSingleton(); + return builder; + } + } +} diff --git a/Libraries/Opc.Ua.PubSub.Schema/IPubSubSchemaProvider.cs b/Libraries/Opc.Ua.PubSub.Schema/IPubSubSchemaProvider.cs new file mode 100644 index 0000000000..80f0a3b800 --- /dev/null +++ b/Libraries/Opc.Ua.PubSub.Schema/IPubSubSchemaProvider.cs @@ -0,0 +1,91 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using Opc.Ua.Schema; + +namespace Opc.Ua.PubSub.Schema +{ + /// + /// Generates schema documents for OPC UA PubSub runtime metadata. + /// + public interface IPubSubSchemaProvider + { + /// + /// Creates a JSON Schema document for the fields of a PubSub DataSet payload. + /// + /// The DataSet metadata that describes the fields. + /// The writer field content mask that controls field value shape. + /// Whether verbose OPC UA JSON encoding schema fragments are requested. + /// The generated JSON Schema document. + IUaSchema CreateDataSetSchema( + DataSetMetaDataType metaData, + DataSetFieldContentMask fieldContentMask, + bool verbose = false); + + /// + /// Creates a JSON Schema document for a single PubSub JSON DataSetMessage object. + /// + /// The DataSet metadata that describes the payload fields. + /// The JSON DataSetMessage content mask that controls header fields. + /// The writer field content mask that controls field value shape. + /// Whether verbose OPC UA JSON encoding schema fragments are requested. + /// The generated JSON Schema document. + IUaSchema CreateDataSetMessageSchema( + DataSetMetaDataType metaData, + JsonDataSetMessageContentMask messageContentMask, + DataSetFieldContentMask fieldContentMask, + bool verbose = false); + + /// + /// Creates a JSON Schema document for a PubSub JSON NetworkMessage envelope. + /// + /// The DataSet metadata that describes the payload fields. + /// The JSON NetworkMessage content mask that controls envelope fields. + /// The JSON DataSetMessage content mask that controls message header fields. + /// The writer field content mask that controls field value shape. + /// Whether verbose OPC UA JSON encoding schema fragments are requested. + /// The generated JSON Schema document. + IUaSchema CreateNetworkMessageSchema( + DataSetMetaDataType metaData, + JsonNetworkMessageContentMask networkContentMask, + JsonDataSetMessageContentMask messageContentMask, + DataSetFieldContentMask fieldContentMask, + bool verbose = false); + + /// + /// Creates a JSON Schema document for a PubSub JSON metadata message envelope. + /// + /// The DataSet metadata announced by the metadata message. + /// Whether verbose OPC UA JSON encoding schema fragments are requested. + /// The generated JSON Schema document. + IUaSchema CreateMetaDataMessageSchema( + DataSetMetaDataType metaData, + bool verbose = false); + } +} diff --git a/Libraries/Opc.Ua.PubSub.Schema/NugetREADME.md b/Libraries/Opc.Ua.PubSub.Schema/NugetREADME.md new file mode 100644 index 0000000000..9ba0466892 --- /dev/null +++ b/Libraries/Opc.Ua.PubSub.Schema/NugetREADME.md @@ -0,0 +1,3 @@ +# OPC UA PubSub Schema + +Generates JSON Schema draft 2020-12 documents for OPC UA PubSub JSON DataSet payloads from `DataSetMetaDataType` runtime metadata. diff --git a/Libraries/Opc.Ua.PubSub.Schema/Opc.Ua.PubSub.Schema.csproj b/Libraries/Opc.Ua.PubSub.Schema/Opc.Ua.PubSub.Schema.csproj new file mode 100644 index 0000000000..914906e26f --- /dev/null +++ b/Libraries/Opc.Ua.PubSub.Schema/Opc.Ua.PubSub.Schema.csproj @@ -0,0 +1,41 @@ + + + $(AssemblyPrefix).PubSub.Schema + $(LibTargetFrameworks) + $(PackagePrefix).Opc.Ua.PubSub.Schema + Opc.Ua.PubSub.Schema + OPC UA PubSub JSON Schema generation for DataSet payloads. + true + NugetREADME.md + true + true + enable + + + + + + $(PackageId).Debug + + + + true + + + + + + + + + + + + + + diff --git a/Libraries/Opc.Ua.PubSub.Schema/Properties/AssemblyInfo.cs b/Libraries/Opc.Ua.PubSub.Schema/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..1dd67e5791 --- /dev/null +++ b/Libraries/Opc.Ua.PubSub.Schema/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +[assembly: CLSCompliant(false)] diff --git a/Libraries/Opc.Ua.PubSub.Schema/PubSubSchemaProvider.cs b/Libraries/Opc.Ua.PubSub.Schema/PubSubSchemaProvider.cs new file mode 100644 index 0000000000..aceec69aac --- /dev/null +++ b/Libraries/Opc.Ua.PubSub.Schema/PubSubSchemaProvider.cs @@ -0,0 +1,763 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json.Nodes; +using Opc.Ua.Schema; +using Opc.Ua.Schema.Json; + +namespace Opc.Ua.PubSub.Schema +{ + /// + /// Default PubSub schema provider that generates JSON Schema documents for per-DataSet payload objects. + /// + public sealed class PubSubSchemaProvider : IPubSubSchemaProvider + { + /// + /// Initializes a new instance of the class. + /// + /// Optional type schema provider used for complex field data types. + /// Optional data type definition resolver used for complex field data types. + public PubSubSchemaProvider( + ISchemaProvider? schemaProvider = null, + IDataTypeDefinitionResolver? resolver = null) + { + m_schemaProvider = schemaProvider; + m_resolver = resolver; + } + + /// + public IUaSchema CreateDataSetSchema( + DataSetMetaDataType metaData, + DataSetFieldContentMask fieldContentMask, + bool verbose = false) + { + if (metaData is null) + { + throw new ArgumentNullException(nameof(metaData)); + } + + UaSchemaFormat format = verbose ? UaSchemaFormat.JsonVerbose : UaSchemaFormat.JsonCompact; + var definitions = new JsonObject(); + var properties = new JsonObject(); + var required = new List(); + ArrayOf fields = metaData.Fields; + if (!fields.IsNull) + { + for (int i = 0; i < fields.Count; i++) + { + FieldMetaData field = fields[i]; + string fieldName = FieldName(field, i); + properties[fieldName] = CreateFieldSchema(field, fieldContentMask, format, verbose, definitions); + required.Add(fieldName); + } + } + + string dataSetName = string.IsNullOrEmpty(metaData.Name) ? DefaultDataSetName : metaData.Name!; + string documentId = CreateDocumentId(dataSetName); + var root = new JsonObject + { + ["$schema"] = JsonSchemaDialect, + ["$id"] = documentId, + ["title"] = dataSetName, + ["type"] = "object", + ["properties"] = properties, + ["additionalProperties"] = false + }; + if (required.Count > 0) + { + root["required"] = new JsonArray(required.ToArray()); + } + if (definitions.Count > 0) + { + root["$defs"] = definitions; + } + + return new JsonSchemaDocument(format, documentId, root); + } + + /// + public IUaSchema CreateDataSetMessageSchema( + DataSetMetaDataType metaData, + JsonDataSetMessageContentMask messageContentMask, + DataSetFieldContentMask fieldContentMask, + bool verbose = false) + { + if (metaData is null) + { + throw new ArgumentNullException(nameof(metaData)); + } + + UaSchemaFormat format = verbose ? UaSchemaFormat.JsonVerbose : UaSchemaFormat.JsonCompact; + string dataSetName = DataSetName(metaData); + string documentId = CreateDocumentId("dataset-message", dataSetName); + JsonObject root = CreateDataSetMessageRoot( + metaData, + messageContentMask, + fieldContentMask, + verbose, + dataSetName, + documentId); + + return new JsonSchemaDocument(format, documentId, root); + } + + /// + public IUaSchema CreateNetworkMessageSchema( + DataSetMetaDataType metaData, + JsonNetworkMessageContentMask networkContentMask, + JsonDataSetMessageContentMask messageContentMask, + DataSetFieldContentMask fieldContentMask, + bool verbose = false) + { + if (metaData is null) + { + throw new ArgumentNullException(nameof(metaData)); + } + + UaSchemaFormat format = verbose ? UaSchemaFormat.JsonVerbose : UaSchemaFormat.JsonCompact; + string dataSetName = DataSetName(metaData); + string documentId = CreateDocumentId("ua-data", dataSetName); + string dataSetMessageId = CreateDocumentId("dataset-message", dataSetName); + var definitions = new JsonObject + { + ["DataSetMessage"] = CreateDataSetMessageRoot( + metaData, + messageContentMask, + fieldContentMask, + verbose, + dataSetName, + dataSetMessageId) + }; + var properties = new JsonObject + { + ["MessageType"] = Const(JsonNetworkMessageTypeData), + ["Messages"] = CreateMessagesSchema(networkContentMask) + }; + if ((networkContentMask & JsonNetworkMessageContentMask.NetworkMessageHeader) != 0) + { + properties["MessageId"] = new JsonObject { ["type"] = "string" }; + } + if ((networkContentMask & JsonNetworkMessageContentMask.PublisherId) != 0) + { + properties["PublisherId"] = PublisherIdSchema(); + } + if ((networkContentMask & JsonNetworkMessageContentMask.WriterGroupName) != 0) + { + properties["WriterGroupName"] = new JsonObject { ["type"] = "string" }; + } + if ((networkContentMask & JsonNetworkMessageContentMask.DataSetClassId) != 0) + { + properties["DataSetClassId"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }; + } + if ((networkContentMask & JsonNetworkMessageContentMask.ReplyTo) != 0) + { + properties["ReplyTo"] = ArrayOf(new JsonObject { ["type"] = "string" }); + } + + JsonObject root = CreateObjectDocument( + documentId, + dataSetName + " ua-data NetworkMessage", + properties, + s_networkMessageRequired); + root["$defs"] = definitions; + + return new JsonSchemaDocument(format, documentId, root); + } + + /// + public IUaSchema CreateMetaDataMessageSchema( + DataSetMetaDataType metaData, + bool verbose = false) + { + if (metaData is null) + { + throw new ArgumentNullException(nameof(metaData)); + } + + UaSchemaFormat format = verbose ? UaSchemaFormat.JsonVerbose : UaSchemaFormat.JsonCompact; + string dataSetName = DataSetName(metaData); + string documentId = CreateDocumentId("ua-metadata", dataSetName); + var properties = new JsonObject + { + ["MessageId"] = new JsonObject { ["type"] = "string" }, + ["MessageType"] = Const(JsonNetworkMessageTypeMetaData), + ["PublisherId"] = PublisherIdSchema(), + ["DataSetWriterId"] = Integer(ushort.MinValue, ushort.MaxValue), + ["DataSetClassId"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["MetaData"] = new JsonObject + { + ["type"] = "object", + ["additionalProperties"] = true + } + }; + JsonObject root = CreateObjectDocument( + documentId, + dataSetName + " ua-metadata message", + properties, + s_metaDataMessageRequired); + + return new JsonSchemaDocument(format, documentId, root); + } + + private JsonObject CreateFieldSchema( + FieldMetaData field, + DataSetFieldContentMask fieldContentMask, + UaSchemaFormat format, + bool verbose, + JsonObject definitions) + { + JsonObject rawSchema = ApplyValueRank( + () => CreateElementSchema(field, format, verbose, definitions), + field.ValueRank); + if (IsRawDataMask(fieldContentMask)) + { + return rawSchema; + } + return CreateDataValueSchema(rawSchema, fieldContentMask, verbose); + } + + private JsonObject CreateElementSchema( + FieldMetaData field, + UaSchemaFormat format, + bool verbose, + JsonObject definitions) + { + BuiltInType builtInType = GetBuiltInType(field); + if (builtInType != BuiltInType.Null) + { + return CreateBuiltInSchema(builtInType, verbose, definitions); + } + + return CreateComplexTypeSchema(field.DataType, format, definitions); + } + + private JsonObject CreateDataSetMessageRoot( + DataSetMetaDataType metaData, + JsonDataSetMessageContentMask messageContentMask, + DataSetFieldContentMask fieldContentMask, + bool verbose, + string dataSetName, + string documentId) + { + var properties = new JsonObject + { + ["MessageType"] = DataSetMessageTypeSchema(), + ["Payload"] = CreatePayloadSchema(metaData, fieldContentMask, verbose) + }; + if ((messageContentMask & JsonDataSetMessageContentMask.DataSetWriterId) != 0) + { + properties["DataSetWriterId"] = Integer(ushort.MinValue, ushort.MaxValue); + } + if ((messageContentMask & JsonDataSetMessageContentMask.DataSetWriterName) != 0) + { + properties["DataSetWriterName"] = new JsonObject { ["type"] = "string" }; + } + if ((messageContentMask & JsonDataSetMessageContentMask.PublisherId) != 0) + { + properties["PublisherId"] = PublisherIdSchema(); + } + if ((messageContentMask & JsonDataSetMessageContentMask.WriterGroupName) != 0) + { + properties["WriterGroupName"] = new JsonObject { ["type"] = "string" }; + } + if ((messageContentMask & JsonDataSetMessageContentMask.SequenceNumber) != 0) + { + properties["SequenceNumber"] = Integer(uint.MinValue, uint.MaxValue); + } + if ((messageContentMask & JsonDataSetMessageContentMask.MetaDataVersion) != 0) + { + properties["MetaDataVersion"] = DefinitionObject(new JsonObject + { + ["MajorVersion"] = Integer(uint.MinValue, uint.MaxValue), + ["MinorVersion"] = Integer(uint.MinValue, uint.MaxValue) + }); + } + if ((messageContentMask & JsonDataSetMessageContentMask.Timestamp) != 0) + { + properties["Timestamp"] = DateTimeSchema(); + } + if ((messageContentMask & JsonDataSetMessageContentMask.Status) != 0) + { + properties["Status"] = Integer(uint.MinValue, uint.MaxValue); + } + if ((messageContentMask & JsonDataSetMessageContentMask.MinorVersion) != 0) + { + properties["MinorVersion"] = Integer(uint.MinValue, uint.MaxValue); + } + + return CreateObjectDocument( + documentId, + dataSetName + " DataSetMessage", + properties, + s_dataSetMessageRequired); + } + + private JsonObject CreatePayloadSchema( + DataSetMetaDataType metaData, + DataSetFieldContentMask fieldContentMask, + bool verbose) + { + JsonNode? node = JsonNode.Parse(CreateDataSetSchema(metaData, fieldContentMask, verbose).ToSchemaString()); + return node?.AsObject() ?? throw new InvalidOperationException("The generated DataSet schema is empty."); + } + + private JsonObject CreateComplexTypeSchema( + NodeId dataType, + UaSchemaFormat format, + JsonObject definitions) + { + if (dataType.IsNull) + { + return new JsonObject(); + } + + if (m_resolver is not null && m_resolver.TryResolve(dataType, out UaTypeDescription? description)) + { + return CreateTypeReference(description.TypeId, description.Name, format, definitions); + } + + return CreateTypeReference(new ExpandedNodeId(dataType), dataType.ToString(), format, definitions); + } + + private JsonObject CreateTypeReference( + ExpandedNodeId typeId, + string keyHint, + UaSchemaFormat format, + JsonObject definitions) + { + if (m_schemaProvider is null || typeId.IsNull) + { + return new JsonObject(); + } + + if (!m_schemaProvider.TryGetSchema(typeId, format, UaSchemaScope.Type, out IUaSchema? schema) + || schema is null) + { + return new JsonObject(); + } + + string key = DefinitionKey(keyHint); + if (!definitions.ContainsKey(key)) + { + definitions[key] = JsonNode.Parse(schema.ToSchemaString())?.AsObject() ?? new JsonObject(); + } + return Ref(key); + } + + private static JsonObject CreateDataValueSchema( + JsonObject valueSchema, + DataSetFieldContentMask fieldContentMask, + bool verbose) + { + var properties = new JsonObject + { + ["Value"] = valueSchema + }; + if ((fieldContentMask & DataSetFieldContentMask.StatusCode) != 0) + { + properties["StatusCode"] = CreateBuiltInSchema(BuiltInType.StatusCode, verbose, new JsonObject()); + } + if ((fieldContentMask & DataSetFieldContentMask.SourceTimestamp) != 0) + { + properties["SourceTimestamp"] = DateTimeSchema(); + } + if ((fieldContentMask & DataSetFieldContentMask.SourcePicoSeconds) != 0) + { + properties["SourcePicoseconds"] = Integer(ushort.MinValue, ushort.MaxValue); + } + if ((fieldContentMask & DataSetFieldContentMask.ServerTimestamp) != 0) + { + properties["ServerTimestamp"] = DateTimeSchema(); + } + if ((fieldContentMask & DataSetFieldContentMask.ServerPicoSeconds) != 0) + { + properties["ServerPicoseconds"] = Integer(ushort.MinValue, ushort.MaxValue); + } + + var required = new List { "Value" }; + return new JsonObject + { + ["type"] = "object", + ["properties"] = properties, + ["required"] = new JsonArray(required.ToArray()), + ["additionalProperties"] = false + }; + } + + private static BuiltInType GetBuiltInType(FieldMetaData field) + { + var builtInType = (BuiltInType)field.BuiltInType; + if (builtInType != BuiltInType.Null) + { + return builtInType; + } + return TypeInfo.GetBuiltInType(field.DataType); + } + + private static JsonObject CreateBuiltInSchema(BuiltInType type, bool verbose, JsonObject definitions) + { + switch (type) + { + case BuiltInType.Boolean: + return new JsonObject { ["type"] = "boolean" }; + case BuiltInType.SByte: + return Integer(sbyte.MinValue, sbyte.MaxValue); + case BuiltInType.Byte: + return Integer(byte.MinValue, byte.MaxValue); + case BuiltInType.Int16: + return Integer(short.MinValue, short.MaxValue); + case BuiltInType.UInt16: + return Integer(ushort.MinValue, ushort.MaxValue); + case BuiltInType.Int32: + return Integer(int.MinValue, int.MaxValue); + case BuiltInType.UInt32: + return Integer(uint.MinValue, uint.MaxValue); + case BuiltInType.Int64: + return IntegerString(signed: true); + case BuiltInType.UInt64: + return IntegerString(signed: false); + case BuiltInType.Float: + case BuiltInType.Double: + case BuiltInType.Number: + return TypeArray("number", "string"); + case BuiltInType.Integer: + case BuiltInType.UInteger: + return TypeArray("integer", "string"); + case BuiltInType.String: + return new JsonObject { ["type"] = "string" }; + case BuiltInType.DateTime: + return DateTimeSchema(); + case BuiltInType.Guid: + return new JsonObject { ["type"] = "string", ["format"] = "uuid" }; + case BuiltInType.ByteString: + return new JsonObject { ["type"] = "string", ["contentEncoding"] = "base64" }; + case BuiltInType.XmlElement: + return new JsonObject { ["type"] = "string" }; + case BuiltInType.Enumeration: + return new JsonObject { ["type"] = "integer" }; + case BuiltInType.StatusCode: + return verbose ? StatusCodeObject() : Integer(uint.MinValue, uint.MaxValue); + case BuiltInType.NodeId: + case BuiltInType.ExpandedNodeId: + case BuiltInType.QualifiedName: + case BuiltInType.LocalizedText: + case BuiltInType.Variant: + case BuiltInType.ExtensionObject: + case BuiltInType.DataValue: + case BuiltInType.DiagnosticInfo: + return CreateStandardReference(type, definitions); + default: + return new JsonObject(); + } + } + + private static JsonObject CreateStandardReference(BuiltInType type, JsonObject definitions) + { + string key = "Ua_" + type; + if (!definitions.ContainsKey(key)) + { + definitions[key] = type switch + { + BuiltInType.NodeId => StandardNodeId(), + BuiltInType.ExpandedNodeId => StandardExpandedNodeId(), + BuiltInType.QualifiedName => StandardQualifiedName(), + BuiltInType.LocalizedText => StandardLocalizedText(), + BuiltInType.StatusCode => StatusCodeObject(), + BuiltInType.Variant => new JsonObject { ["type"] = "object" }, + BuiltInType.ExtensionObject => new JsonObject { ["type"] = "object" }, + BuiltInType.DataValue => new JsonObject { ["type"] = "object" }, + BuiltInType.DiagnosticInfo => new JsonObject { ["type"] = "object" }, + _ => new JsonObject() + }; + } + return Ref(key); + } + + private static JsonObject ApplyValueRank(Func elementFactory, int valueRank) + { + switch (valueRank) + { + case ValueRanks.Scalar: + return elementFactory(); + case ValueRanks.Any: + case ValueRanks.ScalarOrOneDimension: + var options = new List + { + elementFactory(), + ArrayOf(elementFactory()) + }; + return new JsonObject + { + ["oneOf"] = new JsonArray(options.ToArray()) + }; + case ValueRanks.OneOrMoreDimensions: + return ArrayOf(elementFactory()); + default: + JsonObject node = elementFactory(); + for (int i = 0; i < valueRank; i++) + { + node = ArrayOf(node); + } + return node; + } + } + + private static JsonObject ArrayOf(JsonObject items) + { + return new JsonObject + { + ["type"] = "array", + ["items"] = items + }; + } + + private static JsonObject DateTimeSchema() + { + return new JsonObject { ["type"] = "string", ["format"] = "date-time" }; + } + + private static JsonObject Const(string value) + { + return new JsonObject { ["const"] = value }; + } + + private static JsonObject CreateMessagesSchema(JsonNetworkMessageContentMask networkContentMask) + { + if ((networkContentMask & JsonNetworkMessageContentMask.SingleDataSetMessage) != 0) + { + return new JsonObject + { + ["type"] = "object", + ["$ref"] = "#/$defs/DataSetMessage" + }; + } + + return ArrayOf(Ref("DataSetMessage")); + } + + private static JsonObject CreateObjectDocument( + string documentId, + string title, + JsonObject properties, + string[] required) + { + var requiredNodes = new List(required.Length); + foreach (string name in required) + { + requiredNodes.Add(name); + } + return new JsonObject + { + ["$schema"] = JsonSchemaDialect, + ["$id"] = documentId, + ["title"] = title, + ["type"] = "object", + ["properties"] = properties, + ["required"] = new JsonArray(requiredNodes.ToArray()), + ["additionalProperties"] = false + }; + } + + private static JsonObject DataSetMessageTypeSchema() + { + var values = new List + { + JsonDataSetMessageTypeKeyFrame, + JsonDataSetMessageTypeDeltaFrame + }; + return new JsonObject { ["enum"] = new JsonArray(values.ToArray()) }; + } + + private static JsonObject DefinitionObject(JsonObject properties, params string[] required) + { + var schema = new JsonObject + { + ["type"] = "object", + ["properties"] = properties, + ["additionalProperties"] = false + }; + if (required.Length > 0) + { + var requiredNodes = new List(required.Length); + foreach (string name in required) + { + requiredNodes.Add(name); + } + schema["required"] = new JsonArray(requiredNodes.ToArray()); + } + return schema; + } + + private static JsonObject Integer(long minimum, long maximum) + { + return new JsonObject + { + ["type"] = "integer", + ["minimum"] = minimum, + ["maximum"] = maximum + }; + } + + private static JsonObject IntegerString(bool signed) + { + return new JsonObject + { + ["type"] = "string", + ["pattern"] = signed ? "^-?\\d+$" : "^\\d+$" + }; + } + + private static bool IsRawDataMask(DataSetFieldContentMask fieldContentMask) + { + return fieldContentMask is DataSetFieldContentMask.None or DataSetFieldContentMask.RawData; + } + + private static JsonObject Ref(string defName) + { + return new JsonObject { ["$ref"] = "#/$defs/" + defName }; + } + + private static JsonObject PublisherIdSchema() + { + return new JsonObject { ["type"] = "string" }; + } + + private static JsonObject StandardExpandedNodeId() + { + return DefinitionObject(new JsonObject + { + ["IdType"] = Integer(byte.MinValue, 3), + ["Id"] = TypeArray("string", "integer"), + ["Namespace"] = TypeArray("string", "integer"), + ["ServerUri"] = TypeArray("string", "integer") + }, "Id"); + } + + private static JsonObject StandardLocalizedText() + { + return DefinitionObject(new JsonObject + { + ["Locale"] = new JsonObject { ["type"] = "string" }, + ["Text"] = new JsonObject { ["type"] = "string" } + }); + } + + private static JsonObject StandardNodeId() + { + return DefinitionObject(new JsonObject + { + ["IdType"] = Integer(byte.MinValue, 3), + ["Id"] = TypeArray("string", "integer"), + ["Namespace"] = TypeArray("string", "integer") + }, "Id"); + } + + private static JsonObject StandardQualifiedName() + { + return DefinitionObject(new JsonObject + { + ["Name"] = new JsonObject { ["type"] = "string" }, + ["Uri"] = TypeArray("string", "integer") + }, "Name"); + } + + private static JsonObject StatusCodeObject() + { + return DefinitionObject(new JsonObject + { + ["Code"] = Integer(uint.MinValue, uint.MaxValue), + ["Symbol"] = new JsonObject { ["type"] = "string" } + }); + } + + private static JsonObject TypeArray(string first, string second) + { + var types = new List { first, second }; + return new JsonObject { ["type"] = new JsonArray(types.ToArray()) }; + } + + private static string CreateDocumentId(string dataSetName) + { + return "urn:opcua:pubsub:dataset:" + Uri.EscapeDataString(dataSetName) + ".schema.json"; + } + + private static string CreateDocumentId(string kind, string dataSetName) + { + return "urn:opcua:pubsub:" + kind + ":" + Uri.EscapeDataString(dataSetName) + ".schema.json"; + } + + private static string DataSetName(DataSetMetaDataType metaData) + { + return string.IsNullOrEmpty(metaData.Name) ? DefaultDataSetName : metaData.Name!; + } + + private static string DefinitionKey(string keyHint) + { + if (string.IsNullOrEmpty(keyHint)) + { + return "Type"; + } + + char[] buffer = new char[keyHint.Length]; + int count = 0; + for (int i = 0; i < keyHint.Length; i++) + { + char c = keyHint[i]; + buffer[count++] = char.IsLetterOrDigit(c) ? c : '_'; + } + return new string(buffer, 0, count); + } + + private static string FieldName(FieldMetaData field, int index) + { + if (!string.IsNullOrEmpty(field.Name)) + { + return field.Name!; + } + return string.Format(CultureInfo.InvariantCulture, "Field{0}", index); + } + + private const string DefaultDataSetName = "DataSet"; + private const string JsonSchemaDialect = "https://json-schema.org/draft/2020-12/schema"; + private const string JsonDataSetMessageTypeDeltaFrame = "ua-deltaframe"; + private const string JsonDataSetMessageTypeKeyFrame = "ua-keyframe"; + private const string JsonNetworkMessageTypeData = "ua-data"; + private const string JsonNetworkMessageTypeMetaData = "ua-metadata"; + + private static readonly string[] s_dataSetMessageRequired = { "MessageType", "Payload" }; + private static readonly string[] s_metaDataMessageRequired = { "MessageType", "MetaData" }; + private static readonly string[] s_networkMessageRequired = { "MessageType", "Messages" }; + + private readonly ISchemaProvider? m_schemaProvider; + private readonly IDataTypeDefinitionResolver? m_resolver; + } +} diff --git a/Libraries/Opc.Ua.Server/Opc.Ua.Server.csproj b/Libraries/Opc.Ua.Server/Opc.Ua.Server.csproj index 8d93f5db33..0edf17dae0 100644 --- a/Libraries/Opc.Ua.Server/Opc.Ua.Server.csproj +++ b/Libraries/Opc.Ua.Server/Opc.Ua.Server.csproj @@ -32,6 +32,7 @@ + diff --git a/Libraries/Opc.Ua.Server/Schema/DataTypeSchemaRegistrationExtensions.cs b/Libraries/Opc.Ua.Server/Schema/DataTypeSchemaRegistrationExtensions.cs new file mode 100644 index 0000000000..987476afd2 --- /dev/null +++ b/Libraries/Opc.Ua.Server/Schema/DataTypeSchemaRegistrationExtensions.cs @@ -0,0 +1,233 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Schema; + +namespace Opc.Ua.Server +{ + /// + /// Extension methods that register server-side data type nodes with the + /// schema generation registry. + /// + public static class DataTypeSchemaRegistrationExtensions + { + /// + /// Registers all known data type nodes from a running server's type tree + /// into a schema generation registry. + /// + /// The server to inspect. + /// The registry to populate. + /// The cancellation token. + /// The number of data types that were registered. + /// A required argument is null. + public static async ValueTask RegisterDataTypeSchemasAsync( + this IServerInternal server, + DataTypeDefinitionRegistry registry, + CancellationToken cancellationToken = default) + { + if (server == null) + { + throw new ArgumentNullException(nameof(server)); + } + if (registry == null) + { + throw new ArgumentNullException(nameof(registry)); + } + + int count = 0; + var visited = new HashSet(); + var pending = new Stack(); + pending.Push(DataTypeIds.BaseDataType); + + while (pending.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + NodeId typeId = pending.Pop(); + + if (!visited.Add(typeId)) + { + continue; + } + + if (await RegisterDataTypeSchemaAsync(server, typeId, registry, cancellationToken) + .ConfigureAwait(false)) + { + count++; + } + + ArrayOf subtypes = server.TypeTree.FindSubTypes(typeId); + for (int ii = 0; ii < subtypes.Count; ii++) + { + pending.Push(subtypes[ii]); + } + } + + return count; + } + + /// + /// Registers all data type states in a server-side node collection into a + /// schema generation registry. + /// + /// The server-side nodes to inspect. + /// The registry to populate. + /// The namespace table used to resolve namespace URIs. + /// The number of data types that were registered. + /// A required argument is null. + public static int RegisterDataTypeSchemas( + this IEnumerable nodes, + DataTypeDefinitionRegistry registry, + NamespaceTable? namespaceUris = null) + { + if (nodes == null) + { + throw new ArgumentNullException(nameof(nodes)); + } + if (registry == null) + { + throw new ArgumentNullException(nameof(registry)); + } + + int count = 0; + foreach (NodeState node in nodes) + { + if (node is DataTypeState dataType && dataType.TryRegisterDataTypeSchema(registry, namespaceUris)) + { + count++; + } + } + + return count; + } + + /// + /// Registers a server-side data type state into a schema generation registry. + /// + /// The data type state to register. + /// The registry to populate. + /// The namespace table used to resolve the namespace URI. + /// true when the data type definition was registered; otherwise false. + /// A required argument is null. + public static bool TryRegisterDataTypeSchema( + this DataTypeState node, + DataTypeDefinitionRegistry registry, + NamespaceTable? namespaceUris = null) + { + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + if (registry == null) + { + throw new ArgumentNullException(nameof(registry)); + } + + return registry.TryAddDataType(ToDataTypeNode(node), namespaceUris); + } + + private static async ValueTask RegisterDataTypeSchemaAsync( + IServerInternal server, + NodeId typeId, + DataTypeDefinitionRegistry registry, + CancellationToken cancellationToken) + { + NodeState? state = await server.NodeManager + .FindNodeInAddressSpaceAsync(typeId, cancellationToken) + .ConfigureAwait(false); + + if (state is DataTypeState dataType) + { + return dataType.TryRegisterDataTypeSchema(registry, server.NamespaceUris); + } + + return await ReadAndRegisterDataTypeSchemaAsync(server, typeId, registry, cancellationToken) + .ConfigureAwait(false); + } + + private static async ValueTask ReadAndRegisterDataTypeSchemaAsync( + IServerInternal server, + NodeId typeId, + DataTypeDefinitionRegistry registry, + CancellationToken cancellationToken) + { + var context = new OperationContext(new RequestHeader(), null, RequestType.Read, RequestLifetime.None); + ArrayOf nodesToRead = + [ + new ReadValueId + { + NodeId = typeId, + AttributeId = Attributes.BrowseName + }, + new ReadValueId + { + NodeId = typeId, + AttributeId = Attributes.DataTypeDefinition + } + ]; + + (ArrayOf values, _) = await server.NodeManager + .ReadAsync(context, 0, TimestampsToReturn.Neither, nodesToRead, cancellationToken) + .ConfigureAwait(false); + + if (values.Count != nodesToRead.Count || + StatusCode.IsBad(values[0].StatusCode) || + StatusCode.IsBad(values[1].StatusCode) || + !values[0].WrappedValue.TryGetValue(out QualifiedName browseName) || + browseName.IsNull || + !values[1].WrappedValue.TryGetValue(out ExtensionObject dataTypeDefinition) || + dataTypeDefinition.IsNull) + { + return false; + } + + var node = new DataTypeNode + { + NodeId = typeId, + BrowseName = browseName, + DataTypeDefinition = dataTypeDefinition + }; + + return registry.TryAddDataType(node, server.NamespaceUris); + } + + private static DataTypeNode ToDataTypeNode(DataTypeState node) + { + return new DataTypeNode + { + NodeId = node.NodeId, + BrowseName = node.BrowseName, + DataTypeDefinition = node.DataTypeDefinition + }; + } + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Bsd/BinarySchemaDocument.cs b/Stack/Opc.Ua.Core.Schema/Bsd/BinarySchemaDocument.cs new file mode 100644 index 0000000000..b0d9c4c247 --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Bsd/BinarySchemaDocument.cs @@ -0,0 +1,358 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Globalization; +using System.IO; +using System.Xml; +using Opc.Ua.Schema.Binary; + +namespace Opc.Ua.Schema.Bsd +{ + /// + /// An OPC Binary schema document generated for an OPC UA data type or namespace. + /// + public sealed class BinarySchemaDocument : IUaSchema + { + /// + /// Initializes a new instance of the class. + /// + /// The target namespace of the dictionary. + /// The OPC Binary type dictionary object model. + public BinarySchemaDocument(string targetNamespace, TypeDictionary dictionary) + { + TargetNamespace = targetNamespace ?? throw new ArgumentNullException(nameof(targetNamespace)); + Dictionary = dictionary ?? throw new ArgumentNullException(nameof(dictionary)); + } + + /// + public UaSchemaFormat Format => UaSchemaFormat.Bsd; + + /// + public string MediaType => "application/xml"; + + /// + public string TargetNamespace { get; } + + /// + /// The OPC Binary type dictionary object model. + /// + public TypeDictionary Dictionary { get; } + + /// + public void WriteTo(Stream stream) + { + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + + using XmlWriter writer = XmlWriter.Create(stream, WriterSettings()); + WriteDictionary(writer); + } + + /// + public void WriteTo(TextWriter writer) + { + if (writer == null) + { + throw new ArgumentNullException(nameof(writer)); + } + + using XmlWriter xmlWriter = XmlWriter.Create(writer, WriterSettings()); + WriteDictionary(xmlWriter); + } + + /// + public string ToSchemaString() + { + using var writer = new StringWriter(CultureInfo.InvariantCulture); + WriteTo(writer); + return writer.ToString(); + } + + private static XmlWriterSettings WriterSettings() + { + return new XmlWriterSettings { Indent = true }; + } + + private void WriteDictionary(XmlWriter writer) + { + writer.WriteStartElement("opc", "TypeDictionary", OpcBinaryNamespace); + writer.WriteAttributeString("xmlns", "xsi", null, XmlSchemaInstanceNamespace); + writer.WriteAttributeString("xmlns", "ua", null, UaTypesNamespace); + writer.WriteAttributeString("xmlns", "tns", null, TargetNamespace); + WriteImportedNamespaceDeclarations(writer); + if (Dictionary.DefaultByteOrderSpecified) + { + writer.WriteAttributeString("DefaultByteOrder", Dictionary.DefaultByteOrder.ToString()); + } + writer.WriteAttributeString("TargetNamespace", TargetNamespace); + + if (Dictionary.Import != null) + { + foreach (ImportDirective import in Dictionary.Import) + { + WriteImport(writer, import); + } + } + + if (Dictionary.Items != null) + { + foreach (TypeDescription item in Dictionary.Items) + { + WriteTypeDescription(writer, item); + } + } + + writer.WriteEndElement(); + } + + private static void WriteImport(XmlWriter writer, ImportDirective import) + { + writer.WriteStartElement("opc", "Import", OpcBinaryNamespace); + if (!string.IsNullOrEmpty(import.Namespace)) + { + writer.WriteAttributeString("Namespace", import.Namespace); + } + if (!string.IsNullOrEmpty(import.Location)) + { + writer.WriteAttributeString("Location", import.Location); + } + writer.WriteEndElement(); + } + + private void WriteTypeDescription(XmlWriter writer, TypeDescription item) + { + switch (item) + { + case StructuredType structuredType: + WriteStructuredType(writer, structuredType); + break; + case EnumeratedType enumeratedType: + WriteEnumeratedType(writer, enumeratedType); + break; + case OpaqueType opaqueType: + WriteOpaqueType(writer, opaqueType); + break; + } + } + + private void WriteStructuredType(XmlWriter writer, StructuredType structuredType) + { + writer.WriteStartElement("opc", "StructuredType", OpcBinaryNamespace); + writer.WriteAttributeString("Name", structuredType.Name); + WriteDocumentation(writer, structuredType.Documentation); + if (structuredType.Field != null) + { + foreach (FieldType field in structuredType.Field) + { + WriteField(writer, field); + } + } + writer.WriteEndElement(); + } + + private void WriteEnumeratedType(XmlWriter writer, EnumeratedType enumeratedType) + { + writer.WriteStartElement("opc", "EnumeratedType", OpcBinaryNamespace); + writer.WriteAttributeString("Name", enumeratedType.Name); + if (enumeratedType.LengthInBitsSpecified) + { + writer.WriteAttributeString( + "LengthInBits", + XmlConvert.ToString(enumeratedType.LengthInBits)); + } + WriteDocumentation(writer, enumeratedType.Documentation); + if (enumeratedType.EnumeratedValue != null) + { + foreach (EnumeratedValue value in enumeratedType.EnumeratedValue) + { + WriteEnumeratedValue(writer, value); + } + } + writer.WriteEndElement(); + } + + private void WriteOpaqueType(XmlWriter writer, OpaqueType opaqueType) + { + writer.WriteStartElement("opc", "OpaqueType", OpcBinaryNamespace); + writer.WriteAttributeString("Name", opaqueType.Name); + if (opaqueType.LengthInBitsSpecified) + { + writer.WriteAttributeString("LengthInBits", XmlConvert.ToString(opaqueType.LengthInBits)); + } + WriteDocumentation(writer, opaqueType.Documentation); + writer.WriteEndElement(); + } + + private void WriteField(XmlWriter writer, FieldType field) + { + writer.WriteStartElement("opc", "Field", OpcBinaryNamespace); + writer.WriteAttributeString("Name", field.Name); + if (field.TypeName != null) + { + writer.WriteAttributeString("TypeName", QualifiedName(field.TypeName)); + } + if (field.LengthSpecified) + { + writer.WriteAttributeString("Length", XmlConvert.ToString(field.Length)); + } + if (!string.IsNullOrEmpty(field.LengthField)) + { + writer.WriteAttributeString("LengthField", field.LengthField); + } + if (field.IsLengthInBytes) + { + writer.WriteAttributeString("IsLengthInBytes", "true"); + } + if (!string.IsNullOrEmpty(field.SwitchField)) + { + writer.WriteAttributeString("SwitchField", field.SwitchField); + } + if (field.SwitchValueSpecified) + { + writer.WriteAttributeString("SwitchValue", XmlConvert.ToString(field.SwitchValue)); + } + if (field.SwitchOperandSpecified) + { + writer.WriteAttributeString("SwitchOperand", field.SwitchOperand.ToString()); + } + WriteDocumentation(writer, field.Documentation); + writer.WriteEndElement(); + } + + private static void WriteEnumeratedValue(XmlWriter writer, EnumeratedValue value) + { + writer.WriteStartElement("opc", "EnumeratedValue", OpcBinaryNamespace); + writer.WriteAttributeString("Name", value.Name); + if (value.ValueSpecified) + { + writer.WriteAttributeString("Value", XmlConvert.ToString(value.Value)); + } + WriteDocumentation(writer, value.Documentation); + writer.WriteEndElement(); + } + + private static void WriteDocumentation(XmlWriter writer, Documentation? documentation) + { + if (documentation?.Text == null || documentation.Text.Length == 0) + { + return; + } + + writer.WriteStartElement("opc", "Documentation", OpcBinaryNamespace); + for (int i = 0; i < documentation.Text.Length; i++) + { + writer.WriteString(documentation.Text[i]); + } + writer.WriteEndElement(); + } + + private string QualifiedName(XmlQualifiedName name) + { + if (name.Namespace == OpcBinaryNamespace) + { + return "opc:" + name.Name; + } + if (name.Namespace == UaTypesNamespace) + { + return "ua:" + name.Name; + } + if (name.Namespace == TargetNamespace) + { + return "tns:" + name.Name; + } + + string prefix = PrefixForNamespace(name.Namespace); + if (!string.IsNullOrEmpty(prefix)) + { + return prefix + ":" + name.Name; + } + + return name.Name; + } + + private void WriteImportedNamespaceDeclarations(XmlWriter writer) + { + if (Dictionary.Import == null) + { + return; + } + + int prefixIndex = 1; + for (int i = 0; i < Dictionary.Import.Length; i++) + { + string? namespaceUri = Dictionary.Import[i].Namespace; + if (string.IsNullOrEmpty(namespaceUri) || + namespaceUri == UaTypesNamespace || + namespaceUri == TargetNamespace) + { + continue; + } + + writer.WriteAttributeString("xmlns", "n" + prefixIndex, null, namespaceUri); + prefixIndex++; + } + } + + private string PrefixForNamespace(string namespaceUri) + { + if (Dictionary.Import == null) + { + return string.Empty; + } + + int prefixIndex = 1; + for (int i = 0; i < Dictionary.Import.Length; i++) + { + string? importNamespace = Dictionary.Import[i].Namespace; + if (string.IsNullOrEmpty(importNamespace) || + importNamespace == UaTypesNamespace || + importNamespace == TargetNamespace) + { + continue; + } + + if (importNamespace == namespaceUri) + { + return "n" + prefixIndex; + } + + prefixIndex++; + } + + return string.Empty; + } + + private const string OpcBinaryNamespace = "http://opcfoundation.org/BinarySchema/"; + private const string UaTypesNamespace = "http://opcfoundation.org/UA/"; + private const string XmlSchemaInstanceNamespace = "http://www.w3.org/2001/XMLSchema-instance"; + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Bsd/BsdSchemaGenerator.cs b/Stack/Opc.Ua.Core.Schema/Bsd/BsdSchemaGenerator.cs new file mode 100644 index 0000000000..08b52ad79c --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Bsd/BsdSchemaGenerator.cs @@ -0,0 +1,424 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Xml; +using Opc.Ua.Schema.Binary; + +namespace Opc.Ua.Schema.Bsd +{ + /// + /// Generates OPC Binary schema (BSD) documents for OPC UA data types + /// according to the OPC UA Part 6 binary encoding. The schema is built using + /// the existing object model and is + /// serialized with a direct XML writer to remain trimming and NativeAOT + /// compatible. + /// + internal sealed class BsdSchemaGenerator : IUaSchemaGenerator + { + /// + public bool CanGenerate(UaSchemaFormat format) + { + return format == UaSchemaFormat.Bsd; + } + + /// + public IUaSchema Generate( + UaTypeDescription type, + IDataTypeDefinitionResolver resolver, + UaSchemaFormat format, + UaSchemaScope scope) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + if (resolver == null) + { + throw new ArgumentNullException(nameof(resolver)); + } + + var context = new GenerationContext(type.NamespaceUri, resolver); + if (scope == UaSchemaScope.Namespace) + { + foreach (UaTypeDescription namespaceType in resolver.GetNamespaceTypes(type.NamespaceUri)) + { + context.EnsureType(namespaceType); + } + } + + context.EnsureType(type); + return new BinarySchemaDocument(type.NamespaceUri, context.Dictionary); + } + + private sealed class GenerationContext + { + public GenerationContext(string targetNamespace, IDataTypeDefinitionResolver resolver) + { + m_resolver = resolver; + m_targetNamespace = targetNamespace; + m_items = []; + m_emittedTypes = new HashSet(StringComparer.Ordinal); + m_visitingTypes = new HashSet(StringComparer.Ordinal); + m_importedNamespaces = new HashSet(StringComparer.Ordinal); + + Dictionary = new TypeDictionary + { + TargetNamespace = targetNamespace, + DefaultByteOrder = ByteOrder.LittleEndian, + DefaultByteOrderSpecified = true, + Import = + [ + new ImportDirective { Namespace = UaTypesNamespace } + ] + }; + } + + public TypeDictionary Dictionary { get; } + + public void EnsureType(UaTypeDescription type) + { + string typeKey = TypeKey(type); + if (m_emittedTypes.Contains(typeKey) || m_visitingTypes.Contains(typeKey)) + { + return; + } + + m_visitingTypes.Add(typeKey); + TypeDescription? description = type.Definition switch + { + StructureDefinition structure => BuildStructure(type, structure), + EnumDefinition enumeration => BuildEnum(type, enumeration), + _ => null + }; + m_visitingTypes.Remove(typeKey); + + if (description != null) + { + m_items.Add(description); + Dictionary.Items = [.. m_items]; + m_emittedTypes.Add(typeKey); + } + } + + private StructuredType BuildStructure(UaTypeDescription type, StructureDefinition structure) + { + bool isUnion = structure.StructureType + is StructureType.Union or StructureType.UnionWithSubtypedValues; + var fields = new List(); + ArrayOf structureFields = structure.Fields; + + if (isUnion) + { + fields.Add(new FieldType + { + Name = "SwitchField", + TypeName = Opc("UInt32") + }); + } + else + { + AddOptionalEncodingMask(fields, structureFields); + } + + for (int i = 0; i < structureFields.Count; i++) + { + AddField(fields, structureFields[i], i, isUnion); + } + + return new StructuredType + { + Name = type.Name, + Field = [.. fields] + }; + } + + private static void AddOptionalEncodingMask( + List fields, + ArrayOf structureFields) + { + int optionalCount = 0; + for (int i = 0; i < structureFields.Count; i++) + { + if (structureFields[i].IsOptional) + { + optionalCount++; + } + } + if (optionalCount == 0) + { + return; + } + + // The binary encoding prefixes optional-field structures with a + // 32-bit EncodingMask: one presence bit per optional field (in + // field order) followed by a reserved bit-field that pads the + // mask to 32 bits. The optional data fields reference their + // presence bit through SwitchField. + for (int i = 0; i < structureFields.Count; i++) + { + StructureField field = structureFields[i]; + if (field.IsOptional) + { + fields.Add(new FieldType + { + Name = FieldName(field, i) + "Specified", + TypeName = Opc("Bit") + }); + } + } + + int reservedBits = EncodingMaskBits - optionalCount; + if (reservedBits > 0) + { + fields.Add(new FieldType + { + Name = "Reserved1", + TypeName = Opc("Bit"), + Length = (uint)reservedBits, + LengthSpecified = true + }); + } + } + + private EnumeratedType BuildEnum(UaTypeDescription type, EnumDefinition enumeration) + { + ArrayOf fields = enumeration.Fields; + var values = new EnumeratedValue[fields.Count]; + for (int i = 0; i < fields.Count; i++) + { + EnumField field = fields[i]; + values[i] = new EnumeratedValue + { + Name = EnumName(field, i), + Value = checked((int)field.Value), + ValueSpecified = true + }; + } + + return new EnumeratedType + { + Name = type.Name, + LengthInBits = 32, + LengthInBitsSpecified = true, + EnumeratedValue = values + }; + } + + private void AddField(List fields, StructureField field, int index, bool isUnion) + { + string name = FieldName(field, index); + XmlQualifiedName typeName = ResolveType(field.DataType); + string? switchField = null; + uint switchValue = 0; + bool switchValueSpecified = false; + + if (isUnion) + { + switchField = "SwitchField"; + switchValue = checked((uint)(index + 1)); + switchValueSpecified = true; + } + else if (field.IsOptional) + { + switchField = name + "Specified"; + } + + if (field.ValueRank == ValueRanks.Scalar) + { + fields.Add(new FieldType + { + Name = name, + TypeName = typeName, + SwitchField = switchField, + SwitchValue = switchValue, + SwitchValueSpecified = switchValueSpecified + }); + return; + } + + string lengthField = "NoOf" + name; + fields.Add(new FieldType + { + Name = lengthField, + TypeName = Opc("Int32"), + SwitchField = switchField, + SwitchValue = switchValue, + SwitchValueSpecified = switchValueSpecified + }); + fields.Add(new FieldType + { + Name = name, + TypeName = typeName, + LengthField = lengthField, + SwitchField = switchField, + SwitchValue = switchValue, + SwitchValueSpecified = switchValueSpecified + }); + } + + private XmlQualifiedName ResolveType(NodeId dataType) + { + BuiltInType builtInType = TypeInfo.GetBuiltInType(dataType); + if (builtInType != BuiltInType.Null) + { + return BuiltInTypeName(builtInType); + } + + if (m_resolver.TryResolve(dataType, out UaTypeDescription? referenced)) + { + if (string.Equals(referenced.NamespaceUri, m_targetNamespace, StringComparison.Ordinal)) + { + EnsureType(referenced); + return Tns(referenced.Name); + } + + AddNamespaceImport(referenced.NamespaceUri); + return new XmlQualifiedName(referenced.Name, referenced.NamespaceUri); + } + + return Ua("ExtensionObject"); + } + + private XmlQualifiedName Tns(string name) + { + return new XmlQualifiedName(name, m_targetNamespace); + } + + private static XmlQualifiedName BuiltInTypeName(BuiltInType builtInType) + { + switch (builtInType) + { + case BuiltInType.Boolean: + return Opc("Boolean"); + case BuiltInType.SByte: + return Opc("SByte"); + case BuiltInType.Byte: + return Opc("Byte"); + case BuiltInType.Int16: + return Opc("Int16"); + case BuiltInType.UInt16: + return Opc("UInt16"); + case BuiltInType.Int32: + case BuiltInType.Enumeration: + return Opc("Int32"); + case BuiltInType.UInt32: + return Opc("UInt32"); + case BuiltInType.Int64: + return Opc("Int64"); + case BuiltInType.UInt64: + return Opc("UInt64"); + case BuiltInType.Float: + return Opc("Float"); + case BuiltInType.Double: + return Opc("Double"); + case BuiltInType.String: + return Opc("CharArray"); + case BuiltInType.DateTime: + return Opc("DateTime"); + case BuiltInType.Guid: + return Opc("Guid"); + case BuiltInType.ByteString: + return Opc("ByteString"); + case BuiltInType.XmlElement: + return Ua("XmlElement"); + case BuiltInType.NodeId: + return Ua("NodeId"); + case BuiltInType.ExpandedNodeId: + return Ua("ExpandedNodeId"); + case BuiltInType.StatusCode: + return Ua("StatusCode"); + case BuiltInType.QualifiedName: + return Ua("QualifiedName"); + case BuiltInType.LocalizedText: + return Ua("LocalizedText"); + case BuiltInType.ExtensionObject: + return Ua("ExtensionObject"); + case BuiltInType.DataValue: + return Ua("DataValue"); + case BuiltInType.Variant: + return Ua("Variant"); + case BuiltInType.DiagnosticInfo: + return Ua("DiagnosticInfo"); + default: + return Ua(builtInType.ToString()); + } + } + + private static XmlQualifiedName Opc(string name) + { + return new XmlQualifiedName(name, OpcBinaryNamespace); + } + + private static XmlQualifiedName Ua(string name) + { + return new XmlQualifiedName(name, UaTypesNamespace); + } + + private static string FieldName(StructureField field, int index) + { + return string.IsNullOrEmpty(field.Name) ? "Field" + index : field.Name!; + } + + private static string EnumName(EnumField field, int index) + { + return string.IsNullOrEmpty(field.Name) ? "Value" + index : field.Name!; + } + + private void AddNamespaceImport(string namespaceUri) + { + if (string.IsNullOrEmpty(namespaceUri) || m_importedNamespaces.Contains(namespaceUri)) + { + return; + } + + m_importedNamespaces.Add(namespaceUri); + ImportDirective[] imports = Dictionary.Import ?? []; + Dictionary.Import = [.. imports, new ImportDirective { Namespace = namespaceUri }]; + } + + private static string TypeKey(UaTypeDescription type) + { + return type.NamespaceUri + "|" + type.Name; + } + + private const string OpcBinaryNamespace = "http://opcfoundation.org/BinarySchema/"; + private const string UaTypesNamespace = "http://opcfoundation.org/UA/"; + private const int EncodingMaskBits = 32; + + private readonly IDataTypeDefinitionResolver m_resolver; + private readonly string m_targetNamespace; + private readonly List m_items; + private readonly HashSet m_emittedTypes; + private readonly HashSet m_visitingTypes; + private readonly HashSet m_importedNamespaces; + } + } +} diff --git a/Stack/Opc.Ua.Core.Schema/DefaultSchemaProvider.cs b/Stack/Opc.Ua.Core.Schema/DefaultSchemaProvider.cs new file mode 100644 index 0000000000..5b4fb9497c --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/DefaultSchemaProvider.cs @@ -0,0 +1,104 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Opc.Ua.Schema +{ + /// + /// The default . It dispatches schema + /// generation to the registered instances + /// based on the requested . + /// + public sealed class DefaultSchemaProvider : ISchemaProvider + { + /// + /// Initializes a new instance of the class. + /// + /// The data type definition resolver. + /// The registered schema generators. + /// A required argument is null. + public DefaultSchemaProvider( + IDataTypeDefinitionResolver resolver, + IEnumerable generators) + { + m_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + if (generators == null) + { + throw new ArgumentNullException(nameof(generators)); + } + m_generators = [.. generators]; + } + + /// + public IUaSchema CreateSchema( + UaTypeDescription type, + UaSchemaFormat format, + UaSchemaScope scope = UaSchemaScope.Type) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + for (int i = 0; i < m_generators.Count; i++) + { + IUaSchemaGenerator generator = m_generators[i]; + if (generator.CanGenerate(format)) + { + return generator.Generate(type, m_resolver, format, scope); + } + } + + throw new NotSupportedException( + $"No schema generator is registered for the format '{format}'."); + } + + /// + public bool TryGetSchema( + ExpandedNodeId typeId, + UaSchemaFormat format, + UaSchemaScope scope, + [NotNullWhen(true)] out IUaSchema? schema) + { + if (m_resolver.TryResolve(typeId, out UaTypeDescription? type)) + { + schema = CreateSchema(type, format, scope); + return true; + } + schema = null; + return false; + } + + private readonly IDataTypeDefinitionResolver m_resolver; + private readonly List m_generators; + } +} diff --git a/Stack/Opc.Ua.Core.Schema/ISchemaProvider.cs b/Stack/Opc.Ua.Core.Schema/ISchemaProvider.cs new file mode 100644 index 0000000000..717ffca479 --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/ISchemaProvider.cs @@ -0,0 +1,67 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Diagnostics.CodeAnalysis; + +namespace Opc.Ua.Schema +{ + /// + /// Produces schemas for OPC UA data types in the supported encodings + /// (XSD, OPC Binary and JSON Schema). Resolve the provider from dependency + /// injection or construct it directly. + /// + public interface ISchemaProvider + { + /// + /// Creates a schema for the supplied data type description. + /// + /// The data type to generate a schema for. + /// The schema format to generate. + /// The scope of the generated schema. + /// The generated schema. + IUaSchema CreateSchema( + UaTypeDescription type, + UaSchemaFormat format, + UaSchemaScope scope = UaSchemaScope.Type); + + /// + /// Resolves the supplied data type id and creates a schema for it. + /// + /// The data type id. + /// The schema format to generate. + /// The scope of the generated schema. + /// The generated schema. + /// true when the type was resolved and a schema produced. + bool TryGetSchema( + ExpandedNodeId typeId, + UaSchemaFormat format, + UaSchemaScope scope, + [NotNullWhen(true)] out IUaSchema? schema); + } +} diff --git a/Stack/Opc.Ua.Core.Schema/IUaSchema.cs b/Stack/Opc.Ua.Core.Schema/IUaSchema.cs new file mode 100644 index 0000000000..ffaf94d385 --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/IUaSchema.cs @@ -0,0 +1,75 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.IO; + +namespace Opc.Ua.Schema +{ + /// + /// A generated schema document. Concrete implementations expose the + /// underlying strongly-typed schema object model (for example an + /// or a JSON Schema document) + /// and can serialize the schema to text or a stream. + /// + public interface IUaSchema + { + /// + /// The format (encoding) the schema describes. + /// + UaSchemaFormat Format { get; } + + /// + /// The IANA media type of the serialized schema. + /// + string MediaType { get; } + + /// + /// The target namespace (or document identifier) of the schema. + /// + string TargetNamespace { get; } + + /// + /// Serializes the schema to the supplied stream. + /// + /// The stream to write the schema to. + void WriteTo(Stream stream); + + /// + /// Serializes the schema to the supplied text writer. + /// + /// The text writer to write the schema to. + void WriteTo(TextWriter writer); + + /// + /// Serializes the schema to a string. + /// + /// The serialized schema. + string ToSchemaString(); + } +} diff --git a/Stack/Opc.Ua.Core.Schema/IUaSchemaGenerator.cs b/Stack/Opc.Ua.Core.Schema/IUaSchemaGenerator.cs new file mode 100644 index 0000000000..6359c01d44 --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/IUaSchemaGenerator.cs @@ -0,0 +1,61 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +namespace Opc.Ua.Schema +{ + /// + /// A generator that produces a schema for a single supported + /// . Implementations are registered with the + /// dependency injection container and selected by the + /// based on the requested format. + /// + public interface IUaSchemaGenerator + { + /// + /// Returns whether the generator supports the requested format. + /// + /// The requested schema format. + /// true when the format is supported. + bool CanGenerate(UaSchemaFormat format); + + /// + /// Generates the schema for the supplied data type description. + /// + /// The data type to generate a schema for. + /// The resolver used to look up referenced types. + /// The schema format to generate. + /// The scope of the generated schema. + /// The generated schema. + IUaSchema Generate( + UaTypeDescription type, + IDataTypeDefinitionResolver resolver, + UaSchemaFormat format, + UaSchemaScope scope); + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Json/JsonBuiltInTypeSchemas.cs b/Stack/Opc.Ua.Core.Schema/Json/JsonBuiltInTypeSchemas.cs new file mode 100644 index 0000000000..4c90bca66e --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Json/JsonBuiltInTypeSchemas.cs @@ -0,0 +1,144 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Text.Json.Nodes; + +namespace Opc.Ua.Schema.Json +{ + /// + /// Maps OPC UA built-in types to JSON Schema fragments according to the + /// OPC UA Part 6 JSON encoding. Integer-keyed primitives are inlined; the + /// complex standard types (NodeId, Variant, ...) are emitted once into the + /// document $defs section and referenced. + /// + internal static class JsonBuiltInTypeSchemas + { + /// + /// Creates a JSON Schema fragment for the supplied scalar built-in type. + /// + /// The built-in type. + /// Whether the verbose flavor is requested. + /// The document definitions section to populate with + /// standard type definitions when referenced. + /// The JSON Schema fragment for the type. + public static JsonObject Create(BuiltInType type, bool verbose, JsonObject defs) + { + switch (type) + { + case BuiltInType.Boolean: + return new JsonObject { ["type"] = "boolean" }; + case BuiltInType.SByte: + return Integer(sbyte.MinValue, sbyte.MaxValue); + case BuiltInType.Byte: + return Integer(byte.MinValue, byte.MaxValue); + case BuiltInType.Int16: + return Integer(short.MinValue, short.MaxValue); + case BuiltInType.UInt16: + return Integer(ushort.MinValue, ushort.MaxValue); + case BuiltInType.Int32: + return Integer(int.MinValue, int.MaxValue); + case BuiltInType.UInt32: + return Integer(uint.MinValue, uint.MaxValue); + case BuiltInType.Int64: + // Int64 is encoded as a JSON string to avoid precision loss. + return IntegerString(signed: true); + case BuiltInType.UInt64: + return IntegerString(signed: false); + case BuiltInType.Float: + case BuiltInType.Double: + case BuiltInType.Number: + // Special values (NaN, Infinity) are encoded as JSON strings. + return new JsonObject { ["type"] = new JsonArray("number", "string") }; + case BuiltInType.Integer: + case BuiltInType.UInteger: + return new JsonObject { ["type"] = new JsonArray("integer", "string") }; + case BuiltInType.String: + return new JsonObject { ["type"] = "string" }; + case BuiltInType.DateTime: + return new JsonObject { ["type"] = "string", ["format"] = "date-time" }; + case BuiltInType.Guid: + return new JsonObject { ["type"] = "string", ["format"] = "uuid" }; + case BuiltInType.ByteString: + return new JsonObject { ["type"] = "string", ["contentEncoding"] = "base64" }; + case BuiltInType.XmlElement: + return new JsonObject { ["type"] = "string" }; + case BuiltInType.Enumeration: + return new JsonObject { ["type"] = "integer" }; + case BuiltInType.StatusCode: + return verbose + ? StandardRef(BuiltInType.StatusCode, defs) + : Integer(uint.MinValue, uint.MaxValue); + case BuiltInType.LocalizedText: + return verbose + ? new JsonObject { ["type"] = "string" } + : StandardRef(BuiltInType.LocalizedText, defs); + case BuiltInType.NodeId: + case BuiltInType.ExpandedNodeId: + case BuiltInType.QualifiedName: + case BuiltInType.Variant: + case BuiltInType.ExtensionObject: + case BuiltInType.DataValue: + case BuiltInType.DiagnosticInfo: + return StandardRef(type, defs); + default: + // Unknown or abstract: allow any value. + return new JsonObject(); + } + } + + private static JsonObject Integer(long minimum, long maximum) + { + return new JsonObject + { + ["type"] = "integer", + ["minimum"] = minimum, + ["maximum"] = maximum + }; + } + + private static JsonObject IntegerString(bool signed) + { + return new JsonObject + { + ["type"] = "string", + ["pattern"] = signed ? "^-?\\d+$" : "^\\d+$" + }; + } + + private static JsonObject StandardRef(BuiltInType type, JsonObject defs) + { + string key = JsonSchemaConstants.StandardDefPrefix + type; + if (!defs.ContainsKey(key)) + { + defs[key] = StandardJsonDefinitions.Create(type); + } + return JsonSchemaConstants.Ref(key); + } + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Json/JsonSchemaConstants.cs b/Stack/Opc.Ua.Core.Schema/Json/JsonSchemaConstants.cs new file mode 100644 index 0000000000..ec2eaf149b --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Json/JsonSchemaConstants.cs @@ -0,0 +1,62 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Text.Json.Nodes; + +namespace Opc.Ua.Schema.Json +{ + /// + /// Constants and small helpers for building OPC UA JSON Schema documents + /// according to OPC UA Part 6 (JSON encoding, Annex C). + /// + internal static class JsonSchemaConstants + { + /// + /// The JSON Schema dialect used for all generated documents. + /// + public const string Dialect = "https://json-schema.org/draft/2020-12/schema"; + + /// + /// The prefix used for the keys of the standard OPC UA built-in object + /// types that are added to the document $defs section. + /// + public const string StandardDefPrefix = "Ua_"; + + /// + /// Returns a JSON Schema reference to a definition in the current + /// document $defs section. + /// + /// The name of the definition. + /// A $ref schema object. + public static JsonObject Ref(string defName) + { + return new JsonObject { ["$ref"] = "#/$defs/" + defName }; + } + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Json/JsonSchemaDocument.cs b/Stack/Opc.Ua.Core.Schema/Json/JsonSchemaDocument.cs new file mode 100644 index 0000000000..601e6b7654 --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Json/JsonSchemaDocument.cs @@ -0,0 +1,108 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Opc.Ua.Schema.Json +{ + /// + /// A JSON Schema (draft 2020-12) document generated for an OPC UA data type + /// or namespace. The underlying object model is exposed through + /// and is built with + /// so that no reflection is required to construct or serialize the schema. + /// + public sealed class JsonSchemaDocument : IUaSchema + { + /// + /// Initializes a new instance of the class. + /// + /// The JSON schema format flavor. + /// The document namespace or identifier. + /// The root JSON Schema object. + /// A required argument is null. + public JsonSchemaDocument( + UaSchemaFormat format, + string targetNamespace, + JsonObject root) + { + Format = format; + TargetNamespace = targetNamespace ?? throw new ArgumentNullException(nameof(targetNamespace)); + Root = root ?? throw new ArgumentNullException(nameof(root)); + } + + /// + public UaSchemaFormat Format { get; } + + /// + public string MediaType => "application/schema+json"; + + /// + public string TargetNamespace { get; } + + /// + /// The root JSON Schema object model. + /// + public JsonObject Root { get; } + + /// + public void WriteTo(Stream stream) + { + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + Root.WriteTo(writer); + writer.Flush(); + } + + /// + public void WriteTo(TextWriter writer) + { + if (writer == null) + { + throw new ArgumentNullException(nameof(writer)); + } + + writer.Write(ToSchemaString()); + } + + /// + public string ToSchemaString() + { + return Root.ToJsonString(s_options); + } + + private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Json/JsonSchemaGenerator.cs b/Stack/Opc.Ua.Core.Schema/Json/JsonSchemaGenerator.cs new file mode 100644 index 0000000000..53cbda669c --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Json/JsonSchemaGenerator.cs @@ -0,0 +1,408 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.Json.Nodes; + +namespace Opc.Ua.Schema.Json +{ + /// + /// Generates JSON Schema (draft 2020-12) documents for OPC UA data types + /// according to the OPC UA Part 6 JSON encoding (Annex C) in both the + /// compact (reversible) and verbose flavors. The schema is constructed as a + /// object model so that no + /// reflection is required and the generator is NativeAOT compatible. + /// + internal sealed class JsonSchemaGenerator : IUaSchemaGenerator + { + /// + public bool CanGenerate(UaSchemaFormat format) + { + return format is UaSchemaFormat.JsonCompact or UaSchemaFormat.JsonVerbose; + } + + /// + public IUaSchema Generate( + UaTypeDescription type, + IDataTypeDefinitionResolver resolver, + UaSchemaFormat format, + UaSchemaScope scope) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + if (resolver == null) + { + throw new ArgumentNullException(nameof(resolver)); + } + + bool verbose = format == UaSchemaFormat.JsonVerbose; + var context = new GenerationContext(type.NamespaceUri, resolver, verbose); + + if (scope == UaSchemaScope.Namespace) + { + foreach (UaTypeDescription namespaceType in resolver.GetNamespaceTypes(type.NamespaceUri)) + { + context.EnsureType(namespaceType); + } + context.EnsureType(type); + + var namespaceDocument = new JsonObject + { + ["$schema"] = JsonSchemaConstants.Dialect, + ["$id"] = DocumentId(type.NamespaceUri), + ["$defs"] = context.Definitions + }; + return new JsonSchemaDocument(format, type.NamespaceUri, namespaceDocument); + } + + string rootKey = context.EnsureType(type); + var document = new JsonObject + { + ["$schema"] = JsonSchemaConstants.Dialect, + ["$id"] = TypeDocumentId(type), + ["title"] = type.Name, + ["$ref"] = "#/$defs/" + rootKey + }; + if (context.Definitions.Count > 0) + { + document["$defs"] = context.Definitions; + } + return new JsonSchemaDocument(format, type.NamespaceUri, document); + } + + private static string TypeDocumentId(UaTypeDescription type) + { + string ns = string.IsNullOrEmpty(type.NamespaceUri) ? DefaultNamespace : type.NamespaceUri; + return ns.TrimEnd('/') + "/" + type.Name + ".schema.json"; + } + + private static string DocumentId(string namespaceUri) + { + string ns = string.IsNullOrEmpty(namespaceUri) ? DefaultNamespace : namespaceUri; + return ns.TrimEnd('/') + "/types.schema.json"; + } + + private const string DefaultNamespace = "urn:opcua:types"; + + /// + /// Holds the per-document state during schema generation. + /// + private sealed class GenerationContext + { + public GenerationContext(string targetNamespace, IDataTypeDefinitionResolver resolver, bool verbose) + { + m_targetNamespace = targetNamespace; + m_resolver = resolver; + m_verbose = verbose; + Definitions = []; + m_visiting = new HashSet(StringComparer.Ordinal); + m_emittedTypes = new HashSet(StringComparer.Ordinal); + } + + public JsonObject Definitions { get; } + + public string EnsureType(UaTypeDescription type) + { + string typeKey = TypeKey(type); + string definitionKey = DefinitionKey(type); + if (m_emittedTypes.Contains(typeKey) || m_visiting.Contains(typeKey)) + { + return definitionKey; + } + + m_visiting.Add(typeKey); + JsonObject schema = type.Definition switch + { + StructureDefinition structure => BuildStructure(type, structure), + EnumDefinition enumeration => BuildEnum(enumeration), + _ => new JsonObject { ["type"] = "object" } + }; + m_visiting.Remove(typeKey); + Definitions[definitionKey] = schema; + m_emittedTypes.Add(typeKey); + return definitionKey; + } + + private JsonObject BuildStructure(UaTypeDescription type, StructureDefinition structure) + { + bool isUnion = structure.StructureType + is StructureType.Union or StructureType.UnionWithSubtypedValues; + ArrayOf fields = structure.Fields; + + if (isUnion) + { + var options = new List(fields.Count); + for (int i = 0; i < fields.Count; i++) + { + StructureField field = fields[i]; + string name = FieldName(field, i); + var properties = new JsonObject(); + var optionRequired = new List(); + if (!m_verbose) + { + // The compact encoding emits the union discriminator. + properties["SwitchField"] = new JsonObject + { + ["type"] = "integer", + ["const"] = i + 1 + }; + optionRequired.Add("SwitchField"); + } + properties[name] = FieldSchema(field); + optionRequired.Add(name); + options.Add(new JsonObject + { + ["type"] = "object", + ["properties"] = properties, + ["required"] = new JsonArray(optionRequired.ToArray()), + ["additionalProperties"] = false + }); + } + return new JsonObject + { + ["title"] = type.Name, + ["oneOf"] = new JsonArray(options.ToArray()) + }; + } + + var fieldSchemas = new JsonObject(); + var required = new List(); + bool hasOptionalField = false; + for (int i = 0; i < fields.Count; i++) + { + StructureField field = fields[i]; + string name = FieldName(field, i); + fieldSchemas[name] = FieldSchema(field); + if (field.IsOptional) + { + hasOptionalField = true; + } + else + { + required.Add(name); + } + } + + if (!m_verbose && hasOptionalField) + { + // The compact encoding prefixes structures that have optional + // fields with an EncodingMask that selects the present fields. + fieldSchemas["EncodingMask"] = new JsonObject + { + ["type"] = "integer", + ["minimum"] = 0 + }; + required.Add("EncodingMask"); + } + + var schema = new JsonObject + { + ["type"] = "object", + ["title"] = type.Name, + ["properties"] = fieldSchemas, + ["additionalProperties"] = false + }; + if (required.Count > 0) + { + schema["required"] = new JsonArray(required.ToArray()); + } + return schema; + } + + private JsonObject BuildEnum(EnumDefinition enumeration) + { + ArrayOf fields = enumeration.Fields; + if (m_verbose) + { + // Verbose enums are encoded as the string "Name_Value". + var names = new List(fields.Count); + for (int i = 0; i < fields.Count; i++) + { + EnumField field = fields[i]; + names.Add($"{field.Name}_{field.Value}"); + } + var verboseSchema = new JsonObject { ["type"] = "string" }; + if (names.Count > 0) + { + verboseSchema["enum"] = new JsonArray(names.ToArray()); + } + return verboseSchema; + } + + var options = new List(fields.Count); + for (int i = 0; i < fields.Count; i++) + { + EnumField field = fields[i]; + var option = new JsonObject { ["const"] = field.Value }; + if (!string.IsNullOrEmpty(field.Name)) + { + option["title"] = field.Name; + } + options.Add(option); + } + var schema = new JsonObject { ["type"] = "integer" }; + if (options.Count > 0) + { + schema["oneOf"] = new JsonArray(options.ToArray()); + } + return schema; + } + + private JsonObject FieldSchema(StructureField field) + { + NodeId dataType = field.DataType; + return ApplyValueRank(() => ElementSchema(dataType), field.ValueRank); + } + + private JsonObject ElementSchema(NodeId dataType) + { + BuiltInType builtInType = TypeInfo.GetBuiltInType(dataType); + if (builtInType != BuiltInType.Null) + { + return JsonBuiltInTypeSchemas.Create(builtInType, m_verbose, Definitions); + } + + if (m_resolver.TryResolve(dataType, out UaTypeDescription? referenced)) + { + string key = EnsureType(referenced); + return JsonSchemaConstants.Ref(key); + } + + // Unresolved type: allow any value. + return new JsonObject(); + } + + private static JsonObject ApplyValueRank(Func elementFactory, int valueRank) + { + switch (valueRank) + { + case ValueRanks.Scalar: + return elementFactory(); + case ValueRanks.Any: + return new JsonObject + { + ["oneOf"] = new JsonArray(elementFactory(), AnyArray()) + }; + case ValueRanks.ScalarOrOneDimension: + return new JsonObject + { + ["oneOf"] = new JsonArray(elementFactory(), ArrayOf(elementFactory())) + }; + case ValueRanks.OneOrMoreDimensions: + return ArrayOf(elementFactory()); + default: + JsonObject node = elementFactory(); + for (int i = 0; i < valueRank; i++) + { + node = ArrayOf(node); + } + return node; + } + } + + private static JsonObject AnyArray() + { + return new JsonObject + { + ["type"] = "array" + }; + } + + private static JsonObject ArrayOf(JsonObject items) + { + return new JsonObject + { + ["type"] = "array", + ["items"] = items + }; + } + + private static string FieldName(StructureField field, int index) + { + return string.IsNullOrEmpty(field.Name) ? "Field" + index : field.Name!; + } + + private string DefinitionKey(UaTypeDescription type) + { + if (string.Equals(type.NamespaceUri, m_targetNamespace, StringComparison.Ordinal)) + { + return type.Name; + } + + return NamespaceToken(type.NamespaceUri) + "_" + type.Name; + } + + private static string TypeKey(UaTypeDescription type) + { + return type.NamespaceUri + "|" + type.Name; + } + + private static string NamespaceToken(string namespaceUri) + { + var builder = new StringBuilder(namespaceUri.Length); + for (int i = 0; i < namespaceUri.Length; i++) + { + char ch = namespaceUri[i]; + builder.Append(char.IsLetterOrDigit(ch) ? ch : '_'); + } + + string sanitized = builder.Length == 0 ? "ns" : builder.ToString().Trim('_'); + if (sanitized.Length == 0) + { + sanitized = "ns"; + } + + return sanitized + "_" + StableHash(namespaceUri).ToString("x8", CultureInfo.InvariantCulture); + } + + private static uint StableHash(string value) + { + uint hash = 2166136261; + for (int i = 0; i < value.Length; i++) + { + hash ^= value[i]; + hash *= 16777619; + } + + return hash; + } + + private readonly string m_targetNamespace; + private readonly IDataTypeDefinitionResolver m_resolver; + private readonly bool m_verbose; + private readonly HashSet m_visiting; + private readonly HashSet m_emittedTypes; + } + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Json/StandardJsonDefinitions.cs b/Stack/Opc.Ua.Core.Schema/Json/StandardJsonDefinitions.cs new file mode 100644 index 0000000000..8558b26165 --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Json/StandardJsonDefinitions.cs @@ -0,0 +1,181 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace Opc.Ua.Schema.Json +{ + /// + /// Builds the JSON Schema definitions for the standard OPC UA built-in + /// object types (NodeId, Variant, ExtensionObject, ...) as described by the + /// OPC UA Part 6 JSON encoding. These definitions are emitted into the + /// $defs section of a document and referenced from fields so the + /// standard types are described once per document. + /// + internal static class StandardJsonDefinitions + { + /// + /// Creates the JSON Schema definition for the supplied standard type. + /// + /// The built-in type to describe. + /// The JSON Schema object for the type. + public static JsonObject Create(BuiltInType builtInType) + { + return builtInType switch + { + BuiltInType.NodeId => NodeId(), + BuiltInType.ExpandedNodeId => ExpandedNodeId(), + BuiltInType.QualifiedName => QualifiedName(), + BuiltInType.LocalizedText => LocalizedText(), + BuiltInType.StatusCode => StatusCode(), + BuiltInType.Variant => Variant(), + BuiltInType.ExtensionObject => ExtensionObject(), + BuiltInType.DataValue => DataValue(), + BuiltInType.DiagnosticInfo => DiagnosticInfo(), + _ => new JsonObject { ["type"] = "object" } + }; + } + + private static JsonObject StringOrInteger() + { + return new JsonObject { ["type"] = new JsonArray("string", "integer") }; + } + + private static JsonObject Object(JsonObject properties, params string[] required) + { + var schema = new JsonObject + { + ["type"] = "object", + ["properties"] = properties, + ["additionalProperties"] = false + }; + if (required.Length > 0) + { + var items = new List(required.Length); + foreach (string name in required) + { + items.Add(name); + } + schema["required"] = new JsonArray(items.ToArray()); + } + return schema; + } + + private static JsonObject NodeId() + { + return Object(new JsonObject + { + ["IdType"] = new JsonObject { ["type"] = "integer", ["minimum"] = 0, ["maximum"] = 3 }, + ["Id"] = StringOrInteger(), + ["Namespace"] = StringOrInteger() + }, "Id"); + } + + private static JsonObject ExpandedNodeId() + { + return Object(new JsonObject + { + ["IdType"] = new JsonObject { ["type"] = "integer", ["minimum"] = 0, ["maximum"] = 3 }, + ["Id"] = StringOrInteger(), + ["Namespace"] = StringOrInteger(), + ["ServerUri"] = StringOrInteger() + }, "Id"); + } + + private static JsonObject QualifiedName() + { + return Object(new JsonObject + { + ["Name"] = new JsonObject { ["type"] = "string" }, + ["Uri"] = StringOrInteger() + }, "Name"); + } + + private static JsonObject LocalizedText() + { + return Object(new JsonObject + { + ["Locale"] = new JsonObject { ["type"] = "string" }, + ["Text"] = new JsonObject { ["type"] = "string" } + }); + } + + private static JsonObject StatusCode() + { + return Object(new JsonObject + { + ["Code"] = new JsonObject { ["type"] = "integer", ["minimum"] = 0, ["maximum"] = 4294967295 }, + ["Symbol"] = new JsonObject { ["type"] = "string" } + }); + } + + private static JsonObject Variant() + { + return Object(new JsonObject + { + ["Type"] = new JsonObject { ["type"] = "integer", ["minimum"] = 0, ["maximum"] = 29 }, + ["Body"] = true, + ["Dimensions"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "integer" } + } + }); + } + + private static JsonObject ExtensionObject() + { + return Object(new JsonObject + { + ["TypeId"] = NodeId(), + ["Encoding"] = new JsonObject { ["type"] = "integer", ["minimum"] = 0, ["maximum"] = 2 }, + ["Body"] = true + }); + } + + private static JsonObject DataValue() + { + return Object(new JsonObject + { + ["Value"] = true, + ["Status"] = StatusCode(), + ["SourceTimestamp"] = new JsonObject { ["type"] = "string", ["format"] = "date-time" }, + ["SourcePicoseconds"] = new JsonObject { ["type"] = "integer" }, + ["ServerTimestamp"] = new JsonObject { ["type"] = "string", ["format"] = "date-time" }, + ["ServerPicoseconds"] = new JsonObject { ["type"] = "integer" } + }); + } + + private static JsonObject DiagnosticInfo() + { + return new JsonObject { ["type"] = "object" }; + } + } +} diff --git a/Stack/Opc.Ua.Core.Schema/NugetREADME.md b/Stack/Opc.Ua.Core.Schema/NugetREADME.md new file mode 100644 index 0000000000..c7002a929b --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/NugetREADME.md @@ -0,0 +1,34 @@ +# OPCFoundation.NetStandard.Opc.Ua.Core.Schema + +Runtime schema generation for OPC UA data types. + +This package produces schemas for the encodeable types generated by the OPC UA +stack and for complex types that are added dynamically at runtime. Schemas are +built as strongly-typed object models in code (no embedded schema strings), so +unused generation paths are trimmed away and the feature is NativeAOT friendly. + +Supported encodings: + +- **XSD** for the XML encoding. +- **BSD** (OPC Binary, Part 6) for the binary encoding. +- **JSON Schema** (Part 6 Annex C) for the JSON encoding, in both *compact* + (reversible) and *verbose* flavors. + +## Usage + +```csharp +IServiceProvider services = new ServiceCollection() + .AddOpcUa() + .AddSchemaGeneration() + .Services + .BuildServiceProvider(); + +ISchemaProvider provider = services.GetRequiredService(); + +if (provider.TryGetSchema(typeId, UaSchemaFormat.JsonCompact, UaSchemaScope.Type, out IUaSchema? schema)) +{ + string json = schema.ToSchemaString(); +} +``` + +See `Docs/SchemaGeneration.md` in the repository for details. diff --git a/Stack/Opc.Ua.Core.Schema/Opc.Ua.Core.Schema.csproj b/Stack/Opc.Ua.Core.Schema/Opc.Ua.Core.Schema.csproj new file mode 100644 index 0000000000..2ff07dddac --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Opc.Ua.Core.Schema.csproj @@ -0,0 +1,30 @@ + + + $(LibCoreTargetFrameworks) + $(AssemblyPrefix).Core.Schema + $(PackagePrefix).Opc.Ua.Core.Schema + Opc.Ua.Schema + OPC UA runtime schema generation (XSD, OPC Binary and JSON Schema) for encodeable and complex data types. + true + NugetREADME.md + true + true + enable + + + + + + $(PackageId).Debug + + + + + + + + + + + + diff --git a/Stack/Opc.Ua.Core.Schema/Properties/AssemblyInfo.cs b/Stack/Opc.Ua.Core.Schema/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..2b9848014c --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +[assembly: CLSCompliant(false)] diff --git a/Stack/Opc.Ua.Core.Schema/Resolution/CompositeDataTypeDefinitionResolver.cs b/Stack/Opc.Ua.Core.Schema/Resolution/CompositeDataTypeDefinitionResolver.cs new file mode 100644 index 0000000000..f7c7e18c3a --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Resolution/CompositeDataTypeDefinitionResolver.cs @@ -0,0 +1,111 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Opc.Ua.Schema +{ + /// + /// Aggregates multiple sources and + /// resolves a type from the first source that knows it. Used to combine an + /// explicit with, for example, an + /// . + /// + public sealed class CompositeDataTypeDefinitionResolver : IDataTypeDefinitionResolver + { + /// + /// Initializes a new instance of the + /// class. + /// + /// The resolver sources, tried in order. + /// is null. + public CompositeDataTypeDefinitionResolver(IEnumerable resolvers) + { + if (resolvers == null) + { + throw new ArgumentNullException(nameof(resolvers)); + } + m_resolvers = [.. resolvers]; + } + + /// + public bool TryResolve( + ExpandedNodeId typeId, + [NotNullWhen(true)] out UaTypeDescription? description) + { + for (int i = 0; i < m_resolvers.Count; i++) + { + if (m_resolvers[i].TryResolve(typeId, out description)) + { + return true; + } + } + description = null; + return false; + } + + /// + public bool TryResolve( + NodeId typeId, + [NotNullWhen(true)] out UaTypeDescription? description) + { + for (int i = 0; i < m_resolvers.Count; i++) + { + if (m_resolvers[i].TryResolve(typeId, out description)) + { + return true; + } + } + description = null; + return false; + } + + /// + public IReadOnlyCollection GetNamespaceTypes(string namespaceUri) + { + var result = new List(); + var seen = new HashSet(); + for (int i = 0; i < m_resolvers.Count; i++) + { + foreach (UaTypeDescription description in m_resolvers[i].GetNamespaceTypes(namespaceUri)) + { + if (seen.Add(description.TypeId.InnerNodeId)) + { + result.Add(description); + } + } + } + return result; + } + + private readonly List m_resolvers; + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Resolution/DataTypeDefinitionRegistry.cs b/Stack/Opc.Ua.Core.Schema/Resolution/DataTypeDefinitionRegistry.cs new file mode 100644 index 0000000000..d25704c41a --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Resolution/DataTypeDefinitionRegistry.cs @@ -0,0 +1,110 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Opc.Ua.Schema +{ + /// + /// An in-memory registry of data type descriptions used as the default + /// . Generated and dynamically + /// built complex types register their here + /// so that schemas can be produced without reflection. The registry is + /// intended to be populated during application start-up before it is read. + /// + public sealed class DataTypeDefinitionRegistry : IDataTypeDefinitionResolver + { + /// + /// Adds or replaces a data type description in the registry. + /// + /// The description to add. + /// The registry to allow chaining. + /// is null. + public DataTypeDefinitionRegistry Add(UaTypeDescription description) + { + if (description == null) + { + throw new ArgumentNullException(nameof(description)); + } + + NodeId key = description.TypeId.InnerNodeId; + if (m_byNodeId.TryGetValue(key, out UaTypeDescription? existing) && + m_byNamespace.TryGetValue(existing.NamespaceUri, out List? existingList)) + { + // Keep the namespace list consistent with the node-id map when a + // type is re-registered (replace rather than leave a stale copy). + existingList.Remove(existing); + } + m_byNodeId[key] = description; + + if (!m_byNamespace.TryGetValue(description.NamespaceUri, out List? list)) + { + list = []; + m_byNamespace[description.NamespaceUri] = list; + } + list.Add(description); + return this; + } + + /// + public bool TryResolve( + ExpandedNodeId typeId, + [NotNullWhen(true)] out UaTypeDescription? description) + { + return TryResolve(typeId.InnerNodeId, out description); + } + + /// + public bool TryResolve( + NodeId typeId, + [NotNullWhen(true)] out UaTypeDescription? description) + { + return m_byNodeId.TryGetValue(typeId, out description); + } + + /// + public IReadOnlyCollection GetNamespaceTypes(string namespaceUri) + { + if (namespaceUri != null && + m_byNamespace.TryGetValue(namespaceUri, out List? list)) + { + // Return a snapshot so a later registration cannot invalidate an + // in-progress namespace enumeration. + return list.ToArray(); + } + return []; + } + + private readonly Dictionary m_byNodeId = []; + private readonly Dictionary> m_byNamespace = + new(StringComparer.Ordinal); + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Resolution/DataTypeDefinitionRegistryExtensions.cs b/Stack/Opc.Ua.Core.Schema/Resolution/DataTypeDefinitionRegistryExtensions.cs new file mode 100644 index 0000000000..0f868843ee --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Resolution/DataTypeDefinitionRegistryExtensions.cs @@ -0,0 +1,85 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +namespace Opc.Ua.Schema +{ + /// + /// Extension methods that populate a + /// from OPC UA address-space nodes. + /// + public static class DataTypeDefinitionRegistryExtensions + { + /// + /// Registers the data type definition carried by an address-space + /// (for example a node obtained by browsing a + /// server or from the client node cache) so a schema can be generated + /// for it. + /// + /// The registry to add the data type to. + /// The data type node. + /// The namespace table used to resolve the + /// node namespace uri. May be null. + /// + /// true when the node carried a usable structure or enum + /// definition and was added; otherwise false. + /// + /// A required argument is null. + public static bool TryAddDataType( + this DataTypeDefinitionRegistry registry, + DataTypeNode node, + NamespaceTable? namespaceUris = null) + { + if (registry == null) + { + throw new ArgumentNullException(nameof(registry)); + } + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + if (node.NodeId.IsNull || + node.DataTypeDefinition.IsNull || + !node.DataTypeDefinition.TryGetValue(out DataTypeDefinition? definition)) + { + return false; + } + + string namespaceUri = namespaceUris?.GetString(node.NodeId.NamespaceIndex) ?? string.Empty; + registry.Add(new UaTypeDescription( + new ExpandedNodeId(node.NodeId), + node.BrowseName, + definition, + namespaceUri)); + return true; + } + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Resolution/EncodeableFactoryDefinitionSource.cs b/Stack/Opc.Ua.Core.Schema/Resolution/EncodeableFactoryDefinitionSource.cs new file mode 100644 index 0000000000..4d66d6be9b --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Resolution/EncodeableFactoryDefinitionSource.cs @@ -0,0 +1,131 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Xml; + +namespace Opc.Ua.Schema +{ + /// + /// Resolves data type definitions from an . + /// Generated and other registered encodeable/enumerated types that + /// implement expose their + /// definition, so any type known to the factory can produce a schema + /// without being registered manually. + /// + [Experimental("UA_NETStandard_1")] + public sealed class EncodeableFactoryDefinitionSource : IDataTypeDefinitionResolver + { + /// + /// Initializes a new instance of the + /// class. + /// + /// The encodeable factory to resolve types from. + /// The namespace table used to materialize the + /// definitions. + /// A required argument is null. + public EncodeableFactoryDefinitionSource( + IEncodeableFactory factory, + NamespaceTable namespaceUris) + { + m_factory = factory ?? throw new ArgumentNullException(nameof(factory)); + m_namespaceUris = namespaceUris ?? throw new ArgumentNullException(nameof(namespaceUris)); + } + + /// + public bool TryResolve( + ExpandedNodeId typeId, + [NotNullWhen(true)] out UaTypeDescription? description) + { + if (m_factory.TryGetEncodeableType(typeId, out IEncodeableType? encodeableType) && + encodeableType is IDataTypeDefinitionSource encodeableSource && + encodeableSource.GetDataTypeDefinition(m_namespaceUris) is DataTypeDefinition encodeable) + { + description = Describe(typeId, encodeableType.XmlName, encodeable); + return true; + } + + if (m_factory.TryGetEnumeratedType(typeId, out IEnumeratedType? enumeratedType) && + enumeratedType is IDataTypeDefinitionSource enumeratedSource && + enumeratedSource.GetDataTypeDefinition(m_namespaceUris) is DataTypeDefinition enumerated) + { + description = Describe(typeId, enumeratedType.XmlName, enumerated); + return true; + } + + description = null; + return false; + } + + /// + public bool TryResolve( + NodeId typeId, + [NotNullWhen(true)] out UaTypeDescription? description) + { + return TryResolve(new ExpandedNodeId(typeId), out description); + } + + /// + public IReadOnlyCollection GetNamespaceTypes(string namespaceUri) + { + if (string.IsNullOrEmpty(namespaceUri)) + { + return []; + } + + var result = new List(); + foreach (ExpandedNodeId typeId in m_factory.KnownTypeIds) + { + if (TryResolve(typeId, out UaTypeDescription? description) && + string.Equals(description.NamespaceUri, namespaceUri, StringComparison.Ordinal)) + { + result.Add(description); + } + } + return result; + } + + private static UaTypeDescription Describe( + ExpandedNodeId typeId, + XmlQualifiedName xmlName, + DataTypeDefinition definition) + { + string? namespaceUri = xmlName != null && !string.IsNullOrEmpty(xmlName.Namespace) + ? xmlName.Namespace + : typeId.NamespaceUri; + var browseName = new QualifiedName(xmlName?.Name); + return new UaTypeDescription(typeId, browseName, definition, namespaceUri); + } + + private readonly IEncodeableFactory m_factory; + private readonly NamespaceTable m_namespaceUris; + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Resolution/IDataTypeDefinitionResolver.cs b/Stack/Opc.Ua.Core.Schema/Resolution/IDataTypeDefinitionResolver.cs new file mode 100644 index 0000000000..a8ce3e52f6 --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Resolution/IDataTypeDefinitionResolver.cs @@ -0,0 +1,69 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Opc.Ua.Schema +{ + /// + /// Resolves the runtime structure definition for an OPC UA data type id. + /// Implementations may aggregate multiple sources (registered generated + /// types, dynamically built complex types, or a server address space). + /// + public interface IDataTypeDefinitionResolver + { + /// + /// Resolves the type description for the supplied data type id. + /// + /// The data type id to resolve. + /// The resolved type description. + /// true when the type was resolved. + bool TryResolve( + ExpandedNodeId typeId, + [NotNullWhen(true)] out UaTypeDescription? description); + + /// + /// Resolves the type description for the supplied data type id. + /// + /// The data type id to resolve. + /// The resolved type description. + /// true when the type was resolved. + bool TryResolve( + NodeId typeId, + [NotNullWhen(true)] out UaTypeDescription? description); + + /// + /// Returns all resolvable data types of a namespace. + /// + /// The namespace uri. + /// The data types in the namespace. + IReadOnlyCollection GetNamespaceTypes(string namespaceUri); + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Resolution/UaTypeDescription.cs b/Stack/Opc.Ua.Core.Schema/Resolution/UaTypeDescription.cs new file mode 100644 index 0000000000..778946c560 --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Resolution/UaTypeDescription.cs @@ -0,0 +1,92 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +namespace Opc.Ua.Schema +{ + /// + /// Describes an OPC UA data type for schema generation. It bundles the + /// type identifier, its browse name and its runtime structure definition + /// ( or ). + /// + public sealed class UaTypeDescription + { + /// + /// Initializes a new instance of the class. + /// + /// The data type identifier. + /// The browse name of the data type. + /// The runtime structure or enum definition. + /// The namespace uri of the data type. When + /// omitted the namespace uri of is used. + /// is null. + public UaTypeDescription( + ExpandedNodeId typeId, + QualifiedName browseName, + DataTypeDefinition definition, + string? namespaceUri = null) + { + TypeId = typeId; + BrowseName = browseName; + Definition = definition ?? throw new ArgumentNullException(nameof(definition)); + NamespaceUri = string.IsNullOrEmpty(namespaceUri) + ? typeId.NamespaceUri ?? string.Empty + : namespaceUri!; + } + + /// + /// The data type identifier. + /// + public ExpandedNodeId TypeId { get; } + + /// + /// The browse name of the data type. + /// + public QualifiedName BrowseName { get; } + + /// + /// The runtime structure or enum definition of the data type. + /// + public DataTypeDefinition Definition { get; } + + /// + /// The namespace uri of the data type. + /// + public string NamespaceUri { get; } + + /// + /// The local name of the data type used as the schema type name. + /// + public string Name + => !BrowseName.IsNull && !string.IsNullOrEmpty(BrowseName.Name) + ? BrowseName.Name! + : "UnnamedType"; + } +} diff --git a/Stack/Opc.Ua.Core.Schema/SchemaProviderExtensions.cs b/Stack/Opc.Ua.Core.Schema/SchemaProviderExtensions.cs new file mode 100644 index 0000000000..4aac24157e --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/SchemaProviderExtensions.cs @@ -0,0 +1,114 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Opc.Ua.Schema +{ + /// + /// Convenience extension methods over that + /// make a data type "expose" its schema in a specific encoding. + /// + public static class SchemaProviderExtensions + { + /// + /// Creates the XML schema (XSD) for the supplied data type. + /// + /// The schema provider. + /// The data type to generate a schema for. + /// The scope of the generated schema. + /// The generated schema. + public static IUaSchema GetXmlSchema( + this ISchemaProvider provider, + UaTypeDescription type, + UaSchemaScope scope = UaSchemaScope.Type) + { + return Guard(provider).CreateSchema(type, UaSchemaFormat.Xsd, scope); + } + + /// + /// Creates the OPC Binary schema (BSD) for the supplied data type. + /// + /// The schema provider. + /// The data type to generate a schema for. + /// The scope of the generated schema. + /// The generated schema. + public static IUaSchema GetBinarySchema( + this ISchemaProvider provider, + UaTypeDescription type, + UaSchemaScope scope = UaSchemaScope.Type) + { + return Guard(provider).CreateSchema(type, UaSchemaFormat.Bsd, scope); + } + + /// + /// Creates the JSON Schema for the supplied data type. + /// + /// The schema provider. + /// The data type to generate a schema for. + /// Whether to use the verbose JSON encoding flavor. + /// The scope of the generated schema. + /// The generated schema. + public static IUaSchema GetJsonSchema( + this ISchemaProvider provider, + UaTypeDescription type, + bool verbose = false, + UaSchemaScope scope = UaSchemaScope.Type) + { + UaSchemaFormat format = verbose ? UaSchemaFormat.JsonVerbose : UaSchemaFormat.JsonCompact; + return Guard(provider).CreateSchema(type, format, scope); + } + + /// + /// Resolves the supplied data type id and creates its JSON Schema. + /// + /// The schema provider. + /// The data type id. + /// The generated schema. + /// Whether to use the verbose JSON encoding flavor. + /// The scope of the generated schema. + /// true when the type was resolved and a schema produced. + public static bool TryGetJsonSchema( + this ISchemaProvider provider, + ExpandedNodeId typeId, + [NotNullWhen(true)] out IUaSchema? schema, + bool verbose = false, + UaSchemaScope scope = UaSchemaScope.Type) + { + UaSchemaFormat format = verbose ? UaSchemaFormat.JsonVerbose : UaSchemaFormat.JsonCompact; + return Guard(provider).TryGetSchema(typeId, format, scope, out schema); + } + + private static ISchemaProvider Guard(ISchemaProvider provider) + { + return provider ?? throw new ArgumentNullException(nameof(provider)); + } + } +} diff --git a/Stack/Opc.Ua.Core.Schema/SchemaServiceCollectionExtensions.cs b/Stack/Opc.Ua.Core.Schema/SchemaServiceCollectionExtensions.cs new file mode 100644 index 0000000000..e06abce7fa --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/SchemaServiceCollectionExtensions.cs @@ -0,0 +1,76 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Opc.Ua; +using Opc.Ua.Schema; +using Opc.Ua.Schema.Bsd; +using Opc.Ua.Schema.Json; +using Opc.Ua.Schema.Xsd; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Dependency injection extensions that register the OPC UA schema + /// generation services. + /// + public static class SchemaServiceCollectionExtensions + { + /// + /// Registers the schema generation services (the + /// , the default + /// resolver and the built-in + /// schema generators). + /// + /// The OPC UA builder. + /// The same instance. + /// is null. + public static IOpcUaBuilder AddSchemaGeneration(this IOpcUaBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + IServiceCollection services = builder.Services; + services.TryAddSingleton(); + services.TryAddSingleton( + static sp => sp.GetRequiredService()); + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + services.TryAddSingleton(); + return builder; + } + } +} diff --git a/Stack/Opc.Ua.Core.Schema/UaSchemaFormat.cs b/Stack/Opc.Ua.Core.Schema/UaSchemaFormat.cs new file mode 100644 index 0000000000..953565fe7c --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/UaSchemaFormat.cs @@ -0,0 +1,74 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +namespace Opc.Ua.Schema +{ + /// + /// The schema format (encoding) to generate for a data type. + /// + public enum UaSchemaFormat + { + /// + /// XML schema (XSD) for the XML data encoding. + /// + Xsd, + + /// + /// OPC Binary schema (BSD) for the binary data encoding. + /// + Bsd, + + /// + /// JSON Schema for the compact (reversible) JSON data encoding. + /// + JsonCompact, + + /// + /// JSON Schema for the verbose JSON data encoding. + /// + JsonVerbose + } + + /// + /// The scope of a generated schema document. + /// + public enum UaSchemaScope + { + /// + /// A schema document for a single data type and the closure of the + /// types it depends on. + /// + Type, + + /// + /// A schema document (dictionary) for all data types in a namespace. + /// + Namespace + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Xsd/XmlSchemaDocument.cs b/Stack/Opc.Ua.Core.Schema/Xsd/XmlSchemaDocument.cs new file mode 100644 index 0000000000..5373b97a97 --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Xsd/XmlSchemaDocument.cs @@ -0,0 +1,303 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Globalization; +using System.IO; +using System.Xml; +using System.Xml.Schema; + +namespace Opc.Ua.Schema.Xsd +{ + /// + /// An XML Schema document generated for an OPC UA data type or namespace. + /// + public sealed class XmlSchemaDocument : IUaSchema + { + /// + /// Initializes a new instance of the class. + /// + /// The target namespace of the schema. + /// The XML Schema object model. + public XmlSchemaDocument(string targetNamespace, XmlSchema schema) + { + TargetNamespace = targetNamespace ?? throw new ArgumentNullException(nameof(targetNamespace)); + Schema = schema ?? throw new ArgumentNullException(nameof(schema)); + } + + /// + public UaSchemaFormat Format => UaSchemaFormat.Xsd; + + /// + public string MediaType => "application/xml"; + + /// + public string TargetNamespace { get; } + + /// + /// The XML Schema object model. + /// + public XmlSchema Schema { get; } + + /// + public void WriteTo(Stream stream) + { + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + + using XmlWriter writer = XmlWriter.Create(stream, WriterSettings()); + WriteSchema(writer); + } + + /// + public void WriteTo(TextWriter writer) + { + if (writer == null) + { + throw new ArgumentNullException(nameof(writer)); + } + + using XmlWriter xmlWriter = XmlWriter.Create(writer, WriterSettings()); + WriteSchema(xmlWriter); + } + + /// + public string ToSchemaString() + { + using var writer = new StringWriter(CultureInfo.InvariantCulture); + WriteTo(writer); + return writer.ToString(); + } + + private static XmlWriterSettings WriterSettings() + { + return new XmlWriterSettings + { + Indent = true + }; + } + + private void WriteSchema(XmlWriter writer) + { + writer.WriteStartElement("xs", "schema", XmlSchema.Namespace); + writer.WriteAttributeString("xmlns", "ua", null, UaTypesNamespace); + writer.WriteAttributeString("xmlns", "tns", null, TargetNamespace); + WriteImportedNamespaceDeclarations(writer); + writer.WriteAttributeString("targetNamespace", TargetNamespace); + writer.WriteAttributeString("elementFormDefault", "qualified"); + + foreach (XmlSchemaObject include in Schema.Includes) + { + WriteSchemaObject(writer, include); + } + + foreach (XmlSchemaObject item in Schema.Items) + { + WriteSchemaObject(writer, item); + } + + writer.WriteEndElement(); + } + + private void WriteSchemaObject(XmlWriter writer, XmlSchemaObject item) + { + switch (item) + { + case XmlSchemaImport import: + writer.WriteStartElement("xs", "import", XmlSchema.Namespace); + writer.WriteAttributeString("namespace", import.Namespace); + writer.WriteEndElement(); + break; + case XmlSchemaComplexType complexType: + WriteComplexType(writer, complexType); + break; + case XmlSchemaSimpleType simpleType: + WriteSimpleType(writer, simpleType); + break; + case XmlSchemaElement element: + WriteElement(writer, element); + break; + case XmlSchemaSequence sequence: + WriteParticle(writer, sequence); + break; + case XmlSchemaChoice choice: + WriteParticle(writer, choice); + break; + } + } + + private void WriteComplexType(XmlWriter writer, XmlSchemaComplexType complexType) + { + writer.WriteStartElement("xs", "complexType", XmlSchema.Namespace); + if (!string.IsNullOrEmpty(complexType.Name)) + { + writer.WriteAttributeString("name", complexType.Name); + } + + WriteParticle(writer, complexType.Particle); + writer.WriteEndElement(); + } + + private void WriteSimpleType(XmlWriter writer, XmlSchemaSimpleType simpleType) + { + writer.WriteStartElement("xs", "simpleType", XmlSchema.Namespace); + writer.WriteAttributeString("name", simpleType.Name); + if (simpleType.Content is XmlSchemaSimpleTypeRestriction restriction) + { + writer.WriteStartElement("xs", "restriction", XmlSchema.Namespace); + writer.WriteAttributeString("base", QualifiedName(restriction.BaseTypeName)); + foreach (XmlSchemaObject facet in restriction.Facets) + { + if (facet is XmlSchemaEnumerationFacet enumeration) + { + writer.WriteStartElement("xs", "enumeration", XmlSchema.Namespace); + writer.WriteAttributeString("value", enumeration.Value); + writer.WriteEndElement(); + } + } + writer.WriteEndElement(); + } + writer.WriteEndElement(); + } + + private void WriteParticle(XmlWriter writer, XmlSchemaParticle? particle) + { + switch (particle) + { + case XmlSchemaSequence sequence: + writer.WriteStartElement("xs", "sequence", XmlSchema.Namespace); + foreach (XmlSchemaObject item in sequence.Items) + { + WriteSchemaObject(writer, item); + } + writer.WriteEndElement(); + break; + case XmlSchemaChoice choice: + writer.WriteStartElement("xs", "choice", XmlSchema.Namespace); + foreach (XmlSchemaObject item in choice.Items) + { + WriteSchemaObject(writer, item); + } + writer.WriteEndElement(); + break; + } + } + + private void WriteElement(XmlWriter writer, XmlSchemaElement element) + { + writer.WriteStartElement("xs", "element", XmlSchema.Namespace); + writer.WriteAttributeString("name", element.Name); + if (!element.SchemaTypeName.IsEmpty) + { + writer.WriteAttributeString("type", QualifiedName(element.SchemaTypeName)); + } + if (element.MinOccurs != 1) + { + writer.WriteAttributeString("minOccurs", XmlConvert.ToString(element.MinOccurs)); + } + if (!string.IsNullOrEmpty(element.MaxOccursString)) + { + writer.WriteAttributeString("maxOccurs", element.MaxOccursString); + } + if (element.IsNillable) + { + writer.WriteAttributeString("nillable", "true"); + } + if (element.SchemaType is XmlSchemaComplexType complexType) + { + WriteComplexType(writer, complexType); + } + writer.WriteEndElement(); + } + + private string QualifiedName(XmlQualifiedName name) + { + if (name.Namespace == XmlSchema.Namespace) + { + return "xs:" + name.Name; + } + if (name.Namespace == UaTypesNamespace) + { + return "ua:" + name.Name; + } + if (name.Namespace == TargetNamespace) + { + return "tns:" + name.Name; + } + + string prefix = PrefixForNamespace(name.Namespace); + if (!string.IsNullOrEmpty(prefix)) + { + return prefix + ":" + name.Name; + } + + return name.Name; + } + + private void WriteImportedNamespaceDeclarations(XmlWriter writer) + { + XmlQualifiedName[] namespaces = Schema.Namespaces.ToArray(); + for (int i = 0; i < namespaces.Length; i++) + { + XmlQualifiedName namespaceDeclaration = namespaces[i]; + if (namespaceDeclaration.Name == "xs" || + namespaceDeclaration.Name == "ua" || + namespaceDeclaration.Name == "tns") + { + continue; + } + + writer.WriteAttributeString( + "xmlns", + namespaceDeclaration.Name, + null, + namespaceDeclaration.Namespace); + } + } + + private string PrefixForNamespace(string namespaceUri) + { + XmlQualifiedName[] namespaces = Schema.Namespaces.ToArray(); + for (int i = 0; i < namespaces.Length; i++) + { + XmlQualifiedName namespaceDeclaration = namespaces[i]; + if (namespaceDeclaration.Namespace == namespaceUri) + { + return namespaceDeclaration.Name; + } + } + + return string.Empty; + } + + private const string UaTypesNamespace = "http://opcfoundation.org/UA/2008/02/Types.xsd"; + } +} diff --git a/Stack/Opc.Ua.Core.Schema/Xsd/XsdSchemaGenerator.cs b/Stack/Opc.Ua.Core.Schema/Xsd/XsdSchemaGenerator.cs new file mode 100644 index 0000000000..1151370cc0 --- /dev/null +++ b/Stack/Opc.Ua.Core.Schema/Xsd/XsdSchemaGenerator.cs @@ -0,0 +1,439 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Xml; +using System.Xml.Schema; +using System.Xml.Serialization; + +namespace Opc.Ua.Schema.Xsd +{ + /// + /// Generates XML Schema (XSD) documents for OPC UA data types according to + /// the OPC UA Part 6 XML encoding. The schema is built using the in-box + /// object model so that no + /// reflection-based serialization is required. + /// + internal sealed class XsdSchemaGenerator : IUaSchemaGenerator + { + /// + public bool CanGenerate(UaSchemaFormat format) + { + return format == UaSchemaFormat.Xsd; + } + + /// + public IUaSchema Generate( + UaTypeDescription type, + IDataTypeDefinitionResolver resolver, + UaSchemaFormat format, + UaSchemaScope scope) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + if (resolver == null) + { + throw new ArgumentNullException(nameof(resolver)); + } + + var context = new GenerationContext(type.NamespaceUri, resolver); + if (scope == UaSchemaScope.Namespace) + { + foreach (UaTypeDescription namespaceType in resolver.GetNamespaceTypes(type.NamespaceUri)) + { + context.EnsureType(namespaceType); + } + } + + context.EnsureType(type); + return new XmlSchemaDocument(type.NamespaceUri, context.Schema); + } + + private sealed class GenerationContext + { + public GenerationContext(string targetNamespace, IDataTypeDefinitionResolver resolver) + { + m_resolver = resolver; + m_targetNamespace = targetNamespace; + m_emittedTypes = new HashSet(StringComparer.Ordinal); + m_visitingTypes = new HashSet(StringComparer.Ordinal); + m_emittedListTypes = new HashSet(StringComparer.Ordinal); + m_importedNamespaces = new HashSet(StringComparer.Ordinal); + m_nextNamespacePrefix = 1; + + Schema = new XmlSchema + { + TargetNamespace = targetNamespace, + ElementFormDefault = XmlSchemaForm.Qualified + }; + Schema.Namespaces = new XmlSerializerNamespaces(); + Schema.Namespaces.Add("xs", XmlSchema.Namespace); + Schema.Namespaces.Add("ua", UaTypesNamespace); + Schema.Namespaces.Add("tns", targetNamespace); + Schema.Includes.Add(new XmlSchemaImport { Namespace = UaTypesNamespace }); + } + + public XmlSchema Schema { get; } + + public void EnsureType(UaTypeDescription type) + { + string typeKey = TypeKey(type); + if (m_emittedTypes.Contains(typeKey) || m_visitingTypes.Contains(typeKey)) + { + return; + } + + m_visitingTypes.Add(typeKey); + switch (type.Definition) + { + case StructureDefinition structure: + AddStructure(type, structure); + break; + case EnumDefinition enumeration: + AddEnum(type, enumeration); + break; + } + m_visitingTypes.Remove(typeKey); + m_emittedTypes.Add(typeKey); + } + + private void AddStructure(UaTypeDescription type, StructureDefinition structure) + { + bool isUnion = structure.StructureType + is StructureType.Union or StructureType.UnionWithSubtypedValues; + var complexType = new XmlSchemaComplexType { Name = type.Name }; + + if (isUnion) + { + var sequence = new XmlSchemaSequence(); + sequence.Items.Add(new XmlSchemaElement + { + Name = "SwitchField", + SchemaTypeName = Xs("unsignedInt"), + MinOccurs = 0 + }); + + var choice = new XmlSchemaChoice(); + AddStructureFields(choice.Items, structure.Fields, forceOptional: true); + sequence.Items.Add(choice); + complexType.Particle = sequence; + } + else + { + var sequence = new XmlSchemaSequence(); + AddStructureFields(sequence.Items, structure.Fields, forceOptional: false); + complexType.Particle = sequence; + } + + Schema.Items.Add(complexType); + AddElement(type.Name, Tns(type.Name), isNillable: false); + AddListType(type.Name, Tns(type.Name), isNillable: true); + } + + private void AddEnum(UaTypeDescription type, EnumDefinition enumeration) + { + var simpleType = new XmlSchemaSimpleType { Name = type.Name }; + var restriction = new XmlSchemaSimpleTypeRestriction + { + BaseTypeName = enumeration.IsOptionSet ? Xs("int") : Xs("string") + }; + ArrayOf fields = enumeration.Fields; + for (int i = 0; i < fields.Count; i++) + { + EnumField field = fields[i]; + restriction.Facets.Add(new XmlSchemaEnumerationFacet + { + Value = enumeration.IsOptionSet ? XmlConvert.ToString(field.Value) : EnumValue(field, i) + }); + } + + simpleType.Content = restriction; + Schema.Items.Add(simpleType); + AddElement(type.Name, Tns(type.Name), isNillable: false); + AddListType(type.Name, Tns(type.Name), isNillable: false); + } + + private void AddStructureFields( + XmlSchemaObjectCollection items, + ArrayOf fields, + bool forceOptional) + { + for (int i = 0; i < fields.Count; i++) + { + StructureField field = fields[i]; + items.Add(BuildFieldElement(field, i, forceOptional)); + } + } + + private XmlSchemaElement BuildFieldElement(StructureField field, int index, bool forceOptional) + { + var element = new XmlSchemaElement + { + Name = FieldName(field, index), + MinOccurs = field.IsOptional || forceOptional ? 0 : 1 + }; + + if (field.ValueRank == ValueRanks.Scalar) + { + TypeReference typeReference = ResolveType(field.DataType); + element.SchemaTypeName = typeReference.Name; + element.IsNillable = typeReference.IsNillable; + return element; + } + + element.SchemaType = BuildArrayType(field.DataType, RankDepth(field.ValueRank)); + element.IsNillable = true; + return element; + } + + private XmlSchemaComplexType BuildArrayType(NodeId dataType, int depth) + { + TypeReference typeReference = ResolveType(dataType); + var complexType = new XmlSchemaComplexType(); + var sequence = new XmlSchemaSequence(); + var element = new XmlSchemaElement + { + Name = ElementName(typeReference), + MinOccurs = 0, + MaxOccursString = "unbounded", + IsNillable = typeReference.IsNillable + }; + + if (depth <= 1) + { + element.SchemaTypeName = typeReference.Name; + } + else + { + element.SchemaType = BuildArrayType(dataType, depth - 1); + } + + sequence.Items.Add(element); + complexType.Particle = sequence; + return complexType; + } + + private TypeReference ResolveType(NodeId dataType) + { + BuiltInType builtInType = TypeInfo.GetBuiltInType(dataType); + if (builtInType != BuiltInType.Null) + { + return BuiltInTypeReference(builtInType); + } + + if (m_resolver.TryResolve(dataType, out UaTypeDescription? referenced)) + { + if (string.Equals(referenced.NamespaceUri, m_targetNamespace, StringComparison.Ordinal)) + { + EnsureType(referenced); + return new TypeReference(Tns(referenced.Name), referenced.Name, true); + } + + AddNamespaceImport(referenced.NamespaceUri); + return new TypeReference(new XmlQualifiedName(referenced.Name, referenced.NamespaceUri), + referenced.Name, + true); + } + + return new TypeReference(Xs("anyType"), "Value", true); + } + + private void AddElement(string name, XmlQualifiedName typeName, bool isNillable) + { + Schema.Items.Add(new XmlSchemaElement + { + Name = name, + SchemaTypeName = typeName, + IsNillable = isNillable + }); + } + + private void AddListType(string name, XmlQualifiedName typeName, bool isNillable) + { + string listName = "ListOf" + name; + if (!m_emittedListTypes.Add(listName)) + { + return; + } + + var complexType = new XmlSchemaComplexType { Name = listName }; + var sequence = new XmlSchemaSequence(); + sequence.Items.Add(new XmlSchemaElement + { + Name = name, + SchemaTypeName = typeName, + MinOccurs = 0, + MaxOccursString = "unbounded", + IsNillable = isNillable + }); + complexType.Particle = sequence; + Schema.Items.Add(complexType); + AddElement(listName, Tns(listName), isNillable: true); + } + + private static TypeReference BuiltInTypeReference(BuiltInType builtInType) + { + switch (builtInType) + { + case BuiltInType.Boolean: + return new TypeReference(Xs("boolean"), "Boolean", false); + case BuiltInType.SByte: + return new TypeReference(Xs("byte"), "SByte", false); + case BuiltInType.Byte: + return new TypeReference(Xs("unsignedByte"), "Byte", false); + case BuiltInType.Int16: + return new TypeReference(Xs("short"), "Int16", false); + case BuiltInType.UInt16: + return new TypeReference(Xs("unsignedShort"), "UInt16", false); + case BuiltInType.Int32: + case BuiltInType.Enumeration: + return new TypeReference(Xs("int"), "Int32", false); + case BuiltInType.UInt32: + case BuiltInType.StatusCode: + return new TypeReference(Xs("unsignedInt"), "UInt32", false); + case BuiltInType.Int64: + return new TypeReference(Xs("long"), "Int64", false); + case BuiltInType.UInt64: + return new TypeReference(Xs("unsignedLong"), "UInt64", false); + case BuiltInType.Float: + return new TypeReference(Xs("float"), "Float", false); + case BuiltInType.Double: + return new TypeReference(Xs("double"), "Double", false); + case BuiltInType.String: + return new TypeReference(Xs("string"), "String", true); + case BuiltInType.DateTime: + return new TypeReference(Xs("dateTime"), "DateTime", true); + case BuiltInType.Guid: + return new TypeReference(Xs("string"), "Guid", true); + case BuiltInType.ByteString: + return new TypeReference(Xs("base64Binary"), "ByteString", true); + case BuiltInType.XmlElement: + return new TypeReference(Xs("anyType"), "XmlElement", true); + default: + return new TypeReference(Ua(builtInType.ToString()), builtInType.ToString(), true); + } + } + + private static int RankDepth(int valueRank) + { + if (valueRank == ValueRanks.Scalar) + { + return 0; + } + + if (valueRank is ValueRanks.Any + or ValueRanks.ScalarOrOneDimension + or ValueRanks.OneOrMoreDimensions) + { + return 1; + } + + return valueRank < 1 ? 1 : valueRank; + } + + private static string ElementName(TypeReference typeReference) + { + return string.IsNullOrEmpty(typeReference.ElementName) ? "Value" : typeReference.ElementName; + } + + private static string FieldName(StructureField field, int index) + { + return string.IsNullOrEmpty(field.Name) ? "Field" + index : field.Name!; + } + + private static string EnumValue(EnumField field, int index) + { + string name = string.IsNullOrEmpty(field.Name) ? "Value" + index : field.Name!; + return name + "_" + XmlConvert.ToString(field.Value); + } + + private void AddNamespaceImport(string namespaceUri) + { + if (string.IsNullOrEmpty(namespaceUri) || m_importedNamespaces.Contains(namespaceUri)) + { + return; + } + + m_importedNamespaces.Add(namespaceUri); + Schema.Namespaces.Add("n" + m_nextNamespacePrefix, namespaceUri); + m_nextNamespacePrefix++; + Schema.Includes.Add(new XmlSchemaImport { Namespace = namespaceUri }); + } + + private static string TypeKey(UaTypeDescription type) + { + return type.NamespaceUri + "|" + type.Name; + } + + private XmlQualifiedName Tns(string name) + { + return new XmlQualifiedName(name, m_targetNamespace); + } + + private static XmlQualifiedName Xs(string name) + { + return new XmlQualifiedName(name, XmlSchema.Namespace); + } + + private static XmlQualifiedName Ua(string name) + { + return new XmlQualifiedName(name, UaTypesNamespace); + } + + private const string UaTypesNamespace = "http://opcfoundation.org/UA/2008/02/Types.xsd"; + + private readonly IDataTypeDefinitionResolver m_resolver; + private readonly string m_targetNamespace; + private readonly HashSet m_emittedTypes; + private readonly HashSet m_visitingTypes; + private readonly HashSet m_emittedListTypes; + private readonly HashSet m_importedNamespaces; + private int m_nextNamespacePrefix; + } + + private sealed class TypeReference + { + public TypeReference(XmlQualifiedName name, string elementName, bool isNillable) + { + Name = name; + ElementName = elementName; + IsNillable = isNillable; + } + + public XmlQualifiedName Name { get; } + + public string ElementName { get; } + + public bool IsNillable { get; } + } + } +} diff --git a/Stack/Opc.Ua.Types/Encoders/IDataTypeDefinitionSource.cs b/Stack/Opc.Ua.Types/Encoders/IDataTypeDefinitionSource.cs new file mode 100644 index 0000000000..c9b3fa246b --- /dev/null +++ b/Stack/Opc.Ua.Types/Encoders/IDataTypeDefinitionSource.cs @@ -0,0 +1,52 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +namespace Opc.Ua +{ + /// + /// Implemented by encodeable and enumerated type activators that can expose + /// the OPC UA data type definition (a or + /// ) of the type they represent. Schema + /// generation uses this to obtain a type's structure from the encodeable + /// type registry without reflection. + /// + public interface IDataTypeDefinitionSource + { + /// + /// Returns the data type definition of the type, or null when the + /// type does not expose one. + /// + /// + /// The namespace table used to resolve the namespace indexes of the + /// referenced data type ids in the returned definition. + /// + /// The data type definition, or null. + DataTypeDefinition? GetDataTypeDefinition(NamespaceTable namespaceUris); + } +} diff --git a/Stack/Opc.Ua.Types/Encoders/IEncodeableFactory.cs b/Stack/Opc.Ua.Types/Encoders/IEncodeableFactory.cs index 2f78c117de..03bb659dfb 100644 --- a/Stack/Opc.Ua.Types/Encoders/IEncodeableFactory.cs +++ b/Stack/Opc.Ua.Types/Encoders/IEncodeableFactory.cs @@ -66,7 +66,7 @@ public interface IEncodeableFactory : IEncodeableTypeLookup /// Encodeable activator /// /// - public abstract class EncodeableType : IEncodeableType + public abstract class EncodeableType : IEncodeableType, IDataTypeDefinitionSource where T : IEncodeable { /// @@ -77,13 +77,19 @@ public abstract class EncodeableType : IEncodeableType /// public abstract IEncodeable CreateInstance(); + + /// + public virtual DataTypeDefinition? GetDataTypeDefinition(NamespaceTable namespaceUris) + { + return null; + } } /// /// Enumerated type activator /// /// - public abstract class EnumeratedType : IEnumeratedType + public abstract class EnumeratedType : IEnumeratedType, IDataTypeDefinitionSource where T : struct, Enum { /// @@ -118,6 +124,12 @@ public virtual bool TryGetValue(string symbol, out int value) /// public abstract XmlQualifiedName XmlName { get; } + + /// + public virtual DataTypeDefinition? GetDataTypeDefinition(NamespaceTable namespaceUris) + { + return null; + } } /// diff --git a/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj b/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj index 9dca385f0a..28d73fc575 100644 --- a/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj +++ b/Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/Tests/Opc.Ua.Aot.Tests/SchemaAotTests.cs b/Tests/Opc.Ua.Aot.Tests/SchemaAotTests.cs new file mode 100644 index 0000000000..a41232700e --- /dev/null +++ b/Tests/Opc.Ua.Aot.Tests/SchemaAotTests.cs @@ -0,0 +1,169 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Text.Json.Nodes; +using System.Xml.Linq; +using Microsoft.Extensions.DependencyInjection; +using Opc.Ua.Schema; + +namespace Opc.Ua.Aot.Tests +{ + /// + /// AOT smoke tests for runtime OPC UA schema generation. + /// + public class SchemaAotTests + { + [Test] + public async Task CreateSchemaForAllFormatsIsAotSafeAsync() + { + using ServiceProvider services = CreateServices(out UaTypeDescription outer); + ISchemaProvider provider = services.GetRequiredService(); + + UaSchemaFormat[] formats = + [ + UaSchemaFormat.JsonCompact, + UaSchemaFormat.JsonVerbose, + UaSchemaFormat.Xsd, + UaSchemaFormat.Bsd + ]; + + foreach (UaSchemaFormat format in formats) + { + IUaSchema schema = provider.CreateSchema(outer, format); + string text = schema.ToSchemaString(); + + await Assert.That(text).IsNotNull(); + await Assert.That(text.Length).IsGreaterThan(0); + + if (format is UaSchemaFormat.JsonCompact or UaSchemaFormat.JsonVerbose) + { + JsonNode? parsed = JsonNode.Parse(text); + await Assert.That(parsed).IsNotNull(); + } + else + { + XDocument parsed = XDocument.Parse(text); + await Assert.That(parsed.Root).IsNotNull(); + } + } + } + + private static ServiceProvider CreateServices(out UaTypeDescription outer) + { + var services = new ServiceCollection(); + services.AddOpcUa().AddSchemaGeneration(); + ServiceProvider provider = services.BuildServiceProvider(); + + DataTypeDefinitionRegistry registry = provider.GetRequiredService(); + UaTypeDescription inner = Structure( + 7102, + "AotInner", + Field("Code", BuiltInType.Int32), + Field("DisplayName", BuiltInType.String)); + UaTypeDescription color = Enumeration(7103, "AotColor", ("Red", 0), ("Green", 1)); + outer = Structure( + 7101, + "AotOuter", + Field("Enabled", BuiltInType.Boolean), + Field("Values", BuiltInType.Double, ValueRanks.OneDimension), + Field("Child", new NodeId(7102, TestNamespaceIndex)), + Field("Shade", new NodeId(7103, TestNamespaceIndex))); + + registry.Add(inner); + registry.Add(color); + registry.Add(outer); + return provider; + } + + private static StructureField Field( + string name, + BuiltInType builtInType, + int valueRank = ValueRanks.Scalar) + { + return Field(name, new NodeId((uint)builtInType), valueRank); + } + + private static StructureField Field( + string name, + NodeId dataType, + int valueRank = ValueRanks.Scalar) + { + return new StructureField + { + Name = name, + DataType = dataType, + ValueRank = valueRank + }; + } + + private static UaTypeDescription Structure( + uint id, + string name, + params StructureField[] fields) + { + var definition = new StructureDefinition + { + BaseDataType = DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = fields + }; + return Describe(id, name, definition); + } + + private static UaTypeDescription Enumeration( + uint id, + string name, + params (string Name, long Value)[] values) + { + var fields = new EnumField[values.Length]; + for (int i = 0; i < values.Length; i++) + { + fields[i] = new EnumField + { + Name = values[i].Name, + Value = values[i].Value + }; + } + + return Describe(id, name, new EnumDefinition { Fields = fields }); + } + + private static UaTypeDescription Describe(uint id, string name, DataTypeDefinition definition) + { + return new UaTypeDescription( + new ExpandedNodeId(new NodeId(id, TestNamespaceIndex)), + new QualifiedName(name, TestNamespaceIndex), + definition, + TestNamespace); + } + + private const string TestNamespace = "http://test.org/UA/schema/aot"; + private const ushort TestNamespaceIndex = 7; + } +} diff --git a/Tests/Opc.Ua.Client.ComplexTypes.Tests/Types/MockResolver.cs b/Tests/Opc.Ua.Client.ComplexTypes.Tests/Types/MockResolver.cs index de471c1396..62ae4fc3df 100644 --- a/Tests/Opc.Ua.Client.ComplexTypes.Tests/Types/MockResolver.cs +++ b/Tests/Opc.Ua.Client.ComplexTypes.Tests/Types/MockResolver.cs @@ -178,9 +178,7 @@ public async Task> LoadDataTypesAsync( .Values.Where(n => n.NodeClass == NodeClass.DataType && n is DataTypeNode dataType && - dataType.DataTypeDefinition.TryGetValue( - out StructureDefinition structureDefinition) && - Utils.IsEqual(structureDefinition.BaseDataType, node)) + IsSubTypeOf(dataType, node)) .Cast(); if (nestedSubTypes) { @@ -235,6 +233,27 @@ public Task FindSuperTypeAsync(NodeId typeId, CancellationToken ct = def return Task.FromResult(DataTypeIds.BaseDataType); } + /// + /// Returns true when the data type node is a direct subtype of the requested base type. + /// + /// The data type node to inspect. + /// The requested base data type. + /// true if the node is a direct subtype of . + private bool IsSubTypeOf(DataTypeNode dataTypeNode, ExpandedNodeId baseDataType) + { + if (dataTypeNode.DataTypeDefinition.TryGetValue( + out StructureDefinition structureDefinition)) + { + return Utils.IsEqual(structureDefinition.BaseDataType, baseDataType); + } + if (dataTypeNode.DataTypeDefinition.TryGetValue(out EnumDefinition _)) + { + NodeId baseNodeId = ExpandedNodeId.ToNodeId(baseDataType, NamespaceUris); + return baseNodeId == DataTypeIds.Enumeration; + } + return false; + } + /// /// Helper to ensure the expanded nodeId contains a valid namespaceUri. /// diff --git a/Tests/Opc.Ua.Client.ComplexTypes.Tests/Types/SchemaRegistrationTests.cs b/Tests/Opc.Ua.Client.ComplexTypes.Tests/Types/SchemaRegistrationTests.cs new file mode 100644 index 0000000000..fffb8867dc --- /dev/null +++ b/Tests/Opc.Ua.Client.ComplexTypes.Tests/Types/SchemaRegistrationTests.cs @@ -0,0 +1,202 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Schema; +using Opc.Ua.Schema.Json; +using Opc.Ua.Tests; + +namespace Opc.Ua.Client.ComplexTypes.Tests.Types +{ + /// + /// Tests schema-registration support for complex types loaded from a resolver. + /// + [TestFixture] + [Category("ComplexTypes")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [Parallelizable] + public class SchemaRegistrationTests + { + /// + /// Verifies loaded structure and enum definitions can be registered for schema generation. + /// + [Test] + public async Task RegisterDataTypeDefinitionsAddsLoadedStructureAndEnumDefinitions() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + var mockResolver = new MockResolver(); + ushort namespaceIndex = mockResolver.NamespaceUris.GetIndexOrAppend(Namespaces.MockResolverUrl); + uint nodeId = 6000; + + var enumDefinition = new EnumDefinition + { + Fields = + [ + new EnumField { Name = "Red", Value = 0 }, + new EnumField { Name = "Blue", Value = 1 } + ] + }; + var enumNode = new DataTypeNode + { + NodeId = new NodeId(nodeId++, namespaceIndex), + NodeClass = NodeClass.DataType, + BrowseName = new QualifiedName("VehicleColor", namespaceIndex), + DisplayName = LocalizedText.From("VehicleColor"), + IsAbstract = false, + DataTypeDefinition = new ExtensionObject(enumDefinition) + }; + + var structureDefinition = new StructureDefinition + { + BaseDataType = DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = + [ + new StructureField + { + Name = "Model", + Description = LocalizedText.From("The model"), + DataType = DataTypeIds.String, + ValueRank = ValueRanks.Scalar + }, + new StructureField + { + Name = "Color", + Description = LocalizedText.From("The color"), + DataType = enumNode.NodeId, + ValueRank = ValueRanks.Scalar + } + ] + }; + var structureNode = new DataTypeNode + { + NodeId = new NodeId(nodeId++, namespaceIndex), + NodeClass = NodeClass.DataType, + BrowseName = new QualifiedName("VehicleType", namespaceIndex), + DisplayName = LocalizedText.From("VehicleType"), + IsAbstract = false, + DataTypeDefinition = new ExtensionObject(structureDefinition) + }; + + AddEncodingNodes(mockResolver, structureNode, namespaceIndex, ref nodeId); + mockResolver.DataTypeNodes[enumNode.NodeId] = enumNode; + mockResolver.DataTypeNodes[structureNode.NodeId] = structureNode; + + ComplexTypeSystem typeSystem = ComplexTypeSystem.Create(mockResolver, telemetry); + bool loaded = await typeSystem.LoadAsync(throwOnError: true).ConfigureAwait(false); + + var registry = new DataTypeDefinitionRegistry(); + DataTypeDefinitionRegistry returnedRegistry = typeSystem.RegisterDataTypeDefinitions(registry); + + bool structureResolved = registry.TryResolve( + new ExpandedNodeId(structureNode.NodeId), + out UaTypeDescription structureDescription); + bool enumResolved = registry.TryResolve( + new ExpandedNodeId(enumNode.NodeId), + out UaTypeDescription enumDescription); + var provider = new DefaultSchemaProvider(registry, [CreateJsonSchemaGenerator()]); + bool schemaResolved = provider.TryGetSchema( + new ExpandedNodeId(structureNode.NodeId), + UaSchemaFormat.JsonCompact, + UaSchemaScope.Type, + out IUaSchema schema); + + Assert.Multiple(() => + { + Assert.That(loaded, Is.True); + Assert.That(returnedRegistry, Is.SameAs(registry)); + Assert.That(structureResolved, Is.True); + Assert.That(structureDescription, Is.Not.Null); + Assert.That(structureDescription!.TypeId.InnerNodeId, Is.EqualTo(structureNode.NodeId)); + Assert.That(structureDescription.BrowseName, Is.EqualTo(structureNode.BrowseName)); + Assert.That(structureDescription.Definition, Is.SameAs(structureDefinition)); + Assert.That( + structureDescription.NamespaceUri, + Is.EqualTo(Namespaces.MockResolverUrl)); + Assert.That(enumResolved, Is.True); + Assert.That(enumDescription, Is.Not.Null); + Assert.That(enumDescription!.TypeId.InnerNodeId, Is.EqualTo(enumNode.NodeId)); + Assert.That(enumDescription.BrowseName, Is.EqualTo(enumNode.BrowseName)); + Assert.That(enumDescription.Definition, Is.SameAs(enumDefinition)); + Assert.That(schemaResolved, Is.True); + Assert.That(schema, Is.Not.Null); + Assert.That(schema!.ToSchemaString(), Does.Contain("VehicleType")); + }); + } + + private static IUaSchemaGenerator CreateJsonSchemaGenerator() + { + Type generatorType = typeof(JsonSchemaDocument).Assembly.GetType( + "Opc.Ua.Schema.Json.JsonSchemaGenerator", + throwOnError: true)!; + return (IUaSchemaGenerator)Activator.CreateInstance(generatorType, nonPublic: true)!; + } + + private static void AddEncodingNodes( + MockResolver mockResolver, + DataTypeNode dataTypeNode, + ushort namespaceIndex, + ref uint nodeId) + { + AddEncodingNode(mockResolver, dataTypeNode, BrowseNames.DefaultBinary, namespaceIndex, ref nodeId); + AddEncodingNode(mockResolver, dataTypeNode, BrowseNames.DefaultXml, namespaceIndex, ref nodeId); + } + + private static void AddEncodingNode( + MockResolver mockResolver, + DataTypeNode dataTypeNode, + string browseName, + ushort namespaceIndex, + ref uint nodeId) + { + var description = new ReferenceDescription + { + NodeId = new NodeId(nodeId++, namespaceIndex), + ReferenceTypeId = ReferenceTypeIds.HasEncoding, + BrowseName = QualifiedName.From(browseName), + DisplayName = LocalizedText.From(browseName), + IsForward = true, + NodeClass = NodeClass.Object + }; + var encoding = new Node(description); + var reference = new ReferenceNode + { + ReferenceTypeId = ReferenceTypeIds.HasEncoding, + IsInverse = false, + TargetId = description.NodeId + }; + + mockResolver.DataTypeNodes[encoding.NodeId] = encoding; + dataTypeNode.References += reference; + } + } +} diff --git a/Tests/Opc.Ua.Core.Schema.Tests/BsdSchemaGeneratorTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/BsdSchemaGeneratorTests.cs new file mode 100644 index 0000000000..62d84b413a --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/BsdSchemaGeneratorTests.cs @@ -0,0 +1,252 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Linq; +using System.Xml.Linq; +using NUnit.Framework; +using Opc.Ua.Schema.Bsd; + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Tests for the OPC Binary schema generation of OPC UA data types. + /// + [TestFixture] + [Category("Schema")] + public class BsdSchemaGeneratorTests + { + [Test] + public void StructureProducesFieldsForBuiltInOptionalArrayAndReferencedTypes() + { + UaTypeDescription inner = SchemaTestData.Structure( + 3202, + "Inner", + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32))); + UaTypeDescription color = SchemaTestData.Enumeration(3203, "Color", ("Red", 0), ("Green", 1)); + UaTypeDescription outer = SchemaTestData.Structure( + 3201, + "Outer", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Name", SchemaTestData.BuiltIn(BuiltInType.String), optional: true), + SchemaTestData.Field("Values", SchemaTestData.BuiltIn(BuiltInType.Double), ValueRanks.OneDimension), + SchemaTestData.Field("Child", new NodeId(3202, SchemaTestData.TestNamespaceIndex)), + SchemaTestData.Field("Shade", new NodeId(3203, SchemaTestData.TestNamespaceIndex))); + DefaultSchemaProvider provider = CreateProvider(inner, color, outer); + + BinarySchemaDocument schema = (BinarySchemaDocument)provider.GetBinarySchema(outer); + XDocument document = XDocument.Parse(schema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(schema.Format, Is.EqualTo(UaSchemaFormat.Bsd)); + Assert.That(schema.MediaType, Is.EqualTo("application/xml")); + Assert.That(FieldAttribute(document, "Id", "TypeName"), Is.EqualTo("opc:Int32")); + Assert.That(FieldAttribute(document, "NameSpecified", "TypeName"), Is.EqualTo("opc:Bit")); + Assert.That(FieldAttribute(document, "Name", "SwitchField"), Is.EqualTo("NameSpecified")); + Assert.That(FieldAttribute(document, "Name", "TypeName"), Is.EqualTo("opc:CharArray")); + Assert.That(FieldAttribute(document, "NoOfValues", "TypeName"), Is.EqualTo("opc:Int32")); + Assert.That(FieldAttribute(document, "Values", "LengthField"), Is.EqualTo("NoOfValues")); + Assert.That(FieldAttribute(document, "Child", "TypeName"), Is.EqualTo("tns:Inner")); + Assert.That(FieldAttribute(document, "Shade", "TypeName"), Is.EqualTo("tns:Color")); + }); + } + + [Test] + public void EnumProducesEnumeratedTypeWithValues() + { + UaTypeDescription color = SchemaTestData.Enumeration(3203, "Color", ("Red", 0), ("Green", 1)); + DefaultSchemaProvider provider = CreateProvider(color); + + BinarySchemaDocument schema = (BinarySchemaDocument)provider.GetBinarySchema(color); + XDocument document = XDocument.Parse(schema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(HasType(document, "EnumeratedType", "Color"), Is.True); + Assert.That(TypeAttribute(document, "EnumeratedType", "Color", "LengthInBits"), Is.EqualTo("32")); + Assert.That(EnumeratedValue(document, "Red"), Is.EqualTo("0")); + Assert.That(EnumeratedValue(document, "Green"), Is.EqualTo("1")); + }); + } + + [Test] + public void UnionProducesSwitchFieldAndSwitchedMembers() + { + UaTypeDescription choice = SchemaTestData.Union( + 3220, + "Choice", + SchemaTestData.Field("Number", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Text", SchemaTestData.BuiltIn(BuiltInType.String))); + DefaultSchemaProvider provider = CreateProvider(choice); + + BinarySchemaDocument schema = (BinarySchemaDocument)provider.GetBinarySchema(choice); + XDocument document = XDocument.Parse(schema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(FieldAttribute(document, "SwitchField", "TypeName"), Is.EqualTo("opc:UInt32")); + Assert.That(FieldAttribute(document, "Number", "SwitchField"), Is.EqualTo("SwitchField")); + Assert.That(FieldAttribute(document, "Number", "SwitchValue"), Is.EqualTo("1")); + Assert.That(FieldAttribute(document, "Text", "SwitchValue"), Is.EqualTo("2")); + }); + } + + [Test] + public void NamespaceScopeIncludesAllNamespaceTypesAndStandardImport() + { + UaTypeDescription inner = SchemaTestData.Structure( + 3202, + "Inner", + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32))); + UaTypeDescription outer = SchemaTestData.Structure( + 3201, + "Outer", + SchemaTestData.Field("Child", new NodeId(3202, SchemaTestData.TestNamespaceIndex))); + DefaultSchemaProvider provider = CreateProvider(inner, outer); + + BinarySchemaDocument schema = (BinarySchemaDocument)provider.GetBinarySchema(outer, UaSchemaScope.Namespace); + XDocument document = XDocument.Parse(schema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(HasType(document, "StructuredType", "Inner"), Is.True); + Assert.That(HasType(document, "StructuredType", "Outer"), Is.True); + Assert.That(document.Descendants(Opc("Import")).Any( + x => (string?)x.Attribute("Namespace") == "http://opcfoundation.org/UA/"), Is.True); + Assert.That(schema.Dictionary.Items, Has.Length.EqualTo(2)); + }); + } + + [Test] + public void OptionalStructEmitsLeadingEncodingMask() + { + UaTypeDescription type = SchemaTestData.Structure( + 3210, + "OptionalType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Note", SchemaTestData.BuiltIn(BuiltInType.String), optional: true)); + DefaultSchemaProvider provider = CreateProvider(type); + + BinarySchemaDocument schema = (BinarySchemaDocument)provider.GetBinarySchema(type); + XDocument document = XDocument.Parse(schema.ToSchemaString()); + var fieldNames = document.Descendants(Opc("Field")) + .Select(x => (string?)x.Attribute("Name")) + .ToList(); + + Assert.Multiple(() => + { + Assert.That(FieldAttribute(document, "NoteSpecified", "TypeName"), Is.EqualTo("opc:Bit")); + Assert.That(FieldAttribute(document, "Reserved1", "TypeName"), Is.EqualTo("opc:Bit")); + Assert.That(FieldAttribute(document, "Reserved1", "Length"), Is.EqualTo("31")); + Assert.That(FieldAttribute(document, "Note", "SwitchField"), Is.EqualTo("NoteSpecified")); + Assert.That(fieldNames.IndexOf("NoteSpecified"), Is.LessThan(fieldNames.IndexOf("Id"))); + Assert.That(fieldNames.IndexOf("Reserved1"), Is.LessThan(fieldNames.IndexOf("Id"))); + }); + } + + [Test] + public void CrossNamespaceReferenceProducesImportAndPrefixedType() + { + UaTypeDescription foreign = SchemaTestData.Structure( + 3231, + "Inner", + SchemaTestData.OtherNamespace, + SchemaTestData.OtherNamespaceIndex, + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32))); + UaTypeDescription outer = SchemaTestData.Structure( + 3230, + "Outer", + SchemaTestData.Field("Child", new NodeId(3231, SchemaTestData.OtherNamespaceIndex))); + DefaultSchemaProvider provider = CreateProvider(foreign, outer); + + BinarySchemaDocument schema = (BinarySchemaDocument)provider.GetBinarySchema(outer); + XDocument document = XDocument.Parse(schema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(document.Root!.Attribute(XNamespace.Xmlns + "n1")!.Value, + Is.EqualTo(SchemaTestData.OtherNamespace)); + Assert.That(document.Descendants(Opc("Import")).Any( + x => (string?)x.Attribute("Namespace") == SchemaTestData.OtherNamespace), Is.True); + Assert.That(FieldAttribute(document, "Child", "TypeName"), Is.EqualTo("n1:Inner")); + }); + } + + private static DefaultSchemaProvider CreateProvider(params UaTypeDescription[] types) + { + var registry = new DataTypeDefinitionRegistry(); + foreach (UaTypeDescription type in types) + { + registry.Add(type); + } + return new DefaultSchemaProvider(registry, [new BsdSchemaGenerator()]); + } + + private static bool HasType(XDocument document, string typeElement, string name) + { + return document.Descendants(Opc(typeElement)).Any(x => (string?)x.Attribute("Name") == name); + } + + private static string? TypeAttribute( + XDocument document, + string typeElement, + string name, + string attributeName) + { + return document + .Descendants(Opc(typeElement)) + .First(x => (string?)x.Attribute("Name") == name) + .Attribute(attributeName) + ?.Value; + } + + private static string? FieldAttribute(XDocument document, string name, string attributeName) + { + return document + .Descendants(Opc("Field")) + .First(x => (string?)x.Attribute("Name") == name) + .Attribute(attributeName) + ?.Value; + } + + private static string? EnumeratedValue(XDocument document, string name) + { + return document + .Descendants(Opc("EnumeratedValue")) + .First(x => (string?)x.Attribute("Name") == name) + .Attribute("Value") + ?.Value; + } + + private static XName Opc(string name) + { + return XName.Get(name, "http://opcfoundation.org/BinarySchema/"); + } + } +} diff --git a/Tests/Opc.Ua.Core.Schema.Tests/BsdSchemaValidationTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/BsdSchemaValidationTests.cs new file mode 100644 index 0000000000..427500e02a --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/BsdSchemaValidationTests.cs @@ -0,0 +1,189 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Linq; +using System.Xml.Linq; +using NUnit.Framework; +using Opc.Ua.Schema.Bsd; + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Validation tests for generated OPC Binary schema documents. + /// + [TestFixture] + [Category("Schema")] + public class BsdSchemaValidationTests + { + [Test] + public void GeneratedBinarySchemasAreStructurallyValidForStructureEnumAndUnion() + { + UaTypeDescription inner = SchemaTestData.Structure( + 4202, + "ValidatedBsdInner", + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32))); + UaTypeDescription color = SchemaTestData.Enumeration( + 4203, + "ValidatedBsdColor", + ("Red", 0), + ("Green", 1)); + UaTypeDescription outer = SchemaTestData.Structure( + 4201, + "ValidatedBsdOuter", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Name", SchemaTestData.BuiltIn(BuiltInType.String), optional: true), + SchemaTestData.Field("Values", SchemaTestData.BuiltIn(BuiltInType.Double), ValueRanks.OneDimension), + SchemaTestData.Field("Child", new NodeId(4202, SchemaTestData.TestNamespaceIndex)), + SchemaTestData.Field("Shade", new NodeId(4203, SchemaTestData.TestNamespaceIndex))); + UaTypeDescription choice = SchemaTestData.Union( + 4210, + "ValidatedBsdChoice", + SchemaTestData.Field("Number", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Text", SchemaTestData.BuiltIn(BuiltInType.String))); + DefaultSchemaProvider provider = CreateProvider(inner, color, outer, choice); + + BinarySchemaDocument structureSchema = (BinarySchemaDocument)provider.GetBinarySchema(outer); + BinarySchemaDocument enumSchema = (BinarySchemaDocument)provider.GetBinarySchema(color); + BinarySchemaDocument unionSchema = (BinarySchemaDocument)provider.GetBinarySchema(choice); + XDocument structureDocument = XDocument.Parse(structureSchema.ToSchemaString()); + XDocument enumDocument = XDocument.Parse(enumSchema.ToSchemaString()); + XDocument unionDocument = XDocument.Parse(unionSchema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(structureSchema.Dictionary.Items, Has.Length.EqualTo(3)); + Assert.That(enumSchema.Dictionary.Items, Has.Length.EqualTo(1)); + Assert.That(unionSchema.Dictionary.Items, Has.Length.EqualTo(1)); + Assert.That(structureDocument.Descendants(Opc("Import")).Any( + x => (string?)x.Attribute("Namespace") == "http://opcfoundation.org/UA/"), Is.True); + Assert.That(HasType(structureDocument, "StructuredType", "ValidatedBsdInner"), Is.True); + Assert.That(HasType(structureDocument, "EnumeratedType", "ValidatedBsdColor"), Is.True); + Assert.That(HasType(structureDocument, "StructuredType", "ValidatedBsdOuter"), Is.True); + Assert.That(HasType(enumDocument, "EnumeratedType", "ValidatedBsdColor"), Is.True); + Assert.That(EnumeratedValue(enumDocument, "Red"), Is.EqualTo("0")); + Assert.That(EnumeratedValue(enumDocument, "Green"), Is.EqualTo("1")); + Assert.That(FieldAttribute(structureDocument, "NameSpecified", "TypeName"), Is.EqualTo("opc:Bit")); + Assert.That(FieldAttribute(structureDocument, "Reserved1", "Length"), Is.EqualTo("31")); + Assert.That(FieldAttribute(structureDocument, "Name", "SwitchField"), Is.EqualTo("NameSpecified")); + Assert.That(FieldAttribute(structureDocument, "Values", "LengthField"), Is.EqualTo("NoOfValues")); + Assert.That(FieldAttribute(structureDocument, "Child", "TypeName"), Is.EqualTo("tns:ValidatedBsdInner")); + Assert.That(FieldAttribute(structureDocument, "Shade", "TypeName"), Is.EqualTo("tns:ValidatedBsdColor")); + Assert.That(FieldAttribute(unionDocument, "SwitchField", "TypeName"), Is.EqualTo("opc:UInt32")); + Assert.That(FieldAttribute(unionDocument, "Number", "SwitchField"), Is.EqualTo("SwitchField")); + Assert.That(FieldAttribute(unionDocument, "Number", "SwitchValue"), Is.EqualTo("1")); + Assert.That(FieldAttribute(unionDocument, "Text", "SwitchValue"), Is.EqualTo("2")); + }); + } + + [Test] + public void CrossNamespaceReferenceProducesImportAndForeignTypeName() + { + const string foreignNamespace = "http://validation.other.test.org/UA/schema"; + const ushort foreignNamespaceIndex = 8; + UaTypeDescription foreign = CreateForeignStructure(foreignNamespace, foreignNamespaceIndex); + UaTypeDescription outer = SchemaTestData.Structure( + 4220, + "ValidatedBsdCrossNamespaceOuter", + SchemaTestData.Field("Foreign", new NodeId(4221, foreignNamespaceIndex))); + DefaultSchemaProvider provider = CreateProvider(foreign, outer); + + BinarySchemaDocument schema = (BinarySchemaDocument)provider.GetBinarySchema(outer); + XDocument document = XDocument.Parse(schema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(document.Descendants(Opc("Import")).Any( + x => (string?)x.Attribute("Namespace") == foreignNamespace), Is.True); + Assert.That(document.Root!.Attribute(XNamespace.Xmlns + "n1")!.Value, Is.EqualTo(foreignNamespace)); + Assert.That(FieldAttribute(document, "Foreign", "TypeName"), Is.EqualTo("n1:ValidatedBsdForeign")); + Assert.That(FieldAttribute(document, "Foreign", "TypeName"), Is.Not.EqualTo("tns:ValidatedBsdForeign")); + }); + } + + private static DefaultSchemaProvider CreateProvider(params UaTypeDescription[] types) + { + var registry = new DataTypeDefinitionRegistry(); + foreach (UaTypeDescription type in types) + { + registry.Add(type); + } + return new DefaultSchemaProvider(registry, [new BsdSchemaGenerator()]); + } + + private static UaTypeDescription CreateForeignStructure(string namespaceUri, ushort namespaceIndex) + { + var definition = new StructureDefinition + { + BaseDataType = DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = + [ + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32)) + ] + }; + return new UaTypeDescription( + new ExpandedNodeId(new NodeId(4221, namespaceIndex)), + new QualifiedName("ValidatedBsdForeign", namespaceIndex), + definition, + namespaceUri); + } + + // BinarySchemaValidator resolves imports by namespace but the generated standard UA import has no location. + // Keeping this test offline is therefore more deterministic with structural XML assertions over the emitted BSD. + private static bool HasType(XDocument document, string typeElement, string name) + { + return document.Descendants(Opc(typeElement)).Any(x => (string?)x.Attribute("Name") == name); + } + + private static string? FieldAttribute(XDocument document, string name, string attributeName) + { + return document + .Descendants(Opc("Field")) + .First(x => (string?)x.Attribute("Name") == name) + .Attribute(attributeName) + ?.Value; + } + + + private static string? EnumeratedValue(XDocument document, string name) + { + return document + .Descendants(Opc("EnumeratedValue")) + .First(x => (string?)x.Attribute("Name") == name) + .Attribute("Value") + ?.Value; + } + + private static XName Opc(string name) + { + return XName.Get(name, "http://opcfoundation.org/BinarySchema/"); + } + } +} + diff --git a/Tests/Opc.Ua.Core.Schema.Tests/BuiltInTypeMappingTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/BuiltInTypeMappingTests.cs new file mode 100644 index 0000000000..190cd89675 --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/BuiltInTypeMappingTests.cs @@ -0,0 +1,166 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Collections.Generic; +using System.Text.Json.Nodes; +using NUnit.Framework; +using Opc.Ua.Schema.Json; + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Tests for Part 6 JSON mappings of OPC UA built-in data types. + /// + [TestFixture] + [Category("Schema")] + public class BuiltInTypeMappingTests + { + [Test] + public void CompactJsonMapsBuiltInFieldsToPartSixShapes() + { + UaTypeDescription type = SchemaTestData.Structure( + 3301, + "AllBuiltIns", + Field(BuiltInType.Boolean), + Field(BuiltInType.SByte), + Field(BuiltInType.Byte), + Field(BuiltInType.Int16), + Field(BuiltInType.UInt16), + Field(BuiltInType.Int32), + Field(BuiltInType.UInt32), + Field(BuiltInType.Int64), + Field(BuiltInType.UInt64), + Field(BuiltInType.Float), + Field(BuiltInType.Double), + Field(BuiltInType.String), + Field(BuiltInType.DateTime), + Field(BuiltInType.Guid), + Field(BuiltInType.ByteString), + Field(BuiltInType.XmlElement), + Field(BuiltInType.NodeId), + Field(BuiltInType.StatusCode), + Field(BuiltInType.QualifiedName), + Field(BuiltInType.LocalizedText), + Field(BuiltInType.ExtensionObject), + Field(BuiltInType.DataValue), + Field(BuiltInType.Variant), + Field(BuiltInType.DiagnosticInfo), + Field(BuiltInType.Enumeration)); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonCompact); + + JsonObject properties = Definition(schema, "AllBuiltIns")["properties"]!.AsObject(); + JsonObject definitions = Definitions(schema); + Assert.Multiple(() => + { + Assert.That(TypeName(properties, "Boolean"), Is.EqualTo("boolean")); + Assert.That(TypeName(properties, "SByte"), Is.EqualTo("integer")); + Assert.That(TypeName(properties, "Byte"), Is.EqualTo("integer")); + Assert.That(TypeName(properties, "Int16"), Is.EqualTo("integer")); + Assert.That(TypeName(properties, "UInt16"), Is.EqualTo("integer")); + Assert.That(TypeName(properties, "Int32"), Is.EqualTo("integer")); + Assert.That(TypeName(properties, "UInt32"), Is.EqualTo("integer")); + Assert.That(TypeName(properties, "Int64"), Is.EqualTo("string")); + Assert.That(TypeName(properties, "UInt64"), Is.EqualTo("string")); + Assert.That(TypeNames(properties, "Float"), Is.EquivalentTo(s_numberOrStringTypes)); + Assert.That(TypeNames(properties, "Double"), Is.EquivalentTo(s_numberOrStringTypes)); + Assert.That(TypeName(properties, "String"), Is.EqualTo("string")); + Assert.That(TypeName(properties, "DateTime"), Is.EqualTo("string")); + Assert.That(PropertyValue(properties, "DateTime", "format"), Is.EqualTo("date-time")); + Assert.That(TypeName(properties, "Guid"), Is.EqualTo("string")); + Assert.That(PropertyValue(properties, "Guid", "format"), Is.EqualTo("uuid")); + Assert.That(TypeName(properties, "ByteString"), Is.EqualTo("string")); + Assert.That(PropertyValue(properties, "ByteString", "contentEncoding"), Is.EqualTo("base64")); + Assert.That(TypeName(properties, "XmlElement"), Is.EqualTo("string")); + Assert.That(Reference(properties, "NodeId"), Is.EqualTo("#/$defs/Ua_NodeId")); + Assert.That(TypeName(properties, "StatusCode"), Is.EqualTo("integer")); + Assert.That(Reference(properties, "QualifiedName"), Is.EqualTo("#/$defs/Ua_QualifiedName")); + Assert.That(Reference(properties, "LocalizedText"), Is.EqualTo("#/$defs/Ua_LocalizedText")); + Assert.That(Reference(properties, "ExtensionObject"), Is.EqualTo("#/$defs/Ua_ExtensionObject")); + Assert.That(Reference(properties, "DataValue"), Is.EqualTo("#/$defs/Ua_DataValue")); + Assert.That(Reference(properties, "Variant"), Is.EqualTo("#/$defs/Ua_Variant")); + Assert.That(Reference(properties, "DiagnosticInfo"), Is.EqualTo("#/$defs/Ua_DiagnosticInfo")); + Assert.That(TypeName(properties, "Enumeration"), Is.EqualTo("integer")); + Assert.That(definitions.ContainsKey("Ua_NodeId"), Is.True); + Assert.That(definitions.ContainsKey("Ua_QualifiedName"), Is.True); + Assert.That(definitions.ContainsKey("Ua_LocalizedText"), Is.True); + Assert.That(definitions.ContainsKey("Ua_ExtensionObject"), Is.True); + Assert.That(definitions.ContainsKey("Ua_DataValue"), Is.True); + Assert.That(definitions.ContainsKey("Ua_Variant"), Is.True); + Assert.That(definitions.ContainsKey("Ua_DiagnosticInfo"), Is.True); + }); + } + + private static StructureField Field(BuiltInType builtInType) + { + return SchemaTestData.Field(builtInType.ToString(), SchemaTestData.BuiltIn(builtInType)); + } + + private static JsonObject Definitions(IUaSchema schema) + { + return ((JsonSchemaDocument)schema).Root["$defs"]!.AsObject(); + } + + private static JsonObject Definition(IUaSchema schema, string name) + { + return Definitions(schema)[name]!.AsObject(); + } + + private static string TypeName(JsonObject properties, string name) + { + return properties[name]!["type"]!.GetValue(); + } + + private static List TypeNames(JsonObject properties, string name) + { + var result = new List(); + foreach (JsonNode? node in properties[name]!["type"]!.AsArray()) + { + if (node != null) + { + result.Add(node.GetValue()); + } + } + return result; + } + + private static string Reference(JsonObject properties, string name) + { + return properties[name]!["$ref"]!.GetValue(); + } + + private static string PropertyValue(JsonObject properties, string name, string propertyName) + { + return properties[name]![propertyName]!.GetValue(); + } + + private static readonly string[] s_numberOrStringTypes = ["number", "string"]; + } +} diff --git a/Tests/Opc.Ua.Core.Schema.Tests/DataTypeNodeRegistrationTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/DataTypeNodeRegistrationTests.cs new file mode 100644 index 0000000000..15a0b606b3 --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/DataTypeNodeRegistrationTests.cs @@ -0,0 +1,91 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using NUnit.Framework; + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Tests for registering a data type from an address-space data type node. + /// + [TestFixture] + [Category("Schema")] + public class DataTypeNodeRegistrationTests + { + [Test] + public void TryAddDataTypeRegistersNodeDefinition() + { + var definition = new StructureDefinition + { + BaseDataType = DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = new[] + { + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32)) + } + }; + var node = new DataTypeNode + { + NodeId = new NodeId(3001, SchemaTestData.TestNamespaceIndex), + BrowseName = new QualifiedName("NodeType", SchemaTestData.TestNamespaceIndex), + DataTypeDefinition = new ExtensionObject(definition) + }; + var registry = new DataTypeDefinitionRegistry(); + + bool added = registry.TryAddDataType(node); + + var provider = new DefaultSchemaProvider(registry, [new Json.JsonSchemaGenerator()]); + bool resolved = provider.TryGetSchema( + new ExpandedNodeId(new NodeId(3001, SchemaTestData.TestNamespaceIndex)), + UaSchemaFormat.JsonCompact, + UaSchemaScope.Type, + out IUaSchema? schema); + + Assert.Multiple(() => + { + Assert.That(added, Is.True); + Assert.That(resolved, Is.True); + Assert.That(schema, Is.Not.Null); + }); + } + + [Test] + public void TryAddDataTypeReturnsFalseWhenNoDefinition() + { + var node = new DataTypeNode + { + NodeId = new NodeId(3002, SchemaTestData.TestNamespaceIndex), + BrowseName = new QualifiedName("Empty", SchemaTestData.TestNamespaceIndex) + }; + var registry = new DataTypeDefinitionRegistry(); + + Assert.That(registry.TryAddDataType(node), Is.False); + } + } +} diff --git a/Tests/Opc.Ua.Core.Schema.Tests/EncodeableFactoryDefinitionSourceTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/EncodeableFactoryDefinitionSourceTests.cs new file mode 100644 index 0000000000..13b2fbf968 --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/EncodeableFactoryDefinitionSourceTests.cs @@ -0,0 +1,135 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Xml; +using Moq; +using NUnit.Framework; + +// The encodeable type registry API is experimental; the schema factory source +// is built on top of it. +#pragma warning disable UA_NETStandard_1 + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Tests for resolving data type definitions from the encodeable factory. + /// + [TestFixture] + [Category("Schema")] + public class EncodeableFactoryDefinitionSourceTests + { + [Test] + public void TryResolveReturnsDefinitionFromFactoryType() + { + StructureDefinition definition = CreateDefinition(); + var typeId = new ExpandedNodeId(new NodeId(4001, 1)); + IEncodeableFactory factory = CreateFactory(typeId, "FactoryType", definition); + var source = new EncodeableFactoryDefinitionSource(factory, new NamespaceTable()); + + bool resolved = source.TryResolve(typeId, out UaTypeDescription? description); + + Assert.Multiple(() => + { + Assert.That(resolved, Is.True); + Assert.That(description!.Definition, Is.SameAs(definition)); + Assert.That(description.Name, Is.EqualTo("FactoryType")); + }); + } + + [Test] + public void TryResolveReturnsFalseForUnknownType() + { + StructureDefinition definition = CreateDefinition(); + var knownId = new ExpandedNodeId(new NodeId(4001, 1)); + IEncodeableFactory factory = CreateFactory(knownId, "FactoryType", definition); + var source = new EncodeableFactoryDefinitionSource(factory, new NamespaceTable()); + + bool resolved = source.TryResolve(new ExpandedNodeId(new NodeId(9999, 1)), out UaTypeDescription? description); + + Assert.Multiple(() => + { + Assert.That(resolved, Is.False); + Assert.That(description, Is.Null); + }); + } + + [Test] + public void CompositeResolverFallsThroughToFactorySource() + { + StructureDefinition definition = CreateDefinition(); + var typeId = new ExpandedNodeId(new NodeId(4002, 1)); + IEncodeableFactory factory = CreateFactory(typeId, "CompositeType", definition); + var registry = new DataTypeDefinitionRegistry(); + var composite = new CompositeDataTypeDefinitionResolver( + [registry, new EncodeableFactoryDefinitionSource(factory, new NamespaceTable())]); + + bool resolved = composite.TryResolve(typeId, out UaTypeDescription? description); + + Assert.Multiple(() => + { + Assert.That(resolved, Is.True); + Assert.That(description!.Name, Is.EqualTo("CompositeType")); + }); + } + + private static StructureDefinition CreateDefinition() + { + return new StructureDefinition + { + BaseDataType = DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = new[] + { + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32)) + } + }; + } + + private static IEncodeableFactory CreateFactory( + ExpandedNodeId typeId, + string name, + DataTypeDefinition definition) + { + var typeMock = new Mock(); + typeMock.SetupGet(t => t.XmlName) + .Returns(new XmlQualifiedName(name, "http://test.org/factory")); + typeMock.As() + .Setup(s => s.GetDataTypeDefinition(It.IsAny())) + .Returns(definition); + + IEncodeableType? encodeableType = typeMock.Object; + IEnumeratedType? enumeratedType = null; + var factoryMock = new Mock(); + factoryMock.Setup(f => f.TryGetEncodeableType(typeId, out encodeableType)).Returns(true); + factoryMock.Setup(f => f.TryGetEnumeratedType( + It.IsAny(), out enumeratedType)).Returns(false); + return factoryMock.Object; + } + } +} diff --git a/Tests/Opc.Ua.Core.Schema.Tests/GeneratedTypeDefinitionTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/GeneratedTypeDefinitionTests.cs new file mode 100644 index 0000000000..2dafa31190 --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/GeneratedTypeDefinitionTests.cs @@ -0,0 +1,83 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using NUnit.Framework; +using Opc.Ua.Schema.Json; + +// The encodeable type registry API is experimental; the schema factory source +// is built on top of it. +#pragma warning disable UA_NETStandard_1 + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Tests that source-generated encodeable types expose their data type + /// definition through the encodeable factory. + /// + [TestFixture] + [Category("Schema")] + public class GeneratedTypeDefinitionTests + { + [Test] + public void GeneratedStructureActivatorExposesDefinition() + { + DataTypeDefinition? definition = + ArgumentActivator.Instance.GetDataTypeDefinition(new NamespaceTable()); + + Assert.That(definition, Is.InstanceOf()); + } + + [Test] + public void SchemaIsProducedFromGeneratedDefinition() + { + DataTypeDefinition definition = + ArgumentActivator.Instance.GetDataTypeDefinition(new NamespaceTable())!; + var typeId = new ExpandedNodeId(DataTypeIds.Argument); + var registry = new DataTypeDefinitionRegistry(); + registry.Add(new UaTypeDescription( + typeId, + new QualifiedName("Argument"), + definition, + Namespaces.OpcUa)); + var provider = new DefaultSchemaProvider(registry, [new JsonSchemaGenerator()]); + + bool resolved = provider.TryGetSchema( + typeId, + UaSchemaFormat.JsonCompact, + UaSchemaScope.Type, + out IUaSchema? schema); + + Assert.Multiple(() => + { + Assert.That(resolved, Is.True); + Assert.That(schema!.ToSchemaString(), Does.Contain("Argument")); + }); + } + } +} diff --git a/Tests/Opc.Ua.Core.Schema.Tests/JsonSchemaGeneratorTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/JsonSchemaGeneratorTests.cs new file mode 100644 index 0000000000..25ae6cef6b --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/JsonSchemaGeneratorTests.cs @@ -0,0 +1,467 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Collections.Generic; +using System.Text.Json.Nodes; +using NUnit.Framework; +using Opc.Ua.Schema.Json; + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Tests for the JSON Schema generation of OPC UA data types. + /// + [TestFixture] + [Category("Schema")] + public class JsonSchemaGeneratorTests + { + [Test] + public void CompactStructureProducesObjectSchemaWithProperties() + { + UaTypeDescription type = SchemaTestData.Structure( + 3001, + "SampleType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Name", SchemaTestData.BuiltIn(BuiltInType.String))); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonCompact); + + JsonObject definition = Definition(schema, "SampleType"); + JsonObject properties = definition["properties"]!.AsObject(); + Assert.Multiple(() => + { + Assert.That(definition["type"]!.GetValue(), Is.EqualTo("object")); + Assert.That(definition["additionalProperties"]!.GetValue(), Is.False); + Assert.That(properties["Id"]!["type"]!.GetValue(), Is.EqualTo("integer")); + Assert.That(properties["Name"]!["type"]!.GetValue(), Is.EqualTo("string")); + Assert.That(RequiredNames(definition), Does.Contain("Id")); + Assert.That(RequiredNames(definition), Does.Contain("Name")); + }); + } + + [Test] + public void OptionalFieldIsNotRequired() + { + UaTypeDescription type = SchemaTestData.Structure( + 3001, + "SampleType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Note", SchemaTestData.BuiltIn(BuiltInType.String), optional: true)); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonCompact); + + JsonObject definition = Definition(schema, "SampleType"); + Assert.Multiple(() => + { + Assert.That(RequiredNames(definition), Does.Contain("Id")); + Assert.That(RequiredNames(definition), Does.Not.Contain("Note")); + }); + } + + [Test] + public void ArrayFieldProducesArraySchema() + { + UaTypeDescription type = SchemaTestData.Structure( + 3001, + "SampleType", + SchemaTestData.Field( + "Items", + SchemaTestData.BuiltIn(BuiltInType.Int32), + ValueRanks.OneDimension)); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonCompact); + + JsonObject items = Definition(schema, "SampleType")["properties"]!["Items"]!.AsObject(); + Assert.Multiple(() => + { + Assert.That(items["type"]!.GetValue(), Is.EqualTo("array")); + Assert.That(items["items"]!["type"]!.GetValue(), Is.EqualTo("integer")); + }); + } + + [Test] + public void Int64FieldIsEncodedAsString() + { + UaTypeDescription type = SchemaTestData.Structure( + 3001, + "SampleType", + SchemaTestData.Field("Big", SchemaTestData.BuiltIn(BuiltInType.Int64))); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonCompact); + + JsonObject big = Definition(schema, "SampleType")["properties"]!["Big"]!.AsObject(); + Assert.That(big["type"]!.GetValue(), Is.EqualTo("string")); + } + + [Test] + public void ByteStringFieldIsEncodedAsBase64String() + { + UaTypeDescription type = SchemaTestData.Structure( + 3001, + "SampleType", + SchemaTestData.Field("Blob", SchemaTestData.BuiltIn(BuiltInType.ByteString))); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonCompact); + + JsonObject blob = Definition(schema, "SampleType")["properties"]!["Blob"]!.AsObject(); + Assert.Multiple(() => + { + Assert.That(blob["type"]!.GetValue(), Is.EqualTo("string")); + Assert.That(blob["contentEncoding"]!.GetValue(), Is.EqualTo("base64")); + }); + } + + [Test] + public void CompactEnumProducesIntegerWithOptions() + { + UaTypeDescription type = SchemaTestData.Enumeration( + 3010, + "Color", + ("Red", 0), + ("Green", 1), + ("Blue", 2)); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonCompact); + + JsonObject definition = Definition(schema, "Color"); + Assert.Multiple(() => + { + Assert.That(definition["type"]!.GetValue(), Is.EqualTo("integer")); + Assert.That(definition["oneOf"]!.AsArray(), Has.Count.EqualTo(3)); + }); + } + + [Test] + public void VerboseEnumProducesStringEnum() + { + UaTypeDescription type = SchemaTestData.Enumeration( + 3010, + "Color", + ("Red", 0), + ("Green", 1)); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonVerbose); + + JsonObject definition = Definition(schema, "Color"); + var names = new List(); + foreach (JsonNode? node in definition["enum"]!.AsArray()) + { + if (node != null) + { + names.Add(node.GetValue()); + } + } + Assert.Multiple(() => + { + Assert.That(definition["type"]!.GetValue(), Is.EqualTo("string")); + Assert.That(names, Does.Contain("Red_0")); + Assert.That(names, Does.Contain("Green_1")); + }); + } + + [Test] + public void ReferencedTypeProducesRefAndInlinesDependency() + { + UaTypeDescription inner = SchemaTestData.Structure( + 3002, + "Inner", + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32))); + UaTypeDescription outer = SchemaTestData.Structure( + 3001, + "Outer", + SchemaTestData.Field("Child", new NodeId(3002, SchemaTestData.TestNamespaceIndex))); + ISchemaProvider provider = SchemaTestData.CreateProvider(inner, outer); + + IUaSchema schema = provider.CreateSchema(outer, UaSchemaFormat.JsonCompact); + + JsonObject outerDefinition = Definition(schema, "Outer"); + string reference = outerDefinition["properties"]!["Child"]!["$ref"]!.GetValue(); + Assert.Multiple(() => + { + Assert.That(reference, Is.EqualTo("#/$defs/Inner")); + Assert.That(Definitions(schema).ContainsKey("Inner"), Is.True); + }); + } + + [Test] + public void CrossNamespaceTypesWithSameNameProduceDistinctDefinitions() + { + UaTypeDescription localDuplicate = SchemaTestData.Structure( + 3031, + "Duplicate", + SchemaTestData.Field("LocalValue", SchemaTestData.BuiltIn(BuiltInType.Int32))); + UaTypeDescription foreignDuplicate = SchemaTestData.Structure( + 3032, + "Duplicate", + SchemaTestData.OtherNamespace, + SchemaTestData.OtherNamespaceIndex, + SchemaTestData.Field("ForeignValue", SchemaTestData.BuiltIn(BuiltInType.String))); + UaTypeDescription outer = SchemaTestData.Structure( + 3030, + "Outer", + SchemaTestData.Field("Local", new NodeId(3031, SchemaTestData.TestNamespaceIndex)), + SchemaTestData.Field("Foreign", new NodeId(3032, SchemaTestData.OtherNamespaceIndex))); + ISchemaProvider provider = SchemaTestData.CreateProvider(localDuplicate, foreignDuplicate, outer); + + IUaSchema schema = provider.CreateSchema(outer, UaSchemaFormat.JsonCompact); + + JsonObject definitions = Definitions(schema); + JsonObject outerDefinition = Definition(schema, "Outer"); + string localReference = outerDefinition["properties"]!["Local"]!["$ref"]!.GetValue(); + string foreignReference = outerDefinition["properties"]!["Foreign"]!["$ref"]!.GetValue(); + string foreignKey = DefinitionName(foreignReference); + Assert.Multiple(() => + { + Assert.That(localReference, Is.EqualTo("#/$defs/Duplicate")); + Assert.That(foreignReference, Is.Not.EqualTo("#/$defs/Duplicate")); + Assert.That(definitions.ContainsKey("Duplicate"), Is.True); + Assert.That(definitions.ContainsKey(foreignKey), Is.True); + Assert.That(definitions[foreignKey]!["title"]!.GetValue(), Is.EqualTo("Duplicate")); + }); + } + + [Test] + public void AnyValueRankAllowsScalarOrUnconstrainedArray() + { + UaTypeDescription type = SchemaTestData.Structure( + 3033, + "AnyRankType", + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32), ValueRanks.Any)); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonCompact); + + JsonArray options = Definition(schema, "AnyRankType")["properties"]!["Value"]!["oneOf"]!.AsArray(); + Assert.Multiple(() => + { + Assert.That(options, Has.Count.EqualTo(2)); + Assert.That(options[0]!["type"]!.GetValue(), Is.EqualTo("integer")); + Assert.That(options[1]!["type"]!.GetValue(), Is.EqualTo("array")); + Assert.That(options[1]!.AsObject().ContainsKey("items"), Is.False); + }); + } + + [Test] + public void ScalarOrOneDimensionValueRankAllowsOnlyScalarOrOneDimensionalArray() + { + UaTypeDescription type = SchemaTestData.Structure( + 3034, + "ScalarOrArrayType", + SchemaTestData.Field( + "Value", + SchemaTestData.BuiltIn(BuiltInType.Int32), + ValueRanks.ScalarOrOneDimension)); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonCompact); + + JsonArray options = Definition(schema, "ScalarOrArrayType")["properties"]!["Value"]!["oneOf"]!.AsArray(); + Assert.Multiple(() => + { + Assert.That(options, Has.Count.EqualTo(2)); + Assert.That(options[0]!["type"]!.GetValue(), Is.EqualTo("integer")); + Assert.That(options[1]!["type"]!.GetValue(), Is.EqualTo("array")); + Assert.That(options[1]!["items"]!["type"]!.GetValue(), Is.EqualTo("integer")); + }); + } + + [Test] + public void UnionProducesOneOfSchema() + { + UaTypeDescription type = SchemaTestData.Union( + 3020, + "Choice", + SchemaTestData.Field("Number", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Text", SchemaTestData.BuiltIn(BuiltInType.String))); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonCompact); + + JsonObject definition = Definition(schema, "Choice"); + Assert.That(definition["oneOf"]!.AsArray(), Has.Count.EqualTo(2)); + } + + [Test] + public void NamespaceScopeIncludesAllTypes() + { + UaTypeDescription inner = SchemaTestData.Structure( + 3002, + "Inner", + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32))); + UaTypeDescription outer = SchemaTestData.Structure( + 3001, + "Outer", + SchemaTestData.Field("Child", new NodeId(3002, SchemaTestData.TestNamespaceIndex))); + ISchemaProvider provider = SchemaTestData.CreateProvider(inner, outer); + + IUaSchema schema = provider.CreateSchema(outer, UaSchemaFormat.JsonCompact, UaSchemaScope.Namespace); + + JsonObject definitions = Definitions(schema); + Assert.Multiple(() => + { + Assert.That(definitions.ContainsKey("Outer"), Is.True); + Assert.That(definitions.ContainsKey("Inner"), Is.True); + }); + } + + [Test] + public void GeneratedSchemaIsValidJsonWithDialect() + { + UaTypeDescription type = SchemaTestData.Structure( + 3001, + "SampleType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32))); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonCompact); + + string json = schema.ToSchemaString(); + Assert.Multiple(() => + { + Assert.That(() => JsonNode.Parse(json), Throws.Nothing); + Assert.That(((JsonSchemaDocument)schema).Root["$schema"]!.GetValue(), + Is.EqualTo(JsonSchemaConstants.Dialect)); + Assert.That(schema.MediaType, Is.EqualTo("application/schema+json")); + }); + } + + [Test] + public void CompactUnionIncludesSwitchField() + { + UaTypeDescription type = SchemaTestData.Union( + 3020, + "Choice", + SchemaTestData.Field("Number", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Text", SchemaTestData.BuiltIn(BuiltInType.String))); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonCompact); + + JsonObject firstOption = Definition(schema, "Choice")["oneOf"]!.AsArray()[0]!.AsObject(); + JsonObject switchField = firstOption["properties"]!["SwitchField"]!.AsObject(); + Assert.Multiple(() => + { + Assert.That(switchField["const"]!.GetValue(), Is.EqualTo(1)); + Assert.That(RequiredNames(firstOption), Does.Contain("SwitchField")); + }); + } + + [Test] + public void VerboseUnionOmitsSwitchField() + { + UaTypeDescription type = SchemaTestData.Union( + 3020, + "Choice", + SchemaTestData.Field("Number", SchemaTestData.BuiltIn(BuiltInType.Int32))); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonVerbose); + + JsonObject firstOption = Definition(schema, "Choice")["oneOf"]!.AsArray()[0]!.AsObject(); + Assert.That(firstOption["properties"]!.AsObject().ContainsKey("SwitchField"), Is.False); + } + + [Test] + public void CompactOptionalStructIncludesEncodingMask() + { + UaTypeDescription type = SchemaTestData.Structure( + 3001, + "OptionalType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Note", SchemaTestData.BuiltIn(BuiltInType.String), optional: true)); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonCompact); + + JsonObject definition = Definition(schema, "OptionalType"); + Assert.Multiple(() => + { + Assert.That(definition["properties"]!.AsObject().ContainsKey("EncodingMask"), Is.True); + Assert.That(RequiredNames(definition), Does.Contain("EncodingMask")); + Assert.That(RequiredNames(definition), Does.Not.Contain("Note")); + }); + } + + [Test] + public void VerboseOptionalStructOmitsEncodingMask() + { + UaTypeDescription type = SchemaTestData.Structure( + 3001, + "OptionalType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Note", SchemaTestData.BuiltIn(BuiltInType.String), optional: true)); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.CreateSchema(type, UaSchemaFormat.JsonVerbose); + + JsonObject definition = Definition(schema, "OptionalType"); + Assert.That(definition["properties"]!.AsObject().ContainsKey("EncodingMask"), Is.False); + } + + private static JsonObject Definitions(IUaSchema schema) + { + return ((JsonSchemaDocument)schema).Root["$defs"]!.AsObject(); + } + + private static JsonObject Definition(IUaSchema schema, string name) + { + return Definitions(schema)[name]!.AsObject(); + } + + private static string DefinitionName(string reference) + { + const string prefix = "#/$defs/"; + Assert.That(reference, Does.StartWith(prefix)); + return reference.Substring(prefix.Length); + } + + private static List RequiredNames(JsonObject definition) + { + var names = new List(); + if (definition["required"] is JsonArray array) + { + foreach (JsonNode? node in array) + { + if (node != null) + { + names.Add(node.GetValue()); + } + } + } + return names; + } + } +} diff --git a/Tests/Opc.Ua.Core.Schema.Tests/Opc.Ua.Core.Schema.Tests.csproj b/Tests/Opc.Ua.Core.Schema.Tests/Opc.Ua.Core.Schema.Tests.csproj new file mode 100644 index 0000000000..87747ca336 --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/Opc.Ua.Core.Schema.Tests.csproj @@ -0,0 +1,35 @@ + + + Exe + $(TestsTargetFrameworks) + false + enable + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/Tests/Opc.Ua.Core.Schema.Tests/Properties/AssemblyInfo.cs b/Tests/Opc.Ua.Core.Schema.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..2b9848014c --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +[assembly: CLSCompliant(false)] diff --git a/Tests/Opc.Ua.Core.Schema.Tests/SchemaProviderExtensionsTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/SchemaProviderExtensionsTests.cs new file mode 100644 index 0000000000..d62bf03762 --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/SchemaProviderExtensionsTests.cs @@ -0,0 +1,87 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using NUnit.Framework; + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Tests for the schema provider convenience extension methods. + /// + [TestFixture] + [Category("Schema")] + public class SchemaProviderExtensionsTests + { + [Test] + public void GetJsonSchemaUsesCompactByDefault() + { + UaTypeDescription type = SchemaTestData.Structure( + 3001, + "SampleType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32))); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.GetJsonSchema(type); + + Assert.That(schema.Format, Is.EqualTo(UaSchemaFormat.JsonCompact)); + } + + [Test] + public void GetJsonSchemaVerboseUsesVerboseFlavor() + { + UaTypeDescription type = SchemaTestData.Structure( + 3001, + "SampleType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32))); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + IUaSchema schema = provider.GetJsonSchema(type, verbose: true); + + Assert.That(schema.Format, Is.EqualTo(UaSchemaFormat.JsonVerbose)); + } + + [Test] + public void TryGetJsonSchemaResolvesRegisteredType() + { + UaTypeDescription type = SchemaTestData.Structure( + 3001, + "SampleType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32))); + ISchemaProvider provider = SchemaTestData.CreateProvider(type); + + bool resolved = provider.TryGetJsonSchema(type.TypeId, out IUaSchema? schema); + + Assert.Multiple(() => + { + Assert.That(resolved, Is.True); + Assert.That(schema, Is.Not.Null); + }); + } + } +} diff --git a/Tests/Opc.Ua.Core.Schema.Tests/SchemaSerializationTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/SchemaSerializationTests.cs new file mode 100644 index 0000000000..2fd92e3453 --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/SchemaSerializationTests.cs @@ -0,0 +1,202 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.IO; +using System.Text; +using System.Text.Json.Nodes; +using System.Xml.Linq; +using NUnit.Framework; +using Opc.Ua.Schema.Bsd; +using Opc.Ua.Schema.Json; +using Opc.Ua.Schema.Xsd; + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Tests for schema document serialization and provider edge cases. + /// + [TestFixture] + [Category("Schema")] + public class SchemaSerializationTests + { + [TestCase(UaSchemaFormat.JsonCompact, "application/schema+json")] + [TestCase(UaSchemaFormat.JsonVerbose, "application/schema+json")] + [TestCase(UaSchemaFormat.Xsd, "application/xml")] + [TestCase(UaSchemaFormat.Bsd, "application/xml")] + public void WriteToProducesSchemaStringContentAndExpectedMediaType( + UaSchemaFormat format, + string mediaType) + { + UaTypeDescription type = SchemaTestData.Structure( + 3401, + "SerializableType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Name", SchemaTestData.BuiltIn(BuiltInType.String))); + IUaSchema schema = CreateProvider(type).CreateSchema(type, format); + string expected = schema.ToSchemaString(); + + using var stream = new MemoryStream(); + schema.WriteTo(stream); + stream.Position = 0; + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + string streamText = reader.ReadToEnd(); + using var writer = new StringWriter(); + schema.WriteTo(writer); + + Assert.Multiple(() => + { + Assert.That(schema.MediaType, Is.EqualTo(mediaType)); + Assert.That(writer.ToString(), Is.EqualTo(expected)); + if (format is UaSchemaFormat.JsonCompact or UaSchemaFormat.JsonVerbose) + { + Assert.That(streamText, Is.EqualTo(expected)); + Assert.That(() => JsonNode.Parse(streamText), Throws.Nothing); + } + else + { + Assert.That(NormalizeXml(streamText), Is.EqualTo(NormalizeXml(expected))); + Assert.That(() => XDocument.Parse(streamText), Throws.Nothing); + } + }); + } + + [TestCase(UaSchemaFormat.JsonCompact)] + [TestCase(UaSchemaFormat.Xsd)] + [TestCase(UaSchemaFormat.Bsd)] + public void WriteToWithNullArgumentsThrowsArgumentNullException(UaSchemaFormat format) + { + UaTypeDescription type = SchemaTestData.Structure( + 3401, + "SerializableType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32))); + IUaSchema schema = CreateProvider(type).CreateSchema(type, format); + + Assert.Multiple(() => + { + Assert.That(() => schema.WriteTo((Stream)null!), Throws.TypeOf()); + Assert.That(() => schema.WriteTo((TextWriter)null!), Throws.TypeOf()); + }); + } + + [TestCase(UaSchemaFormat.JsonCompact)] + [TestCase(UaSchemaFormat.JsonVerbose)] + [TestCase(UaSchemaFormat.Xsd)] + [TestCase(UaSchemaFormat.Bsd)] + public void UnregisteredReferencedTypeProducesValidDocument(UaSchemaFormat format) + { + UaTypeDescription type = SchemaTestData.Structure( + 3401, + "HasUnknownReference", + SchemaTestData.Field("Unknown", new NodeId(9999, SchemaTestData.TestNamespaceIndex))); + IUaSchema schema = CreateProvider(type).CreateSchema(type, format); + string text = schema.ToSchemaString(); + + Assert.Multiple(() => + { + Assert.That(text, Is.Not.Empty); + if (format is UaSchemaFormat.JsonCompact or UaSchemaFormat.JsonVerbose) + { + Assert.That(() => JsonNode.Parse(text), Throws.Nothing); + } + else + { + Assert.That(() => XDocument.Parse(text), Throws.Nothing); + } + }); + } + + [Test] + public void TryGetSchemaReturnsFalseForUnknownTypeId() + { + UaTypeDescription type = SchemaTestData.Structure( + 3401, + "KnownType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32))); + DefaultSchemaProvider provider = CreateProvider(type); + + bool resolved = provider.TryGetSchema( + new ExpandedNodeId(new NodeId(9999, SchemaTestData.TestNamespaceIndex)), + UaSchemaFormat.JsonCompact, + UaSchemaScope.Type, + out IUaSchema? schema); + + Assert.Multiple(() => + { + Assert.That(resolved, Is.False); + Assert.That(schema, Is.Null); + }); + } + + [Test] + public void NullProviderAndTypeArgumentsThrowArgumentNullException() + { + UaTypeDescription type = SchemaTestData.Structure( + 3401, + "KnownType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32))); + DefaultSchemaProvider provider = CreateProvider(type); + ISchemaProvider? nullProvider = null; + + Assert.Multiple(() => + { + Assert.That( + () => new DefaultSchemaProvider(null!, Array.Empty()), + Throws.TypeOf()); + Assert.That( + () => new DefaultSchemaProvider(new DataTypeDefinitionRegistry(), null!), + Throws.TypeOf()); + Assert.That( + () => provider.CreateSchema(null!, UaSchemaFormat.JsonCompact), + Throws.TypeOf()); + Assert.That( + () => nullProvider!.GetJsonSchema(type), + Throws.TypeOf()); + }); + } + + private static DefaultSchemaProvider CreateProvider(params UaTypeDescription[] types) + { + var registry = new DataTypeDefinitionRegistry(); + foreach (UaTypeDescription type in types) + { + registry.Add(type); + } + + return new DefaultSchemaProvider( + registry, + [new JsonSchemaGenerator(), new XsdSchemaGenerator(), new BsdSchemaGenerator()]); + } + + private static string NormalizeXml(string xml) + { + return XDocument.Parse(xml).ToString(SaveOptions.DisableFormatting); + } + } +} diff --git a/Tests/Opc.Ua.Core.Schema.Tests/SchemaServiceCollectionExtensionsTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/SchemaServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..ed4d9d0933 --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/SchemaServiceCollectionExtensionsTests.cs @@ -0,0 +1,92 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Tests for the schema generation dependency injection registration. + /// + [TestFixture] + [Category("Schema")] + public class SchemaServiceCollectionExtensionsTests + { + [Test] + public void AddSchemaGenerationResolvesProviderAndResolvesRegisteredType() + { + var services = new ServiceCollection(); + services.AddOpcUa().AddSchemaGeneration(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + var registry = serviceProvider.GetRequiredService(); + UaTypeDescription type = SchemaTestData.Structure( + 3001, + "SampleType", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32))); + registry.Add(type); + + var provider = serviceProvider.GetRequiredService(); + bool resolved = provider.TryGetSchema( + type.TypeId, + UaSchemaFormat.JsonCompact, + UaSchemaScope.Type, + out IUaSchema? schema); + + Assert.Multiple(() => + { + Assert.That(resolved, Is.True); + Assert.That(schema, Is.Not.Null); + Assert.That(schema!.Format, Is.EqualTo(UaSchemaFormat.JsonCompact)); + }); + } + + [Test] + public void TryGetSchemaReturnsFalseForUnknownType() + { + var services = new ServiceCollection(); + services.AddOpcUa().AddSchemaGeneration(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + var provider = serviceProvider.GetRequiredService(); + bool resolved = provider.TryGetSchema( + new ExpandedNodeId(new NodeId(9999, 1)), + UaSchemaFormat.JsonCompact, + UaSchemaScope.Type, + out IUaSchema? schema); + + Assert.Multiple(() => + { + Assert.That(resolved, Is.False); + Assert.That(schema, Is.Null); + }); + } + } +} diff --git a/Tests/Opc.Ua.Core.Schema.Tests/SchemaTestData.cs b/Tests/Opc.Ua.Core.Schema.Tests/SchemaTestData.cs new file mode 100644 index 0000000000..e5e1569c79 --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/SchemaTestData.cs @@ -0,0 +1,186 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using Opc.Ua.Schema.Json; + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Helpers to build data type descriptions and providers for the schema + /// generation tests. + /// + internal static class SchemaTestData + { + /// + /// The namespace uri used by the test data types. + /// + public const string TestNamespace = "http://test.org/UA/schema"; + + /// + /// The namespace index used by the test data types. + /// + public const ushort TestNamespaceIndex = 1; + + /// + /// The namespace uri used by referenced test data types from another namespace. + /// + public const string OtherNamespace = "http://other.test.org/UA/schema"; + + /// + /// The namespace index used by referenced test data types from another namespace. + /// + public const ushort OtherNamespaceIndex = 2; + + /// + /// Creates a schema provider populated with the supplied data types. + /// + public static ISchemaProvider CreateProvider(params UaTypeDescription[] types) + { + var registry = new DataTypeDefinitionRegistry(); + foreach (UaTypeDescription type in types) + { + registry.Add(type); + } + return new DefaultSchemaProvider(registry, [new JsonSchemaGenerator()]); + } + + /// + /// Returns the node id of a standard built-in data type. + /// + public static NodeId BuiltIn(BuiltInType builtInType) + { + return new NodeId((uint)builtInType); + } + + /// + /// Creates a structure field. + /// + public static StructureField Field( + string name, + NodeId dataType, + int valueRank = ValueRanks.Scalar, + bool optional = false) + { + return new StructureField + { + Name = name, + DataType = dataType, + ValueRank = valueRank, + IsOptional = optional + }; + } + + /// + /// Creates a structure type description. + /// + public static UaTypeDescription Structure( + uint id, + string name, + params StructureField[] fields) + { + return Structure(id, name, TestNamespace, TestNamespaceIndex, fields); + } + + /// + /// Creates a structure type description in the specified namespace. + /// + public static UaTypeDescription Structure( + uint id, + string name, + string namespaceUri, + ushort namespaceIndex, + params StructureField[] fields) + { + return BuildStructure(id, name, namespaceUri, namespaceIndex, StructureType.Structure, fields); + } + + /// + /// Creates a union type description. + /// + public static UaTypeDescription Union( + uint id, + string name, + params StructureField[] fields) + { + return BuildStructure(id, name, TestNamespace, TestNamespaceIndex, StructureType.Union, fields); + } + + /// + /// Creates an enumeration type description. + /// + public static UaTypeDescription Enumeration( + uint id, + string name, + params (string Name, long Value)[] values) + { + var fields = new EnumField[values.Length]; + for (int i = 0; i < values.Length; i++) + { + fields[i] = new EnumField + { + Name = values[i].Name, + Value = values[i].Value + }; + } + var definition = new EnumDefinition { Fields = fields }; + return Describe(id, name, TestNamespace, TestNamespaceIndex, definition); + } + + private static UaTypeDescription BuildStructure( + uint id, + string name, + string namespaceUri, + ushort namespaceIndex, + StructureType structureType, + StructureField[] fields) + { + var definition = new StructureDefinition + { + BaseDataType = DataTypeIds.Structure, + StructureType = structureType, + Fields = fields + }; + return Describe(id, name, namespaceUri, namespaceIndex, definition); + } + + private static UaTypeDescription Describe( + uint id, + string name, + string namespaceUri, + ushort namespaceIndex, + DataTypeDefinition definition) + { + return new UaTypeDescription( + new ExpandedNodeId(new NodeId(id, namespaceIndex)), + new QualifiedName(name, namespaceIndex), + definition, + namespaceUri); + } + } +} diff --git a/Tests/Opc.Ua.Core.Schema.Tests/SchemaValidationIntegrationTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/SchemaValidationIntegrationTests.cs new file mode 100644 index 0000000000..11a83e7574 --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/SchemaValidationIntegrationTests.cs @@ -0,0 +1,171 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Text.Json.Nodes; +using Json.Schema; +using NUnit.Framework; +using Opc.Ua.Schema.Json; + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Validates generated runtime JSON schemas against stack-produced OPC UA JSON. + /// + [TestFixture] + [Category("Integration")] + public class SchemaValidationIntegrationTests + { + [Test] + public void GeneratedCompactRangeSchemaValidatesEncodedRange() + { + UaTypeDescription rangeType = SchemaTestData.Structure( + 884, + "Range", + SchemaTestData.Field("Low", SchemaTestData.BuiltIn(BuiltInType.Double)), + SchemaTestData.Field("High", SchemaTestData.BuiltIn(BuiltInType.Double))); + IUaSchema schema = SchemaTestData.CreateProvider(rangeType) + .CreateSchema(rangeType, UaSchemaFormat.JsonCompact); + JsonNode instance = EncodeEncodeable(new Opc.Ua.Range { Low = 1.0, High = 2.0 }); + + EvaluationResults results = Evaluate(schema, instance); + + Assert.That(results.IsValid, Is.True, results.ToString()); + } + + [Test] + public void GeneratedCompactEuInformationSchemaValidatesEncodedEuInformation() + { + UaTypeDescription euInformationType = SchemaTestData.Structure( + 887, + "EUInformation", + SchemaTestData.Field("NamespaceUri", SchemaTestData.BuiltIn(BuiltInType.String)), + SchemaTestData.Field("UnitId", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("DisplayName", SchemaTestData.BuiltIn(BuiltInType.LocalizedText)), + SchemaTestData.Field("Description", SchemaTestData.BuiltIn(BuiltInType.LocalizedText))); + IUaSchema schema = SchemaTestData.CreateProvider(euInformationType) + .CreateSchema(euInformationType, UaSchemaFormat.JsonCompact); + JsonNode instance = EncodeEncodeable( + new EUInformation + { + NamespaceUri = "http://www.opcfoundation.org/UA/units/un/cefact", + UnitId = 4408652, + DisplayName = new LocalizedText("en", "degree Celsius"), + Description = new LocalizedText("en", "degree Celsius") + }); + + EvaluationResults results = Evaluate(schema, instance); + + Assert.That(results.IsValid, Is.True, results.ToString()); + } + + [Test] + public void GeneratedCompactSchemaRejectsMissingRequiredField() + { + UaTypeDescription sampleType = SchemaTestData.Structure( + 3901, + "RequiredInt32Sample", + SchemaTestData.Field("RequiredValue", SchemaTestData.BuiltIn(BuiltInType.Int32))); + IUaSchema schema = SchemaTestData.CreateProvider(sampleType) + .CreateSchema(sampleType, UaSchemaFormat.JsonCompact); + + EvaluationResults results = Evaluate(schema, new JsonObject()); + + Assert.That(results.IsValid, Is.False, results.ToString()); + } + + [Test] + public void GeneratedCompactOptionalStructSchemaRequiresEncodingMask() + { + UaTypeDescription optionalType = SchemaTestData.Structure( + 3910, + "OptionalSample", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Note", SchemaTestData.BuiltIn(BuiltInType.String), optional: true)); + IUaSchema schema = SchemaTestData.CreateProvider(optionalType) + .CreateSchema(optionalType, UaSchemaFormat.JsonCompact); + + EvaluationResults withMask = Evaluate( + schema, + new JsonObject { ["EncodingMask"] = 0, ["Id"] = 5 }); + EvaluationResults withoutMask = Evaluate( + schema, + new JsonObject { ["Id"] = 5 }); + + Assert.Multiple(() => + { + Assert.That(withMask.IsValid, Is.True, withMask.ToString()); + Assert.That(withoutMask.IsValid, Is.False, withoutMask.ToString()); + }); + } + + [Test] + public void GeneratedCompactUnionSchemaRequiresSwitchField() + { + UaTypeDescription unionType = SchemaTestData.Union( + 3920, + "UnionSample", + SchemaTestData.Field("Number", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Text", SchemaTestData.BuiltIn(BuiltInType.String))); + IUaSchema schema = SchemaTestData.CreateProvider(unionType) + .CreateSchema(unionType, UaSchemaFormat.JsonCompact); + + EvaluationResults withSwitch = Evaluate( + schema, + new JsonObject { ["SwitchField"] = 1, ["Number"] = 7 }); + EvaluationResults withoutSwitch = Evaluate( + schema, + new JsonObject { ["Number"] = 7 }); + + Assert.Multiple(() => + { + Assert.That(withSwitch.IsValid, Is.True, withSwitch.ToString()); + Assert.That(withoutSwitch.IsValid, Is.False, withoutSwitch.ToString()); + }); + } + + private static JsonNode EncodeEncodeable(T value) + where T : IEncodeable, new() + { + using var encoder = new JsonEncoder(ServiceMessageContext.Create(null), JsonEncoderOptions.Compact); + encoder.WriteEncodeable("Value", value); + + JsonNode root = JsonNode.Parse(encoder.CloseAndReturnText()) + ?? throw new ServiceResultException(StatusCodes.BadEncodingError); + return root["Value"] ?? throw new ServiceResultException(StatusCodes.BadEncodingError); + } + + private static EvaluationResults Evaluate(IUaSchema schema, JsonNode instance) + { + JsonSchema jsonSchema = JsonSchema.FromText(schema.ToSchemaString()); + return jsonSchema.Evaluate( + instance, + new EvaluationOptions { OutputFormat = OutputFormat.List }); + } + } +} diff --git a/Tests/Opc.Ua.Core.Schema.Tests/XsdSchemaGeneratorTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/XsdSchemaGeneratorTests.cs new file mode 100644 index 0000000000..eebf88319c --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/XsdSchemaGeneratorTests.cs @@ -0,0 +1,210 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Linq; +using System.Xml.Linq; +using System.Xml.Schema; +using NUnit.Framework; +using Opc.Ua.Schema.Xsd; + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Tests for the XML Schema generation of OPC UA data types. + /// + [TestFixture] + [Category("Schema")] + public class XsdSchemaGeneratorTests + { + [Test] + public void StructureProducesElementsForBuiltInOptionalArrayAndReferencedFields() + { + UaTypeDescription inner = SchemaTestData.Structure( + 3102, + "Inner", + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32))); + UaTypeDescription color = SchemaTestData.Enumeration(3103, "Color", ("Red", 0), ("Green", 1)); + UaTypeDescription outer = SchemaTestData.Structure( + 3101, + "Outer", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Name", SchemaTestData.BuiltIn(BuiltInType.String), optional: true), + SchemaTestData.Field("Values", SchemaTestData.BuiltIn(BuiltInType.Double), ValueRanks.OneDimension), + SchemaTestData.Field("Child", new NodeId(3102, SchemaTestData.TestNamespaceIndex)), + SchemaTestData.Field("Shade", new NodeId(3103, SchemaTestData.TestNamespaceIndex))); + ISchemaProvider provider = CreateProvider(inner, color, outer); + + XmlSchemaDocument schema = (XmlSchemaDocument)provider.GetXmlSchema(outer); + XDocument document = XDocument.Parse(schema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(schema.Format, Is.EqualTo(UaSchemaFormat.Xsd)); + Assert.That(schema.MediaType, Is.EqualTo("application/xml")); + Assert.That(Attribute(document, "Id", "type"), Is.EqualTo("xs:int")); + Assert.That(Attribute(document, "Name", "minOccurs"), Is.EqualTo("0")); + Assert.That(Attribute(document, "Values", "nillable"), Is.EqualTo("true")); + Assert.That(document.ToString(), Does.Contain("maxOccurs=\"unbounded\"")); + Assert.That(Attribute(document, "Child", "type"), Is.EqualTo("tns:Inner")); + Assert.That(Attribute(document, "Shade", "type"), Is.EqualTo("tns:Color")); + Assert.That(() => Compile(schema), Throws.Nothing); + }); + } + + [Test] + public void EnumProducesStringRestrictionWithEnumerationFacets() + { + UaTypeDescription color = SchemaTestData.Enumeration(3103, "Color", ("Red", 0), ("Green", 1)); + ISchemaProvider provider = CreateProvider(color); + + XmlSchemaDocument schema = (XmlSchemaDocument)provider.GetXmlSchema(color); + XDocument document = XDocument.Parse(schema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(document.ToString(), Does.Contain("simpleType name=\"Color\"")); + Assert.That(document.ToString(), Does.Contain("restriction base=\"xs:string\"")); + Assert.That(document.ToString(), Does.Contain("enumeration value=\"Red_0\"")); + Assert.That(document.ToString(), Does.Contain("enumeration value=\"Green_1\"")); + Assert.That(() => Compile(schema), Throws.Nothing); + }); + } + + [Test] + public void UnionProducesChoiceWithSwitchField() + { + UaTypeDescription choice = SchemaTestData.Union( + 3120, + "Choice", + SchemaTestData.Field("Number", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Text", SchemaTestData.BuiltIn(BuiltInType.String))); + ISchemaProvider provider = CreateProvider(choice); + + XmlSchemaDocument schema = (XmlSchemaDocument)provider.GetXmlSchema(choice); + XDocument document = XDocument.Parse(schema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(Attribute(document, "SwitchField", "type"), Is.EqualTo("xs:unsignedInt")); + Assert.That(document.Descendants(Xsd("choice")).Any(), Is.True); + Assert.That(Attribute(document, "Number", "minOccurs"), Is.EqualTo("0")); + Assert.That(Attribute(document, "Text", "minOccurs"), Is.EqualTo("0")); + Assert.That(() => Compile(schema), Throws.Nothing); + }); + } + + [Test] + public void NamespaceScopeIncludesAllNamespaceTypes() + { + UaTypeDescription inner = SchemaTestData.Structure( + 3102, + "Inner", + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32))); + UaTypeDescription outer = SchemaTestData.Structure( + 3101, + "Outer", + SchemaTestData.Field("Child", new NodeId(3102, SchemaTestData.TestNamespaceIndex))); + ISchemaProvider provider = CreateProvider(inner, outer); + + XmlSchemaDocument schema = (XmlSchemaDocument)provider.GetXmlSchema(outer, UaSchemaScope.Namespace); + XDocument document = XDocument.Parse(schema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(HasComplexType(document, "Inner"), Is.True); + Assert.That(HasComplexType(document, "Outer"), Is.True); + Assert.That(() => Compile(schema), Throws.Nothing); + }); + } + + [Test] + public void CrossNamespaceReferenceProducesImportAndPrefixedType() + { + UaTypeDescription foreign = SchemaTestData.Structure( + 3131, + "Inner", + SchemaTestData.OtherNamespace, + SchemaTestData.OtherNamespaceIndex, + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32))); + UaTypeDescription outer = SchemaTestData.Structure( + 3130, + "Outer", + SchemaTestData.Field("Child", new NodeId(3131, SchemaTestData.OtherNamespaceIndex))); + ISchemaProvider provider = CreateProvider(foreign, outer); + + XmlSchemaDocument schema = (XmlSchemaDocument)provider.GetXmlSchema(outer); + XDocument document = XDocument.Parse(schema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(document.Root!.Attribute(XNamespace.Xmlns + "n1")!.Value, + Is.EqualTo(SchemaTestData.OtherNamespace)); + Assert.That(document.Descendants(Xsd("import")).Any( + x => (string?)x.Attribute("namespace") == SchemaTestData.OtherNamespace), Is.True); + Assert.That(Attribute(document, "Child", "type"), Is.EqualTo("n1:Inner")); + }); + } + + private static DefaultSchemaProvider CreateProvider(params UaTypeDescription[] types) + { + var registry = new DataTypeDefinitionRegistry(); + foreach (UaTypeDescription type in types) + { + registry.Add(type); + } + return new DefaultSchemaProvider(registry, [new XsdSchemaGenerator()]); + } + + private static void Compile(XmlSchemaDocument document) + { + var set = new XmlSchemaSet(); + set.Add(document.Schema); + set.Compile(); + } + + private static bool HasComplexType(XDocument document, string name) + { + return document.Descendants(Xsd("complexType")).Any(x => (string?)x.Attribute("name") == name); + } + + private static string? Attribute(XDocument document, string elementName, string attributeName) + { + return document + .Descendants(Xsd("element")) + .First(x => (string?)x.Attribute("name") == elementName) + .Attribute(attributeName) + ?.Value; + } + + private static XName Xsd(string name) + { + return XName.Get(name, XmlSchema.Namespace); + } + } +} diff --git a/Tests/Opc.Ua.Core.Schema.Tests/XsdSchemaValidationTests.cs b/Tests/Opc.Ua.Core.Schema.Tests/XsdSchemaValidationTests.cs new file mode 100644 index 0000000000..4e14635638 --- /dev/null +++ b/Tests/Opc.Ua.Core.Schema.Tests/XsdSchemaValidationTests.cs @@ -0,0 +1,210 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using System.Xml.Schema; +using NUnit.Framework; +using Opc.Ua.Schema.Xsd; + +namespace Opc.Ua.Schema.Tests +{ + /// + /// Validation tests for generated XML Schema documents. + /// + [TestFixture] + [Category("Schema")] + public class XsdSchemaValidationTests + { + [Test] + public void GeneratedStructureSchemaCompilesForTypeAndNamespaceScope() + { + UaTypeDescription inner = SchemaTestData.Structure( + 4102, + "ValidatedInner", + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32))); + UaTypeDescription color = SchemaTestData.Enumeration( + 4103, + "ValidatedColor", + ("Red", 0), + ("Green", 1)); + UaTypeDescription outer = SchemaTestData.Structure( + 4101, + "ValidatedOuter", + SchemaTestData.Field("Id", SchemaTestData.BuiltIn(BuiltInType.Int32)), + SchemaTestData.Field("Name", SchemaTestData.BuiltIn(BuiltInType.String), optional: true), + SchemaTestData.Field("Values", SchemaTestData.BuiltIn(BuiltInType.Double), ValueRanks.OneDimension), + SchemaTestData.Field("Child", new NodeId(4102, SchemaTestData.TestNamespaceIndex)), + SchemaTestData.Field("Shade", new NodeId(4103, SchemaTestData.TestNamespaceIndex))); + DefaultSchemaProvider provider = CreateProvider(inner, color, outer); + + XmlSchemaDocument typeSchema = (XmlSchemaDocument)provider.GetXmlSchema(outer); + XmlSchemaDocument namespaceSchema = (XmlSchemaDocument)provider.GetXmlSchema( + outer, + UaSchemaScope.Namespace); + XDocument typeDocument = XDocument.Parse(typeSchema.ToSchemaString()); + XDocument namespaceDocument = XDocument.Parse(namespaceSchema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(Compile(typeSchema), Is.Empty); + Assert.That(Compile(namespaceSchema), Is.Empty); + Assert.That(HasComplexType(typeDocument, "ValidatedInner"), Is.True); + Assert.That(HasSimpleType(typeDocument, "ValidatedColor"), Is.True); + Assert.That(HasComplexType(typeDocument, "ValidatedOuter"), Is.True); + Assert.That(HasComplexType(namespaceDocument, "ValidatedInner"), Is.True); + Assert.That(HasSimpleType(namespaceDocument, "ValidatedColor"), Is.True); + Assert.That(HasComplexType(namespaceDocument, "ValidatedOuter"), Is.True); + Assert.That(Attribute(typeDocument, "Name", "minOccurs"), Is.EqualTo("0")); + Assert.That(Attribute(typeDocument, "Values", "nillable"), Is.EqualTo("true")); + Assert.That(Attribute(typeDocument, "Child", "type"), Is.EqualTo("tns:ValidatedInner")); + Assert.That(Attribute(typeDocument, "Shade", "type"), Is.EqualTo("tns:ValidatedColor")); + }); + } + + [Test] + public void CrossNamespaceReferenceProducesImportAndForeignPrefix() + { + const string foreignNamespace = "http://validation.other.test.org/UA/schema"; + const ushort foreignNamespaceIndex = 7; + UaTypeDescription foreign = CreateForeignStructure(foreignNamespace, foreignNamespaceIndex); + UaTypeDescription outer = SchemaTestData.Structure( + 4110, + "ValidatedCrossNamespaceOuter", + SchemaTestData.Field("Foreign", new NodeId(4111, foreignNamespaceIndex))); + DefaultSchemaProvider provider = CreateProvider(foreign, outer); + + XmlSchemaDocument schema = (XmlSchemaDocument)provider.GetXmlSchema(outer); + XDocument document = XDocument.Parse(schema.ToSchemaString()); + + Assert.Multiple(() => + { + Assert.That(document.Descendants(Xsd("import")).Any( + x => (string?)x.Attribute("namespace") == foreignNamespace), Is.True); + Assert.That(document.Root!.Attribute(XNamespace.Xmlns + "n1")!.Value, Is.EqualTo(foreignNamespace)); + Assert.That(Attribute(document, "Foreign", "type"), Is.EqualTo("n1:ValidatedForeign")); + Assert.That(Attribute(document, "Foreign", "type"), Is.Not.EqualTo("tns:ValidatedForeign")); + }); + } + + private static DefaultSchemaProvider CreateProvider(params UaTypeDescription[] types) + { + var registry = new DataTypeDefinitionRegistry(); + foreach (UaTypeDescription type in types) + { + registry.Add(type); + } + return new DefaultSchemaProvider(registry, [new XsdSchemaGenerator()]); + } + + private static UaTypeDescription CreateForeignStructure(string namespaceUri, ushort namespaceIndex) + { + var definition = new StructureDefinition + { + BaseDataType = DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = + [ + SchemaTestData.Field("Value", SchemaTestData.BuiltIn(BuiltInType.Int32)) + ] + }; + return new UaTypeDescription( + new ExpandedNodeId(new NodeId(4111, namespaceIndex)), + new QualifiedName("ValidatedForeign", namespaceIndex), + definition, + namespaceUri); + } + + private static List Compile(XmlSchemaDocument document) + { + var errors = new List(); + var set = new XmlSchemaSet(); + set.ValidationEventHandler += (_, e) => errors.Add(e.Severity + ": " + e.Message); + + // The generated schema always imports the standard UA Types namespace. The validation fixtures use + // built-ins that are mapped to XML Schema primitives, so an empty in-memory stub keeps the compile offline. + AddSchema(set, UaTypesNamespace, CreateStubSchema(UaTypesNamespace)); + AddSchema(set, document.TargetNamespace, document.ToSchemaString()); + set.Compile(); + + if (!set.IsCompiled) + { + errors.Add("The XML schema set was not compiled."); + } + + if (set.Count == 0) + { + errors.Add("The XML schema set does not contain compiled schemas."); + } + + return errors; + } + + private static void AddSchema(XmlSchemaSet set, string targetNamespace, string schemaText) + { + using var reader = XmlReader.Create(new StringReader(schemaText)); + set.Add(targetNamespace, reader); + } + + private static string CreateStubSchema(string targetNamespace) + { + return ""; + } + + private static bool HasComplexType(XDocument document, string name) + { + return document.Descendants(Xsd("complexType")).Any(x => (string?)x.Attribute("name") == name); + } + + private static bool HasSimpleType(XDocument document, string name) + { + return document.Descendants(Xsd("simpleType")).Any(x => (string?)x.Attribute("name") == name); + } + + private static string? Attribute(XDocument document, string elementName, string attributeName) + { + return document + .Descendants(Xsd("element")) + .First(x => (string?)x.Attribute("name") == elementName) + .Attribute(attributeName) + ?.Value; + } + + private static XName Xsd(string name) + { + return XName.Get(name, XmlSchema.Namespace); + } + + private const string UaTypesNamespace = "http://opcfoundation.org/UA/2008/02/Types.xsd"; + } +} diff --git a/Tests/Opc.Ua.PubSub.Schema.Tests/Opc.Ua.PubSub.Schema.Tests.csproj b/Tests/Opc.Ua.PubSub.Schema.Tests/Opc.Ua.PubSub.Schema.Tests.csproj new file mode 100644 index 0000000000..615b36b664 --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Schema.Tests/Opc.Ua.PubSub.Schema.Tests.csproj @@ -0,0 +1,38 @@ + + + Exe + $(TestsTargetFrameworks) + Opc.Ua.PubSub.Schema.Tests + enable + false + false + $(NoWarn);CS1591;CA2007;CA2000;CA1014 + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/Tests/Opc.Ua.PubSub.Schema.Tests/Properties/AssemblyInfo.cs b/Tests/Opc.Ua.PubSub.Schema.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..1dd67e5791 --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Schema.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +[assembly: CLSCompliant(false)] diff --git a/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubEnvelopeSchemaTests.cs b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubEnvelopeSchemaTests.cs new file mode 100644 index 0000000000..3690452002 --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubEnvelopeSchemaTests.cs @@ -0,0 +1,224 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Text.Json.Nodes; +using NUnit.Framework; +using Opc.Ua.Schema.Json; + +namespace Opc.Ua.PubSub.Schema.Tests +{ + [TestFixture] + public class PubSubEnvelopeSchemaTests + { + [Test] + public void CreateDataSetMessageSchemaHonorsHeaderMask() + { + var provider = new PubSubSchemaProvider(); + JsonDataSetMessageContentMask mask = JsonDataSetMessageContentMask.DataSetWriterId + | JsonDataSetMessageContentMask.Timestamp + | JsonDataSetMessageContentMask.SequenceNumber; + + JsonObject root = CreateDataSetMessageRoot(provider, mask); + JsonObject properties = root["properties"]!.AsObject(); + JsonObject payload = properties["Payload"]!.AsObject(); + + Assert.Multiple(() => + { + Assert.That(properties["DataSetWriterId"]!["type"]!.GetValue(), Is.EqualTo("integer")); + Assert.That(properties["Timestamp"]!["format"]!.GetValue(), Is.EqualTo("date-time")); + Assert.That(properties["SequenceNumber"]!["type"]!.GetValue(), Is.EqualTo("integer")); + Assert.That(properties["MessageType"]!["enum"]!.AsArray(), Has.Count.EqualTo(2)); + Assert.That(payload["type"]!.GetValue(), Is.EqualTo("object")); + Assert.That(payload["properties"]!["Temperature"], Is.Not.Null); + Assert.That(payload["properties"]!["Enabled"], Is.Not.Null); + }); + } + + [Test] + public void CreateDataSetMessageSchemaWithNoMaskContainsPayloadAndMessageType() + { + var provider = new PubSubSchemaProvider(); + + JsonObject root = CreateDataSetMessageRoot(provider, JsonDataSetMessageContentMask.None); + JsonObject properties = root["properties"]!.AsObject(); + + Assert.Multiple(() => + { + Assert.That(properties.ContainsKey("Payload"), Is.True); + Assert.That(properties.ContainsKey("MessageType"), Is.True); + Assert.That(properties.ContainsKey("DataSetWriterId"), Is.False); + Assert.That(properties.ContainsKey("Timestamp"), Is.False); + Assert.That(properties, Has.Count.EqualTo(2)); + }); + } + + [Test] + public void CreateNetworkMessageSchemaHonorsEnvelopeMask() + { + var provider = new PubSubSchemaProvider(); + JsonNetworkMessageContentMask mask = JsonNetworkMessageContentMask.NetworkMessageHeader + | JsonNetworkMessageContentMask.DataSetMessageHeader + | JsonNetworkMessageContentMask.PublisherId + | JsonNetworkMessageContentMask.DataSetClassId; + + JsonObject root = CreateNetworkMessageRoot(provider, mask); + JsonObject properties = root["properties"]!.AsObject(); + JsonObject messages = properties["Messages"]!.AsObject(); + + Assert.Multiple(() => + { + Assert.That(properties["MessageType"]!["const"]!.GetValue(), Is.EqualTo("ua-data")); + Assert.That(messages["type"]!.GetValue(), Is.EqualTo("array")); + Assert.That(messages["items"]!["$ref"]!.GetValue(), Is.EqualTo("#/$defs/DataSetMessage")); + Assert.That(properties.ContainsKey("PublisherId"), Is.True); + Assert.That(properties.ContainsKey("DataSetClassId"), Is.True); + Assert.That(properties.ContainsKey("ReplyTo"), Is.False); + }); + } + + [Test] + public void CreateNetworkMessageSchemaWithSingleDataSetMessageUsesObjectMessages() + { + var provider = new PubSubSchemaProvider(); + JsonNetworkMessageContentMask mask = JsonNetworkMessageContentMask.NetworkMessageHeader + | JsonNetworkMessageContentMask.DataSetMessageHeader + | JsonNetworkMessageContentMask.SingleDataSetMessage; + + JsonObject root = CreateNetworkMessageRoot(provider, mask); + JsonObject messages = root["properties"]!["Messages"]!.AsObject(); + + Assert.Multiple(() => + { + Assert.That(messages["type"]!.GetValue(), Is.EqualTo("object")); + Assert.That(messages["$ref"]!.GetValue(), Is.EqualTo("#/$defs/DataSetMessage")); + }); + } + + [Test] + public void CreateMetaDataMessageSchemaContainsMetaDataEnvelope() + { + var provider = new PubSubSchemaProvider(); + + JsonObject root = CreateMetaDataMessageRoot(provider); + JsonObject properties = root["properties"]!.AsObject(); + + Assert.Multiple(() => + { + Assert.That(properties["MessageType"]!["const"]!.GetValue(), Is.EqualTo("ua-metadata")); + Assert.That(properties["MetaData"]!["type"]!.GetValue(), Is.EqualTo("object")); + Assert.That(properties.ContainsKey("PublisherId"), Is.True); + Assert.That(properties.ContainsKey("DataSetWriterId"), Is.True); + }); + } + + [Test] + public void EnvelopeSchemasParseAndDeclareDraft202012() + { + var provider = new PubSubSchemaProvider(); + + string dataSetMessage = provider.CreateDataSetMessageSchema( + CreateMetaData(), + JsonDataSetMessageContentMask.None, + DataSetFieldContentMask.None).ToSchemaString(); + string networkMessage = provider.CreateNetworkMessageSchema( + CreateMetaData(), + JsonNetworkMessageContentMask.NetworkMessageHeader, + JsonDataSetMessageContentMask.None, + DataSetFieldContentMask.None).ToSchemaString(); + string metaDataMessage = provider.CreateMetaDataMessageSchema(CreateMetaData()).ToSchemaString(); + + Assert.Multiple(() => + { + AssertDialect(dataSetMessage); + AssertDialect(networkMessage); + AssertDialect(metaDataMessage); + }); + } + + private static void AssertDialect(string schema) + { + JsonObject root = JsonNode.Parse(schema)!.AsObject(); + Assert.That(root["$schema"]!.GetValue(), Is.EqualTo("https://json-schema.org/draft/2020-12/schema")); + } + + private static JsonObject CreateDataSetMessageRoot( + PubSubSchemaProvider provider, + JsonDataSetMessageContentMask mask) + { + var document = (JsonSchemaDocument)provider.CreateDataSetMessageSchema( + CreateMetaData(), + mask, + DataSetFieldContentMask.RawData); + return document.Root; + } + + private static JsonObject CreateNetworkMessageRoot( + PubSubSchemaProvider provider, + JsonNetworkMessageContentMask mask) + { + var document = (JsonSchemaDocument)provider.CreateNetworkMessageSchema( + CreateMetaData(), + mask, + JsonDataSetMessageContentMask.DataSetWriterId, + DataSetFieldContentMask.RawData); + return document.Root; + } + + private static JsonObject CreateMetaDataMessageRoot(PubSubSchemaProvider provider) + { + var document = (JsonSchemaDocument)provider.CreateMetaDataMessageSchema(CreateMetaData()); + return document.Root; + } + + private static DataSetMetaDataType CreateMetaData() + { + return new DataSetMetaDataType + { + Name = "TelemetryEnvelope", + Fields = + [ + new FieldMetaData + { + Name = "Temperature", + BuiltInType = (byte)BuiltInType.Double, + DataType = DataTypeIds.Double, + ValueRank = ValueRanks.Scalar + }, + new FieldMetaData + { + Name = "Enabled", + BuiltInType = (byte)BuiltInType.Boolean, + DataType = DataTypeIds.Boolean, + ValueRank = ValueRanks.Scalar + } + ] + }; + } + } +} diff --git a/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubRealMessageValidationTests.cs b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubRealMessageValidationTests.cs new file mode 100644 index 0000000000..ae09d8a1f1 --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubRealMessageValidationTests.cs @@ -0,0 +1,230 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Json.Schema; +using NUnit.Framework; +using Opc.Ua.PubSub.Diagnostics; +using Opc.Ua.PubSub.Encoding; +using Opc.Ua.PubSub.MetaData; +using UaSchema = Opc.Ua.Schema.IUaSchema; +using PubSubJson = Opc.Ua.PubSub.Encoding.Json; + +namespace Opc.Ua.PubSub.Schema.Tests +{ + /// + /// Validates PubSub JSON messages emitted by the PubSub encoder against generated PubSub schemas. + /// + [TestFixture] + [Category("Integration")] + public class PubSubRealMessageValidationTests + { + [Test] + public async Task GeneratedNetworkMessageSchemaValidatesEncoderProducedUaDataAsync() + { + DataSetMetaDataType metaData = CreateMetaData(); + JsonNetworkMessageContentMask networkMask = JsonNetworkMessageContentMask.NetworkMessageHeader + | JsonNetworkMessageContentMask.DataSetMessageHeader + | JsonNetworkMessageContentMask.PublisherId; + JsonDataSetMessageContentMask messageMask = JsonDataSetMessageContentMask.DataSetWriterId + | JsonDataSetMessageContentMask.SequenceNumber + | JsonDataSetMessageContentMask.Timestamp + | JsonDataSetMessageContentMask.Status + | JsonDataSetMessageContentMask.MessageType + | JsonDataSetMessageContentMask.MetaDataVersion; + var provider = new PubSubSchemaProvider(); + UaSchema schema = provider.CreateNetworkMessageSchema( + metaData, + networkMask, + messageMask, + DataSetFieldContentMask.RawData); + + JsonNode encoded = await EncodeNetworkMessageAsync(metaData, networkMask, messageMask).ConfigureAwait(false); + EvaluationResults validResults = Evaluate(schema, encoded); + JsonObject invalid = encoded.DeepClone().AsObject(); + invalid.Remove("Messages"); + EvaluationResults invalidResults = Evaluate(schema, invalid); + + Assert.Multiple(() => + { + Assert.That(validResults.IsValid, Is.True, validResults.ToString()); + Assert.That(invalidResults.IsValid, Is.False, invalidResults.ToString()); + }); + } + + [Test] + public async Task GeneratedMetaDataMessageSchemaValidatesEncoderProducedUaMetadataAsync() + { + DataSetMetaDataType metaData = CreateMetaData(); + var provider = new PubSubSchemaProvider(); + UaSchema schema = provider.CreateMetaDataMessageSchema(metaData); + + JsonNode encoded = await EncodeMetaDataMessageAsync(metaData).ConfigureAwait(false); + EvaluationResults validResults = Evaluate(schema, encoded); + JsonObject invalid = encoded.DeepClone().AsObject(); + invalid.Remove("MessageType"); + EvaluationResults invalidResults = Evaluate(schema, invalid); + + Assert.Multiple(() => + { + Assert.That(validResults.IsValid, Is.True, validResults.ToString()); + Assert.That(invalidResults.IsValid, Is.False, invalidResults.ToString()); + }); + } + + private static async Task EncodeNetworkMessageAsync( + DataSetMetaDataType metaData, + JsonNetworkMessageContentMask networkMask, + JsonDataSetMessageContentMask messageMask) + { + var dataSetMessage = new PubSubJson.JsonDataSetMessage + { + ContentMask = messageMask, + DataSetWriterId = DataSetWriterId, + SequenceNumber = 12, + Timestamp = new DateTimeUtc(new DateTime(2026, 6, 25, 16, 0, 0, DateTimeKind.Utc)), + Status = StatusCodes.Good, + MessageType = PubSubDataSetMessageType.KeyFrame, + MetaDataVersion = metaData.ConfigurationVersion, + FieldContentMask = DataSetFieldContentMask.RawData, + Fields = + [ + new DataSetField + { + Name = "Enabled", + Value = new Variant(true), + Encoding = PubSubFieldEncoding.RawData + }, + new DataSetField + { + Name = "Temperature", + Value = new Variant(21.5d), + Encoding = PubSubFieldEncoding.RawData + }, + new DataSetField + { + Name = "Name", + Value = new Variant("PumpA"), + Encoding = PubSubFieldEncoding.RawData + } + ] + }; + var message = new PubSubJson.JsonNetworkMessage + { + MessageId = "ua-data-1", + PublisherId = PublisherId.FromUInt16(PublisherIdValue), + ContentMask = networkMask, + MetaData = metaData, + DataSetMessages = [dataSetMessage] + }; + var encoder = new PubSubJson.JsonEncoder(PubSubJson.JsonEncodingMode.RawData); + ReadOnlyMemory bytes = await encoder.EncodeAsync(message, CreateContext(metaData)).ConfigureAwait(false); + return JsonNode.Parse(bytes.Span) ?? throw new JsonException("The PubSub encoder emitted an empty JSON payload."); + } + + private static async Task EncodeMetaDataMessageAsync(DataSetMetaDataType metaData) + { + var message = new PubSubJson.JsonMetaDataMessage + { + MessageId = "ua-metadata-1", + PublisherId = PublisherId.FromUInt16(PublisherIdValue), + DataSetWriterId = DataSetWriterId, + DataSetClassId = new Uuid(new Guid("11112222-3333-4444-5555-666677778888")), + MetaDataPayload = metaData + }; + var encoder = new PubSubJson.JsonEncoder(PubSubJson.JsonEncodingMode.RawData); + ReadOnlyMemory bytes = await encoder.EncodeAsync(message, CreateContext(metaData)).ConfigureAwait(false); + return JsonNode.Parse(bytes.Span) ?? throw new JsonException("The PubSub encoder emitted an empty JSON payload."); + } + + private static PubSubNetworkMessageContext CreateContext(DataSetMetaDataType metaData) + { + var registry = new DataSetMetaDataRegistry(); + registry.Register( + new DataSetMetaDataKey(PublisherId.FromUInt16(PublisherIdValue), DataSetWriterId, 0, Uuid.Empty, 0), + metaData); + return new PubSubNetworkMessageContext( + ServiceMessageContext.CreateEmpty(null!), + registry, + new PubSubDiagnostics(PubSubDiagnosticsLevel.High), + TimeProvider.System); + } + + private static DataSetMetaDataType CreateMetaData() + { + return new DataSetMetaDataType + { + Name = "RealMessageDataSet", + ConfigurationVersion = new ConfigurationVersionDataType + { + MajorVersion = 1, + MinorVersion = 0 + }, + Fields = + [ + new FieldMetaData + { + Name = "Enabled", + BuiltInType = (byte)BuiltInType.Boolean, + DataType = DataTypeIds.Boolean, + ValueRank = ValueRanks.Scalar + }, + new FieldMetaData + { + Name = "Temperature", + BuiltInType = (byte)BuiltInType.Double, + DataType = DataTypeIds.Double, + ValueRank = ValueRanks.Scalar + }, + new FieldMetaData + { + Name = "Name", + BuiltInType = (byte)BuiltInType.String, + DataType = DataTypeIds.String, + ValueRank = ValueRanks.Scalar + } + ] + }; + } + + private static EvaluationResults Evaluate(UaSchema schema, JsonNode instance) + { + JsonSchema jsonSchema = JsonSchema.FromText(schema.ToSchemaString()); + return jsonSchema.Evaluate( + instance, + new EvaluationOptions { OutputFormat = OutputFormat.List }); + } + + private const ushort DataSetWriterId = 1; + private const ushort PublisherIdValue = 300; + } +} diff --git a/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubSchemaCoverageTests.cs b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubSchemaCoverageTests.cs new file mode 100644 index 0000000000..03e31ca962 --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubSchemaCoverageTests.cs @@ -0,0 +1,448 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Linq; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Opc.Ua.Schema; +using Opc.Ua.Schema.Json; + +namespace Opc.Ua.PubSub.Schema.Tests +{ + /// + /// Exercises PubSub JSON schema generation branches that are not covered by envelope validation tests. + /// + [TestFixture] + public class PubSubSchemaCoverageTests + { + [Test] + public void CreateDataSetSchemaTreatsNoneAndRawDataAsRawValues() + { + var provider = new PubSubSchemaProvider(); + + JsonObject noneRoot = CreateDataSetRoot(provider, CreateBuiltInMetaData(), DataSetFieldContentMask.None); + JsonObject rawRoot = CreateDataSetRoot(provider, CreateBuiltInMetaData(), DataSetFieldContentMask.RawData); + + Assert.Multiple(() => + { + Assert.That(noneRoot["properties"]!["Int64Value"]!["type"]!.GetValue(), Is.EqualTo("string")); + Assert.That(rawRoot["properties"]!["UInt64Value"]!["pattern"]!.GetValue(), Is.EqualTo("^\\d+$")); + Assert.That(noneRoot["properties"]!["Int64Value"]!.AsObject().ContainsKey("properties"), Is.False); + Assert.That(rawRoot["properties"]!["FloatValue"]!["type"]!.AsArray(), Has.Count.EqualTo(2)); + }); + } + + [Test] + public void CreateDataSetSchemaWrapsEveryDataValueFieldContentMaskMember() + { + var provider = new PubSubSchemaProvider(); + DataSetFieldContentMask mask = DataSetFieldContentMask.StatusCode + | DataSetFieldContentMask.SourceTimestamp + | DataSetFieldContentMask.SourcePicoSeconds + | DataSetFieldContentMask.ServerTimestamp + | DataSetFieldContentMask.ServerPicoSeconds; + + JsonObject root = CreateDataSetRoot(provider, CreateBuiltInMetaData(), mask); + JsonObject value = root["properties"]!["Int64Value"]!.AsObject(); + JsonObject members = value["properties"]!.AsObject(); + + Assert.Multiple(() => + { + Assert.That(value["type"]!.GetValue(), Is.EqualTo("object")); + Assert.That(members.ContainsKey("Value"), Is.True); + Assert.That(members.ContainsKey("StatusCode"), Is.True); + Assert.That(members.ContainsKey("SourceTimestamp"), Is.True); + Assert.That(members.ContainsKey("SourcePicoseconds"), Is.True); + Assert.That(members.ContainsKey("ServerTimestamp"), Is.True); + Assert.That(members.ContainsKey("ServerPicoseconds"), Is.True); + Assert.That(value["required"]!.AsArray().Select(static n => n!.GetValue()), + Is.EqualTo(s_valueRequired)); + Assert.That(value["additionalProperties"]!.GetValue(), Is.False); + }); + } + + [Test] + public void CreateDataSetSchemaUsesVerboseStatusCodeObjectAndCompactIntegerStatusCode() + { + var provider = new PubSubSchemaProvider(); + DataSetFieldContentMask mask = DataSetFieldContentMask.StatusCode; + + JsonSchemaDocument compact = (JsonSchemaDocument)provider.CreateDataSetSchema( + CreateBuiltInMetaData(), + mask); + JsonSchemaDocument verbose = (JsonSchemaDocument)provider.CreateDataSetSchema( + CreateBuiltInMetaData(), + mask, + verbose: true); + JsonObject compactStatus = compact.Root["properties"]!["Int64Value"]!["properties"]!["StatusCode"]!.AsObject(); + JsonObject verboseStatus = verbose.Root["properties"]!["Int64Value"]!["properties"]!["StatusCode"]!.AsObject(); + + Assert.Multiple(() => + { + Assert.That(compact.Format, Is.EqualTo(UaSchemaFormat.JsonCompact)); + Assert.That(verbose.Format, Is.EqualTo(UaSchemaFormat.JsonVerbose)); + Assert.That(compactStatus["type"]!.GetValue(), Is.EqualTo("integer")); + Assert.That(verboseStatus["type"]!.GetValue(), Is.EqualTo("object")); + Assert.That(verboseStatus["properties"]!["Code"]!["type"]!.GetValue(), Is.EqualTo("integer")); + }); + } + + [Test] + public void CreateDataSetSchemaMapsRepresentativeBuiltInTypes() + { + var provider = new PubSubSchemaProvider(); + + JsonObject root = CreateDataSetRoot(provider, CreateBuiltInMetaData(), DataSetFieldContentMask.RawData); + JsonObject properties = root["properties"]!.AsObject(); + + Assert.Multiple(() => + { + Assert.That(properties["Int64Value"]!["pattern"]!.GetValue(), Is.EqualTo("^-?\\d+$")); + Assert.That(properties["UInt64Value"]!["pattern"]!.GetValue(), Is.EqualTo("^\\d+$")); + Assert.That(properties["FloatValue"]!["type"]!.AsArray().Select(static n => n!.GetValue()), + Is.EqualTo(s_numberTypes)); + Assert.That(properties["DoubleValue"]!["type"]!.AsArray().Select(static n => n!.GetValue()), + Is.EqualTo(s_numberTypes)); + Assert.That(properties["Bytes"]!["contentEncoding"]!.GetValue(), Is.EqualTo("base64")); + Assert.That(properties["Timestamp"]!["format"]!.GetValue(), Is.EqualTo("date-time")); + Assert.That(properties["GuidValue"]!["format"]!.GetValue(), Is.EqualTo("uuid")); + Assert.That(properties["Xml"]!["type"]!.GetValue(), Is.EqualTo("string")); + Assert.That(properties["EnumValue"]!["type"]!.GetValue(), Is.EqualTo("integer")); + Assert.That(properties["NumberValue"]!["type"]!.AsArray().Select(static n => n!.GetValue()), + Is.EqualTo(s_numberTypes)); + Assert.That(properties["UIntegerValue"]!["type"]!.AsArray().Select(static n => n!.GetValue()), + Is.EqualTo(s_integerTypes)); + }); + } + + [Test] + public void CreateDataSetSchemaAddsDefinitionsForStandardObjectTypes() + { + var provider = new PubSubSchemaProvider(); + + JsonObject root = CreateDataSetRoot(provider, CreateStandardObjectMetaData(), DataSetFieldContentMask.RawData); + JsonObject definitions = root["$defs"]!.AsObject(); + + Assert.Multiple(() => + { + AssertStandardReference(root, "Node", "Ua_NodeId"); + AssertStandardReference(root, "Expanded", "Ua_ExpandedNodeId"); + AssertStandardReference(root, "Qualified", "Ua_QualifiedName"); + AssertStandardReference(root, "Localized", "Ua_LocalizedText"); + AssertStandardReference(root, "VariantValue", "Ua_Variant"); + AssertStandardReference(root, "Extension", "Ua_ExtensionObject"); + AssertStandardReference(root, "DataValue", "Ua_DataValue"); + AssertStandardReference(root, "Diagnostic", "Ua_DiagnosticInfo"); + Assert.That(definitions["Ua_NodeId"]!["properties"]!["Id"]!["type"]!.AsArray(), Has.Count.EqualTo(2)); + Assert.That(definitions["Ua_LocalizedText"]!["properties"]!["Text"]!["type"]!.GetValue(), + Is.EqualTo("string")); + }); + } + + [Test] + public void CreateDataSetSchemaAppliesArrayAndAnyValueRanks() + { + var provider = new PubSubSchemaProvider(); + + JsonObject root = CreateDataSetRoot(provider, CreateArrayMetaData(), DataSetFieldContentMask.RawData); + JsonObject oneDimension = root["properties"]!["OneDimension"]!.AsObject(); + JsonObject twoDimensions = root["properties"]!["TwoDimensions"]!.AsObject(); + JsonObject any = root["properties"]!["AnyRank"]!.AsObject(); + JsonObject scalarOrArray = root["properties"]!["ScalarOrArray"]!.AsObject(); + JsonObject oneOrMore = root["properties"]!["OneOrMore"]!.AsObject(); + + Assert.Multiple(() => + { + Assert.That(oneDimension["type"]!.GetValue(), Is.EqualTo("array")); + Assert.That(twoDimensions["items"]!["type"]!.GetValue(), Is.EqualTo("array")); + Assert.That(any["oneOf"]!.AsArray(), Has.Count.EqualTo(2)); + Assert.That(scalarOrArray["oneOf"]!.AsArray(), Has.Count.EqualTo(2)); + Assert.That(oneOrMore["type"]!.GetValue(), Is.EqualTo("array")); + }); + } + + [Test] + public void CreateDataSetSchemaResolvesComplexTypeThroughInjectedProviderAndResolver() + { + var registry = new DataTypeDefinitionRegistry(); + UaTypeDescription type = CreateStructureDescription(); + registry.Add(type); + IUaSchemaGenerator generator = CreateJsonSchemaGenerator(); + var schemaProvider = new DefaultSchemaProvider(registry, [generator]); + var provider = new PubSubSchemaProvider(schemaProvider, registry); + + JsonObject root = CreateDataSetRoot(provider, CreateComplexMetaData(), DataSetFieldContentMask.RawData); + + Assert.Multiple(() => + { + Assert.That(root["properties"]!["Complex"]!["$ref"]!.GetValue(), + Is.EqualTo("#/$defs/ComplexRecord")); + Assert.That(root["$defs"]!["ComplexRecord"], Is.Not.Null); + }); + } + + [Test] + public void CreateDataSetSchemaHandlesFallbackNamesEmptyFieldsAndNullInputs() + { + var provider = new PubSubSchemaProvider(); + var unnamed = new DataSetMetaDataType + { + Fields = + [ + new FieldMetaData + { + BuiltInType = (byte)BuiltInType.Boolean, + DataType = DataTypeIds.Boolean, + ValueRank = ValueRanks.Scalar + }, + new FieldMetaData + { + Name = string.Empty, + BuiltInType = (byte)BuiltInType.Int32, + DataType = DataTypeIds.Int32, + ValueRank = ValueRanks.Scalar + } + ] + }; + + JsonObject unnamedRoot = CreateDataSetRoot(provider, unnamed, DataSetFieldContentMask.RawData); + JsonObject emptyRoot = CreateDataSetRoot(provider, new DataSetMetaDataType { Name = string.Empty }, + DataSetFieldContentMask.RawData); + + Assert.Multiple(() => + { + Assert.That(unnamedRoot["title"]!.GetValue(), Is.EqualTo("DataSet")); + Assert.That(unnamedRoot["properties"]!.AsObject().ContainsKey("Field0"), Is.True); + Assert.That(unnamedRoot["properties"]!.AsObject().ContainsKey("Field1"), Is.True); + Assert.That(emptyRoot["properties"]!.AsObject(), Is.Empty); + Assert.That(emptyRoot.AsObject().ContainsKey("required"), Is.False); + Assert.That(() => provider.CreateDataSetSchema(null!, DataSetFieldContentMask.RawData), + Throws.ArgumentNullException); + Assert.That(() => provider.CreateDataSetMessageSchema(null!, JsonDataSetMessageContentMask.None, + DataSetFieldContentMask.RawData), Throws.ArgumentNullException); + Assert.That(() => provider.CreateNetworkMessageSchema(null!, JsonNetworkMessageContentMask.NetworkMessageHeader, + JsonDataSetMessageContentMask.None, DataSetFieldContentMask.RawData), Throws.ArgumentNullException); + Assert.That(() => provider.CreateMetaDataMessageSchema(null!), Throws.ArgumentNullException); + }); + } + + [Test] + public void CreateEnvelopeSchemasIncludeAllOptionalMaskPropertiesAndDiExtensionRegistersDependencies() + { + var provider = new PubSubSchemaProvider(); + DataSetMetaDataType metaData = CreateBuiltInMetaData(); + JsonDataSetMessageContentMask dataSetMask = JsonDataSetMessageContentMask.DataSetWriterId + | JsonDataSetMessageContentMask.DataSetWriterName + | JsonDataSetMessageContentMask.PublisherId + | JsonDataSetMessageContentMask.WriterGroupName + | JsonDataSetMessageContentMask.SequenceNumber + | JsonDataSetMessageContentMask.MetaDataVersion + | JsonDataSetMessageContentMask.Timestamp + | JsonDataSetMessageContentMask.Status + | JsonDataSetMessageContentMask.MinorVersion; + JsonNetworkMessageContentMask networkMask = JsonNetworkMessageContentMask.NetworkMessageHeader + | JsonNetworkMessageContentMask.DataSetMessageHeader + | JsonNetworkMessageContentMask.PublisherId + | JsonNetworkMessageContentMask.WriterGroupName + | JsonNetworkMessageContentMask.DataSetClassId + | JsonNetworkMessageContentMask.ReplyTo; + + JsonObject dataSetMessage = ((JsonSchemaDocument)provider.CreateDataSetMessageSchema( + metaData, + dataSetMask, + DataSetFieldContentMask.RawData)).Root; + JsonObject networkMessage = ((JsonSchemaDocument)provider.CreateNetworkMessageSchema( + metaData, + networkMask, + dataSetMask, + DataSetFieldContentMask.RawData)).Root; + JsonObject metaDataMessage = ((JsonSchemaDocument)provider.CreateMetaDataMessageSchema(metaData, verbose: true)).Root; + ServiceProvider services = new ServiceCollection().AddOpcUa().AddPubSubSchema().Services.BuildServiceProvider(); + + Assert.Multiple(() => + { + Assert.That(dataSetMessage["properties"]!.AsObject().ContainsKey("DataSetWriterName"), Is.True); + Assert.That(dataSetMessage["properties"]!.AsObject().ContainsKey("PublisherId"), Is.True); + Assert.That(dataSetMessage["properties"]!["MetaDataVersion"]!["properties"]!["MajorVersion"], Is.Not.Null); + Assert.That(networkMessage["properties"]!.AsObject().ContainsKey("WriterGroupName"), Is.True); + Assert.That(networkMessage["properties"]!.AsObject().ContainsKey("ReplyTo"), Is.True); + Assert.That(metaDataMessage["properties"]!["MetaData"]!["additionalProperties"]!.GetValue(), Is.True); + Assert.That(services.GetRequiredService(), Is.TypeOf()); + }); + } + + private static JsonObject CreateDataSetRoot( + PubSubSchemaProvider provider, + DataSetMetaDataType metaData, + DataSetFieldContentMask mask) + { + return ((JsonSchemaDocument)provider.CreateDataSetSchema(metaData, mask)).Root; + } + + private static DataSetMetaDataType CreateBuiltInMetaData() + { + return new DataSetMetaDataType + { + Name = "BuiltIns", + Fields = + [ + Field("Int64Value", BuiltInType.Int64, DataTypeIds.Int64), + Field("UInt64Value", BuiltInType.UInt64, DataTypeIds.UInt64), + Field("FloatValue", BuiltInType.Float, DataTypeIds.Float), + Field("DoubleValue", BuiltInType.Double, DataTypeIds.Double), + Field("Bytes", BuiltInType.ByteString, DataTypeIds.ByteString), + Field("Timestamp", BuiltInType.DateTime, DataTypeIds.DateTime), + Field("GuidValue", BuiltInType.Guid, DataTypeIds.Guid), + Field("Xml", BuiltInType.XmlElement, DataTypeIds.XmlElement), + Field("EnumValue", BuiltInType.Enumeration, DataTypeIds.Enumeration), + Field("NumberValue", BuiltInType.Number, DataTypeIds.Number), + Field("UIntegerValue", BuiltInType.UInteger, DataTypeIds.UInteger) + ] + }; + } + + private static DataSetMetaDataType CreateStandardObjectMetaData() + { + return new DataSetMetaDataType + { + Name = "StandardObjects", + Fields = + [ + Field("Node", BuiltInType.NodeId, DataTypeIds.NodeId), + Field("Expanded", BuiltInType.ExpandedNodeId, DataTypeIds.ExpandedNodeId), + Field("Qualified", BuiltInType.QualifiedName, DataTypeIds.QualifiedName), + Field("Localized", BuiltInType.LocalizedText, DataTypeIds.LocalizedText), + Field("VariantValue", BuiltInType.Variant, DataTypeIds.BaseDataType), + Field("Extension", BuiltInType.ExtensionObject, DataTypeIds.Structure), + Field("DataValue", BuiltInType.DataValue, DataTypeIds.BaseDataType), + Field("Diagnostic", BuiltInType.DiagnosticInfo, DataTypeIds.DiagnosticInfo) + ] + }; + } + + private static DataSetMetaDataType CreateArrayMetaData() + { + return new DataSetMetaDataType + { + Name = "Arrays", + Fields = + [ + Field("OneDimension", BuiltInType.Boolean, DataTypeIds.Boolean, ValueRanks.OneDimension), + Field("TwoDimensions", BuiltInType.Int32, DataTypeIds.Int32, 2), + Field("AnyRank", BuiltInType.String, DataTypeIds.String, ValueRanks.Any), + Field("ScalarOrArray", BuiltInType.Double, DataTypeIds.Double, ValueRanks.ScalarOrOneDimension), + Field("OneOrMore", BuiltInType.Byte, DataTypeIds.Byte, ValueRanks.OneOrMoreDimensions) + ] + }; + } + + private static DataSetMetaDataType CreateComplexMetaData() + { + return new DataSetMetaDataType + { + Name = "ComplexDataSet", + Fields = + [ + new FieldMetaData + { + Name = "Complex", + BuiltInType = (byte)BuiltInType.Null, + DataType = new NodeId(6001, 2), + ValueRank = ValueRanks.Scalar + } + ] + }; + } + + private static UaTypeDescription CreateStructureDescription() + { + var definition = new StructureDefinition + { + BaseDataType = DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = + [ + new StructureField + { + Name = "Enabled", + DataType = DataTypeIds.Boolean, + ValueRank = ValueRanks.Scalar + }, + new StructureField + { + Name = "Count", + DataType = DataTypeIds.Int32, + ValueRank = ValueRanks.Scalar + } + ] + }; + return new UaTypeDescription( + new ExpandedNodeId(new NodeId(6001, 2)), + new QualifiedName("ComplexRecord", 2), + definition, + "http://opcfoundation.org/UA/PubSub/SchemaTests"); + } + + private static FieldMetaData Field( + string name, + BuiltInType builtInType, + NodeId dataType, + int valueRank = ValueRanks.Scalar) + { + return new FieldMetaData + { + Name = name, + BuiltInType = (byte)builtInType, + DataType = dataType, + ValueRank = valueRank + }; + } + + private static void AssertStandardReference(JsonObject root, string propertyName, string definitionName) + { + Assert.That(root["properties"]![propertyName]!["$ref"]!.GetValue(), + Is.EqualTo("#/$defs/" + definitionName)); + Assert.That(root["$defs"]![definitionName], Is.Not.Null); + } + + private static IUaSchemaGenerator CreateJsonSchemaGenerator() + { + Type generatorType = typeof(JsonSchemaDocument).Assembly.GetType( + "Opc.Ua.Schema.Json.JsonSchemaGenerator", + throwOnError: true)!; + return (IUaSchemaGenerator)Activator.CreateInstance(generatorType, nonPublic: true)!; + } + + private static readonly string[] s_valueRequired = ["Value"]; + private static readonly string[] s_numberTypes = ["number", "string"]; + private static readonly string[] s_integerTypes = ["integer", "string"]; + } +} diff --git a/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubSchemaProviderTests.cs b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubSchemaProviderTests.cs new file mode 100644 index 0000000000..9b941b2e09 --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubSchemaProviderTests.cs @@ -0,0 +1,158 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Opc.Ua.Schema.Json; + +namespace Opc.Ua.PubSub.Schema.Tests +{ + [TestFixture] + public class PubSubSchemaProviderTests + { + [Test] + public void CreateDataSetSchemaWithRawDataMapsBuiltInFields() + { + var provider = new PubSubSchemaProvider(); + + JsonObject root = CreateRoot(provider, DataSetFieldContentMask.RawData); + JsonObject properties = root["properties"]!.AsObject(); + JsonObject temperature = properties["Temperature"]!.AsObject(); + JsonObject name = properties["Name"]!.AsObject(); + JsonObject counter = properties["Counter"]!.AsObject(); + JsonObject flags = properties["Flags"]!.AsObject(); + + Assert.Multiple(() => + { + Assert.That(temperature["type"]!.GetValue(), Is.EqualTo("integer")); + Assert.That(temperature["minimum"]!.GetValue(), Is.EqualTo(int.MinValue)); + Assert.That(name["type"]!.GetValue(), Is.EqualTo("string")); + Assert.That(counter["type"]!.GetValue(), Is.EqualTo("string")); + Assert.That(counter["pattern"]!.GetValue(), Is.EqualTo("^-?\\d+$")); + Assert.That(flags["type"]!.GetValue(), Is.EqualTo("array")); + Assert.That(flags["items"]!["type"]!.GetValue(), Is.EqualTo("boolean")); + }); + } + + [Test] + public void CreateDataSetSchemaWithFieldMaskWrapsDataValueMembers() + { + var provider = new PubSubSchemaProvider(); + DataSetFieldContentMask mask = DataSetFieldContentMask.StatusCode + | DataSetFieldContentMask.SourceTimestamp + | DataSetFieldContentMask.SourcePicoSeconds; + + JsonObject root = CreateRoot(provider, mask); + JsonObject field = root["properties"]!["Temperature"]!.AsObject(); + JsonObject properties = field["properties"]!.AsObject(); + + Assert.Multiple(() => + { + Assert.That(field["type"]!.GetValue(), Is.EqualTo("object")); + Assert.That(properties.ContainsKey("Value"), Is.True); + Assert.That(properties.ContainsKey("StatusCode"), Is.True); + Assert.That(properties.ContainsKey("SourceTimestamp"), Is.True); + Assert.That(properties.ContainsKey("SourcePicoseconds"), Is.True); + Assert.That(properties.ContainsKey("ServerTimestamp"), Is.False); + Assert.That(properties["Value"]!["type"]!.GetValue(), Is.EqualTo("integer")); + Assert.That(properties["SourceTimestamp"]!["format"]!.GetValue(), Is.EqualTo("date-time")); + }); + } + + [Test] + public void CreateDataSetSchemaOutputParsesAsJson() + { + var provider = new PubSubSchemaProvider(); + + string schema = provider.CreateDataSetSchema( + CreateMetaData(), + DataSetFieldContentMask.None).ToSchemaString(); + + Assert.That(JsonNode.Parse(schema), Is.Not.Null); + } + + [Test] + public void AddPubSubSchemaRegistersProvider() + { + ServiceProvider services = new ServiceCollection() + .AddOpcUa() + .AddPubSubSchema() + .Services + .BuildServiceProvider(); + + Assert.That(services.GetRequiredService(), Is.TypeOf()); + } + + private static JsonObject CreateRoot(PubSubSchemaProvider provider, DataSetFieldContentMask mask) + { + var document = (JsonSchemaDocument)provider.CreateDataSetSchema(CreateMetaData(), mask); + return document.Root; + } + + private static DataSetMetaDataType CreateMetaData() + { + return new DataSetMetaDataType + { + Name = "Telemetry", + Fields = + [ + new FieldMetaData + { + Name = "Temperature", + BuiltInType = (byte)BuiltInType.Int32, + DataType = DataTypeIds.Int32, + ValueRank = ValueRanks.Scalar + }, + new FieldMetaData + { + Name = "Name", + BuiltInType = (byte)BuiltInType.String, + DataType = DataTypeIds.String, + ValueRank = ValueRanks.Scalar + }, + new FieldMetaData + { + Name = "Counter", + BuiltInType = (byte)BuiltInType.Int64, + DataType = DataTypeIds.Int64, + ValueRank = ValueRanks.Scalar + }, + new FieldMetaData + { + Name = "Flags", + BuiltInType = (byte)BuiltInType.Boolean, + DataType = DataTypeIds.Boolean, + ValueRank = ValueRanks.OneDimension + } + ] + }; + } + } +} diff --git a/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubSchemaValidationIntegrationTests.cs b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubSchemaValidationIntegrationTests.cs new file mode 100644 index 0000000000..bd78012e2c --- /dev/null +++ b/Tests/Opc.Ua.PubSub.Schema.Tests/PubSubSchemaValidationIntegrationTests.cs @@ -0,0 +1,152 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Text.Json.Nodes; +using Json.Schema; +using NUnit.Framework; +using UaSchema = Opc.Ua.Schema.IUaSchema; + +namespace Opc.Ua.PubSub.Schema.Tests +{ + /// + /// Validates generated PubSub JSON schemas against representative PubSub JSON payloads. + /// + [TestFixture] + [Category("Integration")] + public class PubSubSchemaValidationIntegrationTests + { + [Test] + public void GeneratedDataSetSchemaValidatesConformingRawDataPayloadAndRejectsWrongType() + { + var provider = new PubSubSchemaProvider(); + UaSchema schema = provider.CreateDataSetSchema(CreateMetaData(), DataSetFieldContentMask.RawData); + var validPayload = new JsonObject + { + ["Field1"] = 1, + ["Field2"] = "x", + ["Field3"] = "123", + ["Field4"] = new JsonArray(true, false) + }; + var invalidPayload = new JsonObject + { + ["Field1"] = 1, + ["Field2"] = "x", + ["Field3"] = 123, + ["Field4"] = new JsonArray(true, false) + }; + + EvaluationResults validResults = Evaluate(schema, validPayload); + EvaluationResults invalidResults = Evaluate(schema, invalidPayload); + + Assert.Multiple(() => + { + Assert.That(validResults.IsValid, Is.True, validResults.ToString()); + Assert.That(invalidResults.IsValid, Is.False, invalidResults.ToString()); + }); + } + + [Test] + public void GeneratedNetworkMessageSchemaValidatesMinimalUaDataEnvelope() + { + var provider = new PubSubSchemaProvider(); + UaSchema schema = provider.CreateNetworkMessageSchema( + CreateMetaData(), + JsonNetworkMessageContentMask.NetworkMessageHeader, + JsonDataSetMessageContentMask.None, + DataSetFieldContentMask.RawData); + var instance = new JsonObject + { + ["MessageType"] = "ua-data", + ["Messages"] = new JsonArray( + new JsonObject + { + ["MessageType"] = "ua-keyframe", + ["Payload"] = new JsonObject + { + ["Field1"] = 1, + ["Field2"] = "x", + ["Field3"] = "123", + ["Field4"] = new JsonArray(true, false) + } + }) + }; + + EvaluationResults results = Evaluate(schema, instance); + + Assert.That(results.IsValid, Is.True, results.ToString()); + } + + private static DataSetMetaDataType CreateMetaData() + { + return new DataSetMetaDataType + { + Name = "TelemetryValidation", + Fields = + [ + new FieldMetaData + { + Name = "Field1", + BuiltInType = (byte)BuiltInType.Int32, + DataType = DataTypeIds.Int32, + ValueRank = ValueRanks.Scalar + }, + new FieldMetaData + { + Name = "Field2", + BuiltInType = (byte)BuiltInType.String, + DataType = DataTypeIds.String, + ValueRank = ValueRanks.Scalar + }, + new FieldMetaData + { + Name = "Field3", + BuiltInType = (byte)BuiltInType.Int64, + DataType = DataTypeIds.Int64, + ValueRank = ValueRanks.Scalar + }, + new FieldMetaData + { + Name = "Field4", + BuiltInType = (byte)BuiltInType.Boolean, + DataType = DataTypeIds.Boolean, + ValueRank = ValueRanks.OneDimension + } + ] + }; + } + + private static EvaluationResults Evaluate(UaSchema schema, JsonNode instance) + { + JsonSchema jsonSchema = JsonSchema.FromText(schema.ToSchemaString()); + return jsonSchema.Evaluate( + instance, + new EvaluationOptions { OutputFormat = OutputFormat.List }); + } + } +} diff --git a/Tests/Opc.Ua.Server.Tests/ServerDataTypeSchemaRegistrationTests.cs b/Tests/Opc.Ua.Server.Tests/ServerDataTypeSchemaRegistrationTests.cs new file mode 100644 index 0000000000..a3de240e53 --- /dev/null +++ b/Tests/Opc.Ua.Server.Tests/ServerDataTypeSchemaRegistrationTests.cs @@ -0,0 +1,103 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Opc.Ua.Schema; + +namespace Opc.Ua.Server.Tests +{ + /// + /// Tests server-side data type schema registration. + /// + [TestFixture] + [Category("Schema")] + [Parallelizable] + public class ServerDataTypeSchemaRegistrationTests + { + [Test] + public void RegisterDataTypeSchemasRegistersDataTypeStateDefinition() + { + const string namespaceUri = "urn:opcfoundation.org:tests:server:schema"; + var namespaceUris = new NamespaceTable(); + namespaceUris.GetIndexOrAppend(Opc.Ua.Types.Namespaces.OpcUa); + ushort namespaceIndex = namespaceUris.GetIndexOrAppend(namespaceUri); + NodeId typeId = new NodeId(6001, namespaceIndex); + + var dataType = new DataTypeState + { + NodeId = typeId, + BrowseName = new QualifiedName("ServerSchemaType", namespaceIndex), + SuperTypeId = DataTypeIds.Structure, + DataTypeDefinition = new ExtensionObject(new StructureDefinition + { + BaseDataType = DataTypeIds.Structure, + StructureType = StructureType.Structure, + Fields = + [ + new StructureField + { + Name = "Value", + DataType = DataTypeIds.Int32, + ValueRank = ValueRanks.Scalar + } + ] + }) + }; + var nodes = new NodeStateCollection + { + dataType, + new BaseObjectState(null) + }; + var services = new ServiceCollection(); + services.AddOpcUa().AddSchemaGeneration(); + + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + DataTypeDefinitionRegistry registry = serviceProvider.GetRequiredService(); + + int registered = nodes.RegisterDataTypeSchemas(registry, namespaceUris); + ISchemaProvider schemaProvider = serviceProvider.GetRequiredService(); + bool resolved = schemaProvider.TryGetSchema( + new ExpandedNodeId(typeId), + UaSchemaFormat.JsonCompact, + UaSchemaScope.Type, + out IUaSchema schema); + + Assert.Multiple(() => + { + Assert.That(registered, Is.EqualTo(1)); + Assert.That(registry.TryResolve(typeId, out UaTypeDescription description), Is.True); + Assert.That(description, Is.Not.Null); + Assert.That(description.NamespaceUri, Is.EqualTo(namespaceUri)); + Assert.That(resolved, Is.True); + Assert.That(schema, Is.Not.Null); + }); + } + } +} diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeGenerator.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeGenerator.cs index 8828798445..e5a5a0c62b 100644 --- a/Tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeGenerator.cs +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeGenerator.cs @@ -127,14 +127,14 @@ private TemplateString LoadTemplate_ListOfActivatorClasses(ILoadContext context) // PooledEncodeableType constraint. Use the plain // EncodeableType for them. return datatype.IsPartOfOpcUaTypesLibrary() - ? DataTypeTemplates.StructureActivatorClass - : DataTypeTemplates.PooledStructureActivatorClass; + ? DataTypeTemplates.StructureActivatorClassWithDefinition + : DataTypeTemplates.PooledStructureActivatorClassWithDefinition; } if (datatype.BasicDataType == BasicDataType.Enumeration && datatype.IsEnumeration && !datatype.IsOptionSet) { - return DataTypeTemplates.EnumerationActivatorClass; + return DataTypeTemplates.EnumerationActivatorClassWithDefinition; } return null; } diff --git a/Tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeTemplates.cs b/Tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeTemplates.cs index 0d55caeae4..78d62262bb 100644 --- a/Tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeTemplates.cs +++ b/Tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeTemplates.cs @@ -327,6 +327,103 @@ public static readonly {{Tokens.ClassName}}Activator Instance } """); + /// + /// Encodeable type activator that also exposes the data type definition. + /// Used only where a matching DataTypeDefinitions.Create method is emitted. + /// + public static readonly TemplateString StructureActivatorClassWithDefinition = TemplateString.Parse( + $$""" + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("{{Tokens.Tool}}", "{{Tokens.Version}}")] + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute()] + public sealed class {{Tokens.ClassName}}Activator : global::Opc.Ua.EncodeableType<{{Tokens.ClassName}}> + { + /// + /// The singleton instance of the activator. + /// + public static readonly {{Tokens.ClassName}}Activator Instance + = new {{Tokens.ClassName}}Activator(); + + /// + public override global::System.Xml.XmlQualifiedName XmlName { get; } = + new global::System.Xml.XmlQualifiedName("{{Tokens.ClassName}}", {{Tokens.XmlNamespaceUri}}); + + /// + public override global::Opc.Ua.IEncodeable CreateInstance() + { + return new {{Tokens.ClassName}}(); + } + + /// + public override global::Opc.Ua.DataTypeDefinition? GetDataTypeDefinition( + global::Opc.Ua.NamespaceTable namespaceUris) + { + return DataTypeDefinitions.Create{{Tokens.ClassName}}(namespaceUris); + } + } + """); + + /// + /// Pooled encodeable type activator that also exposes the data type definition. + /// + public static readonly TemplateString PooledStructureActivatorClassWithDefinition = TemplateString.Parse( + $$""" + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("{{Tokens.Tool}}", "{{Tokens.Version}}")] + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute()] + public sealed class {{Tokens.ClassName}}Activator : global::Opc.Ua.PooledEncodeableType<{{Tokens.ClassName}}> + { + /// + /// The singleton instance of the activator. + /// + public static readonly {{Tokens.ClassName}}Activator Instance + = new {{Tokens.ClassName}}Activator(); + + /// + public override global::System.Xml.XmlQualifiedName XmlName { get; } = + new global::System.Xml.XmlQualifiedName("{{Tokens.ClassName}}", {{Tokens.XmlNamespaceUri}}); + + /// + protected override void InitializeRent({{Tokens.ClassName}} instance) + { + instance.ClearPooledSentinel(); + } + + /// + public override global::Opc.Ua.DataTypeDefinition? GetDataTypeDefinition( + global::Opc.Ua.NamespaceTable namespaceUris) + { + return DataTypeDefinitions.Create{{Tokens.ClassName}}(namespaceUris); + } + } + """); + + /// + /// Enumeration activator that also exposes the data type definition. + /// + public static readonly TemplateString EnumerationActivatorClassWithDefinition = TemplateString.Parse( + $$""" + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("{{Tokens.Tool}}", "{{Tokens.Version}}")] + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute()] + public sealed class {{Tokens.ClassName}}Activator : global::Opc.Ua.EnumeratedType<{{Tokens.ClassName}}> + { + /// + /// The singleton instance of the activator. + /// + public static readonly {{Tokens.ClassName}}Activator Instance + = new {{Tokens.ClassName}}Activator(); + + /// + public override global::System.Xml.XmlQualifiedName XmlName { get; } = + new global::System.Xml.XmlQualifiedName("{{Tokens.ClassName}}", {{Tokens.XmlNamespaceUri}}); + + /// + public override global::Opc.Ua.DataTypeDefinition? GetDataTypeDefinition( + global::Opc.Ua.NamespaceTable namespaceUris) + { + return DataTypeDefinitions.Create{{Tokens.ClassName}}(namespaceUris); + } + } + """); + /// /// Enumeration activator builder registration /// diff --git a/UA Core Library.slnx b/UA Core Library.slnx index a880be5f47..0ddcf9bc49 100644 --- a/UA Core Library.slnx +++ b/UA Core Library.slnx @@ -14,6 +14,7 @@ + @@ -29,6 +30,7 @@ + diff --git a/UA.slnx b/UA.slnx index 15a93c83b4..af17a8292f 100644 --- a/UA.slnx +++ b/UA.slnx @@ -69,6 +69,7 @@ + @@ -191,11 +192,13 @@ + + @@ -218,6 +221,7 @@ +