From 54e6e1abf8194ca8a5be3d2b743492280146af10 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 12 Jan 2026 15:41:21 +0100 Subject: [PATCH 01/27] Update best practices --- BEST_PRACTICES.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/BEST_PRACTICES.md b/BEST_PRACTICES.md index aacf16e26..bc0a58802 100644 --- a/BEST_PRACTICES.md +++ b/BEST_PRACTICES.md @@ -197,4 +197,18 @@ Use this quick checklist before requesting review: - RestSharp.Serializers.CsvHelper - License: Apache-2.0 +## 17) Working with Issues + +- Use issues for bugs and feature requests only. For questions and support, use: + - StackOverflow with tag `restsharp` +- When creating an issue: + - Provide clear, concise description and reproduction steps. + - Include relevant code snippets or links to repro projects. + - Use appropriate labels and templates. +- When addressing an issue: + - Avoid changing the default behavior unless absolutely necessary. + - Avoid breaking the existing API. + - Leave the existing tests to catch possible regressions. + - Add new tests for the fixed cases. + Keep this document up-to-date when build properties, TFMs, CI workflows, or repository conventions change. \ No newline at end of file From ba170811c9aee3fc245d09ab740e2d34169860a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 12:41:31 +0100 Subject: [PATCH 02/27] Bump actions/upload-artifact from 5 to 6 (#2332) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pull-request.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index e4227b3d2..92807d7e3 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Upload - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: Event File path: ${{ github.event_path }} @@ -40,7 +40,7 @@ jobs: run: dotnet test -c Debug -f ${{ matrix.dotnet }} - name: Upload Test Results if: always() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: Test Results Windows ${{ matrix.dotnet }} path: | @@ -68,7 +68,7 @@ jobs: run: dotnet test -f ${{ matrix.dotnet }} - name: Upload Test Results if: always() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: Test Results Ubuntu ${{ matrix.dotnet }} path: | From 92adf4c1f320edbfdc7ea4999b83d059270f124a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 12:41:46 +0100 Subject: [PATCH 03/27] Bump dawidd6/action-download-artifact from 11 to 12 (#2335) Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 11 to 12. - [Release notes](https://github.com/dawidd6/action-download-artifact/releases) - [Commits](https://github.com/dawidd6/action-download-artifact/compare/ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5...0bd50d53a6d7fb5cb921e607957e9cc12b4ce392) --- updated-dependencies: - dependency-name: dawidd6/action-download-artifact dependency-version: '12' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test-results.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-results.yml b/.github/workflows/test-results.yml index 1c2672674..205421fe9 100644 --- a/.github/workflows/test-results.yml +++ b/.github/workflows/test-results.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Download and Extract Artifacts - uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 + uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 with: run_id: ${{ github.event.workflow_run.id }} path: artifacts From 13b0425ece77e997abc6c71745b92c46fbd237f4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 12:53:25 +0100 Subject: [PATCH 04/27] Fix OAuth1 double-encoding of RFC 3986 special characters in URL paths (#2341) * Initial plan * Fix OAuth1 double-encoding issue for special characters in path segments Co-authored-by: alexeyzimarev <2821205+alexeyzimarev@users.noreply.github.com> * Add detailed security comment for Uri.UnescapeDataString usage Co-authored-by: alexeyzimarev <2821205+alexeyzimarev@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexeyzimarev <2821205+alexeyzimarev@users.noreply.github.com> --- .../Authenticators/OAuth/OAuthTools.cs | 14 +++- .../Auth/OAuth1SignatureTests.cs | 71 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/RestSharp/Authenticators/OAuth/OAuthTools.cs b/src/RestSharp/Authenticators/OAuth/OAuthTools.cs index 1c4f7ab92..8b43f1dd0 100644 --- a/src/RestSharp/Authenticators/OAuth/OAuthTools.cs +++ b/src/RestSharp/Authenticators/OAuth/OAuthTools.cs @@ -154,7 +154,19 @@ static string ConstructRequestUrl(Uri url) { var secure = url is { Scheme: "https", Port: 443 }; var port = basic || secure ? "" : $":{url.Port}"; - return $"{url.Scheme}://{url.Host}{port}{url.AbsolutePath}"; + // Decode the path to avoid double-encoding when the path contains already-encoded characters. + // For example, if a URL segment was added with AddUrlSegment("id", "value!"), it gets encoded + // to "value%21" in the URL. When we extract url.AbsolutePath, it contains "%21" (encoded). + // If we then call UrlEncodeRelaxed on it, Uri.EscapeDataString would encode the "%" to "%25", + // resulting in "%2521" (double-encoded). By decoding first, we ensure proper single encoding. + // + // Security note: This is safe because: + // - The url parameter is a validated Uri object constructed by RestSharp's BuildUri() + // - The decoded path is immediately re-encoded by UrlEncodeRelaxed before use + // - There is no direct user input involved in this internal OAuth signature calculation + var decodedPath = Uri.UnescapeDataString(url.AbsolutePath); + + return $"{url.Scheme}://{url.Host}{port}{decodedPath}"; } /// diff --git a/test/RestSharp.Tests/Auth/OAuth1SignatureTests.cs b/test/RestSharp.Tests/Auth/OAuth1SignatureTests.cs index 782a7a121..30ed0ae71 100644 --- a/test/RestSharp.Tests/Auth/OAuth1SignatureTests.cs +++ b/test/RestSharp.Tests/Auth/OAuth1SignatureTests.cs @@ -49,4 +49,75 @@ public void Generates_correct_signature_base() { "POST&https%3A%2F%2Fapi.twitter.com%2F1.1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521" ); } + + [Fact] + public void Handles_path_with_exclamation_mark() { + // Test that a path segment with ! is encoded correctly in the signature base + var client = new RestClient("https://api.example.com"); + var request = new RestRequest("path/with!exclamation/resource", Method.Get); + + const string method = "GET"; + var url = client.BuildUri(request).ToString(); + var parameters = new WebPairCollection(); + + _workflow.RequestUrl = url; + var oauthParameters = _workflow.BuildProtectedResourceSignature(method, parameters); + + var signatureBase = OAuthTools.ConcatenateRequestElements(method, url, oauthParameters.Parameters); + + // The URL should be encoded with ! as %21 in the signature base + signatureBase.Should().Contain("path%2Fwith%21exclamation%2Fresource"); + } + + [Theory] + [InlineData("path/with!exclamation", "%21")] + [InlineData("path/with*asterisk", "%2A")] + [InlineData("path/with'apostrophe", "%27")] + [InlineData("path/with(paren", "%28")] + [InlineData("path/with)paren", "%29")] + public void Encodes_RFC3986_special_chars_in_path(string path, string encodedChar) { + // Test that RFC 3986 special characters are properly encoded in path segments + var client = new RestClient("https://api.example.com"); + var request = new RestRequest(path, Method.Get); + + const string method = "GET"; + var url = client.BuildUri(request).ToString(); + var parameters = new WebPairCollection(); + + _workflow.RequestUrl = url; + var oauthParameters = _workflow.BuildProtectedResourceSignature(method, parameters); + + var signatureBase = OAuthTools.ConcatenateRequestElements(method, url, oauthParameters.Parameters); + + // The URL should contain the encoded character in the signature base + signatureBase.Should().Contain(encodedChar); + } + + [Theory] + [InlineData("with!exclamation")] + [InlineData("with*asterisk")] + [InlineData("with'apostrophe")] + [InlineData("with(paren")] + [InlineData("with)paren")] + public void Handles_url_segment_with_RFC3986_special_chars(string segmentValue) { + // Test that URL segment parameters with RFC 3986 special characters don't get double-encoded + var client = new RestClient("https://api.example.com"); + var request = new RestRequest("path/{segment}/resource", Method.Get); + request.AddUrlSegment("segment", segmentValue); + + const string method = "GET"; + var url = client.BuildUri(request).ToString(); + var parameters = new WebPairCollection(); + + _workflow.RequestUrl = url; + var oauthParameters = _workflow.BuildProtectedResourceSignature(method, parameters); + + var signatureBase = OAuthTools.ConcatenateRequestElements(method, url, oauthParameters.Parameters); + + // The signature base should NOT contain double-encoded characters like %2521 (which is %25 + 21) + signatureBase.Should().NotContain("%25"); + + // But it should contain properly encoded special chars + signatureBase.Should().MatchRegex("%2[0-9A-F]"); + } } \ No newline at end of file From ea557db018015b1b97465cf2f44bbbdada2eafd5 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 6 Feb 2026 13:49:28 +0100 Subject: [PATCH 05/27] Fix pipe char encoding issue (#2345) * Fix #2207: Prevent Uri constructor from re-encoding pipe character when encode=false The issue was that when AddQueryParameter was called with encode=false, the pipe character (|) was still being encoded to %7C. This happened because the AddQueryString method was creating a new Uri object directly, and the Uri constructor automatically encodes certain characters including the pipe character. The fix uses UriBuilder instead, which preserves the query string as-is without re-encoding it. This ensures that when encode=false is specified, characters like pipe (|) remain unencoded as expected. Added test case to verify pipe character is not encoded when encode=false. * Fix issue #2207: Preserve unencoded characters in query parameters when encode=false - Modified BuildUriExtensions to add BuildUriString method that returns a string URI - Updated RestClient.Async to use string URI for HttpRequestMessage to preserve unencoded characters - Added RawUrl property to RequestBodyCapturer to capture the actual URL string sent over HTTP - Added integration test to verify pipe character is not encoded when encode=false - All existing tests pass * Fix pipe encoding issue * Remove usage of BuildUrl --- .../OAuth/OAuth1Authenticator.cs | 2 +- src/RestSharp/BuildUriExtensions.cs | 25 +++++++++++------- src/RestSharp/Request/UriExtensions.cs | 6 ++--- src/RestSharp/RestClient.Async.cs | 5 ++-- .../AuthenticationTests.cs | 3 +-- .../DefaultParameterTests.cs | 13 ++++++++++ .../Fixtures/RequestBodyCapturer.cs | 2 ++ .../Auth/OAuth1SignatureTests.cs | 2 +- test/RestSharp.Tests/Auth/OAuth1Tests.cs | 2 +- .../Parameters/UrlSegmentTests.cs | 26 ++++++++++--------- test/RestSharp.Tests/RestRequestTests.cs | 4 +-- test/RestSharp.Tests/UrlBuilderTests.Get.cs | 12 +++++++++ 12 files changed, 68 insertions(+), 34 deletions(-) diff --git a/src/RestSharp/Authenticators/OAuth/OAuth1Authenticator.cs b/src/RestSharp/Authenticators/OAuth/OAuth1Authenticator.cs index f69f5ff39..56906120f 100644 --- a/src/RestSharp/Authenticators/OAuth/OAuth1Authenticator.cs +++ b/src/RestSharp/Authenticators/OAuth/OAuth1Authenticator.cs @@ -245,7 +245,7 @@ internal static void AddOAuthData( "Using query parameters in the base URL is not supported for OAuth calls. Consider using AddDefaultQueryParameter instead." ); - var url = client.BuildUri(request).ToString(); + var url = client.BuildUriString(request); var queryStringStart = url.IndexOf('?'); if (queryStringStart != -1) url = url[..queryStringStart]; diff --git a/src/RestSharp/BuildUriExtensions.cs b/src/RestSharp/BuildUriExtensions.cs index 6bf976244..8fb95563e 100644 --- a/src/RestSharp/BuildUriExtensions.cs +++ b/src/RestSharp/BuildUriExtensions.cs @@ -23,18 +23,23 @@ public static class BuildUriExtensions { /// /// Request instance /// - public Uri BuildUri(RestRequest request) { - DoBuildUriValidations(client, request); + public Uri BuildUri(RestRequest request) => new(client.BuildUriString(request)); - var (uri, resource) = client.Options.BaseUrl.GetUrlSegmentParamsValues( - request.Resource, - client.Options.Encode, - request.Parameters, - client.DefaultParameters - ); - var mergedUri = uri.MergeBaseUrlAndResource(resource); + /// + /// Builds the URI string for the request. This method returns a string instead of a Uri object + /// to preserve unencoded characters when encode=false is specified for query parameters. + /// + /// Request instance + /// + [PublicAPI] + public string BuildUriString(RestRequest request) { + var mergedUri = client.BuildUriWithoutQueryParameters(request); var query = client.GetRequestQuery(request); - return mergedUri.AddQueryString(query); + + if (query == null) return mergedUri.AbsoluteUri; + + var separator = mergedUri.AbsoluteUri.Contains('?') ? "&" : "?"; + return $"{mergedUri.AbsoluteUri}{separator}{query}"; } /// diff --git a/src/RestSharp/Request/UriExtensions.cs b/src/RestSharp/Request/UriExtensions.cs index 9cb26ebe8..bc217dff6 100644 --- a/src/RestSharp/Request/UriExtensions.cs +++ b/src/RestSharp/Request/UriExtensions.cs @@ -41,10 +41,10 @@ public static Uri MergeBaseUrlAndResource(this Uri? baseUrl, string? resource) { public static Uri AddQueryString(this Uri uri, string? query) { if (query == null) return uri; - var absoluteUri = uri.AbsoluteUri; - var separator = absoluteUri.Contains('?') ? "&" : "?"; + var builder = new UriBuilder(uri); + builder.Query = builder.Query.Length > 1 ? $"{builder.Query[1..]}&{query}" : query; - return new($"{absoluteUri}{separator}{query}"); + return builder.Uri; } public static UrlSegmentParamsValues GetUrlSegmentParamsValues( diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 75c8ac85f..df4558795 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -111,9 +111,10 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo using var requestContent = new RequestContent(this, request); var httpMethod = AsHttpMethod(request.Method); - var url = this.BuildUri(request); + var urlString = this.BuildUriString(request); + var url = new Uri(urlString); - using var message = new HttpRequestMessage(httpMethod, url); + using var message = new HttpRequestMessage(httpMethod, urlString); message.Content = requestContent.BuildContent(); message.Headers.Host = Options.BaseHost; message.Headers.CacheControl = request.CachePolicy ?? Options.CachePolicy; diff --git a/test/RestSharp.InteractiveTests/AuthenticationTests.cs b/test/RestSharp.InteractiveTests/AuthenticationTests.cs index 7e5d81173..72754b1a8 100644 --- a/test/RestSharp.InteractiveTests/AuthenticationTests.cs +++ b/test/RestSharp.InteractiveTests/AuthenticationTests.cs @@ -39,8 +39,7 @@ public static async Task Can_Authenticate_With_OAuth_Async_With_Callback(Twitter request = new($"oauth/authorize?oauth_token={oauthToken}"); - var url = client.BuildUri(request) - .ToString(); + var url = client.BuildUriString(request); Console.WriteLine($"Open this URL in the browser: {url} and complete the authentication."); Console.Write("Enter the verifier: "); diff --git a/test/RestSharp.Tests.Integrated/DefaultParameterTests.cs b/test/RestSharp.Tests.Integrated/DefaultParameterTests.cs index f61c5a55d..c2038f6a3 100644 --- a/test/RestSharp.Tests.Integrated/DefaultParameterTests.cs +++ b/test/RestSharp.Tests.Integrated/DefaultParameterTests.cs @@ -35,4 +35,17 @@ public async Task Should_not_throw_exception_when_name_is_null() { await client.ExecuteAsync(request); } + + [Fact] + public async Task Should_not_encode_pipe_character_when_encode_is_false() { + using var client = new RestClient(server.Url!); + + var request = new RestRequest("capture"); + request.AddQueryParameter("ids", "in:001|116", false); + + await client.ExecuteAsync(request); + + var query = _capturer.RawUrl.Split('?')[1]; + query.Should().Contain("ids=in:001|116"); + } } \ No newline at end of file diff --git a/test/RestSharp.Tests.Shared/Fixtures/RequestBodyCapturer.cs b/test/RestSharp.Tests.Shared/Fixtures/RequestBodyCapturer.cs index cc9ead4fb..cca83ff9c 100644 --- a/test/RestSharp.Tests.Shared/Fixtures/RequestBodyCapturer.cs +++ b/test/RestSharp.Tests.Shared/Fixtures/RequestBodyCapturer.cs @@ -7,6 +7,7 @@ public class RequestBodyCapturer { public bool HasBody { get; private set; } public string Body { get; private set; } public Uri Url { get; private set; } + public string RawUrl { get; private set; } public bool CaptureBody(string content) { Body = content; @@ -23,6 +24,7 @@ public bool CaptureHeaders(IDictionary headers) { } public bool CaptureUrl(string url) { + RawUrl = url; Url = new(url); return true; } diff --git a/test/RestSharp.Tests/Auth/OAuth1SignatureTests.cs b/test/RestSharp.Tests/Auth/OAuth1SignatureTests.cs index 30ed0ae71..ee018dc5c 100644 --- a/test/RestSharp.Tests/Auth/OAuth1SignatureTests.cs +++ b/test/RestSharp.Tests/Auth/OAuth1SignatureTests.cs @@ -37,7 +37,7 @@ public void Generates_correct_signature_base() { var requestParameters = _request.Parameters.ToWebParameters().ToArray(); var parameters = new WebPairCollection(); parameters.AddRange(requestParameters); - var url = _client.BuildUri(_request).ToString(); + var url = _client.BuildUriString(_request); _workflow.RequestUrl = url; var oauthParameters = _workflow.BuildProtectedResourceSignature(method, parameters); oauthParameters.Parameters.AddRange(requestParameters); diff --git a/test/RestSharp.Tests/Auth/OAuth1Tests.cs b/test/RestSharp.Tests/Auth/OAuth1Tests.cs index 483a89771..8f4356cf6 100644 --- a/test/RestSharp.Tests/Auth/OAuth1Tests.cs +++ b/test/RestSharp.Tests/Auth/OAuth1Tests.cs @@ -46,7 +46,7 @@ public async Task Can_Authenticate_OAuth1_With_Querystring_Parameters() { authenticator.ParameterHandling = OAuthParameterHandling.UrlOrPostParameters; await authenticator.Authenticate(client, request); - var requestUri = client.BuildUri(request); + var requestUri = new Uri(client.BuildUriString(request)); var actual = requestUri.ParseQuery().Select(x => x.Key).ToList(); actual.Should().BeEquivalentTo(expected); diff --git a/test/RestSharp.Tests/Parameters/UrlSegmentTests.cs b/test/RestSharp.Tests/Parameters/UrlSegmentTests.cs index 5282dc57c..d7358a333 100644 --- a/test/RestSharp.Tests/Parameters/UrlSegmentTests.cs +++ b/test/RestSharp.Tests/Parameters/UrlSegmentTests.cs @@ -1,7 +1,8 @@ namespace RestSharp.Tests.Parameters; public class UrlSegmentTests { - const string BaseUrl = "http://localhost:8888/"; + const string BaseUrlNoTrail = "http://localhost:8888"; + const string BaseUrl = $"{BaseUrlNoTrail}/"; [Fact] public void AddUrlSegmentWithInt() { @@ -22,10 +23,10 @@ public void AddUrlSegmentModifiesUrlSegmentWithInt() { var path = string.Format(pathTemplate, $"{{{name}}}"); var request = new RestRequest(path).AddUrlSegment(name, urlSegmentValue); - var expected = string.Format(pathTemplate, urlSegmentValue); + var expected = $"{BaseUrlNoTrail}{string.Format(pathTemplate, urlSegmentValue)}"; using var client = new RestClient(BaseUrl); - var actual = client.BuildUri(request).AbsolutePath; + var actual = client.BuildUriString(request); expected.Should().BeEquivalentTo(actual); } @@ -38,11 +39,11 @@ public void AddUrlSegmentModifiesUrlSegmentWithString() { var path = string.Format(pathTemplate, $"{{{name}}}"); var request = new RestRequest(path).AddUrlSegment(name, urlSegmentValue); - var expected = string.Format(pathTemplate, urlSegmentValue); + var expected = $"{BaseUrlNoTrail}{string.Format(pathTemplate, urlSegmentValue)}"; using var client = new RestClient(BaseUrl); - var actual = client.BuildUri(request).AbsolutePath; + var actual = client.BuildUriString(request); expected.Should().BeEquivalentTo(actual); } @@ -73,14 +74,15 @@ public void UrlSegmentParameter_WithValueWithEncodedSlash_CanLeaveEncodedSlash(s [Fact] public void AddSameUrlSegmentTwice_ShouldReplaceFirst() { - var client = new RestClient(); - var request = new RestRequest("https://api.example.com/orgs/{segment}/something"); + const string host = "https://api.example.com"; + var client = new RestClient(); + var request = new RestRequest($"{host}/orgs/{{segment}}/something"); request.AddUrlSegment("segment", 1); - var url1 = client.BuildUri(request); + var url1 = client.BuildUriString(request); request.AddUrlSegment("segment", 2); - var url2 = client.BuildUri(request); - - url1.AbsolutePath.Should().Be("/orgs/1/something"); - url2.AbsolutePath.Should().Be("/orgs/2/something"); + var url2 = client.BuildUriString(request); + + url1.Should().Be($"{host}/orgs/1/something"); + url2.Should().Be($"{host}/orgs/2/something"); } } \ No newline at end of file diff --git a/test/RestSharp.Tests/RestRequestTests.cs b/test/RestSharp.Tests/RestRequestTests.cs index 988f91734..dc2053c09 100644 --- a/test/RestSharp.Tests/RestRequestTests.cs +++ b/test/RestSharp.Tests/RestRequestTests.cs @@ -26,8 +26,8 @@ public void RestRequest_Test_Already_Encoded() { parameters.Should().BeEquivalentTo(expected, options => options.ExcludingMissingMembers()); using var client = new RestClient(baseUrl); - var actual = client.BuildUri(request); - actual.AbsoluteUri.Should().Be($"{baseUrl}{resource}"); + var actual = client.BuildUriString(request); + actual.Should().Be($"{baseUrl}{resource}"); } [Fact] diff --git a/test/RestSharp.Tests/UrlBuilderTests.Get.cs b/test/RestSharp.Tests/UrlBuilderTests.Get.cs index 85c04b35e..4a2909697 100644 --- a/test/RestSharp.Tests/UrlBuilderTests.Get.cs +++ b/test/RestSharp.Tests/UrlBuilderTests.Get.cs @@ -60,6 +60,18 @@ public void GET_with_empty_request_and_query_parameters_without_encoding() { Assert.Equal(expected, output); } + [Fact] + public void GET_with_pipe_character_in_query_parameter_without_encoding() { + var request = new RestRequest(); + request.AddQueryParameter("ids", "in:001|116", false); + const string expected = $"{Base}/{Resource}?ids=in:001|116"; + + using var client = new RestClient($"{Base}/{Resource}"); + + var output = client.BuildUriString(request); + Assert.Equal(expected, output); + } + [Fact] public void GET_with_Invalid_Url_string_throws_exception() => Assert.Throws(() => { _ = new RestClient("invalid url"); } From f2b1fd1fe4d9c8c7f45245a134a99a3f7f4aba4f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 14:27:01 +0100 Subject: [PATCH 06/27] Bump dawidd6/action-download-artifact from 12 to 14 (#2347) Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 12 to 14. - [Release notes](https://github.com/dawidd6/action-download-artifact/releases) - [Commits](https://github.com/dawidd6/action-download-artifact/compare/0bd50d53a6d7fb5cb921e607957e9cc12b4ce392...5c98f0b039f36ef966fdb7dfa9779262785ecb05) --- updated-dependencies: - dependency-name: dawidd6/action-download-artifact dependency-version: '14' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test-results.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-results.yml b/.github/workflows/test-results.yml index 205421fe9..660e44fcf 100644 --- a/.github/workflows/test-results.yml +++ b/.github/workflows/test-results.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Download and Extract Artifacts - uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 + uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 with: run_id: ${{ github.event.workflow_run.id }} path: artifacts From 991130b00a021da3b54c7617cb87c4b17073aac4 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 26 Feb 2026 13:14:47 +0100 Subject: [PATCH 07/27] Fix default parameter merging bugs, add MergedParameters to RestResponse (#2349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix default parameters missing from RestResponse.Request.Parameters (#2282) Merge default parameters into RestRequest.Parameters early in ExecuteRequestAsync so they are visible via response.Request.Parameters. Remove the now-redundant separate merges in RequestContent, BuildUriExtensions, RequestHeaders, and OAuth1Authenticator. Co-Authored-By: Claude Opus 4.6 * Add other files * Fix default parameter merging bugs introduced by #2282 Revert per-site merging to restore original design and fix three bugs: 1. Multi-value dedup — same-name defaults (AllowMultipleDefaultParametersWithSameName) were silently dropped 2. Public API breakage — BuildUriString/GetRequestQuery didn't include defaults when called outside ExecuteAsync 3. Request mutation — stale defaults persisted on reused requests when DefaultParameters changed Add MergedParameters property on RestResponse to satisfy the original #2282 requirement of making default parameters visible after execution. Co-Authored-By: Claude Opus 4.6 * Fix CI: run dotnet test on individual test projects The addition of RestSharp.slnx alongside RestSharp.sln causes MSB1011 ("more than one project or solution file") when running bare dotnet test. Run each test project explicitly instead. Co-Authored-By: Claude Opus 4.6 * Make MergedParameters non-null with internal setter - Initialize to empty RequestParameters() so consumers never need null checks - Restrict setter to internal to prevent external mutation of response state Co-Authored-By: Claude Opus 4.6 * Document MergedParameters property in response docs Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/pull-request.yml | 16 +++- CLAUDE.md | 2 + RestSharp.slnx | 65 +++++++++++++++++ docs/docs/usage/response.md | 13 ++++ src/RestSharp/Response/RestResponseBase.cs | 6 ++ src/RestSharp/RestClient.Async.cs | 1 + .../DefaultParameterTests.cs | 73 ++++++++++++++++++- .../HttpHeadersTests.cs | 19 +++++ 8 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 CLAUDE.md create mode 100644 RestSharp.slnx diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 92807d7e3..12eb9b98d 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -37,7 +37,13 @@ jobs: 9.0.x 10.0.x - name: Run tests - run: dotnet test -c Debug -f ${{ matrix.dotnet }} + run: | + dotnet test test/RestSharp.Tests -c Debug -f ${{ matrix.dotnet }} + dotnet test test/RestSharp.Tests.Integrated -c Debug -f ${{ matrix.dotnet }} + dotnet test test/RestSharp.Tests.Serializers.Json -c Debug -f ${{ matrix.dotnet }} + dotnet test test/RestSharp.Tests.Serializers.Xml -c Debug -f ${{ matrix.dotnet }} + dotnet test test/RestSharp.Tests.Serializers.Csv -c Debug -f ${{ matrix.dotnet }} + dotnet test test/RestSharp.Tests.DependencyInjection -c Debug -f ${{ matrix.dotnet }} - name: Upload Test Results if: always() uses: actions/upload-artifact@v6 @@ -65,7 +71,13 @@ jobs: 9.0.x 10.0.x - name: Run tests - run: dotnet test -f ${{ matrix.dotnet }} + run: | + dotnet test test/RestSharp.Tests -f ${{ matrix.dotnet }} + dotnet test test/RestSharp.Tests.Integrated -f ${{ matrix.dotnet }} + dotnet test test/RestSharp.Tests.Serializers.Json -f ${{ matrix.dotnet }} + dotnet test test/RestSharp.Tests.Serializers.Xml -f ${{ matrix.dotnet }} + dotnet test test/RestSharp.Tests.Serializers.Csv -f ${{ matrix.dotnet }} + dotnet test test/RestSharp.Tests.DependencyInjection -f ${{ matrix.dotnet }} - name: Upload Test Results if: always() uses: actions/upload-artifact@v6 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..615e58179 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ +## MCP Servers Available +- mem0: Use this AI memory for storing and retrieving long-term context as well as short-term context \ No newline at end of file diff --git a/RestSharp.slnx b/RestSharp.slnx new file mode 100644 index 000000000..d06f32574 --- /dev/null +++ b/RestSharp.slnx @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/docs/usage/response.md b/docs/docs/usage/response.md index dbaf302b4..43745ec39 100644 --- a/docs/docs/usage/response.md +++ b/docs/docs/usage/response.md @@ -28,6 +28,19 @@ Response object contains the following properties: | `ErrorException` | `Exception?` | Exception thrown when executing the request, if any. | | `Version` | `Version?` | HTTP protocol version of the request. | | `RootElement` | `string?` | Root element of the serialized response content, only works if deserializer supports it. | +| `MergedParameters` | `ParametersCollection` | Combined view of request parameters and client default parameters at execution time. | + +### Merged parameters + +The `MergedParameters` property provides a combined view of the request's own parameters and the client's [default parameters](request.md#request-headers) as they were at execution time. This is useful for logging or debugging the full set of parameters that were applied to a request, since `Request.Parameters` only contains the parameters added directly to the request. + +```csharp +var response = await client.ExecuteAsync(request); + +foreach (var param in response.MergedParameters) { + Console.WriteLine($"{param.Name} = {param.Value} ({param.Type})"); +} +``` In addition, `RestResponse` has one additional property: diff --git a/src/RestSharp/Response/RestResponseBase.cs b/src/RestSharp/Response/RestResponseBase.cs index 383895fb4..b54ed4e17 100644 --- a/src/RestSharp/Response/RestResponseBase.cs +++ b/src/RestSharp/Response/RestResponseBase.cs @@ -132,6 +132,12 @@ protected RestResponseBase(RestRequest request) { /// public Version? Version { get; set; } + /// + /// Combined view of request parameters and client default parameters as they were at execution time. + /// Use this to inspect the full set of parameters that were applied to the request. + /// + public ParametersCollection MergedParameters { get; internal set; } = new RequestParameters(); + /// /// Root element of the serialized response content, only works if deserializer supports it /// diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index df4558795..a6142d07a 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -36,6 +36,7 @@ public async Task ExecuteAsync(RestRequest request, CancellationTo ) .ConfigureAwait(false) : GetErrorResponse(request, internalResponse.Exception, internalResponse.TimeoutToken); + response.MergedParameters = new RequestParameters(request.Parameters.Union(DefaultParameters)); await OnAfterRequest(response, cancellationToken).ConfigureAwait(false); return Options.ThrowOnAnyError ? response.ThrowIfError() : response; diff --git a/test/RestSharp.Tests.Integrated/DefaultParameterTests.cs b/test/RestSharp.Tests.Integrated/DefaultParameterTests.cs index c2038f6a3..da058b238 100644 --- a/test/RestSharp.Tests.Integrated/DefaultParameterTests.cs +++ b/test/RestSharp.Tests.Integrated/DefaultParameterTests.cs @@ -4,7 +4,8 @@ namespace RestSharp.Tests.Integrated; public sealed class DefaultParameterTests(WireMockTestServer server) : IClassFixture { - readonly RequestBodyCapturer _capturer = server.ConfigureBodyCapturer(Method.Get, false); + readonly RequestBodyCapturer _capturer = server.ConfigureBodyCapturer(Method.Get, false); + readonly RequestBodyCapturer _capturerOnPath = server.ConfigureBodyCapturer(Method.Get); [Fact] public async Task Should_add_default_and_request_query_get_parameters() { @@ -48,4 +49,74 @@ public async Task Should_not_encode_pipe_character_when_encode_is_false() { var query = _capturer.RawUrl.Split('?')[1]; query.Should().Contain("ids=in:001|116"); } + + [Fact] + public async Task Should_include_multiple_default_query_params_with_same_name() { + using var client = new RestClient( + new RestClientOptions(server.Url!) { AllowMultipleDefaultParametersWithSameName = true } + ); + client.AddDefaultParameter("filter", "active", ParameterType.QueryString); + client.AddDefaultParameter("filter", "verified", ParameterType.QueryString); + + var request = new RestRequest("capture"); + await client.GetAsync(request); + + var query = _capturerOnPath.Url!.Query; + query.Should().Contain("filter=active"); + query.Should().Contain("filter=verified"); + } + + [Fact] + public async Task Should_include_default_query_params_in_BuildUriString_without_executing() { + using var client = new RestClient(server.Url!); + client.AddDefaultParameter("foo", "bar", ParameterType.QueryString); + + var request = new RestRequest("resource"); + var uri = client.BuildUriString(request); + + uri.Should().Contain("foo=bar"); + } + + [Fact] + public async Task Should_not_permanently_mutate_request_parameters_after_execute() { + using var client = new RestClient(server.Url!); + client.AddDefaultParameter("default_key", "default_val", ParameterType.QueryString); + + var request = new RestRequest("capture"); + var paramsBefore = request.Parameters.Count; + + await client.GetAsync(request); + + // Request parameters should not have been mutated by the execution. + request.Parameters.Count.Should().Be(paramsBefore); + + // Now replace the default parameter with a different value. + client.DefaultParameters.ReplaceParameter(new QueryParameter("default_key", "updated_val")); + + await client.GetAsync(request); + + // The second execution should use the updated default value, not the stale one. + var query = _capturerOnPath.Url!.Query; + query.Should().Contain("default_key=updated_val"); + query.Should().NotContain("default_key=default_val"); + } + + [Fact] + public async Task Should_include_default_params_in_merged_parameters_on_response() { + using var client = new RestClient(server.Url!); + client.AddDefaultParameter("default_key", "default_val", ParameterType.QueryString); + + var request = new RestRequest("capture").AddQueryParameter("req_key", "req_val"); + var response = await client.ExecuteAsync(request); + + var defaultParam = response.MergedParameters + .FirstOrDefault(p => p.Name == "default_key" && p.Type == ParameterType.QueryString); + defaultParam.Should().NotBeNull(); + defaultParam!.Value.Should().Be("default_val"); + + var requestParam = response.MergedParameters + .FirstOrDefault(p => p.Name == "req_key" && p.Type == ParameterType.QueryString); + requestParam.Should().NotBeNull(); + requestParam!.Value.Should().Be("req_val"); + } } \ No newline at end of file diff --git a/test/RestSharp.Tests.Integrated/HttpHeadersTests.cs b/test/RestSharp.Tests.Integrated/HttpHeadersTests.cs index 559c6435a..2a0a60084 100644 --- a/test/RestSharp.Tests.Integrated/HttpHeadersTests.cs +++ b/test/RestSharp.Tests.Integrated/HttpHeadersTests.cs @@ -68,6 +68,25 @@ public async Task Should_sent_custom_UserAgent() { response.GetHeaderValue("Server").Should().Be("Kestrel"); } + [Fact] + public async Task Default_headers_should_appear_in_response_merged_parameters() { + const string headerName = "X-Custom-Default"; + const string headerValue = "DefaultValue123"; + + _client.AddDefaultHeader(headerName, headerValue); + + var request = new RestRequest("/headers"); + var response = await _client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var param = response.MergedParameters + .FirstOrDefault(p => p.Name == headerName && p.Type == ParameterType.HttpHeader); + + param.Should().NotBeNull(); + param!.Value.Should().Be(headerValue); + } + static void CheckHeader(RestResponse response, Header header) { var h = FindHeader(response, header.Name); h.Should().NotBeNull(); From 5c5a6b1adda1b037793841bd1c0506074743cdad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:30:37 +0100 Subject: [PATCH 08/27] Bump dawidd6/action-download-artifact from 14 to 15 (#2348) Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 14 to 15. - [Release notes](https://github.com/dawidd6/action-download-artifact/releases) - [Commits](https://github.com/dawidd6/action-download-artifact/compare/5c98f0b039f36ef966fdb7dfa9779262785ecb05...fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8) --- updated-dependencies: - dependency-name: dawidd6/action-download-artifact dependency-version: '15' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test-results.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-results.yml b/.github/workflows/test-results.yml index 660e44fcf..3d8776868 100644 --- a/.github/workflows/test-results.yml +++ b/.github/workflows/test-results.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Download and Extract Artifacts - uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 + uses: dawidd6/action-download-artifact@fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8 with: run_id: ${{ github.event.workflow_run.id }} path: artifacts From eb9e3d1fe9b9097e249f83ed0e218f8198309700 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 26 Feb 2026 13:50:31 +0100 Subject: [PATCH 09/27] Fix ResponseUri returning original URL instead of redirect target when FollowRedirects=false (#2346) (#2350) When FollowRedirects is disabled and the server returns a 3xx redirect, ResponseUri now returns the Location header value (the redirect target) instead of the original request URL. Non-redirect responses are unaffected. Co-authored-by: Claude Opus 4.6 --- src/RestSharp/Response/RestResponse.cs | 15 ++++++++++++++- .../RedirectTests.cs | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/RestSharp/Response/RestResponse.cs b/src/RestSharp/Response/RestResponse.cs index ef08e6bd3..92c3f0e3b 100644 --- a/src/RestSharp/Response/RestResponse.cs +++ b/src/RestSharp/Response/RestResponse.cs @@ -68,7 +68,7 @@ async Task GetDefaultResponse() { IsSuccessStatusCode = httpResponse.IsSuccessStatusCode, RawBytes = bytes, ResponseStatus = options.CalculateResponseStatus(httpResponse), - ResponseUri = httpResponse.RequestMessage?.RequestUri, + ResponseUri = GetResponseUri(httpResponse), RootElement = request.RootElement, Server = httpResponse.Headers.Server.ToString(), StatusCode = httpResponse.StatusCode, @@ -79,6 +79,19 @@ async Task GetDefaultResponse() { } public RestResponse() : this(new()) { } + + static Uri? GetResponseUri(HttpResponseMessage httpResponse) { + var requestUri = httpResponse.RequestMessage?.RequestUri; + + if ((int)httpResponse.StatusCode is >= 300 and < 400 + && httpResponse.Headers.Location is { } location) { + return location.IsAbsoluteUri || requestUri == null + ? location + : new Uri(requestUri, location); + } + + return requestUri; + } } public delegate ResponseStatus CalculateResponseStatus(HttpResponseMessage httpResponse); \ No newline at end of file diff --git a/test/RestSharp.Tests.Integrated/RedirectTests.cs b/test/RestSharp.Tests.Integrated/RedirectTests.cs index ec33a84cc..970c567cd 100644 --- a/test/RestSharp.Tests.Integrated/RedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/RedirectTests.cs @@ -14,5 +14,24 @@ public async Task Can_Perform_GET_Async_With_Redirect() { response.Data!.Message.Should().Be(val); } + [Fact] + public async Task ResponseUri_Should_Be_Final_Url_When_FollowRedirects_True() { + var request = new RestRequest("redirect"); + var response = await _client.ExecuteAsync(request); + + response.ResponseUri.Should().Be(new Uri(new Uri(server.Url!), "success")); + } + + [Fact] + public async Task ResponseUri_Should_Be_Redirect_Target_When_FollowRedirects_False() { + using var client = new RestClient(new RestClientOptions(server.Url!) { FollowRedirects = false }); + + var request = new RestRequest("redirect"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.ResponseUri.Should().Be(new Uri(new Uri(server.Url!), "success")); + } + public void Dispose() => _client.Dispose(); } \ No newline at end of file From 3831d5237fd614cf0e57e64e113a180714448200 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:16:23 +0100 Subject: [PATCH 10/27] Document Timeout property behavior for RestClientOptions and RestRequest (#2331) * Initial plan * Document Timeout behavior for RestClientOptions and RestRequest Co-authored-by: alexeyzimarev <2821205+alexeyzimarev@users.noreply.github.com> * Improve timeout documentation with dedicated section Co-authored-by: alexeyzimarev <2821205+alexeyzimarev@users.noreply.github.com> * Add migration note about MaxTimeout to Timeout property Co-authored-by: alexeyzimarev <2821205+alexeyzimarev@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexeyzimarev <2821205+alexeyzimarev@users.noreply.github.com> Co-authored-by: Alexey Zimarev --- docs/docs/advanced/configuration.md | 51 +++++++++++++++++++++- src/RestSharp/Options/RestClientOptions.cs | 12 ++++- src/RestSharp/Request/RestRequest.cs | 10 ++++- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/docs/docs/advanced/configuration.md b/docs/docs/advanced/configuration.md index b57638632..69b9fa875 100644 --- a/docs/docs/advanced/configuration.md +++ b/docs/docs/advanced/configuration.md @@ -172,7 +172,7 @@ RestSharp allows configuring `RestClient` using client options, as mentioned at | `RemoteCertificateValidationCallback` | Custom function to validate the server certificate. Normally, it's used when the server uses a certificate that isn't trusted by default. | | `BaseHost` | Value for the `Host` header sent with each request. | | `CookieContainer` | Custom cookie container that will be shared among all calls made by the client. Normally not required as RestSharp handles cookies without using a client-level cookie container. | -| `MaxTimeout` | Client-level timeout in milliseconds. If the request timeout is also set, this value isn't used. | +| `Timeout` | Client-level timeout as `TimeSpan`. Default is 100 seconds. See [Configuring Timeouts](#configuring-timeouts) for details on timeout behavior. | | `Encoding` | Default request encoding. Override it only if you don't use UTF-8. | | `ThrowOnDeserializationError` | Forces the client to throw if it fails to deserialize the response. Remember that not all deserialization issues forces the serializer to throw. Default is `false`, so the client will return a `RestResponse` with deserialization exception details. Only relevant for `Execute...` functions. | | `FailOnDeserializationError` | When set to `true`, if the client fails to deserialize the response, the response object will have status `Failed`, although the HTTP calls might have been successful. Default is `true`. | @@ -213,7 +213,7 @@ Client options apply to all requests made by the client. Sometimes, you want to | `Authenticator` | Overrides the client-level authenticator. | | `Files` | Collection of file parameters, read-only. Use `AddFile` for adding files to the request. | | `Method` | Request HTTP method, default is `GET`. Only needed when using `Execute` or `ExecuteAsync` as other functions like `ExecutePostAsync` will override the request method. | -| `TImeout` | Overrides the client-level timeout. | +| `Timeout` | Request-level timeout override. See [Configuring Timeouts](#configuring-timeouts) for details on timeout behavior. | | `Resource` | Resource part of the remote endpoint URL. For example, when using the client-level base URL `https://localhost:5000/api` and `Resource` set to `weather`, the request will be sent to `https://localhost:5000/api/weather`. It can container resource placeholders to be used in combination with `AddUrlSegment` | | `RequestFormat` | Identifies the request as JSON, XML, binary, or none. Rarely used because the client will set the request format based on the body type if functions like `AddJsonBody` or `AddXmlBody` are used. | | `RootElement` | Used by the default deserializers to determine where to start deserializing from. Only supported for XML responses. Does not apply to requests. | @@ -228,3 +228,50 @@ Client options apply to all requests made by the client. Sometimes, you want to | `Interceptors` | Allows adding interceptors to the request. Both client-level and request-level interceptors will be called. | The table below contains all configuration properties of `RestRequest`. To learn more about adding request parameters, check the [usage page](../usage/request.md) page about creating requests with parameters. + +## Configuring Timeouts + +RestSharp provides flexible timeout configuration at both the client and request levels. The timeout determines how long RestSharp will wait for a response before canceling the request. + +:::note Migration from MaxTimeout +In older versions of RestSharp, the `MaxTimeout` property (measured in milliseconds) was used. This has been replaced by the `Timeout` property, which uses `TimeSpan` for more intuitive and type-safe timeout configuration. +::: + +### Timeout Resolution + +When making a request, RestSharp uses the following priority order to determine the timeout: +1. `RestRequest.Timeout` (if set) +2. `RestClientOptions.Timeout` (if set) +3. Default timeout of 100 seconds + +### Timeout Values + +The `Timeout` property accepts a `TimeSpan?` value and supports the following behaviors: + +| Value | Behavior | +|-------|----------| +| `null` (not set) | Uses the next level timeout (client timeout, then default 100 seconds) | +| Positive `TimeSpan` | Request times out after the specified duration (e.g., `TimeSpan.FromSeconds(30)`) | +| `Timeout.InfiniteTimeSpan` or `TimeSpan.FromMilliseconds(-1)` | No timeout - request will wait indefinitely for a response | +| `TimeSpan.Zero` | Request is canceled immediately (effectively no time allowed for the request) | +| Other negative values | Throws `ArgumentOutOfRangeException` when the request is executed | + +### Examples + +```csharp +// Client-level timeout of 30 seconds +var options = new RestClientOptions("https://api.example.com") { + Timeout = TimeSpan.FromSeconds(30) +}; +var client = new RestClient(options); + +// Request-level timeout override +var request = new RestRequest("resource") { + Timeout = TimeSpan.FromSeconds(60) // This request gets 60 seconds +}; + +// Infinite timeout (no timeout) +var longRunningRequest = new RestRequest("long-operation") { + Timeout = Timeout.InfiniteTimeSpan +}; +``` diff --git a/src/RestSharp/Options/RestClientOptions.cs b/src/RestSharp/Options/RestClientOptions.cs index d30e34f92..8d279d736 100644 --- a/src/RestSharp/Options/RestClientOptions.cs +++ b/src/RestSharp/Options/RestClientOptions.cs @@ -183,8 +183,16 @@ public RestClientOptions(string baseUrl) : this(new Uri(Ensure.NotEmptyString(ba public CookieContainer? CookieContainer { get; set; } /// - /// Request duration. Used when the request timeout is not specified using , - /// + /// Request timeout duration. Used when the request timeout is not specified using . + /// If not set, the default timeout is 100 seconds. + /// + /// + /// + /// Set to (or TimeSpan.FromMilliseconds(-1)) for no timeout + /// Set to to cancel the request immediately + /// Negative values (other than -1 millisecond) will throw + /// + /// public TimeSpan? Timeout { get; set; } /// diff --git a/src/RestSharp/Request/RestRequest.cs b/src/RestSharp/Request/RestRequest.cs index 786f11304..5abec55c8 100644 --- a/src/RestSharp/Request/RestRequest.cs +++ b/src/RestSharp/Request/RestRequest.cs @@ -131,8 +131,16 @@ public RestRequest(Uri resource, Method method = Method.Get) public Method Method { get; set; } /// - /// Custom request timeout + /// Custom request timeout. Overrides if set. + /// If not set, uses the client-level timeout or the default of 100 seconds. /// + /// + /// + /// Set to (or TimeSpan.FromMilliseconds(-1)) for no timeout + /// Set to to cancel the request immediately + /// Negative values (other than -1 millisecond) will throw + /// + /// public TimeSpan? Timeout { get; set; } /// From 4511fab1e29c2d8c060bb7297563c3a898da15fb Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 26 Feb 2026 16:04:41 +0100 Subject: [PATCH 11/27] Restore 2-param AddCookie(name, value) overload (#2351) * Restore 2-param AddCookie(name, value) overload PR #1966 replaced the simple AddCookie(name, value) with a 4-param overload requiring domain upfront, breaking the public API. This restores the 2-param form by deferring domain resolution to execution time using CookieContainer.Add(Uri, Cookie). - Add _cookies pending list and PendingCookies accessor to RestRequest - Add AddCookie(name, value) extension that stores cookies for deferred resolution alongside the existing 4-param AddCookie overload - Resolve pending cookies at execution time in RestClient.Async.cs using the request URL to infer domain - Update cookie documentation across all doc versions to show both forms - Fix Cookes typo in docs Closes #2284 Co-Authored-By: Claude Opus 4.6 * Address code review feedback for AddCookie pending cookies - Wrap cookieContainer.Add in try/catch for CookieException, matching the existing pattern in CookieContainerExtensions.AddCookies - Clear pending cookies after transfer to avoid duplicate adds on request retry/reuse - Make PendingCookies public so BeforeRequest interceptors and authenticators can observe cookies added via the 2-param overload - Clarify docs: 4-param AddCookie populates CookieContainer immediately, 2-param stores in PendingCookies until execution Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- docs/docs/usage/request.md | 10 ++++++++-- docs/versioned_docs/version-v110/usage/usage.md | 10 ++++++++-- docs/versioned_docs/version-v111/usage/request.md | 10 ++++++++-- docs/versioned_docs/version-v112/usage/request.md | 10 ++++++++-- docs/versioned_docs/version-v113/usage/request.md | 10 ++++++++-- src/RestSharp/Request/RestRequest.cs | 15 ++++++++++++++- src/RestSharp/Request/RestRequestExtensions.cs | 9 +++++++++ src/RestSharp/RestClient.Async.cs | 11 +++++++++++ 8 files changed, 74 insertions(+), 11 deletions(-) diff --git a/docs/docs/usage/request.md b/docs/docs/usage/request.md index 374ee14dd..76343b347 100644 --- a/docs/docs/usage/request.md +++ b/docs/docs/usage/request.md @@ -227,11 +227,17 @@ You can add cookies to a request using the `AddCookie` method: request.AddCookie("foo", "bar"); ``` +The simple two-parameter form defers domain resolution until execution time — the cookie domain is inferred from the request URL. If you need to specify the path and domain explicitly, use the four-parameter overload: + +```csharp +request.AddCookie("foo", "bar", "/path", "example.com"); +``` + RestSharp will add cookies from the request as cookie headers and then extract the matching cookies from the response. You can observe and extract response cookies using the `RestResponse.Cookies` properties, which has the `CookieCollection` type. -However, the usage of a default URL segment parameter is questionable as you can just include the parameter value to the base URL of the client. There is, however, a `CookieContainer` instance on the request level. You can either assign the pre-populated container to `request.CookieContainer`, or let the container be created by the request when you call `AddCookie`. Still, the container is only used to extract all the cookies from it and create cookie headers for the request instead of using the container directly. It's because the cookie container is normally configured on the `HttpClientHandler` level and cookies are shared between requests made by the same client. In most of the cases this behaviour can be harmful. +There is a `CookieContainer` instance on the request level. You can either assign the pre-populated container to `request.CookieContainer`, or let the container be created automatically at execution time. The four-parameter `AddCookie` overload populates the container immediately, while the two-parameter form stores cookies in `PendingCookies` until the request is executed. The container is used to extract all the cookies from it and create cookie headers for the request instead of using the container directly. It's because the cookie container is normally configured on the `HttpClientHandler` level and cookies are shared between requests made by the same client. In most of the cases this behaviour can be harmful. -If your use case requires sharing cookies between requests made by the client instance, you can use the client-level `CookieContainer`, which you must provide as the options' property. You can add cookies to the container using the container API. No response cookies, however, would be auto-added to the container, but you can do it in code by getting cookies from the `Cookes` property of the response and adding them to the client-level container available via `IRestClient.Options.CookieContainer` property. +If your use case requires sharing cookies between requests made by the client instance, you can use the client-level `CookieContainer`, which you must provide as the options' property. You can add cookies to the container using the container API. No response cookies, however, would be auto-added to the container, but you can do it in code by getting cookies from the `Cookies` property of the response and adding them to the client-level container available via `IRestClient.Options.CookieContainer` property. ## Request Body diff --git a/docs/versioned_docs/version-v110/usage/usage.md b/docs/versioned_docs/version-v110/usage/usage.md index 7c580f096..1b378aace 100644 --- a/docs/versioned_docs/version-v110/usage/usage.md +++ b/docs/versioned_docs/version-v110/usage/usage.md @@ -230,11 +230,17 @@ You can add cookies to a request using the `AddCookie` method: request.AddCookie("foo", "bar"); ``` +The simple two-parameter form defers domain resolution until execution time — the cookie domain is inferred from the request URL. If you need to specify the path and domain explicitly, use the four-parameter overload: + +```csharp +request.AddCookie("foo", "bar", "/path", "example.com"); +``` + RestSharp will add cookies from the request as cookie headers and then extract the matching cookies from the response. You can observe and extract response cookies using the `RestResponse.Cookies` properties, which has the `CookieCollection` type. -However, the usage of a default URL segment parameter is questionable as you can just include the parameter value to the base URL of the client. There is, however, a `CookieContainer` instance on the request level. You can either assign the pre-populated container to `request.CookieContainer`, or let the container be created by the request when you call `AddCookie`. Still, the container is only used to extract all the cookies from it and create cookie headers for the request instead of using the container directly. It's because the cookie container is normally configured on the `HttpClientHandler` level and cookies are shared between requests made by the same client. In most of the cases this behaviour can be harmful. +There is a `CookieContainer` instance on the request level. You can either assign the pre-populated container to `request.CookieContainer`, or let the container be created automatically at execution time. The four-parameter `AddCookie` overload populates the container immediately, while the two-parameter form stores cookies in `PendingCookies` until the request is executed. The container is used to extract all the cookies from it and create cookie headers for the request instead of using the container directly. It's because the cookie container is normally configured on the `HttpClientHandler` level and cookies are shared between requests made by the same client. In most of the cases this behaviour can be harmful. -If your use case requires sharing cookies between requests made by the client instance, you can use the client-level `CookieContainer`, which you must provide as the options' property. You can add cookies to the container using the container API. No response cookies, however, would be auto-added to the container, but you can do it in code by getting cookies from the `Cookes` property of the response and adding them to the client-level container available via `IRestClient.Options.CookieContainer` property. +If your use case requires sharing cookies between requests made by the client instance, you can use the client-level `CookieContainer`, which you must provide as the options' property. You can add cookies to the container using the container API. No response cookies, however, would be auto-added to the container, but you can do it in code by getting cookies from the `Cookies` property of the response and adding them to the client-level container available via `IRestClient.Options.CookieContainer` property. ### Request Body diff --git a/docs/versioned_docs/version-v111/usage/request.md b/docs/versioned_docs/version-v111/usage/request.md index a844ed8a4..0644ce22a 100644 --- a/docs/versioned_docs/version-v111/usage/request.md +++ b/docs/versioned_docs/version-v111/usage/request.md @@ -208,11 +208,17 @@ You can add cookies to a request using the `AddCookie` method: request.AddCookie("foo", "bar"); ``` +The simple two-parameter form defers domain resolution until execution time — the cookie domain is inferred from the request URL. If you need to specify the path and domain explicitly, use the four-parameter overload: + +```csharp +request.AddCookie("foo", "bar", "/path", "example.com"); +``` + RestSharp will add cookies from the request as cookie headers and then extract the matching cookies from the response. You can observe and extract response cookies using the `RestResponse.Cookies` properties, which has the `CookieCollection` type. -However, the usage of a default URL segment parameter is questionable as you can just include the parameter value to the base URL of the client. There is, however, a `CookieContainer` instance on the request level. You can either assign the pre-populated container to `request.CookieContainer`, or let the container be created by the request when you call `AddCookie`. Still, the container is only used to extract all the cookies from it and create cookie headers for the request instead of using the container directly. It's because the cookie container is normally configured on the `HttpClientHandler` level and cookies are shared between requests made by the same client. In most of the cases this behaviour can be harmful. +There is a `CookieContainer` instance on the request level. You can either assign the pre-populated container to `request.CookieContainer`, or let the container be created automatically at execution time. The four-parameter `AddCookie` overload populates the container immediately, while the two-parameter form stores cookies in `PendingCookies` until the request is executed. The container is used to extract all the cookies from it and create cookie headers for the request instead of using the container directly. It's because the cookie container is normally configured on the `HttpClientHandler` level and cookies are shared between requests made by the same client. In most of the cases this behaviour can be harmful. -If your use case requires sharing cookies between requests made by the client instance, you can use the client-level `CookieContainer`, which you must provide as the options' property. You can add cookies to the container using the container API. No response cookies, however, would be auto-added to the container, but you can do it in code by getting cookies from the `Cookes` property of the response and adding them to the client-level container available via `IRestClient.Options.CookieContainer` property. +If your use case requires sharing cookies between requests made by the client instance, you can use the client-level `CookieContainer`, which you must provide as the options' property. You can add cookies to the container using the container API. No response cookies, however, would be auto-added to the container, but you can do it in code by getting cookies from the `Cookies` property of the response and adding them to the client-level container available via `IRestClient.Options.CookieContainer` property. ## Request Body diff --git a/docs/versioned_docs/version-v112/usage/request.md b/docs/versioned_docs/version-v112/usage/request.md index 374ee14dd..76343b347 100644 --- a/docs/versioned_docs/version-v112/usage/request.md +++ b/docs/versioned_docs/version-v112/usage/request.md @@ -227,11 +227,17 @@ You can add cookies to a request using the `AddCookie` method: request.AddCookie("foo", "bar"); ``` +The simple two-parameter form defers domain resolution until execution time — the cookie domain is inferred from the request URL. If you need to specify the path and domain explicitly, use the four-parameter overload: + +```csharp +request.AddCookie("foo", "bar", "/path", "example.com"); +``` + RestSharp will add cookies from the request as cookie headers and then extract the matching cookies from the response. You can observe and extract response cookies using the `RestResponse.Cookies` properties, which has the `CookieCollection` type. -However, the usage of a default URL segment parameter is questionable as you can just include the parameter value to the base URL of the client. There is, however, a `CookieContainer` instance on the request level. You can either assign the pre-populated container to `request.CookieContainer`, or let the container be created by the request when you call `AddCookie`. Still, the container is only used to extract all the cookies from it and create cookie headers for the request instead of using the container directly. It's because the cookie container is normally configured on the `HttpClientHandler` level and cookies are shared between requests made by the same client. In most of the cases this behaviour can be harmful. +There is a `CookieContainer` instance on the request level. You can either assign the pre-populated container to `request.CookieContainer`, or let the container be created automatically at execution time. The four-parameter `AddCookie` overload populates the container immediately, while the two-parameter form stores cookies in `PendingCookies` until the request is executed. The container is used to extract all the cookies from it and create cookie headers for the request instead of using the container directly. It's because the cookie container is normally configured on the `HttpClientHandler` level and cookies are shared between requests made by the same client. In most of the cases this behaviour can be harmful. -If your use case requires sharing cookies between requests made by the client instance, you can use the client-level `CookieContainer`, which you must provide as the options' property. You can add cookies to the container using the container API. No response cookies, however, would be auto-added to the container, but you can do it in code by getting cookies from the `Cookes` property of the response and adding them to the client-level container available via `IRestClient.Options.CookieContainer` property. +If your use case requires sharing cookies between requests made by the client instance, you can use the client-level `CookieContainer`, which you must provide as the options' property. You can add cookies to the container using the container API. No response cookies, however, would be auto-added to the container, but you can do it in code by getting cookies from the `Cookies` property of the response and adding them to the client-level container available via `IRestClient.Options.CookieContainer` property. ## Request Body diff --git a/docs/versioned_docs/version-v113/usage/request.md b/docs/versioned_docs/version-v113/usage/request.md index 61de32de5..27961951f 100644 --- a/docs/versioned_docs/version-v113/usage/request.md +++ b/docs/versioned_docs/version-v113/usage/request.md @@ -227,11 +227,17 @@ You can add cookies to a request using the `AddCookie` method: request.AddCookie("foo", "bar"); ``` +The simple two-parameter form defers domain resolution until execution time — the cookie domain is inferred from the request URL. If you need to specify the path and domain explicitly, use the four-parameter overload: + +```csharp +request.AddCookie("foo", "bar", "/path", "example.com"); +``` + RestSharp will add cookies from the request as cookie headers and then extract the matching cookies from the response. You can observe and extract response cookies using the `RestResponse.Cookies` properties, which has the `CookieCollection` type. -However, the usage of a default URL segment parameter is questionable as you can just include the parameter value to the base URL of the client. There is, however, a `CookieContainer` instance on the request level. You can either assign the pre-populated container to `request.CookieContainer`, or let the container be created by the request when you call `AddCookie`. Still, the container is only used to extract all the cookies from it and create cookie headers for the request instead of using the container directly. It's because the cookie container is normally configured on the `HttpClientHandler` level and cookies are shared between requests made by the same client. In most of the cases this behaviour can be harmful. +There is a `CookieContainer` instance on the request level. You can either assign the pre-populated container to `request.CookieContainer`, or let the container be created automatically at execution time. The four-parameter `AddCookie` overload populates the container immediately, while the two-parameter form stores cookies in `PendingCookies` until the request is executed. The container is used to extract all the cookies from it and create cookie headers for the request instead of using the container directly. It's because the cookie container is normally configured on the `HttpClientHandler` level and cookies are shared between requests made by the same client. In most of the cases this behaviour can be harmful. -If your use case requires sharing cookies between requests made by the client instance, you can use the client-level `CookieContainer`, which you must provide as the options' property. You can add cookies to the container using the container API. No response cookies, however, would be auto-added to the container, but you can do it in code by getting cookies from the `Cookes` property of the response and adding them to the client-level container available via `IRestClient.Options.CookieContainer` property. +If your use case requires sharing cookies between requests made by the client instance, you can use the client-level `CookieContainer`, which you must provide as the options' property. You can add cookies to the container using the container API. No response cookies, however, would be auto-added to the container, but you can do it in code by getting cookies from the `Cookies` property of the response and adding them to the client-level container available via `IRestClient.Options.CookieContainer` property. ## Request Body diff --git a/src/RestSharp/Request/RestRequest.cs b/src/RestSharp/Request/RestRequest.cs index 5abec55c8..7fa87a718 100644 --- a/src/RestSharp/Request/RestRequest.cs +++ b/src/RestSharp/Request/RestRequest.cs @@ -72,7 +72,8 @@ public RestRequest(string? resource, Method method = Method.Get) : this() { public RestRequest(Uri resource, Method method = Method.Get) : this(resource.IsAbsoluteUri ? resource.AbsoluteUri : resource.OriginalString, method) { } - readonly List _files = []; + readonly List _files = []; + readonly List _cookies = []; /// /// Always send a multipart/form-data request - even when no Files are present. @@ -263,4 +264,16 @@ public RestRequest RemoveParameter(Parameter parameter) { } internal RestRequest AddFile(FileParameter file) => this.With(x => x._files.Add(file)); + + internal RestRequest AddCookie(Cookie cookie) => this.With(x => x._cookies.Add(cookie)); + + /// + /// Cookies added via the 2-param AddCookie(name, value) overload that have not yet been + /// resolved into . Domain is inferred from the request URL at + /// execution time. Interceptors can inspect this list in BeforeRequest to see cookies + /// that will be sent. + /// + public IReadOnlyList PendingCookies => _cookies; + + internal void ClearPendingCookies() => _cookies.Clear(); } \ No newline at end of file diff --git a/src/RestSharp/Request/RestRequestExtensions.cs b/src/RestSharp/Request/RestRequestExtensions.cs index 82323d07b..687884625 100644 --- a/src/RestSharp/Request/RestRequestExtensions.cs +++ b/src/RestSharp/Request/RestRequestExtensions.cs @@ -124,6 +124,15 @@ RestRequest RemoveParameter(string? name, ParameterType type) { return p != null ? request.RemoveParameter(p) : request; } + /// + /// Adds a cookie to the request. The cookie domain will be inferred from the request URL at execution time. + /// + /// Cookie name + /// Cookie value + /// + public RestRequest AddCookie(string name, string value) + => request.AddCookie(new Cookie(name, value)); + /// /// Adds cookie to the cookie container. /// diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index a6142d07a..da807a542 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -130,6 +130,17 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo // Make sure we have a cookie container if not provided in the request var cookieContainer = request.CookieContainer ??= new(); + foreach (var cookie in request.PendingCookies) { + try { + cookieContainer.Add(url, cookie); + } + catch (CookieException) { + // Do not fail request if we cannot parse a cookie + } + } + + request.ClearPendingCookies(); + var headers = new RequestHeaders() .AddHeaders(request.Parameters) .AddHeaders(DefaultParameters) From 2802d8a69d03a9a236a6b5dc2d5e1e673cef72fc Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 26 Feb 2026 17:45:23 +0100 Subject: [PATCH 12/27] Surface root cause in ErrorMessage for wrapped exceptions (#2352) * Use innermost exception message for ErrorMessage to surface root cause errors When HttpClient wraps errors (e.g. TLS/SSL failures), the outer exception message is generic ("An error occurred while sending the request"). Using GetBaseException().Message surfaces the actual root cause, making diagnostics much easier. The full exception chain remains preserved in ErrorException. Fixes #2278 Co-Authored-By: Claude Opus 4.6 * Add tests verifying ErrorMessage surfaces innermost exception message Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/RestSharp/Response/RestResponseBase.cs | 2 +- src/RestSharp/RestClient.Async.cs | 2 +- test/RestSharp.Tests/ErrorMessageTests.cs | 43 ++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 test/RestSharp.Tests/ErrorMessageTests.cs diff --git a/src/RestSharp/Response/RestResponseBase.cs b/src/RestSharp/Response/RestResponseBase.cs index b54ed4e17..57b1f5c4a 100644 --- a/src/RestSharp/Response/RestResponseBase.cs +++ b/src/RestSharp/Response/RestResponseBase.cs @@ -161,6 +161,6 @@ protected RestResponseBase(RestRequest request) { internal void AddException(Exception exception) { ErrorException = exception; - ErrorMessage = exception.Message; + ErrorMessage = exception.GetBaseException().Message; } } diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index da807a542..3e6a83d6f 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -65,7 +65,7 @@ static RestResponse GetErrorResponse(RestRequest request, Exception exception, C ResponseStatus = exception is OperationCanceledException ? TimedOut() ? ResponseStatus.TimedOut : ResponseStatus.Aborted : ResponseStatus.Error, - ErrorMessage = exception.Message, + ErrorMessage = exception.GetBaseException().Message, ErrorException = exception }; diff --git a/test/RestSharp.Tests/ErrorMessageTests.cs b/test/RestSharp.Tests/ErrorMessageTests.cs new file mode 100644 index 000000000..811f23445 --- /dev/null +++ b/test/RestSharp.Tests/ErrorMessageTests.cs @@ -0,0 +1,43 @@ +using System.Security.Authentication; + +namespace RestSharp.Tests; + +public class ErrorMessageTests { + [Fact] + public async Task ErrorMessage_surfaces_innermost_exception_message() { + const string innerMessage = "The remote certificate is invalid according to the validation procedure."; + + var innerException = new AuthenticationException(innerMessage); + var wrappedException = new HttpRequestException("An error occurred while sending the request.", innerException); + + var handler = new FakeHandler(wrappedException); + var client = new RestClient(new RestClientOptions("https://dummy.org") { ConfigureMessageHandler = _ => handler }); + + var response = await client.ExecuteAsync(new RestRequest("/")); + + response.ErrorMessage.Should().Be(innerMessage); + response.ErrorException.Should().BeOfType(); + response.ErrorException!.InnerException.Should().BeOfType(); + response.ResponseStatus.Should().Be(ResponseStatus.Error); + } + + [Fact] + public async Task ErrorMessage_uses_direct_message_when_no_inner_exception() { + const string message = "No such host is known."; + + var exception = new HttpRequestException(message); + var handler = new FakeHandler(exception); + var client = new RestClient(new RestClientOptions("https://dummy.org") { ConfigureMessageHandler = _ => handler }); + + var response = await client.ExecuteAsync(new RestRequest("/")); + + response.ErrorMessage.Should().Be(message); + response.ErrorException.Should().BeOfType(); + response.ErrorException!.InnerException.Should().BeNull(); + } + + class FakeHandler(Exception exception) : HttpMessageHandler { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => throw exception; + } +} From 53b797b41c8c58655d71cf7b2ab5b902577e85ff Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 26 Feb 2026 17:59:59 +0100 Subject: [PATCH 13/27] Fix credential/UseDefaultCredentials property order on HttpClientHandler (#2353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In .NET Core, HttpClientHandler.Credentials and UseDefaultCredentials share a backing field, so setting one overwrites the other. Previously, Credentials was set first (often null), then UseDefaultCredentials — which worked. But setting Credentials = CredentialCache.DefaultCredentials explicitly would get overwritten by UseDefaultCredentials = false. Now UseDefaultCredentials is set first, and Credentials is only set when non-null, so neither property can silently clobber the other. Fixes #2236 Co-authored-by: Claude Opus 4.6 --- src/RestSharp/RestClient.cs | 4 +- .../CredentialConfigurationTests.cs | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 test/RestSharp.Tests/CredentialConfigurationTests.cs diff --git a/src/RestSharp/RestClient.cs b/src/RestSharp/RestClient.cs index c48c45544..fe50cd607 100644 --- a/src/RestSharp/RestClient.cs +++ b/src/RestSharp/RestClient.cs @@ -234,8 +234,8 @@ internal static void ConfigureHttpMessageHandler(HttpClientHandler handler, Rest if (!OperatingSystem.IsBrowser()) { #endif handler.UseCookies = false; - handler.Credentials = options.Credentials; - handler.UseDefaultCredentials = options.UseDefaultCredentials; + handler.UseDefaultCredentials = options.UseDefaultCredentials; + if (options.Credentials != null) handler.Credentials = options.Credentials; handler.AutomaticDecompression = options.AutomaticDecompression; handler.PreAuthenticate = options.PreAuthenticate; if (options.MaxRedirects.HasValue) handler.MaxAutomaticRedirections = options.MaxRedirects.Value; diff --git a/test/RestSharp.Tests/CredentialConfigurationTests.cs b/test/RestSharp.Tests/CredentialConfigurationTests.cs new file mode 100644 index 000000000..1528e4489 --- /dev/null +++ b/test/RestSharp.Tests/CredentialConfigurationTests.cs @@ -0,0 +1,44 @@ +using System.Net; + +namespace RestSharp.Tests; + +public class CredentialConfigurationTests { + [Fact] + public void Explicit_credentials_are_not_overwritten_by_UseDefaultCredentials() { + var credentials = new NetworkCredential("user", "password"); + var options = new RestClientOptions("https://dummy.org") { + Credentials = credentials, + UseDefaultCredentials = false + }; + + var handler = new HttpClientHandler(); + RestClient.ConfigureHttpMessageHandler(handler, options); + + handler.Credentials.Should().BeSameAs(credentials); + } + + [Fact] + public void DefaultCredentials_set_explicitly_are_not_overwritten() { + var options = new RestClientOptions("https://dummy.org") { + Credentials = CredentialCache.DefaultCredentials, + UseDefaultCredentials = false + }; + + var handler = new HttpClientHandler(); + RestClient.ConfigureHttpMessageHandler(handler, options); + + handler.Credentials.Should().BeSameAs(CredentialCache.DefaultCredentials); + } + + [Fact] + public void UseDefaultCredentials_sets_credentials_when_no_explicit_credentials() { + var options = new RestClientOptions("https://dummy.org") { + UseDefaultCredentials = true + }; + + var handler = new HttpClientHandler(); + RestClient.ConfigureHttpMessageHandler(handler, options); + + handler.UseDefaultCredentials.Should().BeTrue(); + } +} From acdf9af5027aaa77a88a9233f4768d60907f0a24 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 26 Feb 2026 19:25:11 +0100 Subject: [PATCH 14/27] Use CultureInfo.InvariantCulture by default when converting parameter values to strings (#2354) Previously, AddParameter, AddQueryParameter, AddUrlSegment, AddHeader, AddOrUpdateParameter, AddOrUpdateHeader, and Parameter.CreateParameter all used ToString() which respects the current culture. This caused issues on non-English locales where e.g. 1.234 would be formatted as "1,234" with a comma decimal separator. All generic overloads now default to InvariantCulture and accept an optional CultureInfo parameter for callers who need locale-specific formatting. Fixes #2270 Co-authored-by: Claude Opus 4.6 --- src/RestSharp/Extensions/StringExtensions.cs | 3 + src/RestSharp/Parameters/ObjectParser.cs | 5 +- src/RestSharp/Parameters/Parameter.cs | 9 +- .../Request/RestRequestExtensions.Headers.cs | 15 ++- .../Request/RestRequestExtensions.Query.cs | 9 +- .../Request/RestRequestExtensions.Url.cs | 9 +- .../Request/RestRequestExtensions.cs | 18 ++-- test/RestSharp.Tests/InvariantCultureTests.cs | 98 +++++++++++++++++++ test/RestSharp.Tests/ObjectParserTests.cs | 11 ++- 9 files changed, 153 insertions(+), 24 deletions(-) create mode 100644 test/RestSharp.Tests/InvariantCultureTests.cs diff --git a/src/RestSharp/Extensions/StringExtensions.cs b/src/RestSharp/Extensions/StringExtensions.cs index 637177a67..800e331be 100644 --- a/src/RestSharp/Extensions/StringExtensions.cs +++ b/src/RestSharp/Extensions/StringExtensions.cs @@ -149,6 +149,9 @@ internal IEnumerable GetNameVariants(CultureInfo culture) { } } + internal static string? ToStringValue(this object? value, CultureInfo? culture = null) + => value is IFormattable f ? f.ToString(null, culture ?? CultureInfo.InvariantCulture) : value?.ToString(); + internal static bool IsEmpty([NotNullWhen(false)] this string? value) => string.IsNullOrWhiteSpace(value); internal static bool IsNotEmpty([NotNullWhen(true)] this string? value) => !string.IsNullOrWhiteSpace(value); diff --git a/src/RestSharp/Parameters/ObjectParser.cs b/src/RestSharp/Parameters/ObjectParser.cs index 3f3464d97..c9cebcc02 100644 --- a/src/RestSharp/Parameters/ObjectParser.cs +++ b/src/RestSharp/Parameters/ObjectParser.cs @@ -15,6 +15,9 @@ using System.Reflection; +using System.Globalization; +using RestSharp.Extensions; + namespace RestSharp; static class ObjectParser { @@ -72,7 +75,7 @@ IEnumerable GetArray(PropertyInfo propertyInfo, object? value) bool IsAllowedProperty(string propertyName) => includedProperties.Length == 0 || includedProperties.Length > 0 && includedProperties.Contains(propertyName); - string? ParseValue(string? format, object? value) => format == null ? value?.ToString() : string.Format($"{{0:{format}}}", value); + string? ParseValue(string? format, object? value) => format == null ? value.ToStringValue() : string.Format(CultureInfo.InvariantCulture, $"{{0:{format}}}", value); } } diff --git a/src/RestSharp/Parameters/Parameter.cs b/src/RestSharp/Parameters/Parameter.cs index b23c592dc..ebe4cf590 100644 --- a/src/RestSharp/Parameters/Parameter.cs +++ b/src/RestSharp/Parameters/Parameter.cs @@ -13,6 +13,7 @@ // limitations under the License. using System.Diagnostics; +using RestSharp.Extensions; namespace RestSharp; @@ -76,10 +77,10 @@ protected Parameter(string? name, object? value, ParameterType type, bool encode public static Parameter CreateParameter(string? name, object? value, ParameterType type, bool encode = true) // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault => type switch { - ParameterType.GetOrPost => new GetOrPostParameter(Ensure.NotEmptyString(name, nameof(name)), value?.ToString(), encode), - ParameterType.UrlSegment => new UrlSegmentParameter(Ensure.NotEmptyString(name, nameof(name)), value?.ToString()!, encode), - ParameterType.HttpHeader => new HeaderParameter(name!, value?.ToString()!), - ParameterType.QueryString => new QueryParameter(Ensure.NotEmptyString(name, nameof(name)), value?.ToString(), encode), + ParameterType.GetOrPost => new GetOrPostParameter(Ensure.NotEmptyString(name, nameof(name)), value.ToStringValue(), encode), + ParameterType.UrlSegment => new UrlSegmentParameter(Ensure.NotEmptyString(name, nameof(name)), value.ToStringValue()!, encode), + ParameterType.HttpHeader => new HeaderParameter(name!, value.ToStringValue()!), + ParameterType.QueryString => new QueryParameter(Ensure.NotEmptyString(name, nameof(name)), value.ToStringValue(), encode), _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) }; diff --git a/src/RestSharp/Request/RestRequestExtensions.Headers.cs b/src/RestSharp/Request/RestRequestExtensions.Headers.cs index 2838db65f..f026948a1 100644 --- a/src/RestSharp/Request/RestRequestExtensions.Headers.cs +++ b/src/RestSharp/Request/RestRequestExtensions.Headers.cs @@ -12,6 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Globalization; +using RestSharp.Extensions; + namespace RestSharp; public static partial class RestRequestExtensions { @@ -42,12 +45,14 @@ public RestRequest AddHeader(string name, string value) /// /// Adds a header to the request. RestSharp will try to separate request and content headers when calling the resource. + /// The value will be converted to string using the specified culture, or by default. /// /// Header name /// Header value + /// Culture to use for formatting the value, defaults to /// - public RestRequest AddHeader(string name, T value) where T : struct - => request.AddHeader(name, Ensure.NotNull(value.ToString(), nameof(value))); + public RestRequest AddHeader(string name, T value, CultureInfo? culture = null) where T : struct + => request.AddHeader(name, Ensure.NotNull(value.ToStringValue(culture), nameof(value))); /// /// Adds or updates the request header. RestSharp will try to separate request and content headers when calling the resource. @@ -62,12 +67,14 @@ public RestRequest AddOrUpdateHeader(string name, string value) /// /// Adds or updates the request header. RestSharp will try to separate request and content headers when calling the resource. /// The existing header with the same name will be replaced. + /// The value will be converted to string using the specified culture, or by default. /// /// Header name /// Header value + /// Culture to use for formatting the value, defaults to /// - public RestRequest AddOrUpdateHeader(string name, T value) where T : struct - => request.AddOrUpdateHeader(name, Ensure.NotNull(value.ToString(), nameof(value))); + public RestRequest AddOrUpdateHeader(string name, T value, CultureInfo? culture = null) where T : struct + => request.AddOrUpdateHeader(name, Ensure.NotNull(value.ToStringValue(culture), nameof(value))); /// /// Adds multiple headers to the request, using the key-value pairs provided. diff --git a/src/RestSharp/Request/RestRequestExtensions.Query.cs b/src/RestSharp/Request/RestRequestExtensions.Query.cs index 265dc360d..14d69e309 100644 --- a/src/RestSharp/Request/RestRequestExtensions.Query.cs +++ b/src/RestSharp/Request/RestRequestExtensions.Query.cs @@ -12,6 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Globalization; +using RestSharp.Extensions; + namespace RestSharp; public static partial class RestRequestExtensions { @@ -31,12 +34,14 @@ public RestRequest AddQueryParameter(string name, string? value, bool encode = t /// /// Adds a query string parameter to the request. The request resource should not contain any placeholders for this parameter. /// The parameter will be added to the request URL as a query string using name=value format. + /// The value will be converted to string using the specified culture, or by default. /// /// Parameter name /// Parameter value /// Encode the value or not, default true + /// Culture to use for formatting the value, defaults to /// - public RestRequest AddQueryParameter(string name, T value, bool encode = true) where T : struct - => request.AddQueryParameter(name, value.ToString(), encode); + public RestRequest AddQueryParameter(string name, T value, bool encode = true, CultureInfo? culture = null) where T : struct + => request.AddQueryParameter(name, value.ToStringValue(culture), encode); } } \ No newline at end of file diff --git a/src/RestSharp/Request/RestRequestExtensions.Url.cs b/src/RestSharp/Request/RestRequestExtensions.Url.cs index 499c629df..66dbfe5ad 100644 --- a/src/RestSharp/Request/RestRequestExtensions.Url.cs +++ b/src/RestSharp/Request/RestRequestExtensions.Url.cs @@ -12,6 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Globalization; +using RestSharp.Extensions; + namespace RestSharp; public static partial class RestRequestExtensions { @@ -31,12 +34,14 @@ public RestRequest AddUrlSegment(string name, string? value, bool encode = true) /// /// Adds a URL segment parameter to the request. The resource URL must have a placeholder for the parameter for it to work. /// For example, if you add a URL segment parameter with the name "id", the resource URL should contain {id} in its path. + /// The value will be converted to string using the specified culture, or by default. /// /// Name of the parameter; must be matching a placeholder in the resource URL as {name} /// Value of the parameter /// Encode the value or not, default true + /// Culture to use for formatting the value, defaults to /// - public RestRequest AddUrlSegment(string name, T value, bool encode = true) where T : struct - => request.AddUrlSegment(name, value.ToString(), encode); + public RestRequest AddUrlSegment(string name, T value, bool encode = true, CultureInfo? culture = null) where T : struct + => request.AddUrlSegment(name, value.ToStringValue(culture), encode); } } \ No newline at end of file diff --git a/src/RestSharp/Request/RestRequestExtensions.cs b/src/RestSharp/Request/RestRequestExtensions.cs index 687884625..55bdd550f 100644 --- a/src/RestSharp/Request/RestRequestExtensions.cs +++ b/src/RestSharp/Request/RestRequestExtensions.cs @@ -12,6 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Globalization; +using RestSharp.Extensions; + namespace RestSharp; [PublicAPI] @@ -45,14 +48,15 @@ public RestRequest AddParameter(string? name, object value, ParameterType type, /// /// Adds a HTTP parameter to the request (QueryString for GET, DELETE, OPTIONS and HEAD; Encoded form for POST and PUT). - /// The value will be converted to string. + /// The value will be converted to string using the specified culture, or by default. /// /// Name of the parameter /// Value of the parameter /// Encode the value or not, default true + /// Culture to use for formatting the value, defaults to /// This request - public RestRequest AddParameter(string name, T value, bool encode = true) where T : struct - => request.AddParameter(name, value.ToString(), encode); + public RestRequest AddParameter(string name, T value, bool encode = true, CultureInfo? culture = null) where T : struct + => request.AddParameter(name, value.ToStringValue(culture), encode); /// /// Adds or updates a HTTP parameter to the request (QueryString for GET, DELETE, OPTIONS and HEAD; Encoded form for POST and PUT) @@ -65,14 +69,16 @@ public RestRequest AddOrUpdateParameter(string name, string? value, bool encode => request.AddOrUpdateParameter(new GetOrPostParameter(name, value, encode)); /// - /// Adds or updates a HTTP parameter to the request (QueryString for GET, DELETE, OPTIONS and HEAD; Encoded form for POST and PUT) + /// Adds or updates a HTTP parameter to the request (QueryString for GET, DELETE, OPTIONS and HEAD; Encoded form for POST and PUT). + /// The value will be converted to string using the specified culture, or by default. /// /// Name of the parameter /// Value of the parameter /// Encode the value or not, default true + /// Culture to use for formatting the value, defaults to /// This request - public RestRequest AddOrUpdateParameter(string name, T value, bool encode = true) where T : struct - => request.AddOrUpdateParameter(name, value.ToString(), encode); + public RestRequest AddOrUpdateParameter(string name, T value, bool encode = true, CultureInfo? culture = null) where T : struct + => request.AddOrUpdateParameter(name, value.ToStringValue(culture), encode); RestRequest AddParameters(IEnumerable parameters) { request.Parameters.AddParameters(parameters); diff --git a/test/RestSharp.Tests/InvariantCultureTests.cs b/test/RestSharp.Tests/InvariantCultureTests.cs new file mode 100644 index 000000000..6816adae2 --- /dev/null +++ b/test/RestSharp.Tests/InvariantCultureTests.cs @@ -0,0 +1,98 @@ +using System.Globalization; + +namespace RestSharp.Tests; + +public class InvariantCultureTests { + [Fact] + public void AddParameter_uses_invariant_culture_for_double() { + var originalCulture = CultureInfo.CurrentCulture; + + try { + CultureInfo.CurrentCulture = new CultureInfo("da-DK"); + var request = new RestRequest().AddParameter("value", 1.234); + + var parameter = request.Parameters.FirstOrDefault(p => p.Name == "value"); + parameter.Should().NotBeNull(); + parameter!.Value.Should().Be("1.234"); + } + finally { + CultureInfo.CurrentCulture = originalCulture; + } + } + + [Fact] + public void AddParameter_can_use_specific_culture() { + var request = new RestRequest().AddParameter("value", 1.234, culture: new CultureInfo("da-DK")); + + var parameter = request.Parameters.FirstOrDefault(p => p.Name == "value"); + parameter.Should().NotBeNull(); + parameter!.Value.Should().Be("1,234"); + } + + [Fact] + public void AddOrUpdateParameter_uses_invariant_culture_for_double() { + var originalCulture = CultureInfo.CurrentCulture; + + try { + CultureInfo.CurrentCulture = new CultureInfo("da-DK"); + var request = new RestRequest().AddOrUpdateParameter("value", 1.234); + + var parameter = request.Parameters.FirstOrDefault(p => p.Name == "value"); + parameter.Should().NotBeNull(); + parameter!.Value.Should().Be("1.234"); + } + finally { + CultureInfo.CurrentCulture = originalCulture; + } + } + + [Fact] + public void AddQueryParameter_uses_invariant_culture_for_decimal() { + var originalCulture = CultureInfo.CurrentCulture; + + try { + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + var request = new RestRequest().AddQueryParameter("price", 99.95m); + + var parameter = request.Parameters.FirstOrDefault(p => p.Name == "price"); + parameter.Should().NotBeNull(); + parameter!.Value.Should().Be("99.95"); + } + finally { + CultureInfo.CurrentCulture = originalCulture; + } + } + + [Fact] + public void AddUrlSegment_uses_invariant_culture_for_float() { + var originalCulture = CultureInfo.CurrentCulture; + + try { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + var request = new RestRequest("{id}").AddUrlSegment("id", 3.14f); + + var parameter = request.Parameters.FirstOrDefault(p => p.Name == "id"); + parameter.Should().NotBeNull(); + parameter!.Value.Should().Be("3.14"); + } + finally { + CultureInfo.CurrentCulture = originalCulture; + } + } + + [Fact] + public void CreateParameter_uses_invariant_culture_for_object_value() { + var originalCulture = CultureInfo.CurrentCulture; + + try { + CultureInfo.CurrentCulture = new CultureInfo("da-DK"); + object value = 1.234; + var parameter = Parameter.CreateParameter("value", value, ParameterType.QueryString); + + parameter.Value.Should().Be("1.234"); + } + finally { + CultureInfo.CurrentCulture = originalCulture; + } + } +} diff --git a/test/RestSharp.Tests/ObjectParserTests.cs b/test/RestSharp.Tests/ObjectParserTests.cs index 6bcbef081..769493a86 100644 --- a/test/RestSharp.Tests/ObjectParserTests.cs +++ b/test/RestSharp.Tests/ObjectParserTests.cs @@ -1,4 +1,6 @@ // ReSharper disable PropertyCanBeMadeInitOnly.Local +using System.Globalization; + namespace RestSharp.Tests; public class ObjectParserTests { @@ -17,11 +19,10 @@ public void ShouldUseRequestProperty() { var parsed = request.GetProperties().ToDictionary(x => x.Name, x => x.Value); parsed["some_data"].Should().Be(request.SomeData); - parsed["SomeDate"].Should().Be(request.SomeDate.ToString("d")); - parsed["Plain"].Should().Be(request.Plain.ToString()); - // ReSharper disable once SpecifyACultureInStringConversionExplicitly - parsed["PlainArray"].Should().Be(string.Join(",", dates.Select(x => x.ToString()))); - parsed["dates"].Should().Be(string.Join(",", dates.Select(x => x.ToString("d")))); + parsed["SomeDate"].Should().Be(request.SomeDate.ToString("d", CultureInfo.InvariantCulture)); + parsed["Plain"].Should().Be(request.Plain.ToString(CultureInfo.InvariantCulture)); + parsed["PlainArray"].Should().Be(string.Join(",", dates.Select(x => x.ToString(CultureInfo.InvariantCulture)))); + parsed["dates"].Should().Be(string.Join(",", dates.Select(x => x.ToString("d", CultureInfo.InvariantCulture)))); } [Fact] From 3582f733dba15107c4cc5d319eec3dba5b9ab068 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 26 Feb 2026 20:33:53 +0100 Subject: [PATCH 15/27] Use descriptive error message for timed out requests (#2356) When a request times out, ErrorMessage was set to the TaskCanceledException message ("A task was canceled.") which contradicts ResponseStatus.TimedOut. Now ErrorMessage says "The request timed out." for timeout scenarios, keeping all three state properties (ResponseStatus, ErrorMessage, ErrorException) consistent. ErrorException still holds the original TaskCanceledException. Fixes #2257 Co-authored-by: Claude Opus 4.6 --- src/RestSharp/RestClient.Async.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 3e6a83d6f..8beee730b 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -61,11 +61,13 @@ public async Task ExecuteAsync(RestRequest request, CancellationTo } static RestResponse GetErrorResponse(RestRequest request, Exception exception, CancellationToken timeoutToken) { + var timedOut = exception is OperationCanceledException && TimedOut(); + var response = new RestResponse(request) { ResponseStatus = exception is OperationCanceledException - ? TimedOut() ? ResponseStatus.TimedOut : ResponseStatus.Aborted + ? timedOut ? ResponseStatus.TimedOut : ResponseStatus.Aborted : ResponseStatus.Error, - ErrorMessage = exception.GetBaseException().Message, + ErrorMessage = timedOut ? "The request timed out." : exception.GetBaseException().Message, ErrorException = exception }; From 046795231e33ca08c72cb906e9085b4e94bcbdae Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 26 Feb 2026 20:58:55 +0100 Subject: [PATCH 16/27] Quote multipart form parameter names by default (RFC 7578) (#2357) * Quote multipart form parameter names by default (RFC 7578) Change MultipartFormQuoteParameters default from false to true so that parameter names in Content-Disposition headers are quoted consistently with file parameter names, as required by RFC 7578. Fixes #2271 Co-Authored-By: Claude Opus 4.6 * Apply parameter name quoting to body parts in multipart form data Extend MultipartFormQuoteParameters to also cover BodyParameter parts added via AddBody, ensuring consistent Content-Disposition name quoting across all multipart form parts. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/RestSharp/Request/RequestContent.cs | 2 +- src/RestSharp/Request/RestRequest.cs | 5 ++--- test/RestSharp.Tests.Integrated/MultipartFormDataTests.cs | 8 ++++---- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/RestSharp/Request/RequestContent.cs b/src/RestSharp/Request/RequestContent.cs index 03af0bdcf..affc60618 100644 --- a/src/RestSharp/Request/RequestContent.cs +++ b/src/RestSharp/Request/RequestContent.cs @@ -143,7 +143,7 @@ void AddBody(bool hasPostParameters, BodyParameter bodyParameter) { if (name.IsEmpty()) mpContent.Add(bodyContent); else - mpContent.Add(bodyContent, name); + mpContent.Add(bodyContent, request.MultipartFormQuoteParameters ? $"\"{name}\"" : name); Content = mpContent; } else { diff --git a/src/RestSharp/Request/RestRequest.cs b/src/RestSharp/Request/RestRequest.cs index 7fa87a718..b2f86f6ef 100644 --- a/src/RestSharp/Request/RestRequest.cs +++ b/src/RestSharp/Request/RestRequest.cs @@ -87,10 +87,9 @@ public RestRequest(Uri resource, Method method = Method.Get) /// /// When set to true, parameter values in a multipart form data requests will be enclosed in - /// quotation marks. Default is false. Enable it if the remote endpoint requires parameters - /// to be in quotes (for example, FreshDesk API). + /// quotation marks. Default is true, as per RFC 7578. /// - public bool MultipartFormQuoteParameters { get; set; } + public bool MultipartFormQuoteParameters { get; set; } = true; /// /// When set to true, the form boundary part of the content type will be enclosed in diff --git a/test/RestSharp.Tests.Integrated/MultipartFormDataTests.cs b/test/RestSharp.Tests.Integrated/MultipartFormDataTests.cs index 1d5ee5899..5fba92f84 100644 --- a/test/RestSharp.Tests.Integrated/MultipartFormDataTests.cs +++ b/test/RestSharp.Tests.Integrated/MultipartFormDataTests.cs @@ -31,7 +31,7 @@ public void Dispose() { const string ContentDispositionString = $"{KnownHeaders.ContentDisposition}: form-data;"; const string Expected = - $"--{{0}}{LineBreak}{ContentTypeString}{LineBreak}{ContentDispositionString} name=foo{LineBreak}{LineBreak}bar{LineBreak}" + + $"--{{0}}{LineBreak}{ContentTypeString}{LineBreak}{ContentDispositionString} name=\"foo\"{LineBreak}{LineBreak}bar{LineBreak}" + $"--{{0}}{LineBreak}{ContentTypeString}{LineBreak}{ContentDispositionString} name=\"a name with spaces\"{LineBreak}{LineBreak}somedata{LineBreak}" + $"--{{0}}--{LineBreak}"; @@ -41,7 +41,7 @@ public void Dispose() { $"{LineBreak}{KnownHeaders.ContentDisposition}: form-data; name=\"fileName\"; filename=\"TestFile.txt\"" + $"{LineBreak}{LineBreak}This is a test file for RestSharp.{LineBreak}" + $"--{{0}}{LineBreak}{KnownHeaders.ContentType}: application/json; {CharsetString}" + - $"{LineBreak}{KnownHeaders.ContentDisposition}: form-data; name=controlName" + + $"{LineBreak}{KnownHeaders.ContentDisposition}: form-data; name=\"controlName\"" + $"{LineBreak}{LineBreak}test{LineBreak}" + $"--{{0}}--{LineBreak}"; @@ -199,7 +199,7 @@ public async Task MultipartFormData_Without_File_Creates_A_Valid_RequestBody() { var expectedBody = new[] { ContentTypeString, - $"{ContentDispositionString} name={multipartName}", + $"{ContentDispositionString} name=\"{multipartName}\"", bodyData }; @@ -228,7 +228,7 @@ public async Task PostParameter_contentType_in_multipart_form() { var actual = capturer.Body!.Replace("\n", string.Empty).Split('\r'); actual[1].Should().Be("Content-Type: application/json; charset=utf-8"); - actual[2].Should().Be($"Content-Disposition: form-data; name={parameterName}"); + actual[2].Should().Be($"Content-Disposition: form-data; name=\"{parameterName}\""); actual[4].Should().Be(parameterValue); } } \ No newline at end of file From 91dfd97b97d37d4d9098048b392d0c1453380deb Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 26 Feb 2026 21:04:03 +0100 Subject: [PATCH 17/27] Update CLAUDE.md with project guidance (#2358) * Update CLAUDE.md with comprehensive project guidance for Claude Code Replace minimal MCP server note with full developer guidance including build/test commands, architecture overview, code conventions, and multi-targeting notes. Uses .slnx solution format with a note about net8.0 incompatibility requiring project-level test runs. Co-Authored-By: Claude Opus 4.6 * Fix misleading "immutable" claim about RestResponse in CLAUDE.md RestResponse types have public setters on all properties. Remove the incorrect "immutable" label and clarify what [GenerateClone] actually does. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- CLAUDE.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 615e58179..1fd8c31b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,2 +1,104 @@ -## MCP Servers Available -- mem0: Use this AI memory for storing and retrieving long-term context as well as short-term context \ No newline at end of file +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Test Commands + +```bash +# Build +dotnet build RestSharp.slnx -c Debug + +# Run all tests +dotnet test RestSharp.slnx -c Debug + +# Run tests for a specific TFM +dotnet test RestSharp.slnx -f net9.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 +dotnet pack src/RestSharp/RestSharp.csproj -c Release -o nuget -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg +``` + +**Note:** `dotnet test` with `.slnx` does not work with `net8.0`. To run tests targeting `net8.0`, use individual project files: `dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net8.0` + +## Project Overview + +RestSharp is a lightweight HTTP API client library for .NET that wraps `HttpClient`. It provides default parameters, multiple parameter types (query, URL segment, header, cookie, body), built-in JSON/XML/CSV serialization, and authentication support. + +### Repository Layout + +- `src/RestSharp/` — Core library +- `src/RestSharp.Serializers.NewtonsoftJson/` — Newtonsoft.Json serializer adapter +- `src/RestSharp.Serializers.Xml/` — Custom XML serializer +- `src/RestSharp.Serializers.CsvHelper/` — CsvHelper serializer adapter +- `src/RestSharp.Extensions.DependencyInjection/` — DI integration +- `gen/SourceGenerator/` — Custom incremental source generators +- `test/RestSharp.Tests/` — Unit tests +- `test/RestSharp.Tests.Integrated/` — Integration tests (WireMock-based) +- `test/RestSharp.Tests.Serializers.*/` — Serializer-specific tests + +## Architecture + +### Core Classes + +- **RestClient** (`RestClient.cs`, split into partials `RestClient.*.cs`) — Main entry point. Wraps `HttpClient`, holds `ReadOnlyRestClientOptions`, `RestSerializers`, and `DefaultParameters`. Thread-safe when configured via `RestClientOptions`. +- **RestRequest** (`Request/RestRequest.cs`) — Request container with fluent API via extension methods in `RestRequest*.cs` files. Parameters split by type: query, body, header, URL segment, cookie, file. +- **RestResponse / RestResponse\** (`Response/RestResponse*.cs`) — Response containers with public setters. `[GenerateClone]` generates a static factory to copy base properties from `RestResponse` into `RestResponse`. +- **RestClientOptions** (`Options/RestClientOptions.cs`) — Mutable configuration class. An immutable `ReadOnlyRestClientOptions` wrapper is generated by `[GenerateImmutable]`. + +### Request Pipeline + +`ExecuteAsync` → build request → interceptor chain (`BeforeRequest` → `BeforeHttpRequest` → send → `AfterHttpRequest` → `AfterRequest`) → deserialization → error handling. Interceptors are configured via `RestClientOptions.Interceptors`. + +### Parameter System (`Parameters/`) + +Abstract `Parameter` record base with concrete types: `HeaderParameter`, `QueryParameter`, `BodyParameter`, `UrlSegmentParameter`, `FileParameter`, `GetOrPostParameter`, `CookieParameter`. `ObjectParser` converts objects to parameters via reflection. + +### Serialization (`Serializers/`) + +`ISerializer`/`IDeserializer` interfaces. `RestSerializers` manages serializers by `DataFormat` enum (Json, Xml, Csv). Default: `SystemTextJsonSerializer`. Configured via `ConfigureSerialization` delegate in client constructors. + +### Authentication (`Authenticators/`) + +`IAuthenticator` with single `Authenticate(IRestClient, RestRequest)` method. Built-in: `HttpBasicAuthenticator`, `JwtAuthenticator`, OAuth1/OAuth2 authenticators. Set via `RestClientOptions.Authenticator`. + +### Source Generators (`gen/SourceGenerator/`) + +- `[GenerateImmutable]` — Creates read-only wrapper (used on `RestClientOptions`) +- `[GenerateClone]` — Creates static factory clone methods (used on `RestResponse`) +- `[Exclude]` — Excludes properties from immutable generation +- Generator target must be `netstandard2.0`. Inspect output in `obj///generated/SourceGenerator/`. + +## Multi-Targeting + +**Library:** `netstandard2.0`, `net471`, `net48`, `net8.0`, `net9.0`, `net10.0` +**Tests:** `net48` (Windows only), `net8.0`, `net9.0`, `net10.0` + +Use conditional compilation for TFM-specific APIs: `#if NET`, `#if NET8_0_OR_GREATER`. `System.Text.Json` is a NuGet dependency on older TFMs but built-in on net8.0+. + +## Code Style & Conventions + +- **C# version:** preview (latest features) +- **Nullable:** Enabled in `/src`, disabled in `/test` +- **License header required** in all `/src` files (Apache-2.0, see `.github/copilot-instructions.md` for exact text) +- **Strong-named** assemblies via `RestSharp.snk` +- **Partial classes** for large types, linked via `` in csproj +- **Package versions** centrally managed in `Directory.Packages.props` — don't pin in individual projects +- **Versioning** via MinVer from git tags (no hardcoded versions) +- Follow `.editorconfig` for formatting + +## Testing + +- **Stack:** xUnit + FluentAssertions + AutoFixture +- **HTTP mocking:** WireMock.Net (avoid live endpoints) +- Global usings in test projects: `Xunit`, `FluentAssertions`, `AutoFixture` +- Guard TFM-specific tests with `#if NET8_0_OR_GREATER` +- Test results: `test-results//.trx` + +## Working with Issues + +- Avoid changing default behavior unless absolutely necessary +- Avoid breaking the existing API +- Leave existing tests intact to catch regressions; add new tests for fixed cases From ae6b354e7817caf77483ede03468bee68f03b948 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 27 Feb 2026 13:20:02 +0100 Subject: [PATCH 18/27] Implement custom redirect handling to fix lost Set-Cookie on redirects (#2360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement custom redirect handling to fix lost Set-Cookie on redirects Fixes #2077 and #2059. Previously RestSharp delegated redirects to HttpClient (AllowAutoRedirect=true) but set UseCookies=false, so Set-Cookie headers from intermediate redirect responses were silently lost. This replaces HttpClient's redirect handling with a custom loop in ExecuteRequestAsync that processes Set-Cookie at each hop. Adds RedirectOptions class with fine-grained control over redirect behavior: FollowRedirectsToInsecure, ForwardHeaders, ForwardAuthorization, ForwardCookies, ForwardBody, ForwardQuery, MaxRedirects, and RedirectStatusCodes. Existing FollowRedirects/MaxRedirects properties delegate to RedirectOptions for backward compatibility. Co-Authored-By: Claude Opus 4.6 * Address PR review feedback: reduce complexity, fix disposal, fix duplicate cookies - Extract redirect loop into smaller focused methods (SendWithRedirectsAsync, ShouldFollowRedirect, ResolveRedirectUrl, CreateRedirectMessage, ParseResponseCookies, AddPendingCookies) to reduce cognitive complexity - Fix double-dispose warning (S3966) by using previousMessage pattern and try/finally for message disposal in SendWithRedirectsAsync - Fix duplicate Cookie header bug in AddCookieHeaders (remove existing parameter before adding merged cookies) - Add Host/CacheControl headers to redirect request messages - Add comments for intentional cert validation bypass in HTTPS tests Co-Authored-By: Claude Opus 4.6 * Reduce test code duplication flagged by SonarCloud - Move shared test endpoints (set-cookie-and-redirect, echo-cookies, redirect-no-query, redirect-custom-status) into WireMockTestServer - Switch CookieRedirectTests to use IClassFixture instead of standalone WireMockServer, eliminating cross-file duplication - Parameterize verb change tests with [Theory]/[InlineData] (5 tests → 1) - Parameterize header, auth, query, and HTTPS tests with [Theory] - Extract CreateClient helper to reduce setup boilerplate - CookieRedirectTests: 616 → 336 lines (45% reduction) Co-Authored-By: Claude Opus 4.6 * Strip Authorization header on cross-origin and HTTPS-to-HTTP redirects Address security concern from PR review: ForwardAuthorization could leak credentials to unintended hosts on redirect. - Compare full authority (host+port) against original request URL, matching browser same-origin policy - Always strip Authorization on HTTPS→HTTP redirects (defense-in-depth) - Add ForwardAuthorizationToExternalHost option (default false) for explicit opt-in to cross-origin auth forwarding - Add tests for cross-host auth stripping and explicit opt-in Co-Authored-By: Claude Opus 4.6 * Consolidate cross-host auth tests into parameterized Theory to reduce duplication Co-Authored-By: Claude Opus 4.6 * Reduce code duplication: consolidate ForwardBody tests and reuse shared EchoRequest - Merge ForwardBody_False and ForwardBody_True into a single parameterized Theory - Replace inline echo-request callback with shared WireMockTestServer.EchoRequest - Make EchoRequest public for cross-project reuse Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/RestSharp/Options/RedirectOptions.cs | 83 ++++ src/RestSharp/Options/RestClientOptions.cs | 23 +- src/RestSharp/Request/RequestHeaders.cs | 6 + src/RestSharp/RestClient.Async.cs | 258 ++++++++++-- src/RestSharp/RestClient.cs | 4 +- .../CookieRedirectTests.cs | 367 ++++++++++++++++++ .../Server/WireMockTestServer.cs | 120 ++++++ test/RestSharp.Tests/OptionsTests.cs | 6 +- 8 files changed, 817 insertions(+), 50 deletions(-) create mode 100644 src/RestSharp/Options/RedirectOptions.cs create mode 100644 test/RestSharp.Tests.Integrated/CookieRedirectTests.cs diff --git a/src/RestSharp/Options/RedirectOptions.cs b/src/RestSharp/Options/RedirectOptions.cs new file mode 100644 index 000000000..baaafdb86 --- /dev/null +++ b/src/RestSharp/Options/RedirectOptions.cs @@ -0,0 +1,83 @@ +// 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; + +/// +/// Options for controlling redirect behavior when RestSharp handles redirects. +/// +public class RedirectOptions { + /// + /// Whether to follow redirects. Default is true. + /// + public bool FollowRedirects { get; set; } = true; + + /// + /// Whether to follow redirects from HTTPS to HTTP (insecure). Default is false. + /// + public bool FollowRedirectsToInsecure { get; set; } + + /// + /// Whether to forward request headers on redirect. Default is true. + /// + public bool ForwardHeaders { get; set; } = true; + + /// + /// Whether to forward the Authorization header on same-host redirects. Default is false. + /// Even when enabled, Authorization is stripped on cross-host redirects unless + /// is also set to true. + /// + public bool ForwardAuthorization { get; set; } + + /// + /// Whether to forward the Authorization header when redirecting to a different host. Default is false. + /// Only applies when is true. Enabling this can expose credentials + /// to unintended hosts if a redirect points to a third-party server. + /// + public bool ForwardAuthorizationToExternalHost { get; set; } + + /// + /// Whether to forward cookies on redirect. Default is true. + /// Cookies from Set-Cookie headers are always stored in the CookieContainer regardless of this setting. + /// + public bool ForwardCookies { get; set; } = true; + + /// + /// Whether to forward the request body on redirect when the HTTP verb is preserved. Default is true. + /// Body is always dropped when the verb changes to GET. + /// + public bool ForwardBody { get; set; } = true; + + /// + /// Whether to forward original query string parameters on redirect. Default is true. + /// + public bool ForwardQuery { get; set; } = true; + + /// + /// Maximum number of redirects to follow. Default is 50. + /// + public int MaxRedirects { get; set; } = 50; + + /// + /// HTTP status codes that are considered redirects. + /// + public IReadOnlyList RedirectStatusCodes { get; set; } = [ + HttpStatusCode.MovedPermanently, // 301 + HttpStatusCode.Found, // 302 + HttpStatusCode.SeeOther, // 303 + HttpStatusCode.TemporaryRedirect, // 307 + (HttpStatusCode)308, // 308 Permanent Redirect + ]; +} diff --git a/src/RestSharp/Options/RestClientOptions.cs b/src/RestSharp/Options/RestClientOptions.cs index 8d279d736..5cd8b09a2 100644 --- a/src/RestSharp/Options/RestClientOptions.cs +++ b/src/RestSharp/Options/RestClientOptions.cs @@ -108,12 +108,19 @@ public RestClientOptions(string baseUrl) : this(new Uri(Ensure.NotEmptyString(ba #endif /// - /// Set the maximum number of redirects to follow + /// Set the maximum number of redirects to follow. + /// This is a convenience property that delegates to .MaxRedirects. /// #if NET [UnsupportedOSPlatform("browser")] #endif - public int? MaxRedirects { get; set; } + [Exclude] + public int? MaxRedirects { + get => RedirectOptions.MaxRedirects; + set { + if (value.HasValue) RedirectOptions.MaxRedirects = value.Value; + } + } /// /// X509CertificateCollection to be sent with request @@ -141,8 +148,18 @@ public RestClientOptions(string baseUrl) : this(new Uri(Ensure.NotEmptyString(ba /// /// Instruct the client to follow redirects. Default is true. + /// This is a convenience property that delegates to .FollowRedirects. + /// + [Exclude] + public bool FollowRedirects { + get => RedirectOptions.FollowRedirects; + set => RedirectOptions.FollowRedirects = value; + } + + /// + /// Options for controlling redirect behavior. /// - public bool FollowRedirects { get; set; } = true; + public RedirectOptions RedirectOptions { get; set; } = new(); /// /// Gets or sets a value that indicates if the header for an HTTP request contains Continue. diff --git a/src/RestSharp/Request/RequestHeaders.cs b/src/RestSharp/Request/RequestHeaders.cs index 10677d6e3..86d4be6fd 100644 --- a/src/RestSharp/Request/RequestHeaders.cs +++ b/src/RestSharp/Request/RequestHeaders.cs @@ -35,6 +35,11 @@ public RequestHeaders AddAcceptHeader(string[] acceptedContentTypes) { return this; } + public RequestHeaders RemoveHeader(string name) { + Parameters.RemoveAll(p => string.Equals(p.Name, name, StringComparison.InvariantCultureIgnoreCase)); + return this; + } + // Add Cookie header from the cookie container public RequestHeaders AddCookieHeaders(Uri uri, CookieContainer? cookieContainer) { if (cookieContainer == null) return this; @@ -48,6 +53,7 @@ public RequestHeaders AddCookieHeaders(Uri uri, CookieContainer? cookieContainer if (existing?.Value != null) { newCookies = newCookies.Union(SplitHeader(existing.Value!)); + Parameters.Remove(existing); } Parameters.Add(new(KnownHeaders.Cookie, string.Join("; ", newCookies))); diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 8beee730b..73ed0410b 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -1,11 +1,11 @@ -// Copyright (c) .NET Foundation and Contributors -// +// 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. @@ -19,7 +19,7 @@ namespace RestSharp; public partial class RestClient { - // Default HttpClient timeout + // Default HttpClient timeout readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(100); /// @@ -111,37 +111,21 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo await authenticator.Authenticate(this, request).ConfigureAwait(false); } - using var requestContent = new RequestContent(this, request); + var contentToDispose = new List(); + var initialContent = new RequestContent(this, request); + contentToDispose.Add(initialContent); var httpMethod = AsHttpMethod(request.Method); - var urlString = this.BuildUriString(request); - var url = new Uri(urlString); - - using var message = new HttpRequestMessage(httpMethod, urlString); - message.Content = requestContent.BuildContent(); - message.Headers.Host = Options.BaseHost; - message.Headers.CacheControl = request.CachePolicy ?? Options.CachePolicy; - message.Version = request.Version; + var url = new Uri(this.BuildUriString(request)); using var timeoutCts = new CancellationTokenSource(request.Timeout ?? Options.Timeout ?? _defaultTimeout); using var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); var ct = cts.Token; - HttpResponseMessage? responseMessage; // Make sure we have a cookie container if not provided in the request var cookieContainer = request.CookieContainer ??= new(); - - foreach (var cookie in request.PendingCookies) { - try { - cookieContainer.Add(url, cookie); - } - catch (CookieException) { - // Do not fail request if we cannot parse a cookie - } - } - - request.ClearPendingCookies(); + AddPendingCookies(cookieContainer, url, request); var headers = new RequestHeaders() .AddHeaders(request.Parameters) @@ -150,32 +134,224 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo .AddCookieHeaders(url, cookieContainer) .AddCookieHeaders(url, Options.CookieContainer); + var message = new HttpRequestMessage(httpMethod, url); + message.Content = initialContent.BuildContent(); + message.Headers.Host = Options.BaseHost; + message.Headers.CacheControl = request.CachePolicy ?? Options.CachePolicy; + message.Version = request.Version; message.AddHeaders(headers); + #pragma warning disable CS0618 // Type or member is obsolete if (request.OnBeforeRequest != null) await request.OnBeforeRequest(message).ConfigureAwait(false); #pragma warning restore CS0618 // Type or member is obsolete await OnBeforeHttpRequest(request, message, cancellationToken).ConfigureAwait(false); + var (responseMessage, finalUrl, error) = await SendWithRedirectsAsync( + message, url, httpMethod, request, cookieContainer, contentToDispose, ct + ).ConfigureAwait(false); + + DisposeContent(contentToDispose); + + if (error != null) { + return new(null, finalUrl, null, error, timeoutCts.Token); + } + +#pragma warning disable CS0618 // Type or member is obsolete + if (request.OnAfterRequest != null) await request.OnAfterRequest(responseMessage!).ConfigureAwait(false); +#pragma warning restore CS0618 // Type or member is obsolete + await OnAfterHttpRequest(request, responseMessage!, cancellationToken).ConfigureAwait(false); + return new(responseMessage, finalUrl, cookieContainer, null, timeoutCts.Token); + } + + async Task<(HttpResponseMessage? Response, Uri FinalUrl, Exception? Error)> SendWithRedirectsAsync( + HttpRequestMessage message, + Uri url, + HttpMethod httpMethod, + RestRequest request, + CookieContainer cookieContainer, + List contentToDispose, + CancellationToken ct + ) { + var redirectOptions = Options.RedirectOptions; + var redirectCount = 0; + var originalUrl = url; + try { - responseMessage = await HttpClient.SendAsync(message, request.CompletionOption, ct).ConfigureAwait(false); - - // Parse all the cookies from the response and update the cookie jar with cookies - if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader)) { - // ReSharper disable once PossibleMultipleEnumeration - cookieContainer.AddCookies(url, cookiesHeader); - // ReSharper disable once PossibleMultipleEnumeration - Options.CookieContainer?.AddCookies(url, cookiesHeader); + while (true) { + var responseMessage = await HttpClient.SendAsync(message, request.CompletionOption, ct).ConfigureAwait(false); + + ParseResponseCookies(responseMessage, url, cookieContainer); + + if (!ShouldFollowRedirect(redirectOptions, responseMessage, redirectCount)) { + return (responseMessage, url, null); + } + + var redirectUrl = ResolveRedirectUrl(url, responseMessage, redirectOptions); + + if (redirectUrl == null) { + return (responseMessage, url, null); + } + + var newMethod = GetRedirectMethod(httpMethod, responseMessage.StatusCode); + var verbChangedToGet = newMethod == HttpMethod.Get && httpMethod != HttpMethod.Get; + + responseMessage.Dispose(); + + var previousMessage = message; + url = redirectUrl; + httpMethod = newMethod; + redirectCount++; + + message = CreateRedirectMessage( + httpMethod, url, originalUrl, request, redirectOptions, cookieContainer, contentToDispose, verbChangedToGet + ); + previousMessage.Dispose(); } } catch (Exception ex) { - return new(null, url, null, ex, timeoutCts.Token); + return (null, url, ex); + } + finally { + message.Dispose(); } + } -#pragma warning disable CS0618 // Type or member is obsolete - if (request.OnAfterRequest != null) await request.OnAfterRequest(responseMessage).ConfigureAwait(false); -#pragma warning restore CS0618 // Type or member is obsolete - await OnAfterHttpRequest(request, responseMessage, cancellationToken).ConfigureAwait(false); - return new(responseMessage, url, cookieContainer, null, timeoutCts.Token); + static void AddPendingCookies(CookieContainer cookieContainer, Uri url, RestRequest request) { + foreach (var cookie in request.PendingCookies) { + try { + cookieContainer.Add(url, cookie); + } + catch (CookieException) { + // Do not fail request if we cannot parse a cookie + } + } + + request.ClearPendingCookies(); + } + + void ParseResponseCookies(HttpResponseMessage responseMessage, Uri url, CookieContainer cookieContainer) { + if (!responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader)) return; + + // ReSharper disable once PossibleMultipleEnumeration + cookieContainer.AddCookies(url, cookiesHeader); + // ReSharper disable once PossibleMultipleEnumeration + Options.CookieContainer?.AddCookies(url, cookiesHeader); + } + + static bool ShouldFollowRedirect(RedirectOptions options, HttpResponseMessage response, int redirectCount) + => options.FollowRedirects + && options.RedirectStatusCodes.Contains(response.StatusCode) + && response.Headers.Location != null + && redirectCount < options.MaxRedirects; + + static Uri? ResolveRedirectUrl(Uri currentUrl, HttpResponseMessage response, RedirectOptions options) { + var location = response.Headers.Location!; + var redirectUrl = location.IsAbsoluteUri ? location : new Uri(currentUrl, location); + + if (options.ForwardQuery && string.IsNullOrEmpty(redirectUrl.Query) && !string.IsNullOrEmpty(currentUrl.Query)) { + var builder = new UriBuilder(redirectUrl) { Query = currentUrl.Query.TrimStart('?') }; + redirectUrl = builder.Uri; + } + + // Block HTTPS -> HTTP unless explicitly allowed + if (currentUrl.Scheme == "https" && redirectUrl.Scheme == "http" && !options.FollowRedirectsToInsecure) { + return null; + } + + return redirectUrl; + } + + HttpRequestMessage CreateRedirectMessage( + HttpMethod httpMethod, + Uri url, + Uri originalUrl, + RestRequest request, + RedirectOptions redirectOptions, + CookieContainer cookieContainer, + List contentToDispose, + bool verbChangedToGet + ) { + var redirectMessage = new HttpRequestMessage(httpMethod, url); + redirectMessage.Version = request.Version; + redirectMessage.Headers.Host = Options.BaseHost; + redirectMessage.Headers.CacheControl = request.CachePolicy ?? Options.CachePolicy; + + if (!verbChangedToGet && redirectOptions.ForwardBody) { + var redirectContent = new RequestContent(this, request); + contentToDispose.Add(redirectContent); + redirectMessage.Content = redirectContent.BuildContent(); + } + + var redirectHeaders = BuildRedirectHeaders(url, originalUrl, redirectOptions, request, cookieContainer); + redirectMessage.AddHeaders(redirectHeaders); + + return redirectMessage; + } + + RequestHeaders BuildRedirectHeaders( + Uri url, Uri originalUrl, RedirectOptions redirectOptions, RestRequest request, CookieContainer cookieContainer + ) { + var redirectHeaders = new RequestHeaders(); + + if (redirectOptions.ForwardHeaders) { + redirectHeaders + .AddHeaders(request.Parameters) + .AddHeaders(DefaultParameters) + .AddAcceptHeader(AcceptedContentTypes); + + if (!ShouldForwardAuthorization(url, originalUrl, redirectOptions)) { + redirectHeaders.RemoveHeader(KnownHeaders.Authorization); + } + } + else { + redirectHeaders.AddAcceptHeader(AcceptedContentTypes); + } + + // Always remove existing Cookie headers before adding fresh ones from the container + redirectHeaders.RemoveHeader(KnownHeaders.Cookie); + + if (redirectOptions.ForwardCookies) { + redirectHeaders + .AddCookieHeaders(url, cookieContainer) + .AddCookieHeaders(url, Options.CookieContainer); + } + + return redirectHeaders; + } + + static bool ShouldForwardAuthorization(Uri redirectUrl, Uri originalUrl, RedirectOptions options) { + if (!options.ForwardAuthorization) return false; + + // Never forward credentials from HTTPS to HTTP (they would be sent in plaintext) + if (originalUrl.Scheme == "https" && redirectUrl.Scheme == "http") return false; + + // Compare full authority (host + port) to match browser same-origin policy + var isSameOrigin = string.Equals(redirectUrl.Authority, originalUrl.Authority, StringComparison.OrdinalIgnoreCase); + + return isSameOrigin || options.ForwardAuthorizationToExternalHost; + } + + static HttpMethod GetRedirectMethod(HttpMethod originalMethod, HttpStatusCode statusCode) { + // 307 and 308: always preserve the original method + if (statusCode is HttpStatusCode.TemporaryRedirect or (HttpStatusCode)308) { + return originalMethod; + } + + // 303: all methods except GET and HEAD become GET + if (statusCode == HttpStatusCode.SeeOther) { + return originalMethod == HttpMethod.Get || originalMethod == HttpMethod.Head + ? originalMethod + : HttpMethod.Get; + } + + // 301 and 302: POST becomes GET (matches browser/HttpClient behavior), others preserved + return originalMethod == HttpMethod.Post ? HttpMethod.Get : originalMethod; + } + + static void DisposeContent(List contentList) { + foreach (var content in contentList) { + content.Dispose(); + } } static async ValueTask OnBeforeRequest(RestRequest request, CancellationToken cancellationToken) { @@ -238,4 +414,4 @@ internal static HttpMethod AsHttpMethod(Method method) Method.Search => new("SEARCH"), _ => throw new ArgumentOutOfRangeException(nameof(method)) }; -} \ No newline at end of file +} diff --git a/src/RestSharp/RestClient.cs b/src/RestSharp/RestClient.cs index fe50cd607..d67a6fb7f 100644 --- a/src/RestSharp/RestClient.cs +++ b/src/RestSharp/RestClient.cs @@ -238,8 +238,6 @@ internal static void ConfigureHttpMessageHandler(HttpClientHandler handler, Rest if (options.Credentials != null) handler.Credentials = options.Credentials; handler.AutomaticDecompression = options.AutomaticDecompression; handler.PreAuthenticate = options.PreAuthenticate; - if (options.MaxRedirects.HasValue) handler.MaxAutomaticRedirections = options.MaxRedirects.Value; - if (options.RemoteCertificateValidationCallback != null) handler.ServerCertificateCustomValidationCallback = (request, cert, chain, errors) => options.RemoteCertificateValidationCallback(request, cert, chain, errors); @@ -251,7 +249,7 @@ internal static void ConfigureHttpMessageHandler(HttpClientHandler handler, Rest #if NET } #endif - handler.AllowAutoRedirect = options.FollowRedirects; + handler.AllowAutoRedirect = false; #if NET // ReSharper disable once InvertIf diff --git a/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs new file mode 100644 index 000000000..bd9b25e0c --- /dev/null +++ b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs @@ -0,0 +1,367 @@ +using System.Text.Json; +using WireMock; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using WireMock.Types; +using WireMock.Util; + +namespace RestSharp.Tests.Integrated; + +/// +/// Tests for cookie behavior during redirects and custom redirect handling. +/// Verifies fixes for https://github.com/restsharp/RestSharp/issues/2077 +/// and https://github.com/restsharp/RestSharp/issues/2059 +/// +public sealed class CookieRedirectTests(WireMockTestServer server) : IClassFixture, IDisposable { + readonly RestClient _client = new(server.Url!); + + RestClient CreateClient(Action? configure = null) { + var options = new RestClientOptions(server.Url!); + configure?.Invoke(options); + return new RestClient(options); + } + + // ─── Cookie tests ──────────────────────────────────────────────────── + + [Fact] + public async Task Redirect_Should_Forward_Cookies_Set_During_Redirect() { + using var client = CreateClient(o => o.CookieContainer = new()); + + var request = new RestRequest("/set-cookie-and-redirect"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content!.Should().Contain("redirectCookie", + "cookies from Set-Cookie headers on redirect responses should be forwarded to the final destination"); + } + + [Fact] + public async Task Redirect_Should_Capture_SetCookie_From_Redirect_In_CookieContainer() { + var cookieContainer = new CookieContainer(); + using var client = CreateClient(o => o.CookieContainer = cookieContainer); + + var request = new RestRequest("/set-cookie-and-redirect"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + cookieContainer.GetCookies(new Uri(server.Url!)).Cast() + .Should().Contain(c => c.Name == "redirectCookie" && c.Value == "value1", + "cookies from Set-Cookie headers on redirect responses should be stored in the CookieContainer"); + } + + [Fact] + public async Task Redirect_With_Existing_Cookies_Should_Include_Both_Old_And_New_Cookies() { + var host = new Uri(server.Url!).Host; + using var client = CreateClient(o => o.CookieContainer = new()); + + var request = new RestRequest("/set-cookie-and-redirect") { + CookieContainer = new() + }; + request.CookieContainer.Add(new Cookie("existingCookie", "existingValue", "/", host)); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content!.Should().Contain("existingCookie", + "pre-existing cookies should be forwarded through redirects"); + response.Content.Should().Contain("redirectCookie", + "cookies set during redirect should also arrive at the final destination"); + } + + // ─── FollowRedirects = false ───────────────────────────────────────── + + [Fact] + public async Task FollowRedirects_False_Should_Return_Redirect_Response() { + using var client = CreateClient(o => o.FollowRedirects = false); + + var request = new RestRequest("/set-cookie-and-redirect"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + } + + // ─── Max redirects ─────────────────────────────────────────────────── + + [Fact] + public async Task Should_Stop_After_MaxRedirects() { + using var client = CreateClient(o => o.RedirectOptions = new RedirectOptions { MaxRedirects = 3 }); + + var request = new RestRequest("/redirect-countdown?n=10"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect); + } + + [Fact] + public async Task Should_Follow_All_Redirects_When_Under_MaxRedirects() { + using var client = CreateClient(o => o.RedirectOptions = new RedirectOptions { MaxRedirects = 50 }); + + var request = new RestRequest("/redirect-countdown?n=5"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().Contain("Done!"); + } + + // ─── Verb changes (parameterized) ──────────────────────────────────── + + [Theory] + [InlineData(302, "GET")] + [InlineData(303, "GET")] + [InlineData(307, "POST")] + [InlineData(308, "POST")] + public async Task Post_Redirect_Should_Use_Expected_Method(int statusCode, string expectedMethod) { + using var client = CreateClient(); + + var request = new RestRequest($"/redirect-with-status?status={statusCode}", Method.Post); + request.AddJsonBody(new { data = "test" }); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var doc = JsonDocument.Parse(response.Content!); + doc.RootElement.GetProperty("Method").GetString().Should().Be(expectedMethod); + } + + [Fact] + public async Task Body_Should_Be_Dropped_When_Verb_Changes_To_Get() { + using var client = CreateClient(); + + var request = new RestRequest("/redirect-with-status?status=302", Method.Post); + request.AddJsonBody(new { data = "test" }); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var doc = JsonDocument.Parse(response.Content!); + doc.RootElement.GetProperty("Method").GetString().Should().Be("GET"); + doc.RootElement.GetProperty("Body").GetString().Should().BeEmpty(); + } + + // ─── Header forwarding ────────────────────────────────────────────── + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ForwardHeaders_Controls_Custom_Header_Forwarding(bool forwardHeaders) { + using var client = CreateClient(o => + o.RedirectOptions = new RedirectOptions { ForwardHeaders = forwardHeaders } + ); + + var request = new RestRequest("/redirect-with-status?status=302"); + request.AddHeader("X-Custom-Header", "custom-value"); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + if (forwardHeaders) + response.Content.Should().Contain("X-Custom-Header").And.Contain("custom-value"); + else + response.Content.Should().NotContain("X-Custom-Header"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ForwardAuthorization_Controls_Auth_Header_Forwarding(bool forwardAuth) { + using var client = CreateClient(o => + o.RedirectOptions = new RedirectOptions { ForwardAuthorization = forwardAuth } + ); + + var request = new RestRequest("/redirect-with-status?status=302"); + request.AddHeader("Authorization", "Bearer test-token"); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + if (forwardAuth) + response.Content.Should().Contain("Bearer test-token"); + else + response.Content.Should().NotContain("Bearer test-token"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, true)] + public async Task ForwardAuthorizationToExternalHost_Controls_Cross_Origin_Auth( + bool allowExternal, bool expectAuth + ) { + // Create a second server (different port = different origin) with echo endpoint + using var externalServer = WireMockServer.Start(); + externalServer + .Given(Request.Create().WithPath("/echo-request")) + .RespondWith(Response.Create().WithCallback(WireMockTestServer.EchoRequest)); + + // Main server redirects to the external server + var redirectPath = $"/redirect-external-{allowExternal}"; + server.Given(Request.Create().WithPath(redirectPath)) + .RespondWith(Response.Create().WithCallback(_ => new ResponseMessage { + StatusCode = 302, + Headers = new Dictionary> { + ["Location"] = new(externalServer.Url + "/echo-request") + } + })); + + using var client = CreateClient(o => + o.RedirectOptions = new RedirectOptions { + ForwardAuthorization = true, + ForwardAuthorizationToExternalHost = allowExternal + } + ); + + var request = new RestRequest(redirectPath); + request.AddHeader("Authorization", "Bearer secret-token"); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + if (expectAuth) + response.Content.Should().Contain("Bearer secret-token"); + else + response.Content.Should().NotContain("Bearer secret-token"); + } + + [Fact] + public async Task ForwardCookies_False_Should_Not_Send_Cookies_On_Redirect() { + var host = new Uri(server.Url!).Host; + var cookieContainer = new CookieContainer(); + using var client = CreateClient(o => { + o.CookieContainer = cookieContainer; + o.RedirectOptions = new RedirectOptions { ForwardCookies = false }; + }); + + var request = new RestRequest("/set-cookie-and-redirect?url=/echo-cookies") { + CookieContainer = new() + }; + request.CookieContainer.Add(new Cookie("existingCookie", "existingValue", "/", host)); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + cookieContainer.GetCookies(new Uri(server.Url!)).Cast().Should() + .Contain(c => c.Name == "redirectCookie", + "Set-Cookie should still be stored even when ForwardCookies is false"); + response.Content.Should().NotContain("existingCookie"); + response.Content.Should().NotContain("redirectCookie"); + } + + // ─── ForwardBody ──────────────────────────────────────────────────── + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ForwardBody_Controls_Body_Forwarding_When_Verb_Preserved(bool forwardBody) { + using var client = CreateClient(o => + o.RedirectOptions = new RedirectOptions { ForwardBody = forwardBody } + ); + + var request = new RestRequest("/redirect-with-status?status=307", Method.Post); + request.AddJsonBody(new { data = "test-body" }); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var doc = JsonDocument.Parse(response.Content!); + doc.RootElement.GetProperty("Method").GetString().Should().Be("POST"); + + if (forwardBody) + doc.RootElement.GetProperty("Body").GetString().Should().Contain("test-body"); + else + doc.RootElement.GetProperty("Body").GetString().Should().BeEmpty(); + } + + // ─── ForwardQuery ─────────────────────────────────────────────────── + + [Theory] + [InlineData(true, true)] + [InlineData(false, false)] + public async Task ForwardQuery_Controls_Query_String_Forwarding(bool forwardQuery, bool expectQuery) { + using var client = CreateClient(o => + o.RedirectOptions = new RedirectOptions { ForwardQuery = forwardQuery } + ); + + var request = new RestRequest("/redirect-no-query"); + request.AddQueryParameter("foo", "bar"); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + if (expectQuery) + response.ResponseUri!.Query.Should().Contain("foo=bar"); + else + (response.ResponseUri!.Query ?? "").Should().NotContain("foo=bar"); + } + + // ─── RedirectStatusCodes customization ────────────────────────────── + + [Fact] + public async Task Custom_RedirectStatusCodes_Should_Follow_Custom_Code() { + using var client = CreateClient(o => + o.RedirectOptions = new RedirectOptions { + RedirectStatusCodes = [HttpStatusCode.Found, (HttpStatusCode)399] + } + ); + + var request = new RestRequest("/redirect-custom-status?status=399"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK, + "399 should be treated as a redirect because it's in RedirectStatusCodes"); + } + + [Fact] + public async Task Custom_RedirectStatusCodes_Should_Not_Follow_Excluded_Code() { + using var client = CreateClient(o => + o.RedirectOptions = new RedirectOptions { + RedirectStatusCodes = [(HttpStatusCode)399] + } + ); + + var request = new RestRequest("/redirect-with-status?status=302"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.Found, + "302 should NOT be followed because it's not in the custom RedirectStatusCodes"); + } + + // ─── FollowRedirectsToInsecure ────────────────────────────────────── + + [Theory] + [InlineData(false, HttpStatusCode.Redirect)] + [InlineData(true, HttpStatusCode.OK)] + public async Task FollowRedirectsToInsecure_Controls_Https_To_Http_Redirect( + bool allowInsecure, HttpStatusCode expectedStatus + ) { + using var httpsServer = WireMockServer.Start(new WireMock.Settings.WireMockServerSettings { + Port = 0, + UseSSL = true + }); + + httpsServer + .Given(Request.Create().WithPath("/https-redirect")) + .RespondWith(Response.Create().WithCallback(_ => new ResponseMessage { + StatusCode = 302, + Headers = new Dictionary> { + ["Location"] = new(server.Url + "/echo-request") + } + })); + + using var client = new RestClient(new RestClientOptions(httpsServer.Url!) { + // Cert validation disabled intentionally: local test HTTPS server uses self-signed cert + RemoteCertificateValidationCallback = (_, _, _, _) => true, + RedirectOptions = new RedirectOptions { FollowRedirectsToInsecure = allowInsecure } + }); + + var request = new RestRequest("/https-redirect"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(expectedStatus); + } + + public void Dispose() => _client.Dispose(); +} diff --git a/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs b/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs index 527e3d5a0..1df67db33 100644 --- a/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs +++ b/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs @@ -44,6 +44,32 @@ public WireMockTestServer() : base(new() { Port = 0, UseHttp2 = false, UseSSL = Given(Request.Create().WithPath("/headers")) .RespondWith(Response.Create().WithCallback(EchoHeaders)); + + Given(Request.Create().WithPath("/redirect-countdown")) + .RespondWith(Response.Create().WithCallback(RedirectCountdown)); + + Given(Request.Create().WithPath("/redirect-with-status")) + .RespondWith(Response.Create().WithCallback(RedirectWithStatus)); + + Given(Request.Create().WithPath("/echo-request")) + .RespondWith(Response.Create().WithCallback(EchoRequest)); + + Given(Request.Create().WithPath("/set-cookie-and-redirect").UsingGet()) + .RespondWith(Response.Create().WithCallback(SetCookieAndRedirect)); + + Given(Request.Create().WithPath("/echo-cookies").UsingGet()) + .RespondWith(Response.Create().WithCallback(EchoCookies)); + + Given(Request.Create().WithPath("/redirect-no-query")) + .RespondWith(Response.Create().WithCallback(_ => new ResponseMessage { + StatusCode = 302, + Headers = new Dictionary> { + ["Location"] = new("/echo-request") + } + })); + + Given(Request.Create().WithPath("/redirect-custom-status")) + .RespondWith(Response.Create().WithCallback(RedirectCustomStatus)); } static ResponseMessage WrapForm(IRequestMessage request) { @@ -94,6 +120,100 @@ static ResponseMessage StatusCode(IRequestMessage request) { }; } + static ResponseMessage RedirectCountdown(IRequestMessage request) { + var n = 1; + + if (request.Query != null && request.Query.TryGetValue("n", out var nValues)) { + n = int.Parse(nValues[0]); + } + + if (n <= 1) { + return CreateJson(new SuccessResponse("Done!")); + } + + return new ResponseMessage { + StatusCode = (int)HttpStatusCode.TemporaryRedirect, + Headers = new Dictionary> { + ["Location"] = new($"/redirect-countdown?n={n - 1}") + } + }; + } + + static ResponseMessage RedirectWithStatus(IRequestMessage request) { + var status = 302; + var url = "/echo-request"; + + if (request.Query != null) { + if (request.Query.TryGetValue("status", out var statusValues)) { + status = int.Parse(statusValues[0]); + } + + if (request.Query.TryGetValue("url", out var urlValues)) { + url = urlValues[0]; + } + } + + return new ResponseMessage { + StatusCode = status, + Headers = new Dictionary> { + ["Location"] = new(url) + } + }; + } + + public static ResponseMessage EchoRequest(IRequestMessage request) { + var headers = request.Headers? + .ToDictionary(x => x.Key, x => string.Join(", ", x.Value)) + ?? new Dictionary(); + + return CreateJson(new { + Method = request.Method, + Headers = headers, + Body = request.Body ?? "" + }); + } + + static ResponseMessage SetCookieAndRedirect(IRequestMessage request) { + var url = "/echo-cookies"; + if (request.Query != null && request.Query.TryGetValue("url", out var urlValues)) + url = urlValues[0]; + + return new ResponseMessage { + StatusCode = 302, + Headers = new Dictionary> { + ["Location"] = new(url), + ["Set-Cookie"] = new("redirectCookie=value1; Path=/") + } + }; + } + + static ResponseMessage EchoCookies(IRequestMessage request) { + var cookieHeaders = new List(); + if (request.Headers != null && request.Headers.TryGetValue("Cookie", out var values)) + cookieHeaders.AddRange(values); + + var parsedCookies = request.Cookies?.Select(x => $"{x.Key}={x.Value}").ToList() + ?? new List(); + + return CreateJson(new { + RawCookieHeaders = cookieHeaders, + ParsedCookies = parsedCookies + }); + } + + static ResponseMessage RedirectCustomStatus(IRequestMessage request) { + var status = 399; + if (request.Query != null && request.Query.TryGetValue("status", out var statusValues)) + status = int.Parse(statusValues[0]); + + return new ResponseMessage { + StatusCode = status, + Headers = new Dictionary> { + ["Location"] = new("/echo-request") + } + }; + } + public static ResponseMessage CreateJson(object response) => new() { BodyData = new BodyData { diff --git a/test/RestSharp.Tests/OptionsTests.cs b/test/RestSharp.Tests/OptionsTests.cs index d1d8aa372..9a310ef13 100644 --- a/test/RestSharp.Tests/OptionsTests.cs +++ b/test/RestSharp.Tests/OptionsTests.cs @@ -2,11 +2,11 @@ namespace RestSharp.Tests; public class OptionsTests { [Fact] - public void Ensure_follow_redirect() { - var value = false; + public void HttpClient_AllowAutoRedirect_Is_Always_False() { + var value = true; var options = new RestClientOptions { FollowRedirects = true, ConfigureMessageHandler = Configure }; using var _ = new RestClient(options); - value.Should().BeTrue(); + value.Should().BeFalse("RestSharp handles redirects internally"); return; HttpMessageHandler Configure(HttpMessageHandler handler) { From 8cafdd1dbc83b42c942c6bfb75dd1288f5263599 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 17:26:54 +0100 Subject: [PATCH 19/27] Stop modifying HttpClient.DefaultRequestHeaders (#2363) * Stop modifying HttpClient.DefaultRequestHeaders (#2156) Move Expect100Continue from HttpClient.DefaultRequestHeaders to per-request HttpRequestMessage.Headers so that shared HttpClient instances are not mutated by RestClient configuration. Co-Authored-By: Claude Opus 4.6 * Set ExpectContinue on redirect messages too Apply Options.Expect100Continue to HttpRequestMessages created during redirect handling, matching the behavior of the initial request. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../ServiceCollectionExtensions.cs | 1 - src/RestSharp/RestClient.Async.cs | 16 ++++++------ src/RestSharp/RestClient.cs | 6 ----- test/RestSharp.Tests/RestClientTests.cs | 25 +++++++++++++++++++ 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs index d1f13c36d..4b74607ba 100644 --- a/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/RestSharp.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -26,7 +26,6 @@ public void AddRestClient( services .AddHttpClient(name) - .ConfigureHttpClient(client => RestClient.ConfigureHttpClient(client, options)) .ConfigurePrimaryHttpMessageHandler(() => { var handler = new HttpClientHandler(); RestClient.ConfigureHttpMessageHandler(handler, options); diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 73ed0410b..269a680de 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -135,10 +135,11 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo .AddCookieHeaders(url, Options.CookieContainer); var message = new HttpRequestMessage(httpMethod, url); - message.Content = initialContent.BuildContent(); - message.Headers.Host = Options.BaseHost; - message.Headers.CacheControl = request.CachePolicy ?? Options.CachePolicy; - message.Version = request.Version; + message.Content = initialContent.BuildContent(); + message.Headers.Host = Options.BaseHost; + message.Headers.CacheControl = request.CachePolicy ?? Options.CachePolicy; + message.Headers.ExpectContinue = Options.Expect100Continue; + message.Version = request.Version; message.AddHeaders(headers); #pragma warning disable CS0618 // Type or member is obsolete @@ -272,9 +273,10 @@ HttpRequestMessage CreateRedirectMessage( bool verbChangedToGet ) { var redirectMessage = new HttpRequestMessage(httpMethod, url); - redirectMessage.Version = request.Version; - redirectMessage.Headers.Host = Options.BaseHost; - redirectMessage.Headers.CacheControl = request.CachePolicy ?? Options.CachePolicy; + redirectMessage.Version = request.Version; + redirectMessage.Headers.Host = Options.BaseHost; + redirectMessage.Headers.CacheControl = request.CachePolicy ?? Options.CachePolicy; + redirectMessage.Headers.ExpectContinue = Options.Expect100Continue; if (!verbChangedToGet && redirectOptions.ForwardBody) { var redirectContent = new RequestContent(this, request); diff --git a/src/RestSharp/RestClient.cs b/src/RestSharp/RestClient.cs index d67a6fb7f..9ab456975 100644 --- a/src/RestSharp/RestClient.cs +++ b/src/RestSharp/RestClient.cs @@ -93,7 +93,6 @@ HttpClient GetClient() { ConfigureHttpMessageHandler(handler, options); var finalHandler = options.ConfigureMessageHandler?.Invoke(handler) ?? handler; var httpClient = new HttpClient(finalHandler); - ConfigureHttpClient(httpClient, options); // We will use Options.Timeout in ExecuteAsInternalAsync method httpClient.Timeout = Timeout.InfiniteTimeSpan; @@ -188,7 +187,6 @@ public RestClient( DefaultParameters = new(Options); if (options != null) { - ConfigureHttpClient(httpClient, options); ConfigureDefaultParameters(options); } } @@ -224,10 +222,6 @@ public RestClient( ) : this(new HttpClient(handler, disposeHandler), true, configureRestClient, configureSerialization) { } - internal static void ConfigureHttpClient(HttpClient httpClient, RestClientOptions options) { - if (options.Expect100Continue != null) httpClient.DefaultRequestHeaders.ExpectContinue = options.Expect100Continue; - } - // ReSharper disable once CognitiveComplexity internal static void ConfigureHttpMessageHandler(HttpClientHandler handler, RestClientOptions options) { #if NET diff --git a/test/RestSharp.Tests/RestClientTests.cs b/test/RestSharp.Tests/RestClientTests.cs index 0bb54b328..2c82278f1 100644 --- a/test/RestSharp.Tests/RestClientTests.cs +++ b/test/RestSharp.Tests/RestClientTests.cs @@ -123,4 +123,29 @@ public void ConfigureDefaultParameters_sets_user_agent_given_httpClient_instance Assert.Empty(httpClient.DefaultRequestHeaders.UserAgent); } + + [Fact] + public void Should_not_set_expect_continue_on_shared_http_client_default_headers() { + // arrange + var httpClient = new HttpClient(); + var options = new RestClientOptions { Expect100Continue = true }; + + // act + using var restClient = new RestClient(httpClient, options); + + // assert — the shared HttpClient's DefaultRequestHeaders must not be modified + httpClient.DefaultRequestHeaders.ExpectContinue.Should().BeNull(); + } + + [Fact] + public void Should_not_set_expect_continue_on_new_http_client_default_headers() { + // arrange + var options = new RestClientOptions { Expect100Continue = false }; + + // act + using var restClient = new RestClient(options); + + // assert + restClient.HttpClient.DefaultRequestHeaders.ExpectContinue.Should().BeNull(); + } } \ No newline at end of file From 47ca3149dadf866e402b50cf82572b12bec35ebd Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 17:39:29 +0100 Subject: [PATCH 20/27] Add OAuth2 token lifecycle authenticators (#2361) (#2362) * Add design doc for OAuth2 token lifecycle authenticators (#2101) Co-Authored-By: Claude Opus 4.6 * Add implementation plan for OAuth2 token lifecycle authenticators Co-Authored-By: Claude Opus 4.6 * feat: add OAuth2 token data models (RFC 6749) Co-Authored-By: Claude Opus 4.6 * feat: add OAuth2 client credentials authenticator with token lifecycle Co-Authored-By: Claude Opus 4.6 * test: add tests for OAuth2 client credentials authenticator Co-Authored-By: Claude Opus 4.6 * feat: add OAuth2 refresh token authenticator with token lifecycle Co-Authored-By: Claude Opus 4.6 * test: add tests for OAuth2 refresh token authenticator Co-Authored-By: Claude Opus 4.6 * feat: add generic OAuth2 token authenticator with delegate provider Co-Authored-By: Claude Opus 4.6 * test: add tests for generic OAuth2 token authenticator Co-Authored-By: Claude Opus 4.6 * docs: add documentation for OAuth2 token lifecycle authenticators Co-Authored-By: Claude Opus 4.6 * refactor: extract shared token endpoint logic into base class OAuth2ClientCredentialsAuthenticator and OAuth2RefreshTokenAuthenticator shared ~60 lines of identical code for HttpClient management, locking, token parsing, error handling, and disposal. Extract into OAuth2EndpointAuthenticatorBase. Subclasses now only provide grant-specific parameters and post-response hooks. Fixes SonarCloud duplication gate (4.5% > 3% threshold). Co-Authored-By: Claude Opus 4.6 * Address Qodo code review: CancellationToken, nullable ExpiresIn, scope on refresh - Add optional CancellationToken to IAuthenticator.Authenticate and propagate it through all authenticators to SemaphoreSlim.WaitAsync, HttpClient.PostAsync, and the user delegate in OAuth2TokenAuthenticator - Make OAuth2TokenResponse.ExpiresIn nullable (int?) so missing expires_in from the server is treated as non-expiring instead of causing a refresh storm - Send scope parameter in OAuth2RefreshTokenAuthenticator when configured Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- docs/docs/advanced/authenticators.md | 94 +- docs/docs/usage/example.md | 81 +- ...026-03-01-oauth2-token-lifecycle-design.md | 100 ++ .../2026-03-01-oauth2-token-lifecycle-plan.md | 994 ++++++++++++++++++ .../Authenticators/AuthenticatorBase.cs | 2 +- .../Authenticators/IAuthenticator.cs | 2 +- .../OAuth/OAuth1Authenticator.cs | 2 +- .../OAuth2ClientCredentialsAuthenticator.cs | 38 + .../OAuth2/OAuth2EndpointAuthenticatorBase.cs | 117 +++ .../OAuth2/OAuth2RefreshTokenAuthenticator.cs | 59 ++ .../Authenticators/OAuth2/OAuth2Token.cs | 22 + .../OAuth2/OAuth2TokenAuthenticator.cs | 66 ++ .../OAuth2/OAuth2TokenRequest.cs | 75 ++ .../OAuth2/OAuth2TokenResponse.cs | 38 + src/RestSharp/RestClient.Async.cs | 2 +- .../Auth/AuthenticatorTests.cs | 2 +- ...uth2ClientCredentialsAuthenticatorTests.cs | 202 ++++ .../OAuth2RefreshTokenAuthenticatorTests.cs | 211 ++++ .../Auth/OAuth2TokenAuthenticatorTests.cs | 97 ++ 19 files changed, 2135 insertions(+), 69 deletions(-) create mode 100644 docs/plans/2026-03-01-oauth2-token-lifecycle-design.md create mode 100644 docs/plans/2026-03-01-oauth2-token-lifecycle-plan.md create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2EndpointAuthenticatorBase.cs create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2Token.cs create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2TokenRequest.cs create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs create mode 100644 test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs create mode 100644 test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs create mode 100644 test/RestSharp.Tests/Auth/OAuth2TokenAuthenticatorTests.cs diff --git a/docs/docs/advanced/authenticators.md b/docs/docs/advanced/authenticators.md index 14196d97c..c80deb891 100644 --- a/docs/docs/advanced/authenticators.md +++ b/docs/docs/advanced/authenticators.md @@ -128,7 +128,95 @@ var authenticator = OAuth1Authenticator.ForAccessToken( ## OAuth2 -RestSharp has two very simple authenticators to send the access token as part of the request. +RestSharp provides OAuth2 authenticators at two levels: **token lifecycle authenticators** that handle the full flow (obtaining, caching, and refreshing tokens automatically), and **simple authenticators** that just stamp a pre-obtained token onto requests. + +### Token lifecycle authenticators + +These authenticators manage tokens end-to-end. They use their own internal `HttpClient` for token endpoint calls, so there's no circular dependency with the `RestClient` they're attached to. All are thread-safe for concurrent use. + +#### Client credentials + +Use `OAuth2ClientCredentialsAuthenticator` for machine-to-machine flows. It POSTs `grant_type=client_credentials` to your token endpoint, caches the token, and refreshes it automatically before it expires. + +```csharp +var request = new OAuth2TokenRequest( + "https://auth.example.com/oauth2/token", + "my-client-id", + "my-client-secret" +) { + Scope = "api.read api.write" +}; + +var options = new RestClientOptions("https://api.example.com") { + Authenticator = new OAuth2ClientCredentialsAuthenticator(request) +}; +using var client = new RestClient(options); +``` + +The authenticator will obtain a token on the first request and reuse it until it expires. The `ExpiryBuffer` property (default 30 seconds) controls how far in advance of actual expiry the token is considered stale. + +#### Refresh token + +Use `OAuth2RefreshTokenAuthenticator` when you already have an access token and refresh token (e.g., from an authorization code flow). It uses the initial access token until it expires, then automatically refreshes using the `refresh_token` grant type. + +```csharp +var request = new OAuth2TokenRequest( + "https://auth.example.com/oauth2/token", + "my-client-id", + "my-client-secret" +) { + OnTokenRefreshed = response => { + // Persist the new tokens to your storage + SaveTokens(response.AccessToken, response.RefreshToken); + } +}; + +var options = new RestClientOptions("https://api.example.com") { + Authenticator = new OAuth2RefreshTokenAuthenticator( + request, + accessToken: "current-access-token", + refreshToken: "current-refresh-token", + expiresAt: DateTimeOffset.UtcNow.AddMinutes(30) + ) +}; +using var client = new RestClient(options); +``` + +If the server rotates refresh tokens, the authenticator will automatically use the new refresh token for subsequent refreshes. The `OnTokenRefreshed` callback fires every time a new token is obtained, so you can persist the updated tokens. + +#### Custom token provider + +Use `OAuth2TokenAuthenticator` when you have a non-standard token flow or want full control over how tokens are obtained. Provide an async delegate that returns an `OAuth2Token`: + +```csharp +var options = new RestClientOptions("https://api.example.com") { + Authenticator = new OAuth2TokenAuthenticator(async cancellationToken => { + var token = await myCustomTokenService.GetTokenAsync(cancellationToken); + return new OAuth2Token(token.Value, token.ExpiresAt); + }) +}; +using var client = new RestClient(options); +``` + +The authenticator caches the result and re-invokes your delegate when the token expires. + +#### Bringing your own HttpClient + +By default, the token lifecycle authenticators create their own `HttpClient` for token endpoint calls (and dispose it when the authenticator is disposed). If you need to customize it (e.g., for proxy settings or mTLS), pass your own: + +```csharp +var request = new OAuth2TokenRequest( + "https://auth.example.com/oauth2/token", + "my-client-id", + "my-client-secret" +) { + HttpClient = myCustomHttpClient // not disposed by the authenticator +}; +``` + +### Simple authenticators + +If you manage tokens yourself and just need to stamp them onto requests, use these simpler authenticators. `OAuth2UriQueryParameterAuthenticator` accepts the access token as the only constructor argument, and it will send the provided token as a query parameter `oauth_token`. @@ -148,8 +236,6 @@ var client = new RestClient(options); The code above will tell RestSharp to send the bearer token with each request as a header. Essentially, the code above does the same as the sample for `JwtAuthenticator` below. -As those authenticators don't do much to get the token itself, you might be interested in looking at our [sample OAuth2 authenticator](../usage/example.md#authenticator), which requests the token on its own. - ## JWT The JWT authentication can be supported by using `JwtAuthenticator`. It is a very simple class that can be constructed like this: @@ -182,4 +268,4 @@ var client = new RestClient(options); The `Authenticate` method is the very first thing called upon calling `RestClient.Execute` or `RestClient.Execute`. It gets the `RestRequest` currently being executed giving you access to every part of the request data (headers, parameters, etc.) -You can find an example of a custom authenticator that fetches and uses an OAuth2 bearer token [here](../usage/example.md#authenticator). +You can find an example of using the built-in OAuth2 authenticator in a typed API client [here](../usage/example.md#authenticator). diff --git a/docs/docs/usage/example.md b/docs/docs/usage/example.md index 6182d8ff3..ed3b907bb 100644 --- a/docs/docs/usage/example.md +++ b/docs/docs/usage/example.md @@ -45,7 +45,14 @@ public class TwitterClient : ITwitterClient, IDisposable { readonly RestClient _client; public TwitterClient(string apiKey, string apiKeySecret) { - var options = new RestClientOptions("https://api.twitter.com/2"); + var tokenRequest = new OAuth2TokenRequest( + "https://api.twitter.com/oauth2/token", + apiKey, + apiKeySecret + ); + var options = new RestClientOptions("https://api.twitter.com/2") { + Authenticator = new OAuth2ClientCredentialsAuthenticator(tokenRequest) + }; _client = new RestClient(options); } @@ -79,73 +86,27 @@ public TwitterClient(IOptions options) { Then, you can register and configure the client using ASP.NET Core dependency injection container. -Right now, the client won't really work as Twitter API requires authentication. It's covered in the next section. +Notice the client constructor already configures the `OAuth2ClientCredentialsAuthenticator`. The authenticator setup is described in the next section. ## Authenticator -Before we can call the API itself, we need to get a bearer token. Twitter exposes an endpoint `https://api.twitter.com/oauth2/token`. As it follows the OAuth2 conventions, the code can be used to create an authenticator for some other vendors. - -First, we need a model for deserializing the token endpoint response. OAuth2 uses snake case for property naming, so we need to decorate model properties with `JsonPropertyName` attribute: - -```csharp -record TokenResponse { - [JsonPropertyName("token_type")] - public string TokenType { get; init; } - [JsonPropertyName("access_token")] - public string AccessToken { get; init; } -} -``` - -Next, we create the authenticator itself. It needs the API key and API key secret to call the token endpoint using basic HTTP authentication. In addition, we can extend the list of parameters with the base URL to convert it to a more generic OAuth2 authenticator. - -The easiest way to create an authenticator is to inherit from the `AuthenticatorBase` base class: - -```csharp -public class TwitterAuthenticator : AuthenticatorBase { - readonly string _baseUrl; - readonly string _clientId; - readonly string _clientSecret; - - public TwitterAuthenticator(string baseUrl, string clientId, string clientSecret) : base("") { - _baseUrl = baseUrl; - _clientId = clientId; - _clientSecret = clientSecret; - } - - protected override async ValueTask GetAuthenticationParameter(string accessToken) { - Token = string.IsNullOrEmpty(Token) ? await GetToken() : Token; - return new HeaderParameter(KnownHeaders.Authorization, Token); - } -} -``` - -During the first call made by the client using the authenticator, it will find out that the `Token` property is empty. It will then call the `GetToken` function to get the token once and reuse the token going forward. - -Now, we need to implement the `GetToken` function in the class: +Before we can call the API itself, we need to get a bearer token. Twitter exposes an endpoint `https://api.twitter.com/oauth2/token`. As it follows the standard OAuth2 client credentials convention, we can use the built-in `OAuth2ClientCredentialsAuthenticator`: ```csharp -async Task GetToken() { - var options = new RestClientOptions(_baseUrl){ - Authenticator = new HttpBasicAuthenticator(_clientId, _clientSecret), - }; - using var client = new RestClient(options); - - var request = new RestRequest("oauth2/token") - .AddParameter("grant_type", "client_credentials"); - var response = await client.PostAsync(request); - return $"{response!.TokenType} {response!.AccessToken}"; -} +var tokenRequest = new OAuth2TokenRequest( + "https://api.twitter.com/oauth2/token", + apiKey, + apiKeySecret +); + +var options = new RestClientOptions("https://api.twitter.com/2") { + Authenticator = new OAuth2ClientCredentialsAuthenticator(tokenRequest) +}; ``` -As we need to make a call to the token endpoint, we need our own short-lived instance of `RestClient`. Unlike the actual Twitter client, it will use the `HttpBasicAuthenticator` to send the API key and secret as the username and password. The client then gets disposed as we only use it once. - -Here we add a POST parameter `grant_type` with `client_credentials` as its value. At the moment, it's the only supported value. - -The POST request will use the `application/x-www-form-urlencoded` content type by default. +The authenticator will automatically obtain a token on the first request, cache it, and refresh it when it expires. It uses its own `HttpClient` internally for token endpoint calls, so there's no circular dependency with the `RestClient`. -::: note -Sample code provided on this page is a production code. For example, the authenticator might produce undesired side effect when multiple requests are made at the same time when the token hasn't been obtained yet. It can be solved rather than simply using semaphores or synchronized invocation. -::: +For more details on the available OAuth2 authenticators (including refresh token flows and custom token providers), see [Authenticators](../advanced/authenticators.md#oauth2). ## Final words diff --git a/docs/plans/2026-03-01-oauth2-token-lifecycle-design.md b/docs/plans/2026-03-01-oauth2-token-lifecycle-design.md new file mode 100644 index 000000000..e43141153 --- /dev/null +++ b/docs/plans/2026-03-01-oauth2-token-lifecycle-design.md @@ -0,0 +1,100 @@ +# OAuth2 Token Lifecycle Authenticators + +**Issue:** [#2101](https://github.com/restsharp/RestSharp/issues/2101) +**Date:** 2026-03-01 + +## Problem + +The existing OAuth2 authenticators are static token stampers — they take a pre-obtained token and add it to requests. Users who need automatic token acquisition, caching, and refresh hit a circular dependency: the authenticator needs an HttpClient to call the token endpoint, but it lives inside the RestClient it's attached to. + +## Solution + +Self-contained OAuth2 authenticators that manage the full token lifecycle using their own internal HttpClient for token endpoint calls. + +## Components + +### OAuth2TokenResponse + +RFC 6749 Section 5.1 token response model. Used for deserializing token endpoint responses. + +Fields: `AccessToken`, `TokenType`, `ExpiresIn`, `RefreshToken` (optional), `Scope` (optional). Deserialized with `System.Text.Json` using `JsonPropertyName` attributes for snake_case mapping. + +### OAuth2TokenRequest + +Shared configuration for token endpoint calls. + +- `TokenEndpointUrl` (required) — URL of the OAuth2 token endpoint +- `ClientId` (required) — OAuth2 client ID +- `ClientSecret` (required) — OAuth2 client secret +- `Scope` (optional) — requested scope +- `ExtraParameters` (optional) — additional form parameters +- `HttpClient` (optional) — bring your own HttpClient for token calls +- `ExpiryBuffer` — refresh before actual expiry (default 30s) +- `OnTokenRefreshed` — callback fired when a new token is obtained + +### OAuth2Token + +Simple record `(string AccessToken, DateTimeOffset ExpiresAt)` for the generic authenticator's delegate return type. + +### OAuth2ClientCredentialsAuthenticator + +Machine-to-machine flow. POSTs `grant_type=client_credentials` to the token endpoint. Caches the token and refreshes when expired. Thread-safe via SemaphoreSlim with double-check pattern. Implements IDisposable to clean up owned HttpClient. + +### OAuth2RefreshTokenAuthenticator + +User token flow. Takes initial access + refresh tokens. When the access token expires, POSTs `grant_type=refresh_token`. Updates the cached refresh token if the server rotates it. Fires `OnTokenRefreshed` callback so callers can persist new tokens. + +### OAuth2TokenAuthenticator + +Generic/delegate-based. Takes `Func>`. For non-standard flows where users provide their own token acquisition logic. Caches the result and re-invokes the delegate on expiry. + +## Data Flow + +``` +Request → Authenticate() + → cached token valid? → stamp Authorization header + → expired? → acquire SemaphoreSlim + → double-check still expired + → POST to token endpoint (own HttpClient) + → parse OAuth2TokenResponse + → cache token, compute expiry (ExpiresIn - ExpiryBuffer) + → fire OnTokenRefreshed callback + → stamp Authorization header +``` + +## Error Handling + +- Non-2xx from token endpoint: throw HttpRequestException with status and body +- Missing access_token in response: throw InvalidOperationException +- No retry logic — callers control retries at RestClient level + +## Thread Safety + +SemaphoreSlim(1, 1) with double-check pattern. One thread refreshes; concurrent callers wait and reuse the new token. + +## IDisposable + +Authenticators that create their own HttpClient dispose it. User-provided HttpClient is not disposed. Same pattern as RestClient itself. + +## Multi-targeting + +System.Text.Json for deserialization — NuGet package on netstandard2.0/net471/net48, built-in on net8.0+. No conditional compilation needed. + +## Files + +``` +src/RestSharp/Authenticators/OAuth2/ + OAuth2TokenResponse.cs (new) + OAuth2TokenRequest.cs (new) + OAuth2Token.cs (new) + OAuth2ClientCredentialsAuthenticator.cs (new) + OAuth2RefreshTokenAuthenticator.cs (new) + OAuth2TokenAuthenticator.cs (new) + +test/RestSharp.Tests/Auth/ + OAuth2ClientCredentialsAuthenticatorTests.cs (new) + OAuth2RefreshTokenAuthenticatorTests.cs (new) + OAuth2TokenAuthenticatorTests.cs (new) +``` + +No changes to existing files. No API breaks. diff --git a/docs/plans/2026-03-01-oauth2-token-lifecycle-plan.md b/docs/plans/2026-03-01-oauth2-token-lifecycle-plan.md new file mode 100644 index 000000000..9e93b95c5 --- /dev/null +++ b/docs/plans/2026-03-01-oauth2-token-lifecycle-plan.md @@ -0,0 +1,994 @@ +# OAuth2 Token Lifecycle Authenticators — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add self-contained OAuth2 authenticators that handle token acquisition, caching, expiry, and refresh internally — fixing issue #2101. + +**Architecture:** Three new authenticators (`OAuth2ClientCredentialsAuthenticator`, `OAuth2RefreshTokenAuthenticator`, `OAuth2TokenAuthenticator`) that own their own `HttpClient` for token endpoint calls, avoiding the circular dependency. They share a common `OAuth2TokenResponse` model (RFC 6749) and `OAuth2TokenRequest` config class. Thread-safe via `SemaphoreSlim`. + +**Tech Stack:** C# preview, System.Text.Json, xUnit + FluentAssertions + RichardSzalay.MockHttp + +**Conventions:** +- All `/src` files need the Apache-2.0 license header (see existing files for exact text) +- All public types get `[PublicAPI]` attribute (from JetBrains.Annotations, auto-imported via `src/Directory.Build.props`) +- Namespace: `RestSharp.Authenticators.OAuth2` (matches existing OAuth2 authenticators) +- Tests: nullable disabled, global usings for `Xunit`, `FluentAssertions`, `AutoFixture` already configured +- Build: `dotnet build RestSharp.slnx -c Debug` +- Test: `dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net9.0` + +--- + +### Task 1: Data models — OAuth2TokenResponse, OAuth2Token, OAuth2TokenRequest + +**Files:** +- Create: `src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs` +- Create: `src/RestSharp/Authenticators/OAuth2/OAuth2Token.cs` +- Create: `src/RestSharp/Authenticators/OAuth2/OAuth2TokenRequest.cs` + +**Step 1: Create OAuth2TokenResponse** + +```csharp +// 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 System.Text.Json.Serialization; + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// OAuth 2.0 token endpoint response as defined in RFC 6749 Section 5.1. +/// +[PublicAPI] +public record OAuth2TokenResponse { + [JsonPropertyName("access_token")] + public string AccessToken { get; init; } = ""; + + [JsonPropertyName("token_type")] + public string TokenType { get; init; } = ""; + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; init; } + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; init; } + + [JsonPropertyName("scope")] + public string? Scope { get; init; } +} +``` + +**Step 2: Create OAuth2Token** + +```csharp +// 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.Authenticators.OAuth2; + +/// +/// Represents an access token with its expiration time. Used as the return type +/// for the custom token provider delegate in . +/// +[PublicAPI] +public record OAuth2Token(string AccessToken, DateTimeOffset ExpiresAt); +``` + +**Step 3: Create OAuth2TokenRequest** + +```csharp +// 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.Authenticators.OAuth2; + +/// +/// Configuration for OAuth 2.0 token endpoint requests. Shared by +/// and . +/// +[PublicAPI] +public class OAuth2TokenRequest { + /// + /// The URL of the OAuth 2.0 token endpoint. + /// + public required string TokenEndpointUrl { get; init; } + + /// + /// The OAuth 2.0 client identifier. + /// + public required string ClientId { get; init; } + + /// + /// The OAuth 2.0 client secret. + /// + public required string ClientSecret { get; init; } + + /// + /// Optional scope to request. + /// + public string? Scope { get; init; } + + /// + /// Additional form parameters to include in the token request. + /// + public Dictionary? ExtraParameters { get; init; } + + /// + /// Optional HttpClient to use for token endpoint calls. When provided, the authenticator + /// will not create or dispose its own HttpClient. + /// + public HttpClient? HttpClient { get; init; } + + /// + /// How long before actual token expiry to consider it expired. Defaults to 30 seconds. + /// + public TimeSpan ExpiryBuffer { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// Callback invoked when a new token is obtained. Use this to persist tokens to storage. + /// + public Action? OnTokenRefreshed { get; init; } +} +``` + +**Step 4: Build to verify compilation** + +Run: `dotnet build RestSharp.slnx -c Debug` +Expected: BUILD SUCCEEDED + +**Step 5: Commit** + +```bash +git add src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs \ + src/RestSharp/Authenticators/OAuth2/OAuth2Token.cs \ + src/RestSharp/Authenticators/OAuth2/OAuth2TokenRequest.cs +git commit -m "feat: add OAuth2 token data models (RFC 6749)" +``` + +--- + +### Task 2: OAuth2ClientCredentialsAuthenticator + +**Files:** +- Create: `src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs` + +**Step 1: Create the authenticator** + +```csharp +// 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 System.Text.Json; + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// OAuth 2.0 Client Credentials authenticator. Automatically obtains and caches access tokens +/// from the token endpoint using the client_credentials grant type. +/// Uses its own HttpClient for token endpoint calls, avoiding circular dependencies with RestClient. +/// Thread-safe for concurrent request usage. +/// +[PublicAPI] +public class OAuth2ClientCredentialsAuthenticator : IAuthenticator, IDisposable { + readonly OAuth2TokenRequest _request; + readonly HttpClient _tokenClient; + readonly bool _disposeClient; + readonly SemaphoreSlim _lock = new(1, 1); + + string? _accessToken; + DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue; + + public OAuth2ClientCredentialsAuthenticator(OAuth2TokenRequest request) { + _request = request; + + if (request.HttpClient != null) { + _tokenClient = request.HttpClient; + _disposeClient = false; + } + else { + _tokenClient = new HttpClient(); + _disposeClient = true; + } + } + + public async ValueTask Authenticate(IRestClient client, RestRequest request) { + var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); + request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"Bearer {token}")); + } + + async Task GetOrRefreshTokenAsync() { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + await _lock.WaitAsync().ConfigureAwait(false); + + try { + // Double-check after acquiring lock + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + var parameters = new Dictionary { + ["grant_type"] = "client_credentials", + ["client_id"] = _request.ClientId, + ["client_secret"] = _request.ClientSecret + }; + + if (_request.Scope != null) + parameters["scope"] = _request.Scope; + + if (_request.ExtraParameters != null) { + foreach (var (key, value) in _request.ExtraParameters) + parameters[key] = value; + } + + using var content = new FormUrlEncodedContent(parameters); + using var response = await _tokenClient.PostAsync(_request.TokenEndpointUrl, content).ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"Token request failed with status {response.StatusCode}: {body}"); + + var tokenResponse = JsonSerializer.Deserialize(body); + + if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken)) + throw new InvalidOperationException($"Token endpoint returned an invalid response: {body}"); + + _accessToken = tokenResponse.AccessToken; + _tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn) - _request.ExpiryBuffer; + + _request.OnTokenRefreshed?.Invoke(tokenResponse); + + return _accessToken; + } + finally { + _lock.Release(); + } + } + + public void Dispose() { + if (_disposeClient) _tokenClient.Dispose(); + _lock.Dispose(); + } +} +``` + +**Step 2: Build to verify compilation** + +Run: `dotnet build RestSharp.slnx -c Debug` +Expected: BUILD SUCCEEDED + +**Step 3: Commit** + +```bash +git add src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs +git commit -m "feat: add OAuth2 client credentials authenticator with token lifecycle" +``` + +--- + +### Task 3: Tests for OAuth2ClientCredentialsAuthenticator + +**Files:** +- Create: `test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs` + +The tests use `MockHttpMessageHandler` from `RichardSzalay.MockHttp` to simulate the token endpoint. We inject the mock handler into the authenticator's `HttpClient` via `OAuth2TokenRequest.HttpClient`. + +**Step 1: Write the test class** + +```csharp +using System.Net; +using RestSharp.Authenticators.OAuth2; +using RichardSzalay.MockHttp; + +namespace RestSharp.Tests.Auth; + +public class OAuth2ClientCredentialsAuthenticatorTests : IDisposable { + const string TokenEndpoint = "https://auth.example.com/token"; + + static string TokenJson(int expiresIn = 3600, string accessToken = "test-access-token") + => $$"""{"access_token":"{{accessToken}}","token_type":"Bearer","expires_in":{{expiresIn}}}"""; + + readonly MockHttpMessageHandler _mockHttp = new(); + + OAuth2ClientCredentialsAuthenticator CreateAuthenticator( + Action? onRefreshed = null, + TimeSpan? expiryBuffer = null + ) { + var request = new OAuth2TokenRequest { + TokenEndpointUrl = TokenEndpoint, + ClientId = "my-client", + ClientSecret = "my-secret", + HttpClient = new HttpClient(_mockHttp), + OnTokenRefreshed = onRefreshed, + ExpiryBuffer = expiryBuffer ?? TimeSpan.Zero + }; + return new OAuth2ClientCredentialsAuthenticator(request); + } + + [Fact] + public async Task Should_obtain_token_and_set_authorization_header() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + using var auth = CreateAuthenticator(); + var restRequest = new RestRequest(); + await auth.Authenticate(null!, restRequest); + + var header = restRequest.Parameters.FirstOrDefault( + p => p.Name == KnownHeaders.Authorization + ); + header.Should().NotBeNull(); + header!.Value.Should().Be("Bearer test-access-token"); + } + + [Fact] + public async Task Should_cache_token_across_multiple_calls() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + using var auth = CreateAuthenticator(); + await auth.Authenticate(null!, new RestRequest()); + await auth.Authenticate(null!, new RestRequest()); + + // MockHttp was set up with When (not Expect), so count calls manually + // The second call should reuse the cached token + _mockHttp.GetMatchCount(_mockHttp.When(HttpMethod.Post, TokenEndpoint)).Should().BeLessOrEqual(1); + } + + [Fact] + public async Task Should_refresh_expired_token() { + var callCount = 0; + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(_ => { + callCount++; + var json = callCount == 1 + ? TokenJson(expiresIn: 0, accessToken: "token-1") + : TokenJson(expiresIn: 3600, accessToken: "token-2"); + return new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + }); + + using var auth = CreateAuthenticator(expiryBuffer: TimeSpan.Zero); + + var req1 = new RestRequest(); + await auth.Authenticate(null!, req1); + req1.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer token-1"); + + // expires_in was 0 and buffer is 0, so token is already expired + var req2 = new RestRequest(); + await auth.Authenticate(null!, req2); + req2.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer token-2"); + + callCount.Should().Be(2); + } + + [Fact] + public async Task Should_invoke_callback_on_token_refresh() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + OAuth2TokenResponse captured = null; + using var auth = CreateAuthenticator(onRefreshed: t => captured = t); + + await auth.Authenticate(null!, new RestRequest()); + + captured.Should().NotBeNull(); + captured!.AccessToken.Should().Be("test-access-token"); + captured.TokenType.Should().Be("Bearer"); + captured.ExpiresIn.Should().Be(3600); + } + + [Fact] + public async Task Should_throw_on_error_response() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(HttpStatusCode.BadRequest, "application/json", """{"error":"invalid_client"}"""); + + using var auth = CreateAuthenticator(); + + var act = () => auth.Authenticate(null!, new RestRequest()).AsTask(); + await act.Should().ThrowAsync() + .WithMessage("*400*invalid_client*"); + } + + [Fact] + public async Task Should_throw_on_empty_access_token() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", """{"access_token":"","token_type":"Bearer","expires_in":3600}"""); + + using var auth = CreateAuthenticator(); + + var act = () => auth.Authenticate(null!, new RestRequest()).AsTask(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Should_send_scope_when_configured() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .WithFormData("scope", "read write") + .Respond("application/json", TokenJson()); + + var request = new OAuth2TokenRequest { + TokenEndpointUrl = TokenEndpoint, + ClientId = "my-client", + ClientSecret = "my-secret", + Scope = "read write", + HttpClient = new HttpClient(_mockHttp) + }; + using var auth = new OAuth2ClientCredentialsAuthenticator(request); + + await auth.Authenticate(null!, new RestRequest()); + // If scope wasn't sent, the mock would not match and the request would fail + } + + public void Dispose() => _mockHttp.Dispose(); +} +``` + +**Step 2: Run tests to verify they pass** + +Run: `dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj --filter "FullyQualifiedName~OAuth2ClientCredentialsAuthenticator" -f net9.0` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs +git commit -m "test: add tests for OAuth2 client credentials authenticator" +``` + +--- + +### Task 4: OAuth2RefreshTokenAuthenticator + +**Files:** +- Create: `src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs` + +**Step 1: Create the authenticator** + +```csharp +// 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 System.Text.Json; + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// OAuth 2.0 Refresh Token authenticator. Uses an initial access token and refresh token pair, +/// automatically refreshing the access token when it expires using the refresh_token grant type. +/// Uses its own HttpClient for token endpoint calls, avoiding circular dependencies with RestClient. +/// Thread-safe for concurrent request usage. +/// +[PublicAPI] +public class OAuth2RefreshTokenAuthenticator : IAuthenticator, IDisposable { + readonly OAuth2TokenRequest _request; + readonly HttpClient _tokenClient; + readonly bool _disposeClient; + readonly SemaphoreSlim _lock = new(1, 1); + + string _accessToken; + string _refreshToken; + DateTimeOffset _tokenExpiry; + + /// Token endpoint configuration. + /// The initial access token. + /// The initial refresh token. + /// When the initial access token expires. Pass to force an immediate refresh. + public OAuth2RefreshTokenAuthenticator( + OAuth2TokenRequest request, + string accessToken, + string refreshToken, + DateTimeOffset expiresAt + ) { + _request = request; + _accessToken = accessToken; + _refreshToken = refreshToken; + _tokenExpiry = expiresAt; + + if (request.HttpClient != null) { + _tokenClient = request.HttpClient; + _disposeClient = false; + } + else { + _tokenClient = new HttpClient(); + _disposeClient = true; + } + } + + public async ValueTask Authenticate(IRestClient client, RestRequest request) { + var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); + request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"Bearer {token}")); + } + + async Task GetOrRefreshTokenAsync() { + if (DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + await _lock.WaitAsync().ConfigureAwait(false); + + try { + if (DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + var parameters = new Dictionary { + ["grant_type"] = "refresh_token", + ["client_id"] = _request.ClientId, + ["client_secret"] = _request.ClientSecret, + ["refresh_token"] = _refreshToken + }; + + if (_request.ExtraParameters != null) { + foreach (var (key, value) in _request.ExtraParameters) + parameters[key] = value; + } + + using var content = new FormUrlEncodedContent(parameters); + using var response = await _tokenClient.PostAsync(_request.TokenEndpointUrl, content).ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"Token refresh failed with status {response.StatusCode}: {body}"); + + var tokenResponse = JsonSerializer.Deserialize(body); + + if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken)) + throw new InvalidOperationException($"Token endpoint returned an invalid response: {body}"); + + _accessToken = tokenResponse.AccessToken; + _tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn) - _request.ExpiryBuffer; + + // Update refresh token if server rotates it + if (!string.IsNullOrEmpty(tokenResponse.RefreshToken)) + _refreshToken = tokenResponse.RefreshToken; + + _request.OnTokenRefreshed?.Invoke(tokenResponse); + + return _accessToken; + } + finally { + _lock.Release(); + } + } + + public void Dispose() { + if (_disposeClient) _tokenClient.Dispose(); + _lock.Dispose(); + } +} +``` + +**Step 2: Build to verify compilation** + +Run: `dotnet build RestSharp.slnx -c Debug` +Expected: BUILD SUCCEEDED + +**Step 3: Commit** + +```bash +git add src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs +git commit -m "feat: add OAuth2 refresh token authenticator with token lifecycle" +``` + +--- + +### Task 5: Tests for OAuth2RefreshTokenAuthenticator + +**Files:** +- Create: `test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs` + +**Step 1: Write the test class** + +```csharp +using System.Net; +using RestSharp.Authenticators.OAuth2; +using RichardSzalay.MockHttp; + +namespace RestSharp.Tests.Auth; + +public class OAuth2RefreshTokenAuthenticatorTests : IDisposable { + const string TokenEndpoint = "https://auth.example.com/token"; + + static string TokenJson( + string accessToken = "new-access-token", + int expiresIn = 3600, + string refreshToken = null + ) { + var refresh = refreshToken != null ? $""","refresh_token":"{refreshToken}"""" : ""; + return $$"""{"access_token":"{{accessToken}}","token_type":"Bearer","expires_in":{{expiresIn}}{{refresh}}}"""; + } + + readonly MockHttpMessageHandler _mockHttp = new(); + + OAuth2RefreshTokenAuthenticator CreateAuthenticator( + string accessToken = "initial-access", + string refreshToken = "initial-refresh", + DateTimeOffset? expiresAt = null, + Action onRefreshed = null + ) { + var request = new OAuth2TokenRequest { + TokenEndpointUrl = TokenEndpoint, + ClientId = "my-client", + ClientSecret = "my-secret", + HttpClient = new HttpClient(_mockHttp), + OnTokenRefreshed = onRefreshed, + ExpiryBuffer = TimeSpan.Zero + }; + return new OAuth2RefreshTokenAuthenticator( + request, + accessToken, + refreshToken, + expiresAt ?? DateTimeOffset.MinValue + ); + } + + [Fact] + public async Task Should_use_initial_token_when_not_expired() { + using var auth = CreateAuthenticator(expiresAt: DateTimeOffset.UtcNow.AddHours(1)); + + var req = new RestRequest(); + await auth.Authenticate(null!, req); + + req.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer initial-access"); + } + + [Fact] + public async Task Should_refresh_when_token_expired() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + using var auth = CreateAuthenticator(expiresAt: DateTimeOffset.MinValue); + + var req = new RestRequest(); + await auth.Authenticate(null!, req); + + req.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer new-access-token"); + } + + [Fact] + public async Task Should_send_refresh_token_in_request() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .WithFormData("refresh_token", "initial-refresh") + .WithFormData("grant_type", "refresh_token") + .Respond("application/json", TokenJson()); + + using var auth = CreateAuthenticator(); + await auth.Authenticate(null!, new RestRequest()); + // If refresh_token or grant_type weren't sent, mock wouldn't match + } + + [Fact] + public async Task Should_update_refresh_token_when_rotated() { + var callCount = 0; + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(_ => { + callCount++; + var json = callCount == 1 + ? TokenJson(accessToken: "token-1", expiresIn: 0, refreshToken: "rotated-refresh") + : TokenJson(accessToken: "token-2", expiresIn: 3600); + return new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + }); + + using var auth = CreateAuthenticator(); + + // First call: gets token-1 with rotated refresh token + await auth.Authenticate(null!, new RestRequest()); + // Second call: token-1 is expired (expiresIn=0), should use rotated-refresh + await auth.Authenticate(null!, new RestRequest()); + + callCount.Should().Be(2); + } + + [Fact] + public async Task Should_invoke_callback_on_refresh() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson(refreshToken: "new-refresh")); + + OAuth2TokenResponse captured = null; + using var auth = CreateAuthenticator(onRefreshed: t => captured = t); + + await auth.Authenticate(null!, new RestRequest()); + + captured.Should().NotBeNull(); + captured!.AccessToken.Should().Be("new-access-token"); + captured.RefreshToken.Should().Be("new-refresh"); + } + + [Fact] + public async Task Should_throw_on_error_response() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(HttpStatusCode.Unauthorized, "application/json", """{"error":"invalid_grant"}"""); + + using var auth = CreateAuthenticator(); + + var act = () => auth.Authenticate(null!, new RestRequest()).AsTask(); + await act.Should().ThrowAsync() + .WithMessage("*401*invalid_grant*"); + } + + public void Dispose() => _mockHttp.Dispose(); +} +``` + +**Step 2: Run tests** + +Run: `dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj --filter "FullyQualifiedName~OAuth2RefreshTokenAuthenticator" -f net9.0` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs +git commit -m "test: add tests for OAuth2 refresh token authenticator" +``` + +--- + +### Task 6: OAuth2TokenAuthenticator (generic/delegate-based) + +**Files:** +- Create: `src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs` + +**Step 1: Create the authenticator** + +```csharp +// 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.Authenticators.OAuth2; + +/// +/// Generic OAuth 2.0 authenticator that delegates token acquisition to a user-provided function. +/// Caches the token and re-invokes the delegate when the token expires. +/// Use this for non-standard OAuth2 flows or custom token providers. +/// Thread-safe for concurrent request usage. +/// +[PublicAPI] +public class OAuth2TokenAuthenticator : IAuthenticator, IDisposable { + readonly Func> _getToken; + readonly string _tokenType; + readonly SemaphoreSlim _lock = new(1, 1); + + string? _accessToken; + DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue; + + /// Async delegate that returns an access token and its expiration time. + /// The token type for the Authorization header. Defaults to "Bearer". + public OAuth2TokenAuthenticator(Func> getToken, string tokenType = "Bearer") { + _getToken = getToken; + _tokenType = tokenType; + } + + public async ValueTask Authenticate(IRestClient client, RestRequest request) { + var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); + request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"{_tokenType} {token}")); + } + + async Task GetOrRefreshTokenAsync() { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + await _lock.WaitAsync().ConfigureAwait(false); + + try { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + var result = await _getToken(CancellationToken.None).ConfigureAwait(false); + _accessToken = result.AccessToken; + _tokenExpiry = result.ExpiresAt; + + return _accessToken; + } + finally { + _lock.Release(); + } + } + + public void Dispose() => _lock.Dispose(); +} +``` + +**Step 2: Build to verify compilation** + +Run: `dotnet build RestSharp.slnx -c Debug` +Expected: BUILD SUCCEEDED + +**Step 3: Commit** + +```bash +git add src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs +git commit -m "feat: add generic OAuth2 token authenticator with delegate provider" +``` + +--- + +### Task 7: Tests for OAuth2TokenAuthenticator + +**Files:** +- Create: `test/RestSharp.Tests/Auth/OAuth2TokenAuthenticatorTests.cs` + +**Step 1: Write the test class** + +```csharp +using RestSharp.Authenticators.OAuth2; + +namespace RestSharp.Tests.Auth; + +public class OAuth2TokenAuthenticatorTests { + [Fact] + public async Task Should_call_delegate_and_set_authorization_header() { + var token = new OAuth2Token("my-token", DateTimeOffset.UtcNow.AddHours(1)); + + using var auth = new OAuth2TokenAuthenticator(_ => Task.FromResult(token)); + var req = new RestRequest(); + await auth.Authenticate(null!, req); + + req.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer my-token"); + } + + [Fact] + public async Task Should_cache_token_across_calls() { + var callCount = 0; + var token = new OAuth2Token("my-token", DateTimeOffset.UtcNow.AddHours(1)); + + using var auth = new OAuth2TokenAuthenticator(_ => { + callCount++; + return Task.FromResult(token); + }); + + await auth.Authenticate(null!, new RestRequest()); + await auth.Authenticate(null!, new RestRequest()); + + callCount.Should().Be(1); + } + + [Fact] + public async Task Should_re_invoke_delegate_when_token_expired() { + var callCount = 0; + + using var auth = new OAuth2TokenAuthenticator(_ => { + callCount++; + // Always return a token that's already expired + var t = callCount == 1 + ? new OAuth2Token("token-1", DateTimeOffset.UtcNow.AddSeconds(-1)) + : new OAuth2Token("token-2", DateTimeOffset.UtcNow.AddHours(1)); + return Task.FromResult(t); + }); + + var req1 = new RestRequest(); + await auth.Authenticate(null!, req1); + req1.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer token-1"); + + var req2 = new RestRequest(); + await auth.Authenticate(null!, req2); + req2.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer token-2"); + + callCount.Should().Be(2); + } + + [Fact] + public async Task Should_use_custom_token_type() { + var token = new OAuth2Token("my-token", DateTimeOffset.UtcNow.AddHours(1)); + + using var auth = new OAuth2TokenAuthenticator(_ => Task.FromResult(token), tokenType: "MAC"); + var req = new RestRequest(); + await auth.Authenticate(null!, req); + + req.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("MAC my-token"); + } +} +``` + +**Step 2: Run tests** + +Run: `dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj --filter "FullyQualifiedName~OAuth2TokenAuthenticator" -f net9.0` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add test/RestSharp.Tests/Auth/OAuth2TokenAuthenticatorTests.cs +git commit -m "test: add tests for generic OAuth2 token authenticator" +``` + +--- + +### Task 8: Full build and test verification + +**Step 1: Run the full build** + +Run: `dotnet build RestSharp.slnx -c Debug` +Expected: BUILD SUCCEEDED + +**Step 2: Run all unit tests** + +Run: `dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net9.0` +Expected: All tests PASS (existing + new) + +**Step 3: Run tests on net8.0** + +Run: `dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net8.0` +Expected: All tests PASS + +**Step 4: Run the full solution tests** + +Run: `dotnet test RestSharp.slnx -c Debug` +Expected: All tests PASS diff --git a/src/RestSharp/Authenticators/AuthenticatorBase.cs b/src/RestSharp/Authenticators/AuthenticatorBase.cs index dc765e8c7..299b5f75e 100644 --- a/src/RestSharp/Authenticators/AuthenticatorBase.cs +++ b/src/RestSharp/Authenticators/AuthenticatorBase.cs @@ -19,6 +19,6 @@ public abstract class AuthenticatorBase(string token) : IAuthenticator { protected abstract ValueTask GetAuthenticationParameter(string accessToken); - public async ValueTask Authenticate(IRestClient client, RestRequest request) + public async ValueTask Authenticate(IRestClient client, RestRequest request, CancellationToken cancellationToken = default) => request.AddOrUpdateParameter(await GetAuthenticationParameter(Token).ConfigureAwait(false)); } \ No newline at end of file diff --git a/src/RestSharp/Authenticators/IAuthenticator.cs b/src/RestSharp/Authenticators/IAuthenticator.cs index b7cbe9606..8fa2998bd 100644 --- a/src/RestSharp/Authenticators/IAuthenticator.cs +++ b/src/RestSharp/Authenticators/IAuthenticator.cs @@ -15,5 +15,5 @@ namespace RestSharp.Authenticators; public interface IAuthenticator { - ValueTask Authenticate(IRestClient client, RestRequest request); + ValueTask Authenticate(IRestClient client, RestRequest request, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/RestSharp/Authenticators/OAuth/OAuth1Authenticator.cs b/src/RestSharp/Authenticators/OAuth/OAuth1Authenticator.cs index 56906120f..de9d61434 100644 --- a/src/RestSharp/Authenticators/OAuth/OAuth1Authenticator.cs +++ b/src/RestSharp/Authenticators/OAuth/OAuth1Authenticator.cs @@ -41,7 +41,7 @@ public class OAuth1Authenticator : IAuthenticator { public virtual string? ClientUsername { get; set; } public virtual string? ClientPassword { get; set; } - public ValueTask Authenticate(IRestClient client, RestRequest request) { + public ValueTask Authenticate(IRestClient client, RestRequest request, CancellationToken cancellationToken = default) { var workflow = new OAuthWorkflow { ConsumerKey = ConsumerKey, ConsumerSecret = ConsumerSecret, diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs new file mode 100644 index 000000000..2c89fbe82 --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs @@ -0,0 +1,38 @@ +// 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.Authenticators.OAuth2; + +/// +/// OAuth 2.0 Client Credentials authenticator. Automatically obtains and caches access tokens +/// from the token endpoint using the client_credentials grant type. +/// Uses its own HttpClient for token endpoint calls, avoiding circular dependencies with RestClient. +/// Thread-safe for concurrent request usage. +/// +[PublicAPI] +public class OAuth2ClientCredentialsAuthenticator(OAuth2TokenRequest request) + : OAuth2EndpointAuthenticatorBase(request) { + protected override Dictionary BuildRequestParameters() { + var parameters = new Dictionary { + ["grant_type"] = "client_credentials", + ["client_id"] = TokenRequest.ClientId, + ["client_secret"] = TokenRequest.ClientSecret + }; + + if (TokenRequest.Scope != null) + parameters["scope"] = TokenRequest.Scope; + + return parameters; + } +} diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2EndpointAuthenticatorBase.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2EndpointAuthenticatorBase.cs new file mode 100644 index 000000000..7480a2ac2 --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2EndpointAuthenticatorBase.cs @@ -0,0 +1,117 @@ +// 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 System.Text.Json; + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// Base class for OAuth 2.0 authenticators that call a token endpoint. +/// Handles HttpClient lifecycle, thread-safe token caching with double-check locking, +/// token response parsing, error handling, and the OnTokenRefreshed callback. +/// +public abstract class OAuth2EndpointAuthenticatorBase : IAuthenticator, IDisposable { + readonly HttpClient _tokenClient; + readonly bool _disposeClient; + readonly SemaphoreSlim _lock = new(1, 1); + + protected OAuth2TokenRequest TokenRequest { get; } + + string? _accessToken; + DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue; + + protected OAuth2EndpointAuthenticatorBase(OAuth2TokenRequest request) { + TokenRequest = request; + + if (request.HttpClient != null) { + _tokenClient = request.HttpClient; + _disposeClient = false; + } + else { + _tokenClient = new HttpClient(); + _disposeClient = true; + } + } + + protected void SetInitialToken(string accessToken, DateTimeOffset expiresAt) { + _accessToken = accessToken; + _tokenExpiry = expiresAt; + } + + public async ValueTask Authenticate(IRestClient client, RestRequest request, CancellationToken cancellationToken = default) { + var token = await GetOrRefreshTokenAsync(cancellationToken).ConfigureAwait(false); + request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"Bearer {token}")); + } + + /// + /// Build the grant-specific form parameters for the token request. + /// + protected abstract Dictionary BuildRequestParameters(); + + /// + /// Called after a successful token response. Override to handle grant-specific + /// fields such as refresh token rotation. + /// + protected virtual void OnTokenResponse(OAuth2TokenResponse response) { } + + async Task GetOrRefreshTokenAsync(CancellationToken cancellationToken) { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + var parameters = BuildRequestParameters(); + + if (TokenRequest.ExtraParameters != null) { + foreach (var kvp in TokenRequest.ExtraParameters) + parameters[kvp.Key] = kvp.Value; + } + + using var content = new FormUrlEncodedContent(parameters); + using var response = await _tokenClient.PostAsync(TokenRequest.TokenEndpointUrl, content, cancellationToken).ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"Token request failed with status {response.StatusCode}: {body}"); + + var tokenResponse = JsonSerializer.Deserialize(body); + + if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken)) + throw new InvalidOperationException($"Token endpoint returned an invalid response: {body}"); + + _accessToken = tokenResponse.AccessToken; + _tokenExpiry = tokenResponse.ExpiresIn.HasValue + ? DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn.Value) - TokenRequest.ExpiryBuffer + : DateTimeOffset.MaxValue; + + OnTokenResponse(tokenResponse); + TokenRequest.OnTokenRefreshed?.Invoke(tokenResponse); + + return _accessToken; + } + finally { + _lock.Release(); + } + } + + public void Dispose() { + if (_disposeClient) _tokenClient.Dispose(); + _lock.Dispose(); + } +} diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs new file mode 100644 index 000000000..a26cfe837 --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs @@ -0,0 +1,59 @@ +// 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.Authenticators.OAuth2; + +/// +/// OAuth 2.0 Refresh Token authenticator. Uses an initial access token and refresh token pair, +/// automatically refreshing the access token when it expires using the refresh_token grant type. +/// Uses its own HttpClient for token endpoint calls, avoiding circular dependencies with RestClient. +/// Thread-safe for concurrent request usage. +/// +[PublicAPI] +public class OAuth2RefreshTokenAuthenticator : OAuth2EndpointAuthenticatorBase { + string _refreshToken; + + /// Token endpoint configuration. + /// The initial access token. + /// The initial refresh token. + /// When the initial access token expires. Pass to force an immediate refresh. + public OAuth2RefreshTokenAuthenticator( + OAuth2TokenRequest request, + string accessToken, + string refreshToken, + DateTimeOffset expiresAt + ) : base(request) { + _refreshToken = refreshToken; + SetInitialToken(accessToken, expiresAt); + } + + protected override Dictionary BuildRequestParameters() { + var parameters = new Dictionary { + ["grant_type"] = "refresh_token", + ["client_id"] = TokenRequest.ClientId, + ["client_secret"] = TokenRequest.ClientSecret, + ["refresh_token"] = _refreshToken + }; + + if (TokenRequest.Scope != null) + parameters["scope"] = TokenRequest.Scope; + + return parameters; + } + + protected override void OnTokenResponse(OAuth2TokenResponse response) { + if (!string.IsNullOrEmpty(response.RefreshToken)) + _refreshToken = response.RefreshToken; + } +} diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2Token.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2Token.cs new file mode 100644 index 000000000..fbf495af9 --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2Token.cs @@ -0,0 +1,22 @@ +// 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.Authenticators.OAuth2; + +/// +/// Represents an access token with its expiration time. Used as the return type +/// for the custom token provider delegate in . +/// +[PublicAPI] +public record OAuth2Token(string AccessToken, DateTimeOffset ExpiresAt); diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs new file mode 100644 index 000000000..ce4320743 --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs @@ -0,0 +1,66 @@ +// 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.Authenticators.OAuth2; + +/// +/// Generic OAuth 2.0 authenticator that delegates token acquisition to a user-provided function. +/// Caches the token and re-invokes the delegate when the token expires. +/// Use this for non-standard OAuth2 flows or custom token providers. +/// Thread-safe for concurrent request usage. +/// +[PublicAPI] +public class OAuth2TokenAuthenticator : IAuthenticator, IDisposable { + readonly Func> _getToken; + readonly string _tokenType; + readonly SemaphoreSlim _lock = new(1, 1); + + string? _accessToken; + DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue; + + /// Async delegate that returns an access token and its expiration time. + /// The token type for the Authorization header. Defaults to "Bearer". + public OAuth2TokenAuthenticator(Func> getToken, string tokenType = "Bearer") { + _getToken = getToken; + _tokenType = tokenType; + } + + public async ValueTask Authenticate(IRestClient client, RestRequest request, CancellationToken cancellationToken = default) { + var token = await GetOrRefreshTokenAsync(cancellationToken).ConfigureAwait(false); + request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"{_tokenType} {token}")); + } + + async Task GetOrRefreshTokenAsync(CancellationToken cancellationToken) { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + var result = await _getToken(cancellationToken).ConfigureAwait(false); + _accessToken = result.AccessToken; + _tokenExpiry = result.ExpiresAt; + + return _accessToken; + } + finally { + _lock.Release(); + } + } + + public void Dispose() => _lock.Dispose(); +} diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2TokenRequest.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenRequest.cs new file mode 100644 index 000000000..73f5359d7 --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenRequest.cs @@ -0,0 +1,75 @@ +// 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.Authenticators.OAuth2; + +/// +/// Configuration for OAuth 2.0 token endpoint requests. Shared by +/// and . +/// +[PublicAPI] +public class OAuth2TokenRequest { + /// + /// Creates a new instance of . + /// + /// The URL of the OAuth 2.0 token endpoint. + /// The OAuth 2.0 client identifier. + /// The OAuth 2.0 client secret. + public OAuth2TokenRequest(string tokenEndpointUrl, string clientId, string clientSecret) { + TokenEndpointUrl = Ensure.NotNull(tokenEndpointUrl, nameof(tokenEndpointUrl)); + ClientId = Ensure.NotNull(clientId, nameof(clientId)); + ClientSecret = Ensure.NotNull(clientSecret, nameof(clientSecret)); + } + + /// + /// The URL of the OAuth 2.0 token endpoint. + /// + public string TokenEndpointUrl { get; } + + /// + /// The OAuth 2.0 client identifier. + /// + public string ClientId { get; } + + /// + /// The OAuth 2.0 client secret. + /// + public string ClientSecret { get; } + + /// + /// Optional scope to request. + /// + public string? Scope { get; init; } + + /// + /// Additional form parameters to include in the token request. + /// + public Dictionary? ExtraParameters { get; init; } + + /// + /// Optional HttpClient to use for token endpoint calls. When provided, the authenticator + /// will not create or dispose its own HttpClient. + /// + public HttpClient? HttpClient { get; init; } + + /// + /// How long before actual token expiry to consider it expired. Defaults to 30 seconds. + /// + public TimeSpan ExpiryBuffer { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// Callback invoked when a new token is obtained. Use this to persist tokens to storage. + /// + public Action? OnTokenRefreshed { get; init; } +} diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs new file mode 100644 index 000000000..e49fe11a3 --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs @@ -0,0 +1,38 @@ +// 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 System.Text.Json.Serialization; + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// OAuth 2.0 token endpoint response as defined in RFC 6749 Section 5.1. +/// +[PublicAPI] +public record OAuth2TokenResponse { + [JsonPropertyName("access_token")] + public string AccessToken { get; init; } = ""; + + [JsonPropertyName("token_type")] + public string TokenType { get; init; } = ""; + + [JsonPropertyName("expires_in")] + public int? ExpiresIn { get; init; } + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; init; } + + [JsonPropertyName("scope")] + public string? Scope { get; init; } +} diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 269a680de..e761e922c 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -108,7 +108,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo var authenticator = request.Authenticator ?? Options.Authenticator; if (authenticator != null) { - await authenticator.Authenticate(this, request).ConfigureAwait(false); + await authenticator.Authenticate(this, request, cancellationToken).ConfigureAwait(false); } var contentToDispose = new List(); diff --git a/test/RestSharp.Tests/Auth/AuthenticatorTests.cs b/test/RestSharp.Tests/Auth/AuthenticatorTests.cs index 4129a76a6..550d14b64 100644 --- a/test/RestSharp.Tests/Auth/AuthenticatorTests.cs +++ b/test/RestSharp.Tests/Auth/AuthenticatorTests.cs @@ -34,7 +34,7 @@ public async Task Should_add_authorization_form_parameter() { } class TestAuthenticator(ParameterType type, string name, string value) : IAuthenticator { - public ValueTask Authenticate(IRestClient client, RestRequest request) { + public ValueTask Authenticate(IRestClient client, RestRequest request, CancellationToken cancellationToken = default) { request.AddParameter(name, value, type); return default; } diff --git a/test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs b/test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs new file mode 100644 index 000000000..1a12f9957 --- /dev/null +++ b/test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs @@ -0,0 +1,202 @@ +using System.Net; +using RestSharp.Authenticators.OAuth2; +using RichardSzalay.MockHttp; + +namespace RestSharp.Tests.Auth; + +public class OAuth2ClientCredentialsAuthenticatorTests : IDisposable { + const string TokenEndpoint = "https://auth.example.com/token"; + const string ClientId = "my-client"; + const string ClientSecret = "my-secret"; + + readonly MockHttpMessageHandler _mockHttp = new(); + + static string TokenJson(string accessToken = "test-access-token", int expiresIn = 3600) + => $$"""{"access_token":"{{accessToken}}","token_type":"Bearer","expires_in":{{expiresIn}}}"""; + + OAuth2TokenRequest CreateRequest( + string scope = null, + TimeSpan? expiryBuffer = null, + Action onTokenRefreshed = null + ) { + var request = new OAuth2TokenRequest(TokenEndpoint, ClientId, ClientSecret) { + HttpClient = new HttpClient(_mockHttp), + ExpiryBuffer = expiryBuffer ?? TimeSpan.Zero + }; + + if (scope != null || onTokenRefreshed != null) { + return new OAuth2TokenRequest(TokenEndpoint, ClientId, ClientSecret) { + HttpClient = new HttpClient(_mockHttp), + ExpiryBuffer = expiryBuffer ?? TimeSpan.Zero, + Scope = scope, + OnTokenRefreshed = onTokenRefreshed + }; + } + + return request; + } + + [Fact] + public async Task Should_obtain_token_and_set_authorization_header() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator(CreateRequest()); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("Bearer test-access-token"); + } + + [Fact] + public async Task Should_cache_token_across_multiple_calls() { + var callCount = 0; + + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(_ => { + Interlocked.Increment(ref callCount); + return new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(TokenJson(), System.Text.Encoding.UTF8, "application/json") + }; + }); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator(CreateRequest()); + + var request1 = new RestRequest(); + await authenticator.Authenticate(null!, request1); + + var request2 = new RestRequest(); + await authenticator.Authenticate(null!, request2); + + callCount.Should().Be(1); + } + + [Fact] + public async Task Should_refresh_expired_token() { + var callCount = 0; + + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(_ => { + var token = Interlocked.Increment(ref callCount) == 1 + ? TokenJson("first-token", 0) + : TokenJson("second-token", 3600); + + return new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(token, System.Text.Encoding.UTF8, "application/json") + }; + }); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator(CreateRequest()); + + var request1 = new RestRequest(); + await authenticator.Authenticate(null!, request1); + + var authHeader1 = request1.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader1.Value.Should().Be("Bearer first-token"); + + var request2 = new RestRequest(); + await authenticator.Authenticate(null!, request2); + + var authHeader2 = request2.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader2.Value.Should().Be("Bearer second-token"); + + callCount.Should().Be(2); + } + + [Fact] + public async Task Should_invoke_callback_on_token_refresh() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + OAuth2TokenResponse capturedResponse = null; + using var authenticator = new OAuth2ClientCredentialsAuthenticator( + CreateRequest(onTokenRefreshed: r => capturedResponse = r) + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + capturedResponse.Should().NotBeNull(); + capturedResponse.AccessToken.Should().Be("test-access-token"); + capturedResponse.ExpiresIn.Should().Be(3600); + capturedResponse.TokenType.Should().Be("Bearer"); + } + + [Fact] + public async Task Should_throw_on_error_response() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(HttpStatusCode.BadRequest, "application/json", """{"error":"invalid_client"}"""); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator(CreateRequest()); + + var request = new RestRequest(); + var act = () => authenticator.Authenticate(null!, request).AsTask(); + + await act.Should().ThrowAsync() + .WithMessage("*BadRequest*"); + } + + [Fact] + public async Task Should_throw_on_empty_access_token() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", """{"access_token":"","token_type":"Bearer","expires_in":3600}"""); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator(CreateRequest()); + + var request = new RestRequest(); + var act = () => authenticator.Authenticate(null!, request).AsTask(); + + await act.Should().ThrowAsync() + .WithMessage("*invalid response*"); + } + + [Fact] + public async Task Should_treat_missing_expires_in_as_non_expiring() { + var callCount = 0; + + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(_ => { + Interlocked.Increment(ref callCount); + return new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent( + """{"access_token":"no-expiry-token","token_type":"Bearer"}""", + System.Text.Encoding.UTF8, + "application/json" + ) + }; + }); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator(CreateRequest()); + + var request1 = new RestRequest(); + await authenticator.Authenticate(null!, request1); + + var request2 = new RestRequest(); + await authenticator.Authenticate(null!, request2); + + callCount.Should().Be(1, "token without expires_in should be cached indefinitely"); + } + + [Fact] + public async Task Should_send_scope_when_configured() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .WithFormData("scope", "read write") + .Respond("application/json", TokenJson()); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator( + CreateRequest(scope: "read write") + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("Bearer test-access-token"); + } + + public void Dispose() => _mockHttp.Dispose(); +} diff --git a/test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs b/test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs new file mode 100644 index 000000000..4ffad40e1 --- /dev/null +++ b/test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs @@ -0,0 +1,211 @@ +using System.Net; +using RestSharp.Authenticators.OAuth2; +using RichardSzalay.MockHttp; + +namespace RestSharp.Tests.Auth; + +public class OAuth2RefreshTokenAuthenticatorTests : IDisposable { + const string TokenEndpoint = "https://auth.example.com/token"; + const string ClientId = "my-client"; + const string ClientSecret = "my-secret"; + const string InitialAccess = "initial-access-token"; + const string InitialRefresh = "initial-refresh-token"; + + readonly MockHttpMessageHandler _mockHttp = new(); + + static string TokenJson( + string accessToken = "new-access-token", + int expiresIn = 3600, + string refreshToken = null + ) { + var refreshPart = refreshToken != null + ? $""","refresh_token":"{refreshToken}" """ + : ""; + return $$"""{"access_token":"{{accessToken}}","token_type":"Bearer","expires_in":{{expiresIn}}{{refreshPart}}}"""; + } + + OAuth2TokenRequest CreateRequest( + TimeSpan? expiryBuffer = null, + Action onTokenRefreshed = null + ) => + new(TokenEndpoint, ClientId, ClientSecret) { + HttpClient = new HttpClient(_mockHttp), + ExpiryBuffer = expiryBuffer ?? TimeSpan.Zero, + OnTokenRefreshed = onTokenRefreshed + }; + + [Fact] + public async Task Should_use_initial_token_when_not_expired() { + // No mock response needed — the token endpoint should not be called + using var authenticator = new OAuth2RefreshTokenAuthenticator( + CreateRequest(), + InitialAccess, + InitialRefresh, + DateTimeOffset.UtcNow.AddHours(1) + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be($"Bearer {InitialAccess}"); + } + + [Fact] + public async Task Should_refresh_when_token_expired() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + using var authenticator = new OAuth2RefreshTokenAuthenticator( + CreateRequest(), + InitialAccess, + InitialRefresh, + DateTimeOffset.MinValue + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("Bearer new-access-token"); + } + + [Fact] + public async Task Should_send_refresh_token_in_request() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .WithFormData("grant_type", "refresh_token") + .WithFormData("refresh_token", InitialRefresh) + .WithFormData("client_id", ClientId) + .WithFormData("client_secret", ClientSecret) + .Respond("application/json", TokenJson()); + + using var authenticator = new OAuth2RefreshTokenAuthenticator( + CreateRequest(), + InitialAccess, + InitialRefresh, + DateTimeOffset.MinValue + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("Bearer new-access-token"); + } + + [Fact] + public async Task Should_update_refresh_token_when_rotated() { + var callCount = 0; + const string rotatedRefresh = "rotated-refresh-token"; + + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(_ => { + var count = Interlocked.Increment(ref callCount); + + var json = count == 1 + ? TokenJson("first-access", 0, rotatedRefresh) + : TokenJson("second-access", 3600); + + return new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + }); + + using var authenticator = new OAuth2RefreshTokenAuthenticator( + CreateRequest(), + InitialAccess, + InitialRefresh, + DateTimeOffset.MinValue + ); + + // First call: expires immediately (expiresIn=0), returns rotated refresh token + var request1 = new RestRequest(); + await authenticator.Authenticate(null!, request1); + + var authHeader1 = request1.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader1.Value.Should().Be("Bearer first-access"); + + // Second call: token expired (expiresIn was 0), should use rotated refresh token + var request2 = new RestRequest(); + await authenticator.Authenticate(null!, request2); + + var authHeader2 = request2.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader2.Value.Should().Be("Bearer second-access"); + + callCount.Should().Be(2); + } + + [Fact] + public async Task Should_invoke_callback_on_refresh() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + OAuth2TokenResponse capturedResponse = null; + + using var authenticator = new OAuth2RefreshTokenAuthenticator( + CreateRequest(onTokenRefreshed: r => capturedResponse = r), + InitialAccess, + InitialRefresh, + DateTimeOffset.MinValue + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + capturedResponse.Should().NotBeNull(); + capturedResponse.AccessToken.Should().Be("new-access-token"); + capturedResponse.ExpiresIn.Should().Be(3600); + capturedResponse.TokenType.Should().Be("Bearer"); + } + + [Fact] + public async Task Should_send_scope_when_configured() { + var request2 = new OAuth2TokenRequest(TokenEndpoint, ClientId, ClientSecret) { + HttpClient = new HttpClient(_mockHttp), + ExpiryBuffer = TimeSpan.Zero, + Scope = "api.read api.write" + }; + + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .WithFormData("scope", "api.read api.write") + .Respond("application/json", TokenJson()); + + using var authenticator = new OAuth2RefreshTokenAuthenticator( + request2, + InitialAccess, + InitialRefresh, + DateTimeOffset.MinValue + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("Bearer new-access-token"); + } + + [Fact] + public async Task Should_throw_on_error_response() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(HttpStatusCode.Unauthorized, "application/json", """{"error":"invalid_grant"}"""); + + using var authenticator = new OAuth2RefreshTokenAuthenticator( + CreateRequest(), + InitialAccess, + InitialRefresh, + DateTimeOffset.MinValue + ); + + var request = new RestRequest(); + var act = () => authenticator.Authenticate(null!, request).AsTask(); + + await act.Should().ThrowAsync() + .WithMessage("*Unauthorized*"); + } + + public void Dispose() => _mockHttp.Dispose(); +} diff --git a/test/RestSharp.Tests/Auth/OAuth2TokenAuthenticatorTests.cs b/test/RestSharp.Tests/Auth/OAuth2TokenAuthenticatorTests.cs new file mode 100644 index 000000000..cd7be1c89 --- /dev/null +++ b/test/RestSharp.Tests/Auth/OAuth2TokenAuthenticatorTests.cs @@ -0,0 +1,97 @@ +using RestSharp.Authenticators.OAuth2; + +namespace RestSharp.Tests.Auth; + +public class OAuth2TokenAuthenticatorTests : IDisposable { + readonly List _authenticators = new(); + + OAuth2TokenAuthenticator CreateAuthenticator( + Func> getToken, + string tokenType = "Bearer" + ) { + var auth = new OAuth2TokenAuthenticator(getToken, tokenType); + _authenticators.Add(auth); + return auth; + } + + [Fact] + public async Task Should_call_delegate_and_set_authorization_header() { + var authenticator = CreateAuthenticator( + _ => Task.FromResult(new OAuth2Token("my-token", DateTimeOffset.UtcNow.AddHours(1))) + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("Bearer my-token"); + } + + [Fact] + public async Task Should_cache_token_across_calls() { + var callCount = 0; + + var authenticator = CreateAuthenticator(_ => { + Interlocked.Increment(ref callCount); + return Task.FromResult(new OAuth2Token("my-token", DateTimeOffset.UtcNow.AddHours(1))); + }); + + var request1 = new RestRequest(); + await authenticator.Authenticate(null!, request1); + + var request2 = new RestRequest(); + await authenticator.Authenticate(null!, request2); + + callCount.Should().Be(1); + } + + [Fact] + public async Task Should_re_invoke_delegate_when_token_expired() { + var callCount = 0; + + var authenticator = CreateAuthenticator(_ => { + var count = Interlocked.Increment(ref callCount); + + var token = count == 1 + ? new OAuth2Token("first-token", DateTimeOffset.UtcNow.AddSeconds(-1)) + : new OAuth2Token("second-token", DateTimeOffset.UtcNow.AddHours(1)); + + return Task.FromResult(token); + }); + + var request1 = new RestRequest(); + await authenticator.Authenticate(null!, request1); + + var authHeader1 = request1.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader1.Value.Should().Be("Bearer first-token"); + + var request2 = new RestRequest(); + await authenticator.Authenticate(null!, request2); + + var authHeader2 = request2.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader2.Value.Should().Be("Bearer second-token"); + + callCount.Should().Be(2); + } + + [Fact] + public async Task Should_use_custom_token_type() { + var authenticator = CreateAuthenticator( + _ => Task.FromResult(new OAuth2Token("my-token", DateTimeOffset.UtcNow.AddHours(1))), + tokenType: "MAC" + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("MAC my-token"); + } + + public void Dispose() { + foreach (var auth in _authenticators) + auth.Dispose(); + } +} From 5e9adc2c6d90412eddb234ff40271966c50459d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:44:00 +0100 Subject: [PATCH 21/27] Bump dawidd6/action-download-artifact from 15 to 16 (#2364) Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 15 to 16. - [Release notes](https://github.com/dawidd6/action-download-artifact/releases) - [Commits](https://github.com/dawidd6/action-download-artifact/compare/fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8...2536c51d3d126276eb39f74d6bc9c72ac6ef30d3) --- updated-dependencies: - dependency-name: dawidd6/action-download-artifact dependency-version: '16' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test-results.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-results.yml b/.github/workflows/test-results.yml index 3d8776868..4269c0cb9 100644 --- a/.github/workflows/test-results.yml +++ b/.github/workflows/test-results.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Download and Extract Artifacts - uses: dawidd6/action-download-artifact@fe9d59ce33ce92db8a6ac90b2c8be6b6d90417c8 + uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 with: run_id: ${{ github.event.workflow_run.id }} path: artifacts From a7635760eee027f20991a9485fb30f08a63a65bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:44:11 +0100 Subject: [PATCH 22/27] Bump actions/upload-artifact from 6 to 7 (#2365) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pull-request.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 12eb9b98d..3d7aebb08 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Upload - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Event File path: ${{ github.event_path }} @@ -46,7 +46,7 @@ jobs: dotnet test test/RestSharp.Tests.DependencyInjection -c Debug -f ${{ matrix.dotnet }} - name: Upload Test Results if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Test Results Windows ${{ matrix.dotnet }} path: | @@ -80,7 +80,7 @@ jobs: dotnet test test/RestSharp.Tests.DependencyInjection -f ${{ matrix.dotnet }} - name: Upload Test Results if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Test Results Ubuntu ${{ matrix.dotnet }} path: | From c362621b172699270783dedc97d87dfd7d66d961 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 5 Mar 2026 12:54:31 +0100 Subject: [PATCH 23/27] Add missing ConfigureAwait(false) to prevent deadlocks (#2367) * Add missing ConfigureAwait(false) to prevent sync-over-async deadlocks Fixes #2083 Co-Authored-By: Claude Opus 4.6 * Add regression tests for sync-over-async deadlock fix Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../Extensions/HttpResponseExtensions.cs | 2 +- src/RestSharp/Response/RestResponse.cs | 2 +- src/RestSharp/RestClient.Extensions.cs | 6 +- .../SyncRequestTests.cs | 57 +++++++++++++++++++ 4 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 test/RestSharp.Tests.Integrated/SyncRequestTests.cs diff --git a/src/RestSharp/Extensions/HttpResponseExtensions.cs b/src/RestSharp/Extensions/HttpResponseExtensions.cs index 8cfd52a60..6cb669bfa 100644 --- a/src/RestSharp/Extensions/HttpResponseExtensions.cs +++ b/src/RestSharp/Extensions/HttpResponseExtensions.cs @@ -32,7 +32,7 @@ public static async Task GetResponseString(this HttpResponseMessage resp var encoding = encodingString != null ? TryGetEncoding(encodingString) : clientEncoding; using var reader = new StreamReader(new MemoryStream(bytes), encoding); - return await reader.ReadToEndAsync(); + return await reader.ReadToEndAsync().ConfigureAwait(false); Encoding TryGetEncoding(string es) { try { return Encoding.GetEncoding(es); diff --git a/src/RestSharp/Response/RestResponse.cs b/src/RestSharp/Response/RestResponse.cs index 92c3f0e3b..d092271e6 100644 --- a/src/RestSharp/Response/RestResponse.cs +++ b/src/RestSharp/Response/RestResponse.cs @@ -54,7 +54,7 @@ async Task GetDefaultResponse() { #endif var bytes = stream == null ? null : await stream.ReadAsBytes(cancellationToken).ConfigureAwait(false); - var content = bytes == null ? null : await httpResponse.GetResponseString(bytes, options.Encoding); + var content = bytes == null ? null : await httpResponse.GetResponseString(bytes, options.Encoding).ConfigureAwait(false); return new(request) { Content = content, diff --git a/src/RestSharp/RestClient.Extensions.cs b/src/RestSharp/RestClient.Extensions.cs index 80d1263f0..7ca67b1e6 100644 --- a/src/RestSharp/RestClient.Extensions.cs +++ b/src/RestSharp/RestClient.Extensions.cs @@ -45,7 +45,7 @@ public async Task> ExecuteAsync( Ensure.NotNull(request, nameof(request)); var response = await client.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); - return await client.Serializers.Deserialize(request, response, client.Options, cancellationToken); + return await client.Serializers.Deserialize(request, response, client.Options, cancellationToken).ConfigureAwait(false); } /// @@ -172,9 +172,9 @@ [EnumeratorCancellation] CancellationToken cancellationToken using var reader = new StreamReader(stream); #if NET7_0_OR_GREATER - while (await reader.ReadLineAsync(cancellationToken) is { } line && !cancellationToken.IsCancellationRequested) { + while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line && !cancellationToken.IsCancellationRequested) { #else - while (await reader.ReadLineAsync() is { } line && !cancellationToken.IsCancellationRequested) { + while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line && !cancellationToken.IsCancellationRequested) { #endif if (string.IsNullOrWhiteSpace(line)) continue; diff --git a/test/RestSharp.Tests.Integrated/SyncRequestTests.cs b/test/RestSharp.Tests.Integrated/SyncRequestTests.cs new file mode 100644 index 000000000..f782ea63f --- /dev/null +++ b/test/RestSharp.Tests.Integrated/SyncRequestTests.cs @@ -0,0 +1,57 @@ +namespace RestSharp.Tests.Integrated; + +#pragma warning disable xUnit1031 // Blocking calls in tests are intentional — we are testing sync-over-async deadlock safety + +public sealed class SyncRequestTests(WireMockTestServer server) : IClassFixture { + [Fact] + public void Sync_execute_should_not_deadlock() { + // Regression test for https://github.com/restsharp/RestSharp/issues/2083 + // Sync methods (ExecuteGet) could deadlock when await calls inside the pipeline + // did not use ConfigureAwait(false), causing continuations to try to marshal + // back to a captured SynchronizationContext. + + using var client = new RestClient(server.Url!); + var request = new RestRequest("success"); + + RestResponse? response = null; + + var completed = Task.Run(() => { + response = client.ExecuteGet(request); + }).Wait(TimeSpan.FromSeconds(10)); + + completed.Should().BeTrue("sync ExecuteGet should complete without deadlocking"); + response.Should().NotBeNull(); + response!.IsSuccessStatusCode.Should().BeTrue(); + } + + [Fact] + public void Sync_execute_with_deserialization_should_not_deadlock() { + using var client = new RestClient(server.Url!); + var request = new RestRequest("success"); + + RestResponse? response = null; + + var completed = Task.Run(() => { + response = client.ExecuteGet(request); + }).Wait(TimeSpan.FromSeconds(10)); + + completed.Should().BeTrue("sync ExecuteGet should complete without deadlocking"); + response.Should().NotBeNull(); + response!.IsSuccessStatusCode.Should().BeTrue(); + response.Data.Should().NotBeNull(); + } + + [Fact] + public void Sync_execute_from_multiple_threads_should_not_deadlock() { + using var client = new RestClient(server.Url!); + const int threadCount = 5; + + var completed = Parallel.For(0, threadCount, _ => { + var request = new RestRequest("success"); + var response = client.ExecuteGet(request); + response.IsSuccessStatusCode.Should().BeTrue(); + }); + + completed.IsCompleted.Should().BeTrue(); + } +} From 95b155a1b8db1cb3a2c8de5fac7a6c724901e955 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 5 Mar 2026 14:17:41 +0100 Subject: [PATCH 24/27] Add Docusaurus documentation guide with progressive disclosure CLAUDE.md gets a brief summary with link; full details in DOCUMENTATION.md covering structure, versioning, and writing conventions. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 ++++ DOCUMENTATION.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 DOCUMENTATION.md diff --git a/CLAUDE.md b/CLAUDE.md index 1fd8c31b5..605777689 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,10 @@ Use conditional compilation for TFM-specific APIs: `#if NET`, `#if NET8_0_OR_GRE - Guard TFM-specific tests with `#if NET8_0_OR_GREATER` - Test results: `test-results//.trx` +## Documentation + +Docusaurus 3 site in `docs/`. Dev server: `cd docs && pnpm start`. See [DOCUMENTATION.md](DOCUMENTATION.md) for full details on structure, versioning, and writing docs. + ## Working with Issues - Avoid changing default behavior unless absolutely necessary diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 000000000..c231a50f6 --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,43 @@ +# Documentation Guide + +The project documentation lives in `docs/` and uses **Docusaurus 3** with pnpm. + +## Commands + +```bash +cd docs + +# Install dependencies +pnpm install + +# Local dev server with hot reload +pnpm start + +# Production build +pnpm build +``` + +## Structure + +- `docs/docs/` — Current (next) version of docs, auto-generates sidebar from folder structure + - `docs/docs/usage/` — "Using RestSharp" guides (basics, client, request, execute, response, example) + - `docs/docs/advanced/` — Advanced topics (authenticators, configuration, serialization, interceptors, error handling) + - `docs/docs/intro.md` — Getting started page + - `docs/docs/changelog.md` — Release changelog +- `docs/versioned_docs/` — Frozen snapshots for past versions (v110–v113) +- `docs/versioned_sidebars/` — Sidebar configs for each frozen version +- `docs/versions.json` — List of released doc versions +- `docs/src/pages/` — Standalone pages (migration guide, support) +- `docs/docusaurus.config.ts` — Site config (URL, navbar, footer, plugins, versioning) +- `docs/sidebars.ts` — Sidebar config (autogenerated from directory structure) + +## Versioning + +Docs use Docusaurus versioned docs. `docs/docs/` is the "next" (unreleased) version. To cut a new version: `pnpm docusaurus docs:version vXXX`. This copies `docs/docs/` into `versioned_docs/version-vXXX/` and creates a matching sidebar file. Update `docusaurus.config.ts` to add the new version label. + +## Writing Docs + +- Markdown files with optional frontmatter (`sidebar_position`, `title`, `sidebar_label`) +- Category ordering via `_category_.json` files in each directory +- Code blocks use `csharp` language identifier for syntax highlighting +- Edit current docs in `docs/docs/`; do not modify `versioned_docs/` unless backporting fixes From 2ed45d4c51972d9801e75c92a439c9974c4bce12 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 5 Mar 2026 15:23:13 +0100 Subject: [PATCH 25/27] Add v114 documentation and Docusaurus version snapshot (#2368) * Add v114 documentation: changelog, RedirectOptions, parameter formatting, error handling - Add v114.0 changelog with breaking changes, new features, behavior changes, and bug fixes - Document RedirectOptions for fine-grained redirect control - Document MultipartFormQuoteParameters default change (false -> true) - Document InvariantCulture default for parameter formatting - Document improved ErrorMessage for timeouts and wrapped exceptions - Update options table entries for FollowRedirects, MaxRedirects, Expect100Continue - Create Docusaurus v114 versioned docs snapshot Co-Authored-By: Claude Opus 4.6 * Add .DS_Store and .grok/ to .gitignore Co-Authored-By: Claude Opus 4.6 * Fix code snippets in docs: Uri type, semicolons, property names, example constructor - Fix `new Url(...)` to `new Uri(...)` in configuration example - Remove stray semicolon in serialization lambda example - Replace deprecated `MaxTimeout` with `Timeout` in client docs - Replace `BaseAddress` with `BaseUrl` in HttpClient reuse section - Fix example constructor to use `RestClientOptions` instead of `IOptions<>` Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .gitignore | 4 +- docs/docs/advanced/configuration.md | 55 ++- docs/docs/advanced/error-handling.md | 2 + docs/docs/advanced/serialization.md | 2 +- docs/docs/changelog.md | 34 +- docs/docs/usage/client.md | 8 +- docs/docs/usage/example.md | 13 +- .../version-v114/advanced/_category_.json | 7 + .../version-v114/advanced/authenticators.md | 271 +++++++++++++ .../version-v114/advanced/configuration.md | 318 ++++++++++++++++ .../version-v114/advanced/error-handling.md | 73 ++++ .../version-v114/advanced/interceptors.md | 84 +++++ .../version-v114/advanced/serialization.md | 148 ++++++++ docs/versioned_docs/version-v114/changelog.md | 45 +++ docs/versioned_docs/version-v114/intro.md | 112 ++++++ .../version-v114/usage/_category_.json | 7 + .../version-v114/usage/basics.md | 23 ++ .../version-v114/usage/client.md | 115 ++++++ .../version-v114/usage/example.md | 120 ++++++ .../version-v114/usage/execute.md | 172 +++++++++ .../version-v114/usage/request.md | 356 ++++++++++++++++++ .../version-v114/usage/response.md | 49 +++ .../version-v114-sidebars.json | 8 + docs/versions.json | 1 + 24 files changed, 2007 insertions(+), 20 deletions(-) create mode 100644 docs/versioned_docs/version-v114/advanced/_category_.json create mode 100644 docs/versioned_docs/version-v114/advanced/authenticators.md create mode 100644 docs/versioned_docs/version-v114/advanced/configuration.md create mode 100644 docs/versioned_docs/version-v114/advanced/error-handling.md create mode 100644 docs/versioned_docs/version-v114/advanced/interceptors.md create mode 100644 docs/versioned_docs/version-v114/advanced/serialization.md create mode 100644 docs/versioned_docs/version-v114/changelog.md create mode 100644 docs/versioned_docs/version-v114/intro.md create mode 100644 docs/versioned_docs/version-v114/usage/_category_.json create mode 100644 docs/versioned_docs/version-v114/usage/basics.md create mode 100644 docs/versioned_docs/version-v114/usage/client.md create mode 100644 docs/versioned_docs/version-v114/usage/example.md create mode 100644 docs/versioned_docs/version-v114/usage/execute.md create mode 100644 docs/versioned_docs/version-v114/usage/request.md create mode 100644 docs/versioned_docs/version-v114/usage/response.md create mode 100644 docs/versioned_sidebars/version-v114-sidebars.json diff --git a/.gitignore b/.gitignore index 2b2ca2d47..500a275d7 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,6 @@ RestSharp.IntegrationTests/config.json /docs/.vuepress/dist/ .vscode/ .temp/ -*.trx \ No newline at end of file +*.trx +.DS_Store +.grok/ \ No newline at end of file diff --git a/docs/docs/advanced/configuration.md b/docs/docs/advanced/configuration.md index 69b9fa875..06d684c5f 100644 --- a/docs/docs/advanced/configuration.md +++ b/docs/docs/advanced/configuration.md @@ -62,8 +62,8 @@ For example: ```csharp var client = new RestClient(options => { - options.BaseUrl = new Url("https://localhost:5000/api"), - options.DisableCharset = true + options.BaseUrl = new Uri("https://localhost:5000/api"); + options.DisableCharset = true; }); ``` @@ -161,12 +161,13 @@ RestSharp allows configuring `RestClient` using client options, as mentioned at | `UseDefaultCredentials` | Whether to use default OS credentials for NTLM or Kerberos authentication. Not supported in browsers. | | `DisableCharset` | When set to `true`, the `Content-Type` header won't have the `charset` portion. Some older web servers don't understand the `charset` portion in the header and fail to process the request. | | `AutomaticDecompression` | Allows customizing supported decompression methods. Default is `All` except for .NET Framework that only support `GZip`. Not supported in browsers. | -| `MaxRedirects` | The number of redirects to follow. Not supported in browsers. | +| `MaxRedirects` | The number of redirects to follow. Delegates to `RedirectOptions.MaxRedirects`. Default is 50. | | `ClientCertificates` | A collection of X.509 client certificates to be used for authentication. Not supported in browsers. | | `Proxy` | Can be used if the client needs to use an explicit, non-default proxy. Not supported in browsers, on iOS and tvOS. | | `CachePolicy` | Shortcut for setting the default value for `Cache-Control` header. | -| `FollowRedirects` | Instructs the client to follow redirects. Default is `true`. | -| `Expect100Continue` | Gets or sets a value that indicates if the `Expect` header for an HTTP request contains `Continue`. | +| `FollowRedirects` | Instructs the client to follow redirects. Default is `true`. Delegates to `RedirectOptions.FollowRedirects`. | +| `RedirectOptions` | Fine-grained control over redirect behavior. See [Redirect Options](#redirect-options) below. | +| `Expect100Continue` | Gets or sets a value that indicates if the `Expect` header for an HTTP request contains `Continue`. Set per-request, not on `HttpClient.DefaultRequestHeaders`. | | `UserAgent` | Allows overriding the default value for `User-Agent` header, which is `RestSharp/{version}`. | | `PreAuthenticate` | Gets or sets a value that indicates whether the client sends an `Authorization` header with the request. Not supported in browsers. | | `RemoteCertificateValidationCallback` | Custom function to validate the server certificate. Normally, it's used when the server uses a certificate that isn't trusted by default. | @@ -186,10 +187,8 @@ Some of the options are used by RestSharp code, but some are only used to config - `UseDefaultCredentials` - `AutomaticDecompression` - `PreAuthenticate` -- `MaxRedirects` - `RemoteCertificateValidationCallback` - `ClientCertificates` -- `FollowRedirects` - `Proxy` :::note @@ -207,6 +206,7 @@ Client options apply to all requests made by the client. Sometimes, you want to | `AlwaysMultipartFormData` | When set to `true`, the request will be sent as a multipart form, even though it's not required. By default, RestSharp only sends requests with multiple attachments as multipart forms. Default is `false`. | | `AlwaysSingleFileAsContent` | When set to true, the request with file attachment will not be sent as a multipart form, but as plain content. Default is `false`. It cannot be set to `true` when `AlwaysMultipartFormData` is set to `true`, or when the request has `POST` parameters. | | `MultipartFormQuoteBoundary` | Default is `true`, which means that the form boundary string will be wrapped in quotes. If the server has an issue with that, setting this to `false` will remove quotes around the boundary. | +| `MultipartFormQuoteParameters` | Whether to quote parameter names in multipart form `Content-Disposition` headers per RFC 7578. Default is `true` (changed from `false` in v114). | | `FormBoundary` | Allows specifying a custom multipart form boundary instead of using the default random string. | | `RequestParameters` | Collection of request parameters. Normally, you won't need to use it as parameters are added to the request using `Add...` functions. | | `CookieContainer` | Custom request-level cookie container. Default is `null`. You can still set request cookies using `AddCookie` and get response cookies from the response object without using cooking container. | @@ -275,3 +275,44 @@ var longRunningRequest = new RestRequest("long-operation") { Timeout = Timeout.InfiniteTimeSpan }; ``` + +## Redirect Options + +RestSharp handles redirects internally (rather than delegating to `HttpClient`) so it can capture `Set-Cookie` headers from intermediate redirect responses. The `RedirectOptions` class provides fine-grained control over redirect behavior. + +The convenience properties `RestClientOptions.FollowRedirects` and `RestClientOptions.MaxRedirects` delegate to `RedirectOptions` for backward compatibility. + +```csharp +var options = new RestClientOptions("https://api.example.com") { + RedirectOptions = new RedirectOptions { + FollowRedirects = true, + MaxRedirects = 10, + ForwardAuthorization = true, + FollowRedirectsToInsecure = false + } +}; +``` + +| Option | Default | Description | +|-----------------------------------|---------|--------------------------------------------------------------------------------------------------------------| +| `FollowRedirects` | `true` | Whether to follow redirects. | +| `MaxRedirects` | `50` | Maximum number of redirects to follow. | +| `FollowRedirectsToInsecure` | `false` | Whether to follow redirects from HTTPS to HTTP. | +| `ForwardHeaders` | `true` | Whether to forward request headers on redirect. | +| `ForwardAuthorization` | `false` | Whether to forward the `Authorization` header on same-host redirects. | +| `ForwardAuthorizationToExternalHost` | `false` | Whether to forward `Authorization` on cross-host redirects. Only applies when `ForwardAuthorization` is true. | +| `ForwardCookies` | `true` | Whether to forward cookies on redirect. `Set-Cookie` headers are always captured regardless. | +| `ForwardBody` | `true` | Whether to forward the request body when the HTTP verb is preserved. Body is always dropped when verb changes to GET. | +| `ForwardQuery` | `true` | Whether to forward the original query string parameters on redirect. | +| `RedirectStatusCodes` | 301, 302, 303, 307, 308 | HTTP status codes considered as redirects. | + +## Parameter formatting + +By default, all generic parameter methods (`AddParameter`, `AddQueryParameter`, `AddUrlSegment`, `AddHeader`, etc.) format values using `CultureInfo.InvariantCulture`. This ensures consistent behavior regardless of the server's or client's locale. + +If you need locale-specific formatting, pass a `CultureInfo` explicitly: + +```csharp +request.AddParameter("price", 1234.56, culture: new CultureInfo("da-DK")); +// Sends: price=1234,56 +``` diff --git a/docs/docs/advanced/error-handling.md b/docs/docs/advanced/error-handling.md index 80e822dcc..22bfbbd7e 100644 --- a/docs/docs/advanced/error-handling.md +++ b/docs/docs/advanced/error-handling.md @@ -5,6 +5,8 @@ If there is a network transport error (network is down, failed DNS lookup, etc.) If an API returns a 404, `ResponseStatus` will still be `Completed`. If you need access to the HTTP status code returned, you will find it at `RestResponse.StatusCode`. The `Status` property is an indicator of completion independent of the API error handling. +When a request times out, `ResponseStatus` is set to `TimedOut` and `ErrorMessage` will say `"The request timed out."`. For other transport errors, `ErrorMessage` shows the root cause by using `GetBaseException().Message`, so you see the actual error (e.g., a TLS failure) rather than a generic wrapper message like `"An error occurred while sending the request"`. The full exception chain is always available in `ErrorException`. + Normally, RestSharp doesn't throw an exception if the request fails. However, it is possible to configure RestSharp to throw in different situations when it normally doesn't throw diff --git a/docs/docs/advanced/serialization.md b/docs/docs/advanced/serialization.md index b7092519a..1470a3810 100644 --- a/docs/docs/advanced/serialization.md +++ b/docs/docs/advanced/serialization.md @@ -16,7 +16,7 @@ You can tell RestSharp to use a custom serializer by using the `configureSeriali ```csharp var client = new RestClient( options, - configureSerialization: s => s.UseSerializer(() => new CustomSerializer()); + configureSerialization: s => s.UseSerializer(() => new CustomSerializer()) ); ``` diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index f637a8f51..8b1edeafd 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -10,10 +10,36 @@ For release notes of previous versions, please check the [Releases page](https:/ Changes between major versions are documented in the documentation for each version on this website. -# v112.0 +## v114.0 -* Security fix for [CVE-2024-45302](https://github.com/restsharp/RestSharp/security/advisories/GHSA-4rr6-2v9v-wcpc). Header values cannot contain `CRLF`. +### Breaking changes -## v112.1 +* **`IAuthenticator` interface signature changed** — `Authenticate` now accepts an optional `CancellationToken` parameter. All custom authenticator implementations must be recompiled. ([#2362](https://github.com/restsharp/RestSharp/pull/2362)) +* **`FollowRedirects` and `MaxRedirects` removed from `ReadOnlyRestClientOptions`** — these properties are now excluded from the generated immutable wrapper. Use `RedirectOptions` instead. Code accessing these properties on `ReadOnlyRestClientOptions` must be updated. ([#2360](https://github.com/restsharp/RestSharp/pull/2360)) +* **Extension method signatures changed** — `AddParameter`, `AddOrUpdateParameter`, `AddHeader`, `AddOrUpdateHeader`, `AddQueryParameter`, and `AddUrlSegment` now have an additional optional `CultureInfo? culture` parameter. Assemblies compiled against v113 must be recompiled. ([#2354](https://github.com/restsharp/RestSharp/pull/2354)) -* Follow up on v112.0 security fix: remove `\t` from the list of forbidden characters in headers. +### New features + +* **OAuth2 token lifecycle authenticators** — new `OAuth2ClientCredentialsAuthenticator`, `OAuth2RefreshTokenAuthenticator`, and `OAuth2TokenAuthenticator` that handle obtaining, caching, and refreshing tokens automatically. ([#2362](https://github.com/restsharp/RestSharp/pull/2362)) +* **Custom redirect handling with `RedirectOptions`** — RestSharp now manages redirects internally instead of delegating to `HttpClient`, fixing lost `Set-Cookie` headers on redirects. New `RedirectOptions` class provides fine-grained control over redirect behavior. ([#2360](https://github.com/restsharp/RestSharp/pull/2360)) +* **`MergedParameters` on `RestResponse`** — provides a combined view of request and default parameters at execution time, useful for logging and debugging. ([#2349](https://github.com/restsharp/RestSharp/pull/2349)) +* **Restored `AddCookie(name, value)` overload** — the simple two-parameter form defers domain resolution to execution time. ([#2351](https://github.com/restsharp/RestSharp/pull/2351)) + +### Behavior changes + +* **`MultipartFormQuoteParameters` now defaults to `true`** — multipart form parameter names are now quoted per RFC 7578. ([#2357](https://github.com/restsharp/RestSharp/pull/2357)) +* **`InvariantCulture` used for parameter formatting** — all generic parameter methods now format values using `CultureInfo.InvariantCulture` by default. Pass a `CultureInfo` explicitly if locale-specific formatting is needed. ([#2354](https://github.com/restsharp/RestSharp/pull/2354)) +* **Improved `ErrorMessage` for timeouts** — now shows `"The request timed out."` instead of `"A task was canceled."`. ([#2356](https://github.com/restsharp/RestSharp/pull/2356)) +* **`ErrorMessage` surfaces root cause** — uses `GetBaseException().Message` to show the actual error instead of generic wrapper messages. ([#2352](https://github.com/restsharp/RestSharp/pull/2352)) +* **`HttpClient.DefaultRequestHeaders` no longer modified** — `Expect100Continue` is now set per-request. Safe to share an `HttpClient` across multiple `RestClient` instances. ([#2363](https://github.com/restsharp/RestSharp/pull/2363)) + +### Bug fixes + +* Fix `ConfigureAwait(false)` missing on several `await` calls, preventing deadlocks in sync-over-async scenarios. ([#2367](https://github.com/restsharp/RestSharp/pull/2367)) +* Fix `ResponseUri` returning original URL instead of redirect target when `FollowRedirects=false`. ([#2350](https://github.com/restsharp/RestSharp/pull/2350)) +* Fix default parameter merging bugs (multi-value dedup, request mutation, `BuildUriString` without execute). ([#2349](https://github.com/restsharp/RestSharp/pull/2349)) +* Fix OAuth1 double-encoding of RFC 3986 special characters in URL paths. ([#2341](https://github.com/restsharp/RestSharp/pull/2341)) +* Fix pipe character encoding when `AddQueryParameter` is used with `encode=false`. ([#2345](https://github.com/restsharp/RestSharp/pull/2345)) +* Fix `XmlDeserializer` when XML uses same tag name in nested elements. ([#2339](https://github.com/restsharp/RestSharp/pull/2339)) +* Fix credential/`UseDefaultCredentials` property order on `HttpClientHandler`. ([#2353](https://github.com/restsharp/RestSharp/pull/2353)) +* Fix URL escaping on .NET Framework 4.6.2. ([#2327](https://github.com/restsharp/RestSharp/pull/2327)) diff --git a/docs/docs/usage/client.md b/docs/docs/usage/client.md index e84b955f4..97af9fce5 100644 --- a/docs/docs/usage/client.md +++ b/docs/docs/usage/client.md @@ -24,7 +24,7 @@ Here's an example of how to create a client using the same base path as in the p ```csharp // Creates a client using the options object var options = new RestClientOptions("https://localhost:5000") { - MaxTimeout = 1000 + Timeout = TimeSpan.FromSeconds(1) }; var client = new RestClient(options); ``` @@ -47,7 +47,7 @@ Another way to create the client instance is to use a simple client factory. The * `RemoteCertificateValidationCallback` * `ClientCertificates` * `MaxRedirects` -* `MaxTimeout` +* `Timeout` * `UserAgent` * `Expect100Continue` @@ -66,8 +66,8 @@ RestSharp uses `HttpClient` internally to make HTTP requests. It's possible to r One way of doing it is to use `RestClient` constructors that accept an instance of `HttpClient` or `HttpMessageHandler` as an argument. Note that in that case not all the options provided via `RestClientOptions` will be used. Here is the list of options that will work: -- `BaseAddress` is be used to set the base address of the `HttpClient` instance if base address is not set there already. -- `MaxTimeout` is used to cancel the call using the cancellation token source, so +- `BaseUrl` will be taken from `httpClient.BaseAddress` if not set explicitly. +- `Timeout` is used to cancel the call using the cancellation token source. - `UserAgent` will be added to the `RestClient.DefaultParameters` list as a HTTP header. This will be added to each request made by the `RestClient`, and the `HttpClient` instance will not be modified. This is to allow the `HttpClient` instance to be reused for scenarios where different `User-Agent` headers are required. - `Expect100Continue` diff --git a/docs/docs/usage/example.md b/docs/docs/usage/example.md index ed3b907bb..28e722575 100644 --- a/docs/docs/usage/example.md +++ b/docs/docs/usage/example.md @@ -76,11 +76,18 @@ public class TwitterClient : ITwitterClient, IDisposable { It is also possible to use ASP.NET Core Options for configuring the client, instead of passing the credentials as strings. For example, we can add a class for Twitter client options, and use it in a constructor: ```csharp -public class TwitterClientOptions(string ApiKey, string ApiSecret); +public record TwitterClientOptions(string ApiKey, string ApiSecret); public TwitterClient(IOptions options) { - var opt = new RestClientOptions("https://api.twitter.com/2"); - _client = new RestClient(options); + var tokenRequest = new OAuth2TokenRequest( + "https://api.twitter.com/oauth2/token", + options.Value.ApiKey, + options.Value.ApiSecret + ); + var opt = new RestClientOptions("https://api.twitter.com/2") { + Authenticator = new OAuth2ClientCredentialsAuthenticator(tokenRequest) + }; + _client = new RestClient(opt); } ``` diff --git a/docs/versioned_docs/version-v114/advanced/_category_.json b/docs/versioned_docs/version-v114/advanced/_category_.json new file mode 100644 index 000000000..f395bdfe4 --- /dev/null +++ b/docs/versioned_docs/version-v114/advanced/_category_.json @@ -0,0 +1,7 @@ +{ + "label": "Advanced topics", + "position": 4, + "link": { + "type": "generated-index" + } +} diff --git a/docs/versioned_docs/version-v114/advanced/authenticators.md b/docs/versioned_docs/version-v114/advanced/authenticators.md new file mode 100644 index 000000000..c80deb891 --- /dev/null +++ b/docs/versioned_docs/version-v114/advanced/authenticators.md @@ -0,0 +1,271 @@ +# Authenticators + +RestSharp includes authenticators for basic HTTP, OAuth1 and token-based (JWT and OAuth2). + +There are two ways to set the authenticator: client-wide or per-request. + +Set the client-wide authenticator by assigning the `Authenticator` property of `RestClientOptions`: + +```csharp +var options = new RestClientOptions("https://example.com") { + Authenticator = new HttpBasicAuthenticator("username", "password") +}; +var client = new RestClient(options); +``` + +To set the authenticator per-request, assign the `Authenticator` property of `RestRequest`: + +```csharp +var request = new RestRequest("/api/users/me") { + Authenticator = new HttpBasicAuthenticator("username", "password") +}; +var response = await client.ExecuteAsync(request, cancellationToken); +``` + +## Basic authentication + +The `HttpBasicAuthenticator` allows you pass a username and password as a basic `Authorization` header using a base64 encoded string. + +```csharp +var options = new RestClientOptions("https://example.com") { + Authenticator = new HttpBasicAuthenticator("username", "password") +}; +var client = new RestClient(options); +``` + +## OAuth1 + +For OAuth1 authentication the `OAuth1Authenticator` class provides static methods to help generate an OAuth authenticator. +OAuth1 authenticator will add the necessary OAuth parameters to the request, including signature. + +The authenticator will use `HMAC SHA1` to create a signature by default. +Each static function to create the authenticator allows you to override the default and use another method to generate the signature. + +### Request token + +Getting a temporary request token is the usual first step in the 3-legged OAuth1 flow. +Use `OAuth1Authenticator.ForRequestToken` function to get the request token authenticator. +This method requires a `consumerKey` and `consumerSecret` to authenticate. + +```csharp +var options = new RestClientOptions("https://api.twitter.com") { + Authenticator = OAuth1Authenticator.ForRequestToken(consumerKey, consumerSecret) +}; +var client = new RestClient(options); +var request = new RestRequest("oauth/request_token"); +``` + +The response should contain the token and the token secret, which can then be used to complete the authorization process. +If you need to provide the callback URL, assign the `CallbackUrl` property of the authenticator to the callback destination. + +### Access token + +Getting an access token is the usual third step in the 3-legged OAuth1 flow. +This method retrieves an access token when provided `consumerKey`, `consumerSecret`, `oauthToken`, and `oauthTokenSecret`. +If you don't have a token for this call, you need to make a call to get the request token as described above. + +```csharp +var authenticator = OAuth1Authenticator.ForAccessToken( + consumerKey, consumerSecret, oauthToken, oauthTokenSecret +); +var options = new RestClientOptions("https://api.twitter.com") { + Authenticator = authenticator +}; +var client = new RestClient(options); +var request = new RestRequest("oauth/access_token"); +``` + +If the second step in 3-leg OAuth1 flow returned a verifier value, you can use another overload of `ForAccessToken`: + +```csharp +var authenticator = OAuth1Authenticator.ForAccessToken( + consumerKey, consumerSecret, oauthToken, oauthTokenSecret, verifier +); +``` + +The response should contain the access token that can be used to make calls to protected resources. + +For refreshing access tokens, use one of the two overloads of `ForAccessToken` that accept `sessionHandle`. + +### Protected resource + +When the access token is available, use `ForProtectedResource` function to get the authenticator for accessing protected resources. + +```csharp +var authenticator = OAuth1Authenticator.ForAccessToken( + consumerKey, consumerSecret, accessToken, accessTokenSecret +); +var options = new RestClientOptions("https://api.twitter.com/1.1") { + Authenticator = authenticator +}; +var client = new RestClient(options); +var request = new RestRequest("statuses/update.json", Method.Post) + .AddParameter("status", "Hello Ladies + Gentlemen, a signed OAuth request!") + .AddParameter("include_entities", "true"); +``` + +### xAuth + +xAuth is a simplified version of OAuth1. It allows sending the username and password as `x_auth_username` and `x_auth_password` request parameters and directly get the access token. xAuth is not widely supported, but RestSharp still allows using it. + +Create an xAuth authenticator using `OAuth1Authenticator.ForClientAuthentication` function: + +```csharp +var authenticator = OAuth1Authenticator.ForClientAuthentication( + consumerKey, consumerSecret, username, password +); +``` + +### 0-legged OAuth + +The access token authenticator can be used in 0-legged OAuth scenarios by providing `null` for the `consumerSecret`. + +```csharp +var authenticator = OAuth1Authenticator.ForAccessToken( + consumerKey, null, oauthToken, oauthTokenSecret +); +``` + +## OAuth2 + +RestSharp provides OAuth2 authenticators at two levels: **token lifecycle authenticators** that handle the full flow (obtaining, caching, and refreshing tokens automatically), and **simple authenticators** that just stamp a pre-obtained token onto requests. + +### Token lifecycle authenticators + +These authenticators manage tokens end-to-end. They use their own internal `HttpClient` for token endpoint calls, so there's no circular dependency with the `RestClient` they're attached to. All are thread-safe for concurrent use. + +#### Client credentials + +Use `OAuth2ClientCredentialsAuthenticator` for machine-to-machine flows. It POSTs `grant_type=client_credentials` to your token endpoint, caches the token, and refreshes it automatically before it expires. + +```csharp +var request = new OAuth2TokenRequest( + "https://auth.example.com/oauth2/token", + "my-client-id", + "my-client-secret" +) { + Scope = "api.read api.write" +}; + +var options = new RestClientOptions("https://api.example.com") { + Authenticator = new OAuth2ClientCredentialsAuthenticator(request) +}; +using var client = new RestClient(options); +``` + +The authenticator will obtain a token on the first request and reuse it until it expires. The `ExpiryBuffer` property (default 30 seconds) controls how far in advance of actual expiry the token is considered stale. + +#### Refresh token + +Use `OAuth2RefreshTokenAuthenticator` when you already have an access token and refresh token (e.g., from an authorization code flow). It uses the initial access token until it expires, then automatically refreshes using the `refresh_token` grant type. + +```csharp +var request = new OAuth2TokenRequest( + "https://auth.example.com/oauth2/token", + "my-client-id", + "my-client-secret" +) { + OnTokenRefreshed = response => { + // Persist the new tokens to your storage + SaveTokens(response.AccessToken, response.RefreshToken); + } +}; + +var options = new RestClientOptions("https://api.example.com") { + Authenticator = new OAuth2RefreshTokenAuthenticator( + request, + accessToken: "current-access-token", + refreshToken: "current-refresh-token", + expiresAt: DateTimeOffset.UtcNow.AddMinutes(30) + ) +}; +using var client = new RestClient(options); +``` + +If the server rotates refresh tokens, the authenticator will automatically use the new refresh token for subsequent refreshes. The `OnTokenRefreshed` callback fires every time a new token is obtained, so you can persist the updated tokens. + +#### Custom token provider + +Use `OAuth2TokenAuthenticator` when you have a non-standard token flow or want full control over how tokens are obtained. Provide an async delegate that returns an `OAuth2Token`: + +```csharp +var options = new RestClientOptions("https://api.example.com") { + Authenticator = new OAuth2TokenAuthenticator(async cancellationToken => { + var token = await myCustomTokenService.GetTokenAsync(cancellationToken); + return new OAuth2Token(token.Value, token.ExpiresAt); + }) +}; +using var client = new RestClient(options); +``` + +The authenticator caches the result and re-invokes your delegate when the token expires. + +#### Bringing your own HttpClient + +By default, the token lifecycle authenticators create their own `HttpClient` for token endpoint calls (and dispose it when the authenticator is disposed). If you need to customize it (e.g., for proxy settings or mTLS), pass your own: + +```csharp +var request = new OAuth2TokenRequest( + "https://auth.example.com/oauth2/token", + "my-client-id", + "my-client-secret" +) { + HttpClient = myCustomHttpClient // not disposed by the authenticator +}; +``` + +### Simple authenticators + +If you manage tokens yourself and just need to stamp them onto requests, use these simpler authenticators. + +`OAuth2UriQueryParameterAuthenticator` accepts the access token as the only constructor argument, and it will send the provided token as a query parameter `oauth_token`. + +`OAuth2AuthorizationRequestHeaderAuthenticator` has two constructors. One only accepts a single argument, which is the access token. The other constructor also allows you to specify the token type. The authenticator will then add an `Authorization` header using the specified token type or `OAuth` as the default token type, and the token itself. + +For example: + +```csharp +var authenticator = new OAuth2AuthorizationRequestHeaderAuthenticator( + token, "Bearer" +); +var options = new RestClientOptions("https://example.com") { + Authenticator = authenticator +}; +var client = new RestClient(options); +``` + +The code above will tell RestSharp to send the bearer token with each request as a header. Essentially, the code above does the same as the sample for `JwtAuthenticator` below. + +## JWT + +The JWT authentication can be supported by using `JwtAuthenticator`. It is a very simple class that can be constructed like this: + +```csharp +var authenticator = new JwtAuthenticator(myToken); +var options = new RestClientOptions("https://example.com") { + Authenticator = authenticator +}; +var client = new RestClient(options); +``` + +For each request, it will add an `Authorization` header with the value `Bearer `. + +As you might need to refresh the token from, you can use the `SetBearerToken` method to update the token. + +## Custom authenticator + +You can write your own implementation by implementing `IAuthenticator` and +registering it with your RestClient: + +```csharp +var authenticator = new SuperAuthenticator(); // implements IAuthenticator +var options = new RestClientOptions("https://example.com") { + Authenticator = authenticator +}; +var client = new RestClient(options); +``` + +The `Authenticate` method is the very first thing called upon calling `RestClient.Execute` or `RestClient.Execute`. +It gets the `RestRequest` currently being executed giving you access to every part of the request data (headers, parameters, etc.) + +You can find an example of using the built-in OAuth2 authenticator in a typed API client [here](../usage/example.md#authenticator). diff --git a/docs/versioned_docs/version-v114/advanced/configuration.md b/docs/versioned_docs/version-v114/advanced/configuration.md new file mode 100644 index 000000000..06d684c5f --- /dev/null +++ b/docs/versioned_docs/version-v114/advanced/configuration.md @@ -0,0 +1,318 @@ +--- +title: Configuration +description: Learn how to configure RestClient for non-trivial use cases. +sidebar_position: 1 +--- + +# Configuring RestClient + +This page describes how to create and configure `RestClient`. + +## Basic configuration + +The primary `RestClient` constructor accepts an instance of `RestClientOptions`. Most of the time, default option values don't need to be changed. However, in some cases, you'd want to configure the client differently, so you'd need to change some of the options in your code. The constructor also contains a few optional parameters for additional configuration that is not covered by client options. Here's the constructor signature: + +```csharp +public RestClient( + RestClientOptions options, + ConfigureHeaders? configureDefaultHeaders = null, + ConfigureSerialization? configureSerialization = null, + bool useClientFactory = false +) +``` + +Constructor parameters are: + +| Name | Description | Mandatory | +|-------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------| +| options | Client options | Yes | +| configureDefaultHeaders | Function to configure headers. Allows to configure default headers for `HttpClient`. Most of the time you'd prefer using `client.AddDefaultHeader` instead. | No | +| configureSerialization | Function to configure client serializers with non-default options or to use a different serializer ([learn more](serialization.md)) | No | +| useClientFactory | Instructs the client to use `SimpleFactory` ([learn more](../usage/client.md#simple-factory)) to get an `HttpClient` instance | No | + +Here's an example of how to create a client using client options: + +```csharp +var options = new RestClientOptions("https://localhost:5000/api") { + DisableCharset = true +}; +var client = new RestClient(options); +``` + +When you only need to set the base URL, you can use a simplified constructor: + +```csharp +var client = new RestClient("https://localhost:5000/api"); +``` + +The simplified constructor will create an instance of client options and set the base URL provided as the constructor argument. + +Finally, you can override properties of default options using a configuration function. Here's the constructor signature that supports this method: + +```csharp +public RestClient( + ConfigureRestClient? configureRestClient = null, + ConfigureHeaders? configureDefaultHeaders = null, + ConfigureSerialization? configureSerialization = null, + bool useClientFactory = false +) +``` + +For example: + +```csharp +var client = new RestClient(options => { + options.BaseUrl = new Uri("https://localhost:5000/api"); + options.DisableCharset = true; +}); +``` + +You can also provide the base URL as a constructor argument like this: + +```csharp +var client = new RestClient("https://localhost:5000/api", options => { + options.DisableCharset = true +}); +``` + +## Using custom HttpClient + +By default, RestSharp creates an instance of `HttpClient` configured using the client options, and keeps it during the lifetime of the client. When the `RestClient` instance gets disposed, it also disposes the `HttpClient` instance. + +There might be a case when you need to provide your own `HttpClient`. For example, you would want to use `HttpClient` created by HTTP client factory. RestSharp allows you to do it by using additional constructors. These constructors are: + +```csharp +// Create a client using an existing HttpClient and RestClientOptions (optional) +public RestClient( + HttpClient httpClient, + RestClientOptions? options, + bool disposeHttpClient = false, + ConfigureSerialization? configureSerialization = null +) + +// Create a client using an existing HttpClient and optional RestClient configuration function +public RestClient( + HttpClient httpClient, + bool disposeHttpClient = false, + ConfigureRestClient? configureRestClient = null, + ConfigureSerialization? configureSerialization = null +) +``` + +The `disposeHttpClient` argument tells the client to dispose `HttpClient` when the client itself gets disposed. It's set to `false` by default as when the `HttpClient` is provided from the outside, it should normally be disposed on the outside as well. + +## Using custom message handler + +Unless you use an external instance of `HttpClient`, the `RestClient` creates one when being constructed, and it will use the default HTTP message handler, configured using `RestClientOptions`. Normally, you'd get a `SocketHttpHandler` with modern .NET, and `WinHttpHandler` with .NET Framework. + +There might be a case when you need to configure the HTTP message handler. For example, you want to add a delegating message handler. RestSharp allows you to do it by using additional constructors. There's one constructor that allows you to pass the custom `HttpMessageHandler`: + +```csharp +public RestClient( + HttpMessageHandler handler, + bool disposeHandler = true, + ConfigureRestClient? configureRestClient = null, + ConfigureSerialization? configureSerialization = null +) +``` + +This constructor will create a new `HttpClient` instance using the provided message handler. As RestSharp will dispose the `HttpClient` instance when the `RestClient` instance gets disposed, the handler will be disposed as well. If you want to change that and keep the handler, set the `disposeHandler` parameter to `false`. + +:::note +When using a custom message handler, RestSharp **will not** configure it with client options, which are normally used to configure the handler created by RestSharp. +::: + +Another way to customize the message handler is to allow RestSharp to create a handler, but then configure it, or wrap it in a delegating handler. It can be done by using the `RestClientOptions.ConfigureMessageHandler` property. It can be set to a function that receives the handler created by RestSharp and returned either the same handler with different settings, or a new handler. + +For example, if you want to use `MockHttp` and its handler for testing, you can do it like this: + +```csharp +var mockHttp = new MockHttpMessageHandler(); +// Configure the MockHttp handler to do the checks +... + +var options = new RestClientOptions(Url) { + ConfigureMessageHandler = _ => mockHttp +}; +using var client = new RestClient(options); +``` + +In this example, we are reassigning the handler to MockHttp, so the handler created by RestSharp isn't used. In other cases you want to use delegating handlers as middleware, so you'd pass the handler created by RestSharp to the delegating handler: + +```csharp +var options = new RestClientOptions(Url) { + ConfigureMessageHandler = handler => new MyDelegatingHandler(handler) +}; +using var client = new RestClient(options); +``` + +## Client options + +RestSharp allows configuring `RestClient` using client options, as mentioned at the beginning of this page. Below, you find more details about available options. + +| Option | Description | +|----------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `BaseUrl` | Client base URL. It can also be provided as the `RestClientOptions` constructor argument. | +| `ConfigureMessageHandler` | Configures the HTTP message handler (see above). | +| `CalculateResponseStatus` | Function to calculate a different response status from `HttpResponseMessage`. By default, the request is considered as complete if it returns a successful status code or 404. | +| `Authenticator` | Client-level authenticator. Read more about authenticators [here](authenticators.md). | +| `Interceptors` | A collector of interceptors. Read more about interceptors [here](interceptors.md). | +| `Credentials` | Instance of `ICredentials` used for NTLM or Kerberos authentication. Not supported in browsers. | +| `UseDefaultCredentials` | Whether to use default OS credentials for NTLM or Kerberos authentication. Not supported in browsers. | +| `DisableCharset` | When set to `true`, the `Content-Type` header won't have the `charset` portion. Some older web servers don't understand the `charset` portion in the header and fail to process the request. | +| `AutomaticDecompression` | Allows customizing supported decompression methods. Default is `All` except for .NET Framework that only support `GZip`. Not supported in browsers. | +| `MaxRedirects` | The number of redirects to follow. Delegates to `RedirectOptions.MaxRedirects`. Default is 50. | +| `ClientCertificates` | A collection of X.509 client certificates to be used for authentication. Not supported in browsers. | +| `Proxy` | Can be used if the client needs to use an explicit, non-default proxy. Not supported in browsers, on iOS and tvOS. | +| `CachePolicy` | Shortcut for setting the default value for `Cache-Control` header. | +| `FollowRedirects` | Instructs the client to follow redirects. Default is `true`. Delegates to `RedirectOptions.FollowRedirects`. | +| `RedirectOptions` | Fine-grained control over redirect behavior. See [Redirect Options](#redirect-options) below. | +| `Expect100Continue` | Gets or sets a value that indicates if the `Expect` header for an HTTP request contains `Continue`. Set per-request, not on `HttpClient.DefaultRequestHeaders`. | +| `UserAgent` | Allows overriding the default value for `User-Agent` header, which is `RestSharp/{version}`. | +| `PreAuthenticate` | Gets or sets a value that indicates whether the client sends an `Authorization` header with the request. Not supported in browsers. | +| `RemoteCertificateValidationCallback` | Custom function to validate the server certificate. Normally, it's used when the server uses a certificate that isn't trusted by default. | +| `BaseHost` | Value for the `Host` header sent with each request. | +| `CookieContainer` | Custom cookie container that will be shared among all calls made by the client. Normally not required as RestSharp handles cookies without using a client-level cookie container. | +| `Timeout` | Client-level timeout as `TimeSpan`. Default is 100 seconds. See [Configuring Timeouts](#configuring-timeouts) for details on timeout behavior. | +| `Encoding` | Default request encoding. Override it only if you don't use UTF-8. | +| `ThrowOnDeserializationError` | Forces the client to throw if it fails to deserialize the response. Remember that not all deserialization issues forces the serializer to throw. Default is `false`, so the client will return a `RestResponse` with deserialization exception details. Only relevant for `Execute...` functions. | +| `FailOnDeserializationError` | When set to `true`, if the client fails to deserialize the response, the response object will have status `Failed`, although the HTTP calls might have been successful. Default is `true`. | +| `ThrowOnAnyError` | When set to `true`, the client will re-throw any exception from `HttpClient`. Default is `false`. Only applies for `Execute...` functions. | +| `AllowMultipleDefaultParametersWithSameName` | By default, adding parameters with the same name is not allowed. You can override this behaviour by setting this property to `true`. | +| `Encode` | A function to encode URLs, the default is a custom RestSharp function based on `Uri.EscapeDataString()`. Set it if you need a different way to do the encoding. | +| `EncodeQuery` | A function to encode URL query parameters. The default is the same function as for `Encode` property. | + +Some of the options are used by RestSharp code, but some are only used to configure the `HttpMessageHandler`. These options are: +- `Credentials` +- `UseDefaultCredentials` +- `AutomaticDecompression` +- `PreAuthenticate` +- `RemoteCertificateValidationCallback` +- `ClientCertificates` +- `Proxy` + +:::note +If setting these options to non-default values produce no desirable effect, check if your framework and platform supports them. RestSharp doesn't change behaviour based on values of those options. +::: + +The `IRestClient` interface exposes the `Options` property, so any option can be inspected at runtime. However, RestSharp converts the options object provided to the client constructor to an immutable object. Therefore, no client option can be changed after the client is instantiated. It's because changing client options at runtime can produce issues in concurrent environments, effectively rendering the client as not thread-safe. Apart from that, changing the options that are used to create the message handler would require re-creating the handler, and also `HttpClient`, which should not be done at runtime. + +## Configuring requests + +Client options apply to all requests made by the client. Sometimes, you want to fine-tune particular requests, so they execute with custom configuration. It's possible to do using properties of `RestRequest`, described below. + +| Name | Description | +|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `AlwaysMultipartFormData` | When set to `true`, the request will be sent as a multipart form, even though it's not required. By default, RestSharp only sends requests with multiple attachments as multipart forms. Default is `false`. | +| `AlwaysSingleFileAsContent` | When set to true, the request with file attachment will not be sent as a multipart form, but as plain content. Default is `false`. It cannot be set to `true` when `AlwaysMultipartFormData` is set to `true`, or when the request has `POST` parameters. | +| `MultipartFormQuoteBoundary` | Default is `true`, which means that the form boundary string will be wrapped in quotes. If the server has an issue with that, setting this to `false` will remove quotes around the boundary. | +| `MultipartFormQuoteParameters` | Whether to quote parameter names in multipart form `Content-Disposition` headers per RFC 7578. Default is `true` (changed from `false` in v114). | +| `FormBoundary` | Allows specifying a custom multipart form boundary instead of using the default random string. | +| `RequestParameters` | Collection of request parameters. Normally, you won't need to use it as parameters are added to the request using `Add...` functions. | +| `CookieContainer` | Custom request-level cookie container. Default is `null`. You can still set request cookies using `AddCookie` and get response cookies from the response object without using cooking container. | +| `Authenticator` | Overrides the client-level authenticator. | +| `Files` | Collection of file parameters, read-only. Use `AddFile` for adding files to the request. | +| `Method` | Request HTTP method, default is `GET`. Only needed when using `Execute` or `ExecuteAsync` as other functions like `ExecutePostAsync` will override the request method. | +| `Timeout` | Request-level timeout override. See [Configuring Timeouts](#configuring-timeouts) for details on timeout behavior. | +| `Resource` | Resource part of the remote endpoint URL. For example, when using the client-level base URL `https://localhost:5000/api` and `Resource` set to `weather`, the request will be sent to `https://localhost:5000/api/weather`. It can container resource placeholders to be used in combination with `AddUrlSegment` | +| `RequestFormat` | Identifies the request as JSON, XML, binary, or none. Rarely used because the client will set the request format based on the body type if functions like `AddJsonBody` or `AddXmlBody` are used. | +| `RootElement` | Used by the default deserializers to determine where to start deserializing from. Only supported for XML responses. Does not apply to requests. | +| `OnBeforeDeserialization` | **Obsolete** A function to be called before the response is deserializer. Allows changing the content before calling the deserializer. Use [interceptors](interceptors.md) instead. | +| `OnBeforeRequest` | **Obsolete** A function to be called right before the request is executed by `HttpClient`. It receives an instance of `HttpRequestMessage`. Use [interceptors](interceptors.md) instead. | +| `OnAfterRequest` | **Obsolete** A function to be called right after the request is executed by `HttpClient`. It receives an instance of `HttpResponseMessage`. Use [interceptors](interceptors.md) instead. | +| `Attempts` | When the request is being resent to retry, the property value increases by one. | +| `CompletionOption` | Instructs the client on when it should consider the request to be completed. The default is `ResponseContentRead`. It is automatically changed to `ResponseHeadersRead` when using async download functions or streaming. | +| `CachePolicy` | Overrides the client cache policy. | +| `ResponseWriter` | Allows custom handling of the response stream. The function gets the raw response stream and returns another stream or `null`. Cannot be used in combination with `AdvancedResponseWriter`. | +| `AdvancedResponseWriter` | Allows custom handling of the response. The function gets an instance of `HttpResponseMessage` and an instance of `RestRequest`. It must return an instance of `RestResponse`, so it effectively overrides RestSharp default functionality for creating responses. | +| `Interceptors` | Allows adding interceptors to the request. Both client-level and request-level interceptors will be called. | + +The table below contains all configuration properties of `RestRequest`. To learn more about adding request parameters, check the [usage page](../usage/request.md) page about creating requests with parameters. + +## Configuring Timeouts + +RestSharp provides flexible timeout configuration at both the client and request levels. The timeout determines how long RestSharp will wait for a response before canceling the request. + +:::note Migration from MaxTimeout +In older versions of RestSharp, the `MaxTimeout` property (measured in milliseconds) was used. This has been replaced by the `Timeout` property, which uses `TimeSpan` for more intuitive and type-safe timeout configuration. +::: + +### Timeout Resolution + +When making a request, RestSharp uses the following priority order to determine the timeout: +1. `RestRequest.Timeout` (if set) +2. `RestClientOptions.Timeout` (if set) +3. Default timeout of 100 seconds + +### Timeout Values + +The `Timeout` property accepts a `TimeSpan?` value and supports the following behaviors: + +| Value | Behavior | +|-------|----------| +| `null` (not set) | Uses the next level timeout (client timeout, then default 100 seconds) | +| Positive `TimeSpan` | Request times out after the specified duration (e.g., `TimeSpan.FromSeconds(30)`) | +| `Timeout.InfiniteTimeSpan` or `TimeSpan.FromMilliseconds(-1)` | No timeout - request will wait indefinitely for a response | +| `TimeSpan.Zero` | Request is canceled immediately (effectively no time allowed for the request) | +| Other negative values | Throws `ArgumentOutOfRangeException` when the request is executed | + +### Examples + +```csharp +// Client-level timeout of 30 seconds +var options = new RestClientOptions("https://api.example.com") { + Timeout = TimeSpan.FromSeconds(30) +}; +var client = new RestClient(options); + +// Request-level timeout override +var request = new RestRequest("resource") { + Timeout = TimeSpan.FromSeconds(60) // This request gets 60 seconds +}; + +// Infinite timeout (no timeout) +var longRunningRequest = new RestRequest("long-operation") { + Timeout = Timeout.InfiniteTimeSpan +}; +``` + +## Redirect Options + +RestSharp handles redirects internally (rather than delegating to `HttpClient`) so it can capture `Set-Cookie` headers from intermediate redirect responses. The `RedirectOptions` class provides fine-grained control over redirect behavior. + +The convenience properties `RestClientOptions.FollowRedirects` and `RestClientOptions.MaxRedirects` delegate to `RedirectOptions` for backward compatibility. + +```csharp +var options = new RestClientOptions("https://api.example.com") { + RedirectOptions = new RedirectOptions { + FollowRedirects = true, + MaxRedirects = 10, + ForwardAuthorization = true, + FollowRedirectsToInsecure = false + } +}; +``` + +| Option | Default | Description | +|-----------------------------------|---------|--------------------------------------------------------------------------------------------------------------| +| `FollowRedirects` | `true` | Whether to follow redirects. | +| `MaxRedirects` | `50` | Maximum number of redirects to follow. | +| `FollowRedirectsToInsecure` | `false` | Whether to follow redirects from HTTPS to HTTP. | +| `ForwardHeaders` | `true` | Whether to forward request headers on redirect. | +| `ForwardAuthorization` | `false` | Whether to forward the `Authorization` header on same-host redirects. | +| `ForwardAuthorizationToExternalHost` | `false` | Whether to forward `Authorization` on cross-host redirects. Only applies when `ForwardAuthorization` is true. | +| `ForwardCookies` | `true` | Whether to forward cookies on redirect. `Set-Cookie` headers are always captured regardless. | +| `ForwardBody` | `true` | Whether to forward the request body when the HTTP verb is preserved. Body is always dropped when verb changes to GET. | +| `ForwardQuery` | `true` | Whether to forward the original query string parameters on redirect. | +| `RedirectStatusCodes` | 301, 302, 303, 307, 308 | HTTP status codes considered as redirects. | + +## Parameter formatting + +By default, all generic parameter methods (`AddParameter`, `AddQueryParameter`, `AddUrlSegment`, `AddHeader`, etc.) format values using `CultureInfo.InvariantCulture`. This ensures consistent behavior regardless of the server's or client's locale. + +If you need locale-specific formatting, pass a `CultureInfo` explicitly: + +```csharp +request.AddParameter("price", 1234.56, culture: new CultureInfo("da-DK")); +// Sends: price=1234,56 +``` diff --git a/docs/versioned_docs/version-v114/advanced/error-handling.md b/docs/versioned_docs/version-v114/advanced/error-handling.md new file mode 100644 index 000000000..22bfbbd7e --- /dev/null +++ b/docs/versioned_docs/version-v114/advanced/error-handling.md @@ -0,0 +1,73 @@ +# Error handling + +If there is a network transport error (network is down, failed DNS lookup, etc.), or any kind of server error (except 404), `RestResponse.ResponseStatus` will be set to `ResponseStatus.Error`, otherwise it will be `ResponseStatus.Completed`. + +If an API returns a 404, `ResponseStatus` will still be `Completed`. If you need access to the HTTP status code returned, you will find it at `RestResponse.StatusCode`. +The `Status` property is an indicator of completion independent of the API error handling. + +When a request times out, `ResponseStatus` is set to `TimedOut` and `ErrorMessage` will say `"The request timed out."`. For other transport errors, `ErrorMessage` shows the root cause by using `GetBaseException().Message`, so you see the actual error (e.g., a TLS failure) rather than a generic wrapper message like `"An error occurred while sending the request"`. The full exception chain is always available in `ErrorException`. + +Normally, RestSharp doesn't throw an exception if the request fails. + +However, it is possible to configure RestSharp to throw in different situations when it normally doesn't throw +in favor of giving you the error as a property. + +| Property | Behavior | +|-------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `FailOnDeserializationError` | Changes the default behavior when failed deserialization results in a successful response with an empty `Data` property of the response. Setting this property to `true` will tell RestSharp to consider failed deserialization as an error and set the `ResponseStatus` to `Error` accordingly. | +| `ThrowOnDeserializationError` | Changes the default behavior when failed deserialization results in empty `Data` property of the response. Setting this property to `true` will tell RestSharp to throw when deserialization fails. | +| `ThrowOnAnyError` | Setting this property to `true` changes the default behavior and forces RestSharp to throw if any errors occurs when making a request or during deserialization. | + +Those properties are available for the `RestClientOptions` and will be used for all request made with the client instance. + +For example, you can configure the client to throw an exception if any error occurs when making a request or when a request returns a non-successful HTTP status code: + +```csharp +var options = new RestClientOptions(url) { + ThrowOnAnyError = true +}; +var client = new RestClient(options); +var request = new RestRequest("resource/{id}").AddUrlSegment("id", 123); + +// 👇 will throw if the request fails +var deserialized = await client.GetAsync(request); + +// 👇 will NOT throw if the request fails, inspect the response to find out what happened +var response = await client.ExecuteGetAsync(request); +``` + +:::warning +Please be aware that deserialization failures will only work if the serializer throws an exception when deserializing the response. +Many serializers don't throw by default, and just return a `null` result. RestSharp is unable to figure out why `null` is returned, so it won't fail in this case. +Check the serializer documentation to find out if it can be configured to throw on deserialization error. +::: + +There are also slight differences on how different overloads handle exceptions. + +Asynchronous generic methods `GetAsync`, `PostAsync` and so on, which aren't a part of `RestClient` API (those methods are extension methods) return `Task`. It means that there's no `RestResponse` to set the response status to error. We decided to throw an exception when such a request fails. It is a trade-off between the API consistency and usability of the library. Usually, you only need the content of `RestResponse` instance to diagnose issues and most of the time the exception would tell you what's wrong. + +Below, you can find how different extensions deal with errors. Note that functions, which don't throw by default, will throw exceptions when `ThrowOnAnyError` is set to `true`. + +| Function | Throws on errors | +|:----------------------|:-----------------| +| `ExecuteAsync` | No | +| `ExecuteGetAsync` | No | +| `ExecuteGetAsync` | No | +| `ExecutePostAsync` | No | +| `ExecutePostAsync` | No | +| `ExecutePutAsync` | No | +| `ExecutePutAsync` | No | +| `GetAsync` | Yes | +| `GetAsync` | Yes | +| `PostAsync` | Yes | +| `PostAsync` | Yes | +| `PatchAsync` | Yes | +| `PatchAsync` | Yes | +| `DeleteAsync` | Yes | +| `DeleteAsync` | Yes | +| `OptionsAsync` | Yes | +| `OptionsAsync` | Yes | +| `HeadAsync` | Yes | +| `HeadAsync` | Yes | + +In addition, all the functions for JSON requests, like `GetJsonAsync` and `PostJsonAsync` throw an exception if the HTTP call fails. diff --git a/docs/versioned_docs/version-v114/advanced/interceptors.md b/docs/versioned_docs/version-v114/advanced/interceptors.md new file mode 100644 index 000000000..a3bf7a9bc --- /dev/null +++ b/docs/versioned_docs/version-v114/advanced/interceptors.md @@ -0,0 +1,84 @@ +--- +title: Interceptors +--- + +## Intercepting requests and responses + +Interceptors are a powerful feature of RestSharp that allows you to modify requests and responses before they are sent or received. You can use interceptors to add headers, modify the request body, or even cancel the request. You can also use interceptors to modify the response before it is returned to the caller. + +### Implementing an interceptor + +To implement an interceptor, you need to create a class that inherits the `Interceptor` base class. The base class implements all interceptor methods as virtual, so you can override them in your derived class. + +Methods that you can override are: +- `BeforeRequest(RestRequest request, CancellationToken cancellationToken)` +- `AfterRequest(RestResponse response, CancellationToken cancellationToken)` +- `BeforeHttpRequest(HttpRequestMessage requestMessage, CancellationToken cancellationToken)` +- `AfterHttpRequest(HttpResponseMessage responseMessage, CancellationToken cancellationToken)` +- `BeforeDeserialization(RestResponse response, CancellationToken cancellationToken)` + +All those functions must return a `ValueTask` instance. + +Here's an example of an interceptor that adds a header to a request: + +```csharp +// This interceptor adds a header to the request +// You'd not normally use this interceptor, as RestSharp already has a method +// to add headers to the request +class HeaderInterceptor(string headerName, string headerValue) : Interceptors.Interceptor { + public override ValueTask BeforeHttpRequest(HttpRequestMessage requestMessage, CancellationToken cancellationToken) { + requestMessage.Headers.Add(headerName, headerValue); + return ValueTask.CompletedTask; + } +} +``` + +Because interceptor functions return `ValueTask`, you can use `async` and `await` inside them. + +### Using an interceptor + +It's possible to add as many interceptors as you want, both to the client and to the request. The interceptors are executed in the order they were added. + +Adding interceptors to the client is done via the client options: + +```csharp +var options = new RestClientOptions("https://api.example.com") { + Interceptors = [new HeaderInterceptor("Authorization", token)] +}; +var client = new RestClient(options); +``` + +When you add an interceptor to the client, it will be executed for every request made by that client. + +You can also add an interceptor to a specific request: + +```csharp +var request = new RestRequest("resource") { + Interceptors = [new HeaderInterceptor("Authorization", token)] +}; +``` + +In this case, the interceptor will only be executed for that specific request. + +### Deprecation notice + +Interceptors aim to replace the existing request hooks available in RestSharp prior to version 111.0. Those hooks are marked with `Obsolete` attribute and will be removed in the future. If you are using those hooks, we recommend migrating to interceptors as soon as possible. + +To make the migration easier, RestSharp provides a class called `CompatibilityInterceptor`. It has properties for the hooks available in RestSharp 110.0 and earlier. You can use it to migrate your code to interceptors without changing the existing logic. + +For example, a code that uses `OnBeforeRequest` hook: + +```csharp +var request = new RestRequest("success"); +request.OnBeforeDeserialization += _ => throw new Exception(exceptionMessage); +``` + +Can be migrated to interceptors like this: + +```csharp +var request = new RestRequest("success") { + Interceptors = [new CompatibilityInterceptor { + OnBeforeDeserialization = _ => throw new Exception(exceptionMessage) + }] +}; +``` \ No newline at end of file diff --git a/docs/versioned_docs/version-v114/advanced/serialization.md b/docs/versioned_docs/version-v114/advanced/serialization.md new file mode 100644 index 000000000..1470a3810 --- /dev/null +++ b/docs/versioned_docs/version-v114/advanced/serialization.md @@ -0,0 +1,148 @@ +# Serialization + +One of the most common reasons to choose RestSharp over plain `HttpClient` is its rich build-in serialization support. RestSharp allows adding complex objects as request body to be serialized when making a call to an API endpoint, and deserializing the response to a given .NET type. RestSharp supports JSON and XML serialization and deserialization by default. In addition, you can use a CSV serializer or write your own. + +In contrast to `System.Net.Http.Json` package that contains `HttpClient` extensions to make `GET` or `POST` calls using JSON, RestSharp support JSON responses for all HTTP methods, not just for `GET`. + +## Configuration + +:::tip +The default behavior of RestSharp is to swallow deserialization errors and return `null` in the `Data` +property of the response. Read more about it in the [Error Handling](error-handling.md). +::: + +You can tell RestSharp to use a custom serializer by using the `configureSerialization` constructor parameter: + +```csharp +var client = new RestClient( + options, + configureSerialization: s => s.UseSerializer(() => new CustomSerializer()) +); +``` + +All RestSharp serializers implement the `IRestSerializer` interface. Among other things, the interface requires implementing the `AcceptedContentTypes` property, which must return a collection of content types supported by the serializer. Being configured to use certain serializers, RestSharp populates the `Accept` header accordingly, so it doesn't need to be set manually. + +When making a call, RestSharp sets the request content type according to the request body type. For example, when you use `AddJsonBody`, the content type is set to `application/json`. Normally, you won't need to set the `Content-Type` header manually. If you need to set a custom content type for a JSON call, you can use the optional `contentType` argument of `AddJsonBody`, for example: + +```csharp +request.AddJsonBody(data, "text/json"); +``` + +## JSON + +The default JSON serializer uses `System.Text.Json`, which is a part of .NET since .NET 6. For earlier versions, it is added as a dependency. There are also a few serializers provided as additional packages. + +By default, RestSharp will use `JsonSerializerDefaults.Web` configuration. If necessary, you can specify your own options: + +```csharp +var client = new RestClient( + options, + configureSerialization: s => s.UseSystemTextJson(new JsonSerializerOptions {...}) +); +``` + +## XML + +The default XML serializer is `DotNetXmlSerializer`, which uses `System.Xml.Serialization` library from .NET. + +In previous versions of RestSharp, the default XML serializer was a custom RestSharp XML serializer. To make the code library size smaller, that serializer is now available as a separate package [`RestSharp.Serializers.Xml`](https://www.nuget.org/packages/RestSharp.Serializers.Xml). +You can add it back if necessary by installing the package and adding it to the client: + +```csharp +var client = new RestClient( + options, + configureSerialization: s => s.UseXmlSerializer() +); +``` + +As before, you can supply three optional arguments for a custom namespace, custom root element, and if you want to use `SerializeAs` and `DeserializeAs` attributed. + +## NewtonsoftJson (aka Json.Net) + +The `NewtonsoftJson` package is the most popular JSON serializer for .NET. It handles all possible scenarios and is very configurable. Such a flexibility comes with the cost of performance. If you need speed, keep the default JSON serializer. + +RestSharp support Json.Net serializer via a separate package [`RestSharp.Serializers.NewtonsoftJson`](https://www.nuget.org/packages/RestSharp.Serializers.NewtonsoftJson). + +:::warning +Please note that `RestSharp.Newtonsoft.Json` package is not provided by RestSharp, is marked as obsolete on NuGet, and no longer supported by its creator. +::: + +Use the extension method provided by the package to configure the client: + +```csharp +var client = new RestClient( + options, + configureSerialization: s => s.UseNewtonsoftJson() +); +``` + +The serializer configures some options by default: + +```csharp +JsonSerializerSettings DefaultSettings = new JsonSerializerSettings { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + DefaultValueHandling = DefaultValueHandling.Include, + TypeNameHandling = TypeNameHandling.None, + NullValueHandling = NullValueHandling.Ignore, + Formatting = Formatting.None, + ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor +}; +``` + +If you need to use different settings, you can supply your instance of +`JsonSerializerSettings` as a parameter for the extension method. + +## CSV + +A separate package `RestSharp.Serializers.CsvHelper` provides a CSV serializer for RestSharp. It is based on the +`CsvHelper` library. + +Use the extension method provided by the package to configure the client: + +```csharp +var client = new RestClient( + options, + configureSerialization: s => s.UseCsvHelper() +); +``` + +You can also supply your instance of `CsvConfiguration` as a parameter for the extension method. + +```csharp +var client = new RestClient( + options, + configureSerialization: s => s.UseCsvHelper( + new CsvConfiguration(CultureInfo.InvariantCulture) {...} + ) +); +``` + +## Custom + +You can also implement your custom serializer. To support both serialization and +deserialization, you must implement the `IRestSerializer` interface. + +Here is an example of a custom serializer that uses `System.Text.Json`: + +```csharp +public class SimpleJsonSerializer : IRestSerializer { + public string? Serialize(object? obj) => obj == null ? null : JsonSerializer.Serialize(obj); + + public string? Serialize(Parameter bodyParameter) => Serialize(bodyParameter.Value); + + public T? Deserialize(RestResponse response) => JsonSerializer.Deserialize(response.Content!); + + public ContentType ContentType { get; set; } = ContentType.Json; + + public ISerializer Serializer => this; + public IDeserializer Deserializer => this; + public DataFormat DataFormat => DataFormat.Json; + public string[] AcceptedContentTypes => ContentType.JsonAccept; + public SupportsContentType SupportsContentType + => contentType => contentType.Value.EndsWith("json", StringComparison.InvariantCultureIgnoreCase); +} +``` + +The `SupportedContentTypes` function will be used to check if the serializer is able to deserialize the response based on the `Content-Type` response header. + +The `ContentType` property will be used when making a request so the server knows how to handle the payload. diff --git a/docs/versioned_docs/version-v114/changelog.md b/docs/versioned_docs/version-v114/changelog.md new file mode 100644 index 000000000..8b1edeafd --- /dev/null +++ b/docs/versioned_docs/version-v114/changelog.md @@ -0,0 +1,45 @@ +--- +title: What's new +description: List of changes for the current major version +sidebar_position: 1 +--- + +# Changelog + +For release notes of previous versions, please check the [Releases page](https://github.com/restsharp/RestSharp/releases) in RestSharp GitHub repository. + +Changes between major versions are documented in the documentation for each version on this website. + +## v114.0 + +### Breaking changes + +* **`IAuthenticator` interface signature changed** — `Authenticate` now accepts an optional `CancellationToken` parameter. All custom authenticator implementations must be recompiled. ([#2362](https://github.com/restsharp/RestSharp/pull/2362)) +* **`FollowRedirects` and `MaxRedirects` removed from `ReadOnlyRestClientOptions`** — these properties are now excluded from the generated immutable wrapper. Use `RedirectOptions` instead. Code accessing these properties on `ReadOnlyRestClientOptions` must be updated. ([#2360](https://github.com/restsharp/RestSharp/pull/2360)) +* **Extension method signatures changed** — `AddParameter`, `AddOrUpdateParameter`, `AddHeader`, `AddOrUpdateHeader`, `AddQueryParameter`, and `AddUrlSegment` now have an additional optional `CultureInfo? culture` parameter. Assemblies compiled against v113 must be recompiled. ([#2354](https://github.com/restsharp/RestSharp/pull/2354)) + +### New features + +* **OAuth2 token lifecycle authenticators** — new `OAuth2ClientCredentialsAuthenticator`, `OAuth2RefreshTokenAuthenticator`, and `OAuth2TokenAuthenticator` that handle obtaining, caching, and refreshing tokens automatically. ([#2362](https://github.com/restsharp/RestSharp/pull/2362)) +* **Custom redirect handling with `RedirectOptions`** — RestSharp now manages redirects internally instead of delegating to `HttpClient`, fixing lost `Set-Cookie` headers on redirects. New `RedirectOptions` class provides fine-grained control over redirect behavior. ([#2360](https://github.com/restsharp/RestSharp/pull/2360)) +* **`MergedParameters` on `RestResponse`** — provides a combined view of request and default parameters at execution time, useful for logging and debugging. ([#2349](https://github.com/restsharp/RestSharp/pull/2349)) +* **Restored `AddCookie(name, value)` overload** — the simple two-parameter form defers domain resolution to execution time. ([#2351](https://github.com/restsharp/RestSharp/pull/2351)) + +### Behavior changes + +* **`MultipartFormQuoteParameters` now defaults to `true`** — multipart form parameter names are now quoted per RFC 7578. ([#2357](https://github.com/restsharp/RestSharp/pull/2357)) +* **`InvariantCulture` used for parameter formatting** — all generic parameter methods now format values using `CultureInfo.InvariantCulture` by default. Pass a `CultureInfo` explicitly if locale-specific formatting is needed. ([#2354](https://github.com/restsharp/RestSharp/pull/2354)) +* **Improved `ErrorMessage` for timeouts** — now shows `"The request timed out."` instead of `"A task was canceled."`. ([#2356](https://github.com/restsharp/RestSharp/pull/2356)) +* **`ErrorMessage` surfaces root cause** — uses `GetBaseException().Message` to show the actual error instead of generic wrapper messages. ([#2352](https://github.com/restsharp/RestSharp/pull/2352)) +* **`HttpClient.DefaultRequestHeaders` no longer modified** — `Expect100Continue` is now set per-request. Safe to share an `HttpClient` across multiple `RestClient` instances. ([#2363](https://github.com/restsharp/RestSharp/pull/2363)) + +### Bug fixes + +* Fix `ConfigureAwait(false)` missing on several `await` calls, preventing deadlocks in sync-over-async scenarios. ([#2367](https://github.com/restsharp/RestSharp/pull/2367)) +* Fix `ResponseUri` returning original URL instead of redirect target when `FollowRedirects=false`. ([#2350](https://github.com/restsharp/RestSharp/pull/2350)) +* Fix default parameter merging bugs (multi-value dedup, request mutation, `BuildUriString` without execute). ([#2349](https://github.com/restsharp/RestSharp/pull/2349)) +* Fix OAuth1 double-encoding of RFC 3986 special characters in URL paths. ([#2341](https://github.com/restsharp/RestSharp/pull/2341)) +* Fix pipe character encoding when `AddQueryParameter` is used with `encode=false`. ([#2345](https://github.com/restsharp/RestSharp/pull/2345)) +* Fix `XmlDeserializer` when XML uses same tag name in nested elements. ([#2339](https://github.com/restsharp/RestSharp/pull/2339)) +* Fix credential/`UseDefaultCredentials` property order on `HttpClientHandler`. ([#2353](https://github.com/restsharp/RestSharp/pull/2353)) +* Fix URL escaping on .NET Framework 4.6.2. ([#2327](https://github.com/restsharp/RestSharp/pull/2327)) diff --git a/docs/versioned_docs/version-v114/intro.md b/docs/versioned_docs/version-v114/intro.md new file mode 100644 index 000000000..961c5350a --- /dev/null +++ b/docs/versioned_docs/version-v114/intro.md @@ -0,0 +1,112 @@ +--- +sidebar_position: 2 +title: Quick start +--- + +## Introduction + +:::warning +RestSharp v107+ changes the library API surface and its behaviour significantly. We advise looking at [migration](/migration) docs to understand how to migrate to the latest version of RestSharp. +::: + +The main purpose of RestSharp is to make synchronous and asynchronous calls to remote resources over HTTP. As the name suggests, the main audience of RestSharp are developers who use REST APIs. However, RestSharp can call any API over HTTP, as long as you have the resource URI and request parameters that you want to send comply with W3C HTTP standards. + +One of the main challenges of using HTTP APIs for .NET developers is to work with requests and responses of different kinds and translate them to complex C# types. RestSharp can take care of serializing the request body to JSON or XML and deserialize the response. It can also form a valid request URI based on different parameter kinds: path, query, form or body. + +## Getting Started + +Before you can use RestSharp in your application, you need to add the NuGet package. You can do it using your IDE or the command line: + +``` +dotnet add package RestSharp +``` + +### Basic Usage + +If you only have a small number of one-off API requests to perform, you can use RestSharp like this: + +```csharp +using RestSharp; +using RestSharp.Authenticators; + +var options = new RestClientOptions("https://api.twitter.com/1.1") { + Authenticator = new HttpBasicAuthenticator("username", "password") +}; +var client = new RestClient(options); +var request = new RestRequest("statuses/home_timeline.json"); +// The cancellation token comes from the caller. You can still make a call without it. +var response = await client.GetAsync(request, cancellationToken); +``` + +It will return a `RestResponse` back, which contains all the information returned from the remote server. +You have access to the headers, content, HTTP status and more. + +You can also use generic overloads like `Get` to automatically deserialize the response into a .NET class. + +For example: + +```csharp +using RestSharp; +using RestSharp.Authenticators; + +var options = new RestClientOptions("https://api.twitter.com/1.1") { + Authenticator = new HttpBasicAuthenticator("username", "password") +}; +var client = new RestClient(options); + +var request = new RestRequest("statuses/home_timeline.json"); + +// The cancellation token comes from the caller. You can still make a call without it. +var timeline = await client.GetAsync(request, cancellationToken); +``` + +Both snippets above use the `GetAsync` extension, which is a wrapper about `ExecuteGetAsync`, which, in turn, is a wrapper around `ExecuteAsync`. +All `ExecuteAsync` overloads and return the `RestResponse` or `RestResponse`. + +The most important difference is that async methods named after HTTP methods (like `GetAsync` or `PostAsync`) return `Task` instead of `Task>`. It means that you won't get an error response if the request fails as those methods throw an exception for unsuccessful HTTP calls. For keeping the API consistent, non-generic functions like `GetAsync` or `PostAsync` also throw an exception if the request fails, although they return the `Task`. + +Read [here](advanced/error-handling.md) about how RestSharp handles exceptions. + +RestSharp also offers simple ways to call APIs that accept and return JSON payloads. You can use the `GetJsonAsync` and `PostJsonAsync` extension methods, which will automatically serialize the request body to JSON and deserialize the response to the specified type. + +```csharp +var client = new RestClient(options); +var timeline = await client.GetJsonAsync("statuses/home_timeline.json", cancellationToken); +``` + +Read [here](usage/execute.md#json-requests) about making JSON calls without preparing a request object. + +### Content type + +RestSharp supports sending XML or JSON body as part of the request. To add a body to the request, simply call `AddJsonBody` or `AddXmlBody` method of the `RestRequest` object. + +There is no need to set the `Content-Type` or add the `DataFormat` parameter to the request when using those methods, RestSharp will do it for you. + +RestSharp will also handle both XML and JSON responses and perform all necessary deserialization tasks, depending on the server response type. Therefore, you only need to add the `Accept` header if you want to deserialize the response manually. + +For example, only you'd only need these lines to make a request with JSON body: + +```csharp +var request = new RestRequest("address/update").AddJsonBody(updatedAddress); +var response = await client.PostAsync(request); +``` + +It's also possible to make the same call using `PostAsync` shorter syntax: + +```csharp +var response = await PostJsonAsync( + "address/update", request, cancellationToken +); +``` + +Read more about serialization and deserialization [here](advanced/serialization.md). + +### Response + +When you use `ExecuteAsync`, you get an instance of `RestResponse` back. The response object has the `Content` property, which contains the response as string. You can find other useful properties there, like `StatusCode`, `ContentType` and so on. If the request wasn't successful, you'd get a response back with `IsSuccessful` property set to `false` and the error explained in the `ErrorException` and `ErrorMessage` properties. + +When using typed `ExecuteAsync`, you get an instance of `RestResponse` back, which is identical to `RestResponse` but also contains the `T Data` property with the deserialized response. + +None of `ExecuteAsync` overloads throw if the remote server returns an error. You can inspect the response and find the status code, error message, and, potentially, an exception. + +Extensions like `GetAsync` will not return the whole `RestResponse` but just a deserialized response. These extensions will throw an exception if the remote server returns an error. The exception details contain the status code returned by the server. diff --git a/docs/versioned_docs/version-v114/usage/_category_.json b/docs/versioned_docs/version-v114/usage/_category_.json new file mode 100644 index 000000000..bd2045cd6 --- /dev/null +++ b/docs/versioned_docs/version-v114/usage/_category_.json @@ -0,0 +1,7 @@ +{ + "label": "Using RestSharp", + "position": 3, + "link": { + "type": "generated-index" + } +} diff --git a/docs/versioned_docs/version-v114/usage/basics.md b/docs/versioned_docs/version-v114/usage/basics.md new file mode 100644 index 000000000..6bbb5594a --- /dev/null +++ b/docs/versioned_docs/version-v114/usage/basics.md @@ -0,0 +1,23 @@ +--- +sidebar_position: 2 +--- + +# RestSharp basics + +This page describes some of the essential properties and features of RestSharp. + +## What RestSharp does + +Essentially, RestSharp is a wrapper around `HttpClient` that allows you to do the following: +- Add default parameters of any kind (not just headers) to the client, once +- Add parameters of any kind to each request (query, URL segment, form, attachment, serialized body, header) in a straightforward way +- Serialize the payload to JSON or XML if necessary +- Set the correct content headers (content type, disposition, length, etc.) +- Handle the remote endpoint response +- Deserialize the response from JSON or XML if necessary + +## API client + +The best way to call an external HTTP API is to create a typed client, which encapsulates RestSharp calls and doesn't expose the `RestClient` instance in public. + +You can find an example of a Twitter API client on the [Example](example.md) page. diff --git a/docs/versioned_docs/version-v114/usage/client.md b/docs/versioned_docs/version-v114/usage/client.md new file mode 100644 index 000000000..97af9fce5 --- /dev/null +++ b/docs/versioned_docs/version-v114/usage/client.md @@ -0,0 +1,115 @@ +--- +sidebar_position: 3 +title: Creating the client +--- + +## Constructors + +A RestSharp client can be instantiated by one of its constructors. Two most commonly used constructors are: + +#### Only specify the base URL + +You can create an instance of `RestClient` with only a single parameter: the base URL. Even that isn't required as base URL can be left empty. In that case, you'd need to specify the absolute path for each call. When the base URL is set, you can use both relative and absolute path. + +```csharp +// Creates a client with default options to call a given base URL +var client = new RestClient("https://localhost:5000"); +``` + +#### Provide client options + +The most common way to create a client is to use the constructor with options. The options object has the type of `RestClientOptions`. +Here's an example of how to create a client using the same base path as in the previous sample, but with a couple additional settings: + +```csharp +// Creates a client using the options object +var options = new RestClientOptions("https://localhost:5000") { + Timeout = TimeSpan.FromSeconds(1) +}; +var client = new RestClient(options); +``` + +#### Advanced configuration + +RestSharp can be configured with more tweaks, including default request options, how it should handle responses, how serialization works, etc. You can also provide your own instance of `HttpClient` or `HttpMessageHandler`. + +Read more about the advanced configuration of RestSharp on a [dedicated page](../advanced/configuration.md). + +## Simple factory + +Another way to create the client instance is to use a simple client factory. The factory will use the `BaseUrl` property of the client options to cache `HttpClient` instances. Every distinct base URL will get its own `HttpClient` instance. Other options don't affect the caching. Therefore, if you use different options for the same base URL, you'll get the same `HttpClient` instance, which will not be configured with the new options. Options that aren't applied _after_ the first client instance is created are: + +* `Credentials` +* `UseDefaultCredentials` +* `AutomaticDecompression` +* `PreAuthenticate` +* `FollowRedirects` +* `RemoteCertificateValidationCallback` +* `ClientCertificates` +* `MaxRedirects` +* `Timeout` +* `UserAgent` +* `Expect100Continue` + +Constructor parameters to configure the `HttpMessageHandler` and default `HttpClient` headers configuration are also ignored for the cached instance as the factory only configures the handler once. + +You need to set the `useClientFactory` parameter to `true` in the `RestClient` constructor to enable the factory. + +```csharp +var options = new RestClientOptions("https://api.twitter.com/2"); +var client = new RestClient(options, useClientFactory: true); +``` + +## Reusing HttpClient + +RestSharp uses `HttpClient` internally to make HTTP requests. It's possible to reuse the same `HttpClient` instance for multiple `RestClient` instances. This is useful when you want to share the same connection pool between multiple `RestClient` instances. + +One way of doing it is to use `RestClient` constructors that accept an instance of `HttpClient` or `HttpMessageHandler` as an argument. Note that in that case not all the options provided via `RestClientOptions` will be used. Here is the list of options that will work: + +- `BaseUrl` will be taken from `httpClient.BaseAddress` if not set explicitly. +- `Timeout` is used to cancel the call using the cancellation token source. +- `UserAgent` will be added to the `RestClient.DefaultParameters` list as a HTTP header. This will be added to each request made by the `RestClient`, and the `HttpClient` instance will not be modified. This is to allow the `HttpClient` instance to be reused for scenarios where different `User-Agent` headers are required. +- `Expect100Continue` + +Another option is to use a simple HTTP client factory as described [above](#simple-factory). + +## Blazor support + +Inside a Blazor webassembly app, you can make requests to external API endpoints. Microsoft examples show how to do it with `HttpClient`, and it's also possible to use RestSharp for the same purpose. + +You need to remember that webassembly has some platform-specific limitations. Therefore, you won't be able to instantiate `RestClient` using all of its constructors. In fact, you can only use `RestClient` constructors that accept `HttpClient` or `HttpMessageHandler` as an argument. If you use the default parameterless constructor, it will call the option-based constructor with default options. The options-based constructor will attempt to create an `HttpMessageHandler` instance using the options provided, and it will fail with Blazor, as some of those options throw thw "Unsupported platform" exception. + +Here is an example how to register the `RestClient` instance globally as a singleton: + +```csharp +builder.Services.AddSingleton(new RestClient(new HttpClient())); +``` + +Then, on a page you can inject the instance: + +```html +@page "/fetchdata" +@using RestSharp +@inject RestClient _restClient +``` + +And then use it: + +```csharp +@code { + private WeatherForecast[]? forecasts; + + protected override async Task OnInitializedAsync() { + forecasts = await _restClient.GetJsonAsync("http://localhost:5104/weather"); + } + + public class WeatherForecast { + public DateTime Date { get; set; } + public int TemperatureC { get; set; } + public string? Summary { get; set; } + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } +} +``` + +In this case, the call will be made to a WebAPI server hosted at `http://localhost:5104/weather`. Remember that if the WebAPI server is not hosting the webassembly itself, it needs to have a CORS policy configured to allow the webassembly origin to access the API endpoint from the browser. diff --git a/docs/versioned_docs/version-v114/usage/example.md b/docs/versioned_docs/version-v114/usage/example.md new file mode 100644 index 000000000..28e722575 --- /dev/null +++ b/docs/versioned_docs/version-v114/usage/example.md @@ -0,0 +1,120 @@ +--- +sidebar_position: 1 +--- + +# Example + +RestSharp works best as the foundation for a proxy class for your API. Each API would most probably require different settings for `RestClient`. Hence, a dedicated API class (and its interface) gives you sound isolation between different `RestClient` instances and make them testable. + +For example, let's look at a simple Twitter API v2 client, which uses OAuth2 machine-to-machine authentication. For it to work, you would need to have access to the Twitter Developers portal, a project, and an approved application inside the project with OAuth2 enabled. + +## Client model + +Before implementing an API client, we need to have a model for it. The model includes an abstraction for the client, which has functions for the API calls we are interested to implement. In addition, the client model would include the necessary request and response models. Usually those are simple classes or records without logic, which are often referred to as DTOs (data transfer objects). + +This example starts with a single function that retrieves one Twitter user. Lets being by defining the API client interface: + +```csharp +public interface ITwitterClient { + Task GetUser(string user); +} +``` + +As the function returns a `TwitterUser` instance, we need to define it as a model: + +```csharp +public record TwitterUser(string Id, string Name, string Username); +``` + +## Client implementation + +When that is done, we can implement the interface and add all the necessary code blocks to get a working API client. + +The client class needs the following: +- A constructor for passing API credentials +- A wrapped `RestClient` instance with the Twitter API base URI pre-configured +- An authenticator to support authorizing the client using Twitter OAuth2 authentication +- The actual function to get the user (to implement the `ITwitterClient` interface) + +Creating an authenticator is described [below](#authenticator). + +Here's how the client implementation could look like: + +```csharp +public class TwitterClient : ITwitterClient, IDisposable { + readonly RestClient _client; + + public TwitterClient(string apiKey, string apiKeySecret) { + var tokenRequest = new OAuth2TokenRequest( + "https://api.twitter.com/oauth2/token", + apiKey, + apiKeySecret + ); + var options = new RestClientOptions("https://api.twitter.com/2") { + Authenticator = new OAuth2ClientCredentialsAuthenticator(tokenRequest) + }; + _client = new RestClient(options); + } + + public async Task GetUser(string user) { + var response = await _client.GetAsync>( + "users/by/username/{user}", + new { user } + ); + return response!.Data; + } + + record TwitterSingleObject(T Data); + + public void Dispose() { + _client?.Dispose(); + GC.SuppressFinalize(this); + } +} +``` + +It is also possible to use ASP.NET Core Options for configuring the client, instead of passing the credentials as strings. For example, we can add a class for Twitter client options, and use it in a constructor: + +```csharp +public record TwitterClientOptions(string ApiKey, string ApiSecret); + +public TwitterClient(IOptions options) { + var tokenRequest = new OAuth2TokenRequest( + "https://api.twitter.com/oauth2/token", + options.Value.ApiKey, + options.Value.ApiSecret + ); + var opt = new RestClientOptions("https://api.twitter.com/2") { + Authenticator = new OAuth2ClientCredentialsAuthenticator(tokenRequest) + }; + _client = new RestClient(opt); +} +``` + +Then, you can register and configure the client using ASP.NET Core dependency injection container. + +Notice the client constructor already configures the `OAuth2ClientCredentialsAuthenticator`. The authenticator setup is described in the next section. + +## Authenticator + +Before we can call the API itself, we need to get a bearer token. Twitter exposes an endpoint `https://api.twitter.com/oauth2/token`. As it follows the standard OAuth2 client credentials convention, we can use the built-in `OAuth2ClientCredentialsAuthenticator`: + +```csharp +var tokenRequest = new OAuth2TokenRequest( + "https://api.twitter.com/oauth2/token", + apiKey, + apiKeySecret +); + +var options = new RestClientOptions("https://api.twitter.com/2") { + Authenticator = new OAuth2ClientCredentialsAuthenticator(tokenRequest) +}; +``` + +The authenticator will automatically obtain a token on the first request, cache it, and refresh it when it expires. It uses its own `HttpClient` internally for token endpoint calls, so there's no circular dependency with the `RestClient`. + +For more details on the available OAuth2 authenticators (including refresh token flows and custom token providers), see [Authenticators](../advanced/authenticators.md#oauth2). + +## Final words + +This page demonstrates how an API client can be implemented as a typed, configurable client with its own interface. Usage of the client in applications is not covered here as different application types and target frameworks have their own idiomatic ways to use HTTP clients. \ No newline at end of file diff --git a/docs/versioned_docs/version-v114/usage/execute.md b/docs/versioned_docs/version-v114/usage/execute.md new file mode 100644 index 000000000..36343f793 --- /dev/null +++ b/docs/versioned_docs/version-v114/usage/execute.md @@ -0,0 +1,172 @@ +--- +sidebar_position: 5 +title: Making calls +--- + +## Executing requests + +Once you've added all the parameters to your `RestRequest`, you are ready to make a request. + +`RestClient` has a single function for this: + +```csharp +public async Task ExecuteAsync( + RestRequest request, + CancellationToken cancellationToken = default +) +``` + +You can also avoid setting the request method upfront and use one of the overloads: + +```csharp +Task ExecuteGetAsync(RestRequest request, CancellationToken cancellationToken) +Task ExecutePostAsync(RestRequest request, CancellationToken cancellationToken) +Task ExecutePutAsync(RestRequest request, CancellationToken cancellationToken) +Task ExecuteDeleteAsync(RestRequest request, CancellationToken cancellationToken) +Task ExecuteHeadAsync(RestRequest request, CancellationToken cancellationToken) +Task ExecuteOptionsAsync(RestRequest request, CancellationToken cancellationToken) +``` + +When using any of those methods, you will get the response content as string in `response.Content`. + +RestSharp can deserialize the response for you. To use that feature, use one of the generic overloads: + +```csharp +Task> ExecuteAsync(RestRequest request, CancellationToken cancellationToken) +Task> ExecuteGetAsync(RestRequest request, CancellationToken cancellationToken) +Task> ExecutePostAsync(RestRequest request, CancellationToken cancellationToken) +Task> ExecutePutAsync(RestRequest request, CancellationToken cancellationToken) +Task> ExecuteDeleteAsync(RestRequest request, CancellationToken cancellationToken) +Task> ExecuteHeadAsync(RestRequest request, CancellationToken cancellationToken) +Task> ExecuteOptionsAsync(RestRequest request, CancellationToken cancellationToken) +``` + +:::note Beware of errors +All the overloads with names starting with `Execute` don't throw an exception if the server returns an error. Read more about it [here](../advanced/error-handling.md). +It allows you to inspect responses and handle remote server errors gracefully. Overloads without `Execute` prefix throw exceptions in case of any error, so you'd need to ensure to handle exceptions properly. +::: + +If you just need a deserialized response, you can use one of the extensions: + +```csharp +Task GetAsync(RestRequest request, CancellationToken cancellationToken) +Task PostAsync(RestRequest request, CancellationToken cancellationToken) +Task PutAsync(RestRequest request, CancellationToken cancellationToken) +Task PatchAsync(RestRequest request, CancellationToken cancellationToken) +Task DeleteAsync(RestRequest request, CancellationToken cancellationToken) +Task HeadAsync(RestRequest request, CancellationToken cancellationToken) +Task OptionsAsync(RestRequest request, CancellationToken cancellationToken) +``` + +Those extensions will throw an exception if the server returns an error, as there's no other way to float the error back to the caller. + +The `IRestClient` interface also has extensions for making requests without deserialization, which throw an exception if the server returns an error even if the client is configured to not throw exceptions. + +```csharp +Task GetAsync(RestRequest request, CancellationToken cancellationToken) +Task PostAsync(RestRequest request, CancellationToken cancellationToken) +Task PutAsync(RestRequest request, CancellationToken cancellationToken) +Task PatchAsync(RestRequest request, CancellationToken cancellationToken) +Task DeleteAsync(RestRequest request, CancellationToken cancellationToken) +Task HeadAsync(RestRequest request, CancellationToken cancellationToken) +Task OptionsAsync(RestRequest request, CancellationToken cancellationToken) +``` + +### Sync calls + +The preferred way for making requests is to execute them asynchronously as HTTP calls are IO-bound operations. +If you are unable to make async calls, all the functions about have sync overloads, which have the same names without `Async` suffix. +For example, for making a sync `GET` call you can use `ExecuteGet(request)` or `Get`, etc. + +## Requests without body + +Some HTTP methods don't suppose to be used with request body. For those methods, RestSharp supports making simplified calls without using `RestRequest`. All you need is to provide the resource path as a string. + +For example, you can make a `DELETE` call like this: + +```csharp +var response = await client.ExecuteDeleteAsync($"order/delete/{orderId}", cancellationToken); +``` + +Similarly, you can make `GET` calls with or without deserialization of the response using `ExecuteGetAsync(resource)`, `GetAsync(resource)`, `ExecuteGetAsync(resource)`, and `GetAsync(resource)` (see below). + +## JSON requests + +RestSharp provides an easier API for making calls to endpoints that accept and return JSON. + +### GET calls + +To make a simple `GET` call and get a deserialized JSON response with a pre-formed resource string, use this: + +```csharp +var response = await client.GetAsync("endpoint?foo=bar", cancellationToken); +``` + +:::note +In v111, `GetJsonAsync` is renamed to `GetAsync`. +::: + +You can also use a more advanced extension that uses an object to compose the resource string: + +```csharp +var client = new RestClient("https://example.org"); +var args = new { + id = "123", + foo = "bar" +}; +// Will make a call to https://example.org/endpoint/123?foo=bar +var response = await client.GetAsync("endpoint/{id}", args, cancellationToken); +``` + +It will search for the URL segment parameters matching any of the object properties and replace them with values. All the other properties will be used as query parameters. + +One note about `GetAsync` is that it will deserialize the response with any supported content type, not only JSON. + +### POST calls + +Similar things are available for `POST` requests. + +```csharp +var request = new CreateOrder("123", "foo", 10100); +// Will post the request object as JSON to "orders" and returns a +// JSON response deserialized to OrderCreated +var result = client.PostJsonAsync("orders", request, cancellationToken); +``` + +```csharp +var request = new CreateOrder("123", "foo", 10100); +// Will post the request object as JSON to "orders" and returns a +// status code, not expecting any response body +var statusCode = client.PostJsonAsync("orders", request, cancellationToken); +``` + +The same two extensions also exist for `PUT` requests (`PutJsonAsync`); + +## Downloading binary data + +There are two functions that allow you to download binary data from the remote API. + +First, there's `DownloadDataAsync`, which returns `Task`. This function allows you to open a stream reader and asynchronously stream large responses to memory or disk. + +## JSON streaming + +For HTTP API endpoints that stream the response data (like [Twitter search stream](https://developer.twitter.com/en/docs/twitter-api/tweets/filtered-stream/api-reference/get-tweets-search-stream)) you can use RestSharp with `StreamJsonAsync`, which returns an `IAsyncEnumerable`: + +```csharp +public async IAsyncEnumerable SearchStream( + [EnumeratorCancellation] CancellationToken cancellationToken = default +) { + var response = _client.StreamJsonAsync>( + "tweets/search/stream", cancellationToken + ); + + await foreach (var item in response.WithCancellation(cancellationToken)) { + yield return item.Data; + } +} +``` + +The main limitation of this function is that it expects each JSON object to be returned as a single line. It is unable to parse the response by combining multiple lines into a JSON string. + diff --git a/docs/versioned_docs/version-v114/usage/request.md b/docs/versioned_docs/version-v114/usage/request.md new file mode 100644 index 000000000..76343b347 --- /dev/null +++ b/docs/versioned_docs/version-v114/usage/request.md @@ -0,0 +1,356 @@ +--- +sidebar_position: 4 +title: Preparing requests +--- + +## Create a request + +Before making a request using `RestClient`, you need to create a request instance: + +```csharp +var request = new RestRequest(resource); // resource is the sub-path of the client base path +``` + +The default request type is `GET` and you can override it by setting the `Method` property. You can also set the method using the constructor overload: + +```csharp +var request = new RestRequest(resource, Method.Post); +``` + +After you've created a `RestRequest`, you can add parameters to it. Below, you can find all the parameter types supported by RestSharp. + +## Request headers + +Adds the header parameter as an HTTP header that is sent along with the request. The header name is the parameter's name and the header value is the value. + +You can use one of the following request methods to add a header parameter: + +```csharp +AddHeader(string name, string value); +AddHeader(string name, T value); // value will be converted to string +AddOrUpdateHeader(string name, string value); // replaces the header if it already exists +``` + +For example: + +```csharp +var request = new RestRequest("/path").AddHeader("X-Key", someKey); +``` + +You can also add header parameters to the client, and they will be added to every request made by the client. This is useful for adding authentication headers, for example. + +```csharp +client.AddDefaultHeader(string name, string value); +``` + +:::warning Avoid setting Content-Type header +RestSharp will use the correct content type by default. Avoid adding the `Content-Type` header manually to your requests unless you are absolutely sure it is required. You can add a custom content type to the [body parameter](#request-body) itself. +::: + +## Get or Post parameters + +The default RestSharp parameter type is `GetOrPostParameter`. You can add `GetOrPost` parameter to the request using the `AddParameter` function: + +```csharp +request + .AddParameter("name1", "value1") + .AddParameter("name2", "value2"); +``` + +`GetOrPost` behaves differently based on the HTTP method. If you execute a `GET` call, RestSharp will append the parameters to the URL in the form `url?name1=value1&name2=value2`. + +On a `POST` or `PUT` requests, it depends on whether you have files attached to a request. +If not, the parameters will be sent as the body of the request in the form `name1=value1&name2=value2`. Also, the request will be sent as `application/x-www-form-urlencoded`. + +In both cases, name and value will automatically be URL-encoded, unless specified otherwise: + +```csharp +request.AddParameter("name", "Væ üé", false); // don't encode the value +``` + +If you have files, RestSharp will send a `multipart/form-data` request. Your parameters will be part of this request in the form: + +``` +Content-Type: text/plain; charset=utf-8 +Content-Disposition: form-data; name="parameterName" + +ParameterValue +``` + +Sometimes, you need to override the default content type for the parameter when making a multipart form call. It's possible to do by setting the `ContentType` property of the parameter object. As an example, the code below will create a POST parameter with JSON value, and set the appropriate content type: + +```csharp +var parameter = new GetOrPostParameter("someJson", "{\"attributeFormat\":\"pdf\"}") { + ContentType = "application/json" +}; +request.AddParameter(parameter); +``` + +When the request is set to use multipart content, the parameter will be sent as part of the request with the specified content type: + +``` +Content-Type: application/json; charset=utf-8 +Content-Disposition: form-data; name="someJson" + +{"attributeFormat":"pdf"} +``` + +You can also add `GetOrPost` parameter as a default parameter to the client. This will add the parameter to every request made by the client. + +```csharp +client.AddDefaultParameter("foo", "bar"); +``` + +It will work the same way as request parameters, except that it will be added to every request. + +## Query string + +`QueryString` works like `GetOrPost`, except that it always appends the parameters to the url in the form `url?name1=value1&name2=value2`, regardless of the request method. + +Example: + +```csharp +var client = new RestClient("https://search.me"); +var request = new RestRequest("search") + .AddParameter("foo", "bar"); +var response = await client.GetAsync(request); +``` + +It will send a `GET` request to `https://search.me/search?foo=bar`. + +For `POST`-style requests you need to add the query string parameter explicitly: + +```csharp +request.AddQueryParameter("foo", "bar"); +``` + +In some cases, you might need to prevent RestSharp from encoding the query string parameter. +To do so, set the `encode` argument to `false` when adding the parameter: + +```csharp +request.AddQueryParameter("foo", "bar/fox", false); +``` + +You can also add a query string parameter as a default parameter to the client. This will add the parameter to every request made by the client. + +```csharp +client.AddDefaultQueryParameter("foo", "bar"); +``` + +The line above will result in all the requests made by that client instance to have `foo=bar` in the query string for all the requests made by that client. + +## Using AddObject + +You can avoid calling `AddParameter` multiple times if you collect all the parameters in an object, and then use `AddObject`. +For example, this code: + +```csharp +var params = new { + status = 1, + priority = "high", + ids = new [] { "123", "456" } +}; +request.AddObject(params); +``` + +is equivalent to: + +```csharp +request.AddParameter("status", 1); +request.AddParameter("priority", "high"); +request.AddParameter("ids", "123,456"); +``` + +Remember that `AddObject` only works if your properties have primitive types. It also works with collections of primitive types as shown above. + +If you need to override the property name or format, you can do it using the `RequestProperty` attribute. For example: + +```csharp +public class RequestModel { + // override the name and the format + [RequestProperty(Name = "from_date", Format = "d")] + public DateTime FromDate { get; set; } +} + +// add it to the request +request.AddObject(new RequestModel { FromDate = DateTime.Now }); +``` + +In this case, the request will get a GET or POST parameter named `from_date` and its value would be the current date in short date format. + +## Using AddObjectStatic + +Request function `AddObjectStatic(...)` allows using pre-compiled expressions for getting property values. Compared to `AddObject` that uses reflections for each call, `AddObjectStatic` caches functions to retrieve properties from an object of type `T`, so it works much faster. + +You can instruct `AddObjectStatic` to use custom parameter names and formats, as well as supply the list of properties than need to be used as parameters. The last option could be useful if the type `T` has properties that don't need to be sent with HTTP call. + +To use custom parameter name or format, use the `RequestProperty` attribute. For example: + +```csharp +class TestObject { + [RequestProperty(Name = "some_data")] + public string SomeData { get; set; } + + [RequestProperty(Format = "d")] + public DateTime SomeDate { get; set; } + + [RequestProperty(Name = "dates", Format = "d")] + public DateTime[] DatesArray { get; set; } + + public int Plain { get; set; } + public DateTime[] PlainArray { get; set; } +} +``` + +## URL segment parameter + +Unlike `GetOrPost`, URL segment parameter replaces placeholder values in the request URL: + +```csharp +var request = new RestRequest("health/{entity}/status") + .AddUrlSegment("entity", "s2"); +``` + +When the request executes, RestSharp will try to match any `{placeholder}` with a parameter of that name (without the `{}`) and replace it with the value. So the above code results in `health/s2/status` being the URL. + +You can also add `UrlSegment` parameter as a default parameter to the client. This will add the parameter to every request made by the client. + +```csharp +client.AddDefaultUrlSegment("foo", "bar"); +``` + +## Cookies + +You can add cookies to a request using the `AddCookie` method: + +```csharp +request.AddCookie("foo", "bar"); +``` + +The simple two-parameter form defers domain resolution until execution time — the cookie domain is inferred from the request URL. If you need to specify the path and domain explicitly, use the four-parameter overload: + +```csharp +request.AddCookie("foo", "bar", "/path", "example.com"); +``` + +RestSharp will add cookies from the request as cookie headers and then extract the matching cookies from the response. You can observe and extract response cookies using the `RestResponse.Cookies` properties, which has the `CookieCollection` type. + +There is a `CookieContainer` instance on the request level. You can either assign the pre-populated container to `request.CookieContainer`, or let the container be created automatically at execution time. The four-parameter `AddCookie` overload populates the container immediately, while the two-parameter form stores cookies in `PendingCookies` until the request is executed. The container is used to extract all the cookies from it and create cookie headers for the request instead of using the container directly. It's because the cookie container is normally configured on the `HttpClientHandler` level and cookies are shared between requests made by the same client. In most of the cases this behaviour can be harmful. + +If your use case requires sharing cookies between requests made by the client instance, you can use the client-level `CookieContainer`, which you must provide as the options' property. You can add cookies to the container using the container API. No response cookies, however, would be auto-added to the container, but you can do it in code by getting cookies from the `Cookies` property of the response and adding them to the client-level container available via `IRestClient.Options.CookieContainer` property. + +## Request Body + +RestSharp supports multiple ways to add a request body: +- `AddJsonBody` for JSON payloads +- `AddXmlBody` for XML payloads +- `AddStringBody` for pre-serialized payloads + +We recommend using `AddJsonBody` or `AddXmlBody` methods instead of `AddParameter` with type `BodyParameter`. Those methods will set the proper request type and do the serialization work for you. + +When you make a `POST`, `PUT` or `PATCH` request and added `GetOrPost` [parameters](#get-or-post-parameters), RestSharp will send them as a URL-encoded form request body by default. When a request also has files, it will send a `multipart/form-data` request. You can also instruct RestSharp to send the body as `multipart/form-data` by setting the `AlwaysMultipartFormData` property to `true`. + +You can specify a custom body content type if necessary. The `contentType` argument is available in all the overloads that add a request body. + +It is not possible to add client-level default body parameters. + +### String body + +If you have a pre-serialized payload like a JSON string, you can use `AddStringBody` to add it as a body parameter. You need to specify the content type, so the remote endpoint knows what to do with the request body. For example: + +```csharp +const json = "{ data: { foo: \"bar\" } }"; +request.AddStringBody(json, ContentType.Json); +``` + +### JSON body + +When you call `AddJsonBody`, it does the following for you: + +- Instructs the RestClient to serialize the object parameter as JSON when making a request +- Sets the content type to `application/json` +- Sets the internal data type of the request body to `DataType.Json` + +Here is the example: + +```csharp +var param = new MyClass { IntData = 1, StringData = "test123" }; +request.AddJsonBody(param); +``` + +It is possible to override the default content type by supplying the `contentType` argument. For example: + +```csharp +request.AddJsonBody(param, "text/x-json"); +``` + +If you use a pre-serialized string with `AddJsonBody`, it will be sent as-is. The `AddJsonBody` will detect if the parameter is a string and will add it as a string body with JSON content type. +Essentially, it means that top-level strings won't be serialized as JSON when you use `AddJsonBody`. To overcome this issue, you can use an overload of `AddJsonBody`, which allows you to tell RestSharp to serialize the string as JSON: + +```csharp +const string payload = @" +""requestBody"": { + ""content"": { + ""application/json"": { + ""schema"": { + ""type"": ""string"" + } + } + } +},"; +request.AddJsonBody(payload, forceSerialize: true); // the string will be serialized +request.AddJsonBody(payload); // the string will NOT be serialized and will be sent as-is +``` + +### XML body + +When you call `AddXmlBody`, it does the following for you: + +- Instructs the RestClient to serialize the object parameter as XML when making a request +- Sets the content type to `application/xml` +- Sets the internal data type of the request body to `DataType.Xml` + +:::warning +Do not send XML string to `AddXmlBody`; it won't work! +::: + +## Uploading files + +To add a file to the request you can use the `RestRequest` function called `AddFile`. The main function accepts the `FileParameter` argument: + +```csharp +request.AddFile(fileParameter); +``` + +You can instantiate the file parameter using `FileParameter.Create` that accepts a bytes array, or `FileParameter.FromFile`, which will load the file from disk. + +There are also extension functions that wrap the creation of `FileParameter` inside: + +```csharp +// Adds a file from disk +AddFile(parameterName, filePath, contentType); + +// Adds an array of bytes +AddFile(parameterName, bytes, fileName, contentType); + +// Adds a stream returned by the getFile function +AddFile(parameterName, getFile, fileName, contentType); +``` + +Remember that `AddFile` will set all the necessary headers, so please don't try to set content headers manually. + +You can also provide file upload options to the `AddFile` call. The options are: +- `DisableFilenameEncoding` (default `false`): if set to `true`, RestSharp will not encode the file name in the `Content-Disposition` header +- `DisableFilenameStar` (default `true`): if set to `true`, RestSharp will not add the `filename*` parameter to the `Content-Disposition` header + +Example of using the options: + +```csharp +var options = new FileParameterOptions { + DisableFilenameEncoding = true, + DisableFilenameStar = false +}; +request.AddFile("file", filePath, options: options); +``` + +The options specified in the snippet above usually help when you upload files with non-ASCII characters in their names. diff --git a/docs/versioned_docs/version-v114/usage/response.md b/docs/versioned_docs/version-v114/usage/response.md new file mode 100644 index 000000000..43745ec39 --- /dev/null +++ b/docs/versioned_docs/version-v114/usage/response.md @@ -0,0 +1,49 @@ +--- +sidebar_position: 6 +title: Handling responses +--- + +All `Execute{Method}Async` functions return an instance of `RestResponse`. Similarly, `Execute{Method}Async` return a generic instance of `RestResponse` where `T` is the response object type. + +Response object contains the following properties: + +| Property | Type | Description | +|--------------------------|-----------------------------------------------------|------------------------------------------------------------------------------------------| +| `Request` | `RestRequest` | Request instance that was used to get the response. | +| `ContentType` | `string?` | Response content type. `Null` if response has no content. | +| `ContentLength` | `long?` | Response content length. `Null` if response has no content. | +| `ContentEncoding` | `ICollection` | Content encoding collection. Empty if response has no content. | +| `Content` | `string?` | Response content as string. `Null` if response has no content. | +| `IsSuccessfulStatusCode` | `bool` | Indicates if response was successful, so no errors were reported by the server. | +| `ResponseStatus` | `None`, `Completed`, `Error`, `TimedOut`, `Aborted` | Response completion status. Note that completed responses might still return errors. | +| `IsSuccessful` | `bool` | `True` when `IsSuccessfulStatusCode` is `true` and `ResponseStatus` is `Completed`. | +| `StatusDescription` | `string?` | Response status description, if available. | +| `RawBytes` | `byte[]?` | Response content as byte array. `Null` if response has no content. | +| `ResponseUri` | `Uri?` | URI of the response, which might be different from request URI in case of redirects. | +| `Server` | `string?` | Server header value of the response. | +| `Cookies` | `CookieCollection?` | Collection of cookies received with the response, if any. | +| `Headers` | Collection of `HeaderParameter` | Response headers. | +| `ContentHeaders` | Collection of `HeaderParameter` | Response content headers. | +| `ErrorMessage` | `string?` | Transport or another non-HTTP error generated while attempting request. | +| `ErrorException` | `Exception?` | Exception thrown when executing the request, if any. | +| `Version` | `Version?` | HTTP protocol version of the request. | +| `RootElement` | `string?` | Root element of the serialized response content, only works if deserializer supports it. | +| `MergedParameters` | `ParametersCollection` | Combined view of request parameters and client default parameters at execution time. | + +### Merged parameters + +The `MergedParameters` property provides a combined view of the request's own parameters and the client's [default parameters](request.md#request-headers) as they were at execution time. This is useful for logging or debugging the full set of parameters that were applied to a request, since `Request.Parameters` only contains the parameters added directly to the request. + +```csharp +var response = await client.ExecuteAsync(request); + +foreach (var param in response.MergedParameters) { + Console.WriteLine($"{param.Name} = {param.Value} ({param.Type})"); +} +``` + +In addition, `RestResponse` has one additional property: + +| Property | Type | Description | +|----------|------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Data` | `T?` | Deserialized response object. `Null` if there's no content in the response, deserializer failed to understand the response content, or if request failed. | diff --git a/docs/versioned_sidebars/version-v114-sidebars.json b/docs/versioned_sidebars/version-v114-sidebars.json new file mode 100644 index 000000000..caea0c03b --- /dev/null +++ b/docs/versioned_sidebars/version-v114-sidebars.json @@ -0,0 +1,8 @@ +{ + "tutorialSidebar": [ + { + "type": "autogenerated", + "dirName": "." + } + ] +} diff --git a/docs/versions.json b/docs/versions.json index 53c8813a5..1b1084d30 100644 --- a/docs/versions.json +++ b/docs/versions.json @@ -1,4 +1,5 @@ [ + "v114", "v113", "v112", "v111", From 74d16ea9640df898d4867011d1a8c616ec8e2705 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 5 Mar 2026 15:27:25 +0100 Subject: [PATCH 26/27] Remove RestSharp.sln and rename DotSettings to match slnx Co-Authored-By: Claude Opus 4.6 --- RestSharp.sln | 535 ------------------ ....DotSettings => RestSharp.slnx.DotSettings | 0 2 files changed, 535 deletions(-) delete mode 100644 RestSharp.sln rename RestSharp.sln.DotSettings => RestSharp.slnx.DotSettings (100%) diff --git a/RestSharp.sln b/RestSharp.sln deleted file mode 100644 index 0f86b90e0..000000000 --- a/RestSharp.sln +++ /dev/null @@ -1,535 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.3.32811.315 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestSharp", "src\RestSharp\RestSharp.csproj", "{43A1D5D2-650D-40DD-A6C0-14F92689C70B}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{9051DDA0-E563-45D5-9504-085EBAACF469}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestSharp.Tests", "test\RestSharp.Tests\RestSharp.Tests.csproj", "{B1C55C9B-3287-4EB2-8ADD-795DBC77013D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestSharp.Tests.Integrated", "test\RestSharp.Tests.Integrated\RestSharp.Tests.Integrated.csproj", "{AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestSharp.Serializers.NewtonsoftJson", "src\RestSharp.Serializers.NewtonsoftJson\RestSharp.Serializers.NewtonsoftJson.csproj", "{4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Serializers", "Serializers", "{8C7B43EB-2F93-483C-B433-E28F9386AD67}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestSharp.Tests.Shared", "test\RestSharp.Tests.Shared\RestSharp.Tests.Shared.csproj", "{73896669-F05C-41AC-9F6F-A11F549EDEDC}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestSharp.Tests.Serializers.Json", "test\RestSharp.Tests.Serializers.Json\RestSharp.Tests.Serializers.Json.csproj", "{8BF81225-2F85-4412-AD18-6579CBA1879B}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Perf", "Perf", "{1C42C435-8826-4044-8775-A1DA40EF4866}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestSharp.Benchmarks", "benchmarks\RestSharp.Benchmarks\RestSharp.Benchmarks.csproj", "{997AEFE5-D7D4-4033-A31A-07F476D6FE5D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestSharp.InteractiveTests", "test\RestSharp.InteractiveTests\RestSharp.InteractiveTests.csproj", "{6D7D1D60-4473-4C52-800C-9B892C6640A5}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestSharp.Tests.Serializers.Xml", "test\RestSharp.Tests.Serializers.Xml\RestSharp.Tests.Serializers.Xml.csproj", "{E6D94C12-9AD7-46E6-AB62-3676F25FDE51}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestSharp.Serializers.Xml", "src\RestSharp.Serializers.Xml\RestSharp.Serializers.Xml.csproj", "{4A35B1C5-520D-4267-BA70-2DCEAC0A5662}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestSharp.Serializers.CsvHelper", "src\RestSharp.Serializers.CsvHelper\RestSharp.Serializers.CsvHelper.csproj", "{2150E333-8FDC-42A3-9474-1A3956D46DE8}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestSharp.Tests.Serializers.Csv", "test\RestSharp.Tests.Serializers.Csv\RestSharp.Tests.Serializers.Csv.csproj", "{E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SourceGen", "SourceGen", "{55B8F371-B2BA-4DEE-AB98-5BAB8A21B1C2}" -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 - Debug.Appveyor|ARM = Debug.Appveyor|ARM - Debug.Appveyor|Mixed Platforms = Debug.Appveyor|Mixed Platforms - Debug.Appveyor|x64 = Debug.Appveyor|x64 - Debug.Appveyor|x86 = Debug.Appveyor|x86 - Debug|Any CPU = Debug|Any CPU - Debug|ARM = Debug|ARM - Debug|Mixed Platforms = Debug|Mixed Platforms - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|ARM = Release|ARM - Release|Mixed Platforms = Release|Mixed Platforms - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug|ARM.ActiveCfg = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug|ARM.Build.0 = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug|x64.ActiveCfg = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug|x64.Build.0 = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug|x86.ActiveCfg = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Debug|x86.Build.0 = Debug|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Release|Any CPU.Build.0 = Release|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Release|ARM.ActiveCfg = Release|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Release|ARM.Build.0 = Release|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Release|x64.ActiveCfg = Release|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Release|x64.Build.0 = Release|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Release|x86.ActiveCfg = Release|Any CPU - {43A1D5D2-650D-40DD-A6C0-14F92689C70B}.Release|x86.Build.0 = Release|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug|ARM.ActiveCfg = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug|ARM.Build.0 = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug|x64.ActiveCfg = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug|x64.Build.0 = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug|x86.ActiveCfg = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Debug|x86.Build.0 = Debug|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Release|Any CPU.Build.0 = Release|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Release|ARM.ActiveCfg = Release|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Release|ARM.Build.0 = Release|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Release|x64.ActiveCfg = Release|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Release|x64.Build.0 = Release|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Release|x86.ActiveCfg = Release|Any CPU - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D}.Release|x86.Build.0 = Release|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug|ARM.ActiveCfg = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug|ARM.Build.0 = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug|x64.ActiveCfg = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug|x64.Build.0 = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug|x86.ActiveCfg = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Debug|x86.Build.0 = Debug|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Release|Any CPU.Build.0 = Release|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Release|ARM.ActiveCfg = Release|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Release|ARM.Build.0 = Release|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Release|x64.ActiveCfg = Release|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Release|x64.Build.0 = Release|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Release|x86.ActiveCfg = Release|Any CPU - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0}.Release|x86.Build.0 = Release|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug|ARM.ActiveCfg = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug|ARM.Build.0 = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug|x64.ActiveCfg = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug|x64.Build.0 = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug|x86.ActiveCfg = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Debug|x86.Build.0 = Debug|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Release|Any CPU.Build.0 = Release|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Release|ARM.ActiveCfg = Release|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Release|ARM.Build.0 = Release|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Release|x64.ActiveCfg = Release|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Release|x64.Build.0 = Release|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Release|x86.ActiveCfg = Release|Any CPU - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1}.Release|x86.Build.0 = Release|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug|ARM.ActiveCfg = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug|ARM.Build.0 = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug|x64.ActiveCfg = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug|x64.Build.0 = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug|x86.ActiveCfg = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Debug|x86.Build.0 = Debug|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Release|Any CPU.Build.0 = Release|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Release|ARM.ActiveCfg = Release|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Release|ARM.Build.0 = Release|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Release|x64.ActiveCfg = Release|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Release|x64.Build.0 = Release|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Release|x86.ActiveCfg = Release|Any CPU - {73896669-F05C-41AC-9F6F-A11F549EDEDC}.Release|x86.Build.0 = Release|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug|ARM.ActiveCfg = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug|ARM.Build.0 = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug|x64.ActiveCfg = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug|x64.Build.0 = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug|x86.ActiveCfg = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Debug|x86.Build.0 = Debug|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Release|Any CPU.Build.0 = Release|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Release|ARM.ActiveCfg = Release|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Release|ARM.Build.0 = Release|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Release|x64.ActiveCfg = Release|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Release|x64.Build.0 = Release|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Release|x86.ActiveCfg = Release|Any CPU - {8BF81225-2F85-4412-AD18-6579CBA1879B}.Release|x86.Build.0 = Release|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug|ARM.ActiveCfg = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug|ARM.Build.0 = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug|x64.ActiveCfg = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug|x64.Build.0 = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug|x86.ActiveCfg = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Debug|x86.Build.0 = Debug|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Release|Any CPU.Build.0 = Release|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Release|ARM.ActiveCfg = Release|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Release|ARM.Build.0 = Release|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Release|x64.ActiveCfg = Release|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Release|x64.Build.0 = Release|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Release|x86.ActiveCfg = Release|Any CPU - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D}.Release|x86.Build.0 = Release|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug|ARM.ActiveCfg = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug|ARM.Build.0 = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug|x64.ActiveCfg = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug|x64.Build.0 = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug|x86.ActiveCfg = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Debug|x86.Build.0 = Debug|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Release|Any CPU.Build.0 = Release|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Release|ARM.ActiveCfg = Release|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Release|ARM.Build.0 = Release|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Release|x64.ActiveCfg = Release|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Release|x64.Build.0 = Release|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Release|x86.ActiveCfg = Release|Any CPU - {6D7D1D60-4473-4C52-800C-9B892C6640A5}.Release|x86.Build.0 = Release|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug|ARM.ActiveCfg = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug|ARM.Build.0 = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug|x64.ActiveCfg = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug|x64.Build.0 = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug|x86.ActiveCfg = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Debug|x86.Build.0 = Debug|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Release|Any CPU.Build.0 = Release|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Release|ARM.ActiveCfg = Release|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Release|ARM.Build.0 = Release|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Release|x64.ActiveCfg = Release|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Release|x64.Build.0 = Release|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Release|x86.ActiveCfg = Release|Any CPU - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51}.Release|x86.Build.0 = Release|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug|ARM.ActiveCfg = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug|ARM.Build.0 = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug|x64.ActiveCfg = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug|x64.Build.0 = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug|x86.ActiveCfg = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Debug|x86.Build.0 = Debug|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Release|Any CPU.Build.0 = Release|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Release|ARM.ActiveCfg = Release|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Release|ARM.Build.0 = Release|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Release|x64.ActiveCfg = Release|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Release|x64.Build.0 = Release|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Release|x86.ActiveCfg = Release|Any CPU - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Release|x86.Build.0 = Release|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug|ARM.ActiveCfg = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug|ARM.Build.0 = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug|x64.ActiveCfg = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug|x64.Build.0 = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug|x86.ActiveCfg = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Debug|x86.Build.0 = Debug|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Release|Any CPU.Build.0 = Release|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Release|ARM.ActiveCfg = Release|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Release|ARM.Build.0 = Release|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Release|x64.ActiveCfg = Release|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Release|x64.Build.0 = Release|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Release|x86.ActiveCfg = Release|Any CPU - {2150E333-8FDC-42A3-9474-1A3956D46DE8}.Release|x86.Build.0 = Release|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug|ARM.ActiveCfg = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug|ARM.Build.0 = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug|x64.ActiveCfg = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug|x64.Build.0 = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug|x86.ActiveCfg = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Debug|x86.Build.0 = Debug|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Release|Any CPU.Build.0 = Release|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Release|ARM.ActiveCfg = Release|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Release|ARM.Build.0 = Release|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Release|x64.ActiveCfg = Release|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Release|x64.Build.0 = Release|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Release|x86.ActiveCfg = Release|Any CPU - {E6D94FFD-7811-40BE-ABC4-6D6AB41F0060}.Release|x86.Build.0 = Release|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug|ARM.ActiveCfg = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug|ARM.Build.0 = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug|x64.ActiveCfg = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug|x64.Build.0 = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug|x86.ActiveCfg = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Debug|x86.Build.0 = Debug|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Release|Any CPU.Build.0 = Release|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Release|ARM.ActiveCfg = Release|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Release|ARM.Build.0 = Release|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Release|Mixed Platforms.Build.0 = Release|Any CPU - {FE778406-ADCF-45A1-B775-A054B55BFC50}.Release|x64.ActiveCfg = Release|Any CPU - {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 - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {B1C55C9B-3287-4EB2-8ADD-795DBC77013D} = {9051DDA0-E563-45D5-9504-085EBAACF469} - {AC3B3DDC-F011-4E19-8C9B-F748B19ED3C0} = {9051DDA0-E563-45D5-9504-085EBAACF469} - {4205A187-9732-4DA8-B0BE-77A2C6B8C6A1} = {8C7B43EB-2F93-483C-B433-E28F9386AD67} - {73896669-F05C-41AC-9F6F-A11F549EDEDC} = {9051DDA0-E563-45D5-9504-085EBAACF469} - {8BF81225-2F85-4412-AD18-6579CBA1879B} = {9051DDA0-E563-45D5-9504-085EBAACF469} - {997AEFE5-D7D4-4033-A31A-07F476D6FE5D} = {1C42C435-8826-4044-8775-A1DA40EF4866} - {6D7D1D60-4473-4C52-800C-9B892C6640A5} = {9051DDA0-E563-45D5-9504-085EBAACF469} - {E6D94C12-9AD7-46E6-AB62-3676F25FDE51} = {9051DDA0-E563-45D5-9504-085EBAACF469} - {4A35B1C5-520D-4267-BA70-2DCEAC0A5662} = {8C7B43EB-2F93-483C-B433-E28F9386AD67} - {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} - EndGlobalSection -EndGlobal diff --git a/RestSharp.sln.DotSettings b/RestSharp.slnx.DotSettings similarity index 100% rename from RestSharp.sln.DotSettings rename to RestSharp.slnx.DotSettings From 9b2fe0a054eb732d4246ae5e117ffa02c703abd2 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Thu, 5 Mar 2026 15:58:25 +0100 Subject: [PATCH 27/27] Fix build: update RestSharp.sln references to RestSharp.slnx GetDirectoryNameOfFileAbove in Directory.Build.props files used RestSharp.sln as anchor, which was removed. Updated all references. Co-Authored-By: Claude Opus 4.6 --- .devcontainer/devcontainer.json | 2 +- .github/copilot-instructions.md | 4 ++-- BEST_PRACTICES.md | 6 +++--- agents.md | 12 ++++++------ .../RestSharp.Benchmarks/RestSharp.Benchmarks.csproj | 2 +- src/Directory.Build.props | 2 +- test/Directory.Build.props | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e5f1586f9..f04eb12bb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -18,7 +18,7 @@ "vscode-icons-team.vscode-icons", "editorconfig.editorconfig" ], - "postCreateCommand": "dotnet restore RestSharp.sln && dotnet build RestSharp.sln --configuration Release --no-restore && dotnet test RestSharp.sln --configuration Release --no-build", + "postCreateCommand": "dotnet restore RestSharp.slnx && dotnet build RestSharp.slnx --configuration Release --no-restore && dotnet test RestSharp.slnx --configuration Release --no-build", "build": { "dockerfile": "Dockerfile" } diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4a54b40d6..8a3c5f4d0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,10 +24,10 @@ RestSharp is a lightweight HTTP API client library for .NET. It wraps `HttpClien ```bash # Build solution -dotnet build RestSharp.sln -c Debug +dotnet build RestSharp.slnx -c Debug # Run all tests -dotnet test RestSharp.sln -c Debug +dotnet test RestSharp.slnx -c Debug # Run tests for specific TFM dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net8.0 diff --git a/BEST_PRACTICES.md b/BEST_PRACTICES.md index bc0a58802..605559da8 100644 --- a/BEST_PRACTICES.md +++ b/BEST_PRACTICES.md @@ -68,7 +68,7 @@ Custom incremental generators live in gen/SourceGenerator and are referenced as - 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 RestSharp.slnx -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. @@ -172,7 +172,7 @@ Use this quick checklist before requesting review: ## 15) Useful Commands (Quick Reference) - Build solution (Release): - - dotnet build RestSharp.sln -c Release + - dotnet build RestSharp.slnx -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: @@ -182,7 +182,7 @@ Use this quick checklist before requesting review: - 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 + - dotnet clean RestSharp.slnx - rm -rf src/*/bin src/*/obj test/*/bin test/*/obj gen/*/bin gen/*/obj diff --git a/agents.md b/agents.md index 5093f99f8..95e60e45f 100644 --- a/agents.md +++ b/agents.md @@ -9,7 +9,7 @@ This document captures project-specific knowledge to speed up advanced developme ### Projects and Organization -The solution (`RestSharp.sln`) is organized into the following structure: +The solution (`RestSharp.slnx`) is organized into the following structure: **Core Library:** - `src/RestSharp/` - Main library targeting multiple frameworks @@ -294,7 +294,7 @@ These are automatically available in all test files without explicit `using` sta **All tests for entire solution:** ```bash -dotnet test RestSharp.sln -c Debug +dotnet test RestSharp.slnx -c Debug ``` **Specific test project:** @@ -575,12 +575,12 @@ using var stream = ... **Debug build:** ```bash -dotnet build RestSharp.sln -c Debug +dotnet build RestSharp.slnx -c Debug ``` **Release build:** ```bash -dotnet build RestSharp.sln -c Release +dotnet build RestSharp.slnx -c Release ``` ### Working with Source Generator @@ -634,7 +634,7 @@ dotnet build src/RestSharp/RestSharp.csproj -f net8.0 ```bash # Build solution -dotnet build RestSharp.sln -c Release +dotnet build RestSharp.slnx -c Release # Run all tests for a single TFM dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net8.0 @@ -657,7 +657,7 @@ dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net8.0 \ find src/RestSharp/obj/Debug -name "*.g.cs" -o -name "ReadOnly*.cs" # Clean all build artifacts -dotnet clean RestSharp.sln +dotnet clean RestSharp.slnx rm -rf src/*/bin src/*/obj test/*/bin test/*/obj gen/*/bin gen/*/obj ``` diff --git a/benchmarks/RestSharp.Benchmarks/RestSharp.Benchmarks.csproj b/benchmarks/RestSharp.Benchmarks/RestSharp.Benchmarks.csproj index 3d939e0fb..1b6ce4dd0 100644 --- a/benchmarks/RestSharp.Benchmarks/RestSharp.Benchmarks.csproj +++ b/benchmarks/RestSharp.Benchmarks/RestSharp.Benchmarks.csproj @@ -5,7 +5,7 @@ false preview enable - $([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildProjectDirectory), 'RestSharp.sln')) + $([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildProjectDirectory), 'RestSharp.slnx')) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 614781bf3..37b7de337 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,5 +1,5 @@ - + netstandard2.0;net471;net48;net8.0;net9.0;net10.0 restsharp.png diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 2279b675c..f30698fe6 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -1,5 +1,5 @@ - + true false