Skip to main content

siderite_core/
extract.rs

1//! Request extraction.
2//!
3//! siderite owns its extractor traits so that every extractor can also
4//! *document itself* for OpenAPI. The `describe` hooks default to doing
5//! nothing, so a custom extractor needs only the extraction method.
6//!
7//! Handler arguments are extracted in order; all but the last must implement
8//! [`FromRequestParts`]. The last may consume the body ([`FromRequest`]).
9
10use crate::body::Body;
11use crate::error::ApiError;
12use http::request::Parts;
13use serde::de::DeserializeOwned;
14use serde_json::{Value, json};
15use siderite_openapi::{
16    Operation, Parameter, ParameterLocation, Schema, SchemaObject, SchemaRegistry,
17};
18use siderite_validation::{
19    Validate, ValidationContext, ValidationError, parse_value, text_pairs_to_value,
20};
21use std::future::Future;
22
23pub use crate::response::Json;
24
25/// An HTTP request with a siderite [`Body`].
26pub type Request = http::Request<Body>;
27
28/// Extract a value from request metadata (method, URI, headers, extensions).
29pub trait FromRequestParts: Sized + Send {
30    /// Whether extraction needs a request-owned background queue.
31    /// Custom wrappers delegating to BackgroundTasks must propagate this.
32    const BACKGROUND_TASKS: bool = false;
33
34    /// Perform the extraction.
35    fn from_request_parts(parts: &mut Parts)
36    -> impl Future<Output = Result<Self, ApiError>> + Send;
37
38    /// Document this extractor on `op` (parameters, security, ...).
39    fn describe(_op: &mut Operation, _registry: &mut SchemaRegistry) {}
40}
41
42/// Extract a value from the whole request, possibly consuming the body.
43pub trait FromRequest: Sized + Send {
44    /// Whether extraction needs a request-owned background queue.
45    const BACKGROUND_TASKS: bool = false;
46
47    /// Perform the extraction.
48    fn from_request(req: Request) -> impl Future<Output = Result<Self, ApiError>> + Send;
49
50    /// Document this extractor on `op` (request body, ...).
51    fn describe(_op: &mut Operation, _registry: &mut SchemaRegistry) {}
52}
53
54impl<T: FromRequestParts> FromRequest for T {
55    const BACKGROUND_TASKS: bool = T::BACKGROUND_TASKS;
56
57    fn from_request(req: Request) -> impl Future<Output = Result<Self, ApiError>> + Send {
58        let (mut parts, _body) = req.into_parts();
59        async move { T::from_request_parts(&mut parts).await }
60    }
61
62    fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
63        <T as FromRequestParts>::describe(op, registry);
64    }
65}
66
67/// Optional extraction: `None` when the input is absent, the error otherwise.
68///
69/// Only errors marked [`ApiError::absent`] become `None`; a header, query
70/// string or credential that is present but invalid still fails the request.
71impl<T: FromRequestParts> FromRequestParts for Option<T> {
72    const BACKGROUND_TASKS: bool = T::BACKGROUND_TASKS;
73
74    async fn from_request_parts(parts: &mut Parts) -> Result<Self, ApiError> {
75        match T::from_request_parts(parts).await {
76            Ok(value) => Ok(Some(value)),
77            Err(error) if error.is_absent() => Ok(None),
78            Err(error) => Err(error),
79        }
80    }
81
82    /// Documents `T`, with everything it adds marked optional. Security
83    /// schemes it requires become an alternative next to the requirements
84    /// that held before.
85    fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
86        let before = op.parameters.len();
87        let had_body = op.request_body.is_some();
88        let security_before = std::mem::take(&mut op.security);
89        <T as FromRequestParts>::describe(op, registry);
90        let added = std::mem::replace(&mut op.security, security_before);
91        if !added.is_empty() {
92            let base = if op.security.is_empty() {
93                vec![std::collections::BTreeMap::new()]
94            } else {
95                op.security.clone()
96            };
97            let mut alternatives = base.clone();
98            for alternative in &base {
99                for requirement in &added {
100                    let mut with = alternative.clone();
101                    with.extend(requirement.clone());
102                    if !alternatives.contains(&with) {
103                        alternatives.push(with);
104                    }
105                }
106            }
107            op.security = alternatives;
108        }
109        for p in &mut op.parameters[before..] {
110            // OpenAPI requires path parameters to be required.
111            if p.location != ParameterLocation::Path {
112                p.required = false;
113            }
114        }
115        if !had_body && let Some(body) = &mut op.request_body {
116            body.required = false;
117        }
118    }
119}
120
121/// Build a 422 error with one location-tagged entry.
122pub(crate) fn invalid(location: &str, code: &str, message: impl Into<String>) -> ApiError {
123    ApiError::unprocessable(json!([{
124        "location": [location],
125        "code": code,
126        "message": message.into(),
127    }]))
128}
129
130/// Map a [`ValidationError`] to a 422 problem document.
131pub(crate) fn validation_problem(error: &ValidationError) -> ApiError {
132    ApiError::unprocessable(serde_json::to_value(&error.errors).unwrap_or(Value::Null))
133}
134
135/// Run the validation pipeline (`prepare` → deserialize → `validate`).
136pub(crate) fn validated<T>(input: Value, ctx: ValidationContext) -> Result<T, ApiError>
137where
138    T: DeserializeOwned + Validate,
139{
140    parse_value(input, ctx).map_err(|e| validation_problem(&e))
141}
142
143/// Parse `a=1&a=2&b=x` into a JSON object (repeated keys become arrays).
144pub(crate) fn urlencoded_to_value(text: &str) -> Result<Value, serde_urlencoded::de::Error> {
145    serde_urlencoded::from_str::<Vec<(String, String)>>(text).map(text_pairs_to_value)
146}
147
148/// Path parameter extractor (`/users/{id}`).
149///
150/// `T` may be a scalar, a tuple (positional) or a struct (by name).
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub struct Path<T>(pub T);
153
154impl<T> FromRequestParts for Path<T>
155where
156    T: DeserializeOwned + Send + Schema + Validate + 'static,
157{
158    async fn from_request_parts(parts: &mut Parts) -> Result<Self, ApiError> {
159        use axum::extract::FromRequestParts as _;
160        use axum::extract::rejection::PathRejection;
161        match axum::extract::Path::<T>::from_request_parts(parts, &()).await {
162            Ok(axum::extract::Path(value)) => {
163                let mut ctx = ValidationContext::for_text().at_root("path");
164                value.validate(&mut ctx);
165                if ctx.has_errors() {
166                    Err(validation_problem(&ctx.take_errors()))
167                } else {
168                    Ok(Path(value))
169                }
170            }
171            Err(PathRejection::FailedToDeserializePathParams(err)) => {
172                Err(invalid("path", "path_invalid", err.body_text()))
173            }
174            Err(other) => Err(ApiError::internal(other)),
175        }
176    }
177
178    fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
179        let schema = T::schema(registry);
180        if let Some(props) = object_properties(&schema) {
181            for (name, prop, _) in props {
182                op.add_parameter(Parameter::new(name, ParameterLocation::Path, true, prop));
183            }
184        } else if let Some(Value::Array(items)) = schema.get("prefixItems") {
185            for item in items {
186                let item = serde_json::from_value(item.clone()).unwrap_or_default();
187                op.add_parameter(Parameter::new("", ParameterLocation::Path, true, item));
188            }
189        } else {
190            op.add_parameter(Parameter::new("", ParameterLocation::Path, true, schema));
191        }
192    }
193}
194
195/// Query-string extractor. Each field of `T` becomes a query parameter.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub struct Query<T>(pub T);
198
199impl<T> FromRequestParts for Query<T>
200where
201    T: DeserializeOwned + Send + Schema + Validate + 'static,
202{
203    async fn from_request_parts(parts: &mut Parts) -> Result<Self, ApiError> {
204        let query = parts.uri.query().unwrap_or_default();
205        let input = urlencoded_to_value(query)
206            .map_err(|e| invalid("query", "query_invalid", e.to_string()))?;
207        validated(input, ValidationContext::for_text().at_root("query"))
208            .map(Query)
209            .map_err(|e| if query.is_empty() { e.absent() } else { e })
210    }
211
212    fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
213        let schema = T::schema(registry);
214        for (name, prop, required) in object_properties(&schema).unwrap_or_default() {
215            op.add_parameter(Parameter::new(
216                name,
217                ParameterLocation::Query,
218                required,
219                prop,
220            ));
221        }
222    }
223}
224
225/// `(name, schema, required)` for each property of an object schema.
226pub(crate) fn object_properties(
227    schema: &SchemaObject,
228) -> Option<Vec<(String, SchemaObject, bool)>> {
229    let Value::Object(props) = schema.get("properties")? else {
230        return None;
231    };
232    let required: Vec<&str> = schema
233        .get("required")
234        .and_then(Value::as_array)
235        .map(|r| r.iter().filter_map(Value::as_str).collect())
236        .unwrap_or_default();
237    Some(
238        props
239            .iter()
240            .map(|(name, prop)| {
241                let prop = serde_json::from_value(prop.clone()).unwrap_or_default();
242                (name.clone(), prop, required.contains(&name.as_str()))
243            })
244            .collect(),
245    )
246}
247
248impl<T> FromRequest for Json<T>
249where
250    T: DeserializeOwned + Send + Schema + Validate + 'static,
251{
252    async fn from_request(req: Request) -> Result<Self, ApiError> {
253        use axum::extract::FromRequest as _;
254        use axum::extract::rejection::JsonRejection;
255        // Read through the size-limited JSON extractor (keeps 413/415), then
256        // run the validation pipeline on the raw value.
257        let input = match axum::Json::<Value>::from_request(req.map(Body::into_inner), &()).await {
258            Ok(axum::Json(value)) => value,
259            Err(rejection) => {
260                let message = rejection.body_text();
261                return Err(match rejection {
262                    JsonRejection::JsonDataError(_) | JsonRejection::JsonSyntaxError(_) => {
263                        invalid("body", "json_invalid", message)
264                    }
265                    JsonRejection::MissingJsonContentType(_) => {
266                        ApiError::new(http::StatusCode::UNSUPPORTED_MEDIA_TYPE, message)
267                    }
268                    other => ApiError::new(other.status(), message),
269                });
270            }
271        };
272        validated(input, ValidationContext::new().at_root("body")).map(Json)
273    }
274
275    fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
276        let schema = registry.subschema::<T>();
277        op.set_request_body("application/json", schema, true);
278    }
279}
280
281/// The raw request, for handlers that need full control (must be last).
282#[derive(Debug)]
283pub struct RawRequest(pub Request);
284
285impl FromRequest for RawRequest {
286    async fn from_request(req: Request) -> Result<Self, ApiError> {
287        Ok(RawRequest(req))
288    }
289}
290
291/// The request method.
292impl FromRequestParts for http::Method {
293    async fn from_request_parts(parts: &mut Parts) -> Result<Self, ApiError> {
294        Ok(parts.method.clone())
295    }
296}
297
298/// The request URI.
299impl FromRequestParts for http::Uri {
300    async fn from_request_parts(parts: &mut Parts) -> Result<Self, ApiError> {
301        Ok(parts.uri.clone())
302    }
303}
304
305/// All request headers.
306impl FromRequestParts for http::HeaderMap {
307    async fn from_request_parts(parts: &mut Parts) -> Result<Self, ApiError> {
308        Ok(parts.headers.clone())
309    }
310}