Skip to content

Commit 759a3c0

Browse files
authored
feat: support returning the built HttpRequestMessage without sending (#2235)
* feat: support returning the built HttpRequestMessage without sending - Add a return-type-driven build-only shape: a method declared Task<HttpRequestMessage> builds the full request (URL, headers, body/multipart content) and returns it to the caller instead of dispatching it (Retrofit's Call.request()). - Emit the build-and-return path in the source generator and add the RequestMessage case to the shared return-type classifiers so it is inline-eligible and the RF006 analyzer agrees. - Add the same case to the reflection request builder, building the request without sending or disposing it. - Keep both paths in parity: the async authorization token getter is a dispatch-time step and is skipped when only building the request. - The caller owns the returned request and its content (not disposed). - Document the feature in the README and breaking-changes notes. * test: cover request-message return-shape eligibility branches - Add a classifier test that drives inline eligibility for a Task<HttpRequestMessage> return and a Task<T> of another result type, exercising both outcomes of the request-message classification arm. - Check the task result type directly in the classifier instead of re-validating the already-matched task shape, dropping conditions no caller could reach and keeping the generator and analyzer classification paths in agreement.
1 parent 99c055b commit 759a3c0

15 files changed

Lines changed: 441 additions & 8 deletions

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1986,6 +1986,42 @@ so `Wrapper<User>` closes it over `User`. Adapters must have a public parameterl
19861986
the request eagerly and captures it, so a generated deferred call is **single-use**; the reflection path rebuilds the
19871987
request on each invocation, so it can re-run.
19881988

1989+
### Obtaining the built request without sending
1990+
1991+
Sometimes you want the fully built `HttpRequestMessage` — to inspect it, sign it, log it, or dispatch it yourself — without
1992+
Refit sending it. Declare the method to return `Task<HttpRequestMessage>` and Refit runs the whole request-building path
1993+
(URL, headers, and body/multipart content) and hands the request back instead of dispatching it. This is the equivalent of
1994+
Retrofit's `Call.request()`, and it removes the need for the old `DelegatingHandler`-mock workaround.
1995+
1996+
```csharp
1997+
public interface IUserApi
1998+
{
1999+
[Get("/users/{id}")]
2000+
Task<HttpRequestMessage> BuildGetUser(int id, [Query] string filter);
2001+
2002+
[Post("/users")]
2003+
Task<HttpRequestMessage> BuildCreateUser([Body] User user);
2004+
}
2005+
2006+
var api = RestService.For<IUserApi>("https://api.example.com");
2007+
2008+
using var request = await api.BuildGetUser(42, "active");
2009+
// request.Method -> GET
2010+
// request.RequestUri -> /users/42?filter=active (relative; the HttpClient merges the base address on send)
2011+
// request.Headers -> the built headers
2012+
// dispatch it yourself, or inspect/sign/log it first
2013+
```
2014+
2015+
Notes:
2016+
2017+
* **The caller owns the returned request** — Refit does **not** dispose it (and does not dispose its content), so its body
2018+
stays readable and you can dispatch it yourself. Wrap it in `using` (or dispose it) once you are done.
2019+
* Only `Task<HttpRequestMessage>` is supported (not a synchronous `HttpRequestMessage`), because request building is
2020+
asynchronous — body serialization in particular. A `CancellationToken` parameter is still allowed.
2021+
* Both the source-generated and reflection request paths build byte-identical requests.
2022+
* A configured async `AuthorizationHeaderValueGetter` runs at dispatch time, so it is **not** applied to a request obtained
2023+
this way; add its header yourself if you dispatch the request manually.
2024+
19892025
### Using generic interfaces
19902026

19912027
When using something like ASP.NET Web API, it's a fairly common pattern to have a whole stack of CRUD REST services.

docs/breaking-changes.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,13 @@ visible in three places.
199199
generator discovers adapters declared in your project at compile time and emits a direct `Adapt` call — no reflection,
200200
so adapter-backed methods stay trim and Native AOT clean; the reflection request builder resolves adapters registered
201201
in `RefitSettings.ReturnTypeAdapters`. See [Custom return types](../README.md#custom-return-types-ireturntypeadapter).
202+
* **Obtain the built request without sending it.** A method declared to return `Task<HttpRequestMessage>` builds the
203+
full request (method, URL, headers, and body/multipart content) and hands it back to the caller instead of dispatching
204+
it — the equivalent of Retrofit's `Call.request()`, useful for inspection, signing, logging, or manual dispatch. Both
205+
the generated and reflection paths build byte-identical requests. The caller owns the returned request and its content
206+
and is responsible for disposing it; the request is not disposed for you and its content stays readable. A configured
207+
async `AuthorizationHeaderValueGetter` runs at dispatch time and is therefore not applied to a request obtained this
208+
way. See [Obtaining the built request without sending](../README.md#obtaining-the-built-request-without-sending).
202209
* **Optional URL path segments (`{name?}`).** Append `?` to a placeholder name (matching ASP.NET routing) to make the
203210
segment optional. When the bound argument is `null` the segment and its preceding `/` are dropped, so a route such as
204211
`[Get("/push/notifMsg/{deviceId}/{notifMsgId?}")]` produces `/push/notifMsg/device1` (not a 404-prone

src/InterfaceStubGenerator.Shared/Emitter.Helpers.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ internal static (bool IsAsync, string ReturnPrefix, string ConfigureAwaitSuffix)
101101
ReturnTypeInfo.AsyncResult => (true, "return await (", ").ConfigureAwait(false)"),
102102
ReturnTypeInfo.AsyncEnumerable => (false, ReturnStatementPrefix, string.Empty),
103103
ReturnTypeInfo.Observable => (false, ReturnStatementPrefix, string.Empty),
104+
ReturnTypeInfo.RequestMessage => (false, ReturnStatementPrefix, string.Empty),
104105
ReturnTypeInfo.Return => (false, ReturnStatementPrefix, string.Empty),
105106
ReturnTypeInfo.SyncVoid => (false, string.Empty, string.Empty),
106107
_ => throw new ArgumentOutOfRangeException(

src/InterfaceStubGenerator.Shared/Emitter.Inline.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,12 @@ private static string BuildInlineRefitMethodBody(
227227
return BuildInlineObservableMethodSource(methodPrefix, requestConstruction, buildRequestLocal, requestLocal, observableReturn, bodyIndent, methodIndent);
228228
}
229229

230-
var returnSource = BuildInlineReturn(methodModel, request, plan.BufferBodyExpression, plan.CancellationTokenExpression, requestLocal, settingsLocal, plan.AdapterTokenLocal);
230+
// A Task<HttpRequestMessage> method hands the fully built request back to the caller without dispatching it. The
231+
// caller owns the returned request and its content, so it is neither sent nor disposed here; every other shape
232+
// emits its normal send-and-deserialize return.
233+
var returnSource = methodModel.ReturnTypeMetadata == ReturnTypeInfo.RequestMessage
234+
? BuildInlineRequestMessageReturn(requestLocal)
235+
: BuildInlineReturn(methodModel, request, plan.BufferBodyExpression, plan.CancellationTokenExpression, requestLocal, settingsLocal, plan.AdapterTokenLocal);
231236
return $$"""
232237
{{methodPrefix}}{{requestConstruction}}{{returnSource}}{{methodIndent}}}
233238
@@ -361,6 +366,18 @@ private static string BuildInlineObservableReturn(
361366
""";
362367
}
363368

369+
/// <summary>Builds the return statement for a <c>Task&lt;HttpRequestMessage&gt;</c> method that returns the built request.</summary>
370+
/// <param name="requestLocal">The generated request message local name.</param>
371+
/// <returns>The generated return statement handing the built request back without sending it.</returns>
372+
private static string BuildInlineRequestMessageReturn(string requestLocal)
373+
{
374+
var bodyIndent = Indent(MethodBodyIndentation);
375+
return $$"""
376+
{{bodyIndent}}return global::System.Threading.Tasks.Task.FromResult({{requestLocal}});
377+
378+
""";
379+
}
380+
364381
/// <summary>Builds the return statement for an inline generated Refit method.</summary>
365382
/// <param name="methodModel">The method model being emitted.</param>
366383
/// <param name="request">The parsed request model.</param>

src/InterfaceStubGenerator.Shared/Models/ReturnTypeInfo.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,8 @@ internal enum ReturnTypeInfo
2222
Observable,
2323

2424
/// <summary>The method returns void synchronously.</summary>
25-
SyncVoid
25+
SyncVoid,
26+
27+
/// <summary>The method returns the built <c>Task&lt;HttpRequestMessage&gt;</c> without sending it.</summary>
28+
RequestMessage
2629
}

src/InterfaceStubGenerator.Shared/Parser.InlineEligibility.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ namespace Refit.Generator;
1414
/// </content>
1515
internal static partial class Parser
1616
{
17+
/// <summary>The <c>System.Threading.Tasks</c> namespace name, matched structurally to identify task return shapes.</summary>
18+
private const string TasksNamespace = "System.Threading.Tasks";
19+
1720
/// <summary>Determines whether generated request building can construct a method's request inline.</summary>
1821
/// <param name="methodSymbol">The Refit method symbol.</param>
1922
/// <param name="httpMethodBaseAttributeSymbol">The resolved <c>Refit.HttpMethodAttribute</c> symbol.</param>
@@ -81,11 +84,22 @@ private static ReturnTypeInfo ClassifyInlineReturnShape(ITypeSymbol returnType)
8184
var ns = namedType.ContainingNamespace.ToDisplayString();
8285
return namedType.MetadataName switch
8386
{
84-
"Task" when ns == "System.Threading.Tasks" => ReturnTypeInfo.AsyncVoid,
85-
"Task`1" or "ValueTask`1" when ns == "System.Threading.Tasks" => ReturnTypeInfo.AsyncResult,
87+
"Task" when ns == TasksNamespace => ReturnTypeInfo.AsyncVoid,
88+
"Task`1" when IsHttpRequestMessageType(namedType.TypeArguments[0]) => ReturnTypeInfo.RequestMessage,
89+
"Task`1" or "ValueTask`1" when ns == TasksNamespace => ReturnTypeInfo.AsyncResult,
8690
"IAsyncEnumerable`1" when ns == "System.Collections.Generic" => ReturnTypeInfo.AsyncEnumerable,
8791
"IObservable`1" when ns == "System" => ReturnTypeInfo.Observable,
8892
_ => ReturnTypeInfo.Return
8993
};
9094
}
95+
96+
/// <summary>Determines whether a type is <c>System.Net.Http.HttpRequestMessage</c>, the type argument of the
97+
/// build-and-return <c>Task&lt;HttpRequestMessage&gt;</c> shape.</summary>
98+
/// <param name="type">The type to inspect.</param>
99+
/// <returns><see langword="true"/> when the type is <c>HttpRequestMessage</c>.</returns>
100+
/// <remarks>Only the <c>Task</c> wrapper is supported: request building is asynchronous (body serialization and the
101+
/// authorization token getter), so a synchronous <c>HttpRequestMessage</c> return would force a blocking build.</remarks>
102+
private static bool IsHttpRequestMessageType(ITypeSymbol type) =>
103+
type is INamedTypeSymbol { MetadataName: "HttpRequestMessage" } named
104+
&& named.ContainingNamespace.ToDisplayString() == "System.Net.Http";
91105
}

src/InterfaceStubGenerator.Shared/Parser.Members.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ private static ReturnTypeInfo GetReturnTypeInfo(IMethodSymbol methodSymbol) =>
232232
methodSymbol.ReturnType.MetadataName switch
233233
{
234234
"Task" => ReturnTypeInfo.AsyncVoid,
235+
"Task`1" when IsHttpRequestMessageType(((INamedTypeSymbol)methodSymbol.ReturnType).TypeArguments[0]) => ReturnTypeInfo.RequestMessage,
235236
"Task`1" or "ValueTask`1" => ReturnTypeInfo.AsyncResult,
236237
"IAsyncEnumerable`1" => ReturnTypeInfo.AsyncEnumerable,
237238
"IObservable`1" => ReturnTypeInfo.Observable,

src/InterfaceStubGenerator.Shared/Parser.Request.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ private static (string? AdapterTypeExpression, ITypeSymbol ResultTypeSource) Res
127127
/// <returns><see langword="true"/> when the return shape can be generated inline.</returns>
128128
private static bool IsInlineReturnShape(ReturnTypeInfo returnTypeInfo, string? adapterTypeExpression) =>
129129
returnTypeInfo is ReturnTypeInfo.AsyncVoid or ReturnTypeInfo.AsyncResult or ReturnTypeInfo.AsyncEnumerable
130-
or ReturnTypeInfo.Observable
130+
or ReturnTypeInfo.Observable or ReturnTypeInfo.RequestMessage
131131
|| adapterTypeExpression is not null;
132132

133133
/// <summary>Reports an error when a method uses a source-generation-only attribute but cannot generate inline.</summary>

src/Refit.Reflection/RequestBuilderImplementation.Execution.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,38 @@ private static bool IsBodyBuffered(
7373
HttpRequestMessage request) =>
7474
(restMethod.BodyParameterInfo?.Item2 ?? false) && request.Content is not null;
7575

76+
/// <summary>Builds a delegate that constructs and returns the request for a <c>Task&lt;HttpRequestMessage&gt;</c> method.</summary>
77+
/// <param name="restMethod">The rest method to build a delegate for.</param>
78+
/// <returns>A delegate that returns a task producing the built request without sending it.</returns>
79+
[RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")]
80+
private Func<HttpClient, object[], object?> BuildRequestMessageFuncForMethod(
81+
RestMethodInfoInternal restMethod) =>
82+
(client, paramList) => BuildRequestMessageWithoutSendingAsync(client, restMethod, paramList);
83+
84+
/// <summary>Builds the request message for a method and returns it to the caller without sending it.</summary>
85+
/// <param name="client">The HTTP client whose base address the request is built against.</param>
86+
/// <param name="restMethod">The rest method being invoked.</param>
87+
/// <param name="paramList">The argument values for the call.</param>
88+
/// <returns>The built request; the caller owns it and is responsible for disposing it.</returns>
89+
/// <remarks>The request is intentionally not disposed here: it is handed to the caller for inspection, signing,
90+
/// logging, or manual dispatch. Any configured async authorization token getter runs at dispatch time and is
91+
/// therefore not applied to a request obtained this way.</remarks>
92+
[RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")]
93+
private Task<HttpRequestMessage> BuildRequestMessageWithoutSendingAsync(
94+
HttpClient client,
95+
RestMethodInfoInternal restMethod,
96+
object[] paramList)
97+
{
98+
RequestExecutionHelpers.ThrowIfBaseAddressMissing(client);
99+
100+
return BuildRequestMessageForMethodAsync(
101+
restMethod,
102+
client.BaseAddress!.AbsolutePath,
103+
restMethod.CancellationToken is not null,
104+
paramList,
105+
applyAuthorizationHeaderGetter: false);
106+
}
107+
76108
/// <summary>Builds and sends the request for a method with no response body, throwing on error.</summary>
77109
/// <param name="client">The HTTP client to send with.</param>
78110
/// <param name="restMethod">The rest method being invoked.</param>

src/Refit.Reflection/RequestBuilderImplementation.RequestBuilding.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,13 +255,16 @@ private static void DropOptionalSegmentSeparator(ref ValueStringBuilder vsb)
255255
/// <param name="basePath">The base path from the client's base address.</param>
256256
/// <param name="paramsContainsCancellationToken">Whether the argument list contains a cancellation token.</param>
257257
/// <param name="paramList">The argument values for the call.</param>
258+
/// <param name="applyAuthorizationHeaderGetter">Whether to run the configured async authorization token getter, a
259+
/// dispatch-time step skipped when the request is built only to be handed back to the caller.</param>
258260
/// <returns>The constructed request message.</returns>
259261
[RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")]
260262
private async Task<HttpRequestMessage> BuildRequestMessageForMethodAsync(
261263
RestMethodInfoInternal restMethod,
262264
string basePath,
263265
bool paramsContainsCancellationToken,
264-
object[] paramList)
266+
object[] paramList,
267+
bool applyAuthorizationHeaderGetter = true)
265268
{
266269
var cancellationToken = CancellationToken.None;
267270

@@ -293,8 +296,11 @@ private async Task<HttpRequestMessage> BuildRequestMessageForMethodAsync(
293296
MapParametersToRequest(restMethod, paramList, ret, multiPartContent, ref headersToAdd, ref queryParamsToAdd);
294297

295298
AddHeadersToRequest(headersToAdd, ret);
296-
await AddAuthorizationHeadersFromGetterAsync(ret, cancellationToken)
297-
.ConfigureAwait(false);
299+
if (applyAuthorizationHeaderGetter)
300+
{
301+
await AddAuthorizationHeadersFromGetterAsync(ret, cancellationToken)
302+
.ConfigureAwait(false);
303+
}
298304

299305
AddPropertiesToRequest(restMethod, ret, paramList, declaredArguments);
300306
#if NET6_0_OR_GREATER

0 commit comments

Comments
 (0)