Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1884,6 +1884,47 @@ someApiInstance.UploadPhoto(id, new StreamPart(myPhotoStream, "photo.jpg", "imag
Note: The `AttachmentName` attribute that was previously described in this section has been deprecated and its use is
not recommended.

#### Flattening a model into form fields with `[FormObject]`

By default a complex object passed to a multipart method is added as a **single** serialized part (for example one
`application/json` body) named after the parameter. That single part cannot be bound field by field by a server that
expects form fields (such as an ASP.NET `[FromForm]` model).

Opt in to per-property flattening by decorating the parameter with `[FormObject]`. Each public property is then sent as
its own `multipart/form-data` text field:

```csharp
public class SetupModel
{
[AliasAs("full_name")]
public string Name { get; set; }

public int Age { get; set; }

public string[] Roles { get; set; }
}

public interface IUploadApi
{
[Multipart]
[Post("/setup")]
Task SetupAsync([FormObject] SetupModel model, [AliasAs("recipe")] StreamPart recipe);
}
```

The call above sends `full_name`, `Age` and `Roles` as individual form fields alongside the `recipe` file part.

- **Field names** are resolved per property: `[AliasAs]` first, then the content serializer's field name (for example
`[JsonPropertyName]`), then the `UrlParameterKeyFormatter`.
- **Values** are rendered as plain text through the `FormUrlEncodedParameterFormatter`, and collections are joined per
the `CollectionFormat` (the default joins with commas), mirroring url-encoded body flattening.
- **Nested objects** are flattened into composed `parent.child` field names (bounded by a nesting-depth cap and a
reference-cycle guard).
- **Files** (`byte[]`, `Stream`, `FileInfo` and the `ByteArrayPart` / `StreamPart` / `FileInfoPart` wrappers) are not
converted by `[FormObject]`; keep passing those as their own separate part parameters.

Without `[FormObject]` the existing single-serialized-part behaviour is unchanged.

### Retrieving the response

Note that in Refit unlike in Retrofit, there is no option for a synchronous
Expand Down
8 changes: 8 additions & 0 deletions docs/breaking-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,14 @@ visible in three places.
per-call deadline composes with `HttpClient.Timeout` and any Polly/`DelegatingHandler` timeout — whichever fires first
wins. Methods without `[Timeout]` are unaffected. Both request paths honor it (reflection and source-generated). See
[Per-request timeouts](../README.md#per-request-timeouts).
* **`[FormObject]` flattens a model into individual multipart fields.** In a `[Multipart]` method, decorating a
complex-object parameter with `[FormObject]` sends each of its public properties as its own `multipart/form-data` text
field instead of the default single serialized part, so a server model bound from the form (such as an ASP.NET
`[FromForm]` model) binds field by field. Field names resolve with the form-encoded precedence (`[AliasAs]`, then the
content serializer's property name, then the key formatter), values render through the `FormUrlEncodedParameterFormatter`,
collections honor `CollectionFormat`, and nested objects compose `parent.child` field names. File-typed members are
left as separate part parameters. The default (no attribute) single-part behavior is unchanged. See
[Flattening a model into form fields with `[FormObject]`](../README.md#flattening-a-model-into-form-fields-with-formobject).
* **Inline query-string generation.** Query parameters — auto-appended parameters, `[AliasAs]`, `[Query(Format = ...)]`,
and scalar collections with every `CollectionFormat` — now generate reflection-free request construction, so the most
common Refit method shapes work with generated-only clients (`AddRefitGeneratedClient`, `RestService.ForGenerated`)
Expand Down
13 changes: 12 additions & 1 deletion src/InterfaceStubGenerator.Shared/Parser.Request.Multipart.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ namespace Refit.Generator;
/// <c>AddMultiPart</c>/<c>AddMultipartItem</c> dispatch and its part name/file-name selection, resolved statically from
/// each parameter's declared type. A part whose declared type is not statically dispatchable (an <c>object</c>, an
/// interface, an open type parameter, or a type that would fall through to the content serializer) makes the whole
/// method fall back to the reflection request builder.
/// method fall back to the reflection request builder, as does an opt-in <c>[FormObject]</c> parameter whose properties
/// the reflection builder flattens into individual form-data parts.
/// </content>
internal static partial class Parser
{
/// <summary>The metadata name of the obsolete <c>Refit.AttachmentNameAttribute</c>.</summary>
private const string AttachmentNameAttributeDisplayName = "AttachmentNameAttribute";

/// <summary>The metadata name of <c>Refit.FormObjectAttribute</c>.</summary>
private const string FormObjectAttributeDisplayName = "FormObjectAttribute";

/// <summary>The default multipart boundary, matching <c>new MultipartAttribute().BoundaryText</c>.</summary>
private const string DefaultMultipartBoundary = "----MyGreatBoundary";

Expand Down Expand Up @@ -104,6 +108,13 @@ private static ParsedRequestParameter ClassifyMultipartParameter(
/// <returns>The part descriptor, or <see langword="null"/> when the type is not statically dispatchable.</returns>
private static MultipartPartModel? TryBuildMultipartPart(IParameterSymbol parameter, in LooseParameterContext context)
{
// An opt-in [FormObject] parameter is flattened per-property by the reflection request builder; returning null
// routes the whole method to that one authoritative implementation instead of duplicating the flattening here.
if (HasParameterAttribute(parameter, FormObjectAttributeDisplayName))
{
return null;
}

if (ClassifyPartType(parameter.Type) is not { } classified)
{
return null;
Expand Down
29 changes: 29 additions & 0 deletions src/Refit.Reflection/RequestBuilderImplementation.Payload.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,14 @@ private void AddMultiPart(
string itemName;
string parameterName;

// An opt-in [FormObject] parameter is flattened into one text form-data part per property (resolved field name +
// formatted value) so server-side form model binding sees individual fields instead of a single serialized part.
if (restMethod.ParameterInfoArray[i].GetCustomAttribute<FormObjectAttribute>(true) is not null)
{
AddFlattenedFormObject(multiPartContent!, param);
return;
}

if (!restMethod.AttachmentNameMap.TryGetValue(i, out var attachment))
{
itemName = restMethod.QueryParameterMap[i];
Expand All @@ -228,6 +236,27 @@ private void AddMultiPart(
}
}

/// <summary>Flattens a complex object's properties into one text multipart part each for a <c>[FormObject]</c> parameter.</summary>
/// <param name="multiPartContent">The multipart content to add to.</param>
/// <param name="param">The object (or dictionary) whose fields become individual form-data parts.</param>
/// <remarks>Reuses <see cref="FormValueMultimap"/> so field-name resolution (alias, serializer, key formatter),
/// value formatting, collection handling and nested <c>parent.child</c> composition match url-encoded body
/// flattening. Each entry is added as its own text <see cref="StringContent"/> under the resolved field name.</remarks>
private void AddFlattenedFormObject(MultipartFormDataContent multiPartContent, object param)
{
foreach (var field in new FormValueMultimap(param, _settings))
{
// A field with no resolvable name cannot be a valid form-data part (the framework rejects an empty
// content-disposition name), so it is skipped rather than allowed to throw mid-request.
if (string.IsNullOrWhiteSpace(field.Key))
{
continue;
}

multiPartContent.Add(new StringContent(field.Value ?? string.Empty), field.Key!);
}
}

/// <summary>Adds a single value to a multipart form as the appropriate content type.</summary>
/// <param name="multiPartContent">The multipart content to add to.</param>
/// <param name="fileName">The file name to use for file-like parts.</param>
Expand Down
41 changes: 41 additions & 0 deletions src/Refit/FormObjectAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
// ReactiveUI and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

namespace Refit;

/// <summary>
/// Marks a complex-object parameter of a <c>[Multipart]</c> method so its public properties are written as individual
/// <c>multipart/form-data</c> text parts (one part per property) instead of the default single serialized part.
/// </summary>
/// <remarks>
/// Without this attribute a complex object is added to a multipart request as one serialized part (for example a single
/// <c>application/json</c> body named after the parameter), which server-side form model binding cannot bind field by
/// field. With it, each property becomes its own named form field, so a server model bound from the form (such as an
/// ASP.NET <c>[FromForm]</c> model) receives the individual values.
/// <para>
/// Field names are resolved per property in the same order as url-encoded body flattening: an explicit
/// <see cref="AliasAsAttribute"/>, then <see cref="IHttpContentSerializer.GetFieldNameForProperty"/>, then
/// <see cref="RefitSettings.UrlParameterKeyFormatter"/>. Values are rendered through
/// <see cref="RefitSettings.FormUrlEncodedParameterFormatter"/>, collections honour
/// <see cref="RefitSettings.CollectionFormat"/>, and a nested object composes its children under a
/// <c>parent.child</c> field name (bounded by a nesting-depth cap and a reference-cycle guard).
/// </para>
/// <para>
/// File-typed members (<see cref="System.IO.Stream"/>, <c>byte[]</c>, <see cref="System.IO.FileInfo"/> and the Refit
/// part types) are not converted to file parts by this attribute; pass those as their own separate part parameters.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// interface IUploadApi
/// {
/// [Multipart]
/// [Post("/setup")]
/// Task SetupAsync([FormObject] SetupModel model, [AliasAs("recipe")] StreamPart recipe);
/// }
/// </code>
/// Each public property of <c>SetupModel</c> is sent as its own form field alongside the <c>recipe</c> file part.
/// </example>
[AttributeUsage(AttributeTargets.Parameter)]
public sealed class FormObjectAttribute : Attribute;
2 changes: 2 additions & 0 deletions src/Refit/PublicAPI/net10.0/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -502,3 +502,5 @@ static Refit.GeneratedRequestRunner.SetRequestTimeout(System.Net.Http.HttpReques
static Refit.HttpRequestMessageOptions.MethodArguments.get -> string!
Refit.RefitSettings.CaptureMethodArguments.get -> bool
Refit.RefitSettings.CaptureMethodArguments.set -> void
Refit.FormObjectAttribute
Refit.FormObjectAttribute.FormObjectAttribute() -> void
2 changes: 2 additions & 0 deletions src/Refit/PublicAPI/net11.0/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -502,3 +502,5 @@ static Refit.GeneratedRequestRunner.SetRequestTimeout(System.Net.Http.HttpReques
static Refit.HttpRequestMessageOptions.MethodArguments.get -> string!
Refit.RefitSettings.CaptureMethodArguments.get -> bool
Refit.RefitSettings.CaptureMethodArguments.set -> void
Refit.FormObjectAttribute
Refit.FormObjectAttribute.FormObjectAttribute() -> void
2 changes: 2 additions & 0 deletions src/Refit/PublicAPI/net462/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,5 @@ static Refit.GeneratedRequestRunner.SetRequestTimeout(System.Net.Http.HttpReques
static Refit.HttpRequestMessageOptions.MethodArguments.get -> string!
Refit.RefitSettings.CaptureMethodArguments.get -> bool
Refit.RefitSettings.CaptureMethodArguments.set -> void
Refit.FormObjectAttribute
Refit.FormObjectAttribute.FormObjectAttribute() -> void
2 changes: 2 additions & 0 deletions src/Refit/PublicAPI/net470/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,5 @@ static Refit.GeneratedRequestRunner.SetRequestTimeout(System.Net.Http.HttpReques
static Refit.HttpRequestMessageOptions.MethodArguments.get -> string!
Refit.RefitSettings.CaptureMethodArguments.get -> bool
Refit.RefitSettings.CaptureMethodArguments.set -> void
Refit.FormObjectAttribute
Refit.FormObjectAttribute.FormObjectAttribute() -> void
2 changes: 2 additions & 0 deletions src/Refit/PublicAPI/net471/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,5 @@ static Refit.GeneratedRequestRunner.SetRequestTimeout(System.Net.Http.HttpReques
static Refit.HttpRequestMessageOptions.MethodArguments.get -> string!
Refit.RefitSettings.CaptureMethodArguments.get -> bool
Refit.RefitSettings.CaptureMethodArguments.set -> void
Refit.FormObjectAttribute
Refit.FormObjectAttribute.FormObjectAttribute() -> void
2 changes: 2 additions & 0 deletions src/Refit/PublicAPI/net472/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,5 @@ static Refit.GeneratedRequestRunner.SetRequestTimeout(System.Net.Http.HttpReques
static Refit.HttpRequestMessageOptions.MethodArguments.get -> string!
Refit.RefitSettings.CaptureMethodArguments.get -> bool
Refit.RefitSettings.CaptureMethodArguments.set -> void
Refit.FormObjectAttribute
Refit.FormObjectAttribute.FormObjectAttribute() -> void
2 changes: 2 additions & 0 deletions src/Refit/PublicAPI/net48/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,5 @@ static Refit.GeneratedRequestRunner.SetRequestTimeout(System.Net.Http.HttpReques
static Refit.HttpRequestMessageOptions.MethodArguments.get -> string!
Refit.RefitSettings.CaptureMethodArguments.get -> bool
Refit.RefitSettings.CaptureMethodArguments.set -> void
Refit.FormObjectAttribute
Refit.FormObjectAttribute.FormObjectAttribute() -> void
2 changes: 2 additions & 0 deletions src/Refit/PublicAPI/net481/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,5 @@ static Refit.GeneratedRequestRunner.SetRequestTimeout(System.Net.Http.HttpReques
static Refit.HttpRequestMessageOptions.MethodArguments.get -> string!
Refit.RefitSettings.CaptureMethodArguments.get -> bool
Refit.RefitSettings.CaptureMethodArguments.set -> void
Refit.FormObjectAttribute
Refit.FormObjectAttribute.FormObjectAttribute() -> void
2 changes: 2 additions & 0 deletions src/Refit/PublicAPI/net8.0/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -502,3 +502,5 @@ static Refit.GeneratedRequestRunner.SetRequestTimeout(System.Net.Http.HttpReques
static Refit.HttpRequestMessageOptions.MethodArguments.get -> string!
Refit.RefitSettings.CaptureMethodArguments.get -> bool
Refit.RefitSettings.CaptureMethodArguments.set -> void
Refit.FormObjectAttribute
Refit.FormObjectAttribute.FormObjectAttribute() -> void
2 changes: 2 additions & 0 deletions src/Refit/PublicAPI/net9.0/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -502,3 +502,5 @@ static Refit.GeneratedRequestRunner.SetRequestTimeout(System.Net.Http.HttpReques
static Refit.HttpRequestMessageOptions.MethodArguments.get -> string!
Refit.RefitSettings.CaptureMethodArguments.get -> bool
Refit.RefitSettings.CaptureMethodArguments.set -> void
Refit.FormObjectAttribute
Refit.FormObjectAttribute.FormObjectAttribute() -> void
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ public sealed class MultipartRequestBuildingLiveTests
/// <summary>A stable score used by the serialized DTO part scenario.</summary>
private const int ReportScore = 42;

/// <summary>The aliased name flattened by the <c>[FormObject]</c> scenario.</summary>
private const string ProfileName = "Ada Lovelace";

/// <summary>The age flattened by the <c>[FormObject]</c> scenario.</summary>
private const int ProfileAge = 36;

/// <summary>The expected plain-text rendering of <see cref="ProfileAge"/>.</summary>
private const string ProfileAgeText = "36";

/// <summary>The bytes uploaded by the primary binary part scenarios.</summary>
private static readonly byte[] SampleBytes = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

Expand Down Expand Up @@ -106,6 +115,40 @@ await harness.AssertParityAsync(
() => [harness.CreateApiValue("Refit.LiveMultipart.Report", ("Title", "Q3"), ("Score", ReportScore))]);
}

/// <summary>Verifies the compiled generated client flattens an opt-in <c>[FormObject]</c> parameter into one text
/// part per property, and stays in parity with the reflection request builder.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
[RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")]
[RequiresDynamicCode("Compares generated request building against the reflection request builder.")]
public async Task FormObjectPartFlattensThroughGeneratedClient()
{
using var harness = LiveMultipartHarness.Create();

var snapshot = await harness.InvokeGeneratedAsync(
"UploadProfile",
() => [harness.CreateApiValue("Refit.LiveMultipart.Profile", ("Name", ProfileName), ("Age", ProfileAge))]);

// The generated client routes [FormObject] through the reflection request builder, which flattens the model into
// one text part per property instead of a single serialized part. Asserting the parts here (not just parity)
// proves the generated code path actually produces the flattened form-data.
const int expectedPartCount = 2;
await Assert.That(snapshot.Parts.Count).IsEqualTo(expectedPartCount);

var namePart = snapshot.Parts.Single(static part => part.Name == "full_name");
await Assert.That(namePart.FileName).IsNull();
await Assert.That(namePart.ContentType).Contains("text/plain");
await Assert.That(Encoding.UTF8.GetString(namePart.Body)).IsEqualTo(ProfileName);

var agePart = snapshot.Parts.Single(static part => part.Name == "Age");
await Assert.That(agePart.ContentType).Contains("text/plain");
await Assert.That(Encoding.UTF8.GetString(agePart.Body)).IsEqualTo(ProfileAgeText);

await harness.AssertParityAsync(
"UploadProfile",
() => [harness.CreateApiValue("Refit.LiveMultipart.Profile", ("Name", ProfileName), ("Age", ProfileAge))]);
}

/// <summary>Verifies a header, request property and path parameter never become multipart parts.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
Expand Down Expand Up @@ -159,8 +202,20 @@ public class Report
public int Score { get; set; }
}

public class Profile
{
[AliasAs("full_name")]
public string? Name { get; set; }

public int Age { get; set; }
}

public interface ILiveMultipartApi
{
[Multipart]
[Post("/upload")]
Task<string> UploadProfile([FormObject] Profile profile);

[Multipart]
[Post("/upload")]
Task<string> UploadFlag([AliasAs("flag")] bool flag);
Expand Down Expand Up @@ -279,6 +334,18 @@ public string CreateTempFile(byte[] bytes)
return path;
}

/// <summary>Invokes a method through the compiled generated client only and snapshots the multipart it built.</summary>
/// <param name="methodName">The interface method name.</param>
/// <param name="argsFactory">Produces the argument set for the generated call.</param>
/// <returns>The multipart snapshot the generated client produced.</returns>
[RequiresUnreferencedCode("Reflects over generated types and members.")]
public async Task<MultipartSnapshot> InvokeGeneratedAsync(string methodName, Func<object[]> argsFactory)
{
var task = (Task)interfaceType.GetMethod(methodName)!.Invoke(generatedApi, argsFactory())!;
await task.ConfigureAwait(false);
return handler.TakeSnapshot();
}

/// <summary>Invokes a method through both request paths and asserts the multipart contents are identical.</summary>
/// <param name="methodName">The interface method name.</param>
/// <param name="argsFactory">Produces a fresh argument set for each path, so single-use streams are not shared.</param>
Expand Down
Loading
Loading