diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e51709ec3..dd55f8c7a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -80,6 +80,10 @@ + + + + $(AssemblyName).testconfig.json diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 461c687e1..5ed00154a 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -10,4 +10,10 @@ + + + + + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 765448f97..414dc0196 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -5,7 +5,7 @@ - 5.4.0 + 5.6.0 1.56.0 @@ -28,7 +28,6 @@ - diff --git a/src/Refit.Newtonsoft.Json/NewtonsoftJsonContentSerializer.cs b/src/Refit.Newtonsoft.Json/NewtonsoftJsonContentSerializer.cs index 2a89a473e..b6bdeeaf7 100644 --- a/src/Refit.Newtonsoft.Json/NewtonsoftJsonContentSerializer.cs +++ b/src/Refit.Newtonsoft.Json/NewtonsoftJsonContentSerializer.cs @@ -41,13 +41,11 @@ public NewtonsoftJsonContentSerializer() "AOT", "IL3050:Calling members annotated with RequiresDynamicCodeAttribute may break when AOT compiling", Justification = "Interface method is unannotated on net8.0+ so cannot propagate; Newtonsoft path is documented as unsuitable for trimmed/AOT apps.")] - public HttpContent ToHttpContent(T item) - { - return new StringContent( + public HttpContent ToHttpContent(T item) => + new StringContent( JsonConvert.SerializeObject(item, _jsonSerializerSettings.Value), Encoding.UTF8, "application/json"); - } /// [UnconditionalSuppressMessage( diff --git a/src/Refit/AnonymousDisposable.cs b/src/Refit/AnonymousDisposable.cs deleted file mode 100644 index d69d6986c..000000000 --- a/src/Refit/AnonymousDisposable.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. -// ReactiveUI and Contributors licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. -namespace Refit; - -/// An that runs the supplied action when disposed. -/// The action to run on disposal. -internal sealed class AnonymousDisposable(Action block) : IDisposable -{ - /// - public void Dispose() => block(); -} diff --git a/src/Refit/ApiResponse{T}.cs b/src/Refit/ApiResponse{T}.cs index 10e26de99..f3eb3e449 100644 --- a/src/Refit/ApiResponse{T}.cs +++ b/src/Refit/ApiResponse{T}.cs @@ -129,23 +129,19 @@ public ApiResponse( /// The current /// Thrown when an unsuccessful response was received from the server. /// Thrown when the request failed before receiving a response from the server. - public Task> EnsureSuccessStatusCodeAsync() - { - return IsSuccessStatusCode + public Task> EnsureSuccessStatusCodeAsync() => + IsSuccessStatusCode ? Task.FromResult(this) : EnsureSlowAsync(); - } /// Ensures the request was successful and without any other error by throwing an exception in case of failure. /// The current /// Thrown when an unsuccessful response was received from the server. /// Thrown when the request failed before receiving a response from the server. - public Task> EnsureSuccessfulAsync() - { - return IsSuccessful + public Task> EnsureSuccessfulAsync() => + IsSuccessful ? Task.FromResult(this) : EnsureSlowAsync(); - } /// [SuppressMessage( diff --git a/src/Refit/Refit.csproj b/src/Refit/Refit.csproj index 27725daf8..54e338df3 100644 --- a/src/Refit/Refit.csproj +++ b/src/Refit/Refit.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Refit/RequestBuilderImplementation.Execution.cs b/src/Refit/RequestBuilderImplementation.Execution.cs index f04e54f65..f82282708 100644 --- a/src/Refit/RequestBuilderImplementation.Execution.cs +++ b/src/Refit/RequestBuilderImplementation.Execution.cs @@ -97,9 +97,8 @@ await RequestExecutionHelpers.SendVoidAsync( Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] private Func> BuildCancellableTaskFuncForMethod( - RestMethodInfoInternal restMethod) - { - return async (client, ct, paramList) => + RestMethodInfoInternal restMethod) => + async (client, ct, paramList) => { RequestExecutionHelpers.ThrowIfBaseAddressMissing(client); @@ -120,7 +119,6 @@ await RequestExecutionHelpers.SendVoidAsync( request?.Dispose(); } }; - } /// Processes a response for a reflection-built request using the shared runtime state machine. /// The result type returned to the caller. diff --git a/src/Refit/RequestBuilderImplementation.TaskToObservable.cs b/src/Refit/RequestBuilderImplementation.TaskToObservable.cs deleted file mode 100644 index cc34b32ac..000000000 --- a/src/Refit/RequestBuilderImplementation.TaskToObservable.cs +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. -// ReactiveUI and Contributors licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. -namespace Refit; - -/// Adapts cancellable task factories into observable sequences for . -[System.Diagnostics.CodeAnalysis.SuppressMessage( - "Minor Code Smell", - "SST1432:Mark type as static", - Justification = "False positive: this is one part of a partial class whose other parts declare instance members; the type cannot be static.")] -internal partial class RequestBuilderImplementation -{ - /// Adapts a cancellable task factory into an observable sequence. - /// The result type produced by the task. - private sealed class TaskToObservable : IObservable - { - /// The factory that produces the task to observe. - private readonly Func> _taskFactory; - - /// Initializes a new instance of the class. - /// The factory that produces the task to observe. - public TaskToObservable(Func> taskFactory) => this._taskFactory = taskFactory; - - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD110", Justification = "Subscription is fire-and-forget; the continuation task is intentionally not awaited.")] - public IDisposable Subscribe(IObserver observer) - { - var cts = new CancellationTokenSource(); - _taskFactory(cts.Token) - .ContinueWith( - t => - { - try - { - if (cts.IsCancellationRequested) - { - return; - } - - ToObservableDone(t, observer); - } - finally - { - cts.Dispose(); - } - }, - TaskScheduler.Default); - - return new AnonymousDisposable(() => - { - try - { - cts.Cancel(); - } - catch (ObjectDisposedException) - { - // The token source was already disposed by the completed continuation; nothing to cancel. - } - }); - } - - /// Forwards the completed task's outcome to the observer. - /// The result type of the task. - /// The completed task. - /// The observer to notify. - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD002", Justification = "Task is already completed here, so the result read never blocks.")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Sonar", "S4462", Justification = "Task is already completed here, so the result read never blocks.")] - private static void ToObservableDone(Task task, IObserver subject) - { - switch (task.Status) - { - case TaskStatus.RanToCompletion: - { - subject.OnNext(task.Result); - subject.OnCompleted(); - break; - } - - case TaskStatus.Faulted: - { - subject.OnError(task.Exception!.InnerException!); - break; - } - - case TaskStatus.Canceled: - { - subject.OnError(new TaskCanceledException(task)); - break; - } - } - } - } -} diff --git a/src/Refit/RequestBuilderImplementation.cs b/src/Refit/RequestBuilderImplementation.cs index 51015feaa..4abab1d05 100644 --- a/src/Refit/RequestBuilderImplementation.cs +++ b/src/Refit/RequestBuilderImplementation.cs @@ -5,10 +5,15 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Reflection; +using ReactiveUI.Primitives.Advanced; namespace Refit { /// Reflection-based request builder that turns Refit interface calls into HTTP requests. + [SuppressMessage( + "Minor Code Smell", + "SST1432:Mark type as static", + Justification = "False positive: this is one part of a partial class whose other parts declare instance members; the type cannot be static.")] internal partial class RequestBuilderImplementation : IRequestBuilder { /// Maximum stack-allocated buffer size, in characters, used when building paths and query strings. @@ -441,9 +446,8 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] private Func BuildGeneratedSyncFuncForMethodGeneric( - RestMethodInfoInternal restMethod) - { - return (client, paramList) => + RestMethodInfoInternal restMethod) => + (client, paramList) => RunSynchronous(() => ExecuteRequestAsync( client, @@ -451,7 +455,6 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( paramList, paramsContainsCancellationToken: false, CancellationToken.None)); - } /// Builds an observable invocation delegate for a method. /// The result type returned to the caller. @@ -469,7 +472,7 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( var taskFunc = BuildCancellableTaskFuncForMethod(restMethod); return (client, paramList) => - new TaskToObservable(ct => + new FromAsyncSignal(ct => { var methodCt = CancellationToken.None; if (restMethod.CancellationToken is not null) @@ -539,9 +542,8 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( /// A delegate that returns a task with no result. [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] private Func BuildVoidTaskFuncForMethod( - RestMethodInfoInternal restMethod) - { - return (client, paramList) => + RestMethodInfoInternal restMethod) => + (client, paramList) => { var ct = CancellationToken.None; @@ -557,6 +559,5 @@ private Func BuildVoidTaskFuncForMethod( restMethod.CancellationToken is not null, ct); }; - } } } diff --git a/src/tests/Refit.GeneratorTests/Fixture.cs b/src/tests/Refit.GeneratorTests/Fixture.cs index af407c2f0..d41c1289c 100644 --- a/src/tests/Refit.GeneratorTests/Fixture.cs +++ b/src/tests/Refit.GeneratorTests/Fixture.cs @@ -32,7 +32,7 @@ public static class Fixture [ typeof(Binder), typeof(GetAttribute), - typeof(System.Reactive.Unit), + typeof(ReactiveUI.Primitives.RxVoid), typeof(Enumerable), typeof(Newtonsoft.Json.JsonConvert), typeof(TestAttribute), diff --git a/src/tests/Refit.GeneratorTests/ParserCoverageTests.cs b/src/tests/Refit.GeneratorTests/ParserCoverageTests.cs index a7684b53e..47c9d3d02 100644 --- a/src/tests/Refit.GeneratorTests/ParserCoverageTests.cs +++ b/src/tests/Refit.GeneratorTests/ParserCoverageTests.cs @@ -18,8 +18,7 @@ public sealed class ParserCoverageTests /// Verifies parser argument validation. /// A task representing the asynchronous test. [Test] - public async Task GenerateInterfaceStubsRejectsNullCompilation() - { + public async Task GenerateInterfaceStubsRejectsNullCompilation() => await Assert.That( () => Parser.GenerateInterfaceStubs( null!, @@ -30,7 +29,6 @@ await Assert.That( [], CancellationToken.None)) .ThrowsExactly(); - } /// Verifies parser diagnostics and namespace normalization when Refit is not referenced. /// A task representing the asynchronous test. diff --git a/src/tests/Refit.GeneratorTests/Refit.GeneratorTests.csproj b/src/tests/Refit.GeneratorTests/Refit.GeneratorTests.csproj index 0e1b75307..8bc3a8032 100644 --- a/src/tests/Refit.GeneratorTests/Refit.GeneratorTests.csproj +++ b/src/tests/Refit.GeneratorTests/Refit.GeneratorTests.csproj @@ -16,7 +16,7 @@ - + diff --git a/src/tests/Refit.Tests/FormValueMultimapTests.cs b/src/tests/Refit.Tests/FormValueMultimapTests.cs index 743e18490..473fd76f4 100644 --- a/src/tests/Refit.Tests/FormValueMultimapTests.cs +++ b/src/tests/Refit.Tests/FormValueMultimapTests.cs @@ -40,11 +40,9 @@ public async Task EmptyIfNullPassedIn() /// Verifies a null settings instance is rejected before source processing. /// A task that represents the asynchronous operation. [Test] - public async Task RejectsNullSettings() - { + public async Task RejectsNullSettings() => await Assert.That(() => new FormValueMultimap(new object(), null!)) .ThrowsExactly(); - } /// Verifies the multimap loads entries from a dictionary source. /// A task that represents the asynchronous operation. diff --git a/src/tests/Refit.Tests/ObservableTestHelpers.cs b/src/tests/Refit.Tests/ObservableTestHelpers.cs new file mode 100644 index 000000000..e5275663f --- /dev/null +++ b/src/tests/Refit.Tests/ObservableTestHelpers.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Advanced; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Signals; + +namespace Refit.Tests; + +/// Test helpers for awaiting observable results through concrete Primitives types. +internal static class ObservableTestHelpers +{ + /// The timeout used by observable integration tests. + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); + + /// Wraps the source in a timeout signal. + /// The observable value type. + /// The source observable. + /// A timeout-wrapped observable. + public static IObservable WithTimeout(IObservable source) => + new ExpireSignal(source, DefaultTimeout, ThreadPoolSequencer.Instance); + + /// Awaits the timeout-wrapped source. + /// The observable value type. + /// The source observable. + /// The final observable value. + public static Task AwaitWithTimeout(IObservable source) => + Await(WithTimeout(source)); + + /// Awaits the source through a concrete Primitives await signal. + /// The observable value type. + /// The source observable. + /// The final observable value. + public static async Task Await(IObservable source) + { + AsyncSignal signal = new(); + using var subscription = source.Subscribe(signal); + return await signal; + } +} diff --git a/src/tests/Refit.Tests/Refit.Tests.csproj b/src/tests/Refit.Tests/Refit.Tests.csproj index 814b673ee..a05b6ccd8 100644 --- a/src/tests/Refit.Tests/Refit.Tests.csproj +++ b/src/tests/Refit.Tests/Refit.Tests.csproj @@ -34,8 +34,8 @@ + - diff --git a/src/tests/Refit.Tests/RequestBuilderTests.cs b/src/tests/Refit.Tests/RequestBuilderTests.cs index 820b6fbca..48772393b 100644 --- a/src/tests/Refit.Tests/RequestBuilderTests.cs +++ b/src/tests/Refit.Tests/RequestBuilderTests.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for full license information. using System.Diagnostics.CodeAnalysis; -using System.Reactive.Linq; namespace Refit.Tests; @@ -22,11 +21,9 @@ public partial class RequestBuilderTests /// Rejects non-interface request-builder targets. /// A task that represents the asynchronous operation. [Test] - public async Task ConstructorRejectsNonInterfaceTargets() - { + public async Task ConstructorRejectsNonInterfaceTargets() => await Assert.That(() => new RequestBuilderImplementation(typeof(string))) .ThrowsExactly(); - } /// Verifies the public request-builder factory entry points create usable builders. /// A task that represents the asynchronous operation. @@ -397,7 +394,7 @@ public async Task ObservableMethodsWithCancellationTokenShouldCancelWhenRequeste }, ["value", cts.Token])!; - await Assert.That(() => observable.Wait()).ThrowsExactly(); + await Assert.That(() => (Task)ObservableTestHelpers.Await(observable)).ThrowsExactly(); await Assert.That(testHttpMessageHandler.RequestMessage!.RequestUri!.ToString()).IsEqualTo("http://api/value/value"); await Assert.That(testHttpMessageHandler.CancellationToken.IsCancellationRequested).IsTrue(); } diff --git a/src/tests/Refit.Tests/RestServiceIntegrationTests.GitHub.cs b/src/tests/Refit.Tests/RestServiceIntegrationTests.GitHub.cs index ec47901bd..7902cf661 100644 --- a/src/tests/Refit.Tests/RestServiceIntegrationTests.GitHub.cs +++ b/src/tests/Refit.Tests/RestServiceIntegrationTests.GitHub.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Net; using System.Net.Http; -using System.Reactive.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -167,9 +166,8 @@ public async Task HitTheGitHubUserApiAsObservableApiResponse() var fixture = RestService.For("https://api.github.com", settings); - var result = await fixture - .GetUserObservableWithMetadata("octocat") - .Timeout(TimeSpan.FromSeconds(10)); + var result = await ObservableTestHelpers.AwaitWithTimeout( + fixture.GetUserObservableWithMetadata("octocat")); await Assert.That(result.Headers!.Any()).IsTrue(); await Assert.That(result.IsSuccessStatusCode).IsTrue(); @@ -216,9 +214,8 @@ public async Task HitTheGitHubUserApiAsObservableIApiResponse() var fixture = RestService.For("https://api.github.com", settings); - var result = await fixture - .GetUserIApiResponseObservableWithMetadata("octocat") - .Timeout(TimeSpan.FromSeconds(10)); + var result = await ObservableTestHelpers.AwaitWithTimeout( + fixture.GetUserIApiResponseObservableWithMetadata("octocat")); await Assert.That(result.Headers!.Any()).IsTrue(); await Assert.That(result.IsSuccessStatusCode).IsTrue(); @@ -286,7 +283,7 @@ public async Task HitWithCamelCaseParameter() var fixture = RestService.For("https://api.github.com", settings); - var result = await fixture.GetUserCamelCase("octocat"); + var result = await ObservableTestHelpers.AwaitWithTimeout(fixture.GetUserCamelCase("octocat")); await Assert.That(result.Login).IsEqualTo("octocat"); await Assert.That(string.IsNullOrEmpty(result.AvatarUrl)).IsFalse(); @@ -516,7 +513,7 @@ public async Task HitTheGitHubUserApiAsObservable() var fixture = RestService.For("https://api.github.com", settings); - var result = await fixture.GetUserObservable("octocat").Timeout(TimeSpan.FromSeconds(10)); + var result = await ObservableTestHelpers.AwaitWithTimeout(fixture.GetUserObservable("octocat")); await Assert.That(result.Login).IsEqualTo("octocat"); await Assert.That(string.IsNullOrEmpty(result.AvatarUrl)).IsFalse(); @@ -547,12 +544,12 @@ public async Task HitTheGitHubUserApiAsObservableAndSubscribeAfterTheFact() var fixture = RestService.For("https://api.github.com", settings); - var obs = fixture.GetUserObservable("octocat").Timeout(TimeSpan.FromSeconds(10)); + var obs = ObservableTestHelpers.WithTimeout(fixture.GetUserObservable("octocat")); // NB: We're gonna await twice, so that the 2nd await is definitely // after the result has completed. - await obs; - var result2 = await obs; + await ObservableTestHelpers.Await(obs); + var result2 = await ObservableTestHelpers.Await(obs); await Assert.That(result2.Login).IsEqualTo("octocat"); await Assert.That(string.IsNullOrEmpty(result2.AvatarUrl)).IsFalse(); } @@ -573,12 +570,12 @@ public async Task TwoSubscriptionsResultInTwoRequests() await Assert.That(input.MessagesSent).IsEqualTo(0); - var obs = fixture.GetIndexObservable().Timeout(TimeSpan.FromSeconds(10)); + var obs = ObservableTestHelpers.WithTimeout(fixture.GetIndexObservable()); - var result1 = await obs; + var result1 = await ObservableTestHelpers.Await(obs); await Assert.That(input.MessagesSent).IsEqualTo(1); - var result2 = await obs; + var result2 = await ObservableTestHelpers.Await(obs); await Assert.That(input.MessagesSent).IsEqualTo(2); // NB: TestHttpMessageHandler returns what we tell it to ('test' by default)