Skip to main content

xapi_rs/lrs/
headers.rs

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