Skip to main content

xapi_rs/lrs/
headers.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{
4    MyError, V200,
5    data::{MyLanguageTag, MyVersion},
6    runtime_error,
7};
8use etag::EntityTag;
9use rocket::{
10    Request,
11    http::{ContentType, Status, hyper::header},
12    request::{FromRequest, Outcome},
13};
14use std::{borrow::Cow, cmp::Ordering, ops::RangeInclusive, str::FromStr};
15use tracing::{debug, error, warn};
16
17/// The **`Content-Transfer-Encoding`** HTTP header name.
18pub const CONTENT_TRANSFER_ENCODING_HDR: &str = "Content-Transfer-Encoding";
19
20/// The xAPI specific **`X-Experience-API-Version`** HTTP header name.
21pub const VERSION_HDR: &str = "X-Experience-API-Version";
22
23/// The xAPI specific **`X-Experience-API-Hash`** HTTP header name.
24pub const HASH_HDR: &str = "X-Experience-API-Hash";
25
26/// The xAPI specific **`X-Experience-API-Consistent-Through`** HTTP header name.
27pub const CONSISTENT_THRU_HDR: &str = "X-Experience-API-Consistent-Through";
28
29/// Valid values for `q` (quality) parameter in `Accept-Language` header.
30const Q_RANGE: RangeInclusive<f32> = RangeInclusive::new(0.0, 1.0);
31
32#[derive(Debug)]
33enum ETagValue {
34    /// When no If-xxx headers are present in the request.
35    Absent,
36    /// When an If-xxx header has a value of *.
37    Any,
38    /// When one or more non-* If-xxx headers are present in the request.
39    Set(Vec<EntityTag>),
40}
41
42/// A Rocket Request Guard to help handle HTTP headers defined in xAPI.
43#[derive(Debug)]
44pub(crate) struct Headers {
45    /// xAPI Version: Every request to the LRS and every response from the
46    /// LRS shall include an HTTP header named `X-Experience-API-Version`
47    /// and the version as the value. For example for version 2.0.0...
48    ///   `X-Experience-API-Version: 2.0.0`
49    /// IMPORTANT (rsn) 20240521 - given that at this time i only support
50    /// 2.0.0 i only check for the header at the reception of a request and
51    /// reject the request if it's not the right version. in the future i
52    /// will be storing the 'want' version here and handle it appropriately
53    /// in each handler.
54    #[allow(dead_code)]
55    version: String,
56    /// Aggregated If-Match header etag values
57    if_match_etags: ETagValue,
58    /// Aggregated If-None-Match header etag values
59    if_none_match_etags: ETagValue,
60    /// A potentially empty list of language-tags (as strings) in descending
61    /// order of caller's weights.
62    #[allow(dead_code)]
63    languages: Vec<MyLanguageTag>,
64    /// Boolean flag indicating whether or not the incoming Request has a
65    /// _Content-Type_ header w/ `application/json` as its value. If the
66    /// header is present and its value is `application/json` this flag
67    /// is set to TRUE; otherwise it's set to FALSE.
68    is_json_content: bool,
69}
70
71/// Encode a language-tag and a quality-value pair used as one of a comma-
72/// separated list being the value of an [`Accept-Language`][1] HTTP header.
73///
74/// [1]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language
75#[derive(Debug)]
76pub(crate) struct Language {
77    /// [This][1] consists of a 2-3 letter base language tag that indicates a
78    /// language, optionally followed by additional subtags separated by `-`.
79    /// The most common extra information is the country or region variant
80    /// (e.g. `en-US`) or the type of alphabet to use (e.g. `sr-Latn`).
81    ///
82    /// [1]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language#language
83    tag: MyLanguageTag,
84    /// Optionally used to describe the order of priority of values in a comma-
85    /// separated list.
86    /// It's a value between `0.0` and `1.0` included, with up to three decimal
87    /// digits, the highest value denoting the highest priority. When absent,
88    /// a default value of `1.0` is used.
89    /// Note that we convert this real number to an unsigned integer by
90    /// multiplying by 1_000 and rounding it.
91    ///
92    /// [1]: https://developer.mozilla.org/en-US/docs/Glossary/Quality_values
93    q: u32,
94}
95
96impl TryFrom<&str> for Language {
97    type Error = MyError;
98
99    /// Construct a [Language] instance from a non-empty string. It does that
100    /// by ensuring the string is a recognized language tag. It also ensures
101    /// it can be canonicalized and is valid according to [RFC-5646 4.5][1].
102    ///
103    /// [1]: https://tools.ietf.org/html/rfc5646#section-4.5
104    fn try_from(value: &str) -> Result<Self, Self::Error> {
105        if value.is_empty() {
106            runtime_error!("Input string must not be empty")
107        }
108
109        let pair: Vec<&str> = value.split(';').collect();
110        // 1st part is always the language-tag...
111        match MyLanguageTag::from_str(pair[0]) {
112            Ok(tag) => {
113                // NOTE (rsn) 20240801 - when `q` is present and an error is
114                // raised while processing it, we'll set it to 0...
115                let mut q = 0.0;
116                if pair.len() > 1 {
117                    let qv: Vec<&str> = pair[1].split('=').collect();
118                    if qv[0] != "q" {
119                        warn!("Q part in '{}' is malformed", pair[0]);
120                    } else {
121                        match qv[1].parse::<f32>() {
122                            Ok(x) => {
123                                if !Q_RANGE.contains(&x) {
124                                    warn!("Q in '{}' is out-of-bounds", pair[0]);
125                                } else {
126                                    q = x;
127                                }
128                            }
129                            Err(x) => warn!("Failed parsing Q w/in '{}': {}", pair[0], x),
130                        }
131                    }
132                } else {
133                    q = 1.0;
134                }
135                Ok(Language {
136                    tag,
137                    q: (q * 1_000.0).round() as u32,
138                })
139            }
140            Err(x) => runtime_error!("Failed parsing Tag in '{}': {}", pair[0], x),
141        }
142    }
143}
144
145impl Default for Headers {
146    fn default() -> Self {
147        Self {
148            version: V200.to_owned(),
149            if_match_etags: ETagValue::Absent,
150            if_none_match_etags: ETagValue::Absent,
151            languages: vec![],
152            is_json_content: false,
153        }
154    }
155}
156
157#[rocket::async_trait]
158impl<'r> FromRequest<'r> for Headers {
159    type Error = MyError;
160
161    async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
162        let version = match req.headers().get_one(VERSION_HDR) {
163            Some(x) => match MyVersion::from_str(x) {
164                Ok(x) => {
165                    if x.to_string() != V200 {
166                        let msg = format!("xAPI v.{x} wanted but i only support 2.0.0");
167                        error!("{}", msg);
168                        // should be 418 I'm a teapot
169                        return Outcome::Error((Status::BadRequest, MyError::Runtime(msg.into())));
170                    }
171                    x
172                }
173                Err(y) => {
174                    let msg = format!("xAPI version header ({x}) has invalid syntax: {y}");
175                    error!("{}", msg);
176                    return Outcome::Error((Status::BadRequest, MyError::Runtime(msg.into())));
177                }
178            },
179            None => {
180                let msg = "Missing xAPI version header";
181                error!("{}", msg);
182                return Outcome::Error((Status::BadRequest, MyError::Runtime(Cow::Borrowed(msg))));
183            }
184        };
185
186        let if_match_etags = if req.headers().contains(header::IF_MATCH) {
187            let mut any = false;
188            let mut v1 = vec![];
189            for h in req.headers().get(header::IF_MATCH.as_str()) {
190                let h = h.trim();
191                debug!("h = '{}'", h);
192                if h == "*" {
193                    any = true;
194                    break;
195                } else {
196                    let parts = h.split(',');
197                    for p in parts {
198                        match EntityTag::from_str(p.trim()) {
199                            Ok(x) => v1.push(x),
200                            Err(x) => error!(
201                                "Malformed If-Match ({}) entity tag. Ignore + continue: {}",
202                                p, x
203                            ),
204                        }
205                    }
206                }
207            }
208            if any {
209                ETagValue::Any
210            } else if v1.is_empty() {
211                ETagValue::Absent
212            } else {
213                ETagValue::Set(v1)
214            }
215        } else {
216            ETagValue::Absent
217        };
218
219        let if_none_match_etags = if req.headers().contains(header::IF_NONE_MATCH) {
220            let mut any = false;
221            let mut v2 = vec![];
222            for h in req.headers().get(header::IF_NONE_MATCH.as_str()) {
223                let h = h.trim();
224                debug!("h = '{}'", h);
225                if h == "*" {
226                    any = true;
227                    break;
228                } else {
229                    let parts = h.split(',');
230                    for p in parts {
231                        match EntityTag::from_str(p.trim()) {
232                            Ok(x) => v2.push(x),
233                            Err(x) => error!(
234                                "Malformed If-None-Match ({}) entity tag. Ignore + continue: {}",
235                                p, x
236                            ),
237                        }
238                    }
239                }
240            }
241            if any {
242                ETagValue::Any
243            } else if v2.is_empty() {
244                ETagValue::Absent
245            } else {
246                ETagValue::Set(v2)
247            }
248        } else {
249            ETagValue::Absent
250        };
251
252        let languages = match req.headers().get_one(header::ACCEPT_LANGUAGE.as_str()) {
253            Some(x) => process_accept_language(x),
254            None => vec![],
255        };
256
257        let is_json_content = req.content_type().is_some_and(|h| *h == ContentType::JSON);
258
259        Outcome::Success(Headers {
260            version: version.to_string(),
261            if_match_etags,
262            if_none_match_etags,
263            languages,
264            is_json_content,
265        })
266    }
267}
268
269fn process_accept_language(s: &str) -> Vec<MyLanguageTag> {
270    let mut tuples = vec![];
271    // it's more efficient to just remove whitespaces rather than
272    // trim at every step of the dissection process.
273    let binding = s.replace(' ', "");
274    let tokens: Vec<&str> = binding.split(',').collect();
275    for t in tokens {
276        if let Ok(x) = Language::try_from(t) {
277            tuples.push(x)
278        }
279    }
280    if tuples.is_empty() {
281        return vec![];
282    }
283
284    // sort tuples 1st by q (descending), and 2nd alphabetically (ascending).
285    tuples.sort_by(|x, y| match x.q.cmp(&y.q) {
286        Ordering::Less => Ordering::Greater,
287        Ordering::Greater => Ordering::Less,
288        Ordering::Equal => x.tag.as_str().cmp(y.tag.as_str()),
289    });
290
291    tuples.iter().map(|x| x.tag.to_owned()).collect()
292}
293
294impl Headers {
295    pub(crate) fn has_no_conditionals(&self) -> bool {
296        matches!(self.if_match_etags, ETagValue::Absent)
297            && matches!(self.if_none_match_etags, ETagValue::Absent)
298    }
299
300    pub(crate) fn has_conditionals(&self) -> bool {
301        self.has_if_match() || self.has_if_none_match()
302    }
303
304    pub(crate) fn has_if_match(&self) -> bool {
305        !matches!(self.if_match_etags, ETagValue::Absent)
306    }
307
308    pub(crate) fn pass_if_match(&self, etag: &EntityTag) -> bool {
309        if self.is_match_any() {
310            true
311        } else {
312            self.match_values()
313                .unwrap()
314                .iter()
315                .any(|x| x.strong_eq(etag))
316        }
317    }
318
319    pub(crate) fn pass_if_none_match(&self, etag: &EntityTag) -> bool {
320        if self.is_none_match_any() {
321            true
322        } else {
323            self.none_match_values()
324                .unwrap()
325                .iter()
326                .all(|x| x.weak_ne(etag))
327        }
328    }
329
330    pub(crate) fn languages(&self) -> &[MyLanguageTag] {
331        self.languages.as_slice()
332    }
333
334    pub(crate) fn is_json_content(&self) -> bool {
335        self.is_json_content
336    }
337
338    fn is_match_any(&self) -> bool {
339        matches!(self.if_match_etags, ETagValue::Any)
340    }
341
342    fn match_values(&self) -> Option<&Vec<EntityTag>> {
343        match &self.if_match_etags {
344            ETagValue::Set(x) => Some(x),
345            _ => None,
346        }
347    }
348
349    fn has_if_none_match(&self) -> bool {
350        !matches!(self.if_none_match_etags, ETagValue::Absent)
351    }
352
353    fn is_none_match_any(&self) -> bool {
354        matches!(self.if_none_match_etags, ETagValue::Any)
355    }
356
357    fn none_match_values(&self) -> Option<&Vec<EntityTag>> {
358        match &self.if_none_match_etags {
359            ETagValue::Set(x) => Some(x),
360            _ => None,
361        }
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use tracing_test::traced_test;
369
370    #[traced_test]
371    #[test]
372    fn test_sort_order_parsing_al() {
373        const TV: &str = "en-AU; q = 0.8, , en;q=0.1 , en-GB,  en-US;q=0.9,";
374
375        let tags = process_accept_language(TV);
376        assert!(!tags.is_empty());
377        assert_eq!(tags.len(), 4);
378        let cv = vec![
379            "en-GB".to_string(),
380            "en-US".to_string(),
381            "en-AU".to_string(),
382            "en".to_string(),
383        ];
384        for i in 0..4 {
385            assert_eq!(tags[i], cv[i])
386        }
387    }
388
389    #[traced_test]
390    #[test]
391    fn test_leniency_parsing_al() {
392        const TV: &str = "fr-CA;q=0.8,foo,fr-LB;p=0.99,fr-FR,fr;q=0.25";
393
394        let tags = process_accept_language(TV);
395        assert!(!tags.is_empty());
396        assert_eq!(tags.len(), 4);
397        let cv = vec![
398            "fr-FR".to_string(),
399            "fr-CA".to_string(),
400            "fr".to_string(),
401            "fr-LB".to_string(),
402        ];
403        for i in 0..4 {
404            assert_eq!(tags[i], cv[i])
405        }
406    }
407}