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