Skip to main content

sofie_http/
query.rs

1use serde_json::Value;
2use sofie_core::{Error, Result};
3use sofie_db::{FieldNames, Input, Resolve};
4
5use crate::{Area, RequestContext};
6
7/// A GET's query string read as `Q`, an `Input` checked as a body is: a refused value is a 422
8/// with its field errors, a key `Q` has no field for a 400 unless it is a tracking key. `Query`
9/// alone reads keys by name.
10pub struct Query<Q = QueryText>(pub Q);
11
12/// The query string as it came, for a caller such as an OAuth provider sending keys of its own.
13pub struct QueryText(Option<String>);
14
15impl Query {
16    /// The first value of `name`, percent-decoded; none when it is absent or not text.
17    #[must_use]
18    pub fn get(&self, name: &str) -> Option<String> {
19        self.0
20            .0
21            .as_deref()?
22            .split('&')
23            .filter_map(|pair| pair.split_once('='))
24            .find(|(key, _)| *key == name)
25            .and_then(|(_, value)| crate::urlencoded::decode(value.as_bytes()).ok())
26    }
27}
28
29impl<A: Area, M> Resolve<RequestContext<A, M>> for Query {
30    fn resolve(context: &mut RequestContext<A, M>) -> impl Future<Output = Result<Self>> + Send {
31        std::future::ready(Ok(Self(QueryText(context.query().map(str::to_owned)))))
32    }
33}
34
35impl<A: Area, M, Q: Input + FieldNames + Send> Resolve<RequestContext<A, M>> for Query<Q> {
36    fn resolve(context: &mut RequestContext<A, M>) -> impl Future<Output = Result<Self>> + Send {
37        std::future::ready(typed(context.query().unwrap_or_default()).map(Self))
38    }
39}
40
41/// What a link shared through ads or social sites carries; a link with one appended still opens.
42pub(crate) fn is_tracking_key(key: &str) -> bool {
43    key.starts_with("utm_") || matches!(key, "fbclid" | "gclid" | "msclkid")
44}
45
46fn typed<Q: Input + FieldNames>(query: &str) -> Result<Q> {
47    let mut asked = crate::form::parse(query.as_bytes())?;
48    asked.retain(|key, _| Q::FIELD_NAMES.contains(&key.as_str()) || !is_tracking_key(key));
49    if let Some(unknown) = asked
50        .keys()
51        .find(|key| !Q::FIELD_NAMES.contains(&key.as_str()))
52    {
53        return Err(Error::BadRequest(format!(
54            "the query has no parameter `{unknown}`"
55        )));
56    }
57    Q::from_json(Value::Object(asked))
58}
59
60#[cfg(test)]
61mod tests {
62    use serde_json::Value;
63    use sofie_core::{Error, FieldError, Result};
64    use sofie_db::{FieldNames, Input, InputFields};
65
66    use super::{Query, QueryText, typed};
67
68    #[test]
69    fn a_value_is_read_by_name_and_decoded() {
70        let query = Query(QueryText(Some("state=a%2Bb&code=x+y&empty=".to_owned())));
71
72        assert_eq!(query.get("state").as_deref(), Some("a+b"));
73        assert_eq!(query.get("code").as_deref(), Some("x y"));
74        assert_eq!(query.get("empty").as_deref(), Some(""));
75        assert_eq!(query.get("missing"), None);
76    }
77
78    #[derive(Debug, PartialEq)]
79    struct Around {
80        near: String,
81        radius: Option<u32>,
82    }
83
84    impl Input for Around {
85        fn from_json(value: Value) -> Result<Self> {
86            let mut fields = InputFields::new(value)?;
87            let near = fields.take("near");
88            let radius = fields.optional::<u32>("radius");
89            let radius = fields.checked(radius, |radius| {
90                radius
91                    .is_some_and(|radius| radius > 50)
92                    .then(|| FieldError::new("radius", "validation.max", "must be at most 50"))
93            });
94            fields.finish()?;
95            Ok(Self {
96                near: near?,
97                radius: radius?,
98            })
99        }
100    }
101
102    impl FieldNames for Around {
103        const FIELD_NAMES: &'static [&'static str] = &["near", "radius"];
104    }
105
106    #[test]
107    fn a_typed_query_reads_its_fields_as_their_types() {
108        let around: Around = typed("near=Berlin%20Mitte&radius=5").unwrap();
109
110        assert_eq!(
111            around,
112            Around {
113                near: "Berlin Mitte".to_owned(),
114                radius: Some(5),
115            }
116        );
117        let blank: Around = typed("near=Ulm&radius=").unwrap();
118        assert_eq!(blank.radius, None);
119    }
120
121    #[test]
122    fn a_refused_value_is_a_field_error_and_an_unknown_key_a_bad_request() {
123        let refused = typed::<Around>("radius=80");
124        let Err(Error::Invalid(errors)) = refused else {
125            panic!("{:?}", refused.err());
126        };
127        let keys: Vec<(&str, &str)> = errors
128            .iter()
129            .map(|error| (error.field.as_str(), error.key.as_ref()))
130            .collect();
131        assert_eq!(
132            keys,
133            [
134                ("near", "validation.required"),
135                ("radius", "validation.max")
136            ]
137        );
138        assert!(matches!(
139            typed::<Around>("near=Ulm&page=2"),
140            Err(Error::BadRequest(message)) if message.contains("`page`")
141        ));
142        assert!(matches!(typed::<()>("near=Ulm"), Err(Error::BadRequest(_))));
143        assert!(typed::<()>("").is_ok());
144    }
145
146    #[test]
147    fn a_tracking_key_appended_to_a_shared_link_is_left_unread() {
148        let around: Around =
149            typed("near=Ulm&utm_source=newsletter&utm_campaign=fall&fbclid=a&gclid=b&msclkid=c")
150                .unwrap();
151
152        assert_eq!(around.near, "Ulm");
153        assert!(typed::<()>("utm_medium=email").is_ok());
154        assert!(matches!(
155            typed::<Around>("near=Ulm&utm=x"),
156            Err(Error::BadRequest(message)) if message.contains("`utm`")
157        ));
158    }
159}