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: String,
24    pub password_confirmation: String,
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(admin_access_token: String, ratelimit_api_key: String) -> UtilResult<Self> {
160        if admin_access_token.trim().is_empty() {
161            return Err(UtilError::new(
162                UtilErrorType::Other,
163                "TMC_ACCESS_TOKEN cannot be empty".to_string(),
164                None,
165            ));
166        }
167        if ratelimit_api_key.trim().is_empty() {
168            return Err(UtilError::new(
169                UtilErrorType::Other,
170                "RATELIMIT_PROTECTION_SAFE_API_KEY cannot be empty".to_string(),
171                None,
172            ));
173        }
174
175        let client = reqwest::Client::builder()
176            .timeout(std::time::Duration::from_secs(15))
177            .build()
178            .map_err(|e| {
179                UtilError::new(
180                    UtilErrorType::Other,
181                    "Failed to build HTTP client".to_string(),
182                    Some(e.into()),
183                )
184            })?;
185
186        Ok(Self {
187            client,
188            admin_access_token: SecretString::new(admin_access_token.into()),
189            ratelimit_api_key: SecretString::new(ratelimit_api_key.into()),
190        })
191    }
192
193    async fn request_with_headers(
194        &self,
195        method: reqwest::Method,
196        url: &str,
197        tmc_request_auth: TMCRequestAuth,
198        body: Option<serde_json::Value>,
199    ) -> UtilResult<reqwest::Response> {
200        let mut builder = self
201            .client
202            .request(method, url)
203            .header(
204                "RATELIMIT-PROTECTION-SAFE-API-KEY",
205                self.ratelimit_api_key.expose_secret(),
206            )
207            .header(reqwest::header::CONTENT_TYPE, "application/json")
208            .header(reqwest::header::ACCEPT, "application/json");
209
210        let access_token = match tmc_request_auth {
211            TMCRequestAuth::UseAdminToken => Some(&self.admin_access_token),
212            TMCRequestAuth::UseUserToken(ref token) => Some(token),
213            TMCRequestAuth::NoAuth => None,
214        };
215
216        if let Some(token) = access_token {
217            builder = builder.bearer_auth(token.expose_secret());
218        }
219
220        if let Some(json_body) = body {
221            builder = builder.json(&json_body);
222        }
223
224        let res = builder.send().await.map_err(|e| {
225            UtilError::new(
226                UtilErrorType::TmcHttpError,
227                "Failed to send HTTP request".to_string(),
228                Some(e.into()),
229            )
230        })?;
231
232        if res.status().is_success() {
233            Ok(res)
234        } else {
235            let status = res.status();
236            let error_text = res
237                .text()
238                .await
239                .unwrap_or_else(|e| format!("(Failed to read error body: {e})"));
240
241            if let Ok(parsed) = reqwest::Url::parse(url) {
242                let redacted = format!(
243                    "{}{}",
244                    parsed.origin().unicode_serialization(),
245                    parsed.path()
246                );
247                tracing::warn!("Request to {} failed with status {}", redacted, status);
248            } else {
249                tracing::warn!("Request failed with status {}", status);
250            }
251            tracing::debug!("Response body: {}", error_text);
252
253            let error_message = parse_tmc_error_response(&error_text, Some(status));
254
255            Err(UtilError::new(
256                UtilErrorType::TmcHttpError,
257                error_message,
258                None,
259            ))
260        }
261    }
262
263    pub async fn update_user_information(
264        &self,
265        first_name: String,
266        last_name: String,
267        email: Option<String>,
268        user_upstream_id: String,
269    ) -> UtilResult<()> {
270        let mut user_obj = serde_json::Map::new();
271        let mut user_field_obj = serde_json::Map::new();
272
273        if let Some(email) = email {
274            user_obj.insert("email".to_string(), serde_json::Value::String(email));
275        }
276
277        user_field_obj.insert(
278            "first_name".to_string(),
279            serde_json::Value::String(first_name),
280        );
281        user_field_obj.insert(
282            "last_name".to_string(),
283            serde_json::Value::String(last_name),
284        );
285
286        let mut payload = serde_json::Map::new();
287
288        if !user_obj.is_empty() {
289            payload.insert("user".to_string(), serde_json::Value::Object(user_obj));
290        }
291
292        payload.insert(
293            "user_field".to_string(),
294            serde_json::Value::Object(user_field_obj),
295        );
296
297        let payload_value = serde_json::Value::Object(payload);
298
299        let url = format!("{}/{}", TMC_API_URL, user_upstream_id);
300
301        self.request_with_headers(
302            reqwest::Method::PUT,
303            &url,
304            TMCRequestAuth::UseAdminToken,
305            Some(payload_value),
306        )
307        .await
308        .map(|_| ())
309    }
310
311    pub async fn post_new_user_to_tmc(
312        &self,
313        user_info: NewUserInfo,
314        app_conf: &ApplicationConfiguration,
315    ) -> UtilResult<i32> {
316        let payload = json!({
317            "user": {
318                "email": user_info.email,
319                "first_name": user_info.first_name,
320                "last_name": user_info.last_name,
321                "password": user_info.password,
322                "password_confirmation": user_info.password_confirmation
323            },
324            "user_field": {
325                "first_name": user_info.first_name,
326                "last_name": user_info.last_name
327            },
328            "origin": app_conf.tmc_account_creation_origin,
329            "language": user_info.language
330        });
331
332        let url = format!("{}?include_id=true", TMC_API_URL);
333        let response = self
334            .request_with_headers(
335                reqwest::Method::POST,
336                &url,
337                TMCRequestAuth::NoAuth,
338                Some(payload),
339            )
340            .await?;
341
342        let body: TMCUserResponse = self
343            .deserialize_response_with_tmc_error_check(response, "TMC user response")
344            .await?;
345        Ok(body.id)
346    }
347
348    pub async fn set_user_password_managed_by_courses_mooc_fi(
349        &self,
350        user_upstream_id: String,
351        user_id: Uuid,
352    ) -> UtilResult<()> {
353        let url = format!(
354            "{}/{}/set_password_managed_by_courses_mooc_fi",
355            TMC_API_URL, user_upstream_id
356        );
357
358        let payload = serde_json::json!({
359            "courses_mooc_fi_user_id": user_id.to_string(),
360        });
361
362        self.request_with_headers(
363            reqwest::Method::POST,
364            &url,
365            TMCRequestAuth::UseAdminToken,
366            Some(payload),
367        )
368        .await
369        .map(|_| ())
370    }
371
372    pub async fn get_user_from_tmc_with_email(&self, email: String) -> UtilResult<TmcUserInfo> {
373        let mut url = Url::parse(TMC_API_URL)?;
374        url.path_segments_mut()
375            .map_err(|_| {
376                UtilError::new(
377                    UtilErrorType::UrlParse,
378                    "Failed to get path segments from URL".to_string(),
379                    None,
380                )
381            })?
382            .push("get_user_with_email");
383        url.query_pairs_mut().append_pair("email", &email);
384
385        let res = self
386            .request_with_headers(
387                reqwest::Method::GET,
388                url.as_str(),
389                TMCRequestAuth::UseAdminToken,
390                None,
391            )
392            .await?;
393
394        let user: TmcUserInfo = self
395            .deserialize_response_with_tmc_error_check(res, "TMC user from JSON")
396            .await?;
397
398        Ok(user)
399    }
400
401    pub async fn delete_user_from_tmc(&self, user_upstream_id: String) -> UtilResult<bool> {
402        let url = format!("{}/{}", TMC_API_URL, user_upstream_id);
403
404        let res = self
405            .request_with_headers(
406                reqwest::Method::DELETE,
407                &url,
408                TMCRequestAuth::UseAdminToken,
409                None,
410            )
411            .await?;
412
413        let body: TmcDeleteAccountResponse = self
414            .deserialize_response_with_tmc_error_check(res, "delete response from TMC")
415            .await?;
416
417        Ok(body.success)
418    }
419
420    pub async fn get_user_from_tmc_mooc_fi_by_tmc_access_token(
421        &self,
422        tmc_access_token: &SecretString,
423    ) -> UtilResult<TMCUser> {
424        info!("Getting user details from tmc.mooc.fi");
425
426        let res = self
427            .request_with_headers(
428                reqwest::Method::GET,
429                &format!("{}/current?show_user_fields=1", TMC_API_URL),
430                TMCRequestAuth::UseUserToken(tmc_access_token.clone()),
431                None,
432            )
433            .await?;
434
435        debug!("Received response from TMC, parsing user data");
436        let tmc_user: TMCUser = self
437            .deserialize_response_with_tmc_error_check(res, "current user from TMC by access token")
438            .await?;
439
440        debug!(
441            "Creating or fetching user with TMC id {} and mooc.fi UUID {}",
442            tmc_user.id,
443            tmc_user
444                .courses_mooc_fi_user_id
445                .map(|uuid| uuid.to_string())
446                .unwrap_or_else(|| "None (will generate new UUID)".to_string())
447        );
448        Ok(tmc_user)
449    }
450
451    pub async fn get_user_from_tmc_mooc_fi_by_tmc_access_token_and_upstream_id(
452        &self,
453        upstream_id: &i32,
454    ) -> UtilResult<TMCUser> {
455        info!("Getting user details from tmc.mooc.fi");
456
457        let res = self
458            .request_with_headers(
459                reqwest::Method::GET,
460                &format!("{}/{}?show_user_fields=1", TMC_API_URL, upstream_id),
461                TMCRequestAuth::UseAdminToken,
462                None,
463            )
464            .await?;
465
466        debug!("Received response from TMC, parsing user data");
467        let tmc_user: TMCUser = self
468            .deserialize_response_with_tmc_error_check(res, "user from TMC by upstream ID")
469            .await?;
470
471        Ok(tmc_user)
472    }
473
474    pub fn mock_for_test() -> Self {
475        Self {
476            client: Client::default(),
477            admin_access_token: SecretString::new("mock-token".to_string().into()),
478            ratelimit_api_key: SecretString::new("mock-api-key".to_string().into()),
479        }
480    }
481
482    pub fn get_admin_access_token(&self) -> &SecretString {
483        &self.admin_access_token
484    }
485}