Skip to main content

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