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, 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}