Skip to main content

doido_controller/
context.rs

1use axum::{
2    body::Body,
3    extract::{FromRequestParts, RawPathParams, Request},
4    http::{header, HeaderValue, StatusCode},
5    response::Response,
6};
7use doido_model::sea_orm::DatabaseConnection;
8use serde::de::DeserializeOwned;
9use serde::Serialize;
10
11/// Maximum request body size accepted by [`Context::form`]/[`Context::body_json`].
12const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
13
14/// Per-request context passed to every action.
15pub struct Context {
16    pub(crate) parts: http::request::Parts,
17    /// Taken (set to `None`) once read by [`form`](Self::form)/[`body_json`](Self::body_json).
18    pub(crate) body: Option<Body>,
19    /// Matched path parameters (e.g. `id` from `/posts/{id}`), in route order.
20    pub(crate) path_params: Vec<(String, String)>,
21}
22
23impl Context {
24    /// Central constructor used by the `#[controller]` macro. Splits the request,
25    /// captures matched path params, and retains the body for later reads.
26    pub async fn build(req: Request) -> Self {
27        let (mut parts, body) = req.into_parts();
28        let path_params = Self::extract_path_params(&mut parts).await;
29        Self {
30            parts,
31            body: Some(body),
32            path_params,
33        }
34    }
35
36    async fn extract_path_params(parts: &mut http::request::Parts) -> Vec<(String, String)> {
37        match RawPathParams::from_request_parts(parts, &()).await {
38            Ok(params) => params
39                .iter()
40                .map(|(k, v)| (k.to_string(), v.to_string()))
41                .collect(),
42            Err(_) => Vec::new(),
43        }
44    }
45
46    pub fn from_request_parts(parts: http::request::Parts) -> Self {
47        Self {
48            parts,
49            body: None,
50            path_params: Vec::new(),
51        }
52    }
53
54    pub fn from_request(parts: http::request::Parts, body: Body) -> Self {
55        Self {
56            parts,
57            body: Some(body),
58            path_params: Vec::new(),
59        }
60    }
61
62    /// The application's database connection (global pool installed at boot).
63    pub fn db(&self) -> &'static DatabaseConnection {
64        doido_model::pool::pool()
65    }
66
67    /// A matched path parameter by name, e.g. `ctx.param("id")` for `/posts/{id}`.
68    pub fn param(&self, name: &str) -> Option<&str> {
69        self.path_params
70            .iter()
71            .find(|(k, _)| k == name)
72            .map(|(_, v)| v.as_str())
73    }
74
75    /// Deserialize a URL-encoded (form) request body. Consumes the body.
76    pub async fn form<T: DeserializeOwned>(&mut self) -> doido_core::Result<T> {
77        let bytes = self.read_body().await?;
78        serde_urlencoded::from_bytes(&bytes)
79            .map_err(|e| doido_core::anyhow::anyhow!("form deserialization failed: {e}"))
80    }
81
82    /// Deserialize a JSON request body. Consumes the body.
83    pub async fn body_json<T: DeserializeOwned>(&mut self) -> doido_core::Result<T> {
84        let bytes = self.read_body().await?;
85        serde_json::from_slice(&bytes)
86            .map_err(|e| doido_core::anyhow::anyhow!("JSON body deserialization failed: {e}"))
87    }
88
89    async fn read_body(&mut self) -> doido_core::Result<Vec<u8>> {
90        let body = self
91            .body
92            .take()
93            .ok_or_else(|| doido_core::anyhow::anyhow!("request body already consumed"))?;
94        let bytes = axum::body::to_bytes(body, MAX_BODY_BYTES)
95            .await
96            .map_err(|e| doido_core::anyhow::anyhow!("failed to read request body: {e}"))?;
97        Ok(bytes.to_vec())
98    }
99
100    /// Deserialize typed params from the request URI query string.
101    pub fn params<T: serde::de::DeserializeOwned>(&self) -> doido_core::Result<T> {
102        let query = self.parts.uri.query().unwrap_or("");
103        serde_urlencoded::from_str(query)
104            .map_err(|e| doido_core::anyhow::anyhow!("params deserialization failed: {e}"))
105    }
106
107    /// Render a Tera view to an HTML 200 response.
108    ///
109    /// `template` is resolved by the global [`doido_view`] engine (installed at
110    /// boot) against `app/views`, with the `.html.tera` suffix added — e.g.
111    /// `"posts/index"` → `app/views/posts/index.html.tera`. A render failure (or
112    /// an uninitialised engine) yields a `500`.
113    pub fn render(&self, template: &str, data: serde_json::Value) -> Response {
114        match doido_view::render(template, &data) {
115            Ok(html) => Response::builder()
116                .status(StatusCode::OK)
117                .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
118                .body(Body::from(html))
119                .expect("valid html response"),
120            Err(error) => {
121                tracing::error!(%error, template, "view render failed");
122                Response::builder()
123                    .status(StatusCode::INTERNAL_SERVER_ERROR)
124                    .body(Body::from("Internal Server Error"))
125                    .expect("valid 500 response")
126            }
127        }
128    }
129
130    /// Return a JSON 200 response.
131    pub fn json<T: Serialize>(&self, data: T) -> Response {
132        let body = serde_json::to_vec(&data).unwrap_or_default();
133        Response::builder()
134            .status(StatusCode::OK)
135            .header(header::CONTENT_TYPE, "application/json")
136            .body(Body::from(body))
137            .unwrap()
138    }
139
140    /// Return a 302 redirect.
141    pub fn redirect_to(&self, location: impl AsRef<str>) -> Response {
142        Response::builder()
143            .status(StatusCode::FOUND)
144            .header(
145                header::LOCATION,
146                HeaderValue::from_str(location.as_ref()).unwrap(),
147            )
148            .body(Body::empty())
149            .unwrap()
150    }
151
152    /// Return a response with an explicit status code and empty body.
153    /// `code` must be a valid HTTP status code (100–999).
154    pub fn status(&self, code: u16) -> Response {
155        Response::builder()
156            .status(code)
157            .body(Body::empty())
158            .unwrap()
159    }
160
161    /// Get a request header by name (lowercase).
162    pub fn header(&self, name: &str) -> Option<&http::HeaderValue> {
163        self.parts.headers.get(name)
164    }
165}
166
167/// Lets a `#[controller]` action body evaluate to either a [`Response`] or a
168/// `Result<Response, E>`. The macro wraps every action body in
169/// `into_action_response()`, so actions can use `?` for fallible work (DB calls,
170/// body parsing) and an `Err` becomes a `500` response.
171pub trait IntoActionResponse {
172    fn into_action_response(self) -> Response;
173}
174
175impl IntoActionResponse for Response {
176    fn into_action_response(self) -> Response {
177        self
178    }
179}
180
181impl<E: std::fmt::Display> IntoActionResponse for Result<Response, E> {
182    fn into_action_response(self) -> Response {
183        match self {
184            Ok(response) => response,
185            Err(error) => {
186                tracing::error!(%error, "action returned an error");
187                Response::builder()
188                    .status(StatusCode::INTERNAL_SERVER_ERROR)
189                    .body(Body::from("Internal Server Error"))
190                    .expect("static 500 response is valid")
191            }
192        }
193    }
194}
195
196// `Context` must stay `Send` so controller handler futures (which hold a
197// `&mut Context` across `.await`) satisfy axum's `Handler` bound.
198const _: fn() = || {
199    fn assert_send<T: Send>() {}
200    assert_send::<Context>();
201};