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