Skip to main content

headless_lms_server/mock_suotar/
wire.rs

1//! The mock's half of the moocfi endpoints: its own request and response shapes, deliberately not
2//! shared with the client so the two can disagree the way a real Suotar and our client can.
3//!
4//! Every endpoint takes a top-level JSON array and answers with one item per request item, in order.
5//! Per-item outcomes are HTTP 200; only request-level failures are 4xx/5xx. Suotar serializes an
6//! `undefined` field by leaving it out and a `null` one as `null`, and the response structs keep
7//! that distinction field by field.
8
9use chrono::{NaiveDate, SecondsFormat};
10
11use crate::prelude::*;
12
13/// The moocfi endpoints, keyed the way the audited client names them.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum Endpoint {
17    ResolvePersons,
18    ResolveEnrolments,
19    ImportAttainments,
20    VerifyAttainments,
21    ListByCourse,
22    ValidateCourseCodes,
23}
24
25impl Endpoint {
26    pub fn max_batch_size(self) -> usize {
27        match self {
28            Self::ImportAttainments => 100,
29            Self::ListByCourse => 50,
30            Self::ResolvePersons
31            | Self::ResolveEnrolments
32            | Self::VerifyAttainments
33            | Self::ValidateCourseCodes => 1000,
34        }
35    }
36}
37
38pub const ASSESSMENT_ITEM_ATTAINMENT: &str = "AssessmentItemAttainment";
39pub const COURSE_UNIT_ATTAINMENT: &str = "CourseUnitAttainment";
40
41#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct ResolvePersonRequestItem {
44    pub request_item_id: String,
45    pub student_number: String,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub struct ResolveEnrolmentRequestItem {
51    pub request_item_id: String,
52    pub student_number: String,
53    pub course_code: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct ImportAttainmentRequestItem {
59    pub request_item_id: String,
60    pub student_number: String,
61    pub course_code: String,
62    pub enrolment_id: String,
63    #[serde(rename = "attainmentDate")]
64    pub attained_at: DateTime<Utc>,
65    pub attainment_language: String,
66    pub grade_scale_id: String,
67    pub grade_id: String,
68    pub credits: f64,
69}
70
71impl ImportAttainmentRequestItem {
72    /// The date the attainment gets: the UTC date of the moment sent.
73    pub fn attainment_date(&self) -> NaiveDate {
74        self.attained_at.date_naive()
75    }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct VerifyAttainmentRequestItem {
81    pub request_item_id: String,
82    pub submitted_attainment_id: String,
83}
84
85/// Shared by list-by-course and course-code validation, which both take only a code.
86#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct CourseCodeRequestItem {
89    pub request_item_id: String,
90    pub course_code: String,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct LocalizedName {
96    pub fi: String,
97    pub sv: String,
98    pub en: String,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct DatePeriod {
104    pub start_date: NaiveDate,
105    /// Exclusive, as in Sisu; `null` for a realisation or study right with no end.
106    pub end_date: Option<NaiveDate>,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct CreditRange {
112    pub min: f64,
113    /// `null` is an open range, which Suotar refuses to import against.
114    pub max: Option<f64>,
115}
116
117/// Exactly the four keys Suotar passes on from the importer's person row.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
119#[serde(rename_all = "camelCase")]
120pub struct PersonResult {
121    pub student_number: String,
122    pub person_id: String,
123    pub first_names: Option<String>,
124    pub last_name: Option<String>,
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct Enrolment {
130    pub id: String,
131    pub state: String,
132    pub kind: String,
133    pub course_unit_id: String,
134    pub assessment_item_id: String,
135    pub course_unit_realisation_id: String,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub course_unit_realisation_name: Option<LocalizedName>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub activity_period: Option<DatePeriod>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub grade_scale_id: Option<String>,
142    pub credits: Option<CreditRange>,
143    pub study_right_id: Option<String>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub study_right_validity_period: Option<DatePeriod>,
146    /// ISO with milliseconds, as the importer hands it through; left out when it has none.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub enrolment_date_time: Option<String>,
149}
150
151/// The importer's attainment passed through: a course-unit attainment has no assessment item or
152/// realisation, and a missing grade leaves `passed` out.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
154#[serde(rename_all = "camelCase")]
155pub struct ExistingAttainment {
156    pub id: String,
157    #[serde(rename = "type")]
158    pub attainment_type: String,
159    pub state: String,
160    pub person_id: String,
161    pub course_unit_id: String,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub assessment_item_id: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub course_unit_realisation_id: Option<String>,
166    /// [`sisu_midnight`], as the importer hands dates through.
167    pub attainment_date: String,
168    pub registration_date: String,
169    pub grade_scale_id: String,
170    /// A bare number when it reads as one, as Sisu's own data has it.
171    #[serde(serialize_with = "number_when_numeric")]
172    pub grade_id: String,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub passed: Option<bool>,
175}
176
177#[derive(Debug, Clone, PartialEq, Serialize)]
178#[serde(rename_all = "camelCase")]
179pub struct EnrolmentResolutionResult {
180    pub enrolments: Vec<Enrolment>,
181    pub existing_attainments: Vec<ExistingAttainment>,
182}
183
184/// What `enrolmentNotFound` and `enrolmentNotAccepted` still hand back.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
186#[serde(rename_all = "camelCase")]
187pub struct ExistingAttainmentsResult {
188    pub existing_attainments: Vec<ExistingAttainment>,
189}
190
191/// The seven fields `duplicateAttainment` and `notImprovedAttainment` report.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
193#[serde(rename_all = "camelCase")]
194pub struct AttainmentSummary {
195    pub id: String,
196    #[serde(rename = "type")]
197    pub attainment_type: String,
198    pub state: String,
199    /// [`sisu_midnight`], as the importer hands dates through.
200    pub attainment_date: String,
201    pub registration_date: String,
202    pub grade_scale_id: String,
203    pub grade_id: String,
204}
205
206/// The five fields of the `duplicateAttainment` Suotar answers from its own recent sends, which
207/// names the submission rather than anything the importer has seen.
208#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
209#[serde(rename_all = "camelCase")]
210pub struct RecentlySentAttainment {
211    pub id: String,
212    #[serde(rename = "type")]
213    pub attainment_type: String,
214    /// `YYYY-MM-DD`, unlike the importer's dates.
215    pub attainment_date: NaiveDate,
216    pub grade_scale_id: String,
217    pub grade_id: String,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
221#[serde(rename_all = "camelCase")]
222pub struct SubmittedAttainment {
223    pub submitted_attainment_id: String,
224    pub submitted_attainment_type: String,
225}
226
227impl SubmittedAttainment {
228    pub fn new(submitted_attainment_id: &str) -> Self {
229        Self {
230            submitted_attainment_id: submitted_attainment_id.to_string(),
231            submitted_attainment_type: ASSESSMENT_ITEM_ATTAINMENT.to_string(),
232        }
233    }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
237#[serde(rename_all = "camelCase")]
238pub struct DuplicateAttainmentResult {
239    pub attainment: AttainmentSummary,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
243#[serde(rename_all = "camelCase")]
244pub struct RecentlySentDuplicateResult {
245    pub attainment: RecentlySentAttainment,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
249#[serde(rename_all = "camelCase")]
250pub struct NotImprovedAttainmentResult {
251    pub previous_attainment: AttainmentSummary,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
255#[serde(rename_all = "camelCase")]
256pub struct AttainmentReference {
257    pub id: String,
258    #[serde(rename = "type")]
259    pub attainment_type: String,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
263#[serde(rename_all = "camelCase")]
264pub struct RegisteredResult {
265    pub attainment: AttainmentReference,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
269#[serde(rename_all = "camelCase")]
270pub struct SubmissionPendingResult {
271    pub submitted_attainment_id: String,
272    pub submitted_attainment_type: String,
273    pub retry_after: String,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
277#[serde(rename_all = "camelCase")]
278pub struct ListedEnrolment {
279    pub id: String,
280    pub course_unit_realisation_id: String,
281    pub state: String,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub enrolment_date_time: Option<String>,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
287#[serde(rename_all = "camelCase")]
288pub struct ListedPerson {
289    pub student_number: String,
290    pub person_id: String,
291    pub first_names: Option<String>,
292    pub last_name: Option<String>,
293    pub primary_email: Option<String>,
294    pub secondary_email: Option<String>,
295    pub enrolment: ListedEnrolment,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
299#[serde(rename_all = "camelCase")]
300pub struct EnrolmentsListedResult {
301    pub people: Vec<ListedPerson>,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
305#[serde(rename_all = "camelCase")]
306pub struct CourseAllowedResult {
307    pub course_code: String,
308    pub name: String,
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub enum ItemStatus {
314    Ok,
315    Error,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct ItemError {
320    pub message: String,
321}
322
323/// A request's items are all one endpoint's shape, but the pipeline also carries fault-shaped items
324/// and logs them, so the result is erased to JSON as soon as the logic has built it.
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct ResponseItem {
328    pub request_item_id: String,
329    pub status: ItemStatus,
330    pub code: String,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub error: Option<ItemError>,
333    /// On an error item only for the codes that hand back the submission they concern, and for the
334    /// enrolment errors, which hand back the existing attainments.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub result: Option<serde_json::Value>,
337}
338
339impl ResponseItem {
340    pub fn ok<R: Serialize>(request_item_id: &str, code: &str, result: R) -> Self {
341        Self {
342            request_item_id: request_item_id.to_string(),
343            status: ItemStatus::Ok,
344            code: code.to_string(),
345            error: None,
346            result: None,
347        }
348        .with_result(result)
349    }
350
351    /// With the code's canonical wording, which depends on the endpoint for `enrolmentNotFound`.
352    pub fn error(endpoint: Endpoint, request_item_id: &str, code: &str) -> Self {
353        Self::error_with_message(
354            request_item_id,
355            code,
356            canonical_message(Some(endpoint), code),
357        )
358    }
359
360    pub fn error_with_message(request_item_id: &str, code: &str, message: String) -> Self {
361        Self {
362            request_item_id: request_item_id.to_string(),
363            status: ItemStatus::Error,
364            code: code.to_string(),
365            error: Some(ItemError { message }),
366            result: None,
367        }
368    }
369
370    pub fn with_result<R: Serialize>(mut self, result: R) -> Self {
371        self.result = Some(serde_json::to_value(result).unwrap_or(serde_json::Value::Null));
372        self
373    }
374}
375
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
377pub struct RequestLevelErrorBody {
378    pub code: String,
379    pub message: String,
380}
381
382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383pub struct RequestLevelError {
384    pub error: RequestLevelErrorBody,
385}
386
387impl RequestLevelError {
388    pub fn new(code: &str) -> Self {
389        Self::with_message(code, canonical_message(None, code))
390    }
391
392    pub fn with_message(code: &str, message: String) -> Self {
393        Self {
394            error: RequestLevelErrorBody {
395                code: code.to_string(),
396                message,
397            },
398        }
399    }
400}
401
402/// Suotar's fixed wording per code, `endpoint` being `None` for a request-level one. Codes whose real
403/// message names the item fall back to a generic sentence here, for a fault that names the code
404/// without a message.
405fn canonical_message(endpoint: Option<Endpoint>, code: &str) -> String {
406    match code {
407        "requestTooLarge" => "Request body is too large.",
408        "unauthorized" => "Missing or invalid credentials.",
409        "internalError" => "Suotar failed to process the request.",
410        "serviceTemporarilyUnavailable" => "Failed to fetch Sisu data.",
411        "malformedRequest" => NOT_AN_ARRAY,
412        "personNotFound" => "No Sisu person was found for the supplied student number.",
413        "courseCodeNotFound" => "Course code could not be resolved in Sisu.",
414        "enrolmentNotFound" if endpoint == Some(Endpoint::ImportAttainments) => {
415            "No ENROLLED Sisu enrolment was found for this student and course code."
416        }
417        "enrolmentNotFound" => "No Sisu enrolment was found for this person and course.",
418        "enrolmentNotAccepted" => "The Sisu enrolment has not been accepted.",
419        "duplicateRequestItem" => {
420            "An earlier request item in this batch is the same completion, and it was registered once. Verify the attainment in `result` rather than submitting this completion again."
421        }
422        "invalidGradeForGradeScale" => {
423            "Grade id is not valid for the resolved enrolment's grade scale."
424        }
425        "studyRightNotValid" => "Study right cannot support the attainment.",
426        "sisuTimeout" => "Sisu operation timed out; outcome is uncertain.",
427        "notRegistered" => {
428            "No final or partial Sisu registration evidence was found for the submitted attainment id."
429        }
430        "submissionPending" => {
431            "This attainment was submitted too recently for Sisu to have shown it to Suotar yet. Keep polling; do not resubmit before retryAfter."
432        }
433        "misregistered" => {
434            "A previously registered attainment has been marked misregistered in Sisu."
435        }
436        "courseNotAllowed" => COURSE_NOT_CARRIED,
437        "sisuValidationFailed" => "Sisu rejected the attainment.",
438        _ => "Suotar returned an unspecified outcome.",
439    }
440    .to_string()
441}
442
443pub const COURSE_NOT_CARRIED: &str = "Suotar does not carry this course code.";
444
445pub const NOT_AN_ARRAY: &str = "Request body must be a JSON array of request items.";
446
447fn number_when_numeric<S: serde::Serializer>(
448    value: &str,
449    serializer: S,
450) -> Result<S::Ok, S::Error> {
451    match value.parse::<i64>() {
452        Ok(number) => serializer.serialize_i64(number),
453        Err(_) => serializer.serialize_str(value),
454    }
455}
456
457/// JavaScript's `toISOString()`: UTC with exactly three fractional digits.
458pub fn iso_millis(time: DateTime<Utc>) -> String {
459    time.to_rfc3339_opts(SecondsFormat::Millis, true)
460}
461
462/// A date the way the importer passes Sisu's through: an instant at UTC midnight.
463pub fn sisu_midnight(date: NaiveDate) -> String {
464    format!("{date}T00:00:00.000Z")
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn a_per_item_error_serializes_to_the_documented_shape() {
473        let item = ResponseItem::error(Endpoint::ResolvePersons, "b2", "personNotFound");
474        assert_eq!(
475            serde_json::to_value(&item).expect("serializes"),
476            serde_json::json!({
477                "requestItemId": "b2",
478                "status": "error",
479                "code": "personNotFound",
480                "error": { "message": "No Sisu person was found for the supplied student number." }
481            })
482        );
483    }
484}