Skip to content

Commit 4380522

Browse files
authored
feat: content types (#17)
1 parent 2e45aca commit 4380522

16 files changed

Lines changed: 590 additions & 44 deletions

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,18 @@ faithfully rather than emit subtly wrong code.
130130
- **Component `$ref` parameters and request bodies** —
131131
`#/components/parameters/*` and `#/components/requestBodies/*` are resolved
132132
within the same document.
133-
- **JSON request bodies** — a `$ref` or a scalar.
133+
- **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.
141+
- **Response bodies** — the same content-type selection logic applies to
142+
response bodies: JSON (broadly matched), `text/plain` (→ `String`), or form
143+
(→ `$ref` object struct), with JSON taking priority when multiple are present.
144+
A response with no supported content type is emitted as bodyless.
134145
- **Responses** — keyed by an explicit status code, the `default` catch-all, or
135146
a range (`5XX`), including component `$ref` responses
136147
(`#/components/responses/...`). A fixed code is emitted as a constant;
@@ -165,6 +176,11 @@ faithfully rather than emit subtly wrong code.
165176
response header declared via `$ref`, a response header with an invalid HTTP
166177
header name, or two response headers whose names collide when mapped to the
167178
same Rust field (e.g. `X-Foo` and `X_Foo`).
179+
- A request body whose only content type is unsupported (e.g. `image/png`
180+
only). An unsupported-only _response_ body is emitted as bodyless instead.
181+
- A `text/plain` body whose schema is not `type: string`.
182+
- A form (`application/x-www-form-urlencoded`) body whose schema is not a `$ref`
183+
to an object schema.
168184

169185
## Coverage
170186

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

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::emit::doc_attr;
88
use crate::emit::emit_type;
99
use crate::emit::models::emit_struct;
1010
use crate::error::Result;
11+
use crate::ir::Body;
1112
use crate::ir::CookieParam;
1213
use crate::ir::Cookies;
1314
use crate::ir::HeaderParam;
@@ -29,6 +30,24 @@ impl crate::emit::ServerEmitter for AxumServer {
2930
}
3031
}
3132

33+
/// The handler extractor pattern + type for a request body of the given kind.
34+
fn body_extractor(kind: crate::ir::BodyKind, ty: &TokenStream) -> TokenStream {
35+
return match kind {
36+
crate::ir::BodyKind::Json => quote! { axum::Json(body): axum::Json<#ty> },
37+
crate::ir::BodyKind::Text => quote! { body: String },
38+
crate::ir::BodyKind::Form => quote! { axum::Form(body): axum::Form<#ty> },
39+
};
40+
}
41+
42+
/// The response tuple term that renders a body of the given kind.
43+
fn response_body_term(kind: crate::ir::BodyKind) -> TokenStream {
44+
return match kind {
45+
crate::ir::BodyKind::Json => quote! { axum::Json(body) },
46+
crate::ir::BodyKind::Text => quote! { body },
47+
crate::ir::BodyKind::Form => quote! { axum::Form(body) },
48+
};
49+
}
50+
3251
/// Emit the axum server interface: the `Api` trait, per-operation response
3352
/// enums, the `Router` builder, and the internal handler functions.
3453
fn service_items(service: &Service) -> Result<Vec<TokenStream>> {
@@ -100,7 +119,7 @@ fn emit_method_args(operation: &Operation) -> Result<Vec<TokenStream>> {
100119
args.push(quote! { cookies: #ty });
101120
}
102121
if let Some(body) = &operation.body {
103-
let ty = emit_type(body)?;
122+
let ty = emit_type(&body.ty)?;
104123
args.push(quote! { body: #ty });
105124
}
106125
return Ok(args);
@@ -152,7 +171,7 @@ fn emit_fixed_response(
152171
name: &proc_macro2::Ident,
153172
variant: &proc_macro2::Ident,
154173
code: u16,
155-
body: &Option<RustType>,
174+
body: &Option<Body>,
156175
) -> Result<(TokenStream, TokenStream)> {
157176
let code = proc_macro2::Literal::u16_unsuffixed(code);
158177
let status = quote! {
@@ -163,12 +182,13 @@ fn emit_fixed_response(
163182
};
164183
let result = match body {
165184
Some(body) => {
166-
let ty = emit_type(body)?;
185+
let ty = emit_type(&body.ty)?;
167186
let variant_def = quote! { #variant(#ty) };
187+
let term = response_body_term(body.kind);
168188
let arm = quote! {
169189
#name::#variant(body) => {
170190
#status
171-
(STATUS, axum::Json(body)).into_response()
191+
(STATUS, #term).into_response()
172192
}
173193
};
174194
(variant_def, arm)
@@ -193,14 +213,15 @@ fn emit_fixed_response(
193213
fn emit_dynamic_response(
194214
name: &proc_macro2::Ident,
195215
variant: &proc_macro2::Ident,
196-
body: &Option<RustType>,
216+
body: &Option<Body>,
197217
) -> Result<(TokenStream, TokenStream)> {
198218
let result = match body {
199219
Some(body) => {
200-
let ty = emit_type(body)?;
220+
let ty = emit_type(&body.ty)?;
201221
let variant_def = quote! { #variant(axum::http::StatusCode, #ty) };
222+
let term = response_body_term(body.kind);
202223
let arm = quote! {
203-
#name::#variant(status, body) => (status, axum::Json(body)).into_response(),
224+
#name::#variant(status, body) => (status, #term).into_response(),
204225
};
205226
(variant_def, arm)
206227
}
@@ -298,8 +319,8 @@ fn emit_handler(operation: &Operation) -> Result<TokenStream> {
298319
call_args.push(quote! { cookies });
299320
}
300321
if let Some(body) = &operation.body {
301-
let ty = emit_type(body)?;
302-
extractors.push(quote! { axum::Json(body): axum::Json<#ty> });
322+
let ty = emit_type(&body.ty)?;
323+
extractors.push(body_extractor(body.kind, &ty));
303324
call_args.push(quote! { body });
304325
}
305326

@@ -329,7 +350,7 @@ fn emit_response_with_headers(
329350
field_defs.push(quote! { status: axum::http::StatusCode });
330351
}
331352
if let Some(body) = &case.body {
332-
let ty = emit_type(body)?;
353+
let ty = emit_type(&body.ty)?;
333354
field_defs.push(quote! { body: #ty });
334355
}
335356
let mut header_field_defs = Vec::with_capacity(case.headers.len());
@@ -380,11 +401,14 @@ fn emit_response_with_headers(
380401
inserts.push(emit_response_header_insert(header));
381402
}
382403

383-
let body_term = if case.body.is_some() {
384-
quote! { , axum::Json(body) }
385-
} else {
386-
quote! {}
387-
};
404+
let body_term = case
405+
.body
406+
.as_ref()
407+
.map(|b| {
408+
let t = response_body_term(b.kind);
409+
return quote! { , #t };
410+
})
411+
.unwrap_or_default();
388412

389413
let arm = quote! {
390414
#name::#variant { #(#binds),* } => {

crates/oapi-codegen/src/ir.rs

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,27 @@ impl RustType {
174174
}
175175
}
176176

177+
/// A request or response body: its Rust type plus the wire content type that
178+
/// selects the axum extractor / response wrapper.
179+
#[derive(Debug, Clone, PartialEq)]
180+
pub struct Body {
181+
/// The Rust type of the decoded body.
182+
pub ty: RustType,
183+
/// The content type that selects the extractor / response wrapper.
184+
pub kind: BodyKind,
185+
}
186+
187+
/// The supported request/response content type for a [`Body`].
188+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189+
pub enum BodyKind {
190+
/// `application/json` (and `+json` / charset variants) → `axum::Json`.
191+
Json,
192+
/// `text/plain` → `String`.
193+
Text,
194+
/// `application/x-www-form-urlencoded` → `axum::Form`.
195+
Form,
196+
}
197+
177198
/// A generated axum server interface: the `Api` trait plus the operations that
178199
/// back its `Router`.
179200
#[derive(Debug, Default, Clone, PartialEq)]
@@ -211,8 +232,9 @@ pub struct Operation {
211232
/// parameters. Its name doubles as the generated `FromRequestParts`
212233
/// extractor type and the `Api` method's `cookies` argument type.
213234
pub cookies: Option<Cookies>,
214-
/// JSON request body type, when the operation declares one.
215-
pub body: Option<RustType>,
235+
/// Request body (JSON, `text/plain`, or form), when the operation declares
236+
/// a supported content type.
237+
pub body: Option<Body>,
216238
/// Response variants, in declaration order.
217239
pub responses: Vec<ResponseCase>,
218240
}
@@ -292,8 +314,9 @@ pub struct ResponseCase {
292314
pub variant: RustIdent,
293315
/// How the variant's HTTP status code is determined.
294316
pub status: ResponseStatus,
295-
/// JSON response body type, when the response declares content.
296-
pub body: Option<RustType>,
317+
/// Response body (JSON, `text/plain`, or form), when the response declares
318+
/// a supported content type.
319+
pub body: Option<Body>,
297320
/// Declared response headers written by the generated `IntoResponse`, in
298321
/// declaration order. Empty means no headers (the pre-C5 variant shape).
299322
pub headers: Vec<ResponseHeader>,

0 commit comments

Comments
 (0)