feat: opt-in [FormObject] flattens a model into multipart form fields#2245
Merged
Conversation
- 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.
ChrisPulman
approved these changes
Jul 15, 2026
|
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



What kind of change does this PR introduce?
Feature.
Opt-in flattening of a complex-object parameter into individual
multipart/form-datafields in a[Multipart]method, via a new[FormObject]parameter attribute.What is the new behavior?
[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 ownmultipart/form-datatext part (one part per property), so a server model bound from the form (for example an ASP.NET[FromForm]model) binds field by field.[AliasAs], then the content serializer's field name (for example[JsonPropertyName]), then theUrlParameterKeyFormatter. Values render as plain text through theFormUrlEncodedParameterFormatter; collections honorCollectionFormat; nested objects composeparent.childfield names (bounded by a nesting-depth cap and a reference-cycle guard). This reusesFormValueMultimap, the same infrastructure as url-encoded body flattening.byte[],Stream,FileInfo, and theByteArrayPart/StreamPart/FileInfoPartwrappers) are not converted by[FormObject]; keep passing those as their own separate part parameters, which continue to work alongside a flattened model.[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.What is the current behavior?
Closes #930.
In a
[Multipart]method a complex-object parameter is added as a single serialized part (for example oneapplication/jsonbody 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?
[FormObject]is strictly opt-in; a method without it behaves exactly as before (a single serialized part).[FormObject]method routes to the reflection request builder, so it requires theRefit.Reflectionpackage 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 mirroringFormValueMultimap) - flagging it as a decision rather than shipping a second flatten implementation unprompted.Checklist
mainbranchAdditional information
RequestBuilderImplementation.Payload.csaddsAddFlattenedFormObject, invoked when the parameter carries[FormObject].Parser.Request.Multipart.csreturns 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.RequestBuilderImplementation.Payload.cs269/269,Parser.Request.Multipart.cs182/182;FormObjectAttribute.csis a marker attribute with no coverable lines, matching the shippedUrlAttribute).parent.child, collections, blank-name and null-value edges) inMultipartTests.FormObjectFlattening.cs; generator fallback decision across every model shape of T inRequestGenerationCoverageTests.Multipart.cs; and an end-to-end assertion that the compiled generated client flattens inMultipartRequestBuildingLiveTests.cs.PublicAPI.Shipped.txtfor all 10 target frameworks; the pending unshipped block from an earlier PR is left untouched.