From 4dc910dfdb9b7dc1859778d6285e063ef4110302 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 27 Nov 2025 19:17:00 +0100 Subject: [PATCH 1/4] Add support for MS DI and client factory --- Directory.Packages.props | 1 + RestSharp.sln | 65 ++++++++++ ...harp.Extensions.DependencyInjection.csproj | 11 ++ .../ServiceCollectionExtensions.cs | 45 +++++++ src/RestSharp/Properties/AssemblyInfo.cs | 3 + src/RestSharp/RestClient.cs | 4 +- src/RestSharp/RestSharp.csproj | 45 +------ .../RequestTests.cs | 21 ++++ ...RestSharp.Tests.DependencyInjection.csproj | 12 ++ .../Interceptor/InterceptorTests.cs | 1 + test/RestSharp.Tests.Integrated/PutTests.cs | 2 - .../RedirectTests.cs | 2 - .../RequestFailureTests.cs | 2 - .../RequestTests.cs | 92 +------------- .../RestSharp.Tests.Integrated.csproj | 3 +- .../RequestTestsBase.cs | 117 ++++++++++++++++++ .../Server/Models.cs | 4 +- .../Server/WireMockTestServer.cs | 13 +- 18 files changed, 300 insertions(+), 143 deletions(-) create mode 100644 src/RestSharp.Extensions.DependencyInjection/RestSharp.Extensions.DependencyInjection.csproj create mode 100644 src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 test/RestSharp.Tests.DependencyInjection/RequestTests.cs create mode 100644 test/RestSharp.Tests.DependencyInjection/RestSharp.Tests.DependencyInjection.csproj create mode 100644 test/RestSharp.Tests.Shared/RequestTestsBase.cs rename test/{RestSharp.Tests.Integrated => RestSharp.Tests.Shared}/Server/Models.cs (66%) rename test/{RestSharp.Tests.Integrated => RestSharp.Tests.Shared}/Server/WireMockTestServer.cs (92%) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7eaedfa27..2ae8a65dc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ 9.0.10 + diff --git a/RestSharp.sln b/RestSharp.sln index b3efff12e..0f86b90e0 100644 --- a/RestSharp.sln +++ b/RestSharp.sln @@ -37,6 +37,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SourceGen", "SourceGen", "{ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceGenerator", "gen\SourceGenerator\SourceGenerator.csproj", "{FE778406-ADCF-45A1-B775-A054B55BFC50}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestSharp.Extensions.DependencyInjection", "src\RestSharp.Extensions.DependencyInjection\RestSharp.Extensions.DependencyInjection.csproj", "{92A6F3CA-100F-4D9D-9742-B62267D445B6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestSharp.Tests.DependencyInjection", "test\RestSharp.Tests.DependencyInjection\RestSharp.Tests.DependencyInjection.csproj", "{602FF788-E926-4404-B3A2-D6B778A5FFB7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug.Appveyor|Any CPU = Debug.Appveyor|Any CPU @@ -446,6 +450,66 @@ Global {FE778406-ADCF-45A1-B775-A054B55BFC50}.Release|x64.Build.0 = Release|Any CPU {FE778406-ADCF-45A1-B775-A054B55BFC50}.Release|x86.ActiveCfg = Release|Any CPU {FE778406-ADCF-45A1-B775-A054B55BFC50}.Release|x86.Build.0 = Release|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug|ARM.ActiveCfg = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug|ARM.Build.0 = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug|x64.ActiveCfg = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug|x64.Build.0 = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug|x86.ActiveCfg = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Debug|x86.Build.0 = Debug|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Release|Any CPU.Build.0 = Release|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Release|ARM.ActiveCfg = Release|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Release|ARM.Build.0 = Release|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Release|x64.ActiveCfg = Release|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Release|x64.Build.0 = Release|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Release|x86.ActiveCfg = Release|Any CPU + {92A6F3CA-100F-4D9D-9742-B62267D445B6}.Release|x86.Build.0 = Release|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug|ARM.ActiveCfg = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug|ARM.Build.0 = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug|x64.ActiveCfg = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug|x64.Build.0 = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug|x86.ActiveCfg = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Debug|x86.Build.0 = Debug|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Release|Any CPU.Build.0 = Release|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Release|ARM.ActiveCfg = Release|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Release|ARM.Build.0 = Release|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Release|x64.ActiveCfg = Release|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Release|x64.Build.0 = Release|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Release|x86.ActiveCfg = Release|Any CPU + {602FF788-E926-4404-B3A2-D6B778A5FFB7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -463,6 +527,7 @@ Global {2150E333-8FDC-42A3-9474-1A3956D46DE8} = {8C7B43EB-2F93-483C-B433-E28F9386AD67} {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060} = {9051DDA0-E563-45D5-9504-085EBAACF469} {FE778406-ADCF-45A1-B775-A054B55BFC50} = {55B8F371-B2BA-4DEE-AB98-5BAB8A21B1C2} + {602FF788-E926-4404-B3A2-D6B778A5FFB7} = {9051DDA0-E563-45D5-9504-085EBAACF469} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {77FF357B-03FA-4FA5-A68F-BFBE5800FEBA} diff --git a/src/RestSharp.Extensions.DependencyInjection/RestSharp.Extensions.DependencyInjection.csproj b/src/RestSharp.Extensions.DependencyInjection/RestSharp.Extensions.DependencyInjection.csproj new file mode 100644 index 000000000..8fc01d585 --- /dev/null +++ b/src/RestSharp.Extensions.DependencyInjection/RestSharp.Extensions.DependencyInjection.csproj @@ -0,0 +1,11 @@ + + + net8.0;net9.0;net10.0 + + + + + + + + diff --git a/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..66fa33103 --- /dev/null +++ b/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace RestSharp.Extensions.DependencyInjection; + +public static class ServiceCollectionExtensions { + const string DefaultRestClient = "DefaultRestClient"; + + extension(IServiceCollection services) { + /// + /// Adds a RestClient to the service collection. + /// + /// The configuration options for the RestClient. + [PublicAPI] + public void AddRestClient(RestClientOptions options) { + services + .AddHttpClient(DefaultRestClient) + .ConfigureHttpClient(client => RestClient.ConfigureHttpClient(client, options)) + .ConfigurePrimaryHttpMessageHandler(() => { + var handler = new HttpClientHandler(); + RestClient.ConfigureHttpMessageHandler(handler, options); + return handler; + } + ); + + services.AddTransient(sp => { + var client = sp.GetRequiredService().CreateClient(DefaultRestClient); + return new RestClient(client, options); + } + ); + } + + /// + /// Adds a RestClient to the service collection with default options. + /// + [PublicAPI] + public void AddRestClient() => services.AddRestClient(new RestClientOptions()); + + /// + /// Adds a RestClient to the service collection with a base URL. + /// + /// The base URL for the RestClient. + [PublicAPI] + public void AddRestClient(string baseUrl) => services.AddRestClient(new RestClientOptions(baseUrl)); + } +} \ No newline at end of file diff --git a/src/RestSharp/Properties/AssemblyInfo.cs b/src/RestSharp/Properties/AssemblyInfo.cs index 831846141..27ad83bc7 100644 --- a/src/RestSharp/Properties/AssemblyInfo.cs +++ b/src/RestSharp/Properties/AssemblyInfo.cs @@ -1,6 +1,9 @@ using System.Runtime.CompilerServices; [assembly: + InternalsVisibleTo( + "RestSharp.Extensions.DependencyInjection, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fda57af14a288d46e3efea89617037585c4de57159cd536ca6dff792ea1d6addc665f2fccb4285413d9d44db5a1be87cb82686db200d16325ed9c42c89cd4824d8cc447f7cee2ac000924c3bceeb1b7fcb5cc1a3901785964d48ce14172001084134f4dcd9973c3776713b595443b1064bb53e2eeb924969244d354e46495e9d" + ), InternalsVisibleTo( "RestSharp.InteractiveTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fda57af14a288d46e3efea89617037585c4de57159cd536ca6dff792ea1d6addc665f2fccb4285413d9d44db5a1be87cb82686db200d16325ed9c42c89cd4824d8cc447f7cee2ac000924c3bceeb1b7fcb5cc1a3901785964d48ce14172001084134f4dcd9973c3776713b595443b1064bb53e2eeb924969244d354e46495e9d" ), diff --git a/src/RestSharp/RestClient.cs b/src/RestSharp/RestClient.cs index 95fd432cb..c48c45544 100644 --- a/src/RestSharp/RestClient.cs +++ b/src/RestSharp/RestClient.cs @@ -224,12 +224,12 @@ public RestClient( ) : this(new HttpClient(handler, disposeHandler), true, configureRestClient, configureSerialization) { } - static void ConfigureHttpClient(HttpClient httpClient, RestClientOptions options) { + internal static void ConfigureHttpClient(HttpClient httpClient, RestClientOptions options) { if (options.Expect100Continue != null) httpClient.DefaultRequestHeaders.ExpectContinue = options.Expect100Continue; } // ReSharper disable once CognitiveComplexity - static void ConfigureHttpMessageHandler(HttpClientHandler handler, RestClientOptions options) { + internal static void ConfigureHttpMessageHandler(HttpClientHandler handler, RestClientOptions options) { #if NET if (!OperatingSystem.IsBrowser()) { #endif diff --git a/src/RestSharp/RestSharp.csproj b/src/RestSharp/RestSharp.csproj index 3b9db4980..fced951ab 100644 --- a/src/RestSharp/RestSharp.csproj +++ b/src/RestSharp/RestSharp.csproj @@ -13,57 +13,18 @@ - - RestClient.Extensions.cs - RestClient.cs - - PropertyCache.cs - - + PropertyCache.cs - - RestClient.Extensions.cs - - - RestClient.Extensions.cs - - - RestClient.Extensions.cs - - - RestClient.Extensions.cs - - + RestClient.Extensions.cs - - RestClient.Extensions.cs - - - RestClient.Extensions.cs - - - RestRequestExtensions.cs - - + RestRequestExtensions.cs - - RestRequestExtensions.cs - - - RestRequestExtensions.cs - - - RestRequestExtensions.cs - - - RestRequestExtensions.cs - diff --git a/test/RestSharp.Tests.DependencyInjection/RequestTests.cs b/test/RestSharp.Tests.DependencyInjection/RequestTests.cs new file mode 100644 index 000000000..05a602208 --- /dev/null +++ b/test/RestSharp.Tests.DependencyInjection/RequestTests.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; +using RestSharp.Extensions.DependencyInjection; +using RestSharp.Tests.Shared; +using RestSharp.Tests.Shared.Server; + +namespace RestSharp.Tests.DependencyInjection; + +public sealed class RequestTests + : RequestTestsBase, IClassFixture, IDisposable { + readonly ServiceProvider _provider; + + public RequestTests(WireMockTestServer server) : base(false) { + var services = new ServiceCollection(); + services.AddRestClient(server.Url!); + _provider = services.BuildServiceProvider(); + } + + public void Dispose() => _provider.Dispose(); + + protected override IRestClient GetClient() => _provider.GetRequiredService(); +} \ No newline at end of file diff --git a/test/RestSharp.Tests.DependencyInjection/RestSharp.Tests.DependencyInjection.csproj b/test/RestSharp.Tests.DependencyInjection/RestSharp.Tests.DependencyInjection.csproj new file mode 100644 index 000000000..7e60e7477 --- /dev/null +++ b/test/RestSharp.Tests.DependencyInjection/RestSharp.Tests.DependencyInjection.csproj @@ -0,0 +1,12 @@ + + + + net8.0;net9.0;net10.0 + + + + + + + + diff --git a/test/RestSharp.Tests.Integrated/Interceptor/InterceptorTests.cs b/test/RestSharp.Tests.Integrated/Interceptor/InterceptorTests.cs index a70784e71..d52577217 100644 --- a/test/RestSharp.Tests.Integrated/Interceptor/InterceptorTests.cs +++ b/test/RestSharp.Tests.Integrated/Interceptor/InterceptorTests.cs @@ -1,4 +1,5 @@ // ReSharper disable AccessToDisposedClosure + namespace RestSharp.Tests.Integrated.Interceptor; public class InterceptorTests(WireMockTestServer server) : IClassFixture { diff --git a/test/RestSharp.Tests.Integrated/PutTests.cs b/test/RestSharp.Tests.Integrated/PutTests.cs index dbe5907e6..70a784d07 100644 --- a/test/RestSharp.Tests.Integrated/PutTests.cs +++ b/test/RestSharp.Tests.Integrated/PutTests.cs @@ -40,5 +40,3 @@ public async Task Can_Timeout_PUT_Async() { public void Dispose() => _client.Dispose(); } - -public record TestRequest(string Data, int Number); \ No newline at end of file diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index 557a7db71..ec33a84cc 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -1,7 +1,5 @@ namespace RestSharp.Tests.Integrated; -using Server; - public sealed class RedirectTests(WireMockTestServer server) : IClassFixture, IDisposable { readonly RestClient _client = new(new RestClientOptions(server.Url!) { FollowRedirects = true }); diff --git a/test/RestSharp.Tests.Integrated/RequestFailureTests.cs b/test/RestSharp.Tests.Integrated/RequestFailureTests.cs index 6702ece2d..c74306db5 100644 --- a/test/RestSharp.Tests.Integrated/RequestFailureTests.cs +++ b/test/RestSharp.Tests.Integrated/RequestFailureTests.cs @@ -2,8 +2,6 @@ namespace RestSharp.Tests.Integrated; -using Server; - public sealed class RequestFailureTests(WireMockTestServer server) : IClassFixture, IDisposable { readonly RestClient _client = new(server.Url!); diff --git a/test/RestSharp.Tests.Integrated/RequestTests.cs b/test/RestSharp.Tests.Integrated/RequestTests.cs index cd53e6c56..16745fcee 100644 --- a/test/RestSharp.Tests.Integrated/RequestTests.cs +++ b/test/RestSharp.Tests.Integrated/RequestTests.cs @@ -1,90 +1,10 @@ namespace RestSharp.Tests.Integrated; -public sealed class AsyncTests(WireMockTestServer server) : IClassFixture, IDisposable { +public sealed class RequestTests(WireMockTestServer server) + : RequestTestsBase(false), IClassFixture, IDisposable { readonly RestClient _client = new(server.Url!); - - [Fact] - public async Task Can_Handle_Exception_Thrown_By_Interceptor_BeforeDeserialization() { - const string exceptionMessage = "Thrown from OnBeforeDeserialization"; - - var request = new RestRequest("success") { - Interceptors = [new ThrowingInterceptor(exceptionMessage)] - }; - - var response = await _client.ExecuteAsync(request); - - Assert.Equal(exceptionMessage, response.ErrorMessage); - Assert.Equal(ResponseStatus.Error, response.ResponseStatus); - } - - [Fact, Obsolete("Obsolete")] - public async Task Can_Handle_Exception_Thrown_By_OnBeforeDeserialization_Handler() { - const string exceptionMessage = "Thrown from OnBeforeDeserialization"; - - var request = new RestRequest("success"); - - request.OnBeforeDeserialization += _ => throw new Exception(exceptionMessage); - - var response = await _client.ExecuteAsync(request); - - Assert.Equal(exceptionMessage, response.ErrorMessage); - Assert.Equal(ResponseStatus.Error, response.ResponseStatus); - } - - [Fact] - public async Task Can_Perform_ExecuteGetAsync_With_Response_Type() { - var request = new RestRequest("success"); - var response = await _client.ExecuteAsync(request); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Data!.Message.Should().Be("Works!"); - } - - [Fact] - public async Task Can_Perform_GET_Async() { - const string val = "Basic async test"; - - var request = new RestRequest($"echo?msg={val}"); - - var response = await _client.ExecuteAsync(request); - response.Content.Should().Be(val); - } - -#if NET - [Fact] - public async Task Can_Timeout_GET_Async() { - var request = new RestRequest("timeout").AddBody("Body_Content"); - - // Half the value of ResponseHandler.Timeout - request.Timeout = TimeSpan.FromMilliseconds(200); - - var response = await _client.ExecuteAsync(request); - - response.ResponseStatus.Should().Be(ResponseStatus.TimedOut, response.ErrorMessage); - } -#endif - - [Fact] - public async Task Can_Perform_Delete_With_Response_Type() { - var request = new RestRequest("delete"); - var response = await _client.ExecuteAsync(request, Method.Delete); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Data!.Message.Should().Be("Works!"); - } - - [Fact] - public async Task Can_Delete_With_Response_Type_using_extension() { - var request = new RestRequest("delete"); - var response = await _client.DeleteAsync(request); - - response!.Message.Should().Be("Works!"); - } - - class ThrowingInterceptor(string errorMessage) : Interceptors.Interceptor { - public override ValueTask BeforeDeserialization(RestResponse response, CancellationToken cancellationToken) - => throw new Exception(errorMessage); - } - + public void Dispose() => _client.Dispose(); -} \ No newline at end of file + + protected override RestClient GetClient() => _client; +} diff --git a/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj b/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj index b0b63fc1a..8018e61be 100644 --- a/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj +++ b/test/RestSharp.Tests.Integrated/RestSharp.Tests.Integrated.csproj @@ -25,6 +25,7 @@ - + + \ No newline at end of file diff --git a/test/RestSharp.Tests.Shared/RequestTestsBase.cs b/test/RestSharp.Tests.Shared/RequestTestsBase.cs new file mode 100644 index 000000000..bc4d416c0 --- /dev/null +++ b/test/RestSharp.Tests.Shared/RequestTestsBase.cs @@ -0,0 +1,117 @@ +using System.Net; +using RestSharp.Serializers; +using RestSharp.Tests.Shared.Server; +using WireMock.ResponseBuilders; + +namespace RestSharp.Tests.Shared; + +public abstract class RequestTestsBase(bool disposeClient) { + protected abstract IRestClient GetClient(); + + IRestClient GetTestClient() => new TestClient(GetClient(), disposeClient); + + [Fact] + public async Task Can_Handle_Exception_Thrown_By_Interceptor_BeforeDeserialization() { + const string exceptionMessage = "Thrown from OnBeforeDeserialization"; + + var request = new RestRequest("success") { + Interceptors = [new ThrowingInterceptor(exceptionMessage)] + }; + + using var client = GetTestClient(); + var response = await client.ExecuteAsync(request); + + Assert.Equal(exceptionMessage, response.ErrorMessage); + Assert.Equal(ResponseStatus.Error, response.ResponseStatus); + } + + [Fact, Obsolete("Obsolete")] + public async Task Can_Handle_Exception_Thrown_By_OnBeforeDeserialization_Handler() { + const string exceptionMessage = "Thrown from OnBeforeDeserialization"; + + var request = new RestRequest("success"); + + request.OnBeforeDeserialization += _ => throw new Exception(exceptionMessage); + + using var client = GetTestClient(); + var response = await client.ExecuteAsync(request); + + Assert.Equal(exceptionMessage, response.ErrorMessage); + Assert.Equal(ResponseStatus.Error, response.ResponseStatus); + } + + [Fact] + public async Task Can_Perform_ExecuteGetAsync_With_Response_Type() { + using var client = GetTestClient(); + var request = new RestRequest("success"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Data!.Message.Should().Be("Works!"); + } + + [Fact] + public async Task Can_Perform_GET_Async() { + const string val = "Basic async test"; + + var request = new RestRequest($"echo?msg={val}"); + + using var client = GetTestClient(); + var response = await client.ExecuteAsync(request); + response.Content.Should().Be(val); + } + +#if NET + [Fact] + public async Task Can_Timeout_GET_Async() { + var request = new RestRequest("timeout").AddBody("Body_Content"); + + // Half the value of ResponseHandler.Timeout + request.Timeout = TimeSpan.FromMilliseconds(200); + + using var client = GetTestClient(); + var response = await client.ExecuteAsync(request); + + response.ResponseStatus.Should().Be(ResponseStatus.TimedOut, response.ErrorMessage); + } +#endif + + [Fact] + public async Task Can_Perform_Delete_With_Response_Type() { + using var client = GetTestClient(); + var request = new RestRequest("delete"); + var response = await client.ExecuteAsync(request, Method.Delete); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Data!.Message.Should().Be("Works!"); + } + + [Fact] + public async Task Can_Delete_With_Response_Type_using_extension() { + using var client = GetTestClient(); + var request = new RestRequest("delete"); + var response = await client.DeleteAsync(request); + + response!.Message.Should().Be("Works!"); + } + + class ThrowingInterceptor(string errorMessage) : Interceptors.Interceptor { + public override ValueTask BeforeDeserialization(RestResponse response, CancellationToken cancellationToken) => throw new(errorMessage); + } + + class TestClient(IRestClient innerClient, bool disposeClient) : IRestClient { + public void Dispose() { + if (disposeClient) innerClient.Dispose(); + } + + public ReadOnlyRestClientOptions Options => innerClient.Options; + public RestSerializers Serializers => innerClient.Serializers; + public DefaultParameters DefaultParameters => innerClient.DefaultParameters; + + public Task ExecuteAsync(RestRequest request, CancellationToken cancellationToken = default) + => innerClient.ExecuteAsync(request, cancellationToken); + + public Task DownloadStreamAsync(RestRequest request, CancellationToken cancellationToken = default) + => innerClient.DownloadStreamAsync(request, cancellationToken); + } +} \ No newline at end of file diff --git a/test/RestSharp.Tests.Integrated/Server/Models.cs b/test/RestSharp.Tests.Shared/Server/Models.cs similarity index 66% rename from test/RestSharp.Tests.Integrated/Server/Models.cs rename to test/RestSharp.Tests.Shared/Server/Models.cs index 7fa4a9226..9ae54a400 100644 --- a/test/RestSharp.Tests.Integrated/Server/Models.cs +++ b/test/RestSharp.Tests.Shared/Server/Models.cs @@ -1,6 +1,6 @@ -namespace RestSharp.Tests.Integrated.Server; +namespace RestSharp.Tests.Shared.Server; -record TestServerResponse(string Name, string Value); +public record TestServerResponse(string Name, string Value); public record UploadResponse(string FileName, long Length, bool Equal); diff --git a/test/RestSharp.Tests.Integrated/Server/WireMockTestServer.cs b/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs similarity index 92% rename from test/RestSharp.Tests.Integrated/Server/WireMockTestServer.cs rename to test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs index 55dc7faee..527e3d5a0 100644 --- a/test/RestSharp.Tests.Integrated/Server/WireMockTestServer.cs +++ b/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs @@ -1,13 +1,17 @@ +using System.Net; using System.Text.Json; -using WireMock.Settings; +using WireMock; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; using WireMock.Types; using WireMock.Util; -namespace RestSharp.Tests.Integrated.Server; +namespace RestSharp.Tests.Shared.Server; // ReSharper disable once ClassNeverInstantiated.Global public class WireMockTestServer : WireMockServer { - public WireMockTestServer() : base(new WireMockServerSettings { Port = 0, UseHttp2 = false, UseSSL = false }) { + public WireMockTestServer() : base(new() { Port = 0, UseHttp2 = false, UseSSL = false }) { Given(Request.Create().WithPath("/echo")) .RespondWith(Response.Create().WithCallback(EchoQuery)); @@ -97,5 +101,6 @@ public static ResponseMessage CreateJson(object response) DetectedBodyType = BodyType.Json } }; +} -} \ No newline at end of file +public record TestRequest(string Data, int Number); \ No newline at end of file From 4227f68bb69ef235df2e59a1067b5dfda68cf34f Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 27 Nov 2025 19:29:52 +0100 Subject: [PATCH 2/4] * Add best practices guide * Add net48 to the new project --- BEST_PRACTICES.md | 200 ++++++++++++++++++ agents.md | 2 +- ...harp.Extensions.DependencyInjection.csproj | 3 - src/RestSharp/RestClient.Async.cs | 2 +- ...RestSharp.Tests.DependencyInjection.csproj | 6 - .../DownloadFileTests.cs | 2 +- .../RequestTests.cs | 2 +- 7 files changed, 204 insertions(+), 13 deletions(-) create mode 100644 BEST_PRACTICES.md diff --git a/BEST_PRACTICES.md b/BEST_PRACTICES.md new file mode 100644 index 000000000..aacf16e26 --- /dev/null +++ b/BEST_PRACTICES.md @@ -0,0 +1,200 @@ +# RestSharp – Best Practices + +This document captures practical guidance for developing, testing, reviewing, and releasing changes in the RestSharp repository. It consolidates conventions used across the solution and provides checklists and examples to reduce regressions and ensure consistency across target frameworks. + + +## 1) Daily Development Workflow + +- Prefer small, focused pull requests with clear descriptions and test coverage. +- Build and test locally before pushing. Validate all relevant target frameworks (TFMs) for your changes. +- Keep commits tidy; prefer meaningful commit messages over large "fix" commits. +- Follow repository code style and file organization rules (see sections below). +- Use the latest C# language features (C# 14). + +Suggested loop: +- dotnet build +- dotnet test + +## 2) Multi-Targeting Guidance + +The core library targets netstandard2.0, net471, net48, net8.0, net9.0, and net10.0. Tests target net48 (Windows only), net8.0, net9.0, and net10.0. + +- Use conditional compilation for TFM-specific APIs or behaviors: + - #if NET + - #if NET for platform attributes (e.g., [UnsupportedOSPlatform("browser")]) +- When adding features, ensure compilation succeeds for all TFMs. If an API is missing on older TFMs, add polyfills or conditional code, or guard with feature detection. +- Be mindful of System.Text.Json: it is a package dependency on older TFMs but built-in on modern TFMs. +- Validate tests for each TFM impacted by your changes. If a test only applies to modern .NET, guard it with #if NET8_0_OR_GREATER. + + +## 3) Build and Configuration + +- Shared build logic lives in props/Common.props, src/Directory.Build.props, and test/Directory.Build.props. Do not duplicate MSBuild settings in individual projects unless necessary. +- Language version is preview; prefer modern C# features but ensure cross-TFM compatibility. +- Nullable reference types: + - Enabled in /src (Nullable=enable). Treat nullable warnings as design feedback. + - Disabled in /test (Nullable=disable) for test authoring ergonomics. +- Assemblies are strong-named via RestSharp.snk. Do not remove signing. +- Package versions are centrally managed in Directory.Packages.props; do not pin versions locally unless justified by TFM constraints. + + +## 4) Source Generators + +Custom incremental generators live in gen/SourceGenerator and are referenced as analyzers by src/RestSharp. + +- Use [GenerateImmutable] to produce immutable wrappers of mutable classes. + - Exclude properties with [Exclude] when not needed in the immutable type. + - Generated files are emitted to obj///generated/SourceGenerator/. +- Use [GenerateClone(BaseType=..., Name=...)] to create static factory methods that upcast/copy base properties into derived types. +- When editing generators: + - Keep the generator targets netstandard2.0. + - Ensure EmitCompilerGeneratedFiles is enabled when debugging locally. + - Validate output by building and inspecting generated files under obj/... +- Keep generator helpers cohesive (Extensions.cs) and prefer small, composable utilities. + + +## 5) Testing Strategy + +- Frameworks and libraries: xUnit + FluentAssertions + AutoFixture. +- Organization: + - Unit tests in test/RestSharp.Tests. + - Integration tests (HTTP/WireMock) in test/RestSharp.Tests.Integrated. + - Serializer-specific tests in dedicated projects. + - Shared helpers in test/RestSharp.Tests.Shared. +- Best practices: + - Co-locate tests with feature areas; use partial classes to split large suites. + - Prefer WireMockServer over live endpoints for HTTP scenarios. + - Avoid flaky tests: don’t depend on timing, locale, or network conditions. + - Use descriptive assertions, e.g., result.Should().Be(expected). + - Scope tests by TFM when API availability differs. +- Useful commands: + - dotnet test RestSharp.sln -c Debug + - dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net8.0 + - dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj --filter "FullyQualifiedName=Namespace.Class.Method" -f net8.0 +- Test results are written to test-results//.trx. + + +## 6) Code Coverage + +- Use coverlet.collector for data-collector-based coverage. +- Example command (Cobertura output): + - dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net10.0 --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura +- Coverage artifacts are placed alongside test results in test-results. + + +## 7) Dependency Injection and Extensions + +- DI extensions live in src/RestSharp.Extensions.DependencyInjection. +- When updating DI helpers: + - Keep APIs minimal and idiomatic for Microsoft.Extensions.DependencyInjection. + - Maintain backward compatibility where feasible; add overloads rather than breaking changes. + - Add focused tests under test/RestSharp.Tests.DependencyInjection. + + +## 8) Serialization Extensions + +- JSON (Newtonsoft.Json), XML, and CSV serializers are separate packages within src/RestSharp.Serializers.*. +- Keep serializer-specific behavior isolated. Avoid coupling core library to serializers. +- Each serializer project has its own tests; ensure behavior parity across TFMs. + + +## 9) Performance and Benchmarks + +- Use benchmarks/RestSharp.Benchmarks for perf investigations. +- Before merging performance-related changes: + - Validate allocations and throughput with BenchmarkDotNet where practical. + - Check for regression across TFMs if the code path is shared. + + +## 10) Versioning and Packaging + +- Versioning is handled by MinVer via Git tags and history. Ensure CI uses full history (unshallow fetch). +- Do not hardcode versions; rely on MinVer for assembly and file versions (CustomVersion target aligns versions post-MinVer). +- Packaging notes: + - dotnet pack src/RestSharp/RestSharp.csproj -c Release -o nuget -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg + - Symbol packages must be .snupkg; SourceLink is enabled for debugging. + - NuGet metadata is defined in src/Directory.Build.props; keep it accurate. + + +## 11) Continuous Integration + +- PR workflow executes matrix tests on Windows (net48, net8.0, net9.0, net10.0) and Linux (net8.0, net9.0, net10.0). +- Build/Deploy workflow packages and pushes to NuGet on dev branch and tags using OIDC-based auth. +- To simulate CI locally: + - dotnet test -c Debug -f net8.0 + - Optionally also run -f net9.0 and -f net10.0. On Windows, include net48. +- Uploading artifacts and publishing test results are CI-responsibilities; keep local output organized in test-results for quick inspection. + + +## 12) Code Organization and Style + +- File organization: + - Use partial classes to split large types by responsibility (e.g., RestClient.*, PropertyCache.*). Link related files via in csproj when appropriate. +- License header: + - All source files under /src must include the standard repository license header. + - Test files do not require the header. +- EditorConfig and rules: + - Follow .editorconfig for formatting and analyzer rules. + - In /src, suppress XML doc warnings via NoWarn=1591; in tests suppress xUnit1033 and CS8002 as configured. +- Nullable: + - Treat nullable annotations and warnings as design signals; prefer explicit nullability and defensive checks at public boundaries. +- Public API surface: + - Avoid breaking changes. If unavoidable, document in docs and changelog, and add clear migration notes. + + +## 13) PR Readiness Checklist + +Use this quick checklist before requesting review: +- Builds cleanly across all targeted TFMs for affected projects. +- Unit/integration tests added or updated; all pass locally for relevant TFMs. +- No analyzer warnings introduced in src (beyond allowed suppressions). +- License header present in new /src files. +- Source generator changes validated (if applicable) by inspecting obj/.../generated output. +- Public API changes reviewed, documented, and tested. +- Central package versions unchanged unless intentionally updated with justification. +- Commit messages and PR description explain the why and the how. + + +## 14) Troubleshooting Guide + +- Tests fail only on a specific TFM + - Run with -f to isolate. Look for conditional compilation and API availability differences. +- Generator output missing + - Ensure EmitCompilerGeneratedFiles is true in the project under test; clean and rebuild; inspect obj/.../generated. +- net48 failures on non-Windows + - Expected. Use net8.0 or higher on Linux/macOS. Run net48 only on Windows. +- MinVer version incorrect + - Ensure CI or local clone has full git history (git fetch --prune --unshallow). +- Platform-specific failures + - Use [UnsupportedOSPlatform] and conditional compilation to guard APIs not available on Browser, etc. + + +## 15) Useful Commands (Quick Reference) + +- Build solution (Release): + - dotnet build RestSharp.sln -c Release +- Run tests for a single TFM: + - dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net8.0 +- Run a single test by fully-qualified name: + - dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj --filter "FullyQualifiedName=RestSharp.Tests.ObjectParserTests.ShouldUseRequestProperty" -f net8.0 +- Pack locally with symbols: + - dotnet pack src/RestSharp/RestSharp.csproj -c Release -o nuget -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg +- View generated source files after build: + - find src/RestSharp/obj/Debug -name "*.g.cs" -o -name "ReadOnly*.cs" +- Clean all build artifacts: + - dotnet clean RestSharp.sln + - rm -rf src/*/bin src/*/obj test/*/bin test/*/obj gen/*/bin gen/*/obj + + +## 16) Documentation and References + +- Main docs: https://restsharp.dev +- Repository: https://github.com/restsharp/RestSharp +- NuGet Packages: + - RestSharp + - RestSharp.Serializers.NewtonsoftJson + - RestSharp.Serializers.Xml + - RestSharp.Serializers.CsvHelper +- License: Apache-2.0 + +Keep this document up-to-date when build properties, TFMs, CI workflows, or repository conventions change. \ No newline at end of file diff --git a/agents.md b/agents.md index 8dffa5fd3..5093f99f8 100644 --- a/agents.md +++ b/agents.md @@ -99,7 +99,7 @@ The build system uses a hierarchical props structure: **Modern .NET (8/9/10):** - Native support for most features -- Conditional compilation using `#if NET8_0_OR_GREATER` +- Conditional compilation using `#if NET` - Platform-specific attributes like `[UnsupportedOSPlatform("browser")]` ### Assembly Signing diff --git a/src/RestSharp.Extensions.DependencyInjection/RestSharp.Extensions.DependencyInjection.csproj b/src/RestSharp.Extensions.DependencyInjection/RestSharp.Extensions.DependencyInjection.csproj index 8fc01d585..e294131f3 100644 --- a/src/RestSharp.Extensions.DependencyInjection/RestSharp.Extensions.DependencyInjection.csproj +++ b/src/RestSharp.Extensions.DependencyInjection/RestSharp.Extensions.DependencyInjection.csproj @@ -1,7 +1,4 @@  - - net8.0;net9.0;net10.0 - diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 06a43d6f6..75c8ac85f 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -92,7 +92,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo Ensure.NotNull(request, nameof(request)); // Make sure we are not disposed of when someone tries to call us! -#if NET8_0_OR_GREATER +#if NET ObjectDisposedException.ThrowIf(_disposed, this); #else if (_disposed) { diff --git a/test/RestSharp.Tests.DependencyInjection/RestSharp.Tests.DependencyInjection.csproj b/test/RestSharp.Tests.DependencyInjection/RestSharp.Tests.DependencyInjection.csproj index 7e60e7477..90202d946 100644 --- a/test/RestSharp.Tests.DependencyInjection/RestSharp.Tests.DependencyInjection.csproj +++ b/test/RestSharp.Tests.DependencyInjection/RestSharp.Tests.DependencyInjection.csproj @@ -1,12 +1,6 @@  - - - net8.0;net9.0;net10.0 - - - diff --git a/test/RestSharp.Tests.Integrated/DownloadFileTests.cs b/test/RestSharp.Tests.Integrated/DownloadFileTests.cs index db6099b9a..10fd2658a 100644 --- a/test/RestSharp.Tests.Integrated/DownloadFileTests.cs +++ b/test/RestSharp.Tests.Integrated/DownloadFileTests.cs @@ -32,7 +32,7 @@ public async Task AdvancedResponseWriter_without_ResponseWriter_reads_stream() { var buf = new byte[16]; // ReSharper disable once MustUseReturnValue using var stream = response.Content.ReadAsStreamAsync().GetAwaiter().GetResult(); -#if NET8_0_OR_GREATER +#if NET stream.ReadExactly(buf); #else stream.Read(buf, 0, buf.Length); diff --git a/test/RestSharp.Tests.Integrated/RequestTests.cs b/test/RestSharp.Tests.Integrated/RequestTests.cs index 16745fcee..ea845ec61 100644 --- a/test/RestSharp.Tests.Integrated/RequestTests.cs +++ b/test/RestSharp.Tests.Integrated/RequestTests.cs @@ -6,5 +6,5 @@ public sealed class RequestTests(WireMockTestServer server) public void Dispose() => _client.Dispose(); - protected override RestClient GetClient() => _client; + protected override IRestClient GetClient() => _client; } From f21e9539e5f9d71edb51d27ca6b348808143dfd7 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 28 Nov 2025 13:23:31 +0100 Subject: [PATCH 3/4] Add named registrations --- .../InheritedCloneGenerator.cs | 46 +++++++++---- .../Constants.cs | 21 ++++++ .../DefaultRestClientFactory.cs | 25 +++++++ .../IRestClientFactory.cs | 19 +++++ .../RestClientConfigOptions.cs | 20 ++++++ .../ServiceCollectionExtensions.cs | 69 +++++++++++++++---- .../Extensions/GenerateImmutableAttribute.cs | 3 +- src/RestSharp/Options/RestClientOptions.cs | 3 +- ...tTests.cs => DefaultClientRequestTests.cs} | 7 +- .../NamedClientWithBaseUrlRequestTests.cs | 22 ++++++ .../NamedClientWithOptionsRequestTests.cs | 22 ++++++ 11 files changed, 225 insertions(+), 32 deletions(-) create mode 100644 src/RestSharp.Extensions.DependencyInjection/Constants.cs create mode 100644 src/RestSharp.Extensions.DependencyInjection/DefaultRestClientFactory.cs create mode 100644 src/RestSharp.Extensions.DependencyInjection/IRestClientFactory.cs create mode 100644 src/RestSharp.Extensions.DependencyInjection/RestClientConfigOptions.cs rename test/RestSharp.Tests.DependencyInjection/{RequestTests.cs => DefaultClientRequestTests.cs} (67%) create mode 100644 test/RestSharp.Tests.DependencyInjection/NamedClientWithBaseUrlRequestTests.cs create mode 100644 test/RestSharp.Tests.DependencyInjection/NamedClientWithOptionsRequestTests.cs diff --git a/gen/SourceGenerator/InheritedCloneGenerator.cs b/gen/SourceGenerator/InheritedCloneGenerator.cs index 76b81911e..1e3843ff9 100644 --- a/gen/SourceGenerator/InheritedCloneGenerator.cs +++ b/gen/SourceGenerator/InheritedCloneGenerator.cs @@ -42,6 +42,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { var attributeData = genericClassSymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass?.Name == $"{AttributeName}Attribute"); var methodName = (string)attributeData.NamedArguments.FirstOrDefault(arg => arg.Key == "Name").Value.Value; var baseType = attributeData.NamedArguments.FirstOrDefault(arg => arg.Key == "BaseType").Value.Value; + var maybeMutate = attributeData.NamedArguments.FirstOrDefault(arg => arg.Key == "Mutate").Value.Value; + var mutate = maybeMutate as bool? ?? false; // Get the generic argument type where properties need to be copied from var attributeSyntax = candidate.AttributeLists @@ -49,7 +51,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { .FirstOrDefault(a => a.Name.ToString().StartsWith(AttributeName)); if (attributeSyntax == null) continue; // This should never happen - var code = GenerateMethod(candidate, genericClassSymbol, (INamedTypeSymbol)baseType, methodName); + var code = GenerateMethod(candidate, genericClassSymbol, (INamedTypeSymbol)baseType, methodName, (bool)mutate!); yield return ($"{genericClassSymbol.Name}.Clone.g.cs", SourceText.From(code, Encoding.UTF8)); } } @@ -59,7 +61,8 @@ static string GenerateMethod( TypeDeclarationSyntax classToExtendSyntax, INamedTypeSymbol classToExtendSymbol, INamedTypeSymbol classToClone, - string methodName + string methodName, + bool mutate ) { var namespaceName = classToExtendSymbol.ContainingNamespace.ToDisplayString(); var className = classToExtendSyntax.Identifier.Text; @@ -74,24 +77,41 @@ string methodName var constructorArgs = string.Join(", ", constructorParams.Select(p => $"original.{GetPropertyName(p.Name, props)}")); var constructorParamNames = constructorParams.Select(p => p.Name).ToArray(); + var endLine = mutate ? ";" : ","; + var spaces = string.Empty.PadRight(mutate ? 8 : 12); var properties = props // ReSharper disable once PossibleUnintendedLinearSearchInSet .Where(prop => !constructorParamNames.Contains(prop.Name, StringComparer.OrdinalIgnoreCase) && prop.SetMethod != null) - .Select(prop => $" {prop.Name} = original.{prop.Name},") + .Select(prop => $"{spaces}{prop.Name} = original.{prop.Name}{endLine}") .ToArray(); - const string template = """ - {Usings} + const string immutableTemplate = + """ + {Usings} - namespace {Namespace}; + namespace {Namespace}; - public partial class {ClassDeclaration} { - public static {ClassDeclaration} {MethodName}({OriginalClassName} original) - => new {ClassDeclaration}({ConstructorArgs}) { - {Properties} - }; - } - """; + public partial class {ClassDeclaration} { + public static {ClassDeclaration} {MethodName}({OriginalClassName} original) { + return new {ClassDeclaration}({ConstructorArgs}) { + {Properties} + }; + } + } + """; + const string mutableTemplate = + """ + {Usings} + + namespace {Namespace}; + + public partial class {ClassDeclaration} { + public void {MethodName}({OriginalClassName} original) { + {Properties} + } + } + """; + var template = mutate ? mutableTemplate : immutableTemplate; var code = template .Replace("{Usings}", string.Join("\n", usings)) diff --git a/src/RestSharp.Extensions.DependencyInjection/Constants.cs b/src/RestSharp.Extensions.DependencyInjection/Constants.cs new file mode 100644 index 000000000..058cad26e --- /dev/null +++ b/src/RestSharp.Extensions.DependencyInjection/Constants.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace RestSharp.Extensions.DependencyInjection; + +static class Constants { + public const string DefaultRestClient = "DefaultRestClient"; + + public static string GetConfigName(string name) => $"{name}$RestClient"; +} \ No newline at end of file diff --git a/src/RestSharp.Extensions.DependencyInjection/DefaultRestClientFactory.cs b/src/RestSharp.Extensions.DependencyInjection/DefaultRestClientFactory.cs new file mode 100644 index 000000000..124257f7e --- /dev/null +++ b/src/RestSharp.Extensions.DependencyInjection/DefaultRestClientFactory.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Microsoft.Extensions.Options; + +namespace RestSharp.Extensions.DependencyInjection; + +class DefaultRestClientFactory(IHttpClientFactory httpClientFactory, IOptionsMonitor optionsMonitor) : IRestClientFactory { + public IRestClient CreateClient(string name) { + var options = optionsMonitor.Get(Constants.GetConfigName(name)); + var httpClient = httpClientFactory.CreateClient(name); + return new RestClient(httpClient, true, options.ConfigureRestClient, options.ConfigureSerialization); + } +} \ No newline at end of file diff --git a/src/RestSharp.Extensions.DependencyInjection/IRestClientFactory.cs b/src/RestSharp.Extensions.DependencyInjection/IRestClientFactory.cs new file mode 100644 index 000000000..2462e515a --- /dev/null +++ b/src/RestSharp.Extensions.DependencyInjection/IRestClientFactory.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace RestSharp.Extensions.DependencyInjection; + +public interface IRestClientFactory { + IRestClient CreateClient(string name); +} \ No newline at end of file diff --git a/src/RestSharp.Extensions.DependencyInjection/RestClientConfigOptions.cs b/src/RestSharp.Extensions.DependencyInjection/RestClientConfigOptions.cs new file mode 100644 index 000000000..6ba35f6ce --- /dev/null +++ b/src/RestSharp.Extensions.DependencyInjection/RestClientConfigOptions.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace RestSharp.Extensions.DependencyInjection; + +class RestClientConfigOptions { + public ConfigureRestClient? ConfigureRestClient { get; set; } + public ConfigureSerialization? ConfigureSerialization { get; set; } +} \ No newline at end of file diff --git a/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs index 66fa33103..268ddb35b 100644 --- a/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -1,19 +1,28 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace RestSharp.Extensions.DependencyInjection; public static class ServiceCollectionExtensions { - const string DefaultRestClient = "DefaultRestClient"; - extension(IServiceCollection services) { /// - /// Adds a RestClient to the service collection. + /// Adds a named RestClient to the service collection. /// - /// The configuration options for the RestClient. + /// Client name + /// Optional: function to configure the client options. + /// Optional: function to configure serializers. [PublicAPI] - public void AddRestClient(RestClientOptions options) { + public void AddRestClient( + string name, + ConfigureRestClient? configureRestClient = null, + ConfigureSerialization? configureSerialization = null + ) { + var options = new RestClientOptions(); + var configure = configureRestClient ?? (_ => { }); + configure(options); + services - .AddHttpClient(DefaultRestClient) + .AddHttpClient(name) .ConfigureHttpClient(client => RestClient.ConfigureHttpClient(client, options)) .ConfigurePrimaryHttpMessageHandler(() => { var handler = new HttpClientHandler(); @@ -22,24 +31,58 @@ public void AddRestClient(RestClientOptions options) { } ); - services.AddTransient(sp => { - var client = sp.GetRequiredService().CreateClient(DefaultRestClient); - return new RestClient(client, options); - } - ); + services.TryAddSingleton(); + + if (name == Constants.DefaultRestClient) { + services.AddTransient(sp => { + var client = sp.GetRequiredService().CreateClient(name); + return new RestClient(client, options); + } + ); + } + else { + services.Configure( + Constants.GetConfigName(name), + o => { + o.ConfigureRestClient = configureRestClient; + o.ConfigureSerialization = configureSerialization; + } + ); + } } /// /// Adds a RestClient to the service collection with default options. /// [PublicAPI] - public void AddRestClient() => services.AddRestClient(new RestClientOptions()); + public void AddRestClient() => services.AddRestClient(Constants.DefaultRestClient); /// /// Adds a RestClient to the service collection with a base URL. /// /// The base URL for the RestClient. [PublicAPI] - public void AddRestClient(string baseUrl) => services.AddRestClient(new RestClientOptions(baseUrl)); + public void AddRestClient(Uri baseUrl) => services.AddRestClient(Constants.DefaultRestClient, o => o.BaseUrl = baseUrl); + + /// + /// Adds a RestClient to the service collection with custom options. + /// + /// Custom options for the RestClient. + [PublicAPI] + public void AddRestClient(RestClientOptions options) => services.AddRestClient(Constants.DefaultRestClient, o => o.CopyFrom(options)); + + /// + /// Adds a named RestClient to the service collection with base URL. + /// + /// Client name. + /// The base URL for the RestClient. + public void AddRestClient(string name, Uri baseUrl) => services.AddRestClient(name, o => o.BaseUrl = baseUrl); + + /// + /// Adds a named RestClient to the service collection with custom options. + /// + /// Client name. + /// Custom options for the RestClient. + public void AddRestClient(string name, RestClientOptions options) => services.AddRestClient(name, o => o.CopyFrom(options)); } } \ No newline at end of file diff --git a/src/RestSharp/Extensions/GenerateImmutableAttribute.cs b/src/RestSharp/Extensions/GenerateImmutableAttribute.cs index 1cafdb049..e049e72e9 100644 --- a/src/RestSharp/Extensions/GenerateImmutableAttribute.cs +++ b/src/RestSharp/Extensions/GenerateImmutableAttribute.cs @@ -22,7 +22,8 @@ class GenerateImmutableAttribute : Attribute; class GenerateCloneAttribute : Attribute { public Type? BaseType { get; set; } public string? Name { get; set; } + public bool Mutate { get; set; } }; [AttributeUsage(AttributeTargets.Property)] -class Exclude : Attribute; +class Exclude : Attribute; \ No newline at end of file diff --git a/src/RestSharp/Options/RestClientOptions.cs b/src/RestSharp/Options/RestClientOptions.cs index a2350cedc..d30e34f92 100644 --- a/src/RestSharp/Options/RestClientOptions.cs +++ b/src/RestSharp/Options/RestClientOptions.cs @@ -32,7 +32,8 @@ namespace RestSharp; [GenerateImmutable] -public class RestClientOptions { +[GenerateClone(BaseType = typeof(RestClientOptions), Name = "CopyFrom", Mutate = true)] +public partial class RestClientOptions { static readonly Version Version = new AssemblyName(typeof(RestClientOptions).Assembly.FullName!).Version!; static readonly string DefaultUserAgent = $"RestSharp/{Version}"; diff --git a/test/RestSharp.Tests.DependencyInjection/RequestTests.cs b/test/RestSharp.Tests.DependencyInjection/DefaultClientRequestTests.cs similarity index 67% rename from test/RestSharp.Tests.DependencyInjection/RequestTests.cs rename to test/RestSharp.Tests.DependencyInjection/DefaultClientRequestTests.cs index 05a602208..9d45b072e 100644 --- a/test/RestSharp.Tests.DependencyInjection/RequestTests.cs +++ b/test/RestSharp.Tests.DependencyInjection/DefaultClientRequestTests.cs @@ -5,13 +5,12 @@ namespace RestSharp.Tests.DependencyInjection; -public sealed class RequestTests - : RequestTestsBase, IClassFixture, IDisposable { +public sealed class DefaultClientRequestTests : RequestTestsBase, IClassFixture, IDisposable { readonly ServiceProvider _provider; - public RequestTests(WireMockTestServer server) : base(false) { + public DefaultClientRequestTests(WireMockTestServer server) : base(false) { var services = new ServiceCollection(); - services.AddRestClient(server.Url!); + services.AddRestClient(new Uri(server.Url!)); _provider = services.BuildServiceProvider(); } diff --git a/test/RestSharp.Tests.DependencyInjection/NamedClientWithBaseUrlRequestTests.cs b/test/RestSharp.Tests.DependencyInjection/NamedClientWithBaseUrlRequestTests.cs new file mode 100644 index 000000000..387fce5c3 --- /dev/null +++ b/test/RestSharp.Tests.DependencyInjection/NamedClientWithBaseUrlRequestTests.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +using RestSharp.Extensions.DependencyInjection; +using RestSharp.Tests.Shared; +using RestSharp.Tests.Shared.Server; + +namespace RestSharp.Tests.DependencyInjection; + +public sealed class NamedClientWithBaseUrlRequestTests : RequestTestsBase, IClassFixture, IDisposable { + readonly ServiceProvider _provider; + + const string ClientName = "test"; + + public NamedClientWithBaseUrlRequestTests(WireMockTestServer server) : base(false) { + var services = new ServiceCollection(); + services.AddRestClient(ClientName, new Uri(server.Url!)); + _provider = services.BuildServiceProvider(); + } + + public void Dispose() => _provider.Dispose(); + + protected override IRestClient GetClient() => _provider.GetRequiredService().CreateClient(ClientName); +} \ No newline at end of file diff --git a/test/RestSharp.Tests.DependencyInjection/NamedClientWithOptionsRequestTests.cs b/test/RestSharp.Tests.DependencyInjection/NamedClientWithOptionsRequestTests.cs new file mode 100644 index 000000000..1fda7af5c --- /dev/null +++ b/test/RestSharp.Tests.DependencyInjection/NamedClientWithOptionsRequestTests.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +using RestSharp.Extensions.DependencyInjection; +using RestSharp.Tests.Shared; +using RestSharp.Tests.Shared.Server; + +namespace RestSharp.Tests.DependencyInjection; + +public sealed class NamedClientWithOptionsRequestTests : RequestTestsBase, IClassFixture, IDisposable { + readonly ServiceProvider _provider; + + const string ClientName = "test"; + + public NamedClientWithOptionsRequestTests(WireMockTestServer server) : base(false) { + var services = new ServiceCollection(); + services.AddRestClient(ClientName, new RestClientOptions(server.Url!)); + _provider = services.BuildServiceProvider(); + } + + public void Dispose() => _provider.Dispose(); + + protected override IRestClient GetClient() => _provider.GetRequiredService().CreateClient(ClientName); +} \ No newline at end of file From e6e7a0c8510031688612b5d1cbbe0a8a53013d7b Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 28 Nov 2025 13:26:58 +0100 Subject: [PATCH 4/4] Add null checks --- .../ServiceCollectionExtensions.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs index 268ddb35b..70e56fbcc 100644 --- a/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -17,6 +17,9 @@ public void AddRestClient( ConfigureRestClient? configureRestClient = null, ConfigureSerialization? configureSerialization = null ) { + Ensure.NotEmptyString(name, nameof(name)); + Ensure.NotNull(services, nameof(services)); + var options = new RestClientOptions(); var configure = configureRestClient ?? (_ => { }); configure(options); @@ -69,20 +72,26 @@ public void AddRestClient( /// /// Custom options for the RestClient. [PublicAPI] - public void AddRestClient(RestClientOptions options) => services.AddRestClient(Constants.DefaultRestClient, o => o.CopyFrom(options)); - + public void AddRestClient(RestClientOptions options) { + Ensure.NotNull(options, nameof(options)); + services.AddRestClient(Constants.DefaultRestClient, o => o.CopyFrom(options)); + } + /// /// Adds a named RestClient to the service collection with base URL. /// /// Client name. /// The base URL for the RestClient. public void AddRestClient(string name, Uri baseUrl) => services.AddRestClient(name, o => o.BaseUrl = baseUrl); - + /// /// Adds a named RestClient to the service collection with custom options. /// /// Client name. /// Custom options for the RestClient. - public void AddRestClient(string name, RestClientOptions options) => services.AddRestClient(name, o => o.CopyFrom(options)); + public void AddRestClient(string name, RestClientOptions options) { + Ensure.NotNull(options, nameof(options)); + services.AddRestClient(name, o => o.CopyFrom(options)); + } } } \ No newline at end of file