@@ -13,6 +13,8 @@ use crate::ir::CookieParam;
1313use crate :: ir:: Cookies ;
1414use crate :: ir:: HeaderParam ;
1515use crate :: ir:: Headers ;
16+ use crate :: ir:: Multipart ;
17+ use crate :: ir:: MultipartField ;
1618use crate :: ir:: Operation ;
1719use crate :: ir:: ResponseCase ;
1820use crate :: ir:: ResponseHeader ;
@@ -31,20 +33,32 @@ impl crate::emit::ServerEmitter for AxumServer {
3133}
3234
3335/// The handler extractor pattern + type for a request body of the given kind.
36+ ///
37+ /// Multipart bodies are emitted by [`emit_multipart`] and wired in by
38+ /// [`emit_handler`] directly, so they never reach this helper.
3439fn body_extractor ( kind : crate :: ir:: BodyKind , ty : & TokenStream ) -> TokenStream {
3540 return match kind {
3641 crate :: ir:: BodyKind :: Json => quote ! { axum:: Json ( body) : axum:: Json <#ty> } ,
3742 crate :: ir:: BodyKind :: Text => quote ! { body: String } ,
3843 crate :: ir:: BodyKind :: Form => quote ! { axum:: Form ( body) : axum:: Form <#ty> } ,
44+ crate :: ir:: BodyKind :: Multipart => {
45+ unreachable ! ( "multipart bodies are emitted via emit_multipart, not body_extractor" )
46+ }
3947 } ;
4048}
4149
4250/// The response tuple term that renders a body of the given kind.
51+ ///
52+ /// Multipart is request-only (`RESPONSE_BODY_PRIORITY` excludes it), so a
53+ /// response body never carries [`crate::ir::BodyKind::Multipart`].
4354fn response_body_term ( kind : crate :: ir:: BodyKind ) -> TokenStream {
4455 return match kind {
4556 crate :: ir:: BodyKind :: Json => quote ! { axum:: Json ( body) } ,
4657 crate :: ir:: BodyKind :: Text => quote ! { body } ,
4758 crate :: ir:: BodyKind :: Form => quote ! { axum:: Form ( body) } ,
59+ crate :: ir:: BodyKind :: Multipart => {
60+ unreachable ! ( "multipart is request-only and never appears in a response body" )
61+ }
4862 } ;
4963}
5064
@@ -62,6 +76,9 @@ fn service_items(service: &Service) -> Result<Vec<TokenStream>> {
6276 if let Some ( cookies) = & operation. cookies {
6377 items. extend ( emit_cookies ( cookies) ?) ;
6478 }
79+ if let Some ( multipart) = & operation. multipart {
80+ items. extend ( emit_multipart ( multipart) ?) ;
81+ }
6582 }
6683 items. push ( emit_trait ( service) ?) ;
6784 for operation in & service. operations {
@@ -122,6 +139,10 @@ fn emit_method_args(operation: &Operation) -> Result<Vec<TokenStream>> {
122139 let ty = emit_type ( & body. ty ) ?;
123140 args. push ( quote ! { body: #ty } ) ;
124141 }
142+ if let Some ( multipart) = & operation. multipart {
143+ let ty = multipart. name . to_token ( ) ;
144+ args. push ( quote ! { body: #ty } ) ;
145+ }
125146 return Ok ( args) ;
126147}
127148
@@ -323,6 +344,11 @@ fn emit_handler(operation: &Operation) -> Result<TokenStream> {
323344 extractors. push ( body_extractor ( body. kind , & ty) ) ;
324345 call_args. push ( quote ! { body } ) ;
325346 }
347+ if let Some ( multipart) = & operation. multipart {
348+ let ty = multipart. name . to_token ( ) ;
349+ extractors. push ( quote ! { body: #ty } ) ;
350+ call_args. push ( quote ! { body } ) ;
351+ }
326352
327353 return Ok ( quote ! {
328354 async fn #handler<T : Api >( #( #extractors) , * ) -> #response {
@@ -665,3 +691,140 @@ fn emit_cookie_binding(param: &CookieParam) -> Result<TokenStream> {
665691 } ;
666692 } ) ;
667693}
694+
695+ /// Emit a `multipart/form-data` extractor: a per-operation struct of decoded
696+ /// fields plus a hand-written `axum::extract::FromRequest` implementation.
697+ ///
698+ /// axum has no typed multipart extractor, so the implementation drives
699+ /// `axum::extract::Multipart`, reads each declared field (text scalars are
700+ /// parsed with `FromStr`; binary/file fields are read as raw bytes), and
701+ /// returns a `400 Bad Request` with a short plaintext reason on a missing
702+ /// required field or an unparseable value. Unknown fields are ignored; a
703+ /// repeated field keeps its last value.
704+ ///
705+ /// The struct is generated per operation (rather than reusing a component
706+ /// model), so multipart works under any model configuration — including
707+ /// `models: false` with cross-file `import-mapping`.
708+ fn emit_multipart ( multipart : & Multipart ) -> Result < Vec < TokenStream > > {
709+ let name = multipart. name . to_token ( ) ;
710+
711+ let mut field_defs = Vec :: with_capacity ( multipart. fields . len ( ) ) ;
712+ let mut accumulators = Vec :: with_capacity ( multipart. fields . len ( ) ) ;
713+ let mut arms = Vec :: with_capacity ( multipart. fields . len ( ) ) ;
714+ let mut inits = Vec :: with_capacity ( multipart. fields . len ( ) ) ;
715+ for field in & multipart. fields {
716+ let ident = field. rust_name . to_token ( ) ;
717+ let ty = emit_type ( & field. ty ) ?;
718+ let field_ty = if field. optional {
719+ quote ! { Option <#ty> }
720+ } else {
721+ quote ! { #ty }
722+ } ;
723+ field_defs. push ( quote ! { pub #ident: #field_ty, } ) ;
724+ accumulators. push ( quote ! { let mut #ident: Option <#ty> = None ; } ) ;
725+ arms. push ( emit_multipart_arm ( field) ?) ;
726+ inits. push ( emit_multipart_init ( field) ) ;
727+ }
728+
729+ let struct_def = quote ! {
730+ #[ derive( Debug , Clone ) ]
731+ pub struct #name {
732+ #( #field_defs) *
733+ }
734+ } ;
735+
736+ let impl_block = quote ! {
737+ impl <S > axum:: extract:: FromRequest <S > for #name
738+ where
739+ S : Send + Sync ,
740+ {
741+ type Rejection = ( axum:: http:: StatusCode , String ) ;
742+
743+ async fn from_request(
744+ request: axum:: extract:: Request ,
745+ state: & S ,
746+ ) -> Result <Self , Self :: Rejection > {
747+ let mut multipart = <axum:: extract:: Multipart as axum:: extract:: FromRequest <S >>:: from_request(
748+ request,
749+ state,
750+ )
751+ . await
752+ . map_err( |error| return ( axum:: http:: StatusCode :: BAD_REQUEST , error. to_string( ) ) ) ?;
753+ #( #accumulators) *
754+ while let Some ( field) = multipart
755+ . next_field( )
756+ . await
757+ . map_err( |error| return ( axum:: http:: StatusCode :: BAD_REQUEST , error. to_string( ) ) ) ?
758+ {
759+ let field_name = field. name( ) . map( |name| return name. to_owned( ) ) ;
760+ match field_name. as_deref( ) {
761+ #( #arms) *
762+ _ => { }
763+ }
764+ }
765+ return Ok ( Self { #( #inits) , * } ) ;
766+ }
767+ }
768+ } ;
769+
770+ return Ok ( vec ! [ struct_def, impl_block] ) ;
771+ }
772+
773+ /// Emit the `match` arm that reads one multipart field into its accumulator: raw
774+ /// bytes for a file field, a verbatim `String`, or a `trim()`-parsed scalar.
775+ fn emit_multipart_arm ( field : & MultipartField ) -> Result < TokenStream > {
776+ let ident = field. rust_name . to_token ( ) ;
777+ let wire = & field. wire_name ;
778+
779+ let read = if field. is_file {
780+ quote ! {
781+ let value = field
782+ . bytes( )
783+ . await
784+ . map_err( |error| return ( axum:: http:: StatusCode :: BAD_REQUEST , error. to_string( ) ) ) ?;
785+ #ident = Some ( value. to_vec( ) ) ;
786+ }
787+ } else if matches ! ( field. ty, RustType :: String ) {
788+ quote ! {
789+ let value = field
790+ . text( )
791+ . await
792+ . map_err( |error| return ( axum:: http:: StatusCode :: BAD_REQUEST , error. to_string( ) ) ) ?;
793+ #ident = Some ( value) ;
794+ }
795+ } else {
796+ let ty = emit_type ( & field. ty ) ?;
797+ let invalid_msg = format ! ( "multipart field `{wire}` has an invalid value" ) ;
798+ quote ! {
799+ let text = field
800+ . text( )
801+ . await
802+ . map_err( |error| return ( axum:: http:: StatusCode :: BAD_REQUEST , error. to_string( ) ) ) ?;
803+ let value = match text. trim( ) . parse:: <#ty>( ) {
804+ Ok ( parsed) => parsed,
805+ Err ( _) => return Err ( ( axum:: http:: StatusCode :: BAD_REQUEST , #invalid_msg. to_owned( ) ) ) ,
806+ } ;
807+ #ident = Some ( value) ;
808+ }
809+ } ;
810+
811+ return Ok ( quote ! {
812+ Some ( #wire) => {
813+ #read
814+ }
815+ } ) ;
816+ }
817+
818+ /// Emit the struct-literal initialiser for one multipart field. A non-optional
819+ /// field is unwrapped with a `400` on absence; an optional field passes its
820+ /// `Option<..>` accumulator straight through via field-init shorthand.
821+ fn emit_multipart_init ( field : & MultipartField ) -> TokenStream {
822+ let ident = field. rust_name . to_token ( ) ;
823+ if field. optional {
824+ return quote ! { #ident } ;
825+ }
826+ let missing_msg = format ! ( "missing required multipart field `{}`" , field. wire_name) ;
827+ return quote ! {
828+ #ident: #ident. ok_or( ( axum:: http:: StatusCode :: BAD_REQUEST , #missing_msg. to_owned( ) ) ) ?
829+ } ;
830+ }
0 commit comments