Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="DotNext" Version="5.26.3" />
<PackageVersion Include="EmbedIO" Version="3.5.2" />
<PackageVersion Include="JsonSchema.Net" Version="7.4.0" />
<PackageVersion Include="Makaretu.Dns.Multicast" Version="0.27.0" />
<PackageVersion Include="Microsoft.AspNetCore.Http" Version="2.3.10" />
<PackageVersion Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.3.10" />
Expand Down Expand Up @@ -117,4 +118,4 @@
<PackageVersion Include="MQTTnet" Version="5.1.0.1559" />
<PackageVersion Include="MQTTnet.Server" Version="5.1.0.1559" />
</ItemGroup>
</Project>
</Project>
1 change: 1 addition & 0 deletions Docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
123 changes: 123 additions & 0 deletions Docs/SchemaGeneration.md
Original file line number Diff line number Diff line change
@@ -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<ISchemaProvider>();
```

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<DataTypeDefinitionRegistry>();
registry.TryAddDataType(dataTypeNode, session.NamespaceUris);
```

- **Source-generated types** — the generated types expose their definition through the generated `DataTypeDefinitions.Create<TypeName>(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.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net10.0'))">true</IsAotCompatible>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net472' OR '$(TargetFramework)' == 'net48'">
<!--
Microsoft.Extensions.Http.Resilience provides net462 assets for the .NET Framework builds,
but its buildTransitive support target warns that these TFMs are untested. TODO: remove this
suppression when the library matrix drops .NET Framework or the package stops warning.
-->
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Opc.Ua.Client.ComplexTypes.Tests" />
</ItemGroup>
Expand Down
60 changes: 56 additions & 4 deletions Libraries/Opc.Ua.Client/ComplexTypes/ComplexTypeSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
using System.Threading.Tasks;
using System.Xml;
using Microsoft.Extensions.Logging;
using Opc.Ua.Schema;

namespace Opc.Ua.Client.ComplexTypes
{
Expand Down Expand Up @@ -396,6 +397,40 @@ public IEnumerable<ExpandedNodeId> GetDefinedDataTypeIds()
NodeId.ToExpandedNodeId(nodeId, m_complexTypeResolver.NamespaceUris));
}

/// <summary>
/// Registers the data type definitions loaded by this complex type system for schema generation.
/// </summary>
/// <param name="registry">The registry to populate.</param>
/// <returns>The registry to allow chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="registry"/> is <c>null</c>.</exception>
public DataTypeDefinitionRegistry RegisterDataTypeDefinitions(DataTypeDefinitionRegistry registry)
{
if (registry == null)
{
throw new ArgumentNullException(nameof(registry));
}

foreach (KeyValuePair<NodeId, DataTypeDefinition> 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;
}

/// <summary>
/// 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
Expand Down Expand Up @@ -452,6 +487,7 @@ void CollectAllDataTypeDefinitions(
public void ClearDataTypeCache()
{
m_dataTypeDefinitionCache.Clear();
m_dataTypeBrowseNameCache.Clear();
}

/// <summary>
Expand Down Expand Up @@ -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!),
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1499,7 +1541,7 @@ private async Task<bool> IsOptionSetSubtypeAsync(
}

// Add StructureDefinition to cache
m_dataTypeDefinitionCache[localDataTypeId] = structureDefinition;
AddDataTypeDefinitionToCache(localDataTypeId, typeName, structureDefinition);

IComplexTypeFieldBuilder fieldBuilder = complexTypeBuilder.AddStructuredType(
typeName,
Expand Down Expand Up @@ -1541,6 +1583,15 @@ private async Task<bool> 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)
Expand Down Expand Up @@ -1741,6 +1792,7 @@ private static void SplitAndSortDictionary(
private readonly IComplexTypeResolver m_complexTypeResolver;
private readonly IComplexTypeFactory m_complexTypeBuilderFactory;
private readonly NodeIdDictionary<DataTypeDefinition> m_dataTypeDefinitionCache = [];
private readonly NodeIdDictionary<QualifiedName> m_dataTypeBrowseNameCache = [];

private static readonly string[] s_supportedEncodings =
[
Expand Down
1 change: 1 addition & 0 deletions Libraries/Opc.Ua.Client/Opc.Ua.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Stack\Opc.Ua.Core\Opc.Ua.Core.csproj" />
<ProjectReference Include="..\..\Stack\Opc.Ua.Core.Schema\Opc.Ua.Core.Schema.csproj" />
<ProjectReference Include="..\Opc.Ua.Configuration\Opc.Ua.Configuration.csproj" />
</ItemGroup>
<ItemGroup>
Expand Down
Loading
Loading