Skip to content

Commit ddc11c5

Browse files
committed
Runtime schema generation (XSD/BSD/JSON) + PubSub message schemas (squash #3916)
Squash-merge of PR #3916 (head part14experimental) into part14pubsub (#3892).
1 parent 606b17a commit ddc11c5

73 files changed

Lines changed: 9667 additions & 13 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Packages.props

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
1616
<PackageVersion Include="DotNext" Version="5.26.3" />
1717
<PackageVersion Include="EmbedIO" Version="3.5.2" />
18+
<PackageVersion Include="JsonSchema.Net" Version="7.4.0" />
1819
<PackageVersion Include="Makaretu.Dns.Multicast" Version="0.27.0" />
1920
<PackageVersion Include="Microsoft.AspNetCore.Http" Version="2.3.10" />
2021
<PackageVersion Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.3.10" />
@@ -122,4 +123,4 @@
122123
<PackageVersion Include="MQTTnet" Version="5.1.0.1559" />
123124
<PackageVersion Include="MQTTnet.Server" Version="5.1.0.1559" />
124125
</ItemGroup>
125-
</Project>
126+
</Project>

Docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Here is a list of available documentation for different topics:
2020
* Working with [ComplexTypes](ComplexTypes.md) - Custom structures and enumerations.
2121
* Client-based [NodeSet Export](NodeSetExport.md) - Export server address space to NodeSet2 XML.
2222
* Source generated [DataTypes] - How to annotate POCO classes and let the source generator generate the `IEncodeable` implementation.
23+
* 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).
2324
* 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.
2425
* [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.
2526
* [Alias Names](AliasNames.md) - Full server + client support for the OPC UA Part 17 alias-name model (`AliasNameType`, `AliasNameCategoryType`, `FindAlias`, `FindAliasVerbose`, `AddAliasesToCategory`, `DeleteAliasesFromCategory`, `LastChange`).

Docs/SchemaGeneration.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Runtime Schema Generation
2+
3+
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:
4+
5+
- **XSD** for the XML encoding.
6+
- **BSD** (OPC Binary, Part 6) for the binary encoding.
7+
- **JSON Schema** (Part 6 Annex C, draft 2020-12) for the JSON encoding, in both the **compact** (reversible, BrowseName-keyed) and **verbose** flavors.
8+
9+
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`.
10+
11+
## Concepts
12+
13+
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:
14+
15+
- `ISchemaProvider` is the entry point. It produces an `IUaSchema` for a requested `UaSchemaFormat` and `UaSchemaScope`.
16+
- `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()`.
17+
- `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.
18+
19+
`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.
20+
21+
## Registration
22+
23+
The services are registered through the standard OPC UA dependency-injection surface:
24+
25+
```csharp
26+
IServiceProvider services = new ServiceCollection()
27+
.AddOpcUa()
28+
.AddSchemaGeneration()
29+
.Services
30+
.BuildServiceProvider();
31+
32+
ISchemaProvider provider = services.GetRequiredService<ISchemaProvider>();
33+
```
34+
35+
The provider can also be constructed directly when dependency injection is not used:
36+
37+
```csharp
38+
var registry = new DataTypeDefinitionRegistry();
39+
registry.Add(new UaTypeDescription(typeId, browseName, structureDefinition, namespaceUri));
40+
41+
ISchemaProvider provider = new DefaultSchemaProvider(
42+
registry,
43+
new IUaSchemaGenerator[] { new JsonSchemaGenerator() });
44+
```
45+
46+
## Registering data types
47+
48+
Schema generation needs the runtime definition of a type. A type's `StructureDefinition` / `EnumDefinition` is registered with the resolver from whichever source has it:
49+
50+
- **Server / browsed types** — a `DataTypeNode` obtained from a server (or the client node cache) carries its definition in `DataTypeNode.DataTypeDefinition`. Register it directly:
51+
52+
```csharp
53+
var registry = serviceProvider.GetRequiredService<DataTypeDefinitionRegistry>();
54+
registry.TryAddDataType(dataTypeNode, session.NamespaceUris);
55+
```
56+
57+
- **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:
58+
59+
```csharp
60+
registry.Add(new UaTypeDescription(typeId, browseName, definition, namespaceUri));
61+
```
62+
63+
- **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.
64+
65+
Once registered, fields that reference other registered types are resolved automatically and included in the generated document.
66+
67+
## Generating a schema
68+
69+
Once a type's definition is registered with the resolver, a schema can be produced from its type id:
70+
71+
```csharp
72+
if (provider.TryGetSchema(typeId, UaSchemaFormat.JsonCompact, UaSchemaScope.Type, out IUaSchema? schema))
73+
{
74+
string json = schema.ToSchemaString();
75+
}
76+
```
77+
78+
The convenience extension methods read more naturally and make a type "expose" its schema:
79+
80+
```csharp
81+
IUaSchema xsd = provider.GetXmlSchema(type);
82+
IUaSchema bsd = provider.GetBinarySchema(type);
83+
IUaSchema jsonCompact = provider.GetJsonSchema(type);
84+
IUaSchema jsonVerbose = provider.GetJsonSchema(type, verbose: true);
85+
86+
// Resolve by type id and produce JSON in one call.
87+
provider.TryGetJsonSchema(typeId, out IUaSchema? schema);
88+
```
89+
90+
## Working with the object model
91+
92+
Because the schema is an object model, callers can inspect or post-process it before serializing. For JSON:
93+
94+
```csharp
95+
var document = (JsonSchemaDocument)provider.GetJsonSchema(type);
96+
JsonObject root = document.Root; // the draft 2020-12 schema
97+
document.WriteTo(stream); // UTF-8, indented
98+
```
99+
100+
## JSON encoding notes (Part 6)
101+
102+
The JSON schemas follow the Part 6 JSON encoding faithfully, matching what the stack's `JsonEncoder` produces:
103+
104+
- `Int64` and `UInt64` are encoded as JSON strings (to avoid precision loss), so they are typed as `string`.
105+
- `Float`/`Double` accept the special string values `Infinity`, `-Infinity` and `NaN`, so they are typed as `["number", "string"]`.
106+
- `ByteString` is a base64 `string`; `DateTime` is a `date-time` string; `Guid` is a `uuid` string.
107+
- The standard structured built-ins (`NodeId`, `Variant`, `ExtensionObject`, `DataValue`, ...) are described once per document in the `$defs` section and referenced.
108+
- Compact enums are integers (with the allowed values listed via `oneOf`); verbose enums are the `Name_Value` strings.
109+
110+
## PubSub schemas
111+
112+
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`:
113+
114+
- `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).
115+
- `CreateDataSetMessageSchema(metaData, messageContentMask, fieldContentMask, verbose)` — a single DataSetMessage whose header fields are gated by `JsonDataSetMessageContentMask` and whose `Payload` is the DataSet schema above.
116+
- `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.
117+
- `CreateMetaDataMessageSchema(metaData, verbose)` — the `ua-metadata` message.
118+
119+
The provider reuses the core `ISchemaProvider` to resolve complex (structured/enum) field data types, embedding them into the document `$defs` section.
120+
121+
## Trimming and NativeAOT
122+
123+
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.

Libraries/Opc.Ua.Client.ComplexTypes/Opc.Ua.Client.ComplexTypes.csproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@
1111
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net10.0'))">true</IsAotCompatible>
1212
<Nullable>enable</Nullable>
1313
</PropertyGroup>
14+
<PropertyGroup Condition="'$(TargetFramework)' == 'net472' OR '$(TargetFramework)' == 'net48'">
15+
<!--
16+
Microsoft.Extensions.Http.Resilience provides net462 assets for the .NET Framework builds,
17+
but its buildTransitive support target warns that these TFMs are untested. TODO: remove this
18+
suppression when the library matrix drops .NET Framework or the package stops warning.
19+
-->
20+
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
21+
</PropertyGroup>
1422
<ItemGroup>
1523
<InternalsVisibleTo Include="Opc.Ua.Client.ComplexTypes.Tests" />
1624
</ItemGroup>

Libraries/Opc.Ua.Client/ComplexTypes/ComplexTypeSystem.cs

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
using System.Threading.Tasks;
3636
using System.Xml;
3737
using Microsoft.Extensions.Logging;
38+
using Opc.Ua.Schema;
3839

3940
namespace Opc.Ua.Client.ComplexTypes
4041
{
@@ -396,6 +397,40 @@ public IEnumerable<ExpandedNodeId> GetDefinedDataTypeIds()
396397
NodeId.ToExpandedNodeId(nodeId, m_complexTypeResolver.NamespaceUris));
397398
}
398399

400+
/// <summary>
401+
/// Registers the data type definitions loaded by this complex type system for schema generation.
402+
/// </summary>
403+
/// <param name="registry">The registry to populate.</param>
404+
/// <returns>The registry to allow chaining.</returns>
405+
/// <exception cref="ArgumentNullException"><paramref name="registry"/> is <c>null</c>.</exception>
406+
public DataTypeDefinitionRegistry RegisterDataTypeDefinitions(DataTypeDefinitionRegistry registry)
407+
{
408+
if (registry == null)
409+
{
410+
throw new ArgumentNullException(nameof(registry));
411+
}
412+
413+
foreach (KeyValuePair<NodeId, DataTypeDefinition> entry in m_dataTypeDefinitionCache)
414+
{
415+
NodeId nodeId = entry.Key;
416+
string namespaceUri = m_complexTypeResolver.NamespaceUris.GetString(nodeId.NamespaceIndex) ??
417+
string.Empty;
418+
QualifiedName browseName = m_dataTypeBrowseNameCache.TryGetValue(
419+
nodeId,
420+
out QualifiedName cachedBrowseName)
421+
? cachedBrowseName
422+
: new QualifiedName(nodeId.ToString(), nodeId.NamespaceIndex);
423+
424+
registry.Add(new UaTypeDescription(
425+
new ExpandedNodeId(nodeId),
426+
browseName,
427+
entry.Value,
428+
namespaceUri));
429+
}
430+
431+
return registry;
432+
}
433+
399434
/// <summary>
400435
/// Get the data type definition and dependent definitions for a data type node id.
401436
/// Recursive through the cache to find all dependent types for structures fields
@@ -452,6 +487,7 @@ void CollectAllDataTypeDefinitions(
452487
public void ClearDataTypeCache()
453488
{
454489
m_dataTypeDefinitionCache.Clear();
490+
m_dataTypeBrowseNameCache.Clear();
455491
}
456492

457493
/// <summary>
@@ -1242,7 +1278,10 @@ private async Task AddEnumTypesAsync(
12421278
if (enumDefinition != null)
12431279
{
12441280
// Add EnumDefinition to cache
1245-
m_dataTypeDefinitionCache[enumType.NodeId] = enumDefinition;
1281+
AddDataTypeDefinitionToCache(
1282+
enumType.NodeId,
1283+
enumType.BrowseName,
1284+
enumDefinition);
12461285

12471286
newType = complexTypeBuilder.AddEnumType(
12481287
QualifiedName.From(enumeratedObject.Name!),
@@ -1357,7 +1396,10 @@ private void AddEncodeableType(ExpandedNodeId nodeId, IType type)
13571396
if (enumDefinition != null)
13581397
{
13591398
// Add EnumDefinition to cache
1360-
m_dataTypeDefinitionCache[enumTypeNode.NodeId] = enumDefinition;
1399+
AddDataTypeDefinitionToCache(
1400+
enumTypeNode.NodeId,
1401+
name,
1402+
enumDefinition);
13611403

13621404
newType = complexTypeBuilder.AddEnumType(name, enumDefinition);
13631405
}
@@ -1410,7 +1452,7 @@ private void AddEncodeableType(ExpandedNodeId nodeId, IType type)
14101452
enumDefinition.IsOptionSet = true;
14111453

14121454
// Add EnumDefinition to cache
1413-
m_dataTypeDefinitionCache[dataTypeNode.NodeId] = enumDefinition;
1455+
AddDataTypeDefinitionToCache(dataTypeNode.NodeId, name, enumDefinition);
14141456

14151457
return complexTypeBuilder.AddOptionSetType(
14161458
name,
@@ -1499,7 +1541,7 @@ private async Task<bool> IsOptionSetSubtypeAsync(
14991541
}
15001542

15011543
// Add StructureDefinition to cache
1502-
m_dataTypeDefinitionCache[localDataTypeId] = structureDefinition;
1544+
AddDataTypeDefinitionToCache(localDataTypeId, typeName, structureDefinition);
15031545

15041546
IComplexTypeFieldBuilder fieldBuilder = complexTypeBuilder.AddStructuredType(
15051547
typeName,
@@ -1541,6 +1583,15 @@ private async Task<bool> IsOptionSetSubtypeAsync(
15411583
return (fieldBuilder.CreateType(), missingTypes);
15421584
}
15431585

1586+
private void AddDataTypeDefinitionToCache(
1587+
NodeId typeId,
1588+
QualifiedName browseName,
1589+
DataTypeDefinition definition)
1590+
{
1591+
m_dataTypeDefinitionCache[typeId] = definition;
1592+
m_dataTypeBrowseNameCache[typeId] = browseName;
1593+
}
1594+
15441595
private static bool IsAllowSubTypes(StructureDefinition structureDefinition)
15451596
{
15461597
switch (structureDefinition.StructureType)
@@ -1741,6 +1792,7 @@ private static void SplitAndSortDictionary(
17411792
private readonly IComplexTypeResolver m_complexTypeResolver;
17421793
private readonly IComplexTypeFactory m_complexTypeBuilderFactory;
17431794
private readonly NodeIdDictionary<DataTypeDefinition> m_dataTypeDefinitionCache = [];
1795+
private readonly NodeIdDictionary<QualifiedName> m_dataTypeBrowseNameCache = [];
17441796

17451797
private static readonly string[] s_supportedEncodings =
17461798
[

Libraries/Opc.Ua.Client/Opc.Ua.Client.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
</ItemGroup>
5151
<ItemGroup>
5252
<ProjectReference Include="..\..\Stack\Opc.Ua.Core\Opc.Ua.Core.csproj" />
53+
<ProjectReference Include="..\..\Stack\Opc.Ua.Core.Schema\Opc.Ua.Core.Schema.csproj" />
5354
<ProjectReference Include="..\Opc.Ua.Configuration\Opc.Ua.Configuration.csproj" />
5455
</ItemGroup>
5556
<ItemGroup>

0 commit comments

Comments
 (0)