Skip to main content

headless_lms_utils/services/
sisu.rs

1use crate::{
2    cache::Cache, error::util_error::SisuErrorVariant, prelude::*,
3    url_encoding::percent_encode_component,
4};
5
6#[derive(Debug, Clone)]
7pub struct SisuClient {
8    base_url: String,
9}
10
11use chrono::NaiveDate;
12use headless_lms_base::config::bool_env_false_by_default;
13use regex::Regex;
14use serde::{Deserialize, Serialize};
15use std::sync::LazyLock;
16use std::time::Duration;
17use std::{cmp::Ordering, collections::HashMap, collections::HashSet};
18use utoipa::ToSchema;
19pub type SisuCourseInfo = Vec<SisuCourseInfoElement>;
20use url::{ParseError, Url};
21
22#[derive(Serialize, Deserialize, ToSchema, Debug)]
23#[serde(rename_all = "camelCase")]
24pub struct SisuCourseInfoElement {
25    pub id: String,
26    pub university_org_ids: Vec<String>,
27    pub group_id: String,
28    pub credits: Credits,
29    pub completion_methods: Vec<Option<serde_json::Value>>,
30    pub name: Name,
31    pub code: String,
32    pub abbreviation: Option<String>,
33    pub validity_period: SisuCourseInfoValidityPeriod,
34    pub grade_scale_id: String,
35    pub tweet_text: Option<serde_json::Value>,
36    pub outcomes: Option<Additional>,
37    pub prerequisites: Option<Additional>,
38    pub content: Option<Additional>,
39    pub additional: Option<Additional>,
40    pub learning_material: Option<Additional>,
41    pub literature: Vec<Option<serde_json::Value>>,
42    pub study_level: String,
43    pub course_unit_type: String,
44    pub subject: Option<serde_json::Value>,
45    pub cefr_level: Option<serde_json::Value>,
46    pub organisations: Vec<Organisation>,
47    pub possible_attainment_languages: Vec<String>,
48    pub part_of_degree: Option<serde_json::Value>,
49    #[serde(default)]
50    pub responsibility_infos: Option<Vec<ResponsibilityInfo>>,
51}
52
53/// One responsible-person entry on a Sisu course unit or course unit realisation. Sisu only
54/// links the person by id; [SisuClient::get_course_contacts] resolves it.
55#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
56#[serde(rename_all = "camelCase")]
57pub struct ResponsibilityInfo {
58    pub role_urn: String,
59    pub person_id: Option<String>,
60    pub text: Option<Additional>,
61    #[serde(default)]
62    pub validity_period: Option<SisuCourseInfoValidityPeriod>,
63}
64
65/// A staff member from Sisu's public person API. Holds work contact details only.
66#[derive(Serialize, Deserialize, ToSchema, Debug)]
67#[serde(rename_all = "camelCase")]
68pub struct SisuPerson {
69    pub id: String,
70    pub first_name: Option<String>,
71    pub last_name: Option<String>,
72    pub email_address: Option<String>,
73    #[serde(default)]
74    pub titles: Vec<Additional>,
75}
76
77/// Who to contact about a course, as Sisu lists them today.
78#[derive(Serialize, Deserialize, Debug, Clone)]
79pub struct SisuCourseContacts {
80    pub course_code: String,
81    pub contacts: Vec<SisuCourseContact>,
82    /// The responsible organisations of the course unit, e.g. a degree programme: the next
83    /// place to ask when no person is listed.
84    pub responsible_organisations: Vec<String>,
85}
86
87/// A person, or a free-text contact note, responsible for a course in Sisu.
88#[derive(Serialize, Deserialize, Debug, Clone)]
89pub struct SisuCourseContact {
90    pub name: Option<String>,
91    /// Last segment of the Sisu role urn, e.g. `responsible-teacher`, `teacher`,
92    /// `administrative-person` or `contact-info`.
93    pub role: String,
94    pub titles: Vec<String>,
95    pub email: Option<String>,
96    pub note: Option<String>,
97    pub source: SisuContactSource,
98}
99
100/// Where in Sisu a contact is listed: on the course unit itself, or on one of its current
101/// course unit realisations (an implementation of the course with its own teachers).
102#[derive(Serialize, Deserialize, Debug, Clone)]
103#[serde(tag = "kind", rename_all = "snake_case")]
104pub enum SisuContactSource {
105    CourseUnit,
106    Realisation {
107        realisation_name: Option<String>,
108        start_date: Option<String>,
109        end_date: Option<String>,
110    },
111}
112
113#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
114pub struct Additional {
115    #[serde(skip_serializing_if = "Option::is_none", default)]
116    pub fi: Option<String>,
117    #[serde(skip_serializing_if = "Option::is_none", default)]
118    pub en: Option<String>,
119    #[serde(skip_serializing_if = "Option::is_none", default)]
120    pub sv: Option<String>,
121}
122
123static STRIP_HTML_REGEX: LazyLock<Regex> =
124    LazyLock::new(|| Regex::new(r"<[^>]*>").expect("invalid regex"));
125
126impl Additional {
127    pub fn choose_language(&self, language_code: &String) -> Option<String> {
128        let mut vec = self.as_vec();
129        vec.sort_by(|o1, o2| {
130            if &o1.0 == language_code {
131                return Ordering::Less;
132            }
133            if &o2.0 == language_code {
134                return Ordering::Greater;
135            }
136            if o1.0 == "en" {
137                return Ordering::Less;
138            }
139            if o2.0 == "en" {
140                return Ordering::Greater;
141            }
142            Ordering::Equal
143        });
144
145        let max_length = vec
146            .iter()
147            .map(|n| {
148                let value = &n.1;
149                if let Some(value) = value {
150                    let cleaned = STRIP_HTML_REGEX.replace_all(value, "");
151                    cleaned.len()
152                } else {
153                    0
154                }
155            })
156            .max()
157            .unwrap_or(0);
158
159        let best = vec.iter().find(|o| {
160            let text = &o.1;
161            if let Some(text) = text {
162                let cleaned = STRIP_HTML_REGEX.replace_all(text, "");
163                let len = cleaned.len();
164                if len < max_length / 2 {
165                    return false;
166                }
167                true
168            } else {
169                false
170            }
171        });
172        best.and_then(|o| o.1.clone())
173    }
174    /// English, else Finnish, else Swedish.
175    pub fn preferring_english(&self) -> Option<String> {
176        [&self.en, &self.fi, &self.sv]
177            .into_iter()
178            .flatten()
179            .find(|text| !text.trim().is_empty())
180            .cloned()
181    }
182
183    fn as_vec(&self) -> Vec<(String, Option<String>)> {
184        vec![
185            ("en".to_string(), self.en.clone()),
186            ("fi".to_string(), self.fi.clone()),
187            ("sv".to_string(), self.sv.clone()),
188        ]
189    }
190}
191
192#[derive(Serialize, Deserialize, ToSchema, Debug)]
193pub struct Credits {
194    pub min: Option<f32>,
195    pub max: Option<f32>,
196}
197
198#[derive(Serialize, Deserialize, ToSchema, Debug)]
199pub struct Name {
200    pub en: Option<String>,
201    pub fi: Option<String>,
202    pub sv: Option<String>,
203}
204
205#[derive(Serialize, Deserialize, ToSchema, Debug)]
206#[serde(rename_all = "camelCase")]
207pub struct Organisation {
208    pub organisation_id: Option<String>,
209    pub educational_institution_urn: Option<serde_json::Value>,
210    pub role_urn: String,
211    pub share: f64,
212    pub validity_period: Option<OrganisationValidityPeriod>,
213}
214
215#[derive(Serialize, Deserialize, ToSchema, Debug)]
216pub struct OrganisationValidityPeriod {}
217
218#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
219#[serde(rename_all = "camelCase")]
220pub struct SisuCourseInfoValidityPeriod {
221    pub start_date: Option<String>,
222    pub end_date: Option<String>,
223}
224
225#[derive(Serialize, Deserialize, ToSchema, Debug)]
226#[serde(rename_all = "camelCase")]
227pub struct SearchResult {
228    pub id: String,
229    #[serde(default)]
230    pub code: Option<String>,
231}
232
233#[derive(Serialize, Deserialize, ToSchema, Debug)]
234#[serde(rename_all = "camelCase")]
235pub struct CourseUnitSearchResults {
236    pub search_results: Vec<SearchResult>,
237}
238#[derive(Debug, ToSchema, Serialize, Deserialize, Clone)]
239pub struct SisuDescriptions {
240    outcomes: Option<String>,
241    content: Option<String>,
242    prerequisites: Option<String>,
243    additional: Option<String>,
244    learning_material: Option<String>,
245}
246
247const TIMEOUT_DURATION: Duration = Duration::from_secs(60);
248
249/// Sisu staff data changes rarely; this keeps repeated support questions from re-fetching it.
250const COURSE_CONTACTS_CACHE_TTL: Duration = Duration::from_secs(3 * 60 * 60);
251
252/// The parts of a Sisu course unit version that say who is responsible for it. Kept separate
253/// from [SisuCourseInfoElement] so an odd field on an old version cannot break the lookup.
254#[derive(Deserialize, Debug)]
255#[serde(rename_all = "camelCase")]
256struct CourseUnitResponsibilities {
257    code: String,
258    validity_period: Option<SisuCourseInfoValidityPeriod>,
259    #[serde(default)]
260    responsibility_infos: Vec<ResponsibilityInfo>,
261    #[serde(default)]
262    organisations: Vec<CourseUnitOrganisation>,
263    #[serde(default)]
264    completion_methods: Vec<CompletionMethodAssessmentItems>,
265}
266
267#[derive(Deserialize, Debug)]
268#[serde(rename_all = "camelCase")]
269struct CourseUnitOrganisation {
270    organisation_id: Option<String>,
271    role_urn: String,
272}
273
274#[derive(Deserialize, Debug)]
275#[serde(rename_all = "camelCase")]
276struct CompletionMethodAssessmentItems {
277    #[serde(default)]
278    assessment_item_ids: Vec<String>,
279}
280
281#[derive(Deserialize, Debug)]
282#[serde(rename_all = "camelCase")]
283struct RealisationResponsibilities {
284    id: String,
285    name: Option<Additional>,
286    activity_period: Option<SisuCourseInfoValidityPeriod>,
287    flow_state: Option<String>,
288    #[serde(default)]
289    responsibility_infos: Vec<ResponsibilityInfo>,
290}
291
292#[derive(Deserialize, Debug)]
293struct SisuOrganisation {
294    name: Option<Additional>,
295}
296
297const RESPONSIBLE_ORGANISATION_ROLE_URN: &str =
298    "urn:code:organisation-role:responsible-organisation";
299
300/// Whether `today` falls in a Sisu period. Sisu end dates are exclusive and either bound may
301/// be missing.
302fn period_contains(period: Option<&SisuCourseInfoValidityPeriod>, today: NaiveDate) -> bool {
303    let Some(period) = period else {
304        return true;
305    };
306    let starts_by_today = parse_sisu_date(period.start_date.as_deref()).is_none_or(|d| d <= today);
307    let ends_after_today = parse_sisu_date(period.end_date.as_deref()).is_none_or(|d| today < d);
308    starts_by_today && ends_after_today
309}
310
311fn parse_sisu_date(date: Option<&str>) -> Option<NaiveDate> {
312    date.and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
313}
314
315fn start_date(period: Option<&SisuCourseInfoValidityPeriod>) -> Option<NaiveDate> {
316    parse_sisu_date(period.and_then(|p| p.start_date.as_deref()))
317}
318
319impl SisuClient {
320    fn get_url(&self) -> Result<Url, ParseError> {
321        let base_url = &self.base_url;
322        let is_mock_sisu = bool_env_false_by_default("USE_MOCK_SISU_ENDPOINT");
323        if is_mock_sisu {
324            let mock_path = Url::parse(base_url.as_str())?;
325            mock_path.join("/api/v0/mock-sisu/")
326        } else {
327            Url::parse("https://sisu.helsinki.fi/kori/api/")
328        }
329    }
330
331    pub fn new(base_url: String) -> UtilResult<Self> {
332        if base_url.trim().is_empty() {
333            return Err(UtilError::new(
334                UtilErrorType::Other,
335                "BASE_URL cannot be empty".to_string(),
336                None,
337            ));
338        }
339        Ok(Self { base_url })
340    }
341
342    async fn get_request_sisu(&self, path: String) -> Result<reqwest::Response, UtilError> {
343        let base_url = Self::get_url(self)?;
344        let url = base_url.join(path.as_str())?;
345        let builder = REQWEST_CLIENT.get(url).timeout(TIMEOUT_DURATION);
346
347        builder.send().await.map_err(|e| {
348            util_err!(
349                SisuClientError(SisuErrorVariant::GenericSisuError),
350                "Request to Sisu failed",
351                e
352            )
353        })
354    }
355
356    pub async fn get_course_ids(
357        &self,
358        course_modules: Vec<String>,
359    ) -> UtilResult<Vec<Vec<String>>> {
360        let course_codes = course_modules;
361        let mut course_ids: Vec<Vec<String>> = vec![];
362        let mut invalid_codes: Vec<String> = vec![];
363        for code in course_codes {
364            let path = format!(
365                "course-unit-search?codeQuery={code}&validity=ALL&returnAllGroupVersions=true"
366            );
367            let response = self.get_request_sisu(path).await?;
368
369            if response.status().is_success() {
370                let json: CourseUnitSearchResults =
371                    serde_json::from_str(&response.text().await.unwrap_or("{}".to_string()))?;
372                let ids: Vec<String> = json.search_results.into_iter().map(|x| x.id).collect();
373
374                if ids.is_empty() {
375                    invalid_codes.push(code);
376                } else {
377                    course_ids.push(ids);
378                }
379            } else if response.status() == 404 {
380                return Err(util_err!(
381                    SisuClientError(SisuErrorVariant::SisuResourceNotFound),
382                    "Course ids not found".to_string()
383                ));
384            } else {
385                return Err(util_err!(
386                    SisuClientError(SisuErrorVariant::GenericSisuError),
387                    "Something went wrong when fetching course ids".to_string()
388                ));
389            }
390        }
391
392        if !invalid_codes.is_empty() {
393            return Err(util_err!(
394                SisuClientError(SisuErrorVariant::InvalidCourseCode),
395                format!("No data found with codes: {invalid_codes:?}")
396            ));
397        }
398        Ok(course_ids)
399    }
400
401    pub async fn get_course_info(
402        &self,
403        course_ids: Vec<Vec<String>>,
404    ) -> UtilResult<Vec<SisuCourseInfoElement>> {
405        let mut data_vec: Vec<SisuCourseInfoElement> = vec![];
406        for id in course_ids {
407            if let Some(first) = id.first() {
408                let path = format!("course-units/v1/{first}");
409                let response = self.get_request_sisu(path).await?;
410
411                if response.status().is_success() {
412                    let json: SisuCourseInfoElement =
413                        serde_json::from_str(&response.text().await.unwrap_or("{}".to_string()))?;
414                    data_vec.push(json);
415                } else if response.status() == 404 {
416                    return Err(util_err!(
417                        SisuClientError(SisuErrorVariant::SisuResourceNotFound),
418                        "Course info not found".to_string()
419                    ));
420                } else {
421                    return Err(util_err!(
422                        SisuClientError(SisuErrorVariant::GenericSisuError),
423                        "Something went wrong when fetching course info".to_string()
424                    ));
425                }
426            } else {
427                return Err(util_err!(
428                    SisuClientError(SisuErrorVariant::SisuResourceNotFound),
429                    "No courses found with course id".to_string()
430                ));
431            }
432        }
433        Ok(data_vec)
434    }
435    pub fn parse_course_info(
436        course_info: Vec<SisuCourseInfoElement>,
437        course_language: String,
438    ) -> HashMap<String, SisuDescriptions> {
439        let mut course_desc: HashMap<String, SisuDescriptions> = HashMap::new();
440
441        for module in course_info {
442            let outcome = module
443                .outcomes
444                .and_then(|x| x.choose_language(&course_language).to_owned());
445            let content = module
446                .content
447                .and_then(|x| x.choose_language(&course_language).to_owned());
448            let preq = module
449                .prerequisites
450                .and_then(|x| x.choose_language(&course_language).to_owned());
451            let material = module
452                .learning_material
453                .and_then(|x| x.choose_language(&course_language).to_owned());
454
455            let add = module
456                .additional
457                .and_then(|x| x.choose_language(&course_language).to_owned());
458
459            let descriptions = SisuDescriptions {
460                outcomes: outcome,
461                content,
462                prerequisites: preq,
463                learning_material: material,
464                additional: add,
465            };
466            course_desc.insert(module.code, descriptions);
467        }
468        course_desc
469    }
470
471    /// Who Sisu lists as responsible for a UH course code today: the people on the course unit
472    /// version valid today, then the teachers of its ongoing realisations (or the next one when
473    /// none is ongoing), each person once, plus the responsible organisations. Errors when the
474    /// code is unknown to Sisu or Sisu fails; an empty contact list is a valid answer. Answers
475    /// are cached for a few hours.
476    pub async fn get_course_contacts(
477        &self,
478        cache: &Cache,
479        uh_course_code: &str,
480    ) -> UtilResult<SisuCourseContacts> {
481        let cache_key = format!(
482            "sisu-course-contacts:{}",
483            uh_course_code.trim().to_uppercase()
484        );
485        cache
486            .get_or_set(cache_key, COURSE_CONTACTS_CACHE_TTL, || {
487                self.fetch_course_contacts(uh_course_code)
488            })
489            .await
490    }
491
492    async fn fetch_course_contacts(&self, uh_course_code: &str) -> UtilResult<SisuCourseContacts> {
493        let today = chrono::Utc::now().date_naive();
494        let course_unit = self.get_current_course_unit(uh_course_code, today).await?;
495
496        let mut contacts = Vec::new();
497        let mut seen_person_ids = HashSet::new();
498        self.push_contacts(
499            &mut contacts,
500            &mut seen_person_ids,
501            &course_unit.responsibility_infos,
502            SisuContactSource::CourseUnit,
503            today,
504        )
505        .await?;
506
507        let mut assessment_item_ids: Vec<String> = course_unit
508            .completion_methods
509            .iter()
510            .flat_map(|method| method.assessment_item_ids.iter().cloned())
511            .collect();
512        assessment_item_ids.sort();
513        assessment_item_ids.dedup();
514        for realisation in self
515            .get_current_realisations(&assessment_item_ids, today)
516            .await?
517        {
518            let source = SisuContactSource::Realisation {
519                realisation_name: realisation
520                    .name
521                    .as_ref()
522                    .and_then(Additional::preferring_english),
523                start_date: realisation
524                    .activity_period
525                    .as_ref()
526                    .and_then(|p| p.start_date.clone()),
527                end_date: realisation
528                    .activity_period
529                    .as_ref()
530                    .and_then(|p| p.end_date.clone()),
531            };
532            self.push_contacts(
533                &mut contacts,
534                &mut seen_person_ids,
535                &realisation.responsibility_infos,
536                source,
537                today,
538            )
539            .await?;
540        }
541
542        let mut responsible_organisations = Vec::new();
543        for organisation in course_unit
544            .organisations
545            .iter()
546            .filter(|o| o.role_urn == RESPONSIBLE_ORGANISATION_ROLE_URN)
547        {
548            let Some(organisation_id) = &organisation.organisation_id else {
549                continue;
550            };
551            if let Some(name) = self.get_organisation_name(organisation_id).await? {
552                responsible_organisations.push(name);
553            }
554        }
555
556        Ok(SisuCourseContacts {
557            course_code: course_unit.code,
558            contacts,
559            responsible_organisations,
560        })
561    }
562
563    /// The latest-starting version of the course unit valid today, else the one that started last.
564    async fn get_current_course_unit(
565        &self,
566        uh_course_code: &str,
567        today: NaiveDate,
568    ) -> UtilResult<CourseUnitResponsibilities> {
569        let path = format!(
570            "course-unit-search?codeQuery={}&validity=ALL&returnAllGroupVersions=true",
571            percent_encode_component(uh_course_code)
572        );
573        let search: CourseUnitSearchResults = self.get_sisu_json(path).await?.ok_or_else(|| {
574            util_err!(
575                SisuClientError(SisuErrorVariant::InvalidCourseCode),
576                format!("No course unit found with code {uh_course_code}")
577            )
578        })?;
579        // The code query also matches longer codes that share the prefix.
580        let ids = search.search_results.into_iter().filter(|result| {
581            result
582                .code
583                .as_deref()
584                .is_none_or(|code| code.eq_ignore_ascii_case(uh_course_code))
585        });
586
587        let mut versions = Vec::new();
588        for result in ids {
589            let version: Option<CourseUnitResponsibilities> = self
590                .get_sisu_json(format!(
591                    "course-units/v1/{}",
592                    percent_encode_component(&result.id)
593                ))
594                .await?;
595            versions.extend(version.filter(|v| v.code.eq_ignore_ascii_case(uh_course_code)));
596        }
597
598        // Versions without an end date overlap, so the latest start among the valid ones wins.
599        let current = versions
600            .iter()
601            .enumerate()
602            .max_by_key(|(_, v)| {
603                (
604                    period_contains(v.validity_period.as_ref(), today),
605                    start_date(v.validity_period.as_ref()),
606                )
607            })
608            .map(|(index, _)| index);
609        match current {
610            Some(index) => Ok(versions.swap_remove(index)),
611            None => Err(util_err!(
612                SisuClientError(SisuErrorVariant::InvalidCourseCode),
613                format!("No course unit found with code {uh_course_code}")
614            )),
615        }
616    }
617
618    /// Published realisations of the assessment items that are ongoing today, or the one
619    /// starting next when none is.
620    async fn get_current_realisations(
621        &self,
622        assessment_item_ids: &[String],
623        today: NaiveDate,
624    ) -> UtilResult<Vec<RealisationResponsibilities>> {
625        let mut realisations: Vec<RealisationResponsibilities> = Vec::new();
626        for assessment_item_id in assessment_item_ids {
627            let path = format!(
628                "course-unit-realisations-by-assessment-item-id?assessmentItemId={}&activityStatus=ONGOING_AND_FUTURE",
629                percent_encode_component(assessment_item_id)
630            );
631            let found: Vec<RealisationResponsibilities> =
632                self.get_sisu_json(path).await?.unwrap_or_default();
633            for realisation in found {
634                if realisation.flow_state.as_deref() == Some("PUBLISHED")
635                    && !realisations.iter().any(|r| r.id == realisation.id)
636                {
637                    realisations.push(realisation);
638                }
639            }
640        }
641
642        if realisations
643            .iter()
644            .any(|r| period_contains(r.activity_period.as_ref(), today))
645        {
646            realisations.retain(|r| period_contains(r.activity_period.as_ref(), today));
647        } else {
648            realisations.sort_by_key(|r| start_date(r.activity_period.as_ref()));
649            realisations.truncate(1);
650        }
651        Ok(realisations)
652    }
653
654    async fn push_contacts(
655        &self,
656        contacts: &mut Vec<SisuCourseContact>,
657        seen_person_ids: &mut HashSet<String>,
658        responsibility_infos: &[ResponsibilityInfo],
659        source: SisuContactSource,
660        today: NaiveDate,
661    ) -> UtilResult<()> {
662        for info in responsibility_infos {
663            if !period_contains(info.validity_period.as_ref(), today) {
664                continue;
665            }
666            let role = info
667                .role_urn
668                .rsplit(':')
669                .next()
670                .unwrap_or(&info.role_urn)
671                .to_string();
672            let note = info
673                .text
674                .as_ref()
675                .and_then(Additional::preferring_english)
676                .map(|text| STRIP_HTML_REGEX.replace_all(&text, "").trim().to_string())
677                // Sisu requires some text on these, so "-" is a common placeholder.
678                .filter(|text| text.chars().any(char::is_alphanumeric));
679            let person = match &info.person_id {
680                Some(person_id) => {
681                    if seen_person_ids.contains(person_id) {
682                        continue;
683                    }
684                    self.get_person(person_id).await?
685                }
686                None => None,
687            };
688            if person.is_none() && note.is_none() {
689                continue;
690            }
691            if let Some(person_id) = &info.person_id {
692                seen_person_ids.insert(person_id.clone());
693            }
694            contacts.push(SisuCourseContact {
695                name: person.as_ref().and_then(|p| {
696                    let full_name = [p.first_name.as_deref(), p.last_name.as_deref()]
697                        .into_iter()
698                        .flatten()
699                        .collect::<Vec<_>>()
700                        .join(" ");
701                    (!full_name.is_empty()).then_some(full_name)
702                }),
703                role,
704                titles: person
705                    .as_ref()
706                    .map(|p| {
707                        p.titles
708                            .iter()
709                            .filter_map(Additional::preferring_english)
710                            .collect()
711                    })
712                    .unwrap_or_default(),
713                email: person.and_then(|p| p.email_address),
714                note,
715                source: source.clone(),
716            });
717        }
718        Ok(())
719    }
720
721    /// `None` when Sisu no longer has the person.
722    async fn get_person(&self, person_id: &str) -> UtilResult<Option<SisuPerson>> {
723        self.get_sisu_json(format!(
724            "persons/v1/{}",
725            percent_encode_component(person_id)
726        ))
727        .await
728    }
729
730    async fn get_organisation_name(&self, organisation_id: &str) -> UtilResult<Option<String>> {
731        let organisation: Option<SisuOrganisation> = self
732            .get_sisu_json(format!(
733                "organisations/{}",
734                percent_encode_component(organisation_id)
735            ))
736            .await?;
737        Ok(organisation
738            .and_then(|o| o.name)
739            .and_then(|name| name.preferring_english()))
740    }
741
742    /// GETs a Sisu path as JSON; `None` on 404 or 400, which Sisu answers for unknown ids.
743    async fn get_sisu_json<T: serde::de::DeserializeOwned>(
744        &self,
745        path: String,
746    ) -> UtilResult<Option<T>> {
747        let response = self.get_request_sisu(path).await?;
748        let status = response.status();
749        if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::BAD_REQUEST {
750            return Ok(None);
751        }
752        if !status.is_success() {
753            return Err(util_err!(
754                SisuClientError(SisuErrorVariant::GenericSisuError),
755                format!("Sisu answered {status}")
756            ));
757        }
758        let body = response.text().await.map_err(|e| {
759            util_err!(
760                SisuClientError(SisuErrorVariant::GenericSisuError),
761                "Reading a Sisu response failed",
762                e
763            )
764        })?;
765        Ok(Some(serde_json::from_str(&body)?))
766    }
767
768    pub fn mock_for_test() -> Self {
769        Self {
770            base_url: String::from("mock-base-url"),
771        }
772    }
773}