Skip to main content

headless_lms_utils/services/
suotar.rs

1//! Client for Suotar, the University of Helsinki study registry.
2//!
3//! Every endpoint is a batch. Per-item outcomes arrive as HTTP 200 and are read from each item's
4//! `status` and `code`; only request-level failures are 4xx/5xx and `Err`. Items are matched back
5//! by `requestItemId`, never by position.
6
7use std::collections::HashSet;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use async_trait::async_trait;
12use chrono::{DateTime, NaiveDate};
13#[cfg(any(test, feature = "test-support"))]
14use headless_lms_base::config::MOCK_SUOTAR_TOKEN;
15use headless_lms_base::config::{
16    SUOTAR_AUTH_SCHEME, SuotarConfiguration, bool_env_false_by_default,
17};
18use once_cell::sync::Lazy;
19use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
20use secrecy::{ExposeSecret, SecretString};
21use serde::de::DeserializeOwned;
22use serde::{Deserialize, Deserializer};
23use utoipa::ToSchema;
24
25use crate::{prelude::*, secret_string::serialize_exposed};
26
27/// Under the ingress's 60 s, so an admin waiting on a call gets our answer rather than a 504.
28pub const INTERACTIVE_REQUEST_TIMEOUT: Duration = Duration::from_secs(50);
29
30/// Separate from `REQWEST_CLIENT` for the keepalive: an import can sit silent on its socket for up
31/// to an hour, which NAT and proxies otherwise drop without telling either end.
32static SUOTAR_HTTP_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
33    suotar_client_builder()
34        .build()
35        .expect("Failed to build the Suotar client: safe to crash, it is built at startup")
36});
37
38/// [`SUOTAR_HTTP_CLIENT`] without `https_only`, for a [`SuotarClient`] that allows plain http. The
39/// shared one stays `https_only` outside test mode, which also refuses a redirect to http.
40#[cfg(any(test, feature = "test-support"))]
41static SUOTAR_PLAIN_HTTP_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
42    suotar_client_builder()
43        .https_only(false)
44        .build()
45        .expect("Failed to build the plain-http Suotar client")
46});
47
48fn suotar_client_builder() -> reqwest::ClientBuilder {
49    crate::http::base_client_builder().tcp_keepalive(Duration::from_secs(30))
50}
51
52/// Carries `suotar_api_calls.id` so Suotar's log and ours join on one value.
53pub const CORRELATION_ID_HEADER: &str = "X-Correlation-Id";
54
55/// Suotar's own body limit (Express `5mb`), so an oversized batch is refused here rather than 413'd
56/// at the far end.
57pub const MAX_REQUEST_BODY_BYTES: usize = 5 * 1024 * 1024;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type, ToSchema)]
60#[sqlx(type_name = "suotar_endpoint", rename_all = "snake_case")]
61#[serde(rename_all = "snake_case")]
62pub enum SuotarEndpoint {
63    ResolvePersons,
64    ResolveEnrolments,
65    ImportAttainments,
66    VerifyAttainments,
67    ListByCourse,
68    ValidateCourseCodes,
69}
70
71impl SuotarEndpoint {
72    /// Relative to the configured base url, which ends in `/`.
73    pub fn path(self) -> &'static str {
74        match self {
75            Self::ResolvePersons => "persons/resolve-by-student-numbers",
76            Self::ResolveEnrolments => "enrolments/resolve",
77            Self::ImportAttainments => "attainments/import",
78            Self::VerifyAttainments => "attainments/verify",
79            Self::ListByCourse => "enrolments/list-by-course",
80            Self::ValidateCourseCodes => "course-codes/validate",
81        }
82    }
83
84    /// Suotar's own per-endpoint limits; a larger batch is refused whole.
85    pub fn max_batch_size(self) -> usize {
86        match self {
87            Self::ResolvePersons
88            | Self::ResolveEnrolments
89            | Self::VerifyAttainments
90            | Self::ValidateCourseCodes => 1000,
91            Self::ImportAttainments => 100,
92            Self::ListByCourse => 50,
93        }
94    }
95
96    /// How long one worker call may take before it is abandoned. On `import` an abandoned call
97    /// leaves the whole batch uncertain, so it is sized above Suotar's worst case for a full batch;
98    /// the read-only endpoints sit below theirs, as a timeout there costs only a retry.
99    pub const fn request_timeout(self) -> Duration {
100        let minutes = match self {
101            Self::ImportAttainments => 60,
102            Self::VerifyAttainments => 25,
103            Self::ResolveEnrolments | Self::ListByCourse => 20,
104            Self::ResolvePersons | Self::ValidateCourseCodes => 10,
105        };
106        Duration::from_secs(minutes * 60)
107    }
108
109    /// An item this endpoint never answered is uncertain, not retryable: re-sending it can put a
110    /// second attainment on a real transcript.
111    pub fn creates_attainments(self) -> bool {
112        matches!(self, Self::ImportAttainments)
113    }
114}
115
116/// The item and result types of one endpoint, tied together so a caller cannot pair an import item
117/// with a verify result.
118pub trait BatchEndpoint {
119    type Item: SuotarRequestItem;
120    type Result: DeserializeOwned;
121    const ENDPOINT: SuotarEndpoint;
122}
123
124/// One marker type per [`SuotarEndpoint`], for [`SuotarClient::post`].
125pub mod endpoints {
126    use super::*;
127
128    macro_rules! batch_endpoint {
129        ($name:ident, $item:ty, $result:ty) => {
130            pub struct $name;
131
132            impl BatchEndpoint for $name {
133                type Item = $item;
134                type Result = $result;
135                const ENDPOINT: SuotarEndpoint = SuotarEndpoint::$name;
136            }
137        };
138    }
139
140    batch_endpoint!(ResolvePersons, ResolvePersonRequestItem, PersonResult);
141    batch_endpoint!(
142        ResolveEnrolments,
143        ResolveEnrolmentRequestItem,
144        EnrolmentResolutionResult
145    );
146    batch_endpoint!(
147        ImportAttainments,
148        ImportAttainmentRequestItem,
149        ImportAttainmentResult
150    );
151    batch_endpoint!(
152        VerifyAttainments,
153        VerifyAttainmentRequestItem,
154        VerifyAttainmentResult
155    );
156    batch_endpoint!(
157        ListByCourse,
158        ListByCourseRequestItem,
159        EnrolmentsListedResult
160    );
161    batch_endpoint!(
162        ValidateCourseCodes,
163        ValidateCourseCodeRequestItem,
164        ValidateCourseCodeResult
165    );
166}
167
168/// A fresh requestItemId for one item of one call.
169pub fn new_request_item_id() -> String {
170    Uuid::new_v4().to_string()
171}
172
173/// Suotar echoes the requestItemId back, which is what makes a reordered or partial response safe to
174/// read.
175pub trait SuotarRequestItem: Serialize {
176    fn request_item_id(&self) -> &str;
177    /// The student number the item asks about, for the items that carry one.
178    fn student_number(&self) -> Option<&SecretString> {
179        None
180    }
181}
182
183macro_rules! request_item {
184    ($name:ident $(, $student_number:ident)?) => {
185        impl SuotarRequestItem for $name {
186            fn request_item_id(&self) -> &str {
187                &self.request_item_id
188            }
189
190            $(
191                fn student_number(&self) -> Option<&SecretString> {
192                    Some(&self.$student_number)
193                }
194            )?
195        }
196    };
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct ResolvePersonRequestItem {
202    pub request_item_id: String,
203    #[serde(serialize_with = "serialize_exposed")]
204    pub student_number: SecretString,
205}
206request_item!(ResolvePersonRequestItem, student_number);
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct ResolveEnrolmentRequestItem {
211    pub request_item_id: String,
212    #[serde(serialize_with = "serialize_exposed")]
213    pub student_number: SecretString,
214    pub course_code: String,
215}
216request_item!(ResolveEnrolmentRequestItem, student_number);
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub struct ImportAttainmentRequestItem {
221    pub request_item_id: String,
222    #[serde(serialize_with = "serialize_exposed")]
223    pub student_number: SecretString,
224    pub course_code: String,
225    pub enrolment_id: String,
226    /// An instant rather than a date, so the registry derives the date in the zone it validates in.
227    pub attainment_date: DateTime<Utc>,
228    pub attainment_language: String,
229    pub grade_scale_id: String,
230    pub grade_id: String,
231    pub credits: f64,
232}
233request_item!(ImportAttainmentRequestItem, student_number);
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "camelCase")]
237pub struct VerifyAttainmentRequestItem {
238    pub request_item_id: String,
239    pub submitted_attainment_id: String,
240}
241request_item!(VerifyAttainmentRequestItem);
242
243/// Lists every realisation of the code; Suotar refuses the whole request if an item names one.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "camelCase")]
246pub struct ListByCourseRequestItem {
247    pub request_item_id: String,
248    pub course_code: String,
249}
250request_item!(ListByCourseRequestItem);
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct ValidateCourseCodeRequestItem {
255    pub request_item_id: String,
256    pub course_code: String,
257}
258request_item!(ValidateCourseCodeRequestItem);
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub struct LocalizedName {
263    pub fi: Option<String>,
264    pub sv: Option<String>,
265    pub en: Option<String>,
266}
267
268/// Sisu's `LocalDateRange`: start inclusive, end exclusive, either end possibly open.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(rename_all = "camelCase")]
271pub struct DatePeriod {
272    #[serde(default, deserialize_with = "lenient_date")]
273    pub start_date: Option<NaiveDate>,
274    #[serde(default, deserialize_with = "lenient_date")]
275    pub end_date: Option<NaiveDate>,
276}
277
278/// Sisu's credit range. Suotar refuses an import against one missing either bound.
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase")]
281pub struct CreditRange {
282    #[serde(default, deserialize_with = "lenient")]
283    pub min: Option<f64>,
284    #[serde(default, deserialize_with = "lenient")]
285    pub max: Option<f64>,
286}
287
288#[derive(Debug, Clone, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct PersonResult {
291    pub student_number: SecretString,
292    pub person_id: SecretString,
293    /// `None` when Sisu holds no name.
294    pub first_names: Option<SecretString>,
295    pub last_name: Option<SecretString>,
296}
297
298/// An enrolment as Suotar passes it through from its importer. Only the id is required: a field
299/// that is missing or unreadable reads as absent rather than dropping the enrolment.
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301#[serde(rename_all = "camelCase")]
302pub struct SuotarEnrolment {
303    pub id: String,
304    #[serde(default, deserialize_with = "lenient")]
305    pub state: Option<String>,
306    #[serde(default, deserialize_with = "lenient")]
307    pub kind: Option<String>,
308    #[serde(default, deserialize_with = "lenient")]
309    pub course_unit_realisation_id: Option<String>,
310    #[serde(default, deserialize_with = "lenient")]
311    pub course_unit_realisation_name: Option<LocalizedName>,
312    #[serde(default, deserialize_with = "lenient")]
313    pub activity_period: Option<DatePeriod>,
314    /// The assessment item's scale, else the course unit's.
315    #[serde(default, deserialize_with = "lenient_id")]
316    pub grade_scale_id: Option<String>,
317    /// The course unit's range; `None` when Sisu gives none, which Suotar refuses to import against.
318    #[serde(default, deserialize_with = "lenient")]
319    pub credits: Option<CreditRange>,
320    /// `None` when Suotar could not resolve the study right, which is no proof it is invalid.
321    #[serde(default, deserialize_with = "lenient")]
322    pub study_right_validity_period: Option<DatePeriod>,
323    #[serde(default, deserialize_with = "lenient_instant")]
324    pub enrolment_date_time: Option<DateTime<Utc>>,
325}
326
327/// An enrolment or attainment that cannot be read drops out alone rather than taking the item with it.
328///
329/// `enrolmentNotFound` and `enrolmentNotAccepted` carry this too, with `existing_attainments` only.
330#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub struct EnrolmentResolutionResult {
333    #[serde(default, deserialize_with = "readable_elements")]
334    pub enrolments: Vec<SuotarEnrolment>,
335    #[serde(default, deserialize_with = "readable_elements")]
336    pub existing_attainments: Vec<SuotarAttainment>,
337}
338
339/// Covers the contract bodies: the bare `{id, type}` of verify's `registered`, the fuller one behind
340/// import's `duplicateAttainment` and `notImprovedAttainment`, and the ones an enrolment answer lists
341/// as already held, where every field but the id and type may be missing. A `duplicateAttainment` Suotar
342/// answers from its own recent sends names the `AssessmentItemAttainment` it submitted, and has no
343/// `state` or `registrationDate`.
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345#[serde(rename_all = "camelCase")]
346pub struct SuotarAttainment {
347    pub id: String,
348    #[serde(rename = "type")]
349    pub attainment_type: String,
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub state: Option<String>,
352    #[serde(
353        default,
354        deserialize_with = "lenient_date",
355        skip_serializing_if = "Option::is_none"
356    )]
357    pub attainment_date: Option<NaiveDate>,
358    #[serde(
359        default,
360        deserialize_with = "lenient_date",
361        skip_serializing_if = "Option::is_none"
362    )]
363    pub registration_date: Option<NaiveDate>,
364    #[serde(
365        default,
366        deserialize_with = "lenient_id",
367        skip_serializing_if = "Option::is_none"
368    )]
369    pub grade_scale_id: Option<String>,
370    #[serde(
371        default,
372        deserialize_with = "lenient_id",
373        skip_serializing_if = "Option::is_none"
374    )]
375    pub grade_id: Option<String>,
376}
377
378/// One shape for every import result: `sent`, `sisuTimeout` and `duplicateRequestItem` fill the
379/// submitted pair, `duplicateAttainment` fills `attainment`, `notImprovedAttainment` fills
380/// `previous_attainment`.
381#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "camelCase")]
383pub struct ImportAttainmentResult {
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub submitted_attainment_id: Option<String>,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub submitted_attainment_type: Option<String>,
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub attainment: Option<SuotarAttainment>,
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub previous_attainment: Option<SuotarAttainment>,
392}
393
394/// `registered` fills `attainment`; `submissionPending` fills the submitted pair and `retry_after`.
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396#[serde(rename_all = "camelCase")]
397pub struct VerifyAttainmentResult {
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub attainment: Option<SuotarAttainment>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub submitted_attainment_id: Option<String>,
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub submitted_attainment_type: Option<String>,
404    /// Before this, a resubmission may still duplicate the pending attainment.
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub retry_after: Option<DateTime<Utc>>,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "camelCase")]
411pub struct ValidateCourseCodeResult {
412    pub course_code: String,
413    /// Suotar's own name for the course.
414    pub name: Option<String>,
415}
416
417/// Passed through from Suotar's importer like [`SuotarEnrolment`], so every field may be absent.
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(rename_all = "camelCase")]
420pub struct ListedEnrolment {
421    #[serde(default, deserialize_with = "lenient")]
422    pub id: Option<String>,
423    #[serde(default, deserialize_with = "lenient")]
424    pub course_unit_realisation_id: Option<String>,
425    #[serde(default, deserialize_with = "lenient")]
426    pub state: Option<String>,
427    #[serde(default, deserialize_with = "lenient_instant")]
428    pub enrolment_date_time: Option<DateTime<Utc>>,
429}
430
431#[derive(Debug, Clone, Deserialize)]
432#[serde(rename_all = "camelCase")]
433pub struct ListedPerson {
434    pub student_number: SecretString,
435    pub person_id: SecretString,
436    pub first_names: Option<SecretString>,
437    pub last_name: Option<SecretString>,
438    pub primary_email: Option<SecretString>,
439    pub secondary_email: Option<SecretString>,
440    #[serde(default, deserialize_with = "lenient")]
441    pub enrolment: Option<ListedEnrolment>,
442}
443
444#[derive(Debug, Clone, Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct EnrolmentsListedResult {
447    /// A person the importer handed over without a student number or person id drops out alone.
448    #[serde(deserialize_with = "readable_elements")]
449    pub people: Vec<ListedPerson>,
450}
451
452/// Importer dates arrive as `YYYY-MM-DD` or as an instant (Sisu's UTC midnight), which means its
453/// UTC date. Anything else reads as absent.
454fn lenient_date<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<NaiveDate>, D::Error> {
455    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
456    Ok(value
457        .as_ref()
458        .and_then(serde_json::Value::as_str)
459        .and_then(|text| {
460            NaiveDate::parse_from_str(text, "%Y-%m-%d")
461                .ok()
462                .or_else(|| {
463                    DateTime::parse_from_rfc3339(text)
464                        .ok()
465                        .map(|instant| instant.with_timezone(&Utc).date_naive())
466                })
467        }))
468}
469
470/// A value that does not read as `T` reads as absent.
471fn lenient<'de, D: Deserializer<'de>, T: DeserializeOwned>(
472    deserializer: D,
473) -> Result<Option<T>, D::Error> {
474    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
475    Ok(value.and_then(|value| serde_json::from_value(value).ok()))
476}
477
478/// A Sisu id that should be a string but, for at least `gradeId`, has arrived as a bare JSON number.
479/// A strict `String` field would fail to parse and drop the whole record in [`readable_elements`],
480/// silencing whatever check depends on it. Coerces either shape into a `String`; anything else reads
481/// as absent.
482fn lenient_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
483    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
484    Ok(value.and_then(|value| match value {
485        serde_json::Value::String(text) => Some(text),
486        serde_json::Value::Number(number) => Some(number.to_string()),
487        _ => None,
488    }))
489}
490
491/// An RFC 3339 instant, or Sisu's zoneless local date-time read as UTC, which is close enough to
492/// order enrolments by. Anything else reads as absent.
493fn lenient_instant<'de, D: Deserializer<'de>>(
494    deserializer: D,
495) -> Result<Option<DateTime<Utc>>, D::Error> {
496    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
497    Ok(value
498        .as_ref()
499        .and_then(serde_json::Value::as_str)
500        .and_then(|text| {
501            DateTime::parse_from_rfc3339(text)
502                .map(|instant| instant.with_timezone(&Utc))
503                .ok()
504                .or_else(|| {
505                    chrono::NaiveDateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S%.f")
506                        .ok()
507                        .map(|local| local.and_utc())
508                })
509        }))
510}
511
512/// Keeps the elements that parse and logs how many did not.
513fn readable_elements<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
514where
515    D: Deserializer<'de>,
516    T: DeserializeOwned,
517{
518    let values = Vec::<serde_json::Value>::deserialize(deserializer)?;
519    let total = values.len();
520    let readable: Vec<T> = values
521        .into_iter()
522        .filter_map(|value| serde_json::from_value(value).ok())
523        .collect();
524    if readable.len() < total {
525        warn!(
526            unreadable = total - readable.len(),
527            total, "Suotar answered with list elements that could not be read; skipping them"
528        );
529    }
530    Ok(readable)
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
534#[serde(rename_all = "camelCase")]
535pub enum SuotarItemStatus {
536    Ok,
537    Error,
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "camelCase")]
542pub struct SuotarItemError {
543    pub message: String,
544}
545
546#[derive(Debug, Clone, PartialEq, Serialize)]
547#[serde(rename_all = "camelCase")]
548pub struct SuotarResponseItem<R> {
549    pub request_item_id: String,
550    pub status: SuotarItemStatus,
551    /// A string, not an enum: Suotar may add codes, and a strict enum would take the pipeline down
552    /// the day it does.
553    pub code: String,
554    /// Also present on the error items that carry one: `sisuTimeout`, `duplicateRequestItem`,
555    /// `submissionPending`, `enrolmentNotFound` and `enrolmentNotAccepted`. An error item's result
556    /// that cannot be read reads as `None`; an ok item's makes the whole item unreadable.
557    #[serde(skip_serializing_if = "Option::is_none")]
558    pub result: Option<R>,
559    #[serde(skip_serializing_if = "Option::is_none")]
560    pub error: Option<SuotarItemError>,
561}
562
563impl<'de, R: DeserializeOwned> Deserialize<'de> for SuotarResponseItem<R> {
564    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
565        #[derive(Deserialize)]
566        #[serde(rename_all = "camelCase")]
567        struct Wire {
568            request_item_id: String,
569            status: SuotarItemStatus,
570            code: String,
571            #[serde(default)]
572            result: Option<serde_json::Value>,
573            #[serde(default)]
574            error: Option<SuotarItemError>,
575        }
576
577        let wire = Wire::deserialize(deserializer)?;
578        let result = match wire.result.map(serde_json::from_value::<R>) {
579            None => None,
580            Some(Ok(result)) => Some(result),
581            // The code is the answer on an error item, and the result only adds to it.
582            Some(Err(_)) if wire.status == SuotarItemStatus::Error => {
583                warn!(
584                    code = %wire.code,
585                    "Suotar answered with a result that could not be read; ignoring the result"
586                );
587                None
588            }
589            // Not the serde message: it quotes the offending value, which may be personal data.
590            Some(Err(_)) => {
591                return Err(serde::de::Error::custom(format!(
592                    "the result of a `{}` item could not be read",
593                    wire.code
594                )));
595            }
596        };
597        Ok(Self {
598            request_item_id: wire.request_item_id,
599            status: wire.status,
600            code: wire.code,
601            result,
602            error: wire.error,
603        })
604    }
605}
606
607#[derive(Debug)]
608pub struct SuotarBatchResponse<R> {
609    pub endpoint: SuotarEndpoint,
610    pub items: Vec<SuotarResponseItem<R>>,
611    pub duration: Duration,
612    /// `suotar_api_calls.id`, absent only when the audit write itself failed.
613    pub call_id: Option<Uuid>,
614    /// Unscrubbed; scrub before persisting any part of it. Shared with the audit record rather than
615    /// copied, since `list-by-course` bodies are the largest the pipeline handles.
616    pub raw_response: Arc<serde_json::Value>,
617}
618
619impl<R> SuotarBatchResponse<R> {
620    pub fn item(&self, request_item_id: &str) -> Option<&SuotarResponseItem<R>> {
621        self.items
622            .iter()
623            .find(|item| item.request_item_id == request_item_id)
624    }
625}
626
627/// How a call to Suotar failed at the request level. Per-item failures are not errors: they come
628/// back inside a successful batch response.
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub enum SuotarErrorVariant {
631    /// Our credentials. Loud, and never attributed to the rows in the batch.
632    Unauthorized,
633    /// Our request. Loud, and never attributed to the rows in the batch.
634    MalformedRequest,
635    /// Another 4xx carrying the documented `{ error: { code, message } }` body.
636    RequestLevelError,
637    /// Suotar's 503 `serviceTemporarilyUnavailable`: an importer lookup failed before anything was
638    /// written or sent.
639    ServiceTemporarilyUnavailable,
640    ServerError,
641    /// The connection itself failed, so the request provably never arrived.
642    TransportNotDelivered,
643    /// The request left and the answer did not arrive. A timeout is this, not the above.
644    TransportUnknown,
645    /// Suotar answered, and the answer was not a batch response.
646    Deserialization,
647}
648
649/// A Suotar call that got no batch response. [`SuotarError::variant`] and [`SuotarError::was_sent`]
650/// are what a caller decides by; the message and the source are for the logs and the audit trail.
651#[derive(Debug)]
652pub struct SuotarError {
653    pub variant: SuotarErrorVariant,
654    /// `false` for a request refused before it left, which says nothing about Suotar.
655    pub was_sent: bool,
656    error: UtilError,
657}
658
659impl SuotarError {
660    #[track_caller]
661    fn new(variant: SuotarErrorVariant, message: impl Into<String>) -> Self {
662        Self {
663            variant,
664            was_sent: true,
665            error: util_err!(SuotarClientError, message.into()),
666        }
667    }
668
669    #[track_caller]
670    fn caused_by(
671        variant: SuotarErrorVariant,
672        message: impl Into<String>,
673        source: impl Into<anyhow::Error>,
674    ) -> Self {
675        Self {
676            variant,
677            was_sent: true,
678            error: util_err!(SuotarClientError, message.into(), source.into()),
679        }
680    }
681
682    fn unsent(self) -> Self {
683        Self {
684            was_sent: false,
685            ..self
686        }
687    }
688
689    /// The error's text without the variant prefix `Display` adds.
690    pub fn message(&self) -> &str {
691        self.error.message()
692    }
693}
694
695impl std::fmt::Display for SuotarError {
696    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697        write!(f, "{:?}: {}", self.variant, self.message())
698    }
699}
700
701impl std::error::Error for SuotarError {
702    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
703        Some(&self.error)
704    }
705}
706
707/// Both fields are audit-row columns: `worker_name` separates the submitter from the verify poller
708/// from a manual retry, and the ids replace the identifiers scrubbing removes from stored bodies.
709#[derive(Debug, Clone, Default)]
710pub struct SuotarCallContext {
711    pub worker_name: String,
712    pub credit_registration_ids: Vec<Uuid>,
713    /// Replaces [`SuotarEndpoint::request_timeout`] when set, e.g. with
714    /// [`INTERACTIVE_REQUEST_TIMEOUT`] for a call someone is waiting on in the browser.
715    pub request_timeout: Option<Duration>,
716}
717
718#[derive(Debug, Clone)]
719pub struct SuotarCallStarted {
720    pub endpoint: SuotarEndpoint,
721    pub request_item_count: usize,
722    pub worker_name: String,
723    pub credit_registration_ids: Vec<Uuid>,
724    pub request_item_ids: Vec<String>,
725    pub started_at: DateTime<Utc>,
726    /// Unscrubbed; the implementation scrubs before it persists anything.
727    pub request_body: serde_json::Value,
728}
729
730#[derive(Debug, Clone, Default)]
731pub struct SuotarCallFinished {
732    pub http_status: Option<u16>,
733    pub duration: Duration,
734    pub succeeded: bool,
735    pub ok_item_count: usize,
736    pub error_item_count: usize,
737    /// The `code` of each error item, for the audit to split off the ones that only mean "not yet".
738    pub error_item_codes: Vec<String>,
739    /// `None` for a call that never left.
740    pub endpoint: Option<SuotarEndpoint>,
741    pub request_level_error_code: Option<String>,
742    pub error_message: Option<String>,
743    /// Unscrubbed; the implementation scrubs before it persists anything.
744    pub response_body: Option<Arc<serde_json::Value>>,
745}
746
747/// Persists one `suotar_api_calls` row per call. A trait because the table is in the models crate,
748/// which depends on this one. Implementations must scrub the bodies.
749#[async_trait]
750pub trait SuotarCallAudit: Send + Sync {
751    /// Returns the row id, which travels out as [`CORRELATION_ID_HEADER`]. `None` means the row
752    /// could not be written; the call goes out anyway.
753    async fn started(&self, started: SuotarCallStarted) -> Option<Uuid>;
754
755    async fn finished(&self, call_id: Uuid, finished: SuotarCallFinished);
756}
757
758pub struct NoSuotarCallAudit;
759
760#[async_trait]
761impl SuotarCallAudit for NoSuotarCallAudit {
762    async fn started(&self, _started: SuotarCallStarted) -> Option<Uuid> {
763        None
764    }
765
766    async fn finished(&self, _call_id: Uuid, _finished: SuotarCallFinished) {}
767}
768
769/// The `request_level_error_code` of a call refused before it was sent. Suotar never sends this
770/// code; `count_unreachable_run_since` in models matches it by this literal.
771pub const REFUSED_BEFORE_SENDING_CODE: &str = "refusedBeforeSending";
772
773/// Suotar matches the `Bearer ` prefix exactly: case-sensitive, one space.
774fn authorization_header_value(token: &str) -> String {
775    format!("{SUOTAR_AUTH_SCHEME} {token}")
776}
777
778#[derive(Clone)]
779pub struct SuotarClient {
780    api_base_url: Url,
781    authorization: SecretString,
782    audit: Arc<dyn SuotarCallAudit>,
783    /// Whether a plain-http `api_base_url` is sent to rather than refused. Only `TEST_MODE` or the
784    /// test-only `new_allowing_http` sets it: production must never reach Suotar over http.
785    allow_http: bool,
786}
787
788impl SuotarClient {
789    /// The client for the configured Suotar. Build it at startup: it builds the shared HTTP client,
790    /// which panics if it cannot.
791    pub fn new(config: &SuotarConfiguration, audit: Arc<dyn SuotarCallAudit>) -> Self {
792        Lazy::force(&SUOTAR_HTTP_CLIENT);
793        Self {
794            api_base_url: config.api_base_url.clone(),
795            authorization: SecretString::new(
796                authorization_header_value(config.api_token.expose_secret()).into(),
797            ),
798            audit,
799            allow_http: bool_env_false_by_default("TEST_MODE"),
800        }
801    }
802
803    /// [`Self::new`], but allowing http even without `TEST_MODE`: for tests against a plain-http
804    /// mock server, which a multi-threaded test binary cannot safely set an env var to arrange.
805    #[cfg(any(test, feature = "test-support"))]
806    pub fn new_allowing_http(
807        config: &SuotarConfiguration,
808        audit: Arc<dyn SuotarCallAudit>,
809    ) -> Self {
810        Self {
811            allow_http: true,
812            ..Self::new(config, audit)
813        }
814    }
815
816    /// A client for the mock Suotar this server serves, with no call audit.
817    #[cfg(any(test, feature = "test-support"))]
818    pub fn mock_for_test() -> Self {
819        Self {
820            api_base_url: Url::parse("http://project-331.local/api/v0/mock-suotar/")
821                .expect("hardcoded url"),
822            authorization: SecretString::new(authorization_header_value(MOCK_SUOTAR_TOKEN).into()),
823            audit: Arc::new(NoSuotarCallAudit),
824            allow_http: bool_env_false_by_default("TEST_MODE"),
825        }
826    }
827
828    /// Sends one batch to `E`'s endpoint. An empty batch is not sent.
829    pub async fn post<E: BatchEndpoint>(
830        &self,
831        context: SuotarCallContext,
832        items: Vec<E::Item>,
833    ) -> Result<SuotarBatchResponse<E::Result>, SuotarError> {
834        let endpoint = E::ENDPOINT;
835        if items.is_empty() {
836            return Ok(empty_batch_response(endpoint));
837        }
838        // Serialized once: the audited body and the wire body must be byte-for-byte the same.
839        let not_encoded = |error| {
840            SuotarError::caused_by(
841                SuotarErrorVariant::TransportNotDelivered,
842                format!("Could not encode a {} request", endpoint.path()),
843                error,
844            )
845            .unsent()
846        };
847        let request_body = serde_json::to_value(&items).map_err(not_encoded)?;
848        let encoded = serde_json::to_vec(&request_body).map_err(not_encoded)?;
849        // Before the pre-flight checks, so a refused batch still leaves an audit row to diagnose.
850        let call_id = self
851            .audit
852            .started(SuotarCallStarted {
853                endpoint,
854                request_item_count: items.len(),
855                worker_name: context.worker_name,
856                credit_registration_ids: context.credit_registration_ids,
857                request_item_ids: items
858                    .iter()
859                    .map(|item| item.request_item_id().to_string())
860                    .collect(),
861                started_at: Utc::now(),
862                request_body,
863            })
864            .await;
865        if call_id.is_none() {
866            error!(
867                endpoint = endpoint.path(),
868                "Could not write a suotar_api_calls row; sending the call unaudited"
869            );
870        }
871
872        let sent_ids = match check_batch(endpoint, &items) {
873            Ok(sent_ids) => sent_ids,
874            Err(error) => return self.refused(call_id, error).await,
875        };
876        if encoded.len() > MAX_REQUEST_BODY_BYTES {
877            return self
878                .refused(
879                    call_id,
880                    SuotarError::new(SuotarErrorVariant::MalformedRequest, format!(
881                            "A {} request of {} items encodes to {} bytes, over the {MAX_REQUEST_BODY_BYTES} byte limit.",
882                            endpoint.path(),
883                            sent_ids.len(),
884                            encoded.len()
885                        )),
886                )
887                .await;
888        }
889
890        let url = match self.api_base_url.join(endpoint.path()) {
891            Ok(url) => url,
892            Err(error) => {
893                return self
894                    .refused(
895                        call_id,
896                        SuotarError::caused_by(
897                            SuotarErrorVariant::TransportNotDelivered,
898                            format!("Could not build the Suotar {} url", endpoint.path()),
899                            error,
900                        ),
901                    )
902                    .await;
903            }
904        };
905        if !self.allow_http && url.scheme() != "https" {
906            return self
907                .refused(
908                    call_id,
909                    SuotarError::new(
910                        SuotarErrorVariant::TransportNotDelivered,
911                        format!(
912                            "Refusing to send a {} request over plain http",
913                            endpoint.path()
914                        ),
915                    ),
916                )
917                .await;
918        }
919        let clock = Instant::now();
920        #[cfg(any(test, feature = "test-support"))]
921        let http_client = if self.allow_http {
922            &*SUOTAR_PLAIN_HTTP_CLIENT
923        } else {
924            &*SUOTAR_HTTP_CLIENT
925        };
926        // `allow_http` comes only from `TEST_MODE` here, where the shared client allows http too.
927        #[cfg(not(any(test, feature = "test-support")))]
928        let http_client = &*SUOTAR_HTTP_CLIENT;
929        let mut request = http_client
930            .post(url)
931            .timeout(
932                context
933                    .request_timeout
934                    .unwrap_or_else(|| endpoint.request_timeout()),
935            )
936            .header(AUTHORIZATION, self.authorization.expose_secret())
937            .header(CONTENT_TYPE, "application/json");
938        if let Some(call_id) = call_id {
939            request = request.header(CORRELATION_ID_HEADER, call_id.to_string());
940        }
941
942        let (mut outcome, finished) = self
943            .exchange(endpoint, request, encoded, sent_ids, clock)
944            .await;
945        if let Ok(response) = &mut outcome {
946            response.call_id = call_id;
947        }
948        if let Some(call_id) = call_id {
949            self.audit.finished(call_id, finished).await;
950        }
951        outcome
952    }
953
954    /// Finishes the `suotar_api_calls` row of a call that never left, tagged so the health rules can
955    /// tell our own refusal from Suotar being unreachable.
956    async fn refused<R>(
957        &self,
958        call_id: Option<Uuid>,
959        error: SuotarError,
960    ) -> Result<SuotarBatchResponse<R>, SuotarError> {
961        if let Some(call_id) = call_id {
962            self.audit
963                .finished(
964                    call_id,
965                    SuotarCallFinished {
966                        request_level_error_code: Some(REFUSED_BEFORE_SENDING_CODE.to_string()),
967                        error_message: Some(error.message().to_string()),
968                        ..SuotarCallFinished::default()
969                    },
970                )
971                .await;
972        }
973        Err(error.unsent())
974    }
975
976    /// Returns the audit record alongside the result: only this function knows the status, the
977    /// duration and the request-level code, and the row needs all three.
978    async fn exchange<R: DeserializeOwned>(
979        &self,
980        endpoint: SuotarEndpoint,
981        request: reqwest::RequestBuilder,
982        body: Vec<u8>,
983        sent_ids: Vec<String>,
984        clock: Instant,
985    ) -> Exchanged<R> {
986        let response = match request.body(body).send().await {
987            Ok(response) => response,
988            Err(error) => {
989                return failed(
990                    SuotarError::caused_by(
991                        transport_variant(&error),
992                        format!("Request to Suotar {} failed", endpoint.path()),
993                        error,
994                    ),
995                    None,
996                    clock.elapsed(),
997                    None,
998                    None,
999                );
1000            }
1001        };
1002
1003        let http_status = response.status().as_u16();
1004        let text = match response.text().await {
1005            Ok(text) => text,
1006            Err(error) => {
1007                return failed(
1008                    SuotarError::caused_by(
1009                        transport_variant(&error),
1010                        format!(
1011                            "Reading the Suotar {} response body failed",
1012                            endpoint.path()
1013                        ),
1014                        error,
1015                    ),
1016                    Some(http_status),
1017                    clock.elapsed(),
1018                    None,
1019                    None,
1020                );
1021            }
1022        };
1023        let duration = clock.elapsed();
1024
1025        if !(200..300).contains(&http_status) {
1026            let detail = serde_json::from_str::<RequestLevelErrorBody>(&text)
1027                .ok()
1028                .map(|parsed| parsed.error);
1029            let code = detail
1030                .as_ref()
1031                .and_then(RequestLevelErrorDetail::code)
1032                .map(str::to_string);
1033            let error = request_level_error(endpoint, http_status, detail.as_ref());
1034            return failed(
1035                error,
1036                Some(http_status),
1037                duration,
1038                code,
1039                Some(Arc::new(body_for_audit(&text))),
1040            );
1041        }
1042
1043        let raw_response: Arc<serde_json::Value> = match serde_json::from_str(&text) {
1044            Ok(value) => Arc::new(value),
1045            Err(error) => {
1046                return failed(
1047                    SuotarError::caused_by(
1048                        SuotarErrorVariant::Deserialization,
1049                        format!(
1050                            "Suotar {} answered {http_status} with a body that is not JSON",
1051                            endpoint.path()
1052                        ),
1053                        error,
1054                    ),
1055                    Some(http_status),
1056                    duration,
1057                    None,
1058                    Some(Arc::new(body_for_audit(&text))),
1059                );
1060            }
1061        };
1062        let Some(array) = raw_response.as_array() else {
1063            return failed(
1064                SuotarError::new(
1065                    SuotarErrorVariant::Deserialization,
1066                    format!(
1067                        "Suotar {} answered {http_status} with a body that is not a batch response",
1068                        endpoint.path()
1069                    ),
1070                ),
1071                Some(http_status),
1072                duration,
1073                None,
1074                Some(raw_response),
1075            );
1076        };
1077        // Item by item, so one malformed entry costs only its own row: parsing the array as a whole
1078        // would park every other row of the batch as unanswered too. `reconcile` then reports the
1079        // dropped ids as missing, which is what an item we cannot read amounts to.
1080        let items: Vec<SuotarResponseItem<R>> = array
1081            .iter()
1082            .filter_map(|item| match SuotarResponseItem::<R>::deserialize(item) {
1083                Ok(parsed) => Some(parsed),
1084                // Not the serde message: it quotes the offending value, which may be personal data.
1085                Err(_) => {
1086                    error!(
1087                        endpoint = endpoint.path(),
1088                        "Suotar answered with an item that could not be read; treating it as unanswered"
1089                    );
1090                    None
1091                }
1092            })
1093            .collect();
1094
1095        let response = reconcile(endpoint, sent_ids, items, duration, raw_response);
1096        let finished = SuotarCallFinished {
1097            http_status: Some(http_status),
1098            duration,
1099            succeeded: true,
1100            ok_item_count: response
1101                .items
1102                .iter()
1103                .filter(|item| item.status == SuotarItemStatus::Ok)
1104                .count(),
1105            error_item_count: response
1106                .items
1107                .iter()
1108                .filter(|item| item.status == SuotarItemStatus::Error)
1109                .count(),
1110            error_item_codes: response
1111                .items
1112                .iter()
1113                .filter(|item| item.status == SuotarItemStatus::Error)
1114                .map(|item| item.code.clone())
1115                .collect(),
1116            endpoint: Some(endpoint),
1117            request_level_error_code: None,
1118            error_message: None,
1119            response_body: Some(Arc::clone(&response.raw_response)),
1120        };
1121        (Ok(response), finished)
1122    }
1123}
1124
1125type Exchanged<R> = (
1126    Result<SuotarBatchResponse<R>, SuotarError>,
1127    SuotarCallFinished,
1128);
1129
1130fn failed<R>(
1131    error: SuotarError,
1132    http_status: Option<u16>,
1133    duration: Duration,
1134    request_level_error_code: Option<String>,
1135    response_body: Option<Arc<serde_json::Value>>,
1136) -> Exchanged<R> {
1137    let finished = SuotarCallFinished {
1138        http_status,
1139        duration,
1140        succeeded: false,
1141        ok_item_count: 0,
1142        error_item_count: 0,
1143        error_item_codes: Vec::new(),
1144        endpoint: None,
1145        request_level_error_code,
1146        error_message: Some(error.message().to_string()),
1147        response_body,
1148    };
1149    (Err(error), finished)
1150}
1151
1152/// A body that is not JSON is still worth keeping; the scrubber takes a bare string too.
1153fn body_for_audit(text: &str) -> serde_json::Value {
1154    serde_json::from_str(text).unwrap_or_else(|_| serde_json::Value::String(text.to_string()))
1155}
1156
1157/// Refuses our own bugs before a request goes out; both would come back as a request-level error
1158/// rejecting the whole batch.
1159fn check_batch<T: SuotarRequestItem>(
1160    endpoint: SuotarEndpoint,
1161    items: &[T],
1162) -> Result<Vec<String>, SuotarError> {
1163    if items.len() > endpoint.max_batch_size() {
1164        return Err(SuotarError::new(
1165            SuotarErrorVariant::MalformedRequest,
1166            format!(
1167                "A {} request carries {} items, over the batch size of {}.",
1168                endpoint.path(),
1169                items.len(),
1170                endpoint.max_batch_size()
1171            ),
1172        ));
1173    }
1174    let mut seen = HashSet::with_capacity(items.len());
1175    for item in items {
1176        if !seen.insert(item.request_item_id()) {
1177            return Err(SuotarError::new(
1178                SuotarErrorVariant::MalformedRequest,
1179                format!(
1180                    "A {} request repeats requestItemId `{}`.",
1181                    endpoint.path(),
1182                    item.request_item_id()
1183                ),
1184            ));
1185        }
1186    }
1187    Ok(items
1188        .iter()
1189        .map(|item| item.request_item_id().to_string())
1190        .collect())
1191}
1192
1193/// Nothing to ask, so an empty batch is never sent and leaves no audit row.
1194fn empty_batch_response<R>(endpoint: SuotarEndpoint) -> SuotarBatchResponse<R> {
1195    SuotarBatchResponse {
1196        endpoint,
1197        items: Vec::new(),
1198        duration: Duration::ZERO,
1199        call_id: None,
1200        raw_response: Arc::new(serde_json::Value::Array(Vec::new())),
1201    }
1202}
1203
1204/// The requestItemIds a response does not pair with what was sent.
1205#[derive(Debug, PartialEq)]
1206struct UnpairedItemIds {
1207    /// Sent, but answered by nothing. Unknown outcome on [`SuotarEndpoint::creates_attainments`].
1208    missing: Vec<String>,
1209    /// Answered, but never sent. Logged and otherwise ignored.
1210    unexpected: Vec<String>,
1211}
1212
1213fn unpaired_item_ids<R>(sent_ids: &[String], items: &[SuotarResponseItem<R>]) -> UnpairedItemIds {
1214    let sent: HashSet<&str> = sent_ids.iter().map(String::as_str).collect();
1215    let answered: HashSet<&str> = items
1216        .iter()
1217        .map(|item| item.request_item_id.as_str())
1218        .collect();
1219    UnpairedItemIds {
1220        missing: sent_ids
1221            .iter()
1222            .filter(|id| !answered.contains(id.as_str()))
1223            .cloned()
1224            .collect(),
1225        unexpected: items
1226            .iter()
1227            .filter(|item| !sent.contains(item.request_item_id.as_str()))
1228            .map(|item| item.request_item_id.clone())
1229            .collect(),
1230    }
1231}
1232
1233/// Pairs the response against what was sent by `requestItemId`; order is not consulted.
1234fn reconcile<R>(
1235    endpoint: SuotarEndpoint,
1236    sent_ids: Vec<String>,
1237    items: Vec<SuotarResponseItem<R>>,
1238    duration: Duration,
1239    raw_response: Arc<serde_json::Value>,
1240) -> SuotarBatchResponse<R> {
1241    let unpaired = unpaired_item_ids(&sent_ids, &items);
1242    if !unpaired.unexpected.is_empty() {
1243        warn!(
1244            endpoint = endpoint.path(),
1245            unexpected = unpaired.unexpected.len(),
1246            "Suotar answered with requestItemIds that were not sent; ignoring them"
1247        );
1248    }
1249    if !unpaired.missing.is_empty() && endpoint.creates_attainments() {
1250        error!(
1251            endpoint = endpoint.path(),
1252            missing = unpaired.missing.len(),
1253            sent = sent_ids.len(),
1254            "Suotar left items unanswered; their attainments may or may not exist and must not be re-sent"
1255        );
1256    }
1257
1258    SuotarBatchResponse {
1259        endpoint,
1260        items,
1261        duration,
1262        call_id: None,
1263        raw_response,
1264    }
1265}
1266
1267#[derive(Debug, Deserialize)]
1268struct RequestLevelErrorBody {
1269    error: RequestLevelErrorDetail,
1270}
1271
1272/// The envelope's `{code, message}`, or the bare string Suotar's fall-through route answers with.
1273#[derive(Debug, Deserialize)]
1274#[serde(untagged)]
1275enum RequestLevelErrorDetail {
1276    Coded { code: String, message: String },
1277    Bare(String),
1278}
1279
1280impl RequestLevelErrorDetail {
1281    fn code(&self) -> Option<&str> {
1282        match self {
1283            Self::Coded { code, .. } => Some(code),
1284            Self::Bare(_) => None,
1285        }
1286    }
1287}
1288
1289fn request_level_error(
1290    endpoint: SuotarEndpoint,
1291    http_status: u16,
1292    detail: Option<&RequestLevelErrorDetail>,
1293) -> SuotarError {
1294    let path = endpoint.path();
1295    // A path the moocfi router does not serve falls through to a route that refuses our key; the
1296    // key is fine and the base url is wrong.
1297    if let Some(RequestLevelErrorDetail::Bare(message)) = detail
1298        && http_status == 401
1299    {
1300        return SuotarError::new(
1301            SuotarErrorVariant::RequestLevelError,
1302            format!(
1303                "Suotar answered {path} with 401 `{message}`, which means the moocfi API does not serve that path. Check SUOTAR_API_BASE_URL."
1304            ),
1305        );
1306    }
1307    let variant = match (http_status, detail.and_then(RequestLevelErrorDetail::code)) {
1308        (401 | 403, _) | (_, Some("unauthorized")) => SuotarErrorVariant::Unauthorized,
1309        (413, _) | (_, Some("malformedRequest" | "requestTooLarge")) => {
1310            SuotarErrorVariant::MalformedRequest
1311        }
1312        (503, Some("serviceTemporarilyUnavailable")) => {
1313            SuotarErrorVariant::ServiceTemporarilyUnavailable
1314        }
1315        (500..=599, _) => SuotarErrorVariant::ServerError,
1316        _ => SuotarErrorVariant::RequestLevelError,
1317    };
1318    let detail = match detail {
1319        Some(RequestLevelErrorDetail::Coded { code, message }) => format!("`{code}`: {message}"),
1320        Some(RequestLevelErrorDetail::Bare(message)) => format!("`{message}`"),
1321        None => "no documented error body".to_string(),
1322    };
1323    SuotarError::new(
1324        variant,
1325        format!("Suotar {path} rejected the whole request with {http_status}, {detail}"),
1326    )
1327}
1328
1329/// `is_connect` is the one case where the request provably never reached Suotar; everything else, a
1330/// timeout above all, may have been processed.
1331fn transport_variant(error: &reqwest::Error) -> SuotarErrorVariant {
1332    if error.is_connect() || error.is_builder() {
1333        SuotarErrorVariant::TransportNotDelivered
1334    } else {
1335        SuotarErrorVariant::TransportUnknown
1336    }
1337}
1338
1339#[cfg(test)]
1340mod tests {
1341    use super::*;
1342    use serde_json::json;
1343
1344    fn person_items(ids: &[&str]) -> Vec<ResolvePersonRequestItem> {
1345        ids.iter()
1346            .map(|id| ResolvePersonRequestItem {
1347                request_item_id: (*id).to_string(),
1348                student_number: "012345678".into(),
1349            })
1350            .collect()
1351    }
1352
1353    fn person_response(ids: &[&str]) -> Vec<SuotarResponseItem<PersonResult>> {
1354        let items: Vec<serde_json::Value> = ids
1355            .iter()
1356            .map(|id| {
1357                json!({
1358                    "requestItemId": id,
1359                    "status": "ok",
1360                    "code": "personFound",
1361                    "result": {
1362                        "studentNumber": "012345678",
1363                        "personId": "otm-person-id",
1364                        "firstNames": "Henrik Admin",
1365                        "lastName": "Nygren",
1366                    }
1367                })
1368            })
1369            .collect();
1370        serde_json::from_value(json!(items)).expect("person response")
1371    }
1372
1373    fn classified(http_status: u16, body: &str) -> SuotarError {
1374        let detail = serde_json::from_str::<RequestLevelErrorBody>(body)
1375            .ok()
1376            .map(|parsed| parsed.error);
1377        request_level_error(
1378            SuotarEndpoint::ImportAttainments,
1379            http_status,
1380            detail.as_ref(),
1381        )
1382    }
1383
1384    fn unpaired(sent: &[&str], items: &[SuotarResponseItem<PersonResult>]) -> UnpairedItemIds {
1385        let sent: Vec<String> = sent.iter().map(|id| (*id).to_string()).collect();
1386        unpaired_item_ids(&sent, items)
1387    }
1388
1389    fn reconciled(
1390        sent: &[&str],
1391        items: Vec<SuotarResponseItem<PersonResult>>,
1392    ) -> SuotarBatchResponse<PersonResult> {
1393        reconcile(
1394            SuotarEndpoint::ResolvePersons,
1395            sent.iter().map(|id| (*id).to_string()).collect(),
1396            items,
1397            Duration::ZERO,
1398            Arc::new(json!([])),
1399        )
1400    }
1401
1402    /// A leading slash on either side would silently drop the base's route prefix and 404 every
1403    /// call.
1404    #[test]
1405    fn every_endpoint_joins_onto_the_configured_base() {
1406        let client = SuotarClient::mock_for_test();
1407        let joined: Vec<String> = [
1408            SuotarEndpoint::ResolvePersons,
1409            SuotarEndpoint::ResolveEnrolments,
1410            SuotarEndpoint::ImportAttainments,
1411            SuotarEndpoint::VerifyAttainments,
1412            SuotarEndpoint::ListByCourse,
1413            SuotarEndpoint::ValidateCourseCodes,
1414        ]
1415        .iter()
1416        .map(|endpoint| {
1417            client
1418                .api_base_url
1419                .join(endpoint.path())
1420                .expect("joins")
1421                .to_string()
1422        })
1423        .collect();
1424        assert_eq!(
1425            joined,
1426            vec![
1427                "http://project-331.local/api/v0/mock-suotar/persons/resolve-by-student-numbers",
1428                "http://project-331.local/api/v0/mock-suotar/enrolments/resolve",
1429                "http://project-331.local/api/v0/mock-suotar/attainments/import",
1430                "http://project-331.local/api/v0/mock-suotar/attainments/verify",
1431                "http://project-331.local/api/v0/mock-suotar/enrolments/list-by-course",
1432                "http://project-331.local/api/v0/mock-suotar/course-codes/validate",
1433            ]
1434        );
1435    }
1436
1437    #[test]
1438    fn a_request_batch_serializes_to_the_documented_shape() {
1439        let items = vec![ImportAttainmentRequestItem {
1440            request_item_id: "11111111-1111-1111-1111-111111111111".to_string(),
1441            student_number: "012345678".into(),
1442            course_code: "TKT10001".to_string(),
1443            enrolment_id: "selected-enrolment-id".to_string(),
1444            attainment_date: "2026-05-22T09:00:00Z".parse().expect("valid instant"),
1445            attainment_language: "fi".to_string(),
1446            grade_scale_id: "sis-hyl-hyv".to_string(),
1447            grade_id: "1".to_string(),
1448            credits: 5.0,
1449        }];
1450        assert_eq!(
1451            serde_json::to_value(&items).expect("serializes"),
1452            json!([{
1453                "requestItemId": "11111111-1111-1111-1111-111111111111",
1454                "studentNumber": "012345678",
1455                "courseCode": "TKT10001",
1456                "enrolmentId": "selected-enrolment-id",
1457                "attainmentDate": "2026-05-22T09:00:00Z",
1458                "attainmentLanguage": "fi",
1459                "gradeScaleId": "sis-hyl-hyv",
1460                "gradeId": "1",
1461                "credits": 5.0
1462            }])
1463        );
1464    }
1465
1466    #[test]
1467    fn an_error_item_deserializes_without_a_result() {
1468        let items: Vec<SuotarResponseItem<PersonResult>> = serde_json::from_value(json!([{
1469            "requestItemId": "b2",
1470            "status": "error",
1471            "code": "personNotFound",
1472            "error": { "message": "No Sisu person was found for the supplied student number." }
1473        }]))
1474        .expect("error item");
1475        assert_eq!(items[0].status, SuotarItemStatus::Error);
1476        assert!(items[0].result.is_none());
1477        assert_eq!(
1478            items[0].error.as_ref().map(|error| error.message.as_str()),
1479            Some("No Sisu person was found for the supplied student number.")
1480        );
1481    }
1482
1483    #[test]
1484    fn a_sisu_timeout_carries_the_id_the_client_may_verify() {
1485        let items: Vec<SuotarResponseItem<ImportAttainmentResult>> =
1486            serde_json::from_value(json!([{
1487                "requestItemId": "item-1",
1488                "status": "error",
1489                "code": "sisuTimeout",
1490                "error": { "message": "Sisu operation timed out; outcome is uncertain." },
1491                "result": {
1492                    "submittedAttainmentId": "hy-kur-1",
1493                    "submittedAttainmentType": "AssessmentItemAttainment"
1494                }
1495            }]))
1496            .expect("timeout with an id");
1497        assert_eq!(
1498            items[0]
1499                .result
1500                .as_ref()
1501                .and_then(|result| result.submitted_attainment_id.as_deref()),
1502            Some("hy-kur-1")
1503        );
1504    }
1505
1506    #[test]
1507    fn an_enrolment_error_carries_the_existing_attainments() {
1508        let items: Vec<SuotarResponseItem<EnrolmentResolutionResult>> =
1509            serde_json::from_value(json!([{
1510                "requestItemId": "item-1",
1511                "status": "error",
1512                "code": "enrolmentNotFound",
1513                "error": { "message": "No Sisu enrolment was found for this person and course." },
1514                "result": { "existingAttainments": [{
1515                    "id": "existing-attainment-id",
1516                    "type": "AssessmentItemAttainment",
1517                    "state": "ATTAINED",
1518                    "attainmentDate": "2026-03-01",
1519                    "registrationDate": "2026-03-05",
1520                    "gradeScaleId": "sis-0-5",
1521                    "gradeId": 3
1522                }] }
1523            }]))
1524            .expect("enrolment error with attainments");
1525        let result = items[0].result.as_ref().expect("result");
1526        assert!(result.enrolments.is_empty());
1527        assert_eq!(
1528            result.existing_attainments[0].grade_id.as_deref(),
1529            Some("3")
1530        );
1531    }
1532
1533    #[test]
1534    fn one_deserializer_covers_every_import_success_body() {
1535        let items: Vec<SuotarResponseItem<ImportAttainmentResult>> =
1536            serde_json::from_value(json!([
1537                {
1538                    "requestItemId": "item-1",
1539                    "status": "ok",
1540                    "code": "sent",
1541                    "result": {
1542                        "submittedAttainmentId": "hy-kur-1",
1543                        "submittedAttainmentType": "AssessmentItemAttainment"
1544                    }
1545                },
1546                {
1547                    "requestItemId": "item-3",
1548                    "status": "ok",
1549                    "code": "duplicateAttainment",
1550                    "result": { "attainment": {
1551                        "id": "existing-id",
1552                        "type": "CourseUnitAttainment",
1553                        "state": "ATTAINED",
1554                        "attainmentDate": "2026-05-22T00:00:00.000Z",
1555                        "registrationDate": "2026-05-22T00:00:00.000Z",
1556                        "gradeScaleId": "sis-hyl-hyv",
1557                        "gradeId": "1"
1558                    } }
1559                },
1560                {
1561                    "requestItemId": "item-5",
1562                    "status": "ok",
1563                    "code": "duplicateAttainment",
1564                    "result": { "attainment": {
1565                        "id": "hy-kur-1",
1566                        "type": "AssessmentItemAttainment",
1567                        "attainmentDate": "2026-05-22",
1568                        "gradeScaleId": "sis-hyl-hyv",
1569                        "gradeId": "1"
1570                    } }
1571                },
1572                {
1573                    "requestItemId": "item-4",
1574                    "status": "ok",
1575                    "code": "notImprovedAttainment",
1576                    "result": { "previousAttainment": {
1577                        "id": "existing-id",
1578                        "type": "CourseUnitAttainment",
1579                        "state": "ATTAINED",
1580                        "gradeScaleId": "sis-0-5",
1581                        "gradeId": "5",
1582                        "attainmentDate": "2026-03-01",
1583                        "registrationDate": "2026-03-05"
1584                    } }
1585                }
1586            ]))
1587            .expect("import successes");
1588
1589        let sent = items[0].result.as_ref().expect("sent result");
1590        assert_eq!(sent.submitted_attainment_id.as_deref(), Some("hy-kur-1"));
1591        let duplicate = items[1].result.as_ref().expect("duplicate result");
1592        assert_eq!(
1593            duplicate
1594                .attainment
1595                .as_ref()
1596                .and_then(|attainment| attainment.grade_id.as_deref()),
1597            Some("1")
1598        );
1599        assert_eq!(
1600            duplicate
1601                .attainment
1602                .as_ref()
1603                .and_then(|attainment| attainment.attainment_date),
1604            NaiveDate::from_ymd_opt(2026, 5, 22)
1605        );
1606        let recently_sent = items[2]
1607            .result
1608            .as_ref()
1609            .and_then(|result| result.attainment.as_ref())
1610            .expect("recently sent duplicate");
1611        assert_eq!(recently_sent.id, "hy-kur-1");
1612        assert_eq!(recently_sent.state, None);
1613        assert_eq!(recently_sent.registration_date, None);
1614        let not_improved = items[3].result.as_ref().expect("not improved result");
1615        assert_eq!(
1616            not_improved
1617                .previous_attainment
1618                .as_ref()
1619                .map(|attainment| attainment.id.as_str()),
1620            Some("existing-id")
1621        );
1622    }
1623
1624    #[test]
1625    fn an_unknown_code_does_not_fail_deserialization() {
1626        let items: Vec<SuotarResponseItem<PersonResult>> = serde_json::from_value(json!([{
1627            "requestItemId": "a1",
1628            "status": "error",
1629            "code": "somethingSuotarAddedLater",
1630            "error": { "message": "..." }
1631        }]))
1632        .expect("unknown code");
1633        assert_eq!(items[0].code, "somethingSuotarAddedLater");
1634    }
1635
1636    #[test]
1637    fn items_are_matched_by_request_item_id_not_position() {
1638        let items = person_response(&["c3", "a1", "b2"]);
1639        assert_eq!(
1640            unpaired(&["a1", "b2", "c3"], &items),
1641            UnpairedItemIds {
1642                missing: Vec::new(),
1643                unexpected: Vec::new(),
1644            }
1645        );
1646        let response = reconciled(&["a1", "b2", "c3"], items);
1647        assert_eq!(
1648            response.item("b2").map(|item| item.code.as_str()),
1649            Some("personFound")
1650        );
1651    }
1652
1653    #[test]
1654    fn an_unanswered_item_is_reported_rather_than_paired_with_a_neighbour() {
1655        let items = person_response(&["c3", "a1"]);
1656        assert_eq!(
1657            unpaired(&["a1", "b2", "c3"], &items).missing,
1658            vec!["b2".to_string()]
1659        );
1660        let response = reconciled(&["a1", "b2", "c3"], items);
1661        assert!(response.item("b2").is_none());
1662        assert!(response.item("c3").is_some());
1663    }
1664
1665    #[test]
1666    fn an_item_id_that_was_never_sent_is_reported_and_kept_out_of_the_way() {
1667        assert_eq!(
1668            unpaired(&["a1"], &person_response(&["a1", "z9"])),
1669            UnpairedItemIds {
1670                missing: Vec::new(),
1671                unexpected: vec!["z9".to_string()],
1672            }
1673        );
1674    }
1675
1676    #[test]
1677    fn a_repeated_request_item_id_is_refused_before_the_request_is_built() {
1678        let error = check_batch(SuotarEndpoint::ResolvePersons, &person_items(&["a1", "a1"]))
1679            .expect_err("duplicate ids");
1680        assert!(error.message().contains("repeats requestItemId `a1`"));
1681    }
1682
1683    #[test]
1684    fn a_batch_over_the_endpoints_size_is_refused_before_the_request_is_built() {
1685        let items: Vec<ResolvePersonRequestItem> = (0..1001)
1686            .map(|index| ResolvePersonRequestItem {
1687                request_item_id: format!("item-{index}"),
1688                student_number: "012345678".into(),
1689            })
1690            .collect();
1691        for (endpoint, size) in [
1692            (SuotarEndpoint::ListByCourse, 50),
1693            (SuotarEndpoint::ImportAttainments, 100),
1694            (SuotarEndpoint::ResolvePersons, 1000),
1695            (SuotarEndpoint::VerifyAttainments, 1000),
1696        ] {
1697            assert_eq!(endpoint.max_batch_size(), size, "{endpoint:?}");
1698            assert!(
1699                check_batch(endpoint, &items[..size]).is_ok(),
1700                "{endpoint:?}"
1701            );
1702            assert!(
1703                check_batch(endpoint, &items[..size + 1]).is_err(),
1704                "{endpoint:?}"
1705            );
1706        }
1707    }
1708
1709    #[test]
1710    fn the_documented_request_level_bodies_classify() {
1711        let unauthorized = classified(
1712            401,
1713            r#"{"error":{"code":"unauthorized","message":"Missing or invalid credentials."}}"#,
1714        );
1715        assert_eq!(unauthorized.variant, SuotarErrorVariant::Unauthorized);
1716
1717        let malformed = classified(
1718            400,
1719            r#"{"error":{"code":"malformedRequest","message":"Request body is not valid JSON or has the wrong top-level shape."}}"#,
1720        );
1721        assert_eq!(malformed.variant, SuotarErrorVariant::MalformedRequest);
1722
1723        let too_large = classified(
1724            413,
1725            r#"{"error":{"code":"requestTooLarge","message":"Request body is too large."}}"#,
1726        );
1727        assert_eq!(too_large.variant, SuotarErrorVariant::MalformedRequest);
1728
1729        let unavailable = classified(
1730            503,
1731            r#"{"error":{"code":"serviceTemporarilyUnavailable","message":"Failed to fetch Sisu data."}}"#,
1732        );
1733        assert_eq!(
1734            unavailable.variant,
1735            SuotarErrorVariant::ServiceTemporarilyUnavailable
1736        );
1737
1738        let internal = classified(
1739            500,
1740            r#"{"error":{"code":"internalError","message":"Suotar failed to process the request."}}"#,
1741        );
1742        assert_eq!(internal.variant, SuotarErrorVariant::ServerError);
1743
1744        let unserved_path = classified(401, r#"{"error":"Unauthorized access"}"#);
1745        assert_eq!(unserved_path.variant, SuotarErrorVariant::RequestLevelError);
1746        assert!(unserved_path.message().contains("SUOTAR_API_BASE_URL"));
1747
1748        let bodyless = classified(502, "<html>");
1749        assert_eq!(bodyless.variant, SuotarErrorVariant::ServerError);
1750    }
1751}