headless_lms_utils/services/
sisu.rs1use crate::{error::util_error::SisuErrorVariant, prelude::*};
2
3#[derive(Debug, Clone)]
4pub struct SisuClient {
5 base_url: String,
6}
7
8use headless_lms_base::config::bool_env_false_by_default;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use std::sync::LazyLock;
12use std::time::Duration;
13use std::{cmp::Ordering, collections::HashMap};
14use utoipa::ToSchema;
15pub type SisuCourseInfo = Vec<SisuCourseInfoElement>;
16use url::{ParseError, Url};
17
18#[derive(Serialize, Deserialize, ToSchema, Debug)]
19#[serde(rename_all = "camelCase")]
20pub struct SisuCourseInfoElement {
21 pub id: String,
22 pub university_org_ids: Vec<String>,
23 pub group_id: String,
24 pub credits: Credits,
25 pub completion_methods: Vec<Option<serde_json::Value>>,
26 pub name: Name,
27 pub code: String,
28 pub abbreviation: Option<String>,
29 pub validity_period: SisuCourseInfoValidityPeriod,
30 pub grade_scale_id: String,
31 pub tweet_text: Option<serde_json::Value>,
32 pub outcomes: Option<Additional>,
33 pub prerequisites: Option<Additional>,
34 pub content: Option<Additional>,
35 pub additional: Option<Additional>,
36 pub learning_material: Option<Additional>,
37 pub literature: Vec<Option<serde_json::Value>>,
38 pub study_level: String,
39 pub course_unit_type: String,
40 pub subject: Option<serde_json::Value>,
41 pub cefr_level: Option<serde_json::Value>,
42 pub organisations: Vec<Organisation>,
43 pub possible_attainment_languages: Vec<String>,
44 pub part_of_degree: Option<serde_json::Value>,
45}
46
47#[derive(Serialize, Deserialize, ToSchema, Debug)]
48pub struct Additional {
49 #[serde(skip_serializing_if = "Option::is_none", default)]
50 pub fi: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none", default)]
52 pub en: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none", default)]
54 pub sv: Option<String>,
55}
56
57static STRIP_HTML_REGEX: LazyLock<Regex> =
58 LazyLock::new(|| Regex::new(r"<[^>]*>").expect("invalid regex"));
59
60impl Additional {
61 pub fn choose_language(&self, language_code: &String) -> Option<String> {
62 let mut vec = self.as_vec();
63 vec.sort_by(|o1, o2| {
64 if &o1.0 == language_code {
65 return Ordering::Less;
66 }
67 if &o2.0 == language_code {
68 return Ordering::Greater;
69 }
70 if o1.0 == "en" {
71 return Ordering::Less;
72 }
73 if o2.0 == "en" {
74 return Ordering::Greater;
75 }
76 Ordering::Equal
77 });
78
79 let max_length = vec
80 .iter()
81 .map(|n| {
82 let value = &n.1;
83 if let Some(value) = value {
84 let cleaned = STRIP_HTML_REGEX.replace_all(value, "");
85 cleaned.len()
86 } else {
87 0
88 }
89 })
90 .max()
91 .unwrap_or(0);
92
93 let best = vec.iter().find(|o| {
94 let text = &o.1;
95 if let Some(text) = text {
96 let cleaned = STRIP_HTML_REGEX.replace_all(text, "");
97 let len = cleaned.len();
98 if len < max_length / 2 {
99 return false;
100 }
101 true
102 } else {
103 false
104 }
105 });
106 best.and_then(|o| o.1.clone())
107 }
108 fn as_vec(&self) -> Vec<(String, Option<String>)> {
109 vec![
110 ("en".to_string(), self.en.clone()),
111 ("fi".to_string(), self.fi.clone()),
112 ("sv".to_string(), self.sv.clone()),
113 ]
114 }
115}
116
117#[derive(Serialize, Deserialize, ToSchema, Debug)]
118pub struct Credits {
119 pub min: Option<i64>,
120 pub max: Option<i64>,
121}
122
123#[derive(Serialize, Deserialize, ToSchema, Debug)]
124pub struct Name {
125 pub en: Option<String>,
126 pub fi: Option<String>,
127 pub sv: Option<String>,
128}
129
130#[derive(Serialize, Deserialize, ToSchema, Debug)]
131#[serde(rename_all = "camelCase")]
132pub struct Organisation {
133 pub organisation_id: Option<String>,
134 pub educational_institution_urn: Option<serde_json::Value>,
135 pub role_urn: String,
136 pub share: i64,
137 pub validity_period: Option<OrganisationValidityPeriod>,
138}
139
140#[derive(Serialize, Deserialize, ToSchema, Debug)]
141pub struct OrganisationValidityPeriod {}
142
143#[derive(Serialize, Deserialize, ToSchema, Debug)]
144#[serde(rename_all = "camelCase")]
145pub struct SisuCourseInfoValidityPeriod {
146 pub start_date: Option<String>,
147 pub end_date: Option<String>,
148}
149
150#[derive(Serialize, Deserialize, ToSchema, Debug)]
151#[serde(rename_all = "camelCase")]
152pub struct SearchResult {
153 pub id: String,
154}
155
156#[derive(Serialize, Deserialize, ToSchema, Debug)]
157#[serde(rename_all = "camelCase")]
158pub struct CourseUnitSearchResults {
159 pub search_results: Vec<SearchResult>,
160}
161#[derive(Debug, ToSchema, Serialize, Deserialize, Clone)]
162pub struct SisuDescriptions {
163 outcomes: Option<String>,
164 content: Option<String>,
165 prerequisites: Option<String>,
166 additional: Option<String>,
167 learning_material: Option<String>,
168}
169
170const TIMEOUT_DURATION: Duration = Duration::from_secs(60);
171
172impl SisuClient {
173 fn get_url(&self) -> Result<Url, ParseError> {
174 let base_url = &self.base_url;
175 let is_mock_sisu = bool_env_false_by_default("USE_MOCK_SISU_ENDPOINT");
176 if is_mock_sisu {
177 let mock_path = Url::parse(base_url.as_str())?;
178 mock_path.join("/api/v0/mock-sisu/")
179 } else {
180 Url::parse("https://sisu.helsinki.fi/kori/api/")
181 }
182 }
183
184 pub fn new(base_url: String) -> UtilResult<Self> {
185 if base_url.trim().is_empty() {
186 return Err(UtilError::new(
187 UtilErrorType::Other,
188 "BASE_URL cannot be empty".to_string(),
189 None,
190 ));
191 }
192 Ok(Self { base_url })
193 }
194
195 async fn get_request_sisu(&self, path: String) -> Result<reqwest::Response, UtilError> {
196 let base_url = Self::get_url(self)?;
197 let url = base_url.join(path.as_str())?;
198 let builder = REQWEST_CLIENT.get(url).timeout(TIMEOUT_DURATION);
199
200 builder.send().await.map_err(|e| {
201 util_err!(
202 SisuClientError(SisuErrorVariant::GenericSisuError),
203 "Request to Sisu failed",
204 e
205 )
206 })
207 }
208
209 pub async fn get_course_ids(
210 &self,
211 course_modules: Vec<String>,
212 ) -> UtilResult<Vec<Vec<String>>> {
213 let course_codes = course_modules;
214 let mut course_ids: Vec<Vec<String>> = vec![];
215 let mut invalid_codes: Vec<String> = vec![];
216 for code in course_codes {
217 let path = format!(
218 "course-unit-search?codeQuery={code}&validity=ALL&returnAllGroupVersions=true"
219 );
220 let response = self.get_request_sisu(path).await?;
221
222 if response.status().is_success() {
223 let json: CourseUnitSearchResults =
224 serde_json::from_str(&response.text().await.unwrap_or("{}".to_string()))?;
225 let ids: Vec<String> = json.search_results.into_iter().map(|x| x.id).collect();
226
227 if ids.is_empty() {
228 invalid_codes.push(code);
229 } else {
230 course_ids.push(ids);
231 }
232 } else if response.status() == 404 {
233 return Err(util_err!(
234 SisuClientError(SisuErrorVariant::SisuResourceNotFound),
235 "Course ids not found".to_string()
236 ));
237 } else {
238 return Err(util_err!(
239 SisuClientError(SisuErrorVariant::GenericSisuError),
240 "Something went wrong when fetching course ids".to_string()
241 ));
242 }
243 }
244
245 if !invalid_codes.is_empty() {
246 return Err(util_err!(
247 SisuClientError(SisuErrorVariant::InvalidCourseCode),
248 format!("No data found with codes: {invalid_codes:?}")
249 ));
250 }
251 Ok(course_ids)
252 }
253
254 pub async fn get_course_info(
255 &self,
256 course_ids: Vec<Vec<String>>,
257 ) -> UtilResult<Vec<SisuCourseInfoElement>> {
258 let mut data_vec: Vec<SisuCourseInfoElement> = vec![];
259 for id in course_ids {
260 if let Some(first) = id.first() {
261 let path = format!("course-units/v1/{first}");
262 let response = self.get_request_sisu(path).await?;
263
264 if response.status().is_success() {
265 let json: SisuCourseInfoElement =
266 serde_json::from_str(&response.text().await.unwrap_or("{}".to_string()))?;
267 data_vec.push(json);
268 } else if response.status() == 404 {
269 return Err(util_err!(
270 SisuClientError(SisuErrorVariant::SisuResourceNotFound),
271 "Course info not found".to_string()
272 ));
273 } else {
274 return Err(util_err!(
275 SisuClientError(SisuErrorVariant::GenericSisuError),
276 "Something went wrong when fetching course info".to_string()
277 ));
278 }
279 } else {
280 return Err(util_err!(
281 SisuClientError(SisuErrorVariant::SisuResourceNotFound),
282 "No courses found with course id".to_string()
283 ));
284 }
285 }
286 Ok(data_vec)
287 }
288 pub fn parse_course_info(
289 course_info: Vec<SisuCourseInfoElement>,
290 course_language: String,
291 ) -> HashMap<String, SisuDescriptions> {
292 let mut course_desc: HashMap<String, SisuDescriptions> = HashMap::new();
293
294 for module in course_info {
295 let outcome = module
296 .outcomes
297 .and_then(|x| x.choose_language(&course_language).to_owned());
298 let content = module
299 .content
300 .and_then(|x| x.choose_language(&course_language).to_owned());
301 let preq = module
302 .prerequisites
303 .and_then(|x| x.choose_language(&course_language).to_owned());
304 let material = module
305 .learning_material
306 .and_then(|x| x.choose_language(&course_language).to_owned());
307
308 let add = module
309 .additional
310 .and_then(|x| x.choose_language(&course_language).to_owned());
311
312 let descriptions = SisuDescriptions {
313 outcomes: outcome,
314 content,
315 prerequisites: preq,
316 learning_material: material,
317 additional: add,
318 };
319 course_desc.insert(module.code, descriptions);
320 }
321 course_desc
322 }
323
324 pub fn mock_for_test() -> Self {
325 Self {
326 base_url: String::from("mock-base-url"),
327 }
328 }
329}