Skip to main content

sofie_http/
context.rs

1use std::marker::PhantomData;
2
3use bytes::Bytes;
4use sofie_core::{Error, Result, Uuid};
5use sofie_db::{Context, Db, Reach, Writes};
6
7use crate::Area;
8use crate::session::RequestSession;
9use crate::urlencoded::Encoding;
10
11#[derive(Clone)]
12pub(crate) struct Matched {
13    pub(crate) parameters: Vec<(String, String)>,
14    pub(crate) query: Option<String>,
15    pub(crate) body: Option<Bytes>,
16    pub(crate) encoding: Encoding,
17    pub(crate) session: RequestSession,
18    /// Every `Read` and commit is held to it.
19    pub(crate) reach: Reach,
20    pub(crate) deadline: crate::deadline::Deadline,
21    pub(crate) app: crate::AppName,
22    pub(crate) route: Option<&'static str>,
23    #[cfg(feature = "idempotency")]
24    pub(crate) work: Option<sofie_db::Work>,
25    #[cfg(feature = "signed-links")]
26    pub(crate) keys: std::sync::Arc<sofie_auth::SigningKeys>,
27    #[cfg(any(feature = "flags", feature = "permissions"))]
28    pub(crate) gates: crate::gate::RequestGates,
29    #[cfg(feature = "shield")]
30    pub(crate) shield: Option<crate::shield::RequestShield>,
31    #[cfg(feature = "storage")]
32    pub(crate) storage: Option<sofie_storage::Storage>,
33    /// A list's refused filter is then a 422; a page shows it beside the filter.
34    #[cfg(feature = "listing")]
35    pub(crate) prefers_json: bool,
36}
37
38// Kept off the per-area `RequestContext`, so each app compiles the body parse once.
39impl Matched {
40    fn bound(&self, name: &str) -> Result<Uuid> {
41        bound_id(&self.parameters, name)
42    }
43
44    fn input(&mut self) -> Result<serde_json::Value> {
45        let body = self.body.take().ok_or_else(body_already_read)?;
46        if self.encoding == Encoding::Form {
47            return crate::form::parse(&body).map(serde_json::Value::Object);
48        }
49        if body.is_empty() {
50            return Ok(serde_json::Value::Object(serde_json::Map::new()));
51        }
52        serde_json::from_slice(&body).map_err(|error| {
53            Error::BadRequest(format!("the request body is not valid JSON: {error}"))
54        })
55    }
56}
57
58#[cfg(feature = "engine")]
59impl Matched {
60    pub(crate) fn for_loader(&self) -> Self {
61        Self {
62            body: None,
63            encoding: Encoding::Json,
64            session: self.session.for_loader(),
65            #[cfg(feature = "idempotency")]
66            work: None,
67            ..self.clone()
68        }
69    }
70}
71
72/// A malformed id is a URL that names nothing; a missing parameter is a
73/// handler bound to a route without it.
74fn bound_id(parameters: &[(String, String)], name: &str) -> Result<Uuid> {
75    let value = parameter(parameters, name)
76        .ok_or_else(|| Error::internal_message(format!("the route has no `{name}` parameter")))?;
77    value.parse().map_err(|_| Error::NotFound)
78}
79
80/// Two extractors taking the body is a handler bug, not the request's.
81pub(crate) fn body_already_read() -> Error {
82    Error::internal_message("the request body was already read")
83}
84
85pub(crate) fn parameter<'p>(parameters: &'p [(String, String)], name: &str) -> Option<&'p str> {
86    let (_, value) = parameters.iter().find(|(given, _)| given == name)?;
87    Some(value)
88}
89
90/// Answers a GET, a screen or an island: a handler resolved in it never writes.
91pub enum Reading {}
92
93/// Answers every other method: the unit of work commits its handler's outcome.
94pub enum Writing {}
95
96pub type ReadContext<A> = RequestContext<A, Reading>;
97
98pub struct RequestContext<A: Area, M = Writing> {
99    db: Db,
100    pub(crate) matched: Matched,
101    kind: PhantomData<fn() -> (A, M)>,
102}
103
104impl<A: Area, M> RequestContext<A, M> {
105    pub(crate) fn new(db: Db, matched: Matched) -> Self {
106        Self {
107            db,
108            matched,
109            kind: PhantomData,
110        }
111    }
112
113    pub(crate) fn session(&self) -> &RequestSession {
114        &self.matched.session
115    }
116
117    pub(crate) fn query(&self) -> Option<&str> {
118        self.matched.query.as_deref()
119    }
120
121    /// A decoded path segment that is not a record's id, such as a page's slug.
122    #[must_use]
123    pub fn path_parameter(&self, name: &str) -> Option<&str> {
124        parameter(&self.matched.parameters, name)
125    }
126}
127
128impl<A: Area, M> Context for RequestContext<A, M> {
129    fn db(&self) -> &Db {
130        &self.db
131    }
132
133    fn reach(&self) -> Reach {
134        self.matched.reach
135    }
136
137    fn bound(&self, name: &str) -> Result<Uuid> {
138        self.matched.bound(name)
139    }
140
141    fn input(&mut self) -> Result<serde_json::Value> {
142        self.matched.input()
143    }
144}
145
146impl<A: Area> Writes for RequestContext<A> {}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn a_malformed_id_names_nothing_and_a_missing_parameter_is_a_bug() {
154        let parameters = vec![("id".to_owned(), "not-a-uuid".to_owned())];
155
156        assert!(matches!(bound_id(&parameters, "id"), Err(Error::NotFound)));
157        assert!(matches!(
158            bound_id(&parameters, "order"),
159            Err(Error::Internal { .. })
160        ));
161    }
162}