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