Skip to content

Commit af8e210

Browse files
authored
Merge pull request #3218 from FirelyTeam/copilot/fix-3214
Fix thread-safety issue with SerializationFilter when reusing JsonSerializerOptions
2 parents 507e511 + fa1e66e commit af8e210

File tree

7 files changed

+269
-39
lines changed

7 files changed

+269
-39
lines changed

src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,19 @@ public string SerializeToString(IReadOnlyDictionary<string, object> members)
8686
private void serializeInternal(
8787
IReadOnlyDictionary<string, object> members,
8888
Utf8JsonWriter writer,
89-
bool skipValue)
89+
bool skipValue,
90+
SerializationFilter? filter = null)
9091
{
9192
writer.WriteStartObject();
92-
var filter = Settings.SummaryFilter;
93+
94+
// Get filter only once at the top level, then pass it through recursive calls
95+
if (filter == null)
96+
{
97+
// Use factory if available, otherwise fall back to the static instance for backward compatibility
98+
#pragma warning disable CS0618 // Type or member is obsolete
99+
filter = Settings.SummaryFilterFactory?.Invoke() ?? Settings.SummaryFilter;
100+
#pragma warning restore CS0618 // Type or member is obsolete
101+
}
93102

94103
if (members is Resource r)
95104
writer.WriteString("resourceType", r.TypeName);
@@ -132,12 +141,12 @@ private void serializeInternal(
132141
writer.WriteStartArray();
133142

134143
foreach (var value in coll)
135-
serializeMemberValue(value, writer, requiredType);
144+
serializeMemberValue(value, writer, filter, requiredType);
136145

137146
writer.WriteEndArray();
138147
}
139148
else
140-
serializeMemberValue(member.Value, writer, requiredType);
149+
serializeMemberValue(member.Value, writer, filter, requiredType);
141150
}
142151

143152
filter?.LeaveMember(member.Key, member.Value, propertyMapping);
@@ -159,10 +168,10 @@ private static string addSuffixToElementName(string elementName, object elementV
159168
return typeName is null ? elementName : elementName + char.ToUpperInvariant(typeName[0]) + typeName.Substring(1);
160169
}
161170

162-
private void serializeMemberValue(object value, Utf8JsonWriter writer, Type? requiredType = null)
171+
private void serializeMemberValue(object value, Utf8JsonWriter writer, SerializationFilter? filter, Type? requiredType = null)
163172
{
164173
if (value is IReadOnlyDictionary<string, object> complex)
165-
serializeInternal(complex, writer, skipValue: false);
174+
serializeInternal(complex, writer, skipValue: false, filter: filter);
166175
else
167176
SerializePrimitiveValue(value, writer, requiredType);
168177
}

src/Hl7.Fhir.Base/Serialization/BaseFhirXmlPocoSerializer.cs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,12 @@ public BaseFhirXmlPocoSerializer(FhirRelease release)
4242
}
4343

4444
/// <summary>
45-
/// Serializes the given dictionary with FHIR data into Json.
45+
/// Serializes the given dictionary with FHIR data into XML.
4646
/// </summary>
47-
public void Serialize(IReadOnlyDictionary<string, object> members, XmlWriter writer, SerializationFilter? summary = default)
47+
/// <param name="members">The dictionary containing FHIR data to serialize.</param>
48+
/// <param name="writer">The XmlWriter to write the serialized data to.</param>
49+
/// <param name="summaryFilterFactory">A factory function that creates a new filter instance for each serialization operation. This ensures thread-safety when reusing serializer instances in concurrent scenarios.</param>
50+
public void Serialize(IReadOnlyDictionary<string, object> members, XmlWriter writer, Func<SerializationFilter>? summaryFilterFactory)
4851
{
4952
writer.WriteStartDocument();
5053

@@ -57,17 +60,41 @@ public void Serialize(IReadOnlyDictionary<string, object> members, XmlWriter wri
5760
writer.WriteStartElement(rootElementName, XmlNs.FHIR);
5861
}
5962

60-
serializeInternal(members, writer, summary);
63+
var filter = summaryFilterFactory?.Invoke();
64+
serializeInternal(members, writer, filter);
6165

6266
if (simulateRoot) writer.WriteEndElement();
6367
writer.WriteEndDocument();
6468
}
6569

6670
/// <summary>
67-
/// Serializes the given dictionary with FHIR data into UTF8 encoded Json.
71+
/// Serializes the given dictionary with FHIR data into XML.
72+
/// </summary>
73+
/// <param name="members">The dictionary containing FHIR data to serialize.</param>
74+
/// <param name="writer">The XmlWriter to write the serialized data to.</param>
75+
/// <param name="summary">The serialization filter to apply. NOTE: For thread-safety when reusing serializer instances, pass a fresh filter instance for each serialization operation.</param>
76+
[Obsolete("Use the overload that takes Func<SerializationFilter> summaryFilterFactory instead to ensure thread-safety when reusing serializer instances in concurrent scenarios. This method will be removed in a future version.")]
77+
public void Serialize(IReadOnlyDictionary<string, object> members, XmlWriter writer, SerializationFilter? summary = default)
78+
{
79+
Serialize(members, writer, summary != null ? () => summary : (Func<SerializationFilter>?)null);
80+
}
81+
82+
/// <summary>
83+
/// Serializes the given dictionary with FHIR data into UTF8 encoded XML.
84+
/// </summary>
85+
/// <param name="members">The dictionary containing FHIR data to serialize.</param>
86+
/// <param name="summaryFilterFactory">A factory function that creates a new filter instance for each serialization operation. This ensures thread-safety when reusing serializer instances in concurrent scenarios.</param>
87+
public string SerializeToString(IReadOnlyDictionary<string, object> members, Func<SerializationFilter>? summaryFilterFactory) =>
88+
SerializationUtil.WriteXmlToString(w => Serialize(members, w, summaryFilterFactory));
89+
90+
/// <summary>
91+
/// Serializes the given dictionary with FHIR data into UTF8 encoded XML.
6892
/// </summary>
93+
/// <param name="members">The dictionary containing FHIR data to serialize.</param>
94+
/// <param name="summary">The serialization filter to apply. NOTE: For thread-safety when reusing serializer instances, pass a fresh filter instance for each serialization operation.</param>
95+
[Obsolete("Use the overload that takes Func<SerializationFilter> summaryFilterFactory instead to ensure thread-safety when reusing serializer instances in concurrent scenarios. This method will be removed in a future version.")]
6996
public string SerializeToString(IReadOnlyDictionary<string, object> members, SerializationFilter? summary = default) =>
70-
SerializationUtil.WriteXmlToString(w => Serialize(members, w, summary));
97+
SerializeToString(members, summary != null ? () => summary : (Func<SerializationFilter>?)null);
7198

7299
/// <summary>
73100
/// Serializes the given dictionary with FHIR data into Json, optionally skipping the "value" element.

src/Hl7.Fhir.Base/Serialization/FhirJsonPocoSerializerSettings.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,14 @@ public record FhirJsonPocoSerializerSettings
2727
/// <summary>
2828
/// Specifies the filter to use for summary serialization.
2929
/// </summary>
30+
[Obsolete("Use SummaryFilterFactory instead to ensure thread-safety when reusing JsonSerializerOptions instances. This property will be removed in a future version.")]
3031
public SerializationFilter? SummaryFilter { get; set; } = default;
32+
33+
/// <summary>
34+
/// Specifies a factory function that creates a new filter instance for each serialization operation.
35+
/// This ensures thread-safety when reusing JsonSerializerOptions instances in concurrent scenarios.
36+
/// </summary>
37+
public Func<SerializationFilter>? SummaryFilterFactory { get; set; } = default;
3138
}
3239
}
3340

src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs

Lines changed: 77 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#nullable enable
1010

1111
using Hl7.Fhir.Introspection;
12+
using System;
1213

1314
namespace Hl7.Fhir.Serialization
1415
{
@@ -41,44 +42,94 @@ public abstract class SerializationFilter
4142
/// <summary>
4243
/// Construct a new filter that conforms to the `_summary=true` summarized form.
4344
/// </summary>
44-
public static SerializationFilter ForSummary() => new BundleFilter(new ElementMetadataFilter() { IncludeInSummary = true });
45+
[Obsolete("Use CreateSummaryFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")]
46+
public static SerializationFilter ForSummary() => CreateSummaryFactory()();
4547

4648
/// <summary>
4749
/// Construct a new filter that conforms to the `_summary=text` summarized form.
4850
/// </summary>
49-
public static SerializationFilter ForText() => new BundleFilter(new TopLevelFilter(
50-
new ElementMetadataFilter()
51-
{
52-
IncludeNames = new[] { "text", "id", "meta" },
53-
IncludeMandatory = true
54-
}));
55-
56-
public static SerializationFilter ForCount() => new BundleFilter(new TopLevelFilter(
57-
new ElementMetadataFilter()
58-
{
59-
IncludeMandatory = true,
60-
IncludeNames = new[] { "id", "total", "link" }
61-
}));
51+
[Obsolete("Use CreateTextFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")]
52+
public static SerializationFilter ForText() => CreateTextFactory()();
53+
54+
[Obsolete("Use CreateCountFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")]
55+
public static SerializationFilter ForCount() => CreateCountFactory()();
6256

6357
/// <summary>
6458
/// Construct a new filter that conforms to the `_summary=data` summarized form.
6559
/// </summary>
66-
public static SerializationFilter ForData() => new BundleFilter(new TopLevelFilter(
67-
new ElementMetadataFilter()
68-
{
69-
IncludeNames = new[] { "text" },
70-
Invert = true
71-
}));
60+
[Obsolete("Use CreateDataFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")]
61+
public static SerializationFilter ForData() => CreateDataFactory()();
7262

7363
/// <summary>
7464
/// Construct a new filter that conforms to the `_elements=...` summarized form.
7565
/// </summary>
76-
public static SerializationFilter ForElements(string[] elements) => new BundleFilter(new TopLevelFilter(
77-
new ElementMetadataFilter()
78-
{
79-
IncludeNames = elements,
80-
IncludeMandatory = true
81-
}));
66+
[Obsolete("Use CreateElementsFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")]
67+
public static SerializationFilter ForElements(string[] elements) => CreateElementsFactory(elements)();
68+
69+
/// <summary>
70+
/// Create a factory function that produces new filter instances conforming to the `_summary=true` summarized form.
71+
/// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances.
72+
/// </summary>
73+
public static Func<SerializationFilter> CreateSummaryFactory()
74+
{
75+
return () => new BundleFilter(new ElementMetadataFilter() { IncludeInSummary = true });
76+
}
77+
78+
/// <summary>
79+
/// Create a factory function that produces new filter instances conforming to the `_summary=text` summarized form.
80+
/// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances.
81+
/// </summary>
82+
public static Func<SerializationFilter> CreateTextFactory()
83+
{
84+
return () => new BundleFilter(new TopLevelFilter(
85+
new ElementMetadataFilter()
86+
{
87+
IncludeNames = new[] { "text", "id", "meta" },
88+
IncludeMandatory = true
89+
}));
90+
}
91+
92+
/// <summary>
93+
/// Create a factory function that produces new filter instances conforming to the `_summary=count` summarized form.
94+
/// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances.
95+
/// </summary>
96+
public static Func<SerializationFilter> CreateCountFactory()
97+
{
98+
return () => new BundleFilter(new TopLevelFilter(
99+
new ElementMetadataFilter()
100+
{
101+
IncludeMandatory = true,
102+
IncludeNames = new[] { "id", "total", "link" }
103+
}));
104+
}
105+
106+
/// <summary>
107+
/// Create a factory function that produces new filter instances conforming to the `_summary=data` summarized form.
108+
/// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances.
109+
/// </summary>
110+
public static Func<SerializationFilter> CreateDataFactory()
111+
{
112+
return () => new BundleFilter(new TopLevelFilter(
113+
new ElementMetadataFilter()
114+
{
115+
IncludeNames = new[] { "text" },
116+
Invert = true
117+
}));
118+
}
119+
120+
/// <summary>
121+
/// Create a factory function that produces new filter instances conforming to the `_elements=...` summarized form.
122+
/// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances.
123+
/// </summary>
124+
public static Func<SerializationFilter> CreateElementsFactory(string[] elements)
125+
{
126+
return () => new BundleFilter(new TopLevelFilter(
127+
new ElementMetadataFilter()
128+
{
129+
IncludeNames = elements,
130+
IncludeMandatory = true
131+
}));
132+
}
82133
}
83134
}
84135

src/Hl7.Fhir.Base/Serialization/engine/PocoSerializationEngine_Xml.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public Resource DeserializeFromXml(string data)
3434
}
3535

3636
/// <inheritdoc />
37-
public string SerializeToXml(Resource instance) => getXmlSerializer().SerializeToString(instance);
37+
public string SerializeToXml(Resource instance) => getXmlSerializer().SerializeToString(instance, (Func<SerializationFilter>?)null);
3838

3939
/// <summary>
4040
/// Deserializes a resource from an XML reader
@@ -70,5 +70,5 @@ public Base DeserializeElementFromXml(Type targetType, XmlReader reader)
7070
/// </summary>
7171
/// <param name="instance">An instance of Base or any of its children</param>
7272
/// <param name="writer">The XML writer</param>
73-
public void SerializeToXmlWriter(Base instance, XmlWriter writer) => getXmlSerializer().Serialize(instance, writer);
73+
public void SerializeToXmlWriter(Base instance, XmlWriter writer) => getXmlSerializer().Serialize(instance, writer, (Func<SerializationFilter>?)null);
7474
}

src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/FhirXmlSerializationTests.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,56 @@ public void SerializesInvalidData()
5252
contactArray.Count().Should().Be(1);
5353
contactArray.First().Elements().Should().BeEmpty();
5454
}
55+
56+
[TestMethod]
57+
public void CanUseFilterFactory()
58+
{
59+
var patient = new Patient
60+
{
61+
Id = "test-patient",
62+
Active = true,
63+
Name = new() { new HumanName { Given = new[] { "John" }, Family = "Doe" } },
64+
Gender = AdministrativeGender.Male
65+
};
66+
67+
var serializer = new BaseFhirXmlPocoSerializer(Specification.FhirRelease.STU3);
68+
69+
// Test the new factory-based method
70+
var elementsFactory = SerializationFilter.CreateElementsFactory(new[] { "id", "active" });
71+
var xmlWithFactory = serializer.SerializeToString(patient, elementsFactory);
72+
73+
// Test the obsolete method for comparison
74+
#pragma warning disable CS0618 // Type or member is obsolete
75+
var filter = SerializationFilter.ForElements(new[] { "id", "active" });
76+
var xmlWithFilter = serializer.SerializeToString(patient, filter);
77+
#pragma warning restore CS0618 // Type or member is obsolete
78+
79+
// Both methods should produce identical output
80+
xmlWithFactory.Should().Be(xmlWithFilter);
81+
82+
// Verify that filtering actually works (should only contain id and active)
83+
var xdoc = XDocument.Parse(xmlWithFactory);
84+
var patientElement = xdoc.Root;
85+
86+
// Should contain id and active elements
87+
patientElement.Elements(XName.Get("id", XmlNs.FHIR)).Should().HaveCount(1);
88+
patientElement.Elements(XName.Get("active", XmlNs.FHIR)).Should().HaveCount(1);
89+
90+
// Should NOT contain name or gender (they were filtered out)
91+
patientElement.Elements(XName.Get("name", XmlNs.FHIR)).Should().BeEmpty();
92+
patientElement.Elements(XName.Get("gender", XmlNs.FHIR)).Should().BeEmpty();
93+
}
94+
95+
[TestMethod]
96+
public void FilterFactoryCreatesNewInstancesEachTime()
97+
{
98+
var elementsFactory = SerializationFilter.CreateElementsFactory(new[] { "id", "active" });
99+
100+
// Each call should return a new instance
101+
var filter1 = elementsFactory();
102+
var filter2 = elementsFactory();
103+
104+
filter1.Should().NotBeSameAs(filter2);
105+
}
55106
}
56107
}

0 commit comments

Comments
 (0)