Skip to main content

headless_lms_utils/services/
tmc.rs

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