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