Files
UA-.NETStandard/docs/SchemaGeneration.md
Marc 5699678b48 Merge master and preserve registry CI coverage
Keep the native registry, publishing-mode and package-inventory checks while adopting upstream disjoint CI shards. Preserve awaited NUnit 5 assertions with ConfigureAwait and upstream using order.
2026-10-23 10:41:32 +02:00

15 KiB

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 (draft 2020-12) for the Part 6 JSON encoding, in both the compact 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. BSD uses the existing Opc.Ua.Schema.Binary.TypeDictionary, and JSON uses System.Text.Json.Nodes.JsonObject.

Contents

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 callers populate from browsed or otherwise available definitions. 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:

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:

var registry = new DataTypeDefinitionRegistry();
registry.Add(new UaTypeDescription(typeId, browseName, structureDefinition, namespaceUri));

ISchemaProvider provider = new DefaultSchemaProvider(
    registry,
    [
        new JsonSchemaGenerator(),
        new XsdSchemaGenerator(),
        new BsdSchemaGenerator()
    ]);

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:
var registry = serviceProvider.GetRequiredService<DataTypeDefinitionRegistry>();
registry.TryAddDataType(dataTypeNode, session.NamespaceUris);
  • Source-generated types — the generated model registration extension adds public structure and enumeration activators to an IEncodeableFactory. These activators implement IDataTypeDefinitionSource; the generated data value does not need to implement that interface. Use EncodeableFactoryDefinitionSource to resolve their definitions without reflection or manual registration:
IEncodeableFactory factory = EncodeableFactory.Create();
factory.Builder
    .AddOpcUa() // use the generated Add<Namespace>() extension for your model
    .Commit();

IDataTypeDefinitionResolver resolver =
    new EncodeableFactoryDefinitionSource(factory, namespaceUris);

ISchemaProvider provider = new DefaultSchemaProvider(
    resolver,
    [
        new JsonSchemaGenerator(),
        new XsdSchemaGenerator(),
        new BsdSchemaGenerator()
    ]);
  • 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.

  • Types already in the encodeable factory — any structured or enumeration type known to an IEncodeableFactory (source-generated types and runtime complex-type stand-ins) exposes its definition through IDataTypeDefinitionSource, so EncodeableFactoryDefinitionSource resolves it directly from the factory without a separate registration. Built-in primitive DataTypes (and simple types that derive directly from them) carry no DataTypeDefinition and are handled by the built-in type system rather than this resolver. Use the factory source on its own, or combine it with a manual DataTypeDefinitionRegistry (for schema-only types that have no encodeable) via CompositeDataTypeDefinitionResolver:

IDataTypeDefinitionResolver resolver = new CompositeDataTypeDefinitionResolver(
    [new EncodeableFactoryDefinitionSource(factory, namespaceUris), registry]);

When AddComplexTypeSystem() is enabled, an OPC UA server wires this up automatically (see ComplexTypes.md). The primed encodeable factory becomes the schema resolver.

Once registered, fields that reference other registered types are resolved automatically and included in the generated document.

Standard field encodings

Number (i=26), Integer (i=27) and UInteger (i=28) are abstract simple types, not structures requiring a DataTypeDefinition. When used as structure fields, they use Variant encoding as specified by OPC 10000-6, 5.1.6. Their scalar and array field schemas therefore reference Variant rather than an unconstrained value or an ExtensionObject.

SchemaTypeInfo.GetFieldEncodingType exposes this classification to callers that walk schema dependencies. It also preserves the standard primitive/alias mappings; custom-namespace identifiers such as ns=1;i=26 still require their own definitions. It does not change the general-purpose TypeInfo classification.

Namespace identity

BrowseName.NamespaceIndex is not a mapping for the data type's NodeId namespace; the two namespaces may legitimately differ. To support both local and URI-based lookup in DataTypeDefinitionRegistry, register an index-form TypeId together with its namespace URI, as TryAddDataType(node, namespaceTable) does:

registry.Add(new UaTypeDescription(
    new ExpandedNodeId(dataTypeNode.NodeId),
    dataTypeNode.BrowseName,
    definition,
    session.NamespaceUris.GetString(dataTypeNode.NodeId.NamespaceIndex)));

A custom URI-only TypeId is resolvable by URI but does not create an inferred local alias. The standard OPC UA namespace URI is the exception because its local index is always zero. Registry entries must share one local namespace-table context.

URI lookup preserves identifier kind as well as value. Numeric zero, an empty GUID, an empty string, and an empty ByteString are distinct identifiers in a nonzero namespace; they must not be conflated with namespace-zero null NodeIds.

Generating a schema

Once a type's definition is registered with the resolver, a schema can be produced from its type id:

if (provider.TryGetSchema(typeId, UaSchemaFormat.JsonCompact, UaSchemaScope.Type, out IUaSchema? schema))
{
    string json = schema.ToSchemaString();
}

When the starting point is a namespace-index NodeId obtained from a server, convert it with the session namespace table. A registry populated with TryAddDataType(dataTypeNode, session.NamespaceUris) accepts both this URI form and the local index-form ExpandedNodeId:

ExpandedNodeId typeId = NodeId.ToExpandedNodeId(dataTypeNode.NodeId, session.NamespaceUris);
provider.TryGetSchema(typeId, UaSchemaFormat.JsonCompact, UaSchemaScope.Type, out IUaSchema? schema);

The convenience extension methods read more naturally and make a type "expose" its schema:

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:

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.
  • Variant fields, including abstract numeric structure fields, use the UaType and Value envelope with optional Dimensions. Null array entries are allowed, and numeric defaults such as zero retain an explicit Value in both Compact and Verbose encoding. Value is omitted only for the NULL value of a nullable type (Part 6, 5.4.2.17, Table 41), while retaining UaType. The deprecated Type/Body envelope is not the Compact/Verbose format.
  • 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.

OpenAPI document of the REST binding

WebApiOpenApiGenerator generates the OpenAPI 3.0 document of the REST binding (Part 6 G.3, see REST binding) from WebApiServiceRoutes and the structure definitions of the request and response types. It is not an IUaSchemaGenerator: a document describes a set of routes rather than one data type.

var generator = new WebApiOpenApiGenerator();
JsonObject document = generator.Generate(
    WebApiServiceSet.Sessionless,   // or AllServices
    includeSchemas: true,           // false: a JSON object as every body
    serverUrl: "/opcua/");          // null: no servers list

Without includeSchemas the document holds the paths and operations only (about 12 KB for all 28 services). With it, every request, response and the structures and enumerations they contain become component schemas (about 46 KB), as required by OpenAPI client generators. The schemas describe the Compact JSON encoding with the property names and types the published OPC Foundation documents use; OPC UA over OpenAPI lists the differences and the tests that keep the document correct. Pass an IDataTypeDefinitionResolver to the constructor to resolve the types from another source than the generated types of the OPC UA namespace. The generator is registered by AddWebApiTransport(), which serves the document.

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.

Shared JSON canonicalization

Opc.Ua.JsonCanonicalizer in the Types library implements RFC 8785 JCS without reflection. TryCanonicalize returns canonical text, and TryGetUtf8 returns its ByteString representation. Both return an explicit error when a value cannot be represented in the interoperable JCS number domain. Existing WoT semantic comparisons use the same implementation.

JCS is appropriate only when the owning contract selects it, such as SHA-256/JCS. It must not be used for exact registry commit comparison, Arrow schema fingerprints or WoT residue digests, which have different identity rules. A named schema document is also not the same thing as the schema generation interface in this library: generic registry admission and selection are separate concerns.