Skip to main content

rustio_admin/
http.rs

1//! The HTTP primitives. `Request` and `Response` are thin wrappers around
2//! hyper's types that carry a typed context and a few conveniences.
3
4use std::any::{Any, TypeId};
5use std::collections::HashMap;
6
7use bytes::Bytes;
8use hyper::{Method, StatusCode};
9
10use crate::error::{Error, Result};
11
12// public:
13/// A per-request typed store. Middleware attaches things here
14/// (the authenticated user, the DB handle, etc.) and handlers read them.
15#[derive(Default)]
16pub struct Context {
17    inner: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
18}
19
20impl Context {
21    // public:
22    pub fn insert<T: Any + Send + Sync>(&mut self, value: T) {
23        self.inner.insert(TypeId::of::<T>(), Box::new(value));
24    }
25
26    // public:
27    pub fn get<T: Any + Send + Sync>(&self) -> Option<&T> {
28        self.inner
29            .get(&TypeId::of::<T>())
30            .and_then(|b| b.downcast_ref::<T>())
31    }
32
33    // public:
34    pub fn get_mut<T: Any + Send + Sync>(&mut self) -> Option<&mut T> {
35        self.inner
36            .get_mut(&TypeId::of::<T>())
37            .and_then(|b| b.downcast_mut::<T>())
38    }
39}
40
41// public:
42pub struct Request {
43    method: Method,
44    path: String,
45    query: String,
46    headers: HashMap<String, String>,
47    params: HashMap<String, String>,
48    body: Bytes,
49    ctx: Context,
50}
51
52impl Request {
53    pub(crate) fn new(
54        method: Method,
55        path: String,
56        query: String,
57        headers: HashMap<String, String>,
58        body: Bytes,
59    ) -> Self {
60        Self {
61            method,
62            path,
63            query,
64            headers,
65            params: HashMap::new(),
66            body,
67            ctx: Context::default(),
68        }
69    }
70
71    // public:
72    pub fn method(&self) -> &Method {
73        &self.method
74    }
75
76    // public:
77    pub fn path(&self) -> &str {
78        &self.path
79    }
80
81    // public:
82    pub fn query_string(&self) -> &str {
83        &self.query
84    }
85
86    // public:
87    pub fn query(&self) -> FormData {
88        FormData::from_urlencoded(&self.query)
89    }
90
91    // public:
92    pub fn header(&self, name: &str) -> Option<&str> {
93        self.headers
94            .get(&name.to_ascii_lowercase())
95            .map(|s| s.as_str())
96    }
97
98    // public:
99    pub fn param(&self, name: &str) -> Option<&str> {
100        self.params.get(name).map(|s| s.as_str())
101    }
102
103    // public:
104    pub fn body(&self) -> &[u8] {
105        &self.body
106    }
107
108    // public:
109    pub fn body_text(&self) -> Result<&str> {
110        std::str::from_utf8(&self.body)
111            .map_err(|_| Error::BadRequest("body is not valid utf-8".into()))
112    }
113
114    // public:
115    pub fn form(&self) -> Result<FormData> {
116        // Multipart bodies need a different parser — `body_text()`
117        // would refuse binary file parts. We extract text fields
118        // only at this layer; the admin handler reruns the parser
119        // with its uploads-dir context to actually write file
120        // parts to disk.
121        let ct = self.header("content-type").unwrap_or("");
122        if let Some(boundary) = crate::multipart::boundary_from_content_type(ct) {
123            return crate::multipart::parse_multipart(&self.body, &boundary)
124                .map(|mp| {
125                    let mut form = FormData::default();
126                    for part in mp.parts {
127                        if part.filename.is_none() {
128                            let text = String::from_utf8_lossy(&part.body).into_owned();
129                            form.set(part.name, text);
130                        }
131                    }
132                    form
133                })
134                .map_err(|e| Error::BadRequest(format!("multipart: {e}")));
135        }
136        let text = self.body_text()?;
137        Ok(FormData::from_urlencoded(text))
138    }
139
140    // public:
141    pub fn ctx(&self) -> &Context {
142        &self.ctx
143    }
144
145    // public:
146    pub fn ctx_mut(&mut self) -> &mut Context {
147        &mut self.ctx
148    }
149
150    pub(crate) fn set_params(&mut self, params: HashMap<String, String>) {
151        self.params = params;
152    }
153
154    // internal: test-only constructor, doc-hidden + cfg-gated
155    /// Test-only minimal-Request constructor. Doc-hidden, gated by
156    /// the `integration-test` feature so it does not appear on the
157    /// public API surface of a regular build. Used by
158    /// `crate::__integration::fake_request()` in the testcontainers
159    /// integration suite — see `DESIGN_R2_ORGANISATIONAL.md` §10.3.
160    #[doc(hidden)]
161    #[cfg(feature = "integration-test")]
162    pub(crate) fn __integration_test_fake(path: String, headers: HashMap<String, String>) -> Self {
163        Self::new(
164            hyper::Method::POST,
165            path,
166            String::new(),
167            headers,
168            bytes::Bytes::new(),
169        )
170    }
171}
172
173// public:
174/// Parsed form body (application/x-www-form-urlencoded) or query string.
175/// Values are owned so handlers can move them into DB calls freely.
176///
177/// Duplicate keys (e.g. multi-checkbox forms posting `status=active`
178/// alongside `status=pending`) are preserved by [`get_all`] but
179/// collapsed to the last submitted value by [`get`] / [`as_map`] for
180/// backward compatibility with handlers that expect a single string
181/// per key.
182#[derive(Debug, Default, Clone)]
183pub struct FormData {
184    /// Single-value view: last write wins. Mirrors classic
185    /// `HashMap<String, String>` semantics so legacy handlers keep
186    /// working unchanged.
187    fields: HashMap<String, String>,
188    /// Multi-value view: every submitted value for each key, in
189    /// submission order. Populated alongside `fields` by
190    /// [`from_urlencoded`]; consulted by [`get_all`]. Keys with a
191    /// single submission still get a one-element Vec here, so callers
192    /// can branch on `.len()` without consulting `fields`.
193    multi: HashMap<String, Vec<String>>,
194}
195
196impl FormData {
197    // public:
198    pub fn from_urlencoded(input: &str) -> Self {
199        let mut fields = HashMap::new();
200        let mut multi: HashMap<String, Vec<String>> = HashMap::new();
201        for pair in input.split('&') {
202            if pair.is_empty() {
203                continue;
204            }
205            let (raw_key, raw_val) = match pair.split_once('=') {
206                Some((k, v)) => (k, v),
207                None => (pair, ""),
208            };
209            let key = decode(raw_key);
210            let val = decode(raw_val);
211            fields.insert(key.clone(), val.clone());
212            multi.entry(key).or_default().push(val);
213        }
214        Self { fields, multi }
215    }
216
217    // public:
218    pub fn get(&self, key: &str) -> Option<&str> {
219        self.fields.get(key).map(|s| s.as_str())
220    }
221
222    /// All values submitted for `key`, in submission order. Returns
223    /// the empty slice when the key wasn't submitted at all. Used by
224    /// multi-select filters where the same field name appears once
225    /// per checked option (`?status=active&status=pending`).
226    pub fn get_all(&self, key: &str) -> &[String] {
227        self.multi.get(key).map(Vec::as_slice).unwrap_or(&[])
228    }
229
230    // public:
231    pub fn required(&self, key: &str) -> Result<&str> {
232        self.get(key)
233            .ok_or_else(|| Error::BadRequest(format!("field {key} is required")))
234    }
235
236    // public:
237    pub fn bool_flag(&self, key: &str) -> bool {
238        // HTML checkboxes: present means true, absent means false.
239        matches!(self.get(key), Some("on" | "true" | "1" | "yes"))
240    }
241
242    // public:
243    pub fn contains(&self, key: &str) -> bool {
244        self.fields.contains_key(key)
245    }
246
247    // public:
248    pub fn as_map(&self) -> &HashMap<String, String> {
249        &self.fields
250    }
251
252    /// Insert or overwrite a key. Used by the admin handlers to inject
253    /// existing values for `readonly_fields` before passing the form
254    /// to the model's `from_form` (the field is rendered `disabled` so
255    /// the browser would otherwise omit it, breaking required parsing).
256    /// Resets the multi-value view to a single entry so `get_all`
257    /// stays consistent with `get`.
258    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
259        let key = key.into();
260        let value = value.into();
261        self.fields.insert(key.clone(), value.clone());
262        self.multi.insert(key, vec![value]);
263    }
264}
265
266fn decode(s: &str) -> String {
267    let spaced = s.replace('+', " ");
268    urlencoding::decode(&spaced)
269        .map(|c| c.into_owned())
270        .unwrap_or(spaced)
271}
272
273// public:
274/// An outbound HTTP response.
275pub struct Response {
276    pub status: StatusCode,
277    pub headers: Vec<(String, String)>,
278    pub body: Bytes,
279}
280
281impl Response {
282    // public:
283    pub fn new(status: StatusCode, body: impl Into<Bytes>) -> Self {
284        Self {
285            status,
286            headers: Vec::new(),
287            body: body.into(),
288        }
289    }
290
291    // public:
292    pub fn ok(body: impl Into<Bytes>) -> Self {
293        Self::new(StatusCode::OK, body)
294    }
295
296    // public:
297    pub fn html(body: impl Into<String>) -> Self {
298        let text = body.into();
299        Self {
300            status: StatusCode::OK,
301            headers: vec![("content-type".into(), "text/html; charset=utf-8".into())],
302            body: Bytes::from(text),
303        }
304    }
305
306    // public:
307    pub fn json_raw(body: impl Into<String>) -> Self {
308        let text = body.into();
309        Self {
310            status: StatusCode::OK,
311            headers: vec![("content-type".into(), "application/json".into())],
312            body: Bytes::from(text),
313        }
314    }
315
316    // public:
317    pub fn redirect(to: impl Into<String>) -> Self {
318        let url = to.into();
319        Self {
320            status: StatusCode::SEE_OTHER,
321            headers: vec![("location".into(), url)],
322            body: Bytes::new(),
323        }
324    }
325
326    // public:
327    pub fn text(body: impl Into<String>) -> Self {
328        let text = body.into();
329        Self {
330            status: StatusCode::OK,
331            headers: vec![("content-type".into(), "text/plain; charset=utf-8".into())],
332            body: Bytes::from(text),
333        }
334    }
335
336    // public:
337    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
338        self.headers.push((name.into(), value.into()));
339        self
340    }
341
342    // public:
343    pub fn with_status(mut self, status: StatusCode) -> Self {
344        self.status = status;
345        self
346    }
347}
348
349pub(crate) fn response_from_error(err: &Error) -> Response {
350    let status = StatusCode::from_u16(err.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
351    let body = err.client_message().to_string();
352    Response {
353        status,
354        headers: vec![("content-type".into(), "text/plain; charset=utf-8".into())],
355        body: Bytes::from(body),
356    }
357}
358
359#[cfg(test)]
360mod formdata_tests {
361    use super::FormData;
362
363    #[test]
364    fn duplicate_keys_collapse_for_get_but_preserved_in_get_all() {
365        let f = FormData::from_urlencoded("status=active&status=pending&status=resolved");
366        // `get` keeps the last write (mirrors classic HashMap insert
367        // semantics) — kept for back-compat with single-value callers.
368        assert_eq!(f.get("status"), Some("resolved"));
369        // `get_all` returns every submission in order — what
370        // multi-select filters parse from the URL.
371        assert_eq!(
372            f.get_all("status"),
373            &[
374                "active".to_string(),
375                "pending".to_string(),
376                "resolved".to_string()
377            ],
378        );
379    }
380
381    #[test]
382    fn get_all_returns_empty_slice_for_missing_key() {
383        let f = FormData::from_urlencoded("a=1");
384        assert!(f.get_all("not-a-key").is_empty());
385    }
386
387    #[test]
388    fn single_submission_is_visible_via_both_views() {
389        let f = FormData::from_urlencoded("a=1");
390        assert_eq!(f.get("a"), Some("1"));
391        assert_eq!(f.get_all("a"), &["1".to_string()]);
392    }
393
394    #[test]
395    fn set_resets_multi_view_to_single_entry() {
396        let mut f = FormData::from_urlencoded("status=active&status=pending");
397        f.set("status", "resolved");
398        // Both views agree after `set`.
399        assert_eq!(f.get("status"), Some("resolved"));
400        assert_eq!(f.get_all("status"), &["resolved".to_string()]);
401    }
402}