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