diff --git a/.github/workflows/build-dev.yml b/.github/workflows/build-dev.yml
index a3d704238..bdf0ee78b 100644
--- a/.github/workflows/build-dev.yml
+++ b/.github/workflows/build-dev.yml
@@ -35,14 +35,12 @@ jobs:
-
name: Unshallow
run: git fetch --prune --unshallow
-
-
name: NuGet login
uses: NuGet/login@v1
id: nuget-login
with:
user: ${{ secrets.NUGET_USER }}
-
-
name: Create and push NuGet package
run: |
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 9eeea5b38..2e5fb6b61 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -23,7 +23,7 @@ jobs:
runs-on: windows-latest
strategy:
matrix:
- dotnet: ['net48', 'net8.0', 'net9.0']
+ dotnet: ['net48', 'net8.0', 'net9.0', 'net10.0']
steps:
-
@@ -61,7 +61,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- dotnet: ['net8.0', 'net9.0']
+ dotnet: ['net8.0', 'net9.0', 'net10.0']
steps:
-
diff --git a/.gitignore b/.gitignore
index 311c76314..2b2ca2d47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,4 +55,5 @@ RestSharp.IntegrationTests/config.json
/out/
/docs/.vuepress/dist/
.vscode/
-.temp/
\ No newline at end of file
+.temp/
+*.trx
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9da81daba..d5c7ec827 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,53 +1,53 @@
-
- true
-
-
- 10.0.0-rc.2.25502.107
- 10.0.0-rc.2.25502.107
-
-
- 9.0.10
-
-
- 8.0.21
-
-
- 9.0.10
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ true
+
+
+ 10.0.0-rc.2.25502.107
+ 10.0.0-rc.2.25502.107
+
+
+ 9.0.10
+
+
+ 8.0.21
+
+
+ 9.0.10
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/agents.md b/agents.md
new file mode 100644
index 000000000..b4cdc3a7b
--- /dev/null
+++ b/agents.md
@@ -0,0 +1,133 @@
+### RestSharp – Developer Notes (agents.md)
+
+#### Scope
+This document captures project-specific knowledge to speed up advanced development and maintenance of RestSharp. It focuses on build, configuration, and testing details unique to this repository, plus conventions and tips that help avoid common pitfalls.
+
+---
+
+### Build and configuration
+
+- Solution layout
+ - Root solution: `RestSharp.sln`.
+ - Library sources in `src/RestSharp` targeting multiple frameworks via shared props.
+ - Tests live under `test/` and are multi-targeted (see below).
+
+- Multi-targeting
+ - Tests target: `net48; net8.0; net9.0; net10.0` (defined in `test/Directory.Build.props`).
+ - .NET Framework 4.8 support is provided via `Microsoft.NETFramework.ReferenceAssemblies.net472` for reference assemblies during build when `TargetFramework == net48`.
+ - CI uses `actions/setup-dotnet@v4` with `dotnet-version: 9.0.x` for packaging; building tests locally may require multiple SDKs if you intend to run against all TFMs. Practically, you can run on a single installed TFM by overriding `-f`.
+ - CI for pull requests runs tests against the supported .NET versions (.NET 8, .NET 9, and .NET 10) on Linux and Windows. On Windows, it also runs tests against .NET Framework 4.8.
+
+- Central props
+ - `test/Directory.Build.props` imports `props/Common.props` from the repo root. This propagates common settings into all test projects.
+ - Notable properties:
+ - `true` and `false` in tests.
+ - `disable` in tests (be mindful when adding nullable-sensitive code in tests).
+ - `xUnit1033;CS8002` to quiet specific analyzer warnings.
+ - Test logs: `VSTestLogger` and `VSTestResultsDirectory` are preconfigured to write TRX per TFM into `test-results/`.
+
+- Packaging (FYI)
+ - CI workflow `.github/workflows/build-dev.yml` demonstrates release packaging: `dotnet pack -c Release -o nuget -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg` and `dotnet nuget push` to nuget.org using OIDC via `NuGet/login@v1`.
+
+---
+
+### Testing
+
+- Test frameworks and helpers
+ - xUnit is the test framework. `test/Directory.Build.props` adds global `using` aliases for `Xunit`, `FluentAssertions`, and `AutoFixture` so you can use `[Fact]`, `Should()`, etc., without explicit `using` statements in each test file.
+ - Additional packages commonly used in unit tests (see `test/RestSharp.Tests/RestSharp.Tests.csproj`): `Moq`, `RichardSzalay.MockHttp`, `System.Net.Http.Json`.
+ - Integrated tests leverage `WireMockServer` (see `RestSharp.Tests.Integrated`) and use assets under `Assets/` where needed.
+
+- Running tests
+ - Run all tests for the entire solution:
+ ```
+ dotnet test RestSharp.sln -c Debug
+ ```
+ - Run a specific test project (multi-targeted will run for all installed TFMs):
+ ```
+ dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj
+ ```
+ - Select a single target framework (useful if you don’t have all SDKs installed):
+ ```
+ dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net8.0
+ ```
+ - Run by fully-qualified name (FQN) — recommended for pinpointing a single test:
+ ```
+ dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj \
+ --filter "FullyQualifiedName=RestSharp.Tests.UrlBuilderTests_Get.Should_build_url_with_query"
+ ```
+ Notes:
+ - Prefer `FullyQualifiedName` for precision. Class and method names are case-sensitive.
+ - You can combine with `-f net8.0` to avoid cross-TFM failures when only one SDK is present.
+ - Run by namespace or class:
+ ```
+ dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj --filter "RestSharp.Tests.UrlBuilderTests"
+ ```
+
+- Logs and results
+ - TRX logs are written per TFM into `test-results//` with file name `.trx` as configured by `VSTestLogger`/`VSTestResultsDirectory` in `Directory.Build.props`.
+ - To additionally emit console diagnostics:
+ ```
+ dotnet test -v n
+ ```
+
+- Code coverage
+ - The `coverlet.collector` package is referenced for data-collector based coverage.
+ - Example coverage run (generates cobertura xml):
+ ```
+ dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj \
+ -f net8.0 \
+ --collect:"XPlat Code Coverage" \
+ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura
+ ```
+ - Output will be placed under the test results directory for the given run.
+
+- Adding new tests
+ - New xUnit test files can be added anywhere under the corresponding test project directory; no extra `using` directives are required for `Xunit`/`FluentAssertions`/`AutoFixture` thanks to `Directory.Build.props` implicit usings.
+ - Prefer co-locating tests by feature area and splitting large suites using partial classes (see `UrlBuilderTests.cs` with `UrlBuilderTests.Get.cs`/`Post.cs` linked via `DependentUpon` in the project) to keep navigation manageable.
+ - For HTTP behavior, use `WireMockServer` in integrated tests rather than live endpoints. See `test/RestSharp.Tests.Integrated/DownloadFileTests.cs` for a pattern: spin up a server, register expectations in the constructor, and dispose in `IDisposable.Dispose`.
+ - Follow existing assertions style with FluentAssertions.
+
+- Verified example run
+ - The test infrastructure was validated by executing a trivial `[Fact]` via fully qualified name using the built-in test runner. Use the FQN filtering example above to replicate.
+
+---
+
+### Additional development information
+
+- Code style and analyzers
+ - Adhere to the style used in `src/RestSharp` and existing tests. Test projects disable nullable by default; the main library might have different settings (check the respective `*.csproj` and imported props).
+ - The repo uses central package management via `Directory.Packages.props`. Prefer bumping versions there unless a project has a specific override.
+
+- HTTP/integration test guidance
+ - Use `WireMockServer` for predictable, offline tests. Avoid time-sensitive or locale-sensitive assertions in integrated tests; when needed, pin formats (e.g., `"d"`) as seen in `ObjectParserTests`.
+ - Be explicit about stream usage across TFMs. Some tests use `#if NET8_0_OR_GREATER` to select APIs like `Stream.ReadExactly`.
+
+- Multi-TFM nuances
+ - When debugging TFM-specific behavior, run `dotnet test -f ` to reproduce. Conditional compilation symbols (e.g., `NET8_0_OR_GREATER`) are used in tests; ensure your changes compile under all declared target frameworks or scope them with `#if`.
+
+- Artifacts and outputs
+ - NuGet packages are output to `nuget/` during local `dotnet pack` unless overridden.
+ - Test artifacts are collected under `test-results//` per the configuration.
+
+- Common pitfalls
+ - Running tests targeting `net48` on non-Windows environments requires the reference assemblies (already pulled by package reference) but still may need Mono/compat setup on some systems; if unavailable, skip with `-f`.
+ - Some integrated tests rely on asset files under `Assets/`. Ensure `AppDomain.CurrentDomain.BaseDirectory` resolves correctly when running from IDE vs CLI.
+
+---
+
+### Quick commands reference
+
+```
+# Build solution
+dotnet build RestSharp.sln -c Release
+
+# Run all tests for a single TFM
+dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net8.0
+
+# Run a single test by FQN
+dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj --filter "FullyQualifiedName=RestSharp.Tests.ObjectParserTests.ShouldUseRequestProperty"
+
+# Pack (local)
+dotnet pack src/RestSharp/RestSharp.csproj -c Release -o nuget -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg
+```
diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs
index 8d064720c..d299a9ead 100644
--- a/src/RestSharp/RestClient.Async.cs
+++ b/src/RestSharp/RestClient.Async.cs
@@ -14,6 +14,8 @@
using RestSharp.Extensions;
+// ReSharper disable PossiblyMistakenUseOfCancellationToken
+
namespace RestSharp;
public partial class RestClient {
diff --git a/src/RestSharp/RestClient.Extensions.cs b/src/RestSharp/RestClient.Extensions.cs
index 4af3fe1c7..80d1263f0 100644
--- a/src/RestSharp/RestClient.Extensions.cs
+++ b/src/RestSharp/RestClient.Extensions.cs
@@ -171,11 +171,10 @@ [EnumeratorCancellation] CancellationToken cancellationToken
using var reader = new StreamReader(stream);
- while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested) {
#if NET7_0_OR_GREATER
- var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
+ while (await reader.ReadLineAsync(cancellationToken) is { } line && !cancellationToken.IsCancellationRequested) {
#else
- var line = await reader.ReadLineAsync().ConfigureAwait(false);
+ while (await reader.ReadLineAsync() is { } line && !cancellationToken.IsCancellationRequested) {
#endif
if (string.IsNullOrWhiteSpace(line)) continue;
diff --git a/test/Directory.Build.props b/test/Directory.Build.props
index b3c32f6d1..a590b3b0f 100644
--- a/test/Directory.Build.props
+++ b/test/Directory.Build.props
@@ -3,9 +3,9 @@
true
false
- net48;net8.0;net9.0
+ net48;net8.0;net9.0;net10.0
disable
- xUnit1033
+ xUnit1033;CS8002
trx%3bLogFileName=$(MSBuildProjectName).trx
$(RepoRoot)/test-results/$(TargetFramework)
diff --git a/test/RestSharp.Tests.Integrated/CookieTests.cs b/test/RestSharp.Tests.Integrated/CookieTests.cs
index 8153b617f..1014fd3d4 100644
--- a/test/RestSharp.Tests.Integrated/CookieTests.cs
+++ b/test/RestSharp.Tests.Integrated/CookieTests.cs
@@ -26,6 +26,10 @@ public CookieTests() {
_server
.Given(Request.Create().WithPath("/set-cookies"))
.RespondWith(Response.Create().WithCallback(HandleSetCookies));
+
+ _server
+ .Given(Request.Create().WithPath("/invalid-cookies"))
+ .RespondWith(Response.Create().WithCallback(HandleInvalidCookies));
}
[Fact]
@@ -62,8 +66,8 @@ public async Task Can_Perform_GET_Async_With_Response_Cookies() {
AssertCookie("cookie1", "value1", x => x == DateTime.MinValue);
response.Cookies.Find("cookie2").Should().BeNull("Cookie 2 should vanish as the path will not match");
- AssertCookie("cookie3", "value3", x => x > DateTime.Now);
- AssertCookie("cookie4", "value4", x => x > DateTime.Now);
+ AssertCookie("cookie3", "value3", x => x > DateTime.UtcNow);
+ AssertCookie("cookie4", "value4", x => x > DateTime.UtcNow);
response.Cookies.Find("cookie5").Should().BeNull("Cookie 5 should vanish as the request is not SSL");
AssertCookie("cookie6", "value6", x => x == DateTime.MinValue, true);
return;
@@ -73,7 +77,7 @@ void AssertCookie(string name, string value, Func checkExpiratio
c.Value.Should().Be(value);
c.Path.Should().Be("/");
c.Domain.Should().Be(_host);
- checkExpiration(c.Expires).Should().BeTrue();
+ checkExpiration(c.Expires).Should().BeTrue($"Expires at {c.Expires}");
c.HttpOnly.Should().Be(httpOnly);
}
}
@@ -94,6 +98,33 @@ public async Task GET_Async_With_Response_Cookies_Should_Not_Fail_With_Cookie_Wi
emptyDomainCookieHeader.Should().Contain("domain=;");
}
+ [Fact]
+ public async Task GET_Async_With_Invalid_Cookies_Should_Still_Return_Response_When_IgnoreInvalidCookies_Is_False() {
+ var request = new RestRequest("invalid-cookies") {
+ CookieContainer = new()
+ };
+ var response = await _client.ExecuteAsync(request);
+
+ // Even with IgnoreInvalidCookies = false, the response should be successful
+ // because AddCookies swallows CookieException by default
+ response.IsSuccessful.Should().BeTrue();
+ response.Content.Should().Be("success");
+ }
+
+ [Fact]
+ public async Task GET_Async_With_Invalid_Cookies_Should_Return_Response_When_IgnoreInvalidCookies_Is_True() {
+ var options = new RestClientOptions(_server.Url!) {
+ CookieContainer = new()
+ };
+ using var client = new RestClient(options);
+
+ var request = new RestRequest("invalid-cookies");
+ var response = await client.ExecuteAsync(request);
+
+ response.IsSuccessful.Should().BeTrue();
+ response.Content.Should().Be("success");
+ }
+
static ResponseMessage HandleGetCookies(IRequestMessage request) {
var response = request.Cookies!.Select(x => $"{x.Key}={x.Value}").ToArray();
return WireMockTestServer.CreateJson(response);
@@ -101,13 +132,13 @@ static ResponseMessage HandleGetCookies(IRequestMessage request) {
static ResponseMessage HandleSetCookies(IRequestMessage request) {
var cookies = new List {
- new("cookie1", "value1", new CookieOptions()),
- new("cookie2", "value2", new CookieOptions { Path = "/path_extra" }),
- new("cookie3", "value3", new CookieOptions { Expires = DateTimeOffset.Now.AddDays(2) }),
- new("cookie4", "value4", new CookieOptions { MaxAge = TimeSpan.FromSeconds(100) }),
- new("cookie5", "value5", new CookieOptions { Secure = true }),
- new("cookie6", "value6", new CookieOptions { HttpOnly = true }),
- new("cookie_empty_domain", "value_empty_domain", new CookieOptions { HttpOnly = true, Domain = string.Empty })
+ new("cookie1", "value1", new()),
+ new("cookie2", "value2", new() { Path = "/path_extra" }),
+ new("cookie3", "value3", new() { Expires = DateTimeOffset.Now.AddDays(2) }),
+ new("cookie4", "value4", new() { MaxAge = TimeSpan.FromSeconds(100) }),
+ new("cookie5", "value5", new() { Secure = true }),
+ new("cookie6", "value6", new() { HttpOnly = true }),
+ new("cookie_empty_domain", "value_empty_domain", new() { HttpOnly = true, Domain = string.Empty })
};
var response = new ResponseMessage {
@@ -125,6 +156,23 @@ static ResponseMessage HandleSetCookies(IRequestMessage request) {
return response;
}
+ static ResponseMessage HandleInvalidCookies(IRequestMessage request) {
+ var response = new ResponseMessage {
+ Headers = new Dictionary>(),
+ BodyData = new BodyData {
+ DetectedBodyType = BodyType.String,
+ BodyAsString = "success"
+ }
+ };
+
+ // Create an invalid cookie with a domain mismatch that will cause CookieException
+ // The cookie domain doesn't match the request URL domain
+ var valuesList = new WireMockList { "invalid_cookie=value; Domain=.invalid-domain.com" };
+ response.Headers.Add(KnownHeaders.SetCookie, valuesList);
+
+ return response;
+ }
+
record CookieInternal(string Name, string Value, CookieOptions Options);
public void Dispose() {
diff --git a/test/RestSharp.Tests.Integrated/DownloadDataTests.cs b/test/RestSharp.Tests.Integrated/DownloadDataTests.cs
new file mode 100644
index 000000000..2540b177f
--- /dev/null
+++ b/test/RestSharp.Tests.Integrated/DownloadDataTests.cs
@@ -0,0 +1,79 @@
+using RestSharp.Extensions;
+
+namespace RestSharp.Tests.Integrated;
+
+public sealed class DownloadDataTests : IDisposable {
+ const string LocalPath = "Assets/Koala.jpg";
+
+ readonly WireMockServer _server = WireMockServer.Start();
+ readonly RestClient _client;
+ readonly string _path = AppDomain.CurrentDomain.BaseDirectory;
+
+ public DownloadDataTests() {
+ var pathToFile = Path.Combine(_path, Path.Combine(LocalPath.Split('/')));
+
+ _server
+ .Given(Request.Create().WithPath($"/{LocalPath}"))
+ .RespondWith(Response.Create().WithBodyFromFile(pathToFile));
+ var options = new RestClientOptions($"{_server.Url}/{LocalPath}") { ThrowOnAnyError = true };
+ _client = new(options);
+ }
+
+ public void Dispose() => _server.Dispose();
+
+ [Fact]
+ public void DownloadDataAsync_returns_null_when_stream_is_null() {
+ var request = new RestRequest("/invalid-endpoint");
+
+ var action = () => _client.DownloadData(request);
+
+ action.Should().ThrowExactly();
+ }
+
+ [Fact]
+ public async Task DownloadDataAsync_returns_bytes_when_stream_has_content() {
+ var request = new RestRequest("");
+
+ var bytes = await _client.DownloadDataAsync(request);
+
+ bytes.Should().NotBeNull();
+ bytes!.Length.Should().BeGreaterThan(0);
+ }
+
+ [Fact]
+ public void DownloadData_sync_wraps_async() {
+ var request = new RestRequest("");
+
+ var bytes = _client.DownloadData(request);
+
+ bytes.Should().NotBeNull();
+ bytes!.Length.Should().BeGreaterThan(0);
+ }
+
+ [Fact]
+ public void DownloadStream_sync_wraps_async() {
+ var request = new RestRequest("");
+
+ using var stream = _client.DownloadStream(request);
+
+ stream.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task DownloadStream_then_ReadAsBytes_matches_DownloadDataAsync() {
+ var request = new RestRequest("");
+
+#if NET6_0_OR_GREATER
+ await using var stream = await _client.DownloadStreamAsync(request);
+#else
+ using var stream = await _client.DownloadStreamAsync(request);
+#endif
+ var bytesFromStream = stream == null ? null : await stream.ReadAsBytes(default);
+
+ var bytesDirect = await _client.DownloadDataAsync(request);
+
+ bytesFromStream.Should().NotBeNull();
+ bytesDirect.Should().NotBeNull();
+ bytesFromStream!.Should().BeEquivalentTo(bytesDirect);
+ }
+}
\ No newline at end of file
diff --git a/test/RestSharp.Tests.Integrated/DownloadFileTests.cs b/test/RestSharp.Tests.Integrated/DownloadFileTests.cs
index d85428ecc..4d77912cc 100644
--- a/test/RestSharp.Tests.Integrated/DownloadFileTests.cs
+++ b/test/RestSharp.Tests.Integrated/DownloadFileTests.cs
@@ -7,15 +7,13 @@ public sealed class DownloadFileTests : IDisposable {
const string LocalPath = "Assets/Koala.jpg";
public DownloadFileTests() {
- // _server = HttpServerFixture.StartServer("Assets/Koala.jpg", FileHandler);
-
var pathToFile = Path.Combine(_path, Path.Combine(LocalPath.Split('/')));
_server
.Given(Request.Create().WithPath($"/{LocalPath}"))
.RespondWith(Response.Create().WithBodyFromFile(pathToFile));
var options = new RestClientOptions($"{_server.Url}/{LocalPath}") { ThrowOnAnyError = true };
- _client = new RestClient(options);
+ _client = new(options);
}
public void Dispose() => _server.Dispose();
@@ -32,7 +30,12 @@ public async Task AdvancedResponseWriter_without_ResponseWriter_reads_stream() {
AdvancedResponseWriter = (response, request) => {
var buf = new byte[16];
// ReSharper disable once MustUseReturnValue
- response.Content.ReadAsStreamAsync().GetAwaiter().GetResult().Read(buf, 0, buf.Length);
+ using var stream = response.Content.ReadAsStreamAsync().GetAwaiter().GetResult();
+#if NET8_0_OR_GREATER
+ stream.ReadExactly(buf);
+#else
+ stream.Read(buf, 0, buf.Length);
+#endif
tag = Encoding.ASCII.GetString(buf, 6, 4);
return new RestResponse(request);
}
diff --git a/test/RestSharp.Tests.Integrated/UploadFileTests.cs b/test/RestSharp.Tests.Integrated/UploadFileTests.cs
index 2d0b2ab54..533208669 100644
--- a/test/RestSharp.Tests.Integrated/UploadFileTests.cs
+++ b/test/RestSharp.Tests.Integrated/UploadFileTests.cs
@@ -73,7 +73,7 @@ public async Task Should_upload_from_stream_non_ascii() {
response.Data.Should().BeEquivalentTo(new UploadResponse(nonAsciiFilename, new FileInfo(_path).Length, true));
}
- static async Task HandleUpload(IRequestMessage request) {
+ static async Task HandleUpload(IRequestMessage request) {
var response = new ResponseMessage();
var checkFile = request.Query == null ||