1use std::collections::HashSet;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::time::{Duration, Instant};
14
15use async_trait::async_trait;
16use chrono::NaiveDate;
17use headless_lms_base::config::{MOCK_SUOTAR_TOKEN, SUOTAR_AUTH_SCHEME, SuotarConfiguration};
18use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
19use secrecy::{ExposeSecret, SecretString};
20use serde::Deserialize;
21use serde::de::DeserializeOwned;
22use utoipa::ToSchema;
23
24use crate::{error::util_error::SuotarErrorVariant, prelude::*};
25
26pub const SUOTAR_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
28
29pub const CORRELATION_ID_HEADER: &str = "X-Correlation-Id";
31
32pub const MAX_REQUEST_BODY_BYTES: usize = 2 * 1024 * 1024;
35
36pub const TRANSIENT_ITEM_CODE: &str = "sisuTemporarilyUnavailable";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type, ToSchema)]
40#[sqlx(type_name = "suotar_endpoint", rename_all = "snake_case")]
41#[serde(rename_all = "snake_case")]
42pub enum SuotarEndpoint {
43 ResolvePersons,
44 ResolveEnrolments,
45 ImportAttainments,
46 VerifyAttainments,
47 ProductAccessTokens,
48 ListByCourse,
49}
50
51impl SuotarEndpoint {
52 pub fn path(self) -> &'static str {
54 match self {
55 Self::ResolvePersons => "persons/resolve-by-student-numbers",
56 Self::ResolveEnrolments => "enrolments/resolve",
57 Self::ImportAttainments => "attainments/import",
58 Self::VerifyAttainments => "attainments/verify",
59 Self::ProductAccessTokens => "open-university-product-access-tokens/resolve",
60 Self::ListByCourse => "enrolments/list-by-course",
61 }
62 }
63
64 pub fn max_batch_size(self) -> usize {
67 match self {
68 Self::ResolvePersons | Self::ResolveEnrolments | Self::ProductAccessTokens => 50,
69 Self::ImportAttainments => 25,
70 Self::VerifyAttainments => 100,
71 Self::ListByCourse => 10,
72 }
73 }
74
75 pub fn creates_attainments(self) -> bool {
78 matches!(self, Self::ImportAttainments)
79 }
80
81 pub fn carries_item_level_transient(self) -> bool {
83 matches!(
84 self,
85 Self::ResolvePersons
86 | Self::VerifyAttainments
87 | Self::ProductAccessTokens
88 | Self::ListByCourse
89 )
90 }
91}
92
93pub trait SuotarRequestItem: Serialize {
96 fn request_item_id(&self) -> &str;
97}
98
99macro_rules! request_item {
100 ($name:ident) => {
101 impl SuotarRequestItem for $name {
102 fn request_item_id(&self) -> &str {
103 &self.request_item_id
104 }
105 }
106 };
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct ResolvePersonRequestItem {
112 pub request_item_id: String,
113 pub student_number: String,
114}
115request_item!(ResolvePersonRequestItem);
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct ResolveEnrolmentRequestItem {
120 pub request_item_id: String,
121 pub student_number: String,
122 pub course_code: String,
123}
124request_item!(ResolveEnrolmentRequestItem);
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct ImportAttainmentRequestItem {
129 pub request_item_id: String,
130 pub student_number: String,
131 pub course_code: String,
132 pub enrolment_id: String,
133 pub attainment_date: NaiveDate,
134 pub attainment_language: String,
135 pub grade_scale_id: String,
136 pub grade_id: String,
137 pub credits: f64,
138}
139request_item!(ImportAttainmentRequestItem);
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct VerifyAttainmentRequestItem {
144 pub request_item_id: String,
145 pub submitted_attainment_id: String,
146}
147request_item!(VerifyAttainmentRequestItem);
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct ProductAccessTokenRequestItem {
152 pub request_item_id: String,
153 pub open_university_product_id: String,
154}
155request_item!(ProductAccessTokenRequestItem);
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub struct ListByCourseRequestItem {
160 pub request_item_id: String,
161 pub course_code: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub course_unit_realisation_id: Option<String>,
164}
165request_item!(ListByCourseRequestItem);
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct LocalizedName {
170 pub fi: String,
171 pub sv: String,
172 pub en: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct DatePeriod {
178 pub start_date: NaiveDate,
179 pub end_date: NaiveDate,
180}
181
182impl DatePeriod {
183 pub fn contains(&self, date: NaiveDate) -> bool {
184 self.start_date <= date && date <= self.end_date
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct CreditRange {
191 pub min: f64,
192 pub max: f64,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct PersonResult {
198 pub student_number: String,
199 pub person_id: String,
200 pub first_names: String,
201 pub last_name: String,
202}
203
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct SuotarEnrolment {
207 pub id: String,
208 pub state: String,
209 pub kind: String,
210 pub course_unit_id: String,
211 pub assessment_item_id: String,
212 pub course_unit_realisation_id: String,
213 pub course_unit_realisation_name: LocalizedName,
214 pub activity_period: DatePeriod,
215 pub grade_scale_id: String,
216 pub credits: CreditRange,
217 pub study_right_id: String,
218 pub study_right_validity_period: DatePeriod,
219 pub enrolment_date_time: DateTime<Utc>,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct ExistingAttainment {
225 pub id: String,
226 #[serde(rename = "type")]
227 pub attainment_type: String,
228 pub state: String,
229 pub person_id: String,
230 pub course_unit_id: String,
231 pub assessment_item_id: String,
232 pub course_unit_realisation_id: String,
233 pub attainment_date: NaiveDate,
234 pub registration_date: NaiveDate,
235 pub grade_scale_id: String,
236 pub grade_id: String,
237 pub passed: bool,
238}
239
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub struct EnrolmentResolutionResult {
243 pub enrolments: Vec<SuotarEnrolment>,
244 #[serde(default)]
245 pub existing_attainments: Vec<ExistingAttainment>,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "camelCase")]
252pub struct SuotarAttainment {
253 pub id: String,
254 #[serde(rename = "type")]
255 pub attainment_type: String,
256 #[serde(skip_serializing_if = "Option::is_none")]
257 pub state: Option<String>,
258 #[serde(skip_serializing_if = "Option::is_none")]
259 pub attainment_date: Option<NaiveDate>,
260 #[serde(skip_serializing_if = "Option::is_none")]
261 pub registration_date: Option<NaiveDate>,
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub grade_scale_id: Option<String>,
264 #[serde(skip_serializing_if = "Option::is_none")]
265 pub grade_id: Option<String>,
266}
267
268#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct ImportAttainmentResult {
273 #[serde(skip_serializing_if = "Option::is_none")]
274 pub submitted_attainment_id: Option<String>,
275 #[serde(skip_serializing_if = "Option::is_none")]
276 pub submitted_attainment_type: Option<String>,
277 #[serde(skip_serializing_if = "Option::is_none")]
278 pub attainment: Option<SuotarAttainment>,
279 #[serde(skip_serializing_if = "Option::is_none")]
280 pub previous_attainment: Option<SuotarAttainment>,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct VerifyAttainmentResult {
286 pub attainment: SuotarAttainment,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct ProductAccessTokenResult {
292 pub id: String,
293 pub access_token: String,
294 pub state: String,
295 pub document_state: String,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase")]
300pub struct ListedEnrolment {
301 pub id: String,
302 pub course_unit_realisation_id: String,
303 pub state: String,
304 pub enrolment_date_time: DateTime<Utc>,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "camelCase")]
309pub struct ListedPerson {
310 pub student_number: String,
311 pub person_id: String,
312 pub first_names: String,
313 pub last_name: String,
314 pub primary_email: String,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub secondary_email: Option<String>,
318 pub enrolment: ListedEnrolment,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(rename_all = "camelCase")]
323pub struct EnrolmentsListedResult {
324 pub people: Vec<ListedPerson>,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(rename_all = "camelCase")]
329pub enum SuotarItemStatus {
330 Ok,
331 Error,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct SuotarItemError {
337 pub message: String,
338 #[serde(skip_serializing_if = "Option::is_none")]
340 pub submitted_attainment_id: Option<String>,
341}
342
343#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct SuotarResponseItem<R> {
346 pub request_item_id: String,
347 pub status: SuotarItemStatus,
348 pub code: String,
351 #[serde(skip_serializing_if = "Option::is_none")]
352 pub result: Option<R>,
353 #[serde(skip_serializing_if = "Option::is_none")]
354 pub error: Option<SuotarItemError>,
355}
356
357#[derive(Debug)]
358pub struct SuotarBatchResponse<R> {
359 pub endpoint: SuotarEndpoint,
360 pub items: Vec<SuotarResponseItem<R>>,
361 pub missing_request_item_ids: Vec<String>,
363 pub unexpected_request_item_ids: Vec<String>,
365 pub http_status: u16,
367 pub duration: Duration,
368 pub call_id: Option<Uuid>,
370 pub raw_response: Arc<serde_json::Value>,
373}
374
375impl<R> SuotarBatchResponse<R> {
376 pub fn item(&self, request_item_id: &str) -> Option<&SuotarResponseItem<R>> {
377 self.items
378 .iter()
379 .find(|item| item.request_item_id == request_item_id)
380 }
381}
382
383#[derive(Debug, Clone, Default)]
386pub struct SuotarCallContext {
387 pub worker_name: String,
388 pub credit_registration_ids: Vec<Uuid>,
389}
390
391impl SuotarCallContext {
392 pub fn new(worker_name: impl Into<String>) -> Self {
393 Self {
394 worker_name: worker_name.into(),
395 credit_registration_ids: Vec::new(),
396 }
397 }
398
399 pub fn for_registrations(mut self, ids: Vec<Uuid>) -> Self {
400 self.credit_registration_ids = ids;
401 self
402 }
403}
404
405#[derive(Debug, Clone)]
406pub struct SuotarCallStarted {
407 pub endpoint: SuotarEndpoint,
408 pub request_item_count: usize,
409 pub worker_name: String,
410 pub credit_registration_ids: Vec<Uuid>,
411 pub started_at: DateTime<Utc>,
412 pub request_body: serde_json::Value,
414}
415
416#[derive(Debug, Clone, Default)]
417pub struct SuotarCallFinished {
418 pub http_status: Option<u16>,
419 pub duration: Duration,
420 pub succeeded: bool,
421 pub ok_item_count: usize,
422 pub error_item_count: usize,
423 pub request_level_error_code: Option<String>,
424 pub error_message: Option<String>,
425 pub response_body: Option<Arc<serde_json::Value>>,
427}
428
429#[async_trait]
432pub trait SuotarCallAudit: Send + Sync {
433 async fn started(&self, started: SuotarCallStarted) -> Option<Uuid>;
436
437 async fn finished(&self, call_id: Uuid, finished: SuotarCallFinished);
438}
439
440pub struct NoSuotarCallAudit;
441
442#[async_trait]
443impl SuotarCallAudit for NoSuotarCallAudit {
444 async fn started(&self, _started: SuotarCallStarted) -> Option<Uuid> {
445 None
446 }
447
448 async fn finished(&self, _call_id: Uuid, _finished: SuotarCallFinished) {}
449}
450
451fn authorization_header_value(token: &str) -> String {
454 format!("{SUOTAR_AUTH_SCHEME} {token}")
455}
456
457#[derive(Clone)]
458pub struct SuotarClient {
459 api_base_url: Url,
460 authorization: SecretString,
461 audit: Arc<dyn SuotarCallAudit>,
462 exchanges: Arc<AtomicU64>,
466}
467
468impl SuotarClient {
469 pub fn new(config: &SuotarConfiguration, audit: Arc<dyn SuotarCallAudit>) -> Self {
470 Self {
471 api_base_url: config.api_base_url.clone(),
472 authorization: SecretString::new(
473 authorization_header_value(config.api_token.expose_secret()).into(),
474 ),
475 audit,
476 exchanges: Arc::new(AtomicU64::new(0)),
477 }
478 }
479
480 pub fn mock_for_test() -> Self {
481 Self {
482 api_base_url: Url::parse("http://project-331.local/api/v0/mock-suotar/")
483 .expect("hardcoded url"),
484 authorization: SecretString::new(authorization_header_value(MOCK_SUOTAR_TOKEN).into()),
485 audit: Arc::new(NoSuotarCallAudit),
486 exchanges: Arc::new(AtomicU64::new(0)),
487 }
488 }
489
490 pub fn exchange_count(&self) -> u64 {
493 self.exchanges.load(Ordering::Relaxed)
494 }
495
496 pub async fn resolve_persons(
497 &self,
498 context: SuotarCallContext,
499 items: Vec<ResolvePersonRequestItem>,
500 ) -> UtilResult<SuotarBatchResponse<PersonResult>> {
501 self.post_batch(SuotarEndpoint::ResolvePersons, context, items)
502 .await
503 }
504
505 pub async fn resolve_enrolments(
506 &self,
507 context: SuotarCallContext,
508 items: Vec<ResolveEnrolmentRequestItem>,
509 ) -> UtilResult<SuotarBatchResponse<EnrolmentResolutionResult>> {
510 self.post_batch(SuotarEndpoint::ResolveEnrolments, context, items)
511 .await
512 }
513
514 pub async fn import_attainments(
515 &self,
516 context: SuotarCallContext,
517 items: Vec<ImportAttainmentRequestItem>,
518 ) -> UtilResult<SuotarBatchResponse<ImportAttainmentResult>> {
519 self.post_batch(SuotarEndpoint::ImportAttainments, context, items)
520 .await
521 }
522
523 pub async fn verify_attainments(
524 &self,
525 context: SuotarCallContext,
526 items: Vec<VerifyAttainmentRequestItem>,
527 ) -> UtilResult<SuotarBatchResponse<VerifyAttainmentResult>> {
528 self.post_batch(SuotarEndpoint::VerifyAttainments, context, items)
529 .await
530 }
531
532 pub async fn resolve_product_access_tokens(
533 &self,
534 context: SuotarCallContext,
535 items: Vec<ProductAccessTokenRequestItem>,
536 ) -> UtilResult<SuotarBatchResponse<ProductAccessTokenResult>> {
537 self.post_batch(SuotarEndpoint::ProductAccessTokens, context, items)
538 .await
539 }
540
541 pub async fn list_enrolments_by_course(
542 &self,
543 context: SuotarCallContext,
544 items: Vec<ListByCourseRequestItem>,
545 ) -> UtilResult<SuotarBatchResponse<EnrolmentsListedResult>> {
546 self.post_batch(SuotarEndpoint::ListByCourse, context, items)
547 .await
548 }
549
550 async fn post_batch<T: SuotarRequestItem, R: DeserializeOwned>(
551 &self,
552 endpoint: SuotarEndpoint,
553 context: SuotarCallContext,
554 items: Vec<T>,
555 ) -> UtilResult<SuotarBatchResponse<R>> {
556 if items.is_empty() {
557 return Ok(empty_batch_response(endpoint));
558 }
559 let request_body = serde_json::to_value(&items)?;
561 let encoded = serde_json::to_vec(&request_body)?;
562 let call_id = self
564 .audit
565 .started(SuotarCallStarted {
566 endpoint,
567 request_item_count: items.len(),
568 worker_name: context.worker_name,
569 credit_registration_ids: context.credit_registration_ids,
570 started_at: Utc::now(),
571 request_body,
572 })
573 .await;
574 if call_id.is_none() {
575 error!(
576 "Could not write a suotar_api_calls row for a {} call; sending it unaudited.",
577 endpoint.path()
578 );
579 }
580
581 let sent_ids = match check_batch(endpoint, &items) {
582 Ok(sent_ids) => sent_ids,
583 Err(error) => return self.refused(call_id, error).await,
584 };
585 if encoded.len() > MAX_REQUEST_BODY_BYTES {
586 return self
587 .refused(
588 call_id,
589 util_err!(
590 SuotarClientError(SuotarErrorVariant::MalformedRequest),
591 format!(
592 "A {} request of {} items encodes to {} bytes, over the {MAX_REQUEST_BODY_BYTES} byte limit.",
593 endpoint.path(),
594 sent_ids.len(),
595 encoded.len()
596 )
597 ),
598 )
599 .await;
600 }
601
602 let url = self.api_base_url.join(endpoint.path())?;
603 let clock = Instant::now();
604 let mut request = REQWEST_CLIENT
605 .post(url)
606 .timeout(SUOTAR_REQUEST_TIMEOUT)
607 .header(AUTHORIZATION, self.authorization.expose_secret())
608 .header(CONTENT_TYPE, "application/json");
609 if let Some(call_id) = call_id {
610 request = request.header(CORRELATION_ID_HEADER, call_id.to_string());
611 }
612
613 self.exchanges.fetch_add(1, Ordering::Relaxed);
614 let (mut outcome, finished) = self
615 .exchange(endpoint, request, encoded, sent_ids, clock)
616 .await;
617 if let Ok(response) = &mut outcome {
618 response.call_id = call_id;
619 }
620 if let Some(call_id) = call_id {
621 self.audit.finished(call_id, finished).await;
622 }
623 outcome
624 }
625
626 async fn refused<R>(
628 &self,
629 call_id: Option<Uuid>,
630 error: UtilError,
631 ) -> UtilResult<SuotarBatchResponse<R>> {
632 if let Some(call_id) = call_id {
633 self.audit
634 .finished(
635 call_id,
636 SuotarCallFinished {
637 error_message: Some(error.message().to_string()),
638 ..SuotarCallFinished::default()
639 },
640 )
641 .await;
642 }
643 Err(error)
644 }
645
646 async fn exchange<R: DeserializeOwned>(
649 &self,
650 endpoint: SuotarEndpoint,
651 request: reqwest::RequestBuilder,
652 body: Vec<u8>,
653 sent_ids: Vec<String>,
654 clock: Instant,
655 ) -> Exchanged<R> {
656 let response = match request.body(body).send().await {
657 Ok(response) => response,
658 Err(error) => {
659 return failed(
660 util_err!(
661 SuotarClientError(transport_variant(&error)),
662 format!("Request to Suotar {} failed", endpoint.path()),
663 error
664 ),
665 None,
666 clock.elapsed(),
667 None,
668 None,
669 );
670 }
671 };
672
673 let http_status = response.status().as_u16();
674 let text = match response.text().await {
675 Ok(text) => text,
676 Err(error) => {
677 return failed(
678 util_err!(
679 SuotarClientError(transport_variant(&error)),
680 format!(
681 "Reading the Suotar {} response body failed",
682 endpoint.path()
683 ),
684 error
685 ),
686 Some(http_status),
687 clock.elapsed(),
688 None,
689 None,
690 );
691 }
692 };
693 let duration = clock.elapsed();
694
695 if !(200..300).contains(&http_status) {
696 let detail = serde_json::from_str::<RequestLevelErrorBody>(&text)
697 .ok()
698 .map(|parsed| parsed.error);
699 let code = detail.as_ref().map(|detail| detail.code.clone());
700 let error = request_level_error(endpoint, http_status, detail.as_ref());
701 return failed(
702 error,
703 Some(http_status),
704 duration,
705 code,
706 Some(Arc::new(body_for_audit(&text))),
707 );
708 }
709
710 let raw_response: Arc<serde_json::Value> = match serde_json::from_str(&text) {
711 Ok(value) => Arc::new(value),
712 Err(error) => {
713 return failed(
714 util_err!(
715 SuotarClientError(SuotarErrorVariant::Deserialization),
716 format!(
717 "Suotar {} answered {http_status} with a body that is not JSON",
718 endpoint.path()
719 ),
720 error
721 ),
722 Some(http_status),
723 duration,
724 None,
725 Some(Arc::new(body_for_audit(&text))),
726 );
727 }
728 };
729 let Some(array) = raw_response.as_array() else {
730 return failed(
731 util_err!(
732 SuotarClientError(SuotarErrorVariant::Deserialization),
733 format!(
734 "Suotar {} answered {http_status} with a body that is not a batch response",
735 endpoint.path()
736 )
737 ),
738 Some(http_status),
739 duration,
740 None,
741 Some(raw_response),
742 );
743 };
744 let items: Vec<SuotarResponseItem<R>> = array
748 .iter()
749 .filter_map(|item| match SuotarResponseItem::<R>::deserialize(item) {
750 Ok(parsed) => Some(parsed),
751 Err(error) => {
752 error!(
753 "Suotar {} answered with an item that could not be read; treating it as unanswered: {error}",
754 endpoint.path()
755 );
756 None
757 }
758 })
759 .collect();
760
761 let response = reconcile(
762 endpoint,
763 sent_ids,
764 items,
765 http_status,
766 duration,
767 raw_response,
768 );
769 let finished = SuotarCallFinished {
770 http_status: Some(http_status),
771 duration,
772 succeeded: true,
773 ok_item_count: response
774 .items
775 .iter()
776 .filter(|item| item.status == SuotarItemStatus::Ok)
777 .count(),
778 error_item_count: response
779 .items
780 .iter()
781 .filter(|item| item.status == SuotarItemStatus::Error)
782 .count(),
783 request_level_error_code: None,
784 error_message: None,
785 response_body: Some(Arc::clone(&response.raw_response)),
786 };
787 (Ok(response), finished)
788 }
789}
790
791type Exchanged<R> = (UtilResult<SuotarBatchResponse<R>>, SuotarCallFinished);
792
793fn failed<R>(
794 error: UtilError,
795 http_status: Option<u16>,
796 duration: Duration,
797 request_level_error_code: Option<String>,
798 response_body: Option<Arc<serde_json::Value>>,
799) -> Exchanged<R> {
800 let finished = SuotarCallFinished {
801 http_status,
802 duration,
803 succeeded: false,
804 ok_item_count: 0,
805 error_item_count: 0,
806 request_level_error_code,
807 error_message: Some(error.message().to_string()),
808 response_body,
809 };
810 (Err(error), finished)
811}
812
813fn body_for_audit(text: &str) -> serde_json::Value {
815 serde_json::from_str(text).unwrap_or_else(|_| serde_json::Value::String(text.to_string()))
816}
817
818fn check_batch<T: SuotarRequestItem>(
821 endpoint: SuotarEndpoint,
822 items: &[T],
823) -> UtilResult<Vec<String>> {
824 if items.len() > endpoint.max_batch_size() {
825 return Err(util_err!(
826 SuotarClientError(SuotarErrorVariant::MalformedRequest),
827 format!(
828 "A {} request carries {} items, over the batch size of {}.",
829 endpoint.path(),
830 items.len(),
831 endpoint.max_batch_size()
832 )
833 ));
834 }
835 let mut seen = HashSet::with_capacity(items.len());
836 for item in items {
837 if !seen.insert(item.request_item_id()) {
838 return Err(util_err!(
839 SuotarClientError(SuotarErrorVariant::MalformedRequest),
840 format!(
841 "A {} request repeats requestItemId `{}`.",
842 endpoint.path(),
843 item.request_item_id()
844 )
845 ));
846 }
847 }
848 Ok(items
849 .iter()
850 .map(|item| item.request_item_id().to_string())
851 .collect())
852}
853
854fn empty_batch_response<R>(endpoint: SuotarEndpoint) -> SuotarBatchResponse<R> {
857 SuotarBatchResponse {
858 endpoint,
859 items: Vec::new(),
860 missing_request_item_ids: Vec::new(),
861 unexpected_request_item_ids: Vec::new(),
862 http_status: 0,
863 duration: Duration::ZERO,
864 call_id: None,
865 raw_response: Arc::new(serde_json::Value::Array(Vec::new())),
866 }
867}
868
869fn reconcile<R>(
871 endpoint: SuotarEndpoint,
872 sent_ids: Vec<String>,
873 items: Vec<SuotarResponseItem<R>>,
874 http_status: u16,
875 duration: Duration,
876 raw_response: Arc<serde_json::Value>,
877) -> SuotarBatchResponse<R> {
878 let sent: HashSet<&str> = sent_ids.iter().map(String::as_str).collect();
879 let answered: HashSet<&str> = items
880 .iter()
881 .map(|item| item.request_item_id.as_str())
882 .collect();
883
884 let missing_request_item_ids: Vec<String> = sent_ids
885 .iter()
886 .filter(|id| !answered.contains(id.as_str()))
887 .cloned()
888 .collect();
889 let unexpected_request_item_ids: Vec<String> = items
890 .iter()
891 .filter(|item| !sent.contains(item.request_item_id.as_str()))
892 .map(|item| item.request_item_id.clone())
893 .collect();
894
895 if !unexpected_request_item_ids.is_empty() {
896 warn!(
897 "Suotar {} answered with {} requestItemIds that were not sent; ignoring them.",
898 endpoint.path(),
899 unexpected_request_item_ids.len()
900 );
901 }
902 if !missing_request_item_ids.is_empty() && endpoint.creates_attainments() {
903 error!(
904 "Suotar {} left {} of {} items unanswered. Their attainments may or may not exist and they must not be re-sent.",
905 endpoint.path(),
906 missing_request_item_ids.len(),
907 sent_ids.len()
908 );
909 }
910 for item in &items {
911 if item.code == TRANSIENT_ITEM_CODE && !endpoint.carries_item_level_transient() {
912 warn!(
913 "Suotar {} returned an item-level `{TRANSIENT_ITEM_CODE}`, which its contract does not list.",
914 endpoint.path()
915 );
916 }
917 }
918
919 SuotarBatchResponse {
920 endpoint,
921 items,
922 missing_request_item_ids,
923 unexpected_request_item_ids,
924 http_status,
925 duration,
926 call_id: None,
927 raw_response,
928 }
929}
930
931#[derive(Debug, Deserialize)]
932struct RequestLevelErrorBody {
933 error: RequestLevelErrorDetail,
934}
935
936#[derive(Debug, Deserialize)]
937struct RequestLevelErrorDetail {
938 code: String,
939 message: String,
940}
941
942fn request_level_error(
943 endpoint: SuotarEndpoint,
944 http_status: u16,
945 detail: Option<&RequestLevelErrorDetail>,
946) -> UtilError {
947 let variant = match (http_status, detail.map(|detail| detail.code.as_str())) {
948 (401 | 403, _) => SuotarErrorVariant::Unauthorized,
949 (_, Some("unauthorized")) => SuotarErrorVariant::Unauthorized,
950 (_, Some("malformedRequest")) => SuotarErrorVariant::MalformedRequest,
951 (500..=599, _) => SuotarErrorVariant::ServerError,
952 _ => SuotarErrorVariant::RequestLevelError,
953 };
954 let detail = match detail {
955 Some(detail) => format!("`{}`: {}", detail.code, detail.message),
956 None => "no documented error body".to_string(),
957 };
958 let path = endpoint.path();
959 util_err!(
960 SuotarClientError(variant),
961 format!("Suotar {path} rejected the whole request with {http_status}, {detail}")
962 )
963}
964
965fn transport_variant(error: &reqwest::Error) -> SuotarErrorVariant {
968 if error.is_connect() || error.is_builder() {
969 SuotarErrorVariant::TransportNotDelivered
970 } else {
971 SuotarErrorVariant::TransportUnknown
972 }
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978 use serde_json::json;
979
980 fn person_items(ids: &[&str]) -> Vec<ResolvePersonRequestItem> {
981 ids.iter()
982 .map(|id| ResolvePersonRequestItem {
983 request_item_id: (*id).to_string(),
984 student_number: "012345678".to_string(),
985 })
986 .collect()
987 }
988
989 fn person_response(ids: &[&str]) -> Vec<SuotarResponseItem<PersonResult>> {
990 let items: Vec<serde_json::Value> = ids
991 .iter()
992 .map(|id| {
993 json!({
994 "requestItemId": id,
995 "status": "ok",
996 "code": "personFound",
997 "result": {
998 "studentNumber": "012345678",
999 "personId": "otm-person-id",
1000 "firstNames": "Henrik Admin",
1001 "lastName": "Nygren",
1002 }
1003 })
1004 })
1005 .collect();
1006 serde_json::from_value(json!(items)).expect("person response")
1007 }
1008
1009 fn classified(http_status: u16, body: &str) -> UtilError {
1010 let detail = serde_json::from_str::<RequestLevelErrorBody>(body)
1011 .ok()
1012 .map(|parsed| parsed.error);
1013 request_level_error(
1014 SuotarEndpoint::ImportAttainments,
1015 http_status,
1016 detail.as_ref(),
1017 )
1018 }
1019
1020 fn reconciled(
1021 sent: &[&str],
1022 items: Vec<SuotarResponseItem<PersonResult>>,
1023 ) -> SuotarBatchResponse<PersonResult> {
1024 reconcile(
1025 SuotarEndpoint::ResolvePersons,
1026 sent.iter().map(|id| (*id).to_string()).collect(),
1027 items,
1028 200,
1029 Duration::ZERO,
1030 Arc::new(json!([])),
1031 )
1032 }
1033
1034 #[test]
1037 fn every_endpoint_joins_onto_the_configured_base() {
1038 let client = SuotarClient::mock_for_test();
1039 let joined: Vec<String> = [
1040 SuotarEndpoint::ResolvePersons,
1041 SuotarEndpoint::ResolveEnrolments,
1042 SuotarEndpoint::ImportAttainments,
1043 SuotarEndpoint::VerifyAttainments,
1044 SuotarEndpoint::ProductAccessTokens,
1045 SuotarEndpoint::ListByCourse,
1046 ]
1047 .iter()
1048 .map(|endpoint| {
1049 client
1050 .api_base_url
1051 .join(endpoint.path())
1052 .expect("joins")
1053 .to_string()
1054 })
1055 .collect();
1056 assert_eq!(
1057 joined,
1058 vec![
1059 "http://project-331.local/api/v0/mock-suotar/persons/resolve-by-student-numbers",
1060 "http://project-331.local/api/v0/mock-suotar/enrolments/resolve",
1061 "http://project-331.local/api/v0/mock-suotar/attainments/import",
1062 "http://project-331.local/api/v0/mock-suotar/attainments/verify",
1063 "http://project-331.local/api/v0/mock-suotar/open-university-product-access-tokens/resolve",
1064 "http://project-331.local/api/v0/mock-suotar/enrolments/list-by-course",
1065 ]
1066 );
1067 }
1068
1069 #[test]
1070 fn a_request_batch_serializes_to_the_documented_shape() {
1071 let items = vec![ImportAttainmentRequestItem {
1072 request_item_id: "cr-11111111-1111-1111-1111-111111111111".to_string(),
1073 student_number: "012345678".to_string(),
1074 course_code: "TKT10001".to_string(),
1075 enrolment_id: "selected-enrolment-id".to_string(),
1076 attainment_date: NaiveDate::from_ymd_opt(2026, 5, 22).expect("valid date"),
1077 attainment_language: "fi".to_string(),
1078 grade_scale_id: "sis-hyl-hyv".to_string(),
1079 grade_id: "1".to_string(),
1080 credits: 5.0,
1081 }];
1082 assert_eq!(
1083 serde_json::to_value(&items).expect("serializes"),
1084 json!([{
1085 "requestItemId": "cr-11111111-1111-1111-1111-111111111111",
1086 "studentNumber": "012345678",
1087 "courseCode": "TKT10001",
1088 "enrolmentId": "selected-enrolment-id",
1089 "attainmentDate": "2026-05-22",
1090 "attainmentLanguage": "fi",
1091 "gradeScaleId": "sis-hyl-hyv",
1092 "gradeId": "1",
1093 "credits": 5.0
1094 }])
1095 );
1096 }
1097
1098 #[test]
1099 fn list_by_course_omits_an_absent_realisation_id() {
1100 let items = vec![ListByCourseRequestItem {
1101 request_item_id: "people-1".to_string(),
1102 course_code: "TKT10001".to_string(),
1103 course_unit_realisation_id: None,
1104 }];
1105 assert_eq!(
1106 serde_json::to_value(&items).expect("serializes"),
1107 json!([{ "requestItemId": "people-1", "courseCode": "TKT10001" }])
1108 );
1109 }
1110
1111 #[test]
1112 fn an_error_item_deserializes_without_a_result() {
1113 let items: Vec<SuotarResponseItem<PersonResult>> = serde_json::from_value(json!([{
1114 "requestItemId": "b2",
1115 "status": "error",
1116 "code": "personNotFound",
1117 "error": { "message": "No Sisu person was found for the supplied student number." }
1118 }]))
1119 .expect("error item");
1120 assert_eq!(items[0].status, SuotarItemStatus::Error);
1121 assert!(items[0].result.is_none());
1122 assert_eq!(
1123 items[0].error.as_ref().map(|error| error.message.as_str()),
1124 Some("No Sisu person was found for the supplied student number.")
1125 );
1126 }
1127
1128 #[test]
1129 fn a_disclosed_sisu_timeout_carries_the_id_the_client_may_verify() {
1130 let items: Vec<SuotarResponseItem<ImportAttainmentResult>> =
1131 serde_json::from_value(json!([{
1132 "requestItemId": "cr-1",
1133 "status": "error",
1134 "code": "sisuTimeout",
1135 "error": {
1136 "message": "Sisu operation timed out; outcome is uncertain.",
1137 "submittedAttainmentId": "hy-kur-1"
1138 }
1139 }]))
1140 .expect("disclosed timeout");
1141 assert_eq!(
1142 items[0]
1143 .error
1144 .as_ref()
1145 .and_then(|error| error.submitted_attainment_id.as_deref()),
1146 Some("hy-kur-1")
1147 );
1148 }
1149
1150 #[test]
1151 fn one_deserializer_covers_every_import_success_body() {
1152 let items: Vec<SuotarResponseItem<ImportAttainmentResult>> =
1153 serde_json::from_value(json!([
1154 {
1155 "requestItemId": "cr-1",
1156 "status": "ok",
1157 "code": "sent",
1158 "result": {
1159 "submittedAttainmentId": "hy-kur-1",
1160 "submittedAttainmentType": "AssessmentItemAttainment"
1161 }
1162 },
1163 {
1164 "requestItemId": "cr-2",
1165 "status": "ok",
1166 "code": "registered",
1167 "result": { "attainment": { "id": "final-id", "type": "CourseUnitAttainment" } }
1168 },
1169 {
1170 "requestItemId": "cr-3",
1171 "status": "ok",
1172 "code": "duplicateAttainment",
1173 "result": { "attainment": {
1174 "id": "existing-id",
1175 "type": "CourseUnitAttainment",
1176 "state": "ATTAINED",
1177 "attainmentDate": "2026-05-22",
1178 "registrationDate": "2026-05-22",
1179 "gradeScaleId": "sis-hyl-hyv",
1180 "gradeId": "1"
1181 } }
1182 },
1183 {
1184 "requestItemId": "cr-4",
1185 "status": "ok",
1186 "code": "notImprovedAttainment",
1187 "result": { "previousAttainment": {
1188 "id": "existing-id",
1189 "type": "CourseUnitAttainment",
1190 "state": "ATTAINED",
1191 "gradeScaleId": "sis-0-5",
1192 "gradeId": "5",
1193 "attainmentDate": "2026-03-01",
1194 "registrationDate": "2026-03-05"
1195 } }
1196 }
1197 ]))
1198 .expect("import successes");
1199
1200 let sent = items[0].result.as_ref().expect("sent result");
1201 assert_eq!(sent.submitted_attainment_id.as_deref(), Some("hy-kur-1"));
1202 let registered = items[1].result.as_ref().expect("registered result");
1203 assert_eq!(
1204 registered
1205 .attainment
1206 .as_ref()
1207 .map(|attainment| attainment.id.as_str()),
1208 Some("final-id")
1209 );
1210 let duplicate = items[2].result.as_ref().expect("duplicate result");
1211 assert_eq!(
1212 duplicate
1213 .attainment
1214 .as_ref()
1215 .and_then(|attainment| attainment.grade_id.as_deref()),
1216 Some("1")
1217 );
1218 let not_improved = items[3].result.as_ref().expect("not improved result");
1219 assert_eq!(
1220 not_improved
1221 .previous_attainment
1222 .as_ref()
1223 .map(|attainment| attainment.id.as_str()),
1224 Some("existing-id")
1225 );
1226 }
1227
1228 #[test]
1229 fn an_unknown_code_does_not_fail_deserialization() {
1230 let items: Vec<SuotarResponseItem<PersonResult>> = serde_json::from_value(json!([{
1231 "requestItemId": "a1",
1232 "status": "error",
1233 "code": "somethingSuotarAddedLater",
1234 "error": { "message": "..." }
1235 }]))
1236 .expect("unknown code");
1237 assert_eq!(items[0].code, "somethingSuotarAddedLater");
1238 }
1239
1240 #[test]
1241 fn items_are_matched_by_request_item_id_not_position() {
1242 let response = reconciled(&["a1", "b2", "c3"], person_response(&["c3", "a1", "b2"]));
1243 assert!(response.missing_request_item_ids.is_empty());
1244 assert!(response.unexpected_request_item_ids.is_empty());
1245 assert_eq!(
1246 response.item("b2").map(|item| item.code.as_str()),
1247 Some("personFound")
1248 );
1249 }
1250
1251 #[test]
1252 fn an_unanswered_item_is_reported_rather_than_paired_with_a_neighbour() {
1253 let response = reconciled(&["a1", "b2", "c3"], person_response(&["c3", "a1"]));
1254 assert_eq!(response.missing_request_item_ids, vec!["b2".to_string()]);
1255 assert!(response.item("b2").is_none());
1256 assert!(response.item("c3").is_some());
1257 }
1258
1259 #[test]
1260 fn an_item_id_that_was_never_sent_is_reported_and_kept_out_of_the_way() {
1261 let response = reconciled(&["a1"], person_response(&["a1", "z9"]));
1262 assert_eq!(response.unexpected_request_item_ids, vec!["z9".to_string()]);
1263 assert!(response.missing_request_item_ids.is_empty());
1264 }
1265
1266 #[test]
1267 fn a_repeated_request_item_id_is_refused_before_the_request_is_built() {
1268 let error = check_batch(SuotarEndpoint::ResolvePersons, &person_items(&["a1", "a1"]))
1269 .expect_err("duplicate ids");
1270 assert!(error.message().contains("repeats requestItemId `a1`"));
1271 }
1272
1273 #[test]
1274 fn a_batch_over_the_endpoints_size_is_refused_before_the_request_is_built() {
1275 let items: Vec<ResolvePersonRequestItem> = (0..101)
1276 .map(|index| ResolvePersonRequestItem {
1277 request_item_id: format!("cr-{index}"),
1278 student_number: "012345678".to_string(),
1279 })
1280 .collect();
1281 for (endpoint, size) in [
1282 (SuotarEndpoint::ListByCourse, 10),
1283 (SuotarEndpoint::ImportAttainments, 25),
1284 (SuotarEndpoint::ResolvePersons, 50),
1285 (SuotarEndpoint::VerifyAttainments, 100),
1286 ] {
1287 assert_eq!(endpoint.max_batch_size(), size, "{endpoint:?}");
1288 assert!(
1289 check_batch(endpoint, &items[..size]).is_ok(),
1290 "{endpoint:?}"
1291 );
1292 assert!(
1293 check_batch(endpoint, &items[..size + 1]).is_err(),
1294 "{endpoint:?}"
1295 );
1296 }
1297 }
1298
1299 #[test]
1300 fn the_documented_request_level_bodies_classify() {
1301 let unauthorized = classified(
1302 401,
1303 r#"{"error":{"code":"unauthorized","message":"Missing or invalid credentials."}}"#,
1304 );
1305 assert!(matches!(
1306 unauthorized.error_type(),
1307 UtilErrorType::SuotarClientError(SuotarErrorVariant::Unauthorized)
1308 ));
1309
1310 let malformed = classified(
1311 400,
1312 r#"{"error":{"code":"malformedRequest","message":"Request body is not valid JSON or has the wrong top-level shape."}}"#,
1313 );
1314 assert!(matches!(
1315 malformed.error_type(),
1316 UtilErrorType::SuotarClientError(SuotarErrorVariant::MalformedRequest)
1317 ));
1318
1319 let server = classified(
1320 503,
1321 r#"{"error":{"code":"sisuTemporarilyUnavailable","message":"Sisu was temporarily unavailable."}}"#,
1322 );
1323 assert!(matches!(
1324 server.error_type(),
1325 UtilErrorType::SuotarClientError(SuotarErrorVariant::ServerError)
1326 ));
1327
1328 let bodyless = classified(502, "<html>");
1329 assert!(matches!(
1330 bodyless.error_type(),
1331 UtilErrorType::SuotarClientError(SuotarErrorVariant::ServerError)
1332 ));
1333 }
1334
1335 #[test]
1336 fn only_the_failures_that_never_reached_suotar_are_safe_to_resend() {
1337 assert!(!SuotarErrorVariant::TransportNotDelivered.outcome_may_have_landed());
1338 assert!(!SuotarErrorVariant::Unauthorized.outcome_may_have_landed());
1339 assert!(!SuotarErrorVariant::MalformedRequest.outcome_may_have_landed());
1340 assert!(!SuotarErrorVariant::RequestLevelError.outcome_may_have_landed());
1341 assert!(SuotarErrorVariant::TransportUnknown.outcome_may_have_landed());
1342 assert!(SuotarErrorVariant::ServerError.outcome_may_have_landed());
1343 assert!(SuotarErrorVariant::Deserialization.outcome_may_have_landed());
1344 }
1345}