From cd19a10bf5aac69ee64c9ce7fcbc016d7598f7fb Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Wed, 25 Mar 2026 09:24:20 +0000 Subject: [PATCH 1/6] Support sync interface members & enum names Allow synchronous return types only for generated/non-public interface members by building a generated sync function path in RequestBuilderImplementation (BuildGeneratedSyncFuncForMethod, DeserializeSyncResponse). Update RestMethodInfo to enforce the new rule. Enhance SystemTextJsonContentSerializer enum converter to map serialized names (including JsonStringEnumMemberName on .NET 9+) both ways and prefer annotated/serialized names when reading/writing. Add tests (guarded by NET9_0_OR_GREATER) to validate JsonStringEnumMemberName behavior and a SyncCapableMockHttpMessageHandler for sync test scenarios. Add a Blazor WebAssembly example (BlazorWasmIssue2065) demonstrating Refit usage and include it in the solution. --- Refit.Tests/ExplicitInterfaceRefitTests.cs | 10 ++- Refit.Tests/SerializedContentTests.cs | 72 +++++++++++++++++ Refit.sln | 19 +++++ Refit/RequestBuilderImplementation.cs | 79 +++++++++++++++---- Refit/RestMethodInfo.cs | 10 ++- Refit/SystemTextJsonContentSerializer.cs | 72 +++++++++++++---- examples/BlazorWasmIssue2065/App.razor | 13 +++ .../BlazorWasmIssue2065.csproj | 25 ++++++ examples/BlazorWasmIssue2065/IIssue2065Api.cs | 9 +++ examples/BlazorWasmIssue2065/IIssue2067Api.cs | 22 ++++++ .../BlazorWasmIssue2065/Pages/Index.razor | 31 ++++++++ .../BlazorWasmIssue2065/Pages/Issue2067.razor | 31 ++++++++ examples/BlazorWasmIssue2065/Program.cs | 29 +++++++ .../Shared/MainLayout.razor | 7 ++ .../BlazorWasmIssue2065/Shared/_Imports.razor | 1 + examples/BlazorWasmIssue2065/_Imports.razor | 9 +++ .../BlazorWasmIssue2065/wwwroot/index.html | 13 +++ .../wwwroot/sample-data/status.json | 3 + .../wwwroot/sample-data/weather.json | 1 + 19 files changed, 417 insertions(+), 39 deletions(-) create mode 100644 examples/BlazorWasmIssue2065/App.razor create mode 100644 examples/BlazorWasmIssue2065/BlazorWasmIssue2065.csproj create mode 100644 examples/BlazorWasmIssue2065/IIssue2065Api.cs create mode 100644 examples/BlazorWasmIssue2065/IIssue2067Api.cs create mode 100644 examples/BlazorWasmIssue2065/Pages/Index.razor create mode 100644 examples/BlazorWasmIssue2065/Pages/Issue2067.razor create mode 100644 examples/BlazorWasmIssue2065/Program.cs create mode 100644 examples/BlazorWasmIssue2065/Shared/MainLayout.razor create mode 100644 examples/BlazorWasmIssue2065/Shared/_Imports.razor create mode 100644 examples/BlazorWasmIssue2065/_Imports.razor create mode 100644 examples/BlazorWasmIssue2065/wwwroot/index.html create mode 100644 examples/BlazorWasmIssue2065/wwwroot/sample-data/status.json create mode 100644 examples/BlazorWasmIssue2065/wwwroot/sample-data/weather.json diff --git a/Refit.Tests/ExplicitInterfaceRefitTests.cs b/Refit.Tests/ExplicitInterfaceRefitTests.cs index 1e1e30e32..a07d3ab96 100644 --- a/Refit.Tests/ExplicitInterfaceRefitTests.cs +++ b/Refit.Tests/ExplicitInterfaceRefitTests.cs @@ -8,6 +8,12 @@ namespace Refit.Tests; public class ExplicitInterfaceRefitTests { + sealed class SyncCapableMockHttpMessageHandler : MockHttpMessageHandler + { + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) => + SendAsync(request, cancellationToken).GetAwaiter().GetResult(); + } + public interface IFoo { int Bar(); @@ -32,7 +38,7 @@ public interface IRemoteFoo2 : IFoo [Fact] public void DefaultInterfaceImplementation_calls_internal_refit_method() { - var mockHttp = new MockHttpMessageHandler(); + var mockHttp = new SyncCapableMockHttpMessageHandler(); var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; mockHttp @@ -50,7 +56,7 @@ public void DefaultInterfaceImplementation_calls_internal_refit_method() [Fact] public void Explicit_interface_member_with_refit_attribute_is_invoked() { - var mockHttp = new MockHttpMessageHandler(); + var mockHttp = new SyncCapableMockHttpMessageHandler(); var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; mockHttp diff --git a/Refit.Tests/SerializedContentTests.cs b/Refit.Tests/SerializedContentTests.cs index b09f50887..a9d4ebea1 100644 --- a/Refit.Tests/SerializedContentTests.cs +++ b/Refit.Tests/SerializedContentTests.cs @@ -614,6 +614,57 @@ public async Task RestService_SerializesBodyUsingDeclaredPolymorphicBaseType() Assert.Contains("\"name\":\"Photon\"", serializedBody, StringComparison.Ordinal); } +#if NET9_0_OR_GREATER + [Fact] + public async Task SystemTextJsonContentSerializer_SupportsJsonStringEnumMemberName() + { + var serializer = new SystemTextJsonContentSerializer( + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions() + ); + + var content = serializer.ToHttpContent( + new EnumMemberNameEnvelope { Status = EnumMemberNameStatus.TotallyReady } + ); + var serialized = await content.ReadAsStringAsync(); + var roundTrip = await serializer.FromHttpContentAsync( + new StringContent("{\"status\":\"totally-ready\"}", Encoding.UTF8, "application/json") + ); + + Assert.Contains("totally-ready", serialized, StringComparison.Ordinal); + Assert.NotNull(roundTrip); + Assert.Equal(EnumMemberNameStatus.TotallyReady, roundTrip.Status); + } + + [Fact] + public async Task RestService_UsesDefaultEnumConverterWithJsonStringEnumMemberName() + { + var settings = new RefitSettings( + new SystemTextJsonContentSerializer( + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions() + ) + ) + { + HttpMessageHandlerFactory = () => new StubHttpMessageHandler(_ => + Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "{\"status\":\"totally-ready\"}", + Encoding.UTF8, + "application/json" + ) + } + ) + ) + }; + + var api = RestService.For(BaseAddress, settings); + var result = await api.GetStatusAsync(); + + Assert.Equal(EnumMemberNameStatus.TotallyReady, result.Status); + } +#endif + [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] [JsonDerivedType(typeof(LaserWeaponRequest), "laser")] public abstract class CreateWeaponRequest @@ -629,6 +680,27 @@ public interface IPolymorphicRequestApi Task CreateWeapon(CreateWeaponRequest request); } +#if NET9_0_OR_GREATER + public enum EnumMemberNameStatus + { + [JsonStringEnumMemberName("totally-ready")] + TotallyReady, + + NeedsReview + } + + public sealed class EnumMemberNameEnvelope + { + public EnumMemberNameStatus Status { get; set; } + } + + public interface IIssue2067StatusApi + { + [Get("/status")] + Task GetStatusAsync(); + } +#endif + [JsonSerializable(typeof(User))] internal sealed partial class SerializedContentJsonSerializerContext : JsonSerializerContext { } diff --git a/Refit.sln b/Refit.sln index ba2afea5c..07be27e10 100644 --- a/Refit.sln +++ b/Refit.sln @@ -50,6 +50,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleSampleUsingLocalApi" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestApiForTest", "examples\SampleUsingLocalApi\RestApiforTest\RestApiForTest.csproj", "{23305490-94C3-7131-087F-54EDF910A7ED}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorWasmIssue2065", "examples\BlazorWasmIssue2065\BlazorWasmIssue2065.csproj", "{89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -302,6 +304,22 @@ Global {23305490-94C3-7131-087F-54EDF910A7ED}.Release|x64.Build.0 = Release|Any CPU {23305490-94C3-7131-087F-54EDF910A7ED}.Release|x86.ActiveCfg = Release|Any CPU {23305490-94C3-7131-087F-54EDF910A7ED}.Release|x86.Build.0 = Release|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Debug|Any CPU.Build.0 = Debug|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Debug|ARM.ActiveCfg = Debug|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Debug|ARM.Build.0 = Debug|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Debug|x64.ActiveCfg = Debug|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Debug|x64.Build.0 = Debug|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Debug|x86.ActiveCfg = Debug|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Debug|x86.Build.0 = Debug|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Release|Any CPU.ActiveCfg = Release|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Release|Any CPU.Build.0 = Release|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Release|ARM.ActiveCfg = Release|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Release|ARM.Build.0 = Release|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Release|x64.ActiveCfg = Release|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Release|x64.Build.0 = Release|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Release|x86.ActiveCfg = Release|Any CPU + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -315,6 +333,7 @@ Global {55ED7170-CB15-0A50-9C4F-C3A0188E150B} = {FA3CFAFC-3218-487F-837B-E8755672EA27} {BE9DED31-FAE3-F798-BD79-7BA96883D07A} = {FA3CFAFC-3218-487F-837B-E8755672EA27} {23305490-94C3-7131-087F-54EDF910A7ED} = {FA3CFAFC-3218-487F-837B-E8755672EA27} + {89F9BB4A-1D8C-4A9C-986F-40E5757C3D51} = {FA3CFAFC-3218-487F-837B-E8755672EA27} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6E9C2873-AFF9-4D32-A784-1BA094814054} diff --git a/Refit/RequestBuilderImplementation.cs b/Refit/RequestBuilderImplementation.cs index fc72ec29a..f2b3f9df4 100644 --- a/Refit/RequestBuilderImplementation.cs +++ b/Refit/RequestBuilderImplementation.cs @@ -265,32 +265,77 @@ RestMethodInfoInternal CloseGenericMethodIfNeeded( return (client, args) => rxFunc!.DynamicInvoke(client, args); } - // Synchronous return types: build a sync wrapper that awaits internally and returns the value - var syncFuncMi = typeof(RequestBuilderImplementation).GetMethod( - nameof(BuildSyncFuncForMethod), - BindingFlags.NonPublic | BindingFlags.Instance - ); - var syncFunc = (MulticastDelegate?) - ( - syncFuncMi!.MakeGenericMethod( - restMethod.ReturnResultType, - restMethod.DeserializedResultType - ) - ).Invoke(this, [restMethod]); + var isExplicitInterfaceMember = restMethod.MethodInfo.Name.IndexOf('.') >= 0; + var isNonPublic = !restMethod.MethodInfo.IsPublic; + if (isExplicitInterfaceMember || isNonPublic) + { + return BuildGeneratedSyncFuncForMethod(restMethod); + } - return (client, args) => syncFunc!.DynamicInvoke(client, args); + throw new ArgumentException( + $"Method \"{restMethod.MethodInfo.Name}\" is invalid. All REST Methods must return either Task or ValueTask or IObservable" + ); } - private Func BuildSyncFuncForMethod(RestMethodInfoInternal restMethod) + Func BuildGeneratedSyncFuncForMethod( + RestMethodInfoInternal restMethod + ) { - var taskFunc = BuildTaskFuncForMethod(restMethod); + var factory = BuildRequestFactoryForMethod( + restMethod, + string.Empty, + paramsContainsCancellationToken: false + ); + return (client, paramList) => { - var task = taskFunc(client, paramList); - return (object?)task.GetAwaiter().GetResult(); + using var rq = factory(paramList); + var resp = client.SendAsync(rq).GetAwaiter().GetResult(); + + try + { + if (restMethod.ReturnResultType == typeof(void)) + { + return null; + } + + return DeserializeSyncResponse(resp, restMethod.DeserializedResultType); + } + finally + { + resp.Dispose(); + } }; } + object? DeserializeSyncResponse(HttpResponseMessage response, Type resultType) + { + if (resultType == typeof(HttpContent)) + { + return response.Content; + } + + var deserializeMethod = typeof(RequestBuilderImplementation).GetMethod( + nameof(DeserializeSyncResponseGeneric), + BindingFlags.NonPublic | BindingFlags.Instance + )!; + + return deserializeMethod.MakeGenericMethod(resultType).Invoke(this, [response]); + } + + T? DeserializeSyncResponseGeneric(HttpResponseMessage response) + { + if (typeof(T) == typeof(string)) + { + return (T?)(object?)response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + } + + return settings.ContentSerializer + .FromHttpContentAsync(response.Content) + .GetAwaiter() + .GetResult(); + } + void AddMultipartItem( MultipartFormDataContent multiPartContent, string fileName, diff --git a/Refit/RestMethodInfo.cs b/Refit/RestMethodInfo.cs index 3c502e808..8fcda9432 100644 --- a/Refit/RestMethodInfo.cs +++ b/Refit/RestMethodInfo.cs @@ -673,11 +673,13 @@ void DetermineReturnTypeInfo(MethodInfo methodInfo) } else { - // Allow synchronous return types only for non-public or explicit interface members. - // This supports internal Refit methods and explicit interface members annotated with Refit attributes. + // Allow synchronous return types only for methods that are implemented by generated stubs + // (for example explicit/default interface implementations). Public top-level Refit methods must + // still use async-compatible return shapes. var isExplicitInterfaceMember = methodInfo.Name.IndexOf('.') >= 0; - var isNonPublic = !(methodInfo.IsPublic); - if (!(isExplicitInterfaceMember || isNonPublic)) + var isNonPublic = !methodInfo.IsPublic; + + if (!isExplicitInterfaceMember && !isNonPublic) { throw new ArgumentException( $"Method \"{methodInfo.Name}\" is invalid. All REST Methods must return either Task or ValueTask or IObservable" diff --git a/Refit/SystemTextJsonContentSerializer.cs b/Refit/SystemTextJsonContentSerializer.cs index e0dd9e4fb..d45eb6680 100644 --- a/Refit/SystemTextJsonContentSerializer.cs +++ b/Refit/SystemTextJsonContentSerializer.cs @@ -176,6 +176,9 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer sealed class NonGenericEnumConverter(Type targetType, Type enumType, bool isNullable) : JsonConverter { + readonly Dictionary namesToValues = GetNamesToValues(enumType); + readonly Dictionary valuesToNames = GetValuesToNames(enumType); + public override bool CanConvert(Type typeToConvert) => typeToConvert == targetType; public override object? Read( @@ -203,20 +206,10 @@ JsonSerializerOptions options throw new JsonException($"Cannot convert an empty value to {targetType}."); } - foreach (var name in Enum.GetNames(enumType)) - { - if (string.Equals(ToCamelCase(name), value, StringComparison.Ordinal)) - return Enum.Parse(enumType, name, ignoreCase: false); - } + if (namesToValues.TryGetValue(value, out var namedValue)) + return namedValue; - try - { - return Enum.Parse(enumType, value, ignoreCase: true); - } - catch (ArgumentException) - { - throw new JsonException($"Unable to convert '{value}' to {targetType}."); - } + throw new JsonException($"Unable to convert '{value}' to {targetType}."); } if (reader.TokenType == JsonTokenType.Number) @@ -240,14 +233,61 @@ JsonSerializerOptions options return; } - var name = Enum.GetName(enumType, value); - if (name is null) + if (!valuesToNames.TryGetValue(value, out var name)) { writer.WriteNumberValue(Convert.ToInt64(value)); return; } - writer.WriteStringValue(ToCamelCase(name)); + writer.WriteStringValue(name); + } + + static Dictionary GetNamesToValues(Type enumType) + { + var map = new Dictionary(StringComparer.Ordinal); + + foreach (var field in enumType.GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var value = Enum.Parse(enumType, field.Name, ignoreCase: false); + foreach (var name in GetSerializedNames(field)) + { + map[name] = value; + } + } + + return map; + } + + static Dictionary GetValuesToNames(Type enumType) + { + var map = new Dictionary(); + + foreach (var field in enumType.GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var value = Enum.Parse(enumType, field.Name, ignoreCase: false); + map[value] = GetPreferredSerializedName(field); + } + + return map; + } + + static IEnumerable GetSerializedNames(FieldInfo field) + { + var preferredName = GetPreferredSerializedName(field); + yield return preferredName; + + if (!string.Equals(field.Name, preferredName, StringComparison.Ordinal)) + yield return field.Name; + } + + static string GetPreferredSerializedName(FieldInfo field) + { +#if NET9_0_OR_GREATER + var enumMemberNameAttribute = field.GetCustomAttribute(); + if (enumMemberNameAttribute is not null) + return enumMemberNameAttribute.Name; +#endif + return ToCamelCase(field.Name); } static string ToCamelCase(string value) => diff --git a/examples/BlazorWasmIssue2065/App.razor b/examples/BlazorWasmIssue2065/App.razor new file mode 100644 index 000000000..14c35973b --- /dev/null +++ b/examples/BlazorWasmIssue2065/App.razor @@ -0,0 +1,13 @@ +@using BlazorWasmIssue2065.Shared + + + + + + + + +

Not found

+
+
+
diff --git a/examples/BlazorWasmIssue2065/BlazorWasmIssue2065.csproj b/examples/BlazorWasmIssue2065/BlazorWasmIssue2065.csproj new file mode 100644 index 000000000..646ea44fa --- /dev/null +++ b/examples/BlazorWasmIssue2065/BlazorWasmIssue2065.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + true + Default + true + + + + + + + + + + + + + diff --git a/examples/BlazorWasmIssue2065/IIssue2065Api.cs b/examples/BlazorWasmIssue2065/IIssue2065Api.cs new file mode 100644 index 000000000..3b21d057e --- /dev/null +++ b/examples/BlazorWasmIssue2065/IIssue2065Api.cs @@ -0,0 +1,9 @@ +using Refit; + +namespace BlazorWasmIssue2065; + +public interface IIssue2065Api +{ + [Get("/sample-data/weather.json")] + Task GetPayload(); +} diff --git a/examples/BlazorWasmIssue2065/IIssue2067Api.cs b/examples/BlazorWasmIssue2065/IIssue2067Api.cs new file mode 100644 index 000000000..2efd90305 --- /dev/null +++ b/examples/BlazorWasmIssue2065/IIssue2067Api.cs @@ -0,0 +1,22 @@ +using Refit; + +namespace BlazorWasmIssue2065; + +internal interface IIssue2067Api +{ + [Get("/sample-data/status.json")] + Task GetStatusAsync(); +} + +internal sealed class Issue2067Response +{ + public Issue2067Status Status { get; set; } +} + +internal enum Issue2067Status +{ + [System.Text.Json.Serialization.JsonStringEnumMemberName("totally-ready")] + TotallyReady, + + NeedsReview +} diff --git a/examples/BlazorWasmIssue2065/Pages/Index.razor b/examples/BlazorWasmIssue2065/Pages/Index.razor new file mode 100644 index 000000000..98b18ff7a --- /dev/null +++ b/examples/BlazorWasmIssue2065/Pages/Index.razor @@ -0,0 +1,31 @@ +@page "/" +@inject IIssue2065Api Api + +Issue 2065 + +

Issue 2065

+ +

This sample proves Refit works inside Blazor WebAssembly without blocking sync waits.

+ + + +

@status

+ +@code { + private string status = "Ready"; + + private async Task CallApi() + { + status = "Calling..."; + + try + { + var payload = await Api.GetPayload(); + status = $"Success: {payload}"; + } + catch (Exception ex) + { + status = $"Failure: {ex.GetType().Name} - {ex.Message}"; + } + } +} diff --git a/examples/BlazorWasmIssue2065/Pages/Issue2067.razor b/examples/BlazorWasmIssue2065/Pages/Issue2067.razor new file mode 100644 index 000000000..589b74f8c --- /dev/null +++ b/examples/BlazorWasmIssue2065/Pages/Issue2067.razor @@ -0,0 +1,31 @@ +@page "/issue2067" +@inject IIssue2067Api Api + +Issue 2067 + +

Issue 2067

+ +

This sample proves Refit's default System.Text.Json enum converter supports JsonStringEnumMemberName.

+ + + +

@status

+ +@code { + private string status = "Ready"; + + private async Task CallApi() + { + status = "Calling..."; + + try + { + var payload = await Api.GetStatusAsync(); + status = $"Success: {payload.Status}"; + } + catch (Exception ex) + { + status = $"Failure: {ex.GetType().Name} - {ex.Message}"; + } + } +} diff --git a/examples/BlazorWasmIssue2065/Program.cs b/examples/BlazorWasmIssue2065/Program.cs new file mode 100644 index 000000000..89c3260dc --- /dev/null +++ b/examples/BlazorWasmIssue2065/Program.cs @@ -0,0 +1,29 @@ +using BlazorWasmIssue2065; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Refit; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddScoped(_ => new HttpClient +{ + BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) +}); + +builder.Services.AddScoped(sp => + RestService.For(sp.GetRequiredService()) +); +builder.Services.AddScoped(sp => + RestService.For( + sp.GetRequiredService(), + new RefitSettings( + new SystemTextJsonContentSerializer( + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions() + ) + ) + ) +); + +await builder.Build().RunAsync(); diff --git a/examples/BlazorWasmIssue2065/Shared/MainLayout.razor b/examples/BlazorWasmIssue2065/Shared/MainLayout.razor new file mode 100644 index 000000000..dcac09d8e --- /dev/null +++ b/examples/BlazorWasmIssue2065/Shared/MainLayout.razor @@ -0,0 +1,7 @@ +@inherits LayoutComponentBase + +
+
+ @Body +
+
diff --git a/examples/BlazorWasmIssue2065/Shared/_Imports.razor b/examples/BlazorWasmIssue2065/Shared/_Imports.razor new file mode 100644 index 000000000..d2616004e --- /dev/null +++ b/examples/BlazorWasmIssue2065/Shared/_Imports.razor @@ -0,0 +1 @@ +@namespace BlazorWasmIssue2065.Shared diff --git a/examples/BlazorWasmIssue2065/_Imports.razor b/examples/BlazorWasmIssue2065/_Imports.razor new file mode 100644 index 000000000..e2dff0fd7 --- /dev/null +++ b/examples/BlazorWasmIssue2065/_Imports.razor @@ -0,0 +1,9 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using BlazorWasmIssue2065 diff --git a/examples/BlazorWasmIssue2065/wwwroot/index.html b/examples/BlazorWasmIssue2065/wwwroot/index.html new file mode 100644 index 000000000..d13941ef6 --- /dev/null +++ b/examples/BlazorWasmIssue2065/wwwroot/index.html @@ -0,0 +1,13 @@ + + + + + + BlazorWasmIssue2065 + + + +
Loading...
+ + + diff --git a/examples/BlazorWasmIssue2065/wwwroot/sample-data/status.json b/examples/BlazorWasmIssue2065/wwwroot/sample-data/status.json new file mode 100644 index 000000000..ce6aa6f4a --- /dev/null +++ b/examples/BlazorWasmIssue2065/wwwroot/sample-data/status.json @@ -0,0 +1,3 @@ +{ + "status": "totally-ready" +} diff --git a/examples/BlazorWasmIssue2065/wwwroot/sample-data/weather.json b/examples/BlazorWasmIssue2065/wwwroot/sample-data/weather.json new file mode 100644 index 000000000..90ef3cff0 --- /dev/null +++ b/examples/BlazorWasmIssue2065/wwwroot/sample-data/weather.json @@ -0,0 +1 @@ +"Blazor WASM Refit call completed" From 2de8036ca2462c326be475b450b71e1f87ec0179 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Wed, 25 Mar 2026 16:03:50 +0000 Subject: [PATCH 2/6] Support case-insensitive enum parsing Enhance the System.Text.Json enum converter to attempt case-insensitive name lookups by adding a second names-to-values map using StringComparer.OrdinalIgnoreCase and making GetNamesToValues accept a comparer. This makes deserialization resilient to case differences in incoming JSON. Also update the BlazorWasm example project file: replace NoPack with IsPackable=false, bump Microsoft.AspNetCore.Components.WebAssembly packages from 10.0.0 to 10.0.5, and adjust file encoding. --- Refit/SystemTextJsonContentSerializer.cs | 19 ++++++++++++++++--- .../BlazorWasmIssue2065.csproj | 8 ++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Refit/SystemTextJsonContentSerializer.cs b/Refit/SystemTextJsonContentSerializer.cs index d45eb6680..c62516c22 100644 --- a/Refit/SystemTextJsonContentSerializer.cs +++ b/Refit/SystemTextJsonContentSerializer.cs @@ -176,7 +176,14 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer sealed class NonGenericEnumConverter(Type targetType, Type enumType, bool isNullable) : JsonConverter { - readonly Dictionary namesToValues = GetNamesToValues(enumType); + readonly Dictionary namesToValues = GetNamesToValues( + enumType, + StringComparer.Ordinal + ); + readonly Dictionary namesToValuesIgnoreCase = GetNamesToValues( + enumType, + StringComparer.OrdinalIgnoreCase + ); readonly Dictionary valuesToNames = GetValuesToNames(enumType); public override bool CanConvert(Type typeToConvert) => typeToConvert == targetType; @@ -209,6 +216,9 @@ JsonSerializerOptions options if (namesToValues.TryGetValue(value, out var namedValue)) return namedValue; + if (namesToValuesIgnoreCase.TryGetValue(value, out var namedValueIgnoreCase)) + return namedValueIgnoreCase; + throw new JsonException($"Unable to convert '{value}' to {targetType}."); } @@ -242,9 +252,12 @@ JsonSerializerOptions options writer.WriteStringValue(name); } - static Dictionary GetNamesToValues(Type enumType) + static Dictionary GetNamesToValues( + Type enumType, + StringComparer comparer + ) { - var map = new Dictionary(StringComparer.Ordinal); + var map = new Dictionary(comparer); foreach (var field in enumType.GetFields(BindingFlags.Public | BindingFlags.Static)) { diff --git a/examples/BlazorWasmIssue2065/BlazorWasmIssue2065.csproj b/examples/BlazorWasmIssue2065/BlazorWasmIssue2065.csproj index 646ea44fa..035301dd6 100644 --- a/examples/BlazorWasmIssue2065/BlazorWasmIssue2065.csproj +++ b/examples/BlazorWasmIssue2065/BlazorWasmIssue2065.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -6,12 +6,12 @@ enable true Default - true + false - - + + From b08ffc2183e9115694a1c37b5ae310328e4ce62a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:59:47 +0000 Subject: [PATCH 3/6] Add tests for CamelCaseStringEnumConverter case-insensitive deserialization (#2069) * Initial plan * Add case-insensitive enum deserialization tests for CamelCaseStringEnumConverter Co-authored-by: ChrisPulman <4910015+ChrisPulman@users.noreply.github.com> Agent-Logs-Url: https://github.com/reactiveui/refit/sessions/457806a0-8430-40b3-8e4e-26f6590217ff --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ChrisPulman <4910015+ChrisPulman@users.noreply.github.com> --- Refit.Tests/SerializedContentTests.cs | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/Refit.Tests/SerializedContentTests.cs b/Refit.Tests/SerializedContentTests.cs index a9d4ebea1..afaba9639 100644 --- a/Refit.Tests/SerializedContentTests.cs +++ b/Refit.Tests/SerializedContentTests.cs @@ -520,6 +520,54 @@ public void SystemTextJsonContentSerializer_DefaultOptions_SerializeLowercaseEnu Assert.Equal("\"alreadyLowercase\"", json); } + [Theory] + [InlineData("vAlUeOnE")] + [InlineData("ValueOne")] + [InlineData("VALUEONE")] + [InlineData("valueone")] + public void SystemTextJsonContentSerializer_DefaultOptions_DeserializesEnumValuesWithVariousCasings( + string jsonValue + ) + { + var result = SystemTextJsonSerializer.Deserialize( + $"\"{jsonValue}\"", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions() + ); + + Assert.Equal(CamelCaseEnum.ValueOne, result); + } + + [Fact] + public void SystemTextJsonContentSerializer_DefaultOptions_ExactCaseMatchTakesPriorityOverCaseInsensitiveWhenMembersDifferByCase() + { + // When enum has members whose names differ only by case, the exact serialized form + // (camelCase) should be used first (case-sensitive), falling back to case-insensitive only + // for inputs that do not exactly match any known serialized form. + + // CaseDifferentMembers.Alpha serializes to "alpha" (camelCase), + // CaseDifferentMembers.ALPHA serializes to "aLPHA" (camelCase). + // Exact-match lookups must correctly disambiguate these. + var options = SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions(); + + Assert.Equal( + CaseDifferentMembers.Alpha, + SystemTextJsonSerializer.Deserialize("\"alpha\"", options) + ); + Assert.Equal( + CaseDifferentMembers.ALPHA, + SystemTextJsonSerializer.Deserialize("\"aLPHA\"", options) + ); + // Field names are also accepted via exact match + Assert.Equal( + CaseDifferentMembers.Alpha, + SystemTextJsonSerializer.Deserialize("\"Alpha\"", options) + ); + Assert.Equal( + CaseDifferentMembers.ALPHA, + SystemTextJsonSerializer.Deserialize("\"ALPHA\"", options) + ); + } + [Fact] public async Task SystemTextJsonContentSerializer_UsesSourceGeneratedMetadataWhenProvided() { @@ -759,6 +807,14 @@ enum CamelCaseEnum alreadyLowercase = 2 } + // Members Alpha and ALPHA differ only by case; this enum is used to verify that + // the case-sensitive lookup takes priority and the correct member is chosen. + enum CaseDifferentMembers + { + Alpha = 1, + ALPHA = 2, + } + sealed class AsyncOnlyJsonContent(string json) : HttpContent { readonly byte[] _bytes = Encoding.UTF8.GetBytes(json); From 2df9244db344bd8335a2888794e8157b7bb0c0ad Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:05:29 +0000 Subject: [PATCH 4/6] Fix sync method pipeline to match async behavior (ExceptionFactory, IApiResponse, disposal) (#2070) * Initial plan * Fix sync pipeline to replicate full BuildCancellableTaskFuncForMethod behavior - Rewrite BuildGeneratedSyncFuncForMethod as a dispatcher that handles void return separately and delegates non-void to a new generic method. - Add BuildGeneratedSyncFuncForMethodGeneric that mirrors the full async pipeline: runs ExceptionFactory (skipped for HttpResponseMessage), honours IsApiResponse, uses ShouldDisposeResponse for cleanup, and surfaces DeserializationExceptionFactory on deserialization errors. - Add DeserializeContentSync handling all content types: HttpResponseMessage, HttpContent, Stream, string, and deserialised T. - Fix RestMethodInfo sync branch to properly decompose IApiResponse / ApiResponse return types, setting DeserializedResultType to the inner type just like the async Task> path does. - Add SyncVoid to ReturnTypeInfo enum and handle it in Parser.cs and Emitter.cs so void-returning sync stub methods emit a plain call instead of the invalid 'return (void)...' pattern. - Add comprehensive tests covering error responses, HttpResponseMessage, HttpContent, Stream, IApiResponse, and void sync methods. Co-authored-by: ChrisPulman <4910015+ChrisPulman@users.noreply.github.com> Agent-Logs-Url: https://github.com/reactiveui/refit/sessions/7e184030-9d18-44ec-aff4-305b2c7e973d * Fix spelling: 'occured' -> 'occurred' in new sync error messages Co-authored-by: ChrisPulman <4910015+ChrisPulman@users.noreply.github.com> Agent-Logs-Url: https://github.com/reactiveui/refit/sessions/7e184030-9d18-44ec-aff4-305b2c7e973d --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ChrisPulman <4910015+ChrisPulman@users.noreply.github.com> --- InterfaceStubGenerator.Shared/Emitter.cs | 7 +- .../Models/MethodModel.cs | 3 +- InterfaceStubGenerator.Shared/Parser.cs | 2 + Refit.Tests/ExplicitInterfaceRefitTests.cs | 182 ++++++++++++++++- Refit/RequestBuilderImplementation.cs | 192 +++++++++++++++--- Refit/RestMethodInfo.cs | 22 +- 6 files changed, 373 insertions(+), 35 deletions(-) diff --git a/InterfaceStubGenerator.Shared/Emitter.cs b/InterfaceStubGenerator.Shared/Emitter.cs index f8364a2fc..e6daadbfe 100644 --- a/InterfaceStubGenerator.Shared/Emitter.cs +++ b/InterfaceStubGenerator.Shared/Emitter.cs @@ -188,6 +188,7 @@ UniqueNameBuilder uniqueNames ReturnTypeInfo.AsyncVoid => (true, "await (", ").ConfigureAwait(false)"), ReturnTypeInfo.AsyncResult => (true, "return await (", ").ConfigureAwait(false)"), ReturnTypeInfo.Return => (false, "return ", ""), + ReturnTypeInfo.SyncVoid => (false, "", ""), _ => throw new ArgumentOutOfRangeException( nameof(methodModel.ReturnTypeMetadata), methodModel.ReturnTypeMetadata, @@ -228,12 +229,16 @@ UniqueNameBuilder uniqueNames lookupName = lookupName.Substring(lastDotIndex + 1); } + var callExpression = methodModel.ReturnTypeMetadata == ReturnTypeInfo.SyncVoid + ? $"______func(this.Client, ______arguments);" + : $"{@return}({returnType})______func(this.Client, ______arguments){configureAwait};"; + source.WriteLine( $""" var ______arguments = {argumentsArrayString}; var ______func = requestBuilder.BuildRestResultFuncForMethod("{lookupName}", {parameterTypesExpression}{genericString} ); - {@return}({returnType})______func(this.Client, ______arguments){configureAwait}; + {callExpression} """ ); diff --git a/InterfaceStubGenerator.Shared/Models/MethodModel.cs b/InterfaceStubGenerator.Shared/Models/MethodModel.cs index 6f6170c10..3a8163f00 100644 --- a/InterfaceStubGenerator.Shared/Models/MethodModel.cs +++ b/InterfaceStubGenerator.Shared/Models/MethodModel.cs @@ -17,5 +17,6 @@ internal enum ReturnTypeInfo : byte { Return, AsyncVoid, - AsyncResult + AsyncResult, + SyncVoid } diff --git a/InterfaceStubGenerator.Shared/Parser.cs b/InterfaceStubGenerator.Shared/Parser.cs index ebd7e6dee..3b7cda4c2 100644 --- a/InterfaceStubGenerator.Shared/Parser.cs +++ b/InterfaceStubGenerator.Shared/Parser.cs @@ -462,6 +462,7 @@ bool isDerived { "Task" => ReturnTypeInfo.AsyncVoid, "Task`1" or "ValueTask`1" => ReturnTypeInfo.AsyncResult, + "Void" => ReturnTypeInfo.SyncVoid, _ => ReturnTypeInfo.Return, }; @@ -623,6 +624,7 @@ private static MethodModel ParseMethod(IMethodSymbol methodSymbol, bool isImplic { "Task" => ReturnTypeInfo.AsyncVoid, "Task`1" or "ValueTask`1" => ReturnTypeInfo.AsyncResult, + "Void" => ReturnTypeInfo.SyncVoid, _ => ReturnTypeInfo.Return, }; diff --git a/Refit.Tests/ExplicitInterfaceRefitTests.cs b/Refit.Tests/ExplicitInterfaceRefitTests.cs index a07d3ab96..1cc7da51b 100644 --- a/Refit.Tests/ExplicitInterfaceRefitTests.cs +++ b/Refit.Tests/ExplicitInterfaceRefitTests.cs @@ -1,4 +1,6 @@ -using System.Net.Http; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; using System.Threading.Tasks; using Refit; using RichardSzalay.MockHttp; @@ -35,6 +37,31 @@ public interface IRemoteFoo2 : IFoo abstract int IFoo.Bar(); } + // Interfaces used to test the full sync pipeline + public interface ISyncPipelineApi + { + [Get("/resource")] + internal string GetString(); + + [Get("/resource")] + internal HttpResponseMessage GetHttpResponseMessage(); + + [Get("/resource")] + internal HttpContent GetHttpContent(); + + [Get("/resource")] + internal Stream GetStream(); + + [Get("/resource")] + internal IApiResponse GetApiResponse(); + + [Get("/resource")] + internal IApiResponse GetRawApiResponse(); + + [Get("/resource")] + internal void DoVoid(); + } + [Fact] public void DefaultInterfaceImplementation_calls_internal_refit_method() { @@ -70,4 +97,157 @@ public void Explicit_interface_member_with_refit_attribute_is_invoked() mockHttp.VerifyNoOutstandingExpectation(); } + + [Fact] + public void Sync_method_throws_ApiException_on_error_response() + { + var mockHttp = new SyncCapableMockHttpMessageHandler(); + var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; + + mockHttp + .Expect(HttpMethod.Get, "http://foo/resource") + .Respond(HttpStatusCode.NotFound); + + var fixture = RestService.For("http://foo", settings); + + var ex = Assert.Throws(() => fixture.GetString()); + Assert.Equal(HttpStatusCode.NotFound, ex.StatusCode); + + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public void Sync_method_returns_HttpResponseMessage_without_running_ExceptionFactory() + { + var mockHttp = new SyncCapableMockHttpMessageHandler(); + var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; + + mockHttp + .Expect(HttpMethod.Get, "http://foo/resource") + .Respond(HttpStatusCode.NotFound); + + var fixture = RestService.For("http://foo", settings); + + // Should not throw even for a 404 – caller owns the response + using var resp = fixture.GetHttpResponseMessage(); + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public void Sync_method_returns_HttpContent_without_disposing_response() + { + var mockHttp = new SyncCapableMockHttpMessageHandler(); + var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; + + mockHttp + .Expect(HttpMethod.Get, "http://foo/resource") + .Respond("text/plain", "hello"); + + var fixture = RestService.For("http://foo", settings); + + var content = fixture.GetHttpContent(); + Assert.NotNull(content); + var text = content.ReadAsStringAsync().GetAwaiter().GetResult(); + Assert.Equal("hello", text); + + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public void Sync_method_returns_Stream_without_disposing_response() + { + var mockHttp = new SyncCapableMockHttpMessageHandler(); + var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; + + mockHttp + .Expect(HttpMethod.Get, "http://foo/resource") + .Respond("text/plain", "hello"); + + var fixture = RestService.For("http://foo", settings); + + using var stream = fixture.GetStream(); + Assert.NotNull(stream); + using var reader = new StreamReader(stream); + Assert.Equal("hello", reader.ReadToEnd()); + + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public void Sync_method_returns_IApiResponse_with_error_on_bad_status() + { + var mockHttp = new SyncCapableMockHttpMessageHandler(); + var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; + + mockHttp + .Expect(HttpMethod.Get, "http://foo/resource") + .Respond(HttpStatusCode.InternalServerError); + + var fixture = RestService.For("http://foo", settings); + + using var apiResp = fixture.GetApiResponse(); + Assert.False(apiResp.IsSuccessStatusCode); + Assert.NotNull(apiResp.Error); + Assert.Equal(HttpStatusCode.InternalServerError, apiResp.Error!.StatusCode); + + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public void Sync_method_returns_IApiResponse_with_content_on_success() + { + var mockHttp = new SyncCapableMockHttpMessageHandler(); + var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; + + mockHttp + .Expect(HttpMethod.Get, "http://foo/resource") + .Respond("application/json", "\"hello\""); + + var fixture = RestService.For("http://foo", settings); + + using var apiResp = fixture.GetApiResponse(); + Assert.True(apiResp.IsSuccessStatusCode); + Assert.Null(apiResp.Error); + // The string branch reads the raw stream (no JSON unwrapping), same as the async path + Assert.Equal("\"hello\"", apiResp.Content); + + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public void Sync_void_method_throws_ApiException_on_error_response() + { + var mockHttp = new SyncCapableMockHttpMessageHandler(); + var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; + + mockHttp + .Expect(HttpMethod.Get, "http://foo/resource") + .Respond(HttpStatusCode.BadRequest); + + var fixture = RestService.For("http://foo", settings); + + var ex = Assert.Throws(() => fixture.DoVoid()); + Assert.Equal(HttpStatusCode.BadRequest, ex.StatusCode); + + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public void Sync_void_method_succeeds_on_ok_response() + { + var mockHttp = new SyncCapableMockHttpMessageHandler(); + var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; + + mockHttp + .Expect(HttpMethod.Get, "http://foo/resource") + .Respond(HttpStatusCode.OK); + + var fixture = RestService.For("http://foo", settings); + + fixture.DoVoid(); // should not throw + + mockHttp.VerifyNoOutstandingExpectation(); + } } diff --git a/Refit/RequestBuilderImplementation.cs b/Refit/RequestBuilderImplementation.cs index f2b3f9df4..fea2179bd 100644 --- a/Refit/RequestBuilderImplementation.cs +++ b/Refit/RequestBuilderImplementation.cs @@ -281,57 +281,191 @@ RestMethodInfoInternal CloseGenericMethodIfNeeded( RestMethodInfoInternal restMethod ) { - var factory = BuildRequestFactoryForMethod( - restMethod, - string.Empty, - paramsContainsCancellationToken: false + // void return: mirror BuildVoidTaskFuncForMethod – run ExceptionFactory, return null + if (restMethod.ReturnResultType == typeof(void)) + { + return (client, paramList) => + { + if (client.BaseAddress == null) + throw new InvalidOperationException( + "BaseAddress must be set on the HttpClient instance" + ); + + var factory = BuildRequestFactoryForMethod( + restMethod, + client.BaseAddress.AbsolutePath, + paramsContainsCancellationToken: false + ); + using var rq = factory(paramList); + using var resp = client + .SendAsync(rq, HttpCompletionOption.ResponseHeadersRead) + .GetAwaiter() + .GetResult(); + var e = settings.ExceptionFactory(resp).GetAwaiter().GetResult(); + if (e != null) + throw e; + return null; + }; + } + + // Non-void: dispatch to the generic implementation that replicates the full + // BuildCancellableTaskFuncForMethod pipeline (ExceptionFactory, IsApiResponse, + // ShouldDisposeResponse, DeserializationExceptionFactory). + var syncFuncMi = typeof(RequestBuilderImplementation).GetMethod( + nameof(BuildGeneratedSyncFuncForMethodGeneric), + BindingFlags.NonPublic | BindingFlags.Instance ); + return (Func) + syncFuncMi! + .MakeGenericMethod( + restMethod.ReturnResultType, + restMethod.DeserializedResultType + ) + .Invoke(this, [restMethod])!; + } + Func BuildGeneratedSyncFuncForMethodGeneric( + RestMethodInfoInternal restMethod + ) + { return (client, paramList) => { - using var rq = factory(paramList); - var resp = client.SendAsync(rq).GetAwaiter().GetResult(); + if (client.BaseAddress == null) + throw new InvalidOperationException( + "BaseAddress must be set on the HttpClient instance" + ); + var factory = BuildRequestFactoryForMethod( + restMethod, + client.BaseAddress.AbsolutePath, + paramsContainsCancellationToken: false + ); + var rq = factory(paramList); + HttpResponseMessage? resp = null; + HttpContent? content = null; + var disposeResponse = true; try { - if (restMethod.ReturnResultType == typeof(void)) + if (IsBodyBuffered(restMethod, rq)) + { + rq.Content!.LoadIntoBufferAsync().GetAwaiter().GetResult(); + } + resp = client + .SendAsync(rq, HttpCompletionOption.ResponseHeadersRead) + .GetAwaiter() + .GetResult(); + content = resp.Content ?? new StringContent(string.Empty); + Exception? e = null; + disposeResponse = restMethod.ShouldDisposeResponse; + + if (typeof(T) != typeof(HttpResponseMessage)) { - return null; + e = settings.ExceptionFactory(resp).GetAwaiter().GetResult(); } - return DeserializeSyncResponse(resp, restMethod.DeserializedResultType); + if (restMethod.IsApiResponse) + { + var body = default(TBody); + try + { + body = + e == null + ? DeserializeContentSync(resp, content) + : default; + } + catch (Exception ex) + { + if (settings.DeserializationExceptionFactory != null) + e = settings + .DeserializationExceptionFactory(resp, ex) + .GetAwaiter() + .GetResult(); + else + { + e = ApiException + .Create( + "An error occurred deserializing the response.", + resp.RequestMessage!, + resp.RequestMessage!.Method, + resp, + settings, + ex + ) + .GetAwaiter() + .GetResult(); + } + } + + return ApiResponse.Create(resp, body, settings, e as ApiException); + } + else if (e != null) + { + disposeResponse = false; // caller must dispose + throw e; + } + else + { + try + { + return DeserializeContentSync(resp, content); + } + catch (Exception ex) + { + if (settings.DeserializationExceptionFactory != null) + { + var customEx = settings + .DeserializationExceptionFactory(resp, ex) + .GetAwaiter() + .GetResult(); + if (customEx != null) + throw customEx; + return default; + } + else + { + throw ApiException + .Create( + "An error occurred deserializing the response.", + resp.RequestMessage!, + resp.RequestMessage!.Method, + resp, + settings, + ex + ) + .GetAwaiter() + .GetResult(); + } + } + } } finally { - resp.Dispose(); + rq.Dispose(); + if (disposeResponse) + { + resp?.Dispose(); + content?.Dispose(); + } } }; } - object? DeserializeSyncResponse(HttpResponseMessage response, Type resultType) - { - if (resultType == typeof(HttpContent)) - { - return response.Content; - } - - var deserializeMethod = typeof(RequestBuilderImplementation).GetMethod( - nameof(DeserializeSyncResponseGeneric), - BindingFlags.NonPublic | BindingFlags.Instance - )!; - - return deserializeMethod.MakeGenericMethod(resultType).Invoke(this, [response]); - } - - T? DeserializeSyncResponseGeneric(HttpResponseMessage response) + T? DeserializeContentSync(HttpResponseMessage resp, HttpContent content) { + if (typeof(T) == typeof(HttpResponseMessage)) + return (T)(object)resp; + if (typeof(T) == typeof(HttpContent)) + return (T)(object)content; + if (typeof(T) == typeof(Stream)) + return (T)(object)content.ReadAsStreamAsync().GetAwaiter().GetResult(); if (typeof(T) == typeof(string)) { - return (T?)(object?)response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + using var stream = content.ReadAsStreamAsync().GetAwaiter().GetResult(); + using var reader = new StreamReader(stream); + return (T)(object)reader.ReadToEnd(); } - return settings.ContentSerializer - .FromHttpContentAsync(response.Content) + .FromHttpContentAsync(content) .GetAwaiter() .GetResult(); } diff --git a/Refit/RestMethodInfo.cs b/Refit/RestMethodInfo.cs index 8fcda9432..950394bb2 100644 --- a/Refit/RestMethodInfo.cs +++ b/Refit/RestMethodInfo.cs @@ -688,9 +688,25 @@ void DetermineReturnTypeInfo(MethodInfo methodInfo) ReturnType = methodInfo.ReturnType; ReturnResultType = methodInfo.ReturnType; - DeserializedResultType = methodInfo.ReturnType == typeof(IApiResponse) - ? typeof(HttpContent) - : methodInfo.ReturnType; + + if ( + ReturnResultType.IsGenericType + && ( + ReturnResultType.GetGenericTypeDefinition() == typeof(ApiResponse<>) + || ReturnResultType.GetGenericTypeDefinition() == typeof(IApiResponse<>) + ) + ) + { + DeserializedResultType = ReturnResultType.GetGenericArguments()[0]; + } + else if (ReturnResultType == typeof(IApiResponse)) + { + DeserializedResultType = typeof(HttpContent); + } + else + { + DeserializedResultType = ReturnResultType; + } } } From 7f5db394dcd33fe5461bea6f851d60e2116069b8 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Thu, 26 Mar 2026 20:38:15 +0000 Subject: [PATCH 5/6] Update Refit.Tests/ExplicitInterfaceRefitTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Refit.Tests/ExplicitInterfaceRefitTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Refit.Tests/ExplicitInterfaceRefitTests.cs b/Refit.Tests/ExplicitInterfaceRefitTests.cs index 1cc7da51b..90206f3ed 100644 --- a/Refit.Tests/ExplicitInterfaceRefitTests.cs +++ b/Refit.Tests/ExplicitInterfaceRefitTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http; -using System.Net.Http.Headers; using System.Threading.Tasks; using Refit; using RichardSzalay.MockHttp; From 6588d75b7b5b98e394d43f04a489a4c9fd151163 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Mon, 30 Mar 2026 00:41:56 +0100 Subject: [PATCH 6/6] Unify sync/async flow, preserve ApiResponse request Refactors request building/execution to centralize async logic and surface correct request metadata. Introduces RunSynchronous helpers and async implementations (ExecuteRequestAsync, ExecuteVoidRequestAsync, BuildRequestMessageForMethodAsync) so synchronous funcs invoke the same async pipeline. AuthorizationHeaderValueGetter is now awaited when building requests. ApiResponse creation now preserves the original HttpRequestMessage/RequestUri and fixes disposal/error/deserialization handling (including improved Exception/DeserializationExceptionFactory usage). Adds tests: awaiting auth header getter and ensuring generated sync ApiResponse preserves RequestMessage; updates expectations in explicit-interface tests to use HasResponseError and assert request properties. --- .../Refit.GeneratorTests.csproj | 7 +- .../AuthenticatedClientHandlerTests.cs | 28 ++ Refit.Tests/ExplicitInterfaceRefitTests.cs | 5 +- Refit.Tests/RequestBuilder.cs | 31 ++ Refit/Refit.csproj | 4 +- Refit/RequestBuilderImplementation.cs | 439 ++++++++++-------- examples/Meow.Common/Meow.Common.csproj | 3 +- examples/Meow/Meow.csproj | 1 + 8 files changed, 312 insertions(+), 206 deletions(-) diff --git a/Refit.GeneratorTests/Refit.GeneratorTests.csproj b/Refit.GeneratorTests/Refit.GeneratorTests.csproj index 9464d6af6..bfadad0d0 100644 --- a/Refit.GeneratorTests/Refit.GeneratorTests.csproj +++ b/Refit.GeneratorTests/Refit.GeneratorTests.csproj @@ -11,6 +11,7 @@ true $(NoWarn);CS1591;CA1819;CA2000;CA2007;CA1056;CA1707;CA1861;xUnit1031 + @@ -18,14 +19,12 @@ + - - - @@ -35,7 +34,7 @@ - + diff --git a/Refit.Tests/AuthenticatedClientHandlerTests.cs b/Refit.Tests/AuthenticatedClientHandlerTests.cs index 3f4476273..263285590 100644 --- a/Refit.Tests/AuthenticatedClientHandlerTests.cs +++ b/Refit.Tests/AuthenticatedClientHandlerTests.cs @@ -415,6 +415,34 @@ public async Task AuthorizationHeaderValueGetterIsUsedWhenSupplyingHttpClient() Assert.Equal("Ok", result); } + [Fact] + public async Task AuthorizationHeaderValueGetterCanAwaitWhenSupplyingHttpClient() + { + var handler = new MockHttpMessageHandler(); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://api") }; + + var settings = new RefitSettings + { + AuthorizationHeaderValueGetter = async (_, __) => + { + await Task.Yield(); + return "tokenValue"; + } + }; + + handler + .Expect(HttpMethod.Get, "http://api/auth") + .WithHeaders("Authorization", "Bearer tokenValue") + .Respond("text/plain", "Ok"); + + var fixture = RestService.For(httpClient, settings); + + var result = await fixture.GetAuthenticated(); + + handler.VerifyNoOutstandingExpectation(); + Assert.Equal("Ok", result); + } + [Fact] public async Task AuthorizationHeaderValueGetterDoesNotOverrideExplicitTokenWhenSupplyingHttpClient() { diff --git a/Refit.Tests/ExplicitInterfaceRefitTests.cs b/Refit.Tests/ExplicitInterfaceRefitTests.cs index 90206f3ed..c1af63a21 100644 --- a/Refit.Tests/ExplicitInterfaceRefitTests.cs +++ b/Refit.Tests/ExplicitInterfaceRefitTests.cs @@ -189,7 +189,8 @@ public void Sync_method_returns_IApiResponse_with_error_on_bad_status() using var apiResp = fixture.GetApiResponse(); Assert.False(apiResp.IsSuccessStatusCode); Assert.NotNull(apiResp.Error); - Assert.Equal(HttpStatusCode.InternalServerError, apiResp.Error!.StatusCode); + Assert.True(apiResp.HasResponseError(out var error)); + Assert.Equal(HttpStatusCode.InternalServerError, error.StatusCode); mockHttp.VerifyNoOutstandingExpectation(); } @@ -209,6 +210,8 @@ public void Sync_method_returns_IApiResponse_with_content_on_success() using var apiResp = fixture.GetApiResponse(); Assert.True(apiResp.IsSuccessStatusCode); Assert.Null(apiResp.Error); + Assert.Equal(HttpMethod.Get, apiResp.RequestMessage.Method); + Assert.Equal("http://foo/resource", apiResp.RequestMessage.RequestUri?.ToString()); // The string branch reads the raw stream (no JSON unwrapping), same as the async path Assert.Equal("\"hello\"", apiResp.Content); diff --git a/Refit.Tests/RequestBuilder.cs b/Refit.Tests/RequestBuilder.cs index 901043144..539a1d311 100644 --- a/Refit.Tests/RequestBuilder.cs +++ b/Refit.Tests/RequestBuilder.cs @@ -2443,6 +2443,37 @@ public void StreamResponseAsApiResponseTest() Assert.Equal(reponseContent, reader.ReadToEnd()); } + [Fact] + public void GeneratedSyncApiResponseShouldPreserveRequestMessage() + { + var fixture = new RequestBuilderImplementation(); + var restMethod = new RestMethodInfoInternal( + typeof(IDummyHttpApi), + typeof(IDummyHttpApi) + .GetMethods() + .First(x => x.Name == nameof(IDummyHttpApi.FetchSomeStringWithMetadata)) + ); + var buildGeneratedSyncFuncForMethod = typeof(RequestBuilderImplementation).GetMethod( + "BuildGeneratedSyncFuncForMethod", + BindingFlags.Instance | BindingFlags.NonPublic + ); + var factory = (Func) + buildGeneratedSyncFuncForMethod!.Invoke(fixture, [restMethod])!; + var testHttpMessageHandler = new TestHttpMessageHandler(); + + var response = (ApiResponse) + factory( + new HttpClient(testHttpMessageHandler) + { + BaseAddress = new Uri("http://api/") + }, + [42] + )!; + + Assert.Same(testHttpMessageHandler.RequestMessage, response.RequestMessage); + Assert.Equal(testHttpMessageHandler.RequestMessage.RequestUri, response.RequestMessage.RequestUri); + } + [Fact] public void StreamResponseTest() { diff --git a/Refit/Refit.csproj b/Refit/Refit.csproj index 5c40375c7..9c1c24c21 100644 --- a/Refit/Refit.csproj +++ b/Refit/Refit.csproj @@ -24,8 +24,8 @@ - - + + diff --git a/Refit/RequestBuilderImplementation.cs b/Refit/RequestBuilderImplementation.cs index f65412e58..191c3d2d3 100644 --- a/Refit/RequestBuilderImplementation.cs +++ b/Refit/RequestBuilderImplementation.cs @@ -256,7 +256,7 @@ RestMethodInfoInternal CloseGenericMethodIfNeeded( ); var rxFunc = (MulticastDelegate?) ( - rxFuncMi!.MakeGenericMethod( + rxFuncMi?.MakeGenericMethod( restMethod.ReturnResultType, restMethod.DeserializedResultType ) @@ -281,47 +281,34 @@ RestMethodInfoInternal CloseGenericMethodIfNeeded( RestMethodInfoInternal restMethod ) { - // void return: mirror BuildVoidTaskFuncForMethod – run ExceptionFactory, return null if (restMethod.ReturnResultType == typeof(void)) { return (client, paramList) => { - if (client.BaseAddress == null) - throw new InvalidOperationException( - "BaseAddress must be set on the HttpClient instance" - ); - - var factory = BuildRequestFactoryForMethod( - restMethod, - client.BaseAddress.AbsolutePath, - paramsContainsCancellationToken: false + RunSynchronous(() => + ExecuteVoidRequestAsync( + client, + restMethod, + paramList, + CancellationToken.None, + paramsContainsCancellationToken: false + ) ); - using var rq = factory(paramList); - using var resp = client - .SendAsync(rq, HttpCompletionOption.ResponseHeadersRead) - .GetAwaiter() - .GetResult(); - var e = settings.ExceptionFactory(resp).GetAwaiter().GetResult(); - if (e != null) - throw e; return null; }; } - // Non-void: dispatch to the generic implementation that replicates the full - // BuildCancellableTaskFuncForMethod pipeline (ExceptionFactory, IsApiResponse, - // ShouldDisposeResponse, DeserializationExceptionFactory). var syncFuncMi = typeof(RequestBuilderImplementation).GetMethod( nameof(BuildGeneratedSyncFuncForMethodGeneric), BindingFlags.NonPublic | BindingFlags.Instance ); return (Func) - syncFuncMi! + syncFuncMi .MakeGenericMethod( restMethod.ReturnResultType, restMethod.DeserializedResultType ) - .Invoke(this, [restMethod])!; + .Invoke(this, [restMethod]); } Func BuildGeneratedSyncFuncForMethodGeneric( @@ -329,145 +316,207 @@ RestMethodInfoInternal restMethod ) { return (client, paramList) => - { - if (client.BaseAddress == null) - throw new InvalidOperationException( - "BaseAddress must be set on the HttpClient instance" - ); + RunSynchronous(() => + ExecuteRequestAsync( + client, + restMethod, + paramList, + CancellationToken.None, + paramsContainsCancellationToken: false + ) + ); + } - var factory = BuildRequestFactoryForMethod( + static void RunSynchronous(Func taskFactory) => + Task.Run(taskFactory).GetAwaiter().GetResult(); + + static T? RunSynchronous(Func> taskFactory) => + Task.Run(taskFactory).GetAwaiter().GetResult(); + + async Task ExecuteVoidRequestAsync( + HttpClient client, + RestMethodInfoInternal restMethod, + object[] paramList, + CancellationToken cancellationToken, + bool paramsContainsCancellationToken + ) + { + if (client.BaseAddress == null) + throw new InvalidOperationException( + "BaseAddress must be set on the HttpClient instance" + ); + + using var rq = await BuildRequestMessageForMethodAsync( restMethod, client.BaseAddress.AbsolutePath, - paramsContainsCancellationToken: false + paramsContainsCancellationToken, + paramList + ) + .ConfigureAwait(false); + + if (IsBodyBuffered(restMethod, rq)) + { + await rq.Content!.LoadIntoBufferAsync().ConfigureAwait(false); + } + + using var resp = await client + .SendAsync(rq, cancellationToken) + .ConfigureAwait(false); + + var exception = await settings.ExceptionFactory(resp).ConfigureAwait(false); + if (exception != null) + { + throw exception; + } + } + + async Task ExecuteRequestAsync( + HttpClient client, + RestMethodInfoInternal restMethod, + object[] paramList, + CancellationToken cancellationToken, + bool paramsContainsCancellationToken + ) + { + if (client.BaseAddress == null) + throw new InvalidOperationException( + "BaseAddress must be set on the HttpClient instance" ); - var rq = factory(paramList); - HttpResponseMessage? resp = null; - HttpContent? content = null; - var disposeResponse = true; + + using var rq = await BuildRequestMessageForMethodAsync( + restMethod, + client.BaseAddress.AbsolutePath, + paramsContainsCancellationToken, + paramList + ) + .ConfigureAwait(false); + + HttpResponseMessage? resp = null; + HttpContent? content = null; + var disposeResponse = true; + try + { + if (IsBodyBuffered(restMethod, rq)) + { + await rq.Content!.LoadIntoBufferAsync().ConfigureAwait(false); + } + try { - if (IsBodyBuffered(restMethod, rq)) - { - rq.Content!.LoadIntoBufferAsync().GetAwaiter().GetResult(); - } - resp = client - .SendAsync(rq, HttpCompletionOption.ResponseHeadersRead) - .GetAwaiter() - .GetResult(); - content = resp.Content ?? new StringContent(string.Empty); - Exception? e = null; - disposeResponse = restMethod.ShouldDisposeResponse; + resp = await client + .SendAsync(rq, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + if (!restMethod.IsApiResponse) + throw new ApiRequestException(rq, rq.Method, settings, ex); + + return ApiResponse.Create( + rq, + resp, + default, + settings, + new ApiRequestException(rq, rq.Method, settings, ex) + ); + } - if (typeof(T) != typeof(HttpResponseMessage)) + content = resp.Content ?? new StringContent(string.Empty); + Exception? e = null; + disposeResponse = restMethod.ShouldDisposeResponse; + + if (typeof(T) != typeof(HttpResponseMessage)) + { + e = await settings.ExceptionFactory(resp).ConfigureAwait(false); + } + + if (restMethod.IsApiResponse) + { + var body = default(TBody); + + try { - e = settings.ExceptionFactory(resp).GetAwaiter().GetResult(); + body = + e == null + ? await DeserializeContentAsync( + resp, + content, + cancellationToken + ).ConfigureAwait(false) + : default; } - - if (restMethod.IsApiResponse) + catch (Exception ex) { - var body = default(TBody); - try + if (settings.DeserializationExceptionFactory != null) + e = await settings + .DeserializationExceptionFactory(resp, ex) + .ConfigureAwait(false); + else { - body = - e == null - ? DeserializeContentSync(resp, content) - : default; + e = await ApiException.Create( + "An error occured deserializing the response.", + resp.RequestMessage!, + resp.RequestMessage!.Method, + resp, + settings, + ex + ).ConfigureAwait(false); } - catch (Exception ex) - { - if (settings.DeserializationExceptionFactory != null) - e = settings - .DeserializationExceptionFactory(resp, ex) - .GetAwaiter() - .GetResult(); - else - { - e = ApiException - .Create( - "An error occurred deserializing the response.", - resp.RequestMessage!, - resp.RequestMessage!.Method, - resp, - settings, - ex - ) - .GetAwaiter() - .GetResult(); - } - } - - return ApiResponse.Create(resp, body, settings, e as ApiException); } - else if (e != null) + + return ApiResponse.Create( + rq, + resp, + body, + settings, + e as ApiException + ); + } + else if (e != null) + { + disposeResponse = false; // caller has to dispose + throw e; + } + else + { + try { - disposeResponse = false; // caller must dispose - throw e; + return await DeserializeContentAsync(resp, content, cancellationToken) + .ConfigureAwait(false); } - else + catch (Exception ex) { - try + if (settings.DeserializationExceptionFactory != null) { - return DeserializeContentSync(resp, content); + var customEx = await settings + .DeserializationExceptionFactory(resp, ex) + .ConfigureAwait(false); + if (customEx != null) + throw customEx; + return default; } - catch (Exception ex) + else { - if (settings.DeserializationExceptionFactory != null) - { - var customEx = settings - .DeserializationExceptionFactory(resp, ex) - .GetAwaiter() - .GetResult(); - if (customEx != null) - throw customEx; - return default; - } - else - { - throw ApiException - .Create( - "An error occurred deserializing the response.", - resp.RequestMessage!, - resp.RequestMessage!.Method, - resp, - settings, - ex - ) - .GetAwaiter() - .GetResult(); - } + throw await ApiException.Create( + "An error occured deserializing the response.", + resp.RequestMessage!, + resp.RequestMessage!.Method, + resp, + settings, + ex + ).ConfigureAwait(false); } } } - finally + } + finally + { + if (disposeResponse) { - rq.Dispose(); - if (disposeResponse) - { - resp?.Dispose(); - content?.Dispose(); - } + resp?.Dispose(); + content?.Dispose(); } - }; - } - - T? DeserializeContentSync(HttpResponseMessage resp, HttpContent content) - { - if (typeof(T) == typeof(HttpResponseMessage)) - return (T)(object)resp; - if (typeof(T) == typeof(HttpContent)) - return (T)(object)content; - if (typeof(T) == typeof(Stream)) - return (T)(object)content.ReadAsStreamAsync().GetAwaiter().GetResult(); - if (typeof(T) == typeof(string)) - { - using var stream = content.ReadAsStreamAsync().GetAwaiter().GetResult(); - using var reader = new StreamReader(stream); - return (T)(object)reader.ReadToEnd(); } - return settings.ContentSerializer - .FromHttpContentAsync(content) - .GetAwaiter() - .GetResult(); } void AddMultipartItem( @@ -903,28 +952,43 @@ static bool ShouldIgnorePropertyInQueryMap(PropertyInfo propertyInfo) return false; } - Func BuildRequestFactoryForMethod( + Func BuildRequestFactoryForMethod( RestMethodInfoInternal restMethod, string basePath, bool paramsContainsCancellationToken ) { return paramList => - { - var cancellationToken = CancellationToken.None; + RunSynchronous(() => + BuildRequestMessageForMethodAsync( + restMethod, + basePath, + paramsContainsCancellationToken, + paramList + ) + ); + } - // make sure we strip out any cancellation tokens - if (paramsContainsCancellationToken) - { - cancellationToken = paramList.OfType().FirstOrDefault(); - paramList = paramList - .Where(o => o == null || o.GetType() != typeof(CancellationToken)) - .ToArray(); - } + async Task BuildRequestMessageForMethodAsync( + RestMethodInfoInternal restMethod, + string basePath, + bool paramsContainsCancellationToken, + object[] paramList + ) + { + var cancellationToken = CancellationToken.None; - var ret = new HttpRequestMessage { Method = restMethod.HttpMethod }; + if (paramsContainsCancellationToken) + { + cancellationToken = paramList.OfType().FirstOrDefault(); + paramList = paramList + .Where(o => o == null || o.GetType() != typeof(CancellationToken)) + .ToArray(); + } - // set up multipart content + var ret = new HttpRequestMessage { Method = restMethod.HttpMethod }; + try + { MultipartFormDataContent? multiPartContent = null; if (restMethod.IsMultipart) { @@ -933,8 +997,8 @@ bool paramsContainsCancellationToken } List>? queryParamsToAdd = null; - var headersToAdd = restMethod.Headers.Count > 0 ? - new Dictionary(restMethod.Headers) + var headersToAdd = restMethod.Headers.Count > 0 + ? new Dictionary(restMethod.Headers) : null; RestMethodParameterInfo? parameterInfo = null; @@ -943,19 +1007,15 @@ bool paramsContainsCancellationToken { var isParameterMappedToRequest = false; var param = paramList[i]; - // if part of REST resource URL, substitute it in if (restMethod.ParameterMap.TryGetValue(i, out var parameterMapValue)) { parameterInfo = parameterMapValue; if (!parameterInfo.IsObjectPropertyParameter) { - // mark parameter mapped if not an object - // we want objects to fall through so any parameters on this object not bound here get passed as query parameters isParameterMappedToRequest = true; } } - // if marked as body, add to content if ( restMethod.BodyParameterInfo != null && restMethod.BodyParameterInfo.Item3 == i @@ -965,7 +1025,6 @@ bool paramsContainsCancellationToken isParameterMappedToRequest = true; } - // if header, add to request headers if (restMethod.HeaderParameterMap.TryGetValue(i, out var headerParameterValue)) { headersToAdd ??= []; @@ -973,7 +1032,6 @@ bool paramsContainsCancellationToken isParameterMappedToRequest = true; } - //if header collection, add to request headers if (restMethod.HeaderCollectionAt(i)) { if (param is IDictionary headerCollection) @@ -988,7 +1046,6 @@ bool paramsContainsCancellationToken isParameterMappedToRequest = true; } - //if authorize, add to request headers with scheme if ( restMethod.AuthorizeParameterInfo != null && restMethod.AuthorizeParameterInfo.Item2 == i @@ -1000,19 +1057,14 @@ bool paramsContainsCancellationToken isParameterMappedToRequest = true; } - //if property, add to populate into HttpRequestMessage.Properties if (restMethod.PropertyParameterMap.ContainsKey(i)) { isParameterMappedToRequest = true; } - // ignore nulls and already processed parameters if (isParameterMappedToRequest || param == null) continue; - // for anything that fell through to here, if this is not a multipart method add the parameter to the query string - // or if is an object bound to the path add any non-path bound properties to query string - // or if it's an object with a query attribute var queryAttribute = restMethod .ParameterInfoArray[i] .GetCustomAttribute(); @@ -1024,7 +1076,14 @@ bool paramsContainsCancellationToken ) { queryParamsToAdd ??= []; - AddQueryParameters(restMethod, queryAttribute, param, queryParamsToAdd, i, parameterInfo); + AddQueryParameters( + restMethod, + queryAttribute, + param, + queryParamsToAdd, + i, + parameterInfo + ); continue; } @@ -1032,17 +1091,13 @@ bool paramsContainsCancellationToken } AddHeadersToRequest(headersToAdd, ret); - AddAuthorizationHeadersFromGetterAsync(ret, cancellationToken) - .GetAwaiter() - .GetResult(); + await AddAuthorizationHeadersFromGetterAsync(ret, cancellationToken) + .ConfigureAwait(false); AddPropertiesToRequest(restMethod, ret, paramList); #if NET6_0_OR_GREATER AddVersionToRequest(ret); #endif - // NB: The URI methods in .NET are dumb. Also, we do this - // UriBuilder business so that we preserve any hardcoded query - // parameters as well as add the parameterized ones. var urlTarget = BuildRelativePath(basePath, restMethod, paramList); var uri = new UriBuilder(new Uri(BaseUri, urlTarget)); @@ -1062,7 +1117,12 @@ bool paramsContainsCancellationToken UriKind.Relative ); return ret; - }; + } + catch + { + ret.Dispose(); + throw; + } } string BuildRelativePath(string basePath, RestMethodInfoInternal restMethod, object[] paramList) @@ -1613,20 +1673,8 @@ Func BuildVoidTaskFuncForMethod( RestMethodInfoInternal restMethod ) { - return async (client, paramList) => + return (client, paramList) => { - if (client.BaseAddress == null) - throw new InvalidOperationException( - "BaseAddress must be set on the HttpClient instance" - ); - - var factory = BuildRequestFactoryForMethod( - restMethod, - client.BaseAddress.AbsolutePath, - restMethod.CancellationToken != null - ); - var rq = factory(paramList); - var ct = CancellationToken.None; if (restMethod.CancellationToken != null) @@ -1634,18 +1682,13 @@ RestMethodInfoInternal restMethod ct = paramList.OfType().FirstOrDefault(); } - // Load the data into buffer when body should be buffered. - if (IsBodyBuffered(restMethod, rq)) - { - await rq.Content!.LoadIntoBufferAsync().ConfigureAwait(false); - } - using var resp = await client.SendAsync(rq, ct).ConfigureAwait(false); - - var exception = await settings.ExceptionFactory(resp).ConfigureAwait(false); - if (exception != null) - { - throw exception; - } + return ExecuteVoidRequestAsync( + client, + restMethod, + paramList, + ct, + restMethod.CancellationToken != null + ); }; } diff --git a/examples/Meow.Common/Meow.Common.csproj b/examples/Meow.Common/Meow.Common.csproj index 2728a84b7..5b52a9bf6 100644 --- a/examples/Meow.Common/Meow.Common.csproj +++ b/examples/Meow.Common/Meow.Common.csproj @@ -1,10 +1,11 @@ - + net8.0 enable enable false + $(NoWarn);CS1591;IDE1006;CA1819;CS8618;CA1707;CA1056 diff --git a/examples/Meow/Meow.csproj b/examples/Meow/Meow.csproj index b428fe9b2..4ee710237 100644 --- a/examples/Meow/Meow.csproj +++ b/examples/Meow/Meow.csproj @@ -6,6 +6,7 @@ enable enable false + $(NoWarn);CS1591