xapi_rs/lrs/resources/
mod.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3#![doc = include_str!("../../../doc/Resources.md")]
4
5pub mod about;
6pub mod activities;
7pub mod activity_profile;
8pub mod agent_profile;
9pub mod agents;
10pub mod state;
11pub mod statement;
12pub mod stats;
13pub mod users;
14pub mod verbs;
15
16use crate::{
17    DataError, MyError,
18    lrs::{Headers, server::get_consistent_thru},
19};
20use chrono::{DateTime, SecondsFormat, Utc};
21use etag::EntityTag;
22use rocket::{
23    Responder,
24    http::{Header, Status, hyper::header},
25    serde::json::Json,
26};
27use serde::Serialize;
28use tracing::debug;
29
30/// A derived Rocket Responder structure w/ an OK Status, a body consisting
31/// of the JSON Serialized string of a generic type `T`, an `Etag` and
32/// `Last-Modified` Headers.
33#[derive(Responder)]
34#[response(status = 200, content_type = "json")]
35pub(crate) struct WithResource<T> {
36    inner: Json<T>,
37    etag: Header<'static>,
38    last_modified: Header<'static>,
39}
40
41#[derive(Responder)]
42#[response(status = 200, content_type = "json")]
43pub(crate) struct WithDocumentOrIDs {
44    inner: String,
45    etag: Header<'static>,
46    last_modified: Header<'static>,
47}
48
49/// A derived Rocket Responder w/ a No Content Status and an ETag Header only.
50#[derive(Responder)]
51pub(crate) struct WithETag {
52    inner: Status,
53    etag: Header<'static>,
54}
55
56/// Given a string reference `s`, hash its bytes and return an `EntityTag`
57/// instance built from the resulting hash.
58pub(crate) fn etag_from_str(s: &str) -> EntityTag {
59    EntityTag::from_data(s.as_bytes())
60}
61
62/// Given an instance of a type `T` that is `serde` _Serializable_, try
63/// serializing it to JSON and return an `EntityTag` from the result.
64///
65/// Raise `LRSError` if an error occurs in the process.
66pub(crate) fn compute_etag<T>(res: &T) -> Result<EntityTag, MyError>
67where
68    T: ?Sized + Serialize,
69{
70    // serialize it...
71    let json = serde_json::to_string(res).map_err(|x| MyError::Data(DataError::JSON(x)))?;
72    Ok(etag_from_str(&json))
73}
74
75/// Internal function to effectively construct and emit a Rocket response
76/// w/ all the needed arguments.
77///
78/// The `timestamp` parameter is the value that will be used to populate the
79/// `Last-Modified` header. If it's `None` the global CONSISTENT_THRU value
80/// will be used.
81pub(crate) async fn do_emit_response<T: Serialize>(
82    c: Headers,
83    resource: T,
84    timestamp: Option<DateTime<Utc>>,
85) -> Result<WithResource<T>, MyError> {
86    let etag = compute_etag(&resource)?;
87    debug!("Etag = '{}'", etag);
88
89    let last_modified = if let Some(x) = timestamp {
90        x.to_rfc3339_opts(SecondsFormat::Millis, true)
91    } else {
92        get_consistent_thru()
93            .await
94            .to_rfc3339_opts(SecondsFormat::Millis, true)
95    };
96    debug!("Last-Modified = '{}'", last_modified);
97
98    let response = Ok(WithResource {
99        inner: Json(resource),
100        etag: Header::new(header::ETAG.as_str(), etag.to_string()),
101        last_modified: Header::new(header::LAST_MODIFIED.as_str(), last_modified),
102    });
103
104    if !c.has_conditionals() {
105        debug!("Request has no If-xxx headers");
106        return response;
107    }
108
109    if c.has_if_match() {
110        if c.pass_if_match(&etag) {
111            debug!("ETag passed If-Match pre-condition");
112            return response;
113        }
114
115        return Err(MyError::HTTP {
116            status: Status::PreconditionFailed,
117            info: "ETag failed If-Match pre-condition".into(),
118        });
119    }
120
121    if c.pass_if_none_match(&etag) {
122        debug!("ETag passed If-None-Match pre-condition");
123        return response;
124    }
125
126    Err(MyError::HTTP {
127        status: Status::NotModified,
128        info: "ETag failed If-None-Match pre-condition".into(),
129    })
130}
131
132/// Given `$resource` of type `$type` that is `serde` _Serializable_ and
133/// `$headers` (an instance of a type that handles HTTP request headers)...
134///
135/// 1. compute the Resource's **`Etag`**, and instantiate both **`Etag`** and
136///    **`Last-Modified`** Headers,
137/// 2. evaluate the **`If-Match`** pre-conditions,
138/// 3. return a _Response_ of the form `Result<WithResource<T>, Status>`.
139#[macro_export]
140macro_rules! emit_response {
141    ( $headers:expr, $resource:expr => $T:ident, $timestamp:expr ) => {
142        $crate::lrs::resources::do_emit_response::<$T>($headers, $resource, Some($timestamp)).await
143    };
144
145    ( $headers:expr, $resource:expr => $T:ident ) => {
146        $crate::lrs::resources::do_emit_response::<$T>($headers, $resource, None).await
147    };
148}
149
150/// Internal function to construct and emit a Rocket response w/ all the needed
151/// arguments when handling a Resource that is a Document or a list of IDs.
152///
153/// The `timestamp` argument will be used to populate the `Last-Modified`
154/// header. If it's `None` the value of the CONSISTENT_THRU Singleton will
155/// be used.
156pub(crate) async fn emit_doc_response(
157    resource: String,
158    timestamp: Option<DateTime<Utc>>,
159) -> Result<WithDocumentOrIDs, MyError> {
160    let etag = etag_from_str(&resource);
161    debug!("etag = '{}'", etag);
162    let last_modified = if let Some(x) = timestamp {
163        x.to_rfc3339_opts(SecondsFormat::Millis, true)
164    } else {
165        get_consistent_thru()
166            .await
167            .to_rfc3339_opts(SecondsFormat::Millis, true)
168    };
169
170    Ok(WithDocumentOrIDs {
171        inner: resource,
172        etag: Header::new(header::ETAG.as_str(), etag.to_string()),
173        last_modified: Header::new(header::LAST_MODIFIED.as_str(), last_modified),
174    })
175}
176
177/// Given an `$etag` (Entity Tag) value and `$headers` (an instance of a type
178/// that handles HTTP request headers), check that the **`If-XXX`** pre-
179/// conditions when present, pass.
180///
181/// Return an HTTP Status that describes the result. Specifically...
182/// * Ok: if pre-conditions where absent, or were present but passed,
183/// * PreconditionFailed: if pre-conditions were present and failed.
184#[macro_export]
185macro_rules! eval_preconditions {
186    ( $etag: expr, $headers: expr ) => {
187        if !$headers.has_conditionals() {
188            tracing::debug!("Request has no If-xxx headers");
189            Status::Ok
190        } else if $headers.has_if_match() {
191            if $headers.pass_if_match($etag) {
192                tracing::debug!("ETag passed If-Match pre-condition");
193                Status::Ok
194            } else {
195                tracing::debug!("ETag failed If-Match pre-condition");
196                Status::PreconditionFailed
197            }
198        } else if $headers.pass_if_none_match($etag) {
199            tracing::debug!("ETag passed If-None-Match pre-condition");
200            Status::Ok
201        } else {
202            tracing::debug!("ETag failed If-None-Match pre-condition");
203            Status::PreconditionFailed
204        }
205    };
206}
207
208/// Generate a Rocket Response w/ an HTTP Status of 204 (No Content) and an
209/// `Etag` Header w/ the given value.
210pub(crate) fn no_content(etag: &EntityTag) -> WithETag {
211    WithETag {
212        inner: Status::NoContent,
213        etag: Header::new(header::ETAG.as_str(), etag.to_string()),
214    }
215}