Skip to content

Commit 663aacc

Browse files
authored
feat: multipart/form-data request bodies (#18)
1 parent 4380522 commit 663aacc

19 files changed

Lines changed: 1030 additions & 24 deletions

Cargo.lock

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,17 +131,30 @@ faithfully rather than emit subtly wrong code.
131131
`#/components/parameters/*` and `#/components/requestBodies/*` are resolved
132132
within the same document.
133133
- **Request bodies** — a single content type is selected per body by priority
134-
(JSON > form > text). JSON (matched as `application/json`, including variants
135-
with `; charset=utf-8` or `+json` suffixes) is deserialized via `axum::Json`;
136-
`text/plain` is read as a `String`; `application/x-www-form-urlencoded` is
137-
decoded into a named struct via `axum::Form` (the schema must be a `$ref` to
138-
an object). When multiple supported types are listed, JSON takes precedence;
139-
if none are supported, the body is omitted from the handler signature.
140-
Multipart and multi-content-type negotiation are planned.
134+
(JSON > form > multipart > text). JSON (matched as `application/json`,
135+
including variants with `; charset=utf-8` or `+json` suffixes) is deserialized
136+
via `axum::Json`; `text/plain` is read as a `String`;
137+
`application/x-www-form-urlencoded` is decoded into a named struct via
138+
`axum::Form` (the schema must be a `$ref` to an object). `multipart/form-data`
139+
is decoded into a dedicated struct via a generated `<Op>Multipart` extractor
140+
(a hand-written `axum::extract::FromRequest` driving `axum::extract::Multipart`,
141+
since axum has no typed multipart extractor): the schema must be an object —
142+
declared inline or as a same-document `$ref` — whose properties are scalars or
143+
binary/file strings (`format: binary`/`byte` → `Vec<u8>`); a missing required
144+
field or an unparseable value yields a `400 Bad Request`. The extractor struct
145+
is generated per operation rather than reusing a component model, so multipart
146+
works under any model configuration (including `models: false`). A server that
147+
uses a multipart body needs
148+
`axum = { version = "0.8", features = ["multipart"] }`. When multiple supported
149+
types are listed, the priority above decides; if none are supported, the body
150+
is omitted from the handler signature. Multi-content-type negotiation is
151+
planned.
141152
- **Response bodies** — the same content-type selection logic applies to
142153
response bodies: JSON (broadly matched), `text/plain` (→ `String`), or form
143154
(→ `$ref` object struct), with JSON taking priority when multiple are present.
144-
A response with no supported content type is emitted as bodyless.
155+
`multipart/form-data` is request-only (axum has no multipart response writer),
156+
so a multipart-only response — like any response with no supported content
157+
type — is emitted as bodyless.
145158
- **Responses** — keyed by an explicit status code, the `default` catch-all, or
146159
a range (`5XX`), including component `$ref` responses
147160
(`#/components/responses/...`). A fixed code is emitted as a constant;
@@ -181,6 +194,10 @@ faithfully rather than emit subtly wrong code.
181194
- A `text/plain` body whose schema is not `type: string`.
182195
- A form (`application/x-www-form-urlencoded`) body whose schema is not a `$ref`
183196
to an object schema.
197+
- A `multipart/form-data` body whose schema is not an object, whose object has a
198+
non-scalar property (a nested object or array), or that references a
199+
cross-file/external schema — either as the body itself or as one of its
200+
properties (such fields cannot be enumerated to build the extractor).
184201

185202
## Coverage
186203

crates/oapi-codegen/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ syn = { version = "2.0.118", features = ["full"] }
2929
# them); axum type-checks the generated server output; openapiv3 backs the
3030
# coverage-matrix anchor in tests/coverage.rs. None are needed by the generator
3131
# itself.
32-
axum = "0.8.9"
32+
axum = { version = "0.8.9", features = ["multipart"] }
3333
axum-extra = { version = "0.12.6", features = ["query", "cookie"] }
3434
chrono = { version = "0.4.45", features = ["serde"] }
3535
openapiv3 = "2.2.0"

crates/oapi-codegen/src/emit/axum.rs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ use crate::ir::CookieParam;
1313
use crate::ir::Cookies;
1414
use crate::ir::HeaderParam;
1515
use crate::ir::Headers;
16+
use crate::ir::Multipart;
17+
use crate::ir::MultipartField;
1618
use crate::ir::Operation;
1719
use crate::ir::ResponseCase;
1820
use 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.
3439
fn 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`].
4354
fn 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

Comments
 (0)