From d6ecbe388f3004a70e2ac0b881564c8b1f984b16 Mon Sep 17 00:00:00 2001 From: Ray Labbe Date: Thu, 5 Feb 2026 17:01:18 -0500 Subject: [PATCH 1/4] feat(api): Properly serialize Project Properties for both types of models (client_interface and flattened) --- Kepware.Api/Model/BaseEntity.cs | 47 +++++- Kepware.Api/Model/Project/Project.cs | 33 +++++ .../Project/ProjectPropertiesJsonConverter.cs | 135 +++++++++++++++++ .../Serializer/BaseEntityYamlConverter.cs | 32 +++- .../Serializer/ClientInterfacesFlattener.cs | 138 ++++++++++++++++++ 5 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 Kepware.Api/Model/Project/ProjectPropertiesJsonConverter.cs create mode 100644 Kepware.Api/Serializer/ClientInterfacesFlattener.cs diff --git a/Kepware.Api/Model/BaseEntity.cs b/Kepware.Api/Model/BaseEntity.cs index 8ebb5e6..7a302a8 100644 --- a/Kepware.Api/Model/BaseEntity.cs +++ b/Kepware.Api/Model/BaseEntity.cs @@ -43,8 +43,18 @@ public interface IHaveName [DebuggerDisplay("{TypeName} - {Description}")] public abstract class BaseEntity : IEquatable { + private bool _dynamicPropertiesNormalized = false; private ulong? _hash; + /// + /// Flag indicating if the entity includes nested dynamic properties that require normalization. + /// + /// + /// When set to true, accessing dynamic properties will trigger normalization via class overrides. + /// For example, the class sets this to true to handle nested client_interfaces arrays. + /// + public bool IncludesNestedDynamicProperties = false; + /// /// Unique hash representing the current state of the entity. /// @@ -95,6 +105,8 @@ public abstract class BaseEntity : IEquatable /// The value of the property, or default if not found. public T? GetDynamicProperty(string key) { + if (IncludesNestedDynamicProperties) EnsureDynamicPropertiesNormalized(); + if (DynamicProperties.TryGetValue(key, out var value)) { if (value is JsonElement jsonElement) @@ -143,6 +155,7 @@ public abstract class BaseEntity : IEquatable /// The current instance for chaining. public BaseEntity SetDynamicProperty(string key, T value) { + if (IncludesNestedDynamicProperties) EnsureDynamicPropertiesNormalized(); if (value is null) { if (DynamicProperties.ContainsKey(key)) @@ -167,8 +180,10 @@ public BaseEntity SetDynamicProperty(string key, T value) /// The key of the property. /// The retrieved value, or null if not found. /// True if the property exists, false otherwise. - public bool TryGetGetDynamicProperty(string key, [NotNullWhen(true)] out T? value) + public bool TryGetDynamicProperty(string key, [NotNullWhen(true)] out T? value) { + if (IncludesNestedDynamicProperties) EnsureDynamicPropertiesNormalized(); + if (DynamicProperties.TryGetValue(key, out var jsonElement) && Convert.ChangeType(KepJsonContext.Unwrap(jsonElement), typeof(T)) is T convertedValue) { @@ -195,6 +210,34 @@ protected internal virtual ulong CalculateHash() ); } + /// + /// Ensures that dynamic properties are normalized. This method uses the Template Method pattern, + /// calling to allow derived classes to customize normalization behavior. + /// + protected virtual void EnsureDynamicPropertiesNormalized() + { + if (_dynamicPropertiesNormalized) return; + + // Call the hook method to allow derived classes to normalize their nested properties + NormalizeNestedProperties(); + + _dynamicPropertiesNormalized = true; + } + + /// + /// Normalizes nested properties specific to this entity type. Override this method to implement + /// custom normalization logic (e.g., flattening nested arrays into dynamic properties). + /// + /// + /// This method is called by during property access. + /// Implement this in derived classes to handle entity-specific normalization. + /// For example, overrides this to flatten nested "client_interfaces" arrays. + /// + protected virtual void NormalizeNestedProperties() + { + // Default implementation: no normalization. Derived classes override to add custom behavior. + } + /// /// Appends additional hash sources for derived classes. /// @@ -229,7 +272,7 @@ public virtual async Task Cleanup(IKepwareDefaultValueProvider defaultValueProvi DynamicProperties.Remove(Properties.Description); } - if (TryGetGetDynamicProperty(Properties.Channel.DeviceDriver, out var driver)) + if (TryGetDynamicProperty(Properties.Channel.DeviceDriver, out var driver)) { var defaultValues = await defaultValueProvider.GetDefaultValuesAsync(driver, TypeName, cancellationToken); foreach (var prop in DynamicProperties.ToList()) diff --git a/Kepware.Api/Model/Project/Project.cs b/Kepware.Api/Model/Project/Project.cs index 43f6869..bfbcceb 100644 --- a/Kepware.Api/Model/Project/Project.cs +++ b/Kepware.Api/Model/Project/Project.cs @@ -15,6 +15,7 @@ namespace Kepware.Api.Model /// interfaces that Kepware supports. /// [Endpoint("/config/v1/project")] + [JsonConverter(typeof(ProjectPropertiesJsonConverter))] public class Project : DefaultEntity // Updated from BaseEntity to leverage GetUpdateDiff methods for Project Properties updates { @@ -28,6 +29,8 @@ public class Project : DefaultEntity /// public Project() { + // Set flag to indicate that dynamic properties will include nested properties from client_interfaces + IncludesNestedDynamicProperties = true; ProjectProperties = new(this); } @@ -81,6 +84,36 @@ public async Task CloneAsync(CancellationToken cancellationToken = defa throw new InvalidOperationException("CloneAsync failed"); } + /// + /// Normalizes nested properties by flattening the client_interfaces array from the Kepware API. + /// + /// + /// If the Kepware API returns a nested client_interfaces array as a single dynamic property (full project load), + /// this method expands it into flattened dynamic properties so existing getters/setters can work. + /// + protected override void NormalizeNestedProperties() + { + // If server returned a nested client_interfaces array as a single dynamic property, + // expand it into flattened dynamic properties so existing getters/setters can work. + if (DynamicProperties.TryGetValue("client_interfaces", out var ciElement) && + ciElement.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var item in ciElement.EnumerateArray()) + { + if (item.ValueKind != System.Text.Json.JsonValueKind.Object) continue; + foreach (var prop in item.EnumerateObject()) + { + if (prop.NameEquals("common.ALLTYPES_NAME")) continue; + // Overwrite existing keys — nested wins + DynamicProperties[prop.Name] = prop.Value.Clone(); + } + } + + // Remove the original nested property to avoid duplication + DynamicProperties.Remove("client_interfaces"); + } + } + } #region Enums diff --git a/Kepware.Api/Model/Project/ProjectPropertiesJsonConverter.cs b/Kepware.Api/Model/Project/ProjectPropertiesJsonConverter.cs new file mode 100644 index 0000000..3401c56 --- /dev/null +++ b/Kepware.Api/Model/Project/ProjectPropertiesJsonConverter.cs @@ -0,0 +1,135 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Collections.Generic; +using System.Text.Json.Serialization.Metadata; +using Kepware.Api.Serializer; + +namespace Kepware.Api.Model +{ + /// + /// Custom converter to support both flat and nested (client_interfaces) project property formats. + /// + public class ProjectPropertiesJsonConverter : JsonConverter + { + public override Project? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.StartObject) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + var project = new Project(); + + // Detect if this is the nested format Project Properties from full project load (has "client_interfaces") + if (root.TryGetProperty("client_interfaces", out var clientInterfaces)) + { + foreach (var iface in clientInterfaces.EnumerateArray()) + { + if (iface.TryGetProperty("common.ALLTYPES_NAME", out var nameProp)) + { + var name = nameProp.GetString(); + foreach (var prop in iface.EnumerateObject()) + { + if (prop.Name != "common.ALLTYPES_NAME") + { + // Map to ProjectProperties via dynamic property name + project.SetDynamicProperty($"{prop.Name}", prop.Value.Clone()); + } + } + } + } + } + + // Map other top-level properties (not client_interfaces) + // This will include covering the flat format Project Properties (not full project load) + + foreach (var prop in root.EnumerateObject()) + { + if (prop.Name == "client_interfaces") + { + continue; + } + + // Handle exposed properties supporting Project model + // This includes properties inherited from BaseEntity such as PROJECT_ID, DESCRIPTION, etc. + // TODO: Expand as needed for other known properties or consider common approach for BaseEntity properties + if (root.TryGetProperty("channels", out var channelsProp)) + { + var jsonTypeInfo = (JsonTypeInfo>)options.GetTypeInfo(typeof(List)); + var channels = JsonSerializer.Deserialize(channelsProp.GetRawText(), jsonTypeInfo); + if (channels != null) + { + project.Channels = new ChannelCollection(); + foreach (var channel in channels) + { + project.Channels.Add(channel); + } + } + } + else if (prop.Name == "PROJECT_ID") + { + if (prop.Value.TryGetInt64(out var projectId)) + { + project.ProjectId = projectId; + } + else + { + throw new JsonException($"Invalid value for PROJECT_ID: {prop.Value}"); + } + } + else if (prop.Name == "common.ALLTYPES_DESCRIPTION") + { + project.Description = prop.Value.ToString(); + } + else + { + project.SetDynamicProperty(prop.Name, prop.Value.Clone()); + } + } + + return project; + } + throw new JsonException("Expected start of object for Project"); + } + + public override void Write(Utf8JsonWriter writer, Project value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + // Emit channels specially (if present) + if (value.Channels != null) + { + writer.WritePropertyName("channels"); + var jsonTypeInfo = (JsonTypeInfo>)options.GetTypeInfo(typeof(List)); + JsonSerializer.Serialize(writer, value.Channels.ToList(), jsonTypeInfo); + } + + // Build grouped client_interfaces element from flattened dynamic properties + var clientInterfacesElement = ClientInterfacesFlattener.BuildClientInterfacesArrayFromDynamicProperties(value.DynamicProperties); + if (clientInterfacesElement.HasValue) + { + writer.WritePropertyName("client_interfaces"); + clientInterfacesElement.Value.WriteTo(writer); + } + + // Emit remaining dynamic properties (skip interface-prefixed keys) + foreach (var kvp in value.DynamicProperties) + { + var idx = kvp.Key.IndexOf('.'); + if (idx > 0) + { + var prefix = kvp.Key.Substring(0, idx); + if (ClientInterfacesFlattener.IsInterfacePrefix(prefix)) + { + continue; + } + } + + writer.WritePropertyName(kvp.Key); + kvp.Value.WriteTo(writer); + } + + writer.WriteEndObject(); + } + } +} diff --git a/Kepware.Api/Serializer/BaseEntityYamlConverter.cs b/Kepware.Api/Serializer/BaseEntityYamlConverter.cs index c605797..58e2d82 100644 --- a/Kepware.Api/Serializer/BaseEntityYamlConverter.cs +++ b/Kepware.Api/Serializer/BaseEntityYamlConverter.cs @@ -80,6 +80,15 @@ public bool Accepts(Type type) { entity.Description = value?.ToString() ?? string.Empty; } + else if (key == "client_interfaces") + { + // Flatten the client_interfaces sequence into the DynamicProperties map. + // Nested values overwrite top-level ones (nested wins). + if (!m_nonpersistetDynamicProps.Contains(key)) + { + ClientInterfacesFlattener.FlattenFromObject(value, entity.DynamicProperties); + } + } else { if (!m_nonpersistetDynamicProps.Contains(key)) @@ -161,13 +170,34 @@ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializ emitter.Emit(new Scalar(entity.Description)); } - // Serialize DynamicProperties directly at the top level + // Emit grouped client_interfaces if present in dynamic properties + var clientInterfacesElement = ClientInterfacesFlattener.BuildClientInterfacesArrayFromDynamicProperties(entity.DynamicProperties); + if (clientInterfacesElement.HasValue) + { + emitter.Emit(new Scalar("client_interfaces")); + SerializeJsonValue(emitter, clientInterfacesElement.Value, nestedObjectSerializer); + } + + // Serialize remaining DynamicProperties at the top level, skipping interface-prefixed keys foreach (var property in entity.DynamicProperties) { if (m_nonpersistetDynamicProps.Contains(property.Key)) { continue; } + + var idx = property.Key.IndexOf('.'); + if (idx > 0) + { + var prefix = property.Key.Substring(0, idx); + // skip writing individual interface-prefixed keys because they are emitted + // grouped in the client_interfaces sequence above + if (ClientInterfacesFlattener.IsInterfacePrefix(prefix)) + { + continue; + } + } + emitter.Emit(new Scalar(property.Key)); SerializeJsonValue(emitter, property.Value, nestedObjectSerializer); } diff --git a/Kepware.Api/Serializer/ClientInterfacesFlattener.cs b/Kepware.Api/Serializer/ClientInterfacesFlattener.cs new file mode 100644 index 0000000..28d03cf --- /dev/null +++ b/Kepware.Api/Serializer/ClientInterfacesFlattener.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace Kepware.Api.Serializer +{ + internal static class ClientInterfacesFlattener + { + private static readonly HashSet KnownPrefixes = new(StringComparer.OrdinalIgnoreCase) + { + "opcdaserver", + "wwtoolkitinterface", + "ddeserver", + "uaserverinterface", + "aeserverinterface", + "hdaserver", + "thingworxinterface" + }; + + public static bool IsInterfacePrefix(string prefix) => KnownPrefixes.Contains(prefix); + + /// + /// Flatten a runtime-deserialized object (from YAML) representing the value of a + /// top-level `client_interfaces` sequence into the provided dynamicProperties dictionary. + /// Nested values overwrite existing top-level keys (nested wins). + /// + public static void FlattenFromObject(object? value, IDictionary dynamicProperties) + { + if (value is not List list) return; + + foreach (var item in list) + { + if (item is not Dictionary dict) continue; + + // discover interface name if provided + string? ifaceName = null; + if (dict.TryGetValue("common.ALLTYPES_NAME", out var nameObj) && nameObj is string s) + { + ifaceName = s; + } + + // if name not present, try to infer from any prefixed keys + if (string.IsNullOrEmpty(ifaceName)) + { + foreach (var k in dict.Keys) + { + var idx = k.IndexOf('.'); + if (idx > 0) + { + var prefix = k.Substring(0, idx); + if (KnownPrefixes.Contains(prefix)) + { + ifaceName = prefix; + break; + } + } + } + } + + // inject all properties from the interface object into dynamicProperties + foreach (var kv in dict) + { + if (kv.Key == "common.ALLTYPES_NAME") + continue; + + dynamicProperties[kv.Key] = KepJsonContext.WrapInJsonElement(kv.Value); + } + } + } + + /// + /// Group flattened dynamic properties into a client_interfaces JsonElement array. + /// Returns null if no interface-prefixed keys are present. + /// + public static JsonElement? BuildClientInterfacesArrayFromDynamicProperties(IDictionary dynamicProperties) + { + var groups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var kv in dynamicProperties) + { + var idx = kv.Key.IndexOf('.'); + if (idx <= 0) continue; + var prefix = kv.Key.Substring(0, idx); + if (!KnownPrefixes.Contains(prefix)) continue; + + if (!groups.TryGetValue(prefix, out var dict)) + { + dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + groups[prefix] = dict; + } + + dict[kv.Key] = kv.Value; + } + + if (groups.Count == 0) return null; + + var list = new List>(); + foreach (var g in groups) + { + var obj = new Dictionary(); + obj["common.ALLTYPES_NAME"] = g.Key; + foreach (var kv in g.Value) + { + obj[kv.Key] = KepJsonContext.Unwrap(kv.Value); + } + list.Add(obj); + } + + // Build a JsonElement array by serializing each object using the AOT-friendly + // wrapping helper and writing the elements into a Utf8JsonWriter. This avoids + // calling JsonSerializer.Serialize on an open generic which would trigger + // trimming/AOT issues (IL2026). + var elements = new List(); + foreach (var obj in list) + { + // Wrap the dictionary into a JsonElement using the generated context + elements.Add(KepJsonContext.WrapInJsonElement(obj)); + } + + using var ms = new System.IO.MemoryStream(); + using (var writer = new Utf8JsonWriter(ms)) + { + writer.WriteStartArray(); + foreach (var el in elements) + { + el.WriteTo(writer); + } + writer.WriteEndArray(); + writer.Flush(); + } + + ms.Position = 0; + using var doc = JsonDocument.Parse(ms); + return doc.RootElement.Clone(); + } + } +} From c92d353e1861b335b8396a67f361871344876cf6 Mon Sep 17 00:00:00 2001 From: Ray Labbe Date: Thu, 5 Feb 2026 17:03:21 -0500 Subject: [PATCH 2/4] test(api): Test for updated serialization of Project Properties --- .../ClientInterfacesFlattenerTests.cs | 84 +++++++ .../EnsureDynamicPropertiesNormalizedTests.cs | 218 ++++++++++++++++++ ...tProperties_ClientInterfacesNestedTests.cs | 48 ++++ .../ApiClient/ProjectPropertiesTests.cs | 9 + 4 files changed, 359 insertions(+) create mode 100644 Kepware.Api.Test/Serializer/ClientInterfacesFlattenerTests.cs create mode 100644 Kepware.Api.Test/Serializer/EnsureDynamicPropertiesNormalizedTests.cs create mode 100644 Kepware.Api.Test/Serializer/ProjectProperties_ClientInterfacesNestedTests.cs diff --git a/Kepware.Api.Test/Serializer/ClientInterfacesFlattenerTests.cs b/Kepware.Api.Test/Serializer/ClientInterfacesFlattenerTests.cs new file mode 100644 index 0000000..33a82e7 --- /dev/null +++ b/Kepware.Api.Test/Serializer/ClientInterfacesFlattenerTests.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using Kepware.Api.Serializer; +using Xunit; + +namespace Kepware.Api.Test.Serializer +{ + public class ClientInterfacesFlattenerTests + { + [Fact] + public void FlattenFromObject_ShouldFlattenKnownInterfaceEntries() + { + var dynamicProps = new Dictionary(); + + var ddeiface = new Dictionary + { + ["common.ALLTYPES_NAME"] = "ddeserver", + ["ddeserver.ENABLE"] = false, + ["ddeserver.SERVICE_NAME"] = "ptcdde" + }; + + var opcdaiface = new Dictionary + { + ["common.ALLTYPES_NAME"] = "opcdaserver", + ["opcdaserver.ENABLE"] = true + }; + + var list = new List { ddeiface, opcdaiface }; + + ClientInterfacesFlattener.FlattenFromObject(list, dynamicProps); + + Assert.True(dynamicProps.ContainsKey("ddeserver.ENABLE")); + Assert.True(dynamicProps.ContainsKey("ddeserver.SERVICE_NAME")); + Assert.False(dynamicProps["ddeserver.ENABLE"].GetBoolean()); + Assert.Equal("ptcdde", dynamicProps["ddeserver.SERVICE_NAME"].GetString()); + Assert.True(dynamicProps.ContainsKey("opcdaserver.ENABLE")); + Assert.True(dynamicProps["opcdaserver.ENABLE"].GetBoolean()); + } + + [Fact] + public void BuildClientInterfacesArrayFromDynamicProperties_ShouldGroupInterfaceKeys() + { + var dynamicProps = new Dictionary + { + ["ddeserver.ENABLE"] = Kepware.Api.Serializer.KepJsonContext.WrapInJsonElement(false), + ["ddeserver.SERVICE_NAME"] = Kepware.Api.Serializer.KepJsonContext.WrapInJsonElement("ptcdde"), + ["opcdaserver.ENABLE"] = Kepware.Api.Serializer.KepJsonContext.WrapInJsonElement(true), + ["uaserverinterface.ENABLE"] = Kepware.Api.Serializer.KepJsonContext.WrapInJsonElement(true), + ["servermain.PROJECT_TITLE"] = Kepware.Api.Serializer.KepJsonContext.WrapInJsonElement("MyProject") + }; + + var el = ClientInterfacesFlattener.BuildClientInterfacesArrayFromDynamicProperties(dynamicProps); + + Assert.NotNull(el); + Assert.Equal(JsonValueKind.Array, el.Value.ValueKind); + var arr = el.Value.EnumerateArray(); + foreach (var obj in arr) + { + if (obj.TryGetProperty("common.ALLTYPES_NAME", out var name) && name.GetString() == "ddeserver") + { + Assert.True(obj.TryGetProperty("ddeserver.SERVICE_NAME", out var svc)); + Assert.Equal("ptcdde", svc.GetString()); + Assert.True(obj.TryGetProperty("ddeserver.ENABLE", out var en)); + Assert.False(en.GetBoolean()); + } + else if (obj.TryGetProperty("common.ALLTYPES_NAME", out var name2) && name2.GetString() == "opcdaserver") + { + Assert.True(obj.TryGetProperty("opcdaserver.ENABLE", out var en)); + Assert.True(en.GetBoolean()); + } + else if (obj.TryGetProperty("common.ALLTYPES_NAME", out var name3) && name3.GetString() == "uaserverinterface") + { + Assert.True(obj.TryGetProperty("uaserverinterface.ENABLE", out var en)); + Assert.True(en.GetBoolean()); + } + else + { + Assert.Fail("Unexpected interface name in client_interfaces array"); + } + } + } + } +} diff --git a/Kepware.Api.Test/Serializer/EnsureDynamicPropertiesNormalizedTests.cs b/Kepware.Api.Test/Serializer/EnsureDynamicPropertiesNormalizedTests.cs new file mode 100644 index 0000000..69637c7 --- /dev/null +++ b/Kepware.Api.Test/Serializer/EnsureDynamicPropertiesNormalizedTests.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using Kepware.Api.Model; +using Kepware.Api.Serializer; +using Xunit; + +namespace Kepware.Api.Test.Serializer +{ + /// + /// Tests for the template method pattern in EnsureDynamicPropertiesNormalized and NormalizeNestedProperties. + /// + public class EnsureDynamicPropertiesNormalizedTests + { + /// + /// Mock entity that demonstrates custom normalization via NormalizeNestedProperties override. + /// + private class MockEntityWithCustomNormalization : DefaultEntity + { + public bool NormalizeWasCalled { get; internal set; } = false; + + /// + /// Demonstrates how a derived class implements custom normalization. + /// + protected override void NormalizeNestedProperties() + { + NormalizeWasCalled = true; + + // Flatten mock "custom_interfaces" array similar to how Project flattens "client_interfaces" + if (DynamicProperties.TryGetValue("custom_interfaces", out var ciElement) && + ciElement.ValueKind == JsonValueKind.Array) + { + foreach (var item in ciElement.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) continue; + foreach (var prop in item.EnumerateObject()) + { + if (prop.NameEquals("common.ALLTYPES_NAME")) continue; + DynamicProperties[prop.Name] = prop.Value.Clone(); + } + } + + DynamicProperties.Remove("custom_interfaces"); + } + } + } + + [Fact] + public void EnsureDynamicPropertiesNormalized_CallsNormalizeNestedProperties() + { + var entity = new MockEntityWithCustomNormalization + { + IncludesNestedDynamicProperties = true + }; + + // Setting a property should trigger normalization + entity.SetDynamicProperty("test.key", "test.value"); + + // Verify that NormalizeNestedProperties was called + Assert.True(entity.NormalizeWasCalled, "NormalizeNestedProperties should have been called"); + } + + [Fact] + public void BaseEntity_DefaultNormalization_DoesNothing() + { + var entity = new DefaultEntity + { + IncludesNestedDynamicProperties = true + }; + + // Add a mock nested array that would be processed if default impl handled it + var nestedArray = JsonSerializer.SerializeToElement(new[] + { + new Dictionary + { + ["common.ALLTYPES_NAME"] = "interface1", + ["interface1.PROPERTY"] = "value" + } + }); + + entity.DynamicProperties["custom_array"] = nestedArray; + + // Trigger normalization + // Exception would be expected if base implementation tried to process the array + Assert.Throws(() => entity.GetDynamicProperty("custom_array")); + + // Base implementation should NOT flatten custom arrays + Assert.True(entity.DynamicProperties.ContainsKey("custom_array"), + "Base implementation should not flatten custom arrays"); + } + + [Fact] + public void DerivedClass_CustomNormalization_FlattensMockInterfaces() + { + var entity = new MockEntityWithCustomNormalization + { + IncludesNestedDynamicProperties = true + }; + + // Simulate nested properties from API response + var nestedArray = JsonSerializer.SerializeToElement(new[] + { + new Dictionary + { + ["common.ALLTYPES_NAME"] = "mock_interface", + ["mock.PROPERTY1"] = "value1", + ["mock.PROPERTY2"] = 42 + } + }); + + entity.DynamicProperties["custom_interfaces"] = nestedArray; + + // Trigger normalization + entity.GetDynamicProperty("mock.PROPERTY1"); + + // After normalization, nested array should be flattened + Assert.False(entity.DynamicProperties.ContainsKey("custom_interfaces"), + "Nested custom_interfaces should be removed after normalization"); + Assert.True(entity.DynamicProperties.ContainsKey("mock.PROPERTY1"), + "Flattened properties should exist in DynamicProperties"); + Assert.Equal("value1", entity.GetDynamicProperty("mock.PROPERTY1")); + } + + [Fact] + public void Project_NormalizeNestedProperties_FlattenClientInterfaces() + { + var json = """ + { + "client_interfaces": [ + { + "common.ALLTYPES_NAME": "ddeserver", + "ddeserver.ENABLE": false, + "ddeserver.SERVICE_NAME": "ptcdde" + }, + { + "common.ALLTYPES_NAME": "opcdaserver", + "opcdaserver.ENABLE": true + } + ] + } + """; + + var project = JsonSerializer.Deserialize(json, KepJsonContext.Default.Project); + Assert.NotNull(project); + + // Project's normalization should flatten client_interfaces + Assert.False(project.GetDynamicProperty("ddeserver.ENABLE")); + Assert.Equal("ptcdde", project.GetDynamicProperty("ddeserver.SERVICE_NAME")); + Assert.True(project.GetDynamicProperty("opcdaserver.ENABLE")); + } + + [Fact] + public void EnsureDynamicPropertiesNormalized_OnlyNormalizedOnce() + { + var entity = new MockEntityWithCustomNormalization + { + IncludesNestedDynamicProperties = true + }; + + // First call should trigger NormalizeNestedProperties + entity.DynamicProperties["test.key"] = KepJsonContext.WrapInJsonElement("test.value"); + entity.GetDynamicProperty("test.key"); + var firstCallResult = entity.NormalizeWasCalled; + + // Reset the flag + entity.NormalizeWasCalled = false; + + // Second call should NOT trigger NormalizeNestedProperties again + entity.GetDynamicProperty("test.key"); + var secondCallResult = entity.NormalizeWasCalled; + + Assert.True(firstCallResult, "First call should trigger NormalizeNestedProperties"); + Assert.False(secondCallResult, "Second call should not trigger NormalizeNestedProperties"); + } + + [Fact] + public void SetDynamicProperty_TriggerNormalization() + { + var entity = new MockEntityWithCustomNormalization + { + IncludesNestedDynamicProperties = true + }; + + // Setting a property should trigger normalization + entity.SetDynamicProperty("test.key", "test.value"); + + Assert.True(entity.NormalizeWasCalled, "NormalizeNestedProperties should be called on SetDynamicProperty"); + } + + [Fact] + public void TryGetGetDynamicProperty_TriggerNormalization() + { + var entity = new MockEntityWithCustomNormalization + { + IncludesNestedDynamicProperties = true + }; + + entity.DynamicProperties["test.key"] = KepJsonContext.WrapInJsonElement("test.value"); + + // Trying to get a property should trigger normalization + entity.TryGetDynamicProperty("test.key", out _); + + Assert.True(entity.NormalizeWasCalled, "NormalizeNestedProperties should be called on TryGetDynamicProperty"); + } + + [Fact] + public void Serialize_ProjectWithModifiedProperties_ShouldEmitClientInterfacesArray() + { + var project = new Project(); + project.SetDynamicProperty("ddeserver.ENABLE", true); + project.SetDynamicProperty("ddeserver.SERVICE_NAME", "ptcdde"); + + var json = JsonSerializer.Serialize(project, KepJsonContext.Default.Project); + Assert.Contains("client_interfaces", json); + Assert.Contains("ddeserver.SERVICE_NAME", json); + } + } +} diff --git a/Kepware.Api.Test/Serializer/ProjectProperties_ClientInterfacesNestedTests.cs b/Kepware.Api.Test/Serializer/ProjectProperties_ClientInterfacesNestedTests.cs new file mode 100644 index 0000000..5b7efde --- /dev/null +++ b/Kepware.Api.Test/Serializer/ProjectProperties_ClientInterfacesNestedTests.cs @@ -0,0 +1,48 @@ +using System; +using System.Text.Json; +using Kepware.Api.Model; +using Xunit; + +namespace Kepware.Api.Test.Serializer +{ + public class ProjectProperties_ClientInterfacesNestedTests + { + [Fact] + public void DeserializeNestedClientInterfaces_ShouldPopulateProjectProperties() + { + var json = """ + { + "client_interfaces": [ + { + "common.ALLTYPES_NAME": "ddeserver", + "ddeserver.ENABLE": false, + "ddeserver.SERVICE_NAME": "ptcdde" + }, + { + "common.ALLTYPES_NAME": "opcdaserver", + "opcdaserver.ENABLE": true + } + ] + } + """; + + var project = JsonSerializer.Deserialize(json, Api.Serializer.KepJsonContext.Default.Project); + Assert.NotNull(project); + // After normalization, dynamic properties should contain flattened keys + Assert.False(project.GetDynamicProperty("ddeserver.ENABLE")); + Assert.Equal("ptcdde", project.GetDynamicProperty("ddeserver.SERVICE_NAME")); + } + + [Fact] + public void Serialize_ProjectWithModifiedProperties_ShouldEmitClientInterfacesArray() + { + var project = new Project(); + project.SetDynamicProperty("ddeserver.ENABLE", true); + project.SetDynamicProperty("ddeserver.SERVICE_NAME", "ptcdde"); + + var json = JsonSerializer.Serialize(project, Api.Serializer.KepJsonContext.Default.Project); + Assert.Contains("client_interfaces", json); + Assert.Contains("ddeserver.SERVICE_NAME", json); + } + } +} diff --git a/Kepware.Api.TestIntg/ApiClient/ProjectPropertiesTests.cs b/Kepware.Api.TestIntg/ApiClient/ProjectPropertiesTests.cs index ef54cc7..fad3221 100644 --- a/Kepware.Api.TestIntg/ApiClient/ProjectPropertiesTests.cs +++ b/Kepware.Api.TestIntg/ApiClient/ProjectPropertiesTests.cs @@ -64,6 +64,15 @@ public async Task SetProjectPropertiesAsync_ShouldReturnTrue_WhenUpdateSuccessfu // Assert result.ShouldBeTrue(); + + // Verify the settings were actually applied + var updatedSettings = await _kepwareApiClient.Project.GetProjectPropertiesAsync(); + updatedSettings.ShouldNotBeNull(); + if (_productInfo.ProductId != "013") + { + updatedSettings.ProjectProperties.OpcDaMaxConnections.ShouldBe(newSettings.ProjectProperties.OpcDaMaxConnections); + } + updatedSettings.ProjectProperties.OpcUaMaxConnections.ShouldBe(newSettings.ProjectProperties.OpcUaMaxConnections); } [Fact] From 638ee509a4a7ca6b6b6ab655914c6b72d3646ea8 Mon Sep 17 00:00:00 2001 From: Ray Labbe Date: Thu, 5 Feb 2026 17:30:33 -0500 Subject: [PATCH 3/4] doc(api): Update readme for project properties enhancement --- Kepware.Api/README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/Kepware.Api/README.md b/Kepware.Api/README.md index 9b4d0a4..ef53f5e 100644 --- a/Kepware.Api/README.md +++ b/Kepware.Api/README.md @@ -9,18 +9,19 @@ The `Kepware.Api` library provides a robust client implementation to interact wi This package is designed to work with all versions of Kepware that support the Configuration API including Kepware Server (KS), and Kepware Edge (KE). For reference, Kepware Server in this documentation will also imply Thingworx Kepware Server and KEPServerEX versions prior to v7.0 when v6.x is referenced. ## Features + 1. Connect to Kepware Configuration APIs securely with HTTPS and optional certificate validation. 2. Perform CRUD operations for the following Kepware configuration objects: -| Features | KS | KE | -| :----------: | :----------: | :----------: | -| **Project Properties**
*(Get Only)* | Y | Y | +| Features | KS | KE | +| :----------: | :----------: | :----------: | +| **Project Properties** | Y | Y | | **Connectivity**
*(Channel, Devices, Tags, Tag Groups)* | Y | Y | | **Administration**
*(User Groups, Users, UA Endpoints, Local License Server)* | Y[^1] | Y | | **Product Info and Health Status** | Y[^4] | Y | -| **Export Project**| Y[^2] | Y | -| **Import Project (via CompareAndApply)[^3]**| Y | Y | -| **Import Project (via JsonProjectLoad Service)**| N[^2] | N | +| **Export Project** | Y[^2] | Y | +| **Import Project (via JsonProjectLoad Service)** | N[^2] | N | +| **Import Project (via CompareAndApply)[^3]** | Y | Y | [^1]: UA Endpoints and Local License Server supported for Kepware Edge only [^2]: JsonProjectLoad was added to Kepware Server v6.17 and later builds, the SDK detects the server version and uses the appropriate service or loads the project by multiple requests if using KepwareApiClient.LoadProject. @@ -29,12 +30,12 @@ This package is designed to work with all versions of Kepware that support the C 3. Configuration API *Services* implemented: -| Services | KS | KE | -| :----------: | :----------: | :----------: | +| Services | KS | KE | +| :----------: | :----------: | :----------: | | **TagGeneration**
*(for supported drivers)* | Y | Y | | **ReinitializeRuntime** | Y* | Y | -| **ProjectLoad and ProjectSave**| N | N | -| **JsonProjectLoad\*\***
*(used for import project feature)*| Y | Y | +| **ProjectLoad and ProjectSave** | N | N | +| **JsonProjectLoad\*\***
*(used for import project feature)* | Y | Y | 4. Synchronize configurations between your application and Kepware server. 5. Supports advanced operations like project comparison, entity synchronization, and driver property queries. From 80d3f85c7faa00c8ea2a900abbbcbe186e761c14 Mon Sep 17 00:00:00 2001 From: Ray Labbe Date: Thu, 5 Feb 2026 17:30:33 -0500 Subject: [PATCH 4/4] doc(api): Update readme for project properties enhancement --- Kepware.Api/README.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/Kepware.Api/README.md b/Kepware.Api/README.md index 9b4d0a4..40625b1 100644 --- a/Kepware.Api/README.md +++ b/Kepware.Api/README.md @@ -1,6 +1,6 @@ # Kepware.Api -[![Build Status](https://github.com/PTCInc/Kepware-ConfigAPI-SDK-dotnet/actions/workflows/dotnet.yml/badge.svg)](https://github.com/PTCInc/Kepware-ConfigAPI-SDK-dotnet/actions) +[![Build Status](https://github.com/PTCInc/Kepware-ConfigAPI-SDK-dotnet/actions/workflows/nuget-test-and-build.yml/badge.svg)](https://github.com/PTCInc/Kepware-ConfigAPI-SDK-dotnet/actions) [![NuGet](https://img.shields.io/nuget/v/Kepware.Api.svg)](https://www.nuget.org/packages/Kepware.Api/) ## Overview @@ -9,18 +9,19 @@ The `Kepware.Api` library provides a robust client implementation to interact wi This package is designed to work with all versions of Kepware that support the Configuration API including Kepware Server (KS), and Kepware Edge (KE). For reference, Kepware Server in this documentation will also imply Thingworx Kepware Server and KEPServerEX versions prior to v7.0 when v6.x is referenced. ## Features + 1. Connect to Kepware Configuration APIs securely with HTTPS and optional certificate validation. 2. Perform CRUD operations for the following Kepware configuration objects: -| Features | KS | KE | -| :----------: | :----------: | :----------: | -| **Project Properties**
*(Get Only)* | Y | Y | +| Features | KS | KE | +| :----------: | :----------: | :----------: | +| **Project Properties** | Y | Y | | **Connectivity**
*(Channel, Devices, Tags, Tag Groups)* | Y | Y | | **Administration**
*(User Groups, Users, UA Endpoints, Local License Server)* | Y[^1] | Y | | **Product Info and Health Status** | Y[^4] | Y | -| **Export Project**| Y[^2] | Y | -| **Import Project (via CompareAndApply)[^3]**| Y | Y | -| **Import Project (via JsonProjectLoad Service)**| N[^2] | N | +| **Export Project** | Y[^2] | Y | +| **Import Project (via JsonProjectLoad Service)** | N[^2] | N | +| **Import Project (via CompareAndApply)[^3]** | Y | Y | [^1]: UA Endpoints and Local License Server supported for Kepware Edge only [^2]: JsonProjectLoad was added to Kepware Server v6.17 and later builds, the SDK detects the server version and uses the appropriate service or loads the project by multiple requests if using KepwareApiClient.LoadProject. @@ -29,12 +30,12 @@ This package is designed to work with all versions of Kepware that support the C 3. Configuration API *Services* implemented: -| Services | KS | KE | -| :----------: | :----------: | :----------: | +| Services | KS | KE | +| :----------: | :----------: | :----------: | | **TagGeneration**
*(for supported drivers)* | Y | Y | | **ReinitializeRuntime** | Y* | Y | -| **ProjectLoad and ProjectSave**| N | N | -| **JsonProjectLoad\*\***
*(used for import project feature)*| Y | Y | +| **ProjectLoad and ProjectSave** | N | N | +| **JsonProjectLoad\*\***
*(used for import project feature)* | Y | Y | 4. Synchronize configurations between your application and Kepware server. 5. Supports advanced operations like project comparison, entity synchronization, and driver property queries.