1use serde_json::Value;
2use sofie_core::{Error, Result};
3use sofie_db::{FieldNames, Input, Resolve};
4
5use crate::{Area, RequestContext};
6
7pub struct Query<Q = QueryText>(pub Q);
11
12pub struct QueryText(Option<String>);
14
15impl Query {
16 #[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
41pub(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}