Skip to main content

xapi_rs/lrs/resources/
agent_profile.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3#![allow(non_snake_case)]
4
5//! Agent Profile Resource (/agents/profile)
6//! -----------------------------------------
7//! A place to store information about an [Agent][1] or an Identified [Group][2]
8//! in a generic form called a _document_. This information is not tied to an
9//! activity or registration. The semantics of the LRS response are driven
10//! by the presence of a `profileId` parameter. If it is included, the **`GET`**
11//! and **`DELETE`** methods acts upon a single defined profile _document_
12//! identified by `profileId`. Otherwise, **`GET`**` returns the available ids
13//! given through the other parameter.
14//!
15//! **IMPORTANT** - This resource has concurrency controls associated w/ it.
16//!
17//! Any deviation from section [4.1.6.5 Agent Profile Resource (/agents/profile)][3]
18//! of the xAPI specification is a bug.
19//!
20//! [1]: crate::Agent
21//! [2]: crate::Group
22//! [3]: https://opensource.ieee.org/xapi/xapi-base-standard-documentation/-/blob/main/9274.1.1%20xAPI%20Base%20Standard%20for%20LRSs.md#4165-agent-profile-resource-agentsprofile
23
24use crate::{
25    DataError, MyError,
26    db::{
27        actor::find_agent_id_from_str,
28        agent_profile::{find, find_ids, remove, upsert},
29    },
30    eval_preconditions,
31    lrs::{
32        DB, Headers, User, WithDocumentOrIDs, emit_doc_response, etag_from_str, no_content,
33        resources::WithETag,
34    },
35};
36use chrono::{DateTime, Utc};
37use rocket::{State, delete, get, http::Status, post, put, routes};
38use serde_json::{Map, Value};
39use sqlx::PgPool;
40use std::mem;
41use tracing::{debug, info};
42
43#[doc(hidden)]
44pub fn routes() -> Vec<rocket::Route> {
45    routes![put, post, delete, get]
46}
47
48/// Store a single document with the given id w/ Body being the document object
49/// to be stored.
50#[put("/?<agent>&<profileId>", data = "<doc>")]
51async fn put(
52    c: Headers,
53    agent: &str,
54    profileId: &str,
55    doc: &str,
56    db: &State<DB>,
57    user: User,
58) -> Result<WithETag, MyError> {
59    debug!("----- put ----- {}", user);
60    user.can_use_xapi()?;
61
62    if doc.is_empty() {
63        return Err(MyError::HTTP {
64            status: Status::BadRequest,
65            info: "Document must NOT be an empty string".into(),
66        });
67    }
68
69    // NOTE (rsn) 20241104 - it's an error if JSON is claimed but document isn't
70    if c.is_json_content() {
71        serde_json::from_str::<Map<String, Value>>(doc)
72            .map_err(|x| MyError::Data(DataError::JSON(x)).with_status(Status::BadRequest))?;
73    }
74
75    let conn = db.pool();
76    match find_agent_id_from_str(conn, agent).await {
77        Ok(agent_id) => {
78            debug!("agent_id = {}", agent_id);
79
80            // if a PUT request is received without If-[None-]Match headers for
81            // a resource that already exists, we should return Status 409
82            let (x, _) = find(conn, agent_id, profileId).await?;
83            match x {
84                None => {
85                    // insert it...
86                    let etag = etag_from_str(doc);
87                    upsert(conn, agent_id, profileId, doc).await?;
88                    Ok(no_content(&etag))
89                }
90                Some(old_doc) => {
91                    if c.has_no_conditionals() {
92                        Err(MyError::HTTP {
93                            status: Status::Conflict,
94                            info: "PUT a known resource, w/ no pre-conditions, is NOT allowed"
95                                .into(),
96                        })
97                    } else {
98                        // only upsert it if pre-conditions pass...
99                        let etag = etag_from_str(&old_doc);
100                        debug!("etag (old) = {}", etag);
101                        match eval_preconditions!(&etag, c) {
102                            s if s != Status::Ok => Err(MyError::HTTP {
103                                status: s,
104                                info: "Failed pre-condition(s)".into(),
105                            }),
106                            _ => {
107                                if old_doc == doc {
108                                    info!("Old + new Agent Profile documents are identical");
109                                    Ok(no_content(&etag))
110                                } else {
111                                    let etag = etag_from_str(doc);
112                                    upsert(conn, agent_id, profileId, doc).await?;
113                                    Ok(no_content(&etag))
114                                }
115                            }
116                        }
117                    }
118                }
119            }
120        }
121        Err(x) => match x {
122            MyError::Data(_) => Err(x.with_status(Status::BadRequest)),
123            x => Err(x),
124        },
125    }
126}
127
128/// Stores/updates a single document with the given id w/ Body being the
129/// document to be stored/updated.
130#[post("/?<agent>&<profileId>", data = "<doc>")]
131async fn post(
132    c: Headers,
133    agent: &str,
134    profileId: &str,
135    doc: &str,
136    db: &State<DB>,
137    user: User,
138) -> Result<WithETag, MyError> {
139    debug!("----- post ----- {}", user);
140    user.can_use_xapi()?;
141
142    if doc.is_empty() {
143        return Err(MyError::HTTP {
144            status: Status::BadRequest,
145            info: "Document must NOT be an empty string".into(),
146        });
147    }
148
149    // NOTE (rsn) 20241104 - it's an error if JSON is claimed but document isn't
150    if c.is_json_content() {
151        serde_json::from_str::<Map<String, Value>>(doc)
152            .map_err(|x| MyError::Data(DataError::JSON(x)).with_status(Status::BadRequest))?;
153    }
154
155    let conn = db.pool();
156    match find_agent_id_from_str(conn, agent).await {
157        Ok(agent_id) => {
158            debug!("agent_id = {}", agent_id);
159
160            let (x, _) = find(conn, agent_id, profileId).await?;
161            match x {
162                None => {
163                    // insert it...
164                    upsert(conn, agent_id, profileId, doc).await?;
165                    let etag = etag_from_str(doc);
166                    Ok(no_content(&etag))
167                }
168                Some(old_doc) => {
169                    let etag = etag_from_str(&old_doc);
170                    debug!("etag (old) = {}", etag);
171                    if c.has_conditionals() {
172                        match eval_preconditions!(&etag, c) {
173                            s if s != Status::Ok => {
174                                return Err(MyError::HTTP {
175                                    status: s,
176                                    info: "Failed pre-condition(s)".into(),
177                                });
178                            }
179                            _ => (),
180                        }
181                    }
182
183                    let mut old: Map<String, Value> =
184                        serde_json::from_str(&old_doc).map_err(|x| {
185                            MyError::Data(DataError::JSON(x)).with_status(Status::BadRequest)
186                        })?;
187
188                    let mut new: Map<String, Value> = serde_json::from_str(doc).map_err(|x| {
189                        MyError::Data(DataError::JSON(x)).with_status(Status::BadRequest)
190                    })?;
191
192                    if old == new {
193                        info!("Old + new Agent Profile documents are identical");
194                        return Ok(no_content(&etag));
195                    }
196
197                    debug!("document (before) = '{}'", old_doc);
198                    for (k, v) in new.iter_mut() {
199                        let new_v = mem::take(v);
200                        old.insert(k.to_owned(), new_v);
201                    }
202                    // serialize updated 'old' so we can persist it...
203                    let merged =
204                        serde_json::to_string(&old).expect("Failed serialize merged document");
205                    debug!("document ( after) = '{}'", merged);
206
207                    upsert(conn, agent_id, profileId, &merged).await?;
208                    let etag = etag_from_str(&merged);
209                    Ok(no_content(&etag))
210                }
211            }
212        }
213        Err(x) => match x {
214            MyError::Data(_) => Err(x.with_status(Status::BadRequest)),
215            x => Err(x),
216        },
217    }
218}
219
220/// Deletes a single document with the given id.
221#[delete("/?<agent>&<profileId>")]
222async fn delete(
223    c: Headers,
224    agent: &str,
225    profileId: &str,
226    db: &State<DB>,
227    user: User,
228) -> Result<Status, MyError> {
229    debug!("----- delete ----- {}", user);
230    let _ = user.can_use_xapi();
231
232    let conn = db.pool();
233    match find_agent_id_from_str(conn, agent).await {
234        Ok(agent_id) => {
235            debug!("agent_id = {}", agent_id);
236            let (document, _) = get_profile(conn, agent_id, profileId).await?;
237            let etag = etag_from_str(&document);
238            debug!("etag (LaRS) = {}", etag);
239            match eval_preconditions!(&etag, c) {
240                s if s != Status::Ok => Err(MyError::HTTP {
241                    status: s,
242                    info: "Failed pre-condition(s)".into(),
243                }),
244                _ => {
245                    remove(conn, agent_id, profileId).await?;
246                    Ok(Status::NoContent)
247                }
248            }
249        }
250        Err(x) => match x {
251            MyError::Data(_) => Err(x.with_status(Status::BadRequest)),
252            x => Err(x),
253        },
254    }
255}
256
257/// When `profileId` is specified, fetch a single document with the given id.
258/// Otherwise, fetch IDs of all Agent Profile documents for the given Agent. If
259/// `since` is specified, then limit result to records that have been stored or
260/// updated since the specified Timestamp (exclusive).
261#[get("/?<agent>&<profileId>&<since>")]
262async fn get(
263    agent: &str,
264    profileId: Option<&str>,
265    since: Option<&str>,
266    db: &State<DB>,
267    user: User,
268) -> Result<WithDocumentOrIDs, MyError> {
269    debug!("----- get ----- {}", user);
270    user.can_use_xapi()?;
271
272    let conn = db.pool();
273    match find_agent_id_from_str(conn, agent).await {
274        Ok(agent_id) => {
275            debug!("agent_id = {}", agent_id);
276            let resource = if let Some(z_profile_id) = profileId {
277                if since.is_some() {
278                    return Err(MyError::HTTP {
279                        status: Status::BadRequest,
280                        info: "Either `profileId` or `since` should be specified; not both".into(),
281                    });
282                } else {
283                    get_profile(conn, agent_id, z_profile_id).await?
284                }
285            } else {
286                let (x, last_updated) = get_ids(conn, agent_id, since).await?;
287                (serde_json::to_string(&x).unwrap(), last_updated)
288            };
289
290            debug!("resource = {:?}", resource);
291            emit_doc_response(resource.0, Some(resource.1)).await
292        }
293        Err(x) => match x {
294            MyError::Data(_) => Err(x.with_status(Status::BadRequest)),
295            x => Err(x),
296        },
297    }
298}
299
300async fn get_profile(
301    conn: &PgPool,
302    actor_id: i32,
303    profile_id: &str,
304) -> Result<(String, DateTime<Utc>), MyError> {
305    let (x, updated) = find(conn, actor_id, profile_id).await?;
306    match x {
307        None => Err(MyError::HTTP {
308            status: Status::NotFound,
309            info: format!("Failed find Agent Profile ({profile_id}) for Actor #{actor_id}").into(),
310        }),
311        Some(doc) => Ok((doc, updated)),
312    }
313}
314
315async fn get_ids(
316    conn: &PgPool,
317    actor_id: i32,
318    since: Option<&str>,
319) -> Result<(Vec<String>, DateTime<Utc>), MyError> {
320    let since = if let Some(z_datetime) = since {
321        let x = DateTime::parse_from_rfc3339(z_datetime)
322            .map_err(|x| MyError::Data(DataError::Time(x)).with_status(Status::BadRequest))?;
323        Some(x.with_timezone(&Utc))
324    } else {
325        None
326    };
327
328    find_ids(conn, actor_id, since).await
329}