Skip to main content

doido_controller/
context.rs

1use crate::cookies::CookieJar;
2use crate::flash::Flash;
3use crate::session::{CookieSessionStore, EncryptedCookieSessionStore, Session};
4use axum::{
5    body::Body,
6    extract::{FromRequestParts, RawPathParams, Request},
7    http::{header, HeaderValue, StatusCode},
8    response::Response,
9};
10use doido_model::sea_orm::DatabaseConnection;
11use serde::de::DeserializeOwned;
12use serde::Serialize;
13use std::collections::BTreeMap;
14
15/// Maximum request body size accepted by [`Context::form`]/[`Context::body_json`].
16const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
17
18/// Cookie name for the encrypted session.
19const SESSION_COOKIE: &str = "_doido_session";
20/// Cookie name for the flash.
21const FLASH_COOKIE: &str = "_doido_flash";
22
23/// Per-request context passed to every action.
24pub struct Context {
25    pub(crate) parts: http::request::Parts,
26    /// Taken (set to `None`) once read by [`form`](Self::form)/[`body_json`](Self::body_json).
27    pub(crate) body: Option<Body>,
28    /// Matched path parameters (e.g. `id` from `/posts/{id}`), in route order.
29    pub(crate) path_params: Vec<(String, String)>,
30    /// Encrypted session, loaded from the session cookie on first access.
31    pub(crate) session: Option<Session>,
32    /// Flash bag, loaded from the flash cookie on first access.
33    pub(crate) flash: Option<Flash>,
34    /// Snapshot of the incoming flash, used to sweep it after one request.
35    pub(crate) flash_loaded: BTreeMap<String, String>,
36    /// Cookie jar, built from the request `Cookie` header on first access.
37    pub(crate) cookies: Option<CookieJar>,
38}
39
40impl Context {
41    /// Central constructor used by the `#[controller]` macro. Splits the request,
42    /// captures matched path params, and retains the body for later reads.
43    pub async fn build(req: Request) -> Self {
44        let (mut parts, body) = req.into_parts();
45        let path_params = Self::extract_path_params(&mut parts).await;
46        Self {
47            parts,
48            body: Some(body),
49            path_params,
50            session: None,
51            flash: None,
52            flash_loaded: BTreeMap::new(),
53            cookies: None,
54        }
55    }
56
57    async fn extract_path_params(parts: &mut http::request::Parts) -> Vec<(String, String)> {
58        match RawPathParams::from_request_parts(parts, &()).await {
59            Ok(params) => params
60                .iter()
61                .map(|(k, v)| (k.to_string(), v.to_string()))
62                .collect(),
63            Err(_) => Vec::new(),
64        }
65    }
66
67    pub fn from_request_parts(parts: http::request::Parts) -> Self {
68        Self {
69            parts,
70            body: None,
71            path_params: Vec::new(),
72            session: None,
73            flash: None,
74            flash_loaded: BTreeMap::new(),
75            cookies: None,
76        }
77    }
78
79    pub fn from_request(parts: http::request::Parts, body: Body) -> Self {
80        Self {
81            parts,
82            body: Some(body),
83            path_params: Vec::new(),
84            session: None,
85            flash: None,
86            flash_loaded: BTreeMap::new(),
87            cookies: None,
88        }
89    }
90
91    /// The application's database connection (global pool installed at boot).
92    pub fn db(&self) -> &'static DatabaseConnection {
93        doido_model::pool::pool()
94    }
95
96    /// A matched path parameter by name, e.g. `ctx.param("id")` for `/posts/{id}`.
97    pub fn param(&self, name: &str) -> Option<&str> {
98        self.path_params
99            .iter()
100            .find(|(k, _)| k == name)
101            .map(|(_, v)| v.as_str())
102    }
103
104    /// Deserialize a URL-encoded (form) request body. Consumes the body.
105    pub async fn form<T: DeserializeOwned>(&mut self) -> doido_core::Result<T> {
106        let bytes = self.read_body().await?;
107        serde_urlencoded::from_bytes(&bytes)
108            .map_err(|e| doido_core::anyhow::anyhow!("form deserialization failed: {e}"))
109    }
110
111    /// Deserialize a JSON request body. Consumes the body.
112    pub async fn body_json<T: DeserializeOwned>(&mut self) -> doido_core::Result<T> {
113        let bytes = self.read_body().await?;
114        serde_json::from_slice(&bytes)
115            .map_err(|e| doido_core::anyhow::anyhow!("JSON body deserialization failed: {e}"))
116    }
117
118    async fn read_body(&mut self) -> doido_core::Result<Vec<u8>> {
119        let body = self
120            .body
121            .take()
122            .ok_or_else(|| doido_core::anyhow::anyhow!("request body already consumed"))?;
123        let bytes = axum::body::to_bytes(body, MAX_BODY_BYTES)
124            .await
125            .map_err(|e| doido_core::anyhow::anyhow!("failed to read request body: {e}"))?;
126        Ok(bytes.to_vec())
127    }
128
129    /// Deserialize typed params from the request URI query string.
130    pub fn params<T: serde::de::DeserializeOwned>(&self) -> doido_core::Result<T> {
131        let query = self.parts.uri.query().unwrap_or("");
132        serde_urlencoded::from_str(query)
133            .map_err(|e| doido_core::anyhow::anyhow!("params deserialization failed: {e}"))
134    }
135
136    /// The query string as strong [`Params`](crate::params::Params), for
137    /// `require`/`permit` allowlisting before use.
138    pub fn query_params(&self) -> crate::params::Params {
139        let query = self.parts.uri.query().unwrap_or("");
140        let pairs: Vec<(String, String)> = serde_urlencoded::from_str(query).unwrap_or_default();
141        let mut map = serde_json::Map::new();
142        for (k, v) in pairs {
143            map.insert(k, serde_json::Value::String(v));
144        }
145        crate::params::Params::new(serde_json::Value::Object(map))
146    }
147
148    /// Render a Tera view to an HTML 200 response.
149    ///
150    /// `template` is resolved by the global [`doido_view`] engine (installed at
151    /// boot) against `app/views`, with the `.html.tera` suffix added — e.g.
152    /// `"posts/index"` → `app/views/posts/index.html.tera`. A render failure (or
153    /// an uninitialised engine) yields a `500`.
154    pub fn render(&self, template: &str, data: serde_json::Value) -> Response {
155        match doido_view::render(template, &data) {
156            Ok(html) => Response::builder()
157                .status(StatusCode::OK)
158                .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
159                .body(Body::from(html))
160                .expect("valid html response"),
161            Err(error) => {
162                tracing::error!(%error, template, "view render failed");
163                Response::builder()
164                    .status(StatusCode::INTERNAL_SERVER_ERROR)
165                    .body(Body::from("Internal Server Error"))
166                    .expect("valid 500 response")
167            }
168        }
169    }
170
171    /// Return a JSON 200 response.
172    pub fn json<T: Serialize>(&self, data: T) -> Response {
173        let body = serde_json::to_vec(&data).unwrap_or_default();
174        Response::builder()
175            .status(StatusCode::OK)
176            .header(header::CONTENT_TYPE, "application/json")
177            .body(Body::from(body))
178            .unwrap()
179    }
180
181    /// Return a 302 redirect.
182    pub fn redirect_to(&self, location: impl AsRef<str>) -> Response {
183        Response::builder()
184            .status(StatusCode::FOUND)
185            .header(
186                header::LOCATION,
187                HeaderValue::from_str(location.as_ref()).unwrap(),
188            )
189            .body(Body::empty())
190            .unwrap()
191    }
192
193    /// Return a response with an explicit status code and empty body.
194    /// `code` must be a valid HTTP status code (100–999).
195    pub fn status(&self, code: u16) -> Response {
196        Response::builder()
197            .status(code)
198            .body(Body::empty())
199            .unwrap()
200    }
201
202    /// Get a request header by name (lowercase).
203    pub fn header(&self, name: &str) -> Option<&http::HeaderValue> {
204        self.parts.headers.get(name)
205    }
206
207    /// Send raw bytes as a response (Rails `send_data`). When `filename` is set,
208    /// a `Content-Disposition: attachment` header prompts a download.
209    pub fn send_data(&self, data: Vec<u8>, content_type: &str, filename: Option<&str>) -> Response {
210        let mut builder = Response::builder()
211            .status(StatusCode::OK)
212            .header(header::CONTENT_TYPE, content_type);
213        if let Some(name) = filename {
214            builder = builder.header(
215                header::CONTENT_DISPOSITION,
216                format!("attachment; filename=\"{name}\""),
217            );
218        }
219        builder
220            .body(Body::from(data))
221            .expect("valid send_data response")
222    }
223
224    /// Send a file's contents as a response (Rails `send_file`). `content_type`
225    /// defaults to `application/octet-stream`; the file name is used for the
226    /// download disposition.
227    pub async fn send_file(
228        &self,
229        path: impl AsRef<std::path::Path>,
230        content_type: Option<&str>,
231    ) -> doido_core::Result<Response> {
232        let path = path.as_ref();
233        let data = tokio::fs::read(path)
234            .await
235            .map_err(|e| doido_core::anyhow::anyhow!("send_file failed to read {path:?}: {e}"))?;
236        let content_type = content_type.unwrap_or("application/octet-stream");
237        let filename = path.file_name().and_then(|n| n.to_str());
238        Ok(self.send_data(data, content_type, filename))
239    }
240
241    /// The negotiated response [`Format`](crate::respond::Format): a `.json` or
242    /// `.html` path extension wins, otherwise the `Accept` header is inspected;
243    /// anything else is `Any`.
244    pub fn negotiated_format(&self) -> crate::respond::Format {
245        use crate::respond::Format;
246        let path = self.parts.uri.path();
247        if path.ends_with(".json") {
248            return Format::Json;
249        }
250        if path.ends_with(".html") {
251            return Format::Html;
252        }
253        match self
254            .parts
255            .headers
256            .get(header::ACCEPT)
257            .and_then(|a| a.to_str().ok())
258        {
259            Some(accept) if accept.contains("application/json") => Format::Json,
260            Some(accept) if accept.contains("text/html") => Format::Html,
261            _ => Format::Any,
262        }
263    }
264
265    /// Begin format-based content negotiation (Rails `respond_to`).
266    pub fn respond_to(&self) -> crate::respond::RespondTo {
267        crate::respond::RespondTo::new(self.negotiated_format())
268    }
269
270    /// Whether the request's `If-None-Match` matches `etag` (or is `*`).
271    pub fn etag_matches(&self, etag: &str) -> bool {
272        match self
273            .parts
274            .headers
275            .get(header::IF_NONE_MATCH)
276            .and_then(|v| v.to_str().ok())
277        {
278            Some("*") => true,
279            Some(inm) => inm.split(',').map(str::trim).any(|t| t == etag),
280            None => false,
281        }
282    }
283
284    fn if_modified_since_matches(&self, last_modified: &str) -> bool {
285        self.parts
286            .headers
287            .get(header::IF_MODIFIED_SINCE)
288            .and_then(|v| v.to_str().ok())
289            .map(|v| v == last_modified)
290            .unwrap_or(false)
291    }
292
293    /// HTTP conditional-GET check (Rails `fresh_when`): if the request's
294    /// validators match `etag` (`If-None-Match`) or `last_modified`
295    /// (`If-Modified-Since`), return a `304 Not Modified` echoing the validators;
296    /// otherwise return `None` and render normally (setting the same validators).
297    pub fn fresh_when(&self, etag: Option<&str>, last_modified: Option<&str>) -> Option<Response> {
298        let fresh = etag.map(|e| self.etag_matches(e)).unwrap_or(false)
299            || last_modified
300                .map(|lm| self.if_modified_since_matches(lm))
301                .unwrap_or(false);
302        if !fresh {
303            return None;
304        }
305        let mut builder = Response::builder().status(StatusCode::NOT_MODIFIED);
306        if let Some(e) = etag {
307            builder = builder.header(header::ETAG, e);
308        }
309        if let Some(lm) = last_modified {
310            builder = builder.header(header::LAST_MODIFIED, lm);
311        }
312        Some(builder.body(Body::empty()).expect("valid 304 response"))
313    }
314
315    /// The plain/signed cookie jar (Rails `cookies` / `cookies.signed`). Reads
316    /// parse the request `Cookie` header; staged writes are flushed to
317    /// `Set-Cookie` after the action runs (see [`commit_to_response`]).
318    ///
319    /// [`commit_to_response`]: Self::commit_to_response
320    pub fn cookies(&mut self) -> &mut CookieJar {
321        if self.cookies.is_none() {
322            let header = self
323                .parts
324                .headers
325                .get(header::COOKIE)
326                .and_then(|v| v.to_str().ok());
327            self.cookies = Some(CookieJar::from_header(header, crate::secret::key_base()));
328        }
329        self.cookies.as_mut().expect("cookie jar just set")
330    }
331
332    /// The encrypted session (Rails `session`). Loaded from the session cookie on
333    /// first access and re-encrypted into the response afterwards.
334    pub fn session(&mut self) -> &mut Session {
335        if self.session.is_none() {
336            let store = EncryptedCookieSessionStore::default();
337            let session = self
338                .raw_cookie(SESSION_COOKIE)
339                .and_then(|raw| store.decode(&raw))
340                .unwrap_or_default();
341            self.session = Some(session);
342        }
343        self.session.as_mut().expect("session just set")
344    }
345
346    /// The flash (Rails `flash`): messages set on one request and read on the
347    /// next. Messages set this request are carried forward in a cookie; a flash
348    /// that is only read is swept so it lives exactly one following request.
349    pub fn flash(&mut self) -> &mut Flash {
350        if self.flash.is_none() {
351            let store = CookieSessionStore::new(crate::secret::key_base());
352            let flash = self
353                .raw_cookie(FLASH_COOKIE)
354                .map(|raw| Flash::from_cookie(&store, &raw))
355                .unwrap_or_default();
356            self.flash_loaded = flash.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
357            self.flash = Some(flash);
358        }
359        self.flash.as_mut().expect("flash just set")
360    }
361
362    /// Read a raw incoming cookie value by name from the request `Cookie` header.
363    fn raw_cookie(&self, name: &str) -> Option<String> {
364        let header = self.parts.headers.get(header::COOKIE)?.to_str().ok()?;
365        header
366            .split(';')
367            .filter_map(|pair| pair.trim().split_once('='))
368            .find(|(k, _)| *k == name)
369            .map(|(_, v)| v.to_string())
370    }
371
372    /// Flush any session/flash/cookie changes into the response `Set-Cookie`
373    /// headers. Called by the `#[controller]` macro after the action; a no-op
374    /// when the action never touched session/flash/cookies.
375    pub fn commit_to_response(&self, response: &mut Response) {
376        let secret = crate::secret::key_base();
377
378        if let Some(session) = &self.session {
379            let value = EncryptedCookieSessionStore::new(secret.clone()).encode(session);
380            append_cookie(
381                response,
382                &format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax"),
383            );
384        }
385
386        if let Some(flash) = &self.flash {
387            let current: BTreeMap<String, String> =
388                flash.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
389            if current != self.flash_loaded {
390                // New messages set this request → carry them to the next one.
391                if current.is_empty() {
392                    append_cookie(response, &format!("{FLASH_COOKIE}=; Path=/; Max-Age=0"));
393                } else {
394                    let value = flash.to_cookie(&CookieSessionStore::new(secret.clone()));
395                    append_cookie(
396                        response,
397                        &format!("{FLASH_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax"),
398                    );
399                }
400            } else if !self.flash_loaded.is_empty() {
401                // Read-only this request → expire so it lives exactly one request.
402                append_cookie(response, &format!("{FLASH_COOKIE}=; Path=/; Max-Age=0"));
403            }
404        }
405
406        if let Some(jar) = &self.cookies {
407            for header_value in jar.to_set_cookie_headers() {
408                append_cookie(response, &header_value);
409            }
410        }
411    }
412}
413
414/// Append a `Set-Cookie` header value to a response (multiple are allowed).
415fn append_cookie(response: &mut Response, value: &str) {
416    if let Ok(header_value) = HeaderValue::from_str(value) {
417        response
418            .headers_mut()
419            .append(header::SET_COOKIE, header_value);
420    }
421}
422
423/// Lets a `#[controller]` action body evaluate to either a [`Response`] or a
424/// `Result<Response, E>`. The macro wraps every action body in
425/// `into_action_response()`, so actions can use `?` for fallible work (DB calls,
426/// body parsing) and an `Err` becomes a `500` response.
427pub trait IntoActionResponse {
428    fn into_action_response(self) -> Response;
429}
430
431impl IntoActionResponse for Response {
432    fn into_action_response(self) -> Response {
433        self
434    }
435}
436
437impl<E: std::fmt::Display> IntoActionResponse for Result<Response, E> {
438    fn into_action_response(self) -> Response {
439        match self {
440            Ok(response) => response,
441            Err(error) => {
442                tracing::error!(%error, "action returned an error");
443                Response::builder()
444                    .status(StatusCode::INTERNAL_SERVER_ERROR)
445                    .body(Body::from("Internal Server Error"))
446                    .expect("static 500 response is valid")
447            }
448        }
449    }
450}
451
452// `Context` must stay `Send` so controller handler futures (which hold a
453// `&mut Context` across `.await`) satisfy axum's `Handler` bound.
454const _: fn() = || {
455    fn assert_send<T: Send>() {}
456    assert_send::<Context>();
457};