Skip to main content

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