Skip to content

Commit 2df9244

Browse files
CopilotChrisPulman
andauthored
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<T,TBody> 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<T> handling all content types: HttpResponseMessage, HttpContent, Stream, string, and deserialised T. - Fix RestMethodInfo sync branch to properly decompose IApiResponse<T> / ApiResponse<T> return types, setting DeserializedResultType to the inner type just like the async Task<IApiResponse<T>> 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<T>, 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>
1 parent b08ffc2 commit 2df9244

6 files changed

Lines changed: 373 additions & 35 deletions

File tree

InterfaceStubGenerator.Shared/Emitter.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ UniqueNameBuilder uniqueNames
188188
ReturnTypeInfo.AsyncVoid => (true, "await (", ").ConfigureAwait(false)"),
189189
ReturnTypeInfo.AsyncResult => (true, "return await (", ").ConfigureAwait(false)"),
190190
ReturnTypeInfo.Return => (false, "return ", ""),
191+
ReturnTypeInfo.SyncVoid => (false, "", ""),
191192
_ => throw new ArgumentOutOfRangeException(
192193
nameof(methodModel.ReturnTypeMetadata),
193194
methodModel.ReturnTypeMetadata,
@@ -228,12 +229,16 @@ UniqueNameBuilder uniqueNames
228229
lookupName = lookupName.Substring(lastDotIndex + 1);
229230
}
230231

232+
var callExpression = methodModel.ReturnTypeMetadata == ReturnTypeInfo.SyncVoid
233+
? $"______func(this.Client, ______arguments);"
234+
: $"{@return}({returnType})______func(this.Client, ______arguments){configureAwait};";
235+
231236
source.WriteLine(
232237
$"""
233238
var ______arguments = {argumentsArrayString};
234239
var ______func = requestBuilder.BuildRestResultFuncForMethod("{lookupName}", {parameterTypesExpression}{genericString} );
235240
236-
{@return}({returnType})______func(this.Client, ______arguments){configureAwait};
241+
{callExpression}
237242
"""
238243
);
239244

InterfaceStubGenerator.Shared/Models/MethodModel.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ internal enum ReturnTypeInfo : byte
1717
{
1818
Return,
1919
AsyncVoid,
20-
AsyncResult
20+
AsyncResult,
21+
SyncVoid
2122
}

InterfaceStubGenerator.Shared/Parser.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,7 @@ bool isDerived
462462
{
463463
"Task" => ReturnTypeInfo.AsyncVoid,
464464
"Task`1" or "ValueTask`1" => ReturnTypeInfo.AsyncResult,
465+
"Void" => ReturnTypeInfo.SyncVoid,
465466
_ => ReturnTypeInfo.Return,
466467
};
467468

@@ -623,6 +624,7 @@ private static MethodModel ParseMethod(IMethodSymbol methodSymbol, bool isImplic
623624
{
624625
"Task" => ReturnTypeInfo.AsyncVoid,
625626
"Task`1" or "ValueTask`1" => ReturnTypeInfo.AsyncResult,
627+
"Void" => ReturnTypeInfo.SyncVoid,
626628
_ => ReturnTypeInfo.Return,
627629
};
628630

Refit.Tests/ExplicitInterfaceRefitTests.cs

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
using System.Net.Http;
1+
using System.Net;
2+
using System.Net.Http;
3+
using System.Net.Http.Headers;
24
using System.Threading.Tasks;
35
using Refit;
46
using RichardSzalay.MockHttp;
@@ -35,6 +37,31 @@ public interface IRemoteFoo2 : IFoo
3537
abstract int IFoo.Bar();
3638
}
3739

40+
// Interfaces used to test the full sync pipeline
41+
public interface ISyncPipelineApi
42+
{
43+
[Get("/resource")]
44+
internal string GetString();
45+
46+
[Get("/resource")]
47+
internal HttpResponseMessage GetHttpResponseMessage();
48+
49+
[Get("/resource")]
50+
internal HttpContent GetHttpContent();
51+
52+
[Get("/resource")]
53+
internal Stream GetStream();
54+
55+
[Get("/resource")]
56+
internal IApiResponse<string> GetApiResponse();
57+
58+
[Get("/resource")]
59+
internal IApiResponse GetRawApiResponse();
60+
61+
[Get("/resource")]
62+
internal void DoVoid();
63+
}
64+
3865
[Fact]
3966
public void DefaultInterfaceImplementation_calls_internal_refit_method()
4067
{
@@ -70,4 +97,157 @@ public void Explicit_interface_member_with_refit_attribute_is_invoked()
7097

7198
mockHttp.VerifyNoOutstandingExpectation();
7299
}
100+
101+
[Fact]
102+
public void Sync_method_throws_ApiException_on_error_response()
103+
{
104+
var mockHttp = new SyncCapableMockHttpMessageHandler();
105+
var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp };
106+
107+
mockHttp
108+
.Expect(HttpMethod.Get, "http://foo/resource")
109+
.Respond(HttpStatusCode.NotFound);
110+
111+
var fixture = RestService.For<ISyncPipelineApi>("http://foo", settings);
112+
113+
var ex = Assert.Throws<ApiException>(() => fixture.GetString());
114+
Assert.Equal(HttpStatusCode.NotFound, ex.StatusCode);
115+
116+
mockHttp.VerifyNoOutstandingExpectation();
117+
}
118+
119+
[Fact]
120+
public void Sync_method_returns_HttpResponseMessage_without_running_ExceptionFactory()
121+
{
122+
var mockHttp = new SyncCapableMockHttpMessageHandler();
123+
var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp };
124+
125+
mockHttp
126+
.Expect(HttpMethod.Get, "http://foo/resource")
127+
.Respond(HttpStatusCode.NotFound);
128+
129+
var fixture = RestService.For<ISyncPipelineApi>("http://foo", settings);
130+
131+
// Should not throw even for a 404 – caller owns the response
132+
using var resp = fixture.GetHttpResponseMessage();
133+
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
134+
135+
mockHttp.VerifyNoOutstandingExpectation();
136+
}
137+
138+
[Fact]
139+
public void Sync_method_returns_HttpContent_without_disposing_response()
140+
{
141+
var mockHttp = new SyncCapableMockHttpMessageHandler();
142+
var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp };
143+
144+
mockHttp
145+
.Expect(HttpMethod.Get, "http://foo/resource")
146+
.Respond("text/plain", "hello");
147+
148+
var fixture = RestService.For<ISyncPipelineApi>("http://foo", settings);
149+
150+
var content = fixture.GetHttpContent();
151+
Assert.NotNull(content);
152+
var text = content.ReadAsStringAsync().GetAwaiter().GetResult();
153+
Assert.Equal("hello", text);
154+
155+
mockHttp.VerifyNoOutstandingExpectation();
156+
}
157+
158+
[Fact]
159+
public void Sync_method_returns_Stream_without_disposing_response()
160+
{
161+
var mockHttp = new SyncCapableMockHttpMessageHandler();
162+
var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp };
163+
164+
mockHttp
165+
.Expect(HttpMethod.Get, "http://foo/resource")
166+
.Respond("text/plain", "hello");
167+
168+
var fixture = RestService.For<ISyncPipelineApi>("http://foo", settings);
169+
170+
using var stream = fixture.GetStream();
171+
Assert.NotNull(stream);
172+
using var reader = new StreamReader(stream);
173+
Assert.Equal("hello", reader.ReadToEnd());
174+
175+
mockHttp.VerifyNoOutstandingExpectation();
176+
}
177+
178+
[Fact]
179+
public void Sync_method_returns_IApiResponse_with_error_on_bad_status()
180+
{
181+
var mockHttp = new SyncCapableMockHttpMessageHandler();
182+
var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp };
183+
184+
mockHttp
185+
.Expect(HttpMethod.Get, "http://foo/resource")
186+
.Respond(HttpStatusCode.InternalServerError);
187+
188+
var fixture = RestService.For<ISyncPipelineApi>("http://foo", settings);
189+
190+
using var apiResp = fixture.GetApiResponse();
191+
Assert.False(apiResp.IsSuccessStatusCode);
192+
Assert.NotNull(apiResp.Error);
193+
Assert.Equal(HttpStatusCode.InternalServerError, apiResp.Error!.StatusCode);
194+
195+
mockHttp.VerifyNoOutstandingExpectation();
196+
}
197+
198+
[Fact]
199+
public void Sync_method_returns_IApiResponse_with_content_on_success()
200+
{
201+
var mockHttp = new SyncCapableMockHttpMessageHandler();
202+
var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp };
203+
204+
mockHttp
205+
.Expect(HttpMethod.Get, "http://foo/resource")
206+
.Respond("application/json", "\"hello\"");
207+
208+
var fixture = RestService.For<ISyncPipelineApi>("http://foo", settings);
209+
210+
using var apiResp = fixture.GetApiResponse();
211+
Assert.True(apiResp.IsSuccessStatusCode);
212+
Assert.Null(apiResp.Error);
213+
// The string branch reads the raw stream (no JSON unwrapping), same as the async path
214+
Assert.Equal("\"hello\"", apiResp.Content);
215+
216+
mockHttp.VerifyNoOutstandingExpectation();
217+
}
218+
219+
[Fact]
220+
public void Sync_void_method_throws_ApiException_on_error_response()
221+
{
222+
var mockHttp = new SyncCapableMockHttpMessageHandler();
223+
var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp };
224+
225+
mockHttp
226+
.Expect(HttpMethod.Get, "http://foo/resource")
227+
.Respond(HttpStatusCode.BadRequest);
228+
229+
var fixture = RestService.For<ISyncPipelineApi>("http://foo", settings);
230+
231+
var ex = Assert.Throws<ApiException>(() => fixture.DoVoid());
232+
Assert.Equal(HttpStatusCode.BadRequest, ex.StatusCode);
233+
234+
mockHttp.VerifyNoOutstandingExpectation();
235+
}
236+
237+
[Fact]
238+
public void Sync_void_method_succeeds_on_ok_response()
239+
{
240+
var mockHttp = new SyncCapableMockHttpMessageHandler();
241+
var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp };
242+
243+
mockHttp
244+
.Expect(HttpMethod.Get, "http://foo/resource")
245+
.Respond(HttpStatusCode.OK);
246+
247+
var fixture = RestService.For<ISyncPipelineApi>("http://foo", settings);
248+
249+
fixture.DoVoid(); // should not throw
250+
251+
mockHttp.VerifyNoOutstandingExpectation();
252+
}
73253
}

0 commit comments

Comments
 (0)