headless_lms_utils/tmc/
mod.rs

1use anyhow::{Context, Result};
2use reqwest::Client;
3use serde_json::json;
4
5use crate::ApplicationConfiguration;
6
7#[derive(Debug, Clone)]
8pub struct TmcClient {
9    client: Client,
10    access_token: String,
11    ratelimit_api_key: String,
12}
13
14pub struct NewUserInfo {
15    pub first_name: String,
16    pub last_name: String,
17    pub email: String,
18    pub password: String,
19    pub password_confirmation: String,
20    pub language: String,
21}
22
23const TMC_API_URL: &str = "https://tmc.mooc.fi/api/v8/users";
24
25impl TmcClient {
26    pub fn new_from_env() -> Result<Self> {
27        let is_dev =
28            cfg!(debug_assertions) || std::env::var("APP_ENV").map_or(true, |v| v == "development");
29
30        let access_token = std::env::var("TMC_ACCESS_TOKEN").unwrap_or_else(|_| {
31            if is_dev {
32                "mock-access-token".to_string()
33            } else {
34                panic!("TMC_ACCESS_TOKEN must be defined in production")
35            }
36        });
37
38        let ratelimit_api_key =
39            std::env::var("RATELIMIT_PROTECTION_SAFE_API_KEY").unwrap_or_else(|_| {
40                if is_dev {
41                    "mock-api-key".to_string()
42                } else {
43                    panic!("RATELIMIT_PROTECTION_SAFE_API_KEY must be defined in production")
44                }
45            });
46
47        if !is_dev {
48            if access_token.trim().is_empty() {
49                anyhow::bail!("TMC_ACCESS_TOKEN cannot be empty");
50            }
51            if ratelimit_api_key.trim().is_empty() {
52                anyhow::bail!("RATELIMIT_PROTECTION_SAFE_API_KEY cannot be empty");
53            }
54        }
55
56        Ok(Self {
57            client: Client::default(),
58            access_token,
59            ratelimit_api_key,
60        })
61    }
62
63    async fn request_with_headers(
64        &self,
65        method: reqwest::Method,
66        url: &str,
67        use_auth: bool,
68        body: Option<serde_json::Value>,
69    ) -> Result<reqwest::Response> {
70        let mut builder = self
71            .client
72            .request(method, url)
73            .header("RATELIMIT-PROTECTION-SAFE-API-KEY", &self.ratelimit_api_key)
74            .header(reqwest::header::CONTENT_TYPE, "application/json")
75            .header(reqwest::header::ACCEPT, "application/json");
76
77        if use_auth {
78            builder = builder.bearer_auth(&self.access_token);
79        }
80
81        if let Some(json_body) = body {
82            builder = builder.json(&json_body);
83        }
84
85        let res = builder
86            .send()
87            .await
88            .context("Failed to send HTTP request")?;
89
90        if res.status().is_success() {
91            Ok(res)
92        } else {
93            let status = res.status();
94            let error_text = res
95                .text()
96                .await
97                .unwrap_or_else(|e| format!("(Failed to read error body: {e})"));
98
99            warn!(
100                "Request to {} failed with status {}: {}",
101                url, status, error_text
102            );
103
104            Err(anyhow::anyhow!(
105                "Request failed with status {}: {}",
106                status,
107                error_text
108            ))
109        }
110    }
111
112    pub async fn update_user_information(
113        &self,
114        first_name: String,
115        last_name: String,
116        email: Option<String>,
117        user_upstream_id: String,
118    ) -> Result<()> {
119        let mut user_obj = serde_json::Map::new();
120        let mut user_field_obj = serde_json::Map::new();
121
122        if let Some(email) = email {
123            user_obj.insert("email".to_string(), serde_json::Value::String(email));
124        }
125
126        user_field_obj.insert(
127            "first_name".to_string(),
128            serde_json::Value::String(first_name),
129        );
130        user_field_obj.insert(
131            "last_name".to_string(),
132            serde_json::Value::String(last_name),
133        );
134
135        let mut payload = serde_json::Map::new();
136
137        if !user_obj.is_empty() {
138            payload.insert("user".to_string(), serde_json::Value::Object(user_obj));
139        }
140
141        payload.insert(
142            "user_field".to_string(),
143            serde_json::Value::Object(user_field_obj),
144        );
145
146        let payload_value = serde_json::Value::Object(payload);
147
148        let url = format!("{}/{}", TMC_API_URL, user_upstream_id);
149
150        self.request_with_headers(reqwest::Method::PUT, &url, true, Some(payload_value))
151            .await
152            .map(|_| ())
153    }
154
155    pub async fn post_new_user_to_moocfi(
156        &self,
157        user_info: NewUserInfo,
158        app_conf: &ApplicationConfiguration,
159    ) -> Result<()> {
160        let payload = json!({
161            "user": {
162                "email": user_info.email,
163                "first_name": user_info.first_name,
164                "last_name": user_info.last_name,
165                "password": user_info.password,
166                "password_confirmation": user_info.password_confirmation
167            },
168            "user_field": {
169                "first_name": user_info.first_name,
170                "last_name": user_info.last_name
171            },
172            "origin": app_conf.tmc_account_creation_origin,
173            "language": user_info.language
174        });
175
176        self.request_with_headers(reqwest::Method::POST, TMC_API_URL, false, Some(payload))
177            .await
178            .map(|_| ())
179    }
180
181    pub fn mock_for_test() -> Self {
182        Self {
183            client: Client::default(),
184            access_token: "mock-token".to_string(),
185            ratelimit_api_key: "mock-api-key".to_string(),
186        }
187    }
188}