diff --git a/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs b/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs index 0d978d33d9..fd70240810 100644 --- a/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs +++ b/src/Hl7.Fhir.Base/Serialization/BaseFhirJsonPocoSerializer.cs @@ -86,10 +86,19 @@ public string SerializeToString(IReadOnlyDictionary members) private void serializeInternal( IReadOnlyDictionary members, Utf8JsonWriter writer, - bool skipValue) + bool skipValue, + SerializationFilter? filter = null) { writer.WriteStartObject(); - var filter = Settings.SummaryFilter; + + // Get filter only once at the top level, then pass it through recursive calls + if (filter == null) + { + // Use factory if available, otherwise fall back to the static instance for backward compatibility +#pragma warning disable CS0618 // Type or member is obsolete + filter = Settings.SummaryFilterFactory?.Invoke() ?? Settings.SummaryFilter; +#pragma warning restore CS0618 // Type or member is obsolete + } if (members is Resource r) writer.WriteString("resourceType", r.TypeName); @@ -132,12 +141,12 @@ private void serializeInternal( writer.WriteStartArray(); foreach (var value in coll) - serializeMemberValue(value, writer, requiredType); + serializeMemberValue(value, writer, filter, requiredType); writer.WriteEndArray(); } else - serializeMemberValue(member.Value, writer, requiredType); + serializeMemberValue(member.Value, writer, filter, requiredType); } filter?.LeaveMember(member.Key, member.Value, propertyMapping); @@ -159,10 +168,10 @@ private static string addSuffixToElementName(string elementName, object elementV return typeName is null ? elementName : elementName + char.ToUpperInvariant(typeName[0]) + typeName.Substring(1); } - private void serializeMemberValue(object value, Utf8JsonWriter writer, Type? requiredType = null) + private void serializeMemberValue(object value, Utf8JsonWriter writer, SerializationFilter? filter, Type? requiredType = null) { if (value is IReadOnlyDictionary complex) - serializeInternal(complex, writer, skipValue: false); + serializeInternal(complex, writer, skipValue: false, filter: filter); else SerializePrimitiveValue(value, writer, requiredType); } diff --git a/src/Hl7.Fhir.Base/Serialization/BaseFhirXmlPocoSerializer.cs b/src/Hl7.Fhir.Base/Serialization/BaseFhirXmlPocoSerializer.cs index dc0bca302d..859c2ce44b 100644 --- a/src/Hl7.Fhir.Base/Serialization/BaseFhirXmlPocoSerializer.cs +++ b/src/Hl7.Fhir.Base/Serialization/BaseFhirXmlPocoSerializer.cs @@ -42,9 +42,12 @@ public BaseFhirXmlPocoSerializer(FhirRelease release) } /// - /// Serializes the given dictionary with FHIR data into Json. + /// Serializes the given dictionary with FHIR data into XML. /// - public void Serialize(IReadOnlyDictionary members, XmlWriter writer, SerializationFilter? summary = default) + /// The dictionary containing FHIR data to serialize. + /// The XmlWriter to write the serialized data to. + /// A factory function that creates a new filter instance for each serialization operation. This ensures thread-safety when reusing serializer instances in concurrent scenarios. + public void Serialize(IReadOnlyDictionary members, XmlWriter writer, Func? summaryFilterFactory) { writer.WriteStartDocument(); @@ -57,17 +60,41 @@ public void Serialize(IReadOnlyDictionary members, XmlWriter wri writer.WriteStartElement(rootElementName, XmlNs.FHIR); } - serializeInternal(members, writer, summary); + var filter = summaryFilterFactory?.Invoke(); + serializeInternal(members, writer, filter); if (simulateRoot) writer.WriteEndElement(); writer.WriteEndDocument(); } /// - /// Serializes the given dictionary with FHIR data into UTF8 encoded Json. + /// Serializes the given dictionary with FHIR data into XML. + /// + /// The dictionary containing FHIR data to serialize. + /// The XmlWriter to write the serialized data to. + /// The serialization filter to apply. NOTE: For thread-safety when reusing serializer instances, pass a fresh filter instance for each serialization operation. + [Obsolete("Use the overload that takes Func summaryFilterFactory instead to ensure thread-safety when reusing serializer instances in concurrent scenarios. This method will be removed in a future version.")] + public void Serialize(IReadOnlyDictionary members, XmlWriter writer, SerializationFilter? summary = default) + { + Serialize(members, writer, summary != null ? () => summary : (Func?)null); + } + + /// + /// Serializes the given dictionary with FHIR data into UTF8 encoded XML. + /// + /// The dictionary containing FHIR data to serialize. + /// A factory function that creates a new filter instance for each serialization operation. This ensures thread-safety when reusing serializer instances in concurrent scenarios. + public string SerializeToString(IReadOnlyDictionary members, Func? summaryFilterFactory) => + SerializationUtil.WriteXmlToString(w => Serialize(members, w, summaryFilterFactory)); + + /// + /// Serializes the given dictionary with FHIR data into UTF8 encoded XML. /// + /// The dictionary containing FHIR data to serialize. + /// The serialization filter to apply. NOTE: For thread-safety when reusing serializer instances, pass a fresh filter instance for each serialization operation. + [Obsolete("Use the overload that takes Func summaryFilterFactory instead to ensure thread-safety when reusing serializer instances in concurrent scenarios. This method will be removed in a future version.")] public string SerializeToString(IReadOnlyDictionary members, SerializationFilter? summary = default) => - SerializationUtil.WriteXmlToString(w => Serialize(members, w, summary)); + SerializeToString(members, summary != null ? () => summary : (Func?)null); /// /// Serializes the given dictionary with FHIR data into Json, optionally skipping the "value" element. diff --git a/src/Hl7.Fhir.Base/Serialization/FhirJsonPocoSerializerSettings.cs b/src/Hl7.Fhir.Base/Serialization/FhirJsonPocoSerializerSettings.cs index 5987f242c7..01e9d32d47 100644 --- a/src/Hl7.Fhir.Base/Serialization/FhirJsonPocoSerializerSettings.cs +++ b/src/Hl7.Fhir.Base/Serialization/FhirJsonPocoSerializerSettings.cs @@ -27,7 +27,14 @@ public record FhirJsonPocoSerializerSettings /// /// Specifies the filter to use for summary serialization. /// + [Obsolete("Use SummaryFilterFactory instead to ensure thread-safety when reusing JsonSerializerOptions instances. This property will be removed in a future version.")] public SerializationFilter? SummaryFilter { get; set; } = default; + + /// + /// Specifies a factory function that creates a new filter instance for each serialization operation. + /// This ensures thread-safety when reusing JsonSerializerOptions instances in concurrent scenarios. + /// + public Func? SummaryFilterFactory { get; set; } = default; } } diff --git a/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs b/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs index 5873a2787d..070f9bd283 100644 --- a/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs +++ b/src/Hl7.Fhir.Base/Serialization/SerializationFilter.cs @@ -9,6 +9,7 @@ #nullable enable using Hl7.Fhir.Introspection; +using System; namespace Hl7.Fhir.Serialization { @@ -41,44 +42,94 @@ public abstract class SerializationFilter /// /// Construct a new filter that conforms to the `_summary=true` summarized form. /// - public static SerializationFilter ForSummary() => new BundleFilter(new ElementMetadataFilter() { IncludeInSummary = true }); + [Obsolete("Use CreateSummaryFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] + public static SerializationFilter ForSummary() => CreateSummaryFactory()(); /// /// Construct a new filter that conforms to the `_summary=text` summarized form. /// - public static SerializationFilter ForText() => new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeNames = new[] { "text", "id", "meta" }, - IncludeMandatory = true - })); - - public static SerializationFilter ForCount() => new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeMandatory = true, - IncludeNames = new[] { "id", "total", "link" } - })); + [Obsolete("Use CreateTextFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] + public static SerializationFilter ForText() => CreateTextFactory()(); + + [Obsolete("Use CreateCountFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] + public static SerializationFilter ForCount() => CreateCountFactory()(); /// /// Construct a new filter that conforms to the `_summary=data` summarized form. /// - public static SerializationFilter ForData() => new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeNames = new[] { "text" }, - Invert = true - })); + [Obsolete("Use CreateDataFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] + public static SerializationFilter ForData() => CreateDataFactory()(); /// /// Construct a new filter that conforms to the `_elements=...` summarized form. /// - public static SerializationFilter ForElements(string[] elements) => new BundleFilter(new TopLevelFilter( - new ElementMetadataFilter() - { - IncludeNames = elements, - IncludeMandatory = true - })); + [Obsolete("Use CreateElementsFactory() instead to ensure thread-safety when reusing JsonSerializerOptions instances. This method will be removed in a future version.")] + public static SerializationFilter ForElements(string[] elements) => CreateElementsFactory(elements)(); + + /// + /// Create a factory function that produces new filter instances conforming to the `_summary=true` summarized form. + /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. + /// + public static Func CreateSummaryFactory() + { + return () => new BundleFilter(new ElementMetadataFilter() { IncludeInSummary = true }); + } + + /// + /// Create a factory function that produces new filter instances conforming to the `_summary=text` summarized form. + /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. + /// + public static Func CreateTextFactory() + { + return () => new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeNames = new[] { "text", "id", "meta" }, + IncludeMandatory = true + })); + } + + /// + /// Create a factory function that produces new filter instances conforming to the `_summary=count` summarized form. + /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. + /// + public static Func CreateCountFactory() + { + return () => new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeMandatory = true, + IncludeNames = new[] { "id", "total", "link" } + })); + } + + /// + /// Create a factory function that produces new filter instances conforming to the `_summary=data` summarized form. + /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. + /// + public static Func CreateDataFactory() + { + return () => new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeNames = new[] { "text" }, + Invert = true + })); + } + + /// + /// Create a factory function that produces new filter instances conforming to the `_elements=...` summarized form. + /// Using this factory ensures thread-safety when reusing JsonSerializerOptions instances. + /// + public static Func CreateElementsFactory(string[] elements) + { + return () => new BundleFilter(new TopLevelFilter( + new ElementMetadataFilter() + { + IncludeNames = elements, + IncludeMandatory = true + })); + } } } diff --git a/src/Hl7.Fhir.Base/Serialization/engine/PocoSerializationEngine_Xml.cs b/src/Hl7.Fhir.Base/Serialization/engine/PocoSerializationEngine_Xml.cs index 9adcf86812..018c647429 100644 --- a/src/Hl7.Fhir.Base/Serialization/engine/PocoSerializationEngine_Xml.cs +++ b/src/Hl7.Fhir.Base/Serialization/engine/PocoSerializationEngine_Xml.cs @@ -34,7 +34,7 @@ public Resource DeserializeFromXml(string data) } /// - public string SerializeToXml(Resource instance) => getXmlSerializer().SerializeToString(instance); + public string SerializeToXml(Resource instance) => getXmlSerializer().SerializeToString(instance, (Func?)null); /// /// Deserializes a resource from an XML reader @@ -70,5 +70,5 @@ public Base DeserializeElementFromXml(Type targetType, XmlReader reader) /// /// An instance of Base or any of its children /// The XML writer - public void SerializeToXmlWriter(Base instance, XmlWriter writer) => getXmlSerializer().Serialize(instance, writer); + public void SerializeToXmlWriter(Base instance, XmlWriter writer) => getXmlSerializer().Serialize(instance, writer, (Func?)null); } \ No newline at end of file diff --git a/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/FhirXmlSerializationTests.cs b/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/FhirXmlSerializationTests.cs index 4841fda564..7306f0d7a6 100644 --- a/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/FhirXmlSerializationTests.cs +++ b/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/FhirXmlSerializationTests.cs @@ -52,5 +52,56 @@ public void SerializesInvalidData() contactArray.Count().Should().Be(1); contactArray.First().Elements().Should().BeEmpty(); } + + [TestMethod] + public void CanUseFilterFactory() + { + var patient = new Patient + { + Id = "test-patient", + Active = true, + Name = new() { new HumanName { Given = new[] { "John" }, Family = "Doe" } }, + Gender = AdministrativeGender.Male + }; + + var serializer = new BaseFhirXmlPocoSerializer(Specification.FhirRelease.STU3); + + // Test the new factory-based method + var elementsFactory = SerializationFilter.CreateElementsFactory(new[] { "id", "active" }); + var xmlWithFactory = serializer.SerializeToString(patient, elementsFactory); + + // Test the obsolete method for comparison +#pragma warning disable CS0618 // Type or member is obsolete + var filter = SerializationFilter.ForElements(new[] { "id", "active" }); + var xmlWithFilter = serializer.SerializeToString(patient, filter); +#pragma warning restore CS0618 // Type or member is obsolete + + // Both methods should produce identical output + xmlWithFactory.Should().Be(xmlWithFilter); + + // Verify that filtering actually works (should only contain id and active) + var xdoc = XDocument.Parse(xmlWithFactory); + var patientElement = xdoc.Root; + + // Should contain id and active elements + patientElement.Elements(XName.Get("id", XmlNs.FHIR)).Should().HaveCount(1); + patientElement.Elements(XName.Get("active", XmlNs.FHIR)).Should().HaveCount(1); + + // Should NOT contain name or gender (they were filtered out) + patientElement.Elements(XName.Get("name", XmlNs.FHIR)).Should().BeEmpty(); + patientElement.Elements(XName.Get("gender", XmlNs.FHIR)).Should().BeEmpty(); + } + + [TestMethod] + public void FilterFactoryCreatesNewInstancesEachTime() + { + var elementsFactory = SerializationFilter.CreateElementsFactory(new[] { "id", "active" }); + + // Each call should return a new instance + var filter1 = elementsFactory(); + var filter2 = elementsFactory(); + + filter1.Should().NotBeSameAs(filter2); + } } } \ No newline at end of file diff --git a/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs b/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs new file mode 100644 index 0000000000..153b9f6625 --- /dev/null +++ b/src/Hl7.Fhir.Support.Poco.Tests/NewPocoSerializers/SummaryFilterThreadSafetyTests.cs @@ -0,0 +1,85 @@ +using FluentAssertions; +using Hl7.Fhir.Model; +using Hl7.Fhir.Serialization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Concurrent; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Hl7.Fhir.Support.Poco.Tests +{ + [TestClass] + public class SummaryFilterThreadSafetyTests + { + [TestMethod] + public void ConcurrentSerializationWithFactory_ShouldBeThreadSafe() + { + // Arrange + var options = new JsonSerializerOptions() + .ForFhir(typeof(Patient).Assembly, new FhirJsonPocoSerializerSettings + { + SummaryFilterFactory = SerializationFilter.CreateElementsFactory(["id", "active"]) + }) + .Pretty(); + + var patient = new Patient + { + Id = "123", + Active = true, + Name = [new() { Family = "Doe", Given = ["John"] }], + MultipleBirth = new FhirBoolean(false), + }; + var bundle = new Bundle + { + Type = Bundle.BundleType.Collection, + Entry = [new() { Resource = patient }] + }; + + ConcurrentBag serialized = []; + + // Act + Parallel.For(0, 100, i => + { + serialized.Add(JsonSerializer.Serialize(bundle, options)); + }); + + // Assert + serialized.Count.Should().Be(100); + + // All results should include the entry field + var resultsWithEntry = serialized.Where(json => json.Contains("\"entry\"")).Count(); + resultsWithEntry.Should().Be(100, "all results should contain the entry field"); + + // No results should contain unfiltered fields + var resultsWithUnfilteredFields = serialized.Where(json => + json.Contains("\"name\"") || json.Contains("\"multipleBirthBoolean\"")).Count(); + resultsWithUnfilteredFields.Should().Be(0, "no results should contain unfiltered fields"); + + // All results should contain the filtered fields + var resultsWithId = serialized.Where(json => json.Contains("\"id\": \"123\"")).Count(); + var resultsWithActive = serialized.Where(json => json.Contains("\"active\": true")).Count(); + resultsWithId.Should().Be(100, "all results should contain the id field"); + resultsWithActive.Should().Be(100, "all results should contain the active field"); + } + + [TestMethod] + public void AllFactoryMethods_ShouldCreateFreshInstancesPerCall() + { + // Verify that each factory method creates a new instance per call + // (this ensures no state is shared between serialization operations) + var summaryFactory = SerializationFilter.CreateSummaryFactory(); + var textFactory = SerializationFilter.CreateTextFactory(); + var countFactory = SerializationFilter.CreateCountFactory(); + var dataFactory = SerializationFilter.CreateDataFactory(); + var elementsFactory = SerializationFilter.CreateElementsFactory(["id", "name"]); + + // Each call should return a different instance + summaryFactory().Should().NotBeSameAs(summaryFactory()); + textFactory().Should().NotBeSameAs(textFactory()); + countFactory().Should().NotBeSameAs(countFactory()); + dataFactory().Should().NotBeSameAs(dataFactory()); + elementsFactory().Should().NotBeSameAs(elementsFactory()); + } + } +} \ No newline at end of file