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