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