Skip to content

Commit d95f98d

Browse files
Merge pull request #305 from microsoft/users/shlomiyosef/converter-use-porp-name-and-ignore
Update ConnectorConverter to honer JsonIgnore and JsonPropertyName
2 parents 47f317d + 08ad250 commit d95f98d

File tree

4 files changed

+145
-56
lines changed

4 files changed

+145
-56
lines changed

src/libraries/Core/Microsoft.Agents.Core/Models/AIEntity.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,19 @@ public AIEntity() : base("https://schema.org/Message") {}
1616
/// <summary>
1717
/// Required. Must be "Message".
1818
/// </summary>
19-
//[JsonPropertyName("@type")]
19+
[JsonPropertyName("@type")]
2020
public string AtType { get; set; } = "Message";
2121

2222
/// <summary>
2323
/// Required. Must be "https://schema.org"
2424
/// </summary>
25-
//[JsonPropertyName("@context")]
25+
[JsonPropertyName("@context")]
2626
public string AtContext { get; set; } = "https://schema.org";
2727

2828
/// <summary>
2929
/// Must be left blank.
3030
/// </summary>
31-
//[JsonPropertyName("@id")]
31+
[JsonPropertyName("@id")]
3232
public string AtId { get; set; } = "";
3333

3434
/// <summary>

src/libraries/Core/Microsoft.Agents.Core/Serialization/Converters/AIEntityConverter.cs

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,7 @@ internal class AIEntityConverter : ConnectorConverter<AIEntity>
1010
{
1111
protected override void ReadExtensionData(ref Utf8JsonReader reader, AIEntity value, string propertyName, JsonSerializerOptions options)
1212
{
13-
if (propertyName.Equals("@type"))
14-
{
15-
value.AtType = JsonSerializer.Deserialize<string>(ref reader, options);
16-
}
17-
else if (propertyName.Equals("@context"))
18-
{
19-
value.AtContext = JsonSerializer.Deserialize<string>(ref reader, options);
20-
}
21-
else if (propertyName.Equals("@id"))
22-
{
23-
value.AtId = JsonSerializer.Deserialize<string>(ref reader, options);
24-
}
25-
else
26-
{
27-
value.Properties.Add(propertyName, JsonSerializer.Deserialize<JsonElement>(ref reader, options));
28-
}
13+
value.Properties.Add(propertyName, JsonSerializer.Deserialize<JsonElement>(ref reader, options));
2914
}
3015

3116
protected override bool TryReadExtensionData(ref Utf8JsonReader reader, AIEntity value, string propertyName, JsonSerializerOptions options)
@@ -57,24 +42,6 @@ protected override bool TryWriteExtensionData(Utf8JsonWriter writer, AIEntity va
5742

5843
return true;
5944
}
60-
else if (propertyName.Equals(nameof(value.AtType)))
61-
{
62-
writer.WritePropertyName("@type");
63-
writer.WriteStringValue(value.AtType);
64-
return true;
65-
}
66-
else if (propertyName.Equals(nameof(value.AtContext)))
67-
{
68-
writer.WritePropertyName("@context");
69-
writer.WriteStringValue(value.AtContext);
70-
return true;
71-
}
72-
else if (propertyName.Equals(nameof(value.AtId)))
73-
{
74-
writer.WritePropertyName("@id");
75-
writer.WriteStringValue(value.AtId);
76-
return true;
77-
}
7845

7946
return false;
8047
}

src/libraries/Core/Microsoft.Agents.Core/Serialization/Converters/ConnectorConverter.cs

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@
77
using System;
88
using System.Reflection;
99
using System.Collections;
10+
using System.Collections.Concurrent;
11+
using System.Linq;
1012

1113
namespace Microsoft.Agents.Core.Serialization.Converters
1214
{
1315
public abstract class ConnectorConverter<T> : JsonConverter<T> where T : new()
1416
{
17+
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> PropertyCache = new();
18+
private static readonly ConcurrentDictionary<(Type, bool, Type), Dictionary<string, (PropertyInfo, bool)>> JsonPropertyMetadataCache = new();
19+
1520
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
1621
{
1722
if (reader.TokenType != JsonTokenType.StartObject)
@@ -21,14 +26,7 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial
2126

2227
var value = new T();
2328

24-
var properties = options.PropertyNameCaseInsensitive
25-
? new Dictionary<string, PropertyInfo>(StringComparer.OrdinalIgnoreCase)
26-
: new Dictionary<string, PropertyInfo>();
27-
28-
foreach (var property in typeof(T).GetProperties())
29-
{
30-
properties.Add(property.Name, property);
31-
}
29+
var propertyMetadataMap = GetJsonPropertyMetadata(typeof(T), options.PropertyNameCaseInsensitive, options.PropertyNamingPolicy);
3230

3331
while (reader.Read())
3432
{
@@ -41,9 +39,9 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial
4139
{
4240
var propertyName = reader.GetString();
4341

44-
if (properties.ContainsKey(propertyName))
42+
if (propertyMetadataMap.TryGetValue(propertyName, out var entry))
4543
{
46-
ReadProperty(ref reader, value, propertyName, options, properties);
44+
ReadProperty(ref reader, value, propertyName, options, entry.Property);
4745
}
4846
else
4947
{
@@ -59,8 +57,18 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions
5957
{
6058
writer.WriteStartObject();
6159

62-
foreach (var property in value.GetType().GetProperties())
60+
var type = value.GetType();
61+
var properties = GetCachedProperties(type);
62+
var propertyMetadataMap = GetJsonPropertyMetadata(type, false, options.PropertyNamingPolicy); // case-insensitivity doesn’t matter here
63+
var reverseMap = propertyMetadataMap.ToDictionary(kv => kv.Value.Property, kv => (kv.Key, kv.Value.IsIgnored));
64+
65+
foreach (var property in properties)
6366
{
67+
if (!reverseMap.TryGetValue(property, out var propertyMetadata) || propertyMetadata.IsIgnored)
68+
{
69+
continue;
70+
}
71+
6472
if (!TryWriteExtensionData(writer, value, property.Name))
6573
{
6674
var propertyValue = property.GetValue(value);
@@ -77,9 +85,7 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions
7785
if (propertyValue != null || !(options.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull))
7886

7987
{
80-
var propertyName = options.PropertyNamingPolicy == JsonNamingPolicy.CamelCase
81-
? JsonNamingPolicy.CamelCase.ConvertName(property.Name)
82-
: property.Name;
88+
var propertyName = propertyMetadata.Key ?? property.Name;
8389

8490
writer.WritePropertyName(propertyName);
8591

@@ -254,10 +260,8 @@ protected void SetGenericProperty(ref Utf8JsonReader reader, Action<object> sett
254260
setter(deserialized);
255261
}
256262

257-
private void ReadProperty(ref Utf8JsonReader reader, T value, string propertyName, JsonSerializerOptions options, Dictionary<string, PropertyInfo> properties)
263+
private void ReadProperty(ref Utf8JsonReader reader, T value, string propertyName, JsonSerializerOptions options, PropertyInfo property)
258264
{
259-
var property = properties[propertyName];
260-
261265
if (TryReadExtensionData(ref reader, value, property.Name, options))
262266
{
263267
return;
@@ -292,5 +296,39 @@ private void ReadProperty(ref Utf8JsonReader reader, T value, string propertyNam
292296
var propertyValue = System.Text.Json.JsonSerializer.Deserialize(ref reader, property.PropertyType, options);
293297
property.SetValue(value, propertyValue);
294298
}
299+
300+
private static PropertyInfo[] GetCachedProperties(Type type)
301+
{
302+
return PropertyCache.GetOrAdd(type, static t => t.GetProperties());
303+
}
304+
305+
private static Dictionary<string, (PropertyInfo Property, bool IsIgnored)> GetJsonPropertyMetadata(Type type, bool caseInsensitive, JsonNamingPolicy? namingPolicy)
306+
{
307+
var cacheKey = (type, caseInsensitive, namingPolicy?.GetType());
308+
return JsonPropertyMetadataCache.GetOrAdd(cacheKey, key =>
309+
{
310+
var (t, insensitive, _) = key;
311+
var comparer = insensitive ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
312+
var metadata = new Dictionary<string, (PropertyInfo, bool)>(comparer);
313+
314+
foreach (var prop in GetCachedProperties(t))
315+
{
316+
var resolvedName = prop.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
317+
?? namingPolicy?.ConvertName(prop.Name)
318+
?? prop.Name;
319+
320+
if (metadata.ContainsKey(resolvedName))
321+
{
322+
throw new InvalidOperationException(
323+
$"Duplicate JSON property name detected: '{resolvedName}' maps to multiple properties in type '{t.FullName}'."
324+
);
325+
}
326+
327+
metadata [resolvedName] = (prop, prop.GetCustomAttribute<JsonIgnoreAttribute>()?.Condition == JsonIgnoreCondition.Always);
328+
}
329+
330+
return metadata;
331+
});
332+
}
295333
}
296334
}

src/tests/Microsoft.Agents.Model.Tests/ObjectSerializationTests.cs

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
using Microsoft.Agents.Core;
54
using Microsoft.Agents.Core.Models;
65
using Microsoft.Agents.Core.Serialization;
76
using System;
87
using System.Collections.Generic;
98
using System.IO;
109
using System.Text.Json;
10+
using System.Text.Json.Serialization;
11+
using System.Threading.Tasks;
1112
using Xunit;
1213

1314
namespace Microsoft.Agents.Model.Tests
@@ -362,6 +363,73 @@ public void ValidateActivitySerializer(string baseFileName)
362363
Assert.Equal(resultingText, outboundJson);
363364
}
364365

366+
[Fact]
367+
public void ActivityWithDerivedEntitySerializationTest()
368+
{
369+
var jsonIn = "{\"membersAdded\":[],\"membersRemoved\":[],\"reactionsAdded\":[],\"reactionsRemoved\":[],\"attachments\":[],\"entities\":[{\"@type\":\"Message\",\"@context\":\"https://schema.org\",\"@id\":\"\",\"additionalType\":[\"AIGeneratedContent\"],\"citation\":[],\"type\":\"https://schema.org/Message\"}],\"listenFor\":[],\"textHighlights\":[]}";
370+
371+
var activity = ProtocolJsonSerializer.ToObject<Activity>(jsonIn);
372+
var jsonOut = ProtocolJsonSerializer.ToJson(activity);
373+
374+
Assert.Equal(jsonIn, jsonOut);
375+
}
376+
377+
378+
379+
[Fact]
380+
public void WithDerivedActivitySerializationTest()
381+
{
382+
List<Activity> activities = [new DerivedActivity
383+
{
384+
Secret = "secret",
385+
Public = "public"
386+
}];
387+
var jsonOut = ProtocolJsonSerializer.ToJson(activities);
388+
var expected = "[{\"@public\":\"public\",\"membersAdded\":[],\"membersRemoved\":[],\"reactionsAdded\":[],\"reactionsRemoved\":[],\"attachments\":[],\"entities\":[],\"listenFor\":[],\"textHighlights\":[]}]";
389+
390+
Assert.Equal(expected, jsonOut);
391+
}
392+
393+
[Fact]
394+
public void SerializeDeserializeIsThreadSafeUnderConcurrency()
395+
{
396+
var text = File.ReadAllText(Path.Combine(Directory.GetCurrentDirectory(), "Resources", "ComplexActivityPayload.json"));
397+
398+
var activity = ProtocolJsonSerializer.ToObject<Activity>(text);
399+
400+
var json = ProtocolJsonSerializer.ToJson(activity);
401+
402+
const int threadCount = 50;
403+
var results = new Activity[threadCount];
404+
405+
Parallel.For(0, threadCount, i =>
406+
{
407+
results[i] = RoundTrip(activity);
408+
});
409+
410+
foreach (var result in results)
411+
{
412+
Assert.NotNull(result);
413+
AssertPropertyValues(result);
414+
}
415+
}
416+
417+
[Fact]
418+
public void DuplicateNameActivitySerializationShouldThrow()
419+
{
420+
// This should throw an exception because the property name 'id' is duplicated.
421+
var activityJson = new DuplicateNameActivity
422+
{
423+
Id = "12345",
424+
MyId = "67890"
425+
};
426+
// Expect an InvalidOperationException from System.Text.Json (Note: The ConnectorConverter is not used here because the compile-time type is 'object').
427+
Assert.Throws<InvalidOperationException>(() => ProtocolJsonSerializer.ToJson(activityJson));
428+
// Expect an InvalidOperationException from ConnectorConverter.
429+
Assert.Throws<InvalidOperationException>(() => JsonSerializer.Serialize<Activity>(activityJson, ProtocolJsonSerializer.SerializationOptions));
430+
}
431+
432+
365433
#if SKIP_EMPTY_LISTS
366434
[Fact]
367435
public void EmptyListDoesntSerialzie()
@@ -450,5 +518,21 @@ private class TestObjectClass
450518

451519
public TestObjectClass TestObject { get; set; }
452520
}
521+
522+
private class DerivedActivity : Activity
523+
{
524+
[JsonIgnore]
525+
public string Secret { get; set; }
526+
527+
[JsonPropertyName("@public")]
528+
public string Public { get; set; }
529+
}
530+
531+
private class DuplicateNameActivity : Activity
532+
{
533+
534+
[JsonPropertyName("id")]
535+
public string MyId { get; set; }
536+
}
453537
}
454-
}
538+
}

0 commit comments

Comments
 (0)