Skip to main content

siderite_core/
form.rs

1//! Form and multipart body extractors.
2
3use crate::body::Body;
4use crate::error::ApiError;
5use crate::extract::{FromRequest, Request, invalid, validated};
6use bytes::Bytes;
7use http::StatusCode;
8use serde::de::DeserializeOwned;
9use siderite_openapi::{Operation, Schema, SchemaObject, SchemaRegistry};
10use siderite_validation::{Validate, ValidationContext, text_pairs_to_value};
11
12/// Default maximum size of a `multipart/form-data` body (10 MiB).
13pub const DEFAULT_MULTIPART_LIMIT: usize = 10 * 1024 * 1024;
14
15/// URL-encoded form body (`application/x-www-form-urlencoded`).
16///
17/// `T` is deserialized from the request body. A wrong `Content-Type` yields
18/// `415`; malformed or semantically invalid fields yield `422` with code
19/// `form_invalid`.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub struct Form<T>(pub T);
22
23impl<T> FromRequest for Form<T>
24where
25    T: DeserializeOwned + Send + Schema + Validate + 'static,
26{
27    async fn from_request(req: Request) -> Result<Self, ApiError> {
28        use axum::extract::FromRequest as _;
29        use axum::extract::rejection::FormRejection;
30        match axum::Form::<Vec<(String, String)>>::from_request(req.map(Body::into_inner), &())
31            .await
32        {
33            Ok(axum::Form(pairs)) => validated(
34                text_pairs_to_value(pairs),
35                ValidationContext::for_text().at_root("body"),
36            )
37            .map(Form),
38            Err(rejection) => {
39                let message = rejection.body_text();
40                Err(match rejection {
41                    FormRejection::FailedToDeserializeForm(_)
42                    | FormRejection::FailedToDeserializeFormBody(_) => {
43                        invalid("body", "form_invalid", message)
44                    }
45                    FormRejection::InvalidFormContentType(_) => {
46                        ApiError::new(StatusCode::UNSUPPORTED_MEDIA_TYPE, message)
47                    }
48                    FormRejection::BytesRejection(rejection) => {
49                        ApiError::new(rejection.status(), message)
50                    }
51                    other => ApiError::new(other.status(), message),
52                })
53            }
54        }
55    }
56
57    fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
58        let schema = registry.subschema::<T>();
59        op.set_request_body("application/x-www-form-urlencoded", schema, true);
60    }
61}
62
63/// Multipart form body (`multipart/form-data`).
64///
65/// Bodies larger than [`DEFAULT_MULTIPART_LIMIT`] are rejected with `413`
66/// when the stream is read. A wrong `Content-Type` yields `415`.
67#[derive(Debug)]
68pub struct Multipart {
69    inner: axum::extract::Multipart,
70}
71
72impl FromRequest for Multipart {
73    async fn from_request(mut req: Request) -> Result<Self, ApiError> {
74        let content_type = req
75            .headers()
76            .get(http::header::CONTENT_TYPE)
77            .and_then(|v| v.to_str().ok())
78            .unwrap_or("");
79        if !content_type
80            .to_ascii_lowercase()
81            .starts_with("multipart/form-data")
82        {
83            return Err(ApiError::new(
84                StatusCode::UNSUPPORTED_MEDIA_TYPE,
85                "multipart requests must have Content-Type: multipart/form-data",
86            ));
87        }
88        axum::extract::DefaultBodyLimit::max(DEFAULT_MULTIPART_LIMIT).apply(&mut req);
89        use axum::extract::FromRequest as _;
90        match axum::extract::Multipart::from_request(req.map(Body::into_inner), &()).await {
91            Ok(inner) => Ok(Self { inner }),
92            Err(rejection) => Err(ApiError::bad_request(rejection.body_text())),
93        }
94    }
95
96    fn describe(op: &mut Operation, _registry: &mut SchemaRegistry) {
97        op.set_request_body("multipart/form-data", SchemaObject::of_type("object"), true);
98    }
99}
100
101impl Multipart {
102    /// Yield the next field in the multipart stream.
103    ///
104    /// # Errors
105    /// Returns [`ApiError`] if the stream is malformed or exceeds the size
106    /// limit (`413`).
107    pub async fn next_field(&mut self) -> Result<Option<MultipartField<'_>>, ApiError> {
108        match self.inner.next_field().await {
109            Ok(Some(field)) => Ok(Some(MultipartField { inner: field })),
110            Ok(None) => Ok(None),
111            Err(err) => Err(multipart_error(err)),
112        }
113    }
114}
115
116/// One field of a [`Multipart`] body.
117#[derive(Debug)]
118pub struct MultipartField<'a> {
119    inner: axum::extract::multipart::Field<'a>,
120}
121
122impl MultipartField<'_> {
123    /// The field's `name` from `Content-Disposition`, if present.
124    #[must_use]
125    pub fn name(&self) -> Option<&str> {
126        self.inner.name()
127    }
128
129    /// The field's `filename` from `Content-Disposition`, if present.
130    #[must_use]
131    pub fn file_name(&self) -> Option<&str> {
132        self.inner.file_name()
133    }
134
135    /// The field's `Content-Type`, if present.
136    #[must_use]
137    pub fn content_type(&self) -> Option<&str> {
138        self.inner.content_type()
139    }
140
141    /// Collect the field's bytes.
142    ///
143    /// # Errors
144    /// Returns [`ApiError`] if reading fails or the body exceeds the size
145    /// limit (`413`).
146    pub async fn bytes(self) -> Result<Bytes, ApiError> {
147        self.inner.bytes().await.map_err(multipart_error)
148    }
149
150    /// Collect the field as UTF-8 text.
151    ///
152    /// # Errors
153    /// Returns [`ApiError`] if reading fails, the body exceeds the size
154    /// limit (`413`), or the bytes are not valid UTF-8.
155    pub async fn text(self) -> Result<String, ApiError> {
156        self.inner.text().await.map_err(multipart_error)
157    }
158}
159
160fn multipart_error(err: axum::extract::multipart::MultipartError) -> ApiError {
161    ApiError::new(err.status(), err.body_text())
162}