Skip to content

feat: opt-in [FormObject] flattens a model into multipart form fields#2245

Merged
glennawatson merged 1 commit into
mainfrom
feat/multipart-object-flattening
Jul 15, 2026
Merged

feat: opt-in [FormObject] flattens a model into multipart form fields#2245
glennawatson merged 1 commit into
mainfrom
feat/multipart-object-flattening

Conversation

@glennawatson

@glennawatson glennawatson commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What kind of change does this PR introduce?

Feature.

Opt-in flattening of a complex-object parameter into individual multipart/form-data fields in a [Multipart] method, via a new [FormObject] parameter attribute.

Attribute name is OPEN TO MAINTAINER FEEDBACK. I chose [FormObject] (candidates were [FormObject], [Flatten], [FormFields]) because it reads as "treat this parameter as a form object whose properties are form fields". Happy to rename in review - it is a single symbol plus its PublicAPI/README/docs references.

What is the new behavior?

  • A new public [FormObject] attribute (AttributeTargets.Parameter). Applied to a complex-object parameter of a [Multipart] method, each of the object's public properties is sent as its own multipart/form-data text part (one part per property), so a server model bound from the form (for example an ASP.NET [FromForm] model) binds field by field.
  • Field names resolve with the existing form-encoded precedence: [AliasAs], then the content serializer's field name (for example [JsonPropertyName]), then the UrlParameterKeyFormatter. Values render as plain text through the FormUrlEncodedParameterFormatter; collections honor CollectionFormat; nested objects compose parent.child field names (bounded by a nesting-depth cap and a reference-cycle guard). This reuses FormValueMultimap, the same infrastructure as url-encoded body flattening.
  • File-typed members (byte[], Stream, FileInfo, and the ByteArrayPart/StreamPart/FileInfoPart wrappers) are not converted by [FormObject]; keep passing those as their own separate part parameters, which continue to work alongside a flattened model.
  • Source-generator path (the primary path): the generator detects a [FormObject] parameter of ANY model shape (sealed/unsealed class, struct, record, interface, object, dictionary, open generic) and routes the whole method to the reflection request builder, which owns the one authoritative flattening implementation. The compiled generated client is exercised end-to-end in tests and asserted to produce the flattened parts, not just to compile.
  • Documented in the README "Multipart uploads" section and a breaking-changes note.

What is the current behavior?

Closes #930.

In a [Multipart] method a complex-object parameter 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 expecting form fields, which is exactly the [FromForm] model-binding gap reported in the issue. There was no way to flatten it. The default (no attribute) single-serialized-part behavior is unchanged by this PR.

What might this PR break?

  • None for existing code. [FormObject] is strictly opt-in; a method without it behaves exactly as before (a single serialized part).
  • Design note for reviewers (the source generator is the primary path): per the authoritative-reflection design, a [FormObject] method routes to the reflection request builder, so it requires the Refit.Reflection package and is not available on a generated-only / Native AOT client (AddRefitGeneratedClient / RestService.ForGenerated) - the same trade-off as other reflection-fallback shapes (nested url-encoded bodies, object-typed multipart parts). If you would rather [FormObject] also generate inline for generated-only/AOT clients, that is a deliberate design change (an inline emitter flatten mirroring FormValueMultimap) - flagging it as a decision rather than shipping a second flatten implementation unprompted.

Checklist

  • I have read the Contribute guide
  • Tests have been added or updated (for bug fixes / features)
  • Docs have been added or updated (for bug fixes / features)
  • Changes target the main branch
  • PR title follows Conventional Commits

Additional information

  • Reflection runtime: RequestBuilderImplementation.Payload.cs adds AddFlattenedFormObject, invoked when the parameter carries [FormObject].
  • Source generator: Parser.Request.Multipart.cs returns null from the multipart classifier for a [FormObject] parameter (the same shortcut used for nested url-encoded bodies), so the method uses the reflection builder with byte-identical output and no duplicated emit logic.
  • Verification: full solution builds clean across all 10 target frameworks (0 warnings, analyzers enforced). Refit.Tests (1150) and Refit.GeneratorTests (445) pass on net8.0. Merged line coverage on every touched product file is 100% (RequestBuilderImplementation.Payload.cs 269/269, Parser.Request.Multipart.cs 182/182; FormObjectAttribute.cs is a marker attribute with no coverable lines, matching the shipped UrlAttribute).
  • Tests: runtime flattening (per-property text parts named by alias/serializer/formatter, nested parent.child, collections, blank-name and null-value edges) in MultipartTests.FormObjectFlattening.cs; generator fallback decision across every model shape of T in RequestGenerationCoverageTests.Multipart.cs; and an end-to-end assertion that the compiled generated client flattens in MultipartRequestBuildingLiveTests.cs.
  • PublicAPI: the new attribute is promoted into PublicAPI.Shipped.txt for all 10 target frameworks; the pending unshipped block from an earlier PR is left untouched.

- Add the [FormObject] parameter attribute: in a [Multipart] method it
  flattens a complex object's public properties into individual
  multipart/form-data text parts (one per property) instead of the
  default single serialized part, so server-side form model binding
  (for example ASP.NET [FromForm]) binds field by field.
- Reuse FormValueMultimap for per-property field-name resolution
  ([AliasAs] > serializer field name > key formatter), value
  formatting, CollectionFormat, and nested parent.child composition
  (depth cap + cycle guard). Files stay separate part parameters.
- Keep the reflection request builder as the one authoritative
  implementation; the source generator routes a [FormObject] parameter
  to the reflection fallback for any model shape, keeping byte-identical
  output with no duplicated emit logic. The default (no attribute)
  single-part behaviour is unchanged.
- Promote the new public attribute across every target framework's
  PublicAPI, and document it in the README multipart section plus a
  breaking-changes note.
@sonarqubecloud

Copy link
Copy Markdown

@glennawatson glennawatson merged commit 485c632 into main Jul 15, 2026
15 checks passed
@glennawatson glennawatson deleted the feat/multipart-object-flattening branch July 15, 2026 11:33
@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (44f81de) to head (475fac7).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main     #2245   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          183       183           
  Lines         9553      9562    +9     
  Branches      1796      1801    +5     
=========================================
+ Hits          9553      9562    +9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Question: Multipart form-data with Refit

2 participants