From bf64d58b96dd80cb7b5528a12db3e66f465ede23 Mon Sep 17 00:00:00 2001 From: Sergey Date: Fri, 25 Sep 2026 18:36:05 +0200 Subject: [PATCH] Fix #2402: defer default serializer initialization --- src/RestSharp/ContentType.cs | 5 +- .../Json/SystemTextJsonSerializer.cs | 4 +- src/RestSharp/Serializers/SerializerConfig.cs | 16 ++- .../Serializers/Xml/XmlRestSerializer.cs | 4 +- .../SerializerAssemblyLoadingTests.cs | 125 ++++++++++++++++++ test/RestSharp.Tests/RestClientTests.cs | 17 ++- 6 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 test/RestSharp.Tests.Serializers.Json/SerializerAssemblyLoadingTests.cs diff --git a/src/RestSharp/ContentType.cs b/src/RestSharp/ContentType.cs index 8f8d70554..8523996e8 100644 --- a/src/RestSharp/ContentType.cs +++ b/src/RestSharp/ContentType.cs @@ -63,6 +63,9 @@ public class ContentType : IEquatable { public static readonly string[] XmlAccept = [Xml, "text/xml"]; + internal static readonly SupportsContentType SupportsJson = contentType => contentType.Value.EndsWith("json", StringComparison.InvariantCultureIgnoreCase); + internal static readonly SupportsContentType SupportsXml = contentType => contentType.Value.EndsWith("xml", StringComparison.InvariantCultureIgnoreCase); + readonly string _value; public bool Equals(ContentType? other) { @@ -81,4 +84,4 @@ public override bool Equals(object? obj) { } public override int GetHashCode() => _value.GetHashCode(); -} \ No newline at end of file +} diff --git a/src/RestSharp/Serializers/Json/SystemTextJsonSerializer.cs b/src/RestSharp/Serializers/Json/SystemTextJsonSerializer.cs index 959ffe9e5..c242bf41f 100644 --- a/src/RestSharp/Serializers/Json/SystemTextJsonSerializer.cs +++ b/src/RestSharp/Serializers/Json/SystemTextJsonSerializer.cs @@ -42,5 +42,5 @@ public class SystemTextJsonSerializer : IRestSerializer, ISerializer, IDeseriali public IDeserializer Deserializer => this; public DataFormat DataFormat => DataFormat.Json; public string[] AcceptedContentTypes => ContentType.JsonAccept; - public SupportsContentType SupportsContentType => contentType => contentType.Value.EndsWith("json", StringComparison.InvariantCultureIgnoreCase); -} \ No newline at end of file + public SupportsContentType SupportsContentType => ContentType.SupportsJson; +} diff --git a/src/RestSharp/Serializers/SerializerConfig.cs b/src/RestSharp/Serializers/SerializerConfig.cs index 27055cf12..b237771f6 100644 --- a/src/RestSharp/Serializers/SerializerConfig.cs +++ b/src/RestSharp/Serializers/SerializerConfig.cs @@ -37,7 +37,21 @@ public SerializerConfig UseSerializer(Func serializerFactory) { return this; } - public void UseDefaultSerializers() => UseSerializer().UseSerializer(); + public void UseDefaultSerializers() { + // Defer construction so custom serializers can replace defaults without loading their dependencies. + Serializers[DataFormat.Json] = new( + DataFormat.Json, + ContentType.JsonAccept, + ContentType.SupportsJson, + static () => new SystemTextJsonSerializer() + ); + Serializers[DataFormat.Xml] = new( + DataFormat.Xml, + ContentType.XmlAccept, + ContentType.SupportsXml, + static () => new XmlRestSerializer() + ); + } /// /// Replace the default serializer with a custom one diff --git a/src/RestSharp/Serializers/Xml/XmlRestSerializer.cs b/src/RestSharp/Serializers/Xml/XmlRestSerializer.cs index db857095f..48af0a233 100644 --- a/src/RestSharp/Serializers/Xml/XmlRestSerializer.cs +++ b/src/RestSharp/Serializers/Xml/XmlRestSerializer.cs @@ -23,7 +23,7 @@ public class XmlRestSerializer(IXmlSerializer serializer, IXmlDeserializer deser public ISerializer Serializer => _serializer; public IDeserializer Deserializer => _deserializer; public string[] AcceptedContentTypes => ContentType.XmlAccept; - public SupportsContentType SupportsContentType => contentType => contentType.Value.EndsWith("xml", StringComparison.InvariantCultureIgnoreCase); + public SupportsContentType SupportsContentType => ContentType.SupportsXml; public DataFormat DataFormat => DataFormat.Xml; @@ -53,4 +53,4 @@ public XmlRestSerializer WithXmlDeserializer(IXmlDeserializer xmlDeserializer) { _deserializer = xmlDeserializer; return this; } -} \ No newline at end of file +} diff --git a/test/RestSharp.Tests.Serializers.Json/SerializerAssemblyLoadingTests.cs b/test/RestSharp.Tests.Serializers.Json/SerializerAssemblyLoadingTests.cs new file mode 100644 index 000000000..4e2c2052d --- /dev/null +++ b/test/RestSharp.Tests.Serializers.Json/SerializerAssemblyLoadingTests.cs @@ -0,0 +1,125 @@ +#if NET48 +using System.Runtime.CompilerServices; +using RestSharp.Serializers; +using RestSharp.Serializers.NewtonsoftJson; + +namespace RestSharp.Tests.Serializers.Json; + +public class SerializerAssemblyLoadingTests { + [Fact] + public void Defaults_do_not_load_json_libraries() { + var loaded = RunIsolated(probe => probe.UseDefaults()); + + loaded.Should().NotContain("System.Text.Json").And.NotContain("Newtonsoft.Json"); + } + + [Fact] + public void Xml_only_does_not_load_json_libraries() { + var loaded = RunIsolated(probe => probe.UseXmlOnly()); + + loaded.Should().NotContain("System.Text.Json").And.NotContain("Newtonsoft.Json"); + } + + [Fact] + public void Default_json_loads_only_system_text_json() { + var loaded = RunIsolated(probe => probe.UseDefaultJson()); + + loaded.Should().Contain("System.Text.Json").And.NotContain("Newtonsoft.Json"); + } + + [Fact] + public void Newtonsoft_loads_only_newtonsoft_json() { + var loaded = RunIsolated(probe => probe.UseNewtonsoft()); + + loaded.Should().Contain("Newtonsoft.Json").And.NotContain("System.Text.Json"); + } + + [Fact] + public void Newtonsoft_only_loads_only_newtonsoft_json() { + var loaded = RunIsolated(probe => probe.UseNewtonsoftOnly()); + + loaded.Should().Contain("Newtonsoft.Json").And.NotContain("System.Text.Json"); + } + + static string[] RunIsolated(Action test) { + var probeType = typeof(IsolatedSerializerConfigurationHelper); + var setup = new AppDomainSetup { + // CodeBase points to the original output directory even when xUnit shadow-copies the assembly. + ApplicationBase = Path.GetDirectoryName(new Uri(probeType.Assembly.CodeBase).LocalPath), + ConfigurationFile = AppDomain.CurrentDomain.SetupInformation.ConfigurationFile, + LoaderOptimization = LoaderOptimization.SingleDomain + }; + var domain = AppDomain.CreateDomain($"Serializer loading {Guid.NewGuid():N}", null, setup); + + try { + var probe = (IsolatedSerializerConfigurationHelper)domain.CreateInstanceAndUnwrap(probeType.Assembly.FullName, probeType.FullName); + probe.GetLoadedAssemblies().Should().NotContain("System.Text.Json").And.NotContain("Newtonsoft.Json"); + + test(probe); + + return probe.GetLoadedAssemblies(); + } + finally { + AppDomain.Unload(domain); + } + } + + /// + /// Exercises serializer configurations in a separate AppDomain and reports its loaded assemblies. + /// + /// + /// Inherits from MarshalByRefObject so calls execute in the isolated domain. + /// Avoid test framework calls here, as they could load unrelated dependencies and affect the assertions. + /// + public class IsolatedSerializerConfigurationHelper : MarshalByRefObject { + public string[] GetLoadedAssemblies() => AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetName().Name).ToArray(); + + public void UseDefaults() { + using var client = new RestClient(); + RoundtripXml(client); + } + + public void UseXmlOnly() { + using var client = new RestClient(configureSerialization: config => config.UseXml()); + RoundtripXml(client); + } + + public void UseDefaultJson() { + using var client = new RestClient(); + RoundtripJson(client); + } + + // Keep Newtonsoft references out of the default/XML path, including its JIT compilation. + [MethodImpl(MethodImplOptions.NoInlining)] + public void UseNewtonsoft() { + using var client = new RestClient(configureSerialization: config => config.UseNewtonsoftJson()); + RoundtripJson(client); + RoundtripXml(client); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public void UseNewtonsoftOnly() { + using var client = new RestClient(configureSerialization: config => config.UseOnlySerializer(() => new JsonNetSerializer())); + RoundtripJson(client); + } + + static void RoundtripJson(RestClient client) { + var serializer = client.Serializers.GetSerializer(DataFormat.Json); + var body = serializer.Serialize(new JsonParameter(new Payload { Value = "roundtrip" })); + var result = serializer.Deserializer.Deserialize(new RestResponse(new RestRequest()) { Content = body }); + if (result?.Value != "roundtrip") throw new InvalidOperationException("JSON roundtrip failed."); + } + + static void RoundtripXml(RestClient client) { + var serializer = client.Serializers.GetSerializer(DataFormat.Xml); + var body = serializer.Serialize(new XmlParameter(new Payload { Value = "roundtrip" })); + var result = serializer.Deserializer.Deserialize(new RestResponse(new RestRequest()) { Content = body }); + if (result?.Value != "roundtrip") throw new InvalidOperationException("XML roundtrip failed."); + } + } + + public class Payload { + public string Value { get; set; } + } +} +#endif diff --git a/test/RestSharp.Tests/RestClientTests.cs b/test/RestSharp.Tests/RestClientTests.cs index 2c82278f1..0b6659cb0 100644 --- a/test/RestSharp.Tests/RestClientTests.cs +++ b/test/RestSharp.Tests/RestClientTests.cs @@ -71,6 +71,21 @@ public void UseOnlySerializer_leaves_only_custom_serializer() { client.Serializers.GetSerializer(DataFormat.Json).Should().NotBeNull(); } + [Theory] + [InlineData(DataFormat.Json)] + [InlineData(DataFormat.Xml)] + public void Default_registration_matches_serializer_metadata_and_creates_fresh_instances(DataFormat format) { + using var client = new RestClient(); + var record = client.Serializers.Serializers[format]; + var first = client.Serializers.GetSerializer(format); + var second = client.Serializers.GetSerializer(format); + + record.DataFormat.Should().Be(first.DataFormat); + record.AcceptedContentTypes.Should().Equal(first.AcceptedContentTypes); + record.SupportsContentType.Should().BeSameAs(first.SupportsContentType); + second.Should().NotBeSameAs(first); + } + [Fact] public void Should_reuse_httpClient_instance() { using var client1 = new RestClient(new Uri("https://fake.api"), useClientFactory: true); @@ -148,4 +163,4 @@ public void Should_not_set_expect_continue_on_new_http_client_default_headers() // assert restClient.HttpClient.DefaultRequestHeaders.ExpectContinue.Should().BeNull(); } -} \ No newline at end of file +}