Skip to main content

headless_lms_utils/services/
tmc.rs

1use std::time::Duration;
2
3use reqwest::Client;
4use secrecy::{ExposeSecret, SecretString};
5use serde::{Deserialize, Serialize};
6use serde_json::json;
7use tracing::{debug, info};
8use url::Url;
9use uuid::Uuid;
10
11use crate::prelude::*;
12use headless_lms_base::config::ApplicationConfiguration;
13
14#[derive(Debug, Clone)]
15pub struct TmcClient {
16    client: Client,
17    admin_access_token: SecretString,
18    ratelimit_api_key: SecretString,
19}
20
21pub struct NewUserInfo {
22    pub first_name: String,
23    pub last_name: String,
24    pub email: String,
25    pub password: SecretString,
26    pub password_confirmation: SecretString,
27    pub language: String,
28}
29
30#[derive(Debug, Deserialize)]
31pub struct TmcUserInfo {
32    pub id: Uuid,
33    pub email: String,
34    pub first_name: Option<String>,
35    pub last_name: Option<String>,
36    pub upstream_id: i32,
37}
38
39#[derive(Deserialize)]
40pub struct TMCUserResponse {
41    pub id: i32,
42}
43
44#[derive(Deserialize)]
45struct TmcDeleteAccountResponse {
46    success: bool,
47    /// Optional because TMC deployments older than the deletion contract omit it.
48    #[serde(default)]
49    already_deleted: Option<bool>,
50}
51
52/// What a delete left behind on the TMC side. Both values mean the upstream account is gone and the
53/// local one may follow.
54#[derive(Debug, PartialEq, Eq, Clone, Copy)]
55pub enum TmcAccountDeletion {
56    Deleted,
57    /// TMC had no such account to begin with.
58    AlreadyDeleted,
59}
60
61#[derive(Debug, Serialize, Deserialize)]
62pub struct TMCUser {
63    pub id: i32, // upstream_id
64    pub username: String,
65    pub email: String,
66    pub administrator: bool,
67    pub courses_mooc_fi_user_id: Option<Uuid>,
68    #[serde(default)]
69    pub user_field: TMCUserField,
70}
71
72/// User fields are optional data on the TMC side: a user who never filled in their profile (or a
73/// TMC instance without the field definitions) serializes them as null or omits them entirely, so
74/// deserialization must not require them.
75#[derive(Debug, Default, Serialize, Deserialize)]
76pub struct TMCUserField {
77    #[serde(default)]
78    pub first_name: Option<String>,
79    #[serde(default)]
80    pub last_name: Option<String>,
81    #[serde(default)]
82    pub organizational_id: Option<String>,
83    #[serde(default)]
84    pub course_announcements: bool,
85}
86
87enum TMCRequestAuth {
88    UseAdminToken,
89    UseUserToken(SecretString),
90    NoAuth,
91}
92
93const TMC_API_URL: &str = "https://tmc.mooc.fi/api/v8/users";
94
95/// Deliberately above tmc.mooc.fi's own 120 s request timeout: giving up earlier would abandon a
96/// delete that upstream then completes, leaving the account gone there and present here.
97const DELETE_ACCOUNT_TIMEOUT: Duration = Duration::from_secs(150);
98
99fn format_tmc_errors(errors: &serde_json::Value) -> String {
100    let mut error_messages = Vec::new();
101
102    if let Some(error_array) = errors.as_array() {
103        for error_msg in error_array {
104            match error_msg.as_str() {
105                Some(msg) => error_messages.push(msg.to_string()),
106                None => error_messages.push(error_msg.to_string()),
107            }
108        }
109    } else if let Some(errors_obj) = errors.as_object() {
110        for (field, field_errors) in errors_obj {
111            if let Some(error_array) = field_errors.as_array() {
112                for error_msg in error_array {
113                    if let Some(msg) = error_msg.as_str() {
114                        let field_name = match field.as_str() {
115                            "login" => "username",
116                            _ => field,
117                        };
118                        error_messages.push(format!("{}: {}", field_name, msg));
119                    }
120                }
121            } else if let Some(msg) = field_errors.as_str() {
122                error_messages.push(format!("{}: {}", field, msg));
123            }
124        }
125    }
126
127    if error_messages.is_empty() {
128        errors.to_string()
129    } else {
130        error_messages.join(", ")
131    }
132}
133
134fn parse_tmc_error_response(error_text: &str, status: Option<reqwest::StatusCode>) -> String {
135    if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(error_text) {
136        // `code` is TMC's stable, machine-readable reason; the human text beside it is neither.
137        let code = error_json.get("code").and_then(|c| c.as_str());
138        let detail = match error_json.get("errors") {
139            Some(errors) => Some(format_tmc_errors(errors)),
140            None => error_json
141                .get("message")
142                .and_then(|m| m.as_str())
143                .map(str::to_string),
144        };
145        match (code, detail) {
146            (Some(code), Some(detail)) => return format!("{code}: {detail}"),
147            (Some(code), None) => return code.to_string(),
148            (None, Some(detail)) => return detail,
149            (None, None) => {}
150        }
151    }
152
153    if let Some(status) = status {
154        format!("Request failed with status {}: {}", status, error_text)
155    } else {
156        format!("Request failed: {}", error_text)
157    }
158}
159
160impl TmcClient {
161    fn check_if_tmc_error_response(response_text: &str) -> Option<UtilError> {
162        if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(response_text)
163            && (error_json.get("errors").is_some()
164                || error_json.get("success") == Some(&serde_json::Value::Bool(false)))
165        {
166            let error_message = parse_tmc_error_response(response_text, None);
167            return Some(UtilError::new(
168                UtilErrorType::TmcErrorResponse,
169                error_message,
170                None,
171            ));
172        }
173        None
174    }
175
176    async fn deserialize_response_with_tmc_error_check<T: serde::de::DeserializeOwned>(
177        &self,
178        response: reqwest::Response,
179        error_context: &str,
180    ) -> UtilResult<T> {
181        let response_text = response.text().await.map_err(|e| {
182            UtilError::new(
183                UtilErrorType::DeserializationError,
184                format!("Failed to read TMC response body: {}", error_context),
185                Some(e.into()),
186            )
187        })?;
188
189        serde_json::from_str(&response_text).map_err(|e| {
190            if let Some(tmc_error) = Self::check_if_tmc_error_response(&response_text) {
191                tmc_error
192            } else {
193                UtilError::new(
194                    UtilErrorType::DeserializationError,
195                    format!("Failed to parse {}: {}", error_context, e),
196                    Some(e.into()),
197                )
198            }
199        })
200    }
201
202    pub fn new(
203        admin_access_token: SecretString,
204        ratelimit_api_key: SecretString,
205    ) -> UtilResult<Self> {
206        if admin_access_token.expose_secret().trim().is_empty() {
207            return Err(UtilError::new(
208                UtilErrorType::Other,
209                "TMC_ACCESS_TOKEN cannot be empty".to_string(),
210                None,
211            ));
212        }
213        if ratelimit_api_key.expose_secret().trim().is_empty() {
214            return Err(UtilError::new(
215                UtilErrorType::Other,
216                "RATELIMIT_PROTECTION_SAFE_API_KEY cannot be empty".to_string(),
217                None,
218            ));
219        }
220
221        let client = reqwest::Client::builder()
222            .timeout(std::time::Duration::from_secs(15))
223            .build()
224            .map_err(|e| {
225                UtilError::new(
226                    UtilErrorType::Other,
227                    "Failed to build HTTP client".to_string(),
228                    Some(e.into()),
229                )
230            })?;
231
232        Ok(Self {
233            client,
234            admin_access_token,
235            ratelimit_api_key,
236        })
237    }
238
239    async fn request_with_headers(
240        &self,
241        method: reqwest::Method,
242        url: &str,
243        tmc_request_auth: TMCRequestAuth,
244        body: Option<serde_json::Value>,
245    ) -> UtilResult<reqwest::Response> {
246        self.request_with_headers_and_timeout(method, url, tmc_request_auth, body, None)
247            .await
248    }
249
250    /// `timeout` overrides the shared client's default for this one request.
251    async fn request_with_headers_and_timeout(
252        &self,
253        method: reqwest::Method,
254        url: &str,
255        tmc_request_auth: TMCRequestAuth,
256        body: Option<serde_json::Value>,
257        timeout: Option<Duration>,
258    ) -> UtilResult<reqwest::Response> {
259        let mut builder = self
260            .client
261            .request(method, url)
262            .header(
263                "RATELIMIT-PROTECTION-SAFE-API-KEY",
264                self.ratelimit_api_key.expose_secret(),
265            )
266            .header(reqwest::header::CONTENT_TYPE, "application/json")
267            .header(reqwest::header::ACCEPT, "application/json");
268
269        let access_token = match tmc_request_auth {
270            TMCRequestAuth::UseAdminToken => Some(&self.admin_access_token),
271            TMCRequestAuth::UseUserToken(ref token) => Some(token),
272            TMCRequestAuth::NoAuth => None,
273        };
274
275        if let Some(token) = access_token {
276            builder = builder.bearer_auth(token.expose_secret());
277        }
278
279        if let Some(json_body) = body {
280            builder = builder.json(&json_body);
281        }
282
283        if let Some(timeout) = timeout {
284            builder = builder.timeout(timeout);
285        }
286
287        let res = builder.send().await.map_err(|e| {
288            UtilError::new(
289                UtilErrorType::TmcHttpError,
290                "Failed to send HTTP request".to_string(),
291                Some(e.into()),
292            )
293        })?;
294
295        if res.status().is_success() {
296            Ok(res)
297        } else {
298            let status = res.status();
299            let error_text = res
300                .text()
301                .await
302                .unwrap_or_else(|e| format!("(Failed to read error body: {e})"));
303
304            if let Ok(parsed) = reqwest::Url::parse(url) {
305                let redacted = format!(
306                    "{}{}",
307                    parsed.origin().unicode_serialization(),
308                    parsed.path()
309                );
310                tracing::warn!("Request to {} failed with status {}", redacted, status);
311            } else {
312                tracing::warn!("Request failed with status {}", status);
313            }
314            tracing::debug!("Response body: {}", error_text);
315
316            let error_message = parse_tmc_error_response(&error_text, Some(status));
317
318            // A statusful failure, unlike the send failure above, which stays `TmcHttpError`.
319            Err(util_err!(
320                TmcHttpStatusError(status.as_u16()),
321                error_message
322            ))
323        }
324    }
325
326    pub async fn update_user_information(
327        &self,
328        first_name: String,
329        last_name: String,
330        email: Option<String>,
331        user_upstream_id: String,
332    ) -> UtilResult<()> {
333        let mut user_obj = serde_json::Map::new();
334        let mut user_field_obj = serde_json::Map::new();
335
336        if let Some(email) = email {
337            user_obj.insert("email".to_string(), serde_json::Value::String(email));
338        }
339
340        user_field_obj.insert(
341            "first_name".to_string(),
342            serde_json::Value::String(first_name),
343        );
344        user_field_obj.insert(
345            "last_name".to_string(),
346            serde_json::Value::String(last_name),
347        );
348
349        let mut payload = serde_json::Map::new();
350
351        if !user_obj.is_empty() {
352            payload.insert("user".to_string(), serde_json::Value::Object(user_obj));
353        }
354
355        payload.insert(
356            "user_field".to_string(),
357            serde_json::Value::Object(user_field_obj),
358        );
359
360        let payload_value = serde_json::Value::Object(payload);
361
362        let url = format!("{}/{}", TMC_API_URL, user_upstream_id);
363
364        self.request_with_headers(
365            reqwest::Method::PUT,
366            &url,
367            TMCRequestAuth::UseAdminToken,
368            Some(payload_value),
369        )
370        .await
371        .map(|_| ())
372    }
373
374    pub async fn post_new_user_to_tmc(
375        &self,
376        user_info: NewUserInfo,
377        app_conf: &ApplicationConfiguration,
378    ) -> UtilResult<i32> {
379        let payload = json!({
380            "user": {
381                "email": user_info.email,
382                "first_name": user_info.first_name,
383                "last_name": user_info.last_name,
384                "password": user_info.password.expose_secret(),
385                "password_confirmation": user_info.password_confirmation.expose_secret()
386            },
387            "user_field": {
388                "first_name": user_info.first_name,
389                "last_name": user_info.last_name
390            },
391            "origin": app_conf.tmc_account_creation_origin,
392            "language": user_info.language
393        });
394
395        let url = format!("{}?include_id=true", TMC_API_URL);
396        let response = self
397            .request_with_headers(
398                reqwest::Method::POST,
399                &url,
400                TMCRequestAuth::NoAuth,
401                Some(payload),
402            )
403            .await?;
404
405        let body: TMCUserResponse = self
406            .deserialize_response_with_tmc_error_check(response, "TMC user response")
407            .await?;
408        Ok(body.id)
409    }
410
411    pub async fn set_user_password_managed_by_courses_mooc_fi(
412        &self,
413        user_upstream_id: String,
414        user_id: Uuid,
415    ) -> UtilResult<()> {
416        let url = format!(
417            "{}/{}/set_password_managed_by_courses_mooc_fi",
418            TMC_API_URL, user_upstream_id
419        );
420
421        let payload = serde_json::json!({
422            "courses_mooc_fi_user_id": user_id.to_string(),
423        });
424
425        self.request_with_headers(
426            reqwest::Method::POST,
427            &url,
428            TMCRequestAuth::UseAdminToken,
429            Some(payload),
430        )
431        .await
432        .map(|_| ())
433    }
434
435    pub async fn get_user_from_tmc_with_email(&self, email: String) -> UtilResult<TmcUserInfo> {
436        let mut url = Url::parse(TMC_API_URL)?;
437        url.path_segments_mut()
438            .map_err(|_| {
439                UtilError::new(
440                    UtilErrorType::UrlParse,
441                    "Failed to get path segments from URL".to_string(),
442                    None,
443                )
444            })?
445            .push("get_user_with_email");
446        url.query_pairs_mut().append_pair("email", &email);
447
448        let res = self
449            .request_with_headers(
450                reqwest::Method::GET,
451                url.as_str(),
452                TMCRequestAuth::UseAdminToken,
453                None,
454            )
455            .await?;
456
457        let user: TmcUserInfo = self
458            .deserialize_response_with_tmc_error_check(res, "TMC user from JSON")
459            .await?;
460
461        Ok(user)
462    }
463
464    /// Deletes the upstream tmc.mooc.fi account behind `user_upstream_id`.
465    ///
466    /// Every failure is an error carrying TMC's HTTP status, which is what separates a retryable
467    /// outage from a rejection the caller must not retry. An unknown user can arrive either way:
468    /// as [`TmcAccountDeletion::AlreadyDeleted`], or, from deployments predating the deletion
469    /// contract, as a [`UtilErrorType::TmcHttpStatusError`] carrying 404.
470    pub async fn delete_user_from_tmc(
471        &self,
472        user_upstream_id: String,
473    ) -> UtilResult<TmcAccountDeletion> {
474        let url = format!("{}/{}", TMC_API_URL, user_upstream_id);
475
476        let res = self
477            .request_with_headers_and_timeout(
478                reqwest::Method::DELETE,
479                &url,
480                TMCRequestAuth::UseAdminToken,
481                None,
482                Some(DELETE_ACCOUNT_TIMEOUT),
483            )
484            .await?;
485
486        let body: TmcDeleteAccountResponse = self
487            .deserialize_response_with_tmc_error_check(res, "delete response from TMC")
488            .await?;
489
490        if !body.success {
491            return Err(util_err!(
492                TmcErrorResponse,
493                "TMC answered the account deletion with success: false".to_string()
494            ));
495        }
496
497        if body.already_deleted.unwrap_or(false) {
498            Ok(TmcAccountDeletion::AlreadyDeleted)
499        } else {
500            Ok(TmcAccountDeletion::Deleted)
501        }
502    }
503
504    pub async fn get_user_from_tmc_mooc_fi_by_tmc_access_token(
505        &self,
506        tmc_access_token: &SecretString,
507    ) -> UtilResult<TMCUser> {
508        info!("Getting user details from tmc.mooc.fi");
509
510        let res = self
511            .request_with_headers(
512                reqwest::Method::GET,
513                &format!("{}/current?show_user_fields=1", TMC_API_URL),
514                TMCRequestAuth::UseUserToken(tmc_access_token.clone()),
515                None,
516            )
517            .await?;
518
519        debug!("Received response from TMC, parsing user data");
520        let tmc_user: TMCUser = self
521            .deserialize_response_with_tmc_error_check(res, "current user from TMC by access token")
522            .await?;
523
524        debug!(
525            "Creating or fetching user with TMC id {} and mooc.fi UUID {}",
526            tmc_user.id,
527            tmc_user
528                .courses_mooc_fi_user_id
529                .map(|uuid| uuid.to_string())
530                .unwrap_or_else(|| "None (will generate new UUID)".to_string())
531        );
532        Ok(tmc_user)
533    }
534
535    pub async fn get_user_from_tmc_mooc_fi_by_tmc_access_token_and_upstream_id(
536        &self,
537        upstream_id: &i32,
538    ) -> UtilResult<TMCUser> {
539        info!("Getting user details from tmc.mooc.fi");
540
541        let res = self
542            .request_with_headers(
543                reqwest::Method::GET,
544                &format!("{}/{}?show_user_fields=1", TMC_API_URL, upstream_id),
545                TMCRequestAuth::UseAdminToken,
546                None,
547            )
548            .await?;
549
550        debug!("Received response from TMC, parsing user data");
551        let tmc_user: TMCUser = self
552            .deserialize_response_with_tmc_error_check(res, "user from TMC by upstream ID")
553            .await?;
554
555        Ok(tmc_user)
556    }
557
558    pub fn mock_for_test() -> Self {
559        Self {
560            client: Client::default(),
561            admin_access_token: SecretString::new("mock-token".to_string().into()),
562            ratelimit_api_key: SecretString::new("mock-api-key".to_string().into()),
563        }
564    }
565
566    pub fn get_admin_access_token(&self) -> &SecretString {
567        &self.admin_access_token
568    }
569}