Skip to main content

headless_lms_server/mock_suotar/
faults.rs

1//! Addressed faults: what can go wrong that the world's own data cannot express.
2//!
3//! Predicates are AND-ed and order-independent, so a miss can name the single predicate that failed.
4//! No HTTP and no Redis: matching is decided from the fault and the request's item addresses alone.
5
6use crate::prelude::*;
7
8use super::wire::Endpoint;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub enum Stage {
13    Auth,
14    RequestGate,
15    Parse,
16    Resolve,
17    AfterWrite,
18    Respond,
19}
20
21impl Stage {
22    pub const ALL: [Self; 6] = [
23        Self::Auth,
24        Self::RequestGate,
25        Self::Parse,
26        Self::Resolve,
27        Self::AfterWrite,
28        Self::Respond,
29    ];
30
31    /// True where the write-back pipeline has already committed.
32    pub fn is_post_commit(self) -> bool {
33        matches!(self, Self::AfterWrite | Self::Respond)
34    }
35
36    /// True where the body has not been read yet, so an owner-narrowed fault has to be deferred.
37    pub fn is_pre_load(self) -> bool {
38        matches!(self, Self::Auth | Self::RequestGate | Self::Parse)
39    }
40
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::Auth => "auth",
44            Self::RequestGate => "requestGate",
45            Self::Parse => "parse",
46            Self::Resolve => "resolve",
47            Self::AfterWrite => "afterWrite",
48            Self::Respond => "respond",
49        }
50    }
51}
52
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct OwnerRef {
56    pub user: Option<String>,
57    pub course: Option<String>,
58}
59
60impl OwnerRef {
61    pub fn is_empty(&self) -> bool {
62        self.user.is_none() && self.course.is_none()
63    }
64}
65
66/// An owner turned into the keys the wire carries, resolved once at arm time: a fault whose meaning
67/// changed because the user linked in the meantime would be unassertable.
68#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct ResolvedOwner {
71    pub user: Option<String>,
72    pub course: Option<String>,
73    pub student_numbers: Vec<String>,
74    pub course_codes: Vec<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub enum Predicate {
80    Endpoint(Endpoint),
81    Stage(Stage),
82    StudentNumber(String),
83    CourseCode(String),
84    Owner(OwnerRef),
85}
86
87impl Predicate {
88    pub fn key(&self) -> &'static str {
89        match self {
90            Self::Endpoint(_) => "endpoint",
91            Self::Stage(_) => "stage",
92            Self::StudentNumber(_) => "studentNumber",
93            Self::CourseCode(_) => "courseCode",
94            Self::Owner(_) => "owner",
95        }
96    }
97
98    /// Names data one spec owns, which is what keeps a fault inside its own traffic.
99    fn is_owner_key(&self) -> bool {
100        matches!(
101            self,
102            Self::StudentNumber(_) | Self::CourseCode(_) | Self::Owner(_)
103        )
104    }
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase", tag = "kind")]
109pub enum Effect {
110    /// `sisuTimeout` on an import item after the write carries the written submission's id and
111    /// leaves it unconfirmed, as a send that never answered does.
112    ItemLevel {
113        code: String,
114        message: Option<String>,
115    },
116    RequestLevel {
117        status: u16,
118        code: String,
119        message: Option<String>,
120    },
121    /// A non-envelope answer, such as the fall-through 401 or a proxy's HTML error page.
122    #[serde(rename_all = "camelCase")]
123    RawBody {
124        status: u16,
125        body: String,
126        content_type: Option<String>,
127    },
128    ConnectionReset,
129    /// Leaves the matched item out of the response, whatever became of it.
130    DropItem,
131}
132
133impl Effect {
134    /// Derived from the kind, never declared: a descriptor naming both `level: item` and
135    /// `kind: connectionReset` is nonsense.
136    pub fn is_request_shaped(&self) -> bool {
137        !matches!(self, Self::ItemLevel { .. } | Self::DropItem)
138    }
139
140    pub fn code(&self) -> Option<&str> {
141        match self {
142            Self::ItemLevel { code, .. } | Self::RequestLevel { code, .. } => Some(code),
143            Self::RawBody { .. } | Self::ConnectionReset | Self::DropItem => None,
144        }
145    }
146
147    pub fn kind(&self) -> &'static str {
148        match self {
149            Self::ItemLevel { .. } => "itemLevel",
150            Self::RequestLevel { .. } => "requestLevel",
151            Self::RawBody { .. } => "rawBody",
152            Self::ConnectionReset => "connectionReset",
153            Self::DropItem => "dropItem",
154        }
155    }
156}
157
158#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160pub struct Lifetime {
161    pub matching_calls: Option<u32>,
162    pub matching_items: Option<u32>,
163}
164
165impl Lifetime {
166    pub fn budget(&self) -> Option<u32> {
167        self.matching_calls.or(self.matching_items)
168    }
169}
170
171#[derive(Debug, Clone, Default, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct FlatWhen {
174    pub endpoint: Option<Endpoint>,
175    pub stage: Option<Stage>,
176    pub student_number: Option<String>,
177    pub course_code: Option<String>,
178    pub owner: Option<OwnerRef>,
179}
180
181/// A flat literal is sugar and desugars into the predicate list.
182#[derive(Debug, Clone, Deserialize)]
183#[serde(untagged)]
184pub enum WhenSpec {
185    Predicates(Vec<Predicate>),
186    Flat(Box<FlatWhen>),
187}
188
189impl WhenSpec {
190    pub fn into_predicates(self) -> Vec<Predicate> {
191        match self {
192            Self::Predicates(predicates) => predicates,
193            Self::Flat(flat) => {
194                let mut predicates = Vec::new();
195                if let Some(endpoint) = flat.endpoint {
196                    predicates.push(Predicate::Endpoint(endpoint));
197                }
198                if let Some(stage) = flat.stage {
199                    predicates.push(Predicate::Stage(stage));
200                }
201                if let Some(value) = flat.student_number {
202                    predicates.push(Predicate::StudentNumber(value));
203                }
204                if let Some(value) = flat.course_code {
205                    predicates.push(Predicate::CourseCode(value));
206                }
207                if let Some(value) = flat.owner {
208                    predicates.push(Predicate::Owner(value));
209                }
210                predicates
211            }
212        }
213    }
214}
215
216#[derive(Debug, Clone, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct FaultSpec {
219    pub id: String,
220    pub when: WhenSpec,
221    pub then: Effect,
222    #[serde(default)]
223    pub lifetime: Lifetime,
224    /// Required to arm the one combination that would otherwise pin a double submission into a
225    /// green build.
226    #[serde(default)]
227    pub proves_double_submission: bool,
228}
229
230/// An armed fault. Immutable: re-arming an id replaces the value and re-mints its `seq`.
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct Fault {
234    pub id: String,
235    /// Arm order, which is precedence: first match wins.
236    pub seq: u64,
237    pub when: Vec<Predicate>,
238    pub then: Effect,
239    pub lifetime: Lifetime,
240    pub proves_double_submission: bool,
241    pub owner: Option<ResolvedOwner>,
242    pub parallel_safe: bool,
243    pub armed_at: DateTime<Utc>,
244}
245
246impl Fault {
247    pub fn endpoint(&self) -> Option<Endpoint> {
248        self.when.iter().find_map(|predicate| match predicate {
249            Predicate::Endpoint(endpoint) => Some(*endpoint),
250            _ => None,
251        })
252    }
253
254    pub fn stage(&self) -> Option<Stage> {
255        self.when.iter().find_map(|predicate| match predicate {
256            Predicate::Stage(stage) => Some(*stage),
257            _ => None,
258        })
259    }
260
261    pub fn has_owner_key(&self) -> bool {
262        self.when.iter().any(Predicate::is_owner_key)
263    }
264}
265
266/// The address keys one request item carries, after the working-set load filled in what the wire
267/// did not.
268#[derive(Debug, Clone, Default, PartialEq, Eq)]
269pub struct ItemAddress {
270    pub request_item_id: String,
271    pub student_number: Option<String>,
272    pub course_code: Option<String>,
273    pub submitted_attainment_id: Option<String>,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub enum FaultMatch {
278    Fires,
279    /// The key of the predicate that failed.
280    Missed(&'static str),
281}
282
283pub fn matches_item(
284    fault: &Fault,
285    endpoint: Endpoint,
286    stage: Stage,
287    item: &ItemAddress,
288) -> FaultMatch {
289    for predicate in &fault.when {
290        let satisfied = match predicate {
291            Predicate::Endpoint(wanted) => *wanted == endpoint,
292            Predicate::Stage(wanted) => *wanted == stage,
293            Predicate::StudentNumber(wanted) => item.student_number.as_deref() == Some(wanted),
294            Predicate::CourseCode(wanted) => item.course_code.as_deref() == Some(wanted),
295            Predicate::Owner(_) => fault
296                .owner
297                .as_ref()
298                .is_some_and(|owner| owner_matches(owner, item)),
299        };
300        if !satisfied {
301            return FaultMatch::Missed(predicate.key());
302        }
303    }
304    FaultMatch::Fires
305}
306
307/// A request-shaped effect fires only when **every** item resolves to the fault's owner: on a mixed
308/// batch it would otherwise kill rows nobody armed anything for. List-by-course and
309/// `malformedRequest` are the exceptions, because there Suotar itself fails the whole request for
310/// any one item: a code whose lookup fails, or an item it cannot validate.
311pub fn matches_request(
312    fault: &Fault,
313    endpoint: Endpoint,
314    stage: Stage,
315    items: &[ItemAddress],
316) -> FaultMatch {
317    if !fault.has_owner_key() {
318        return matches_item(fault, endpoint, stage, &ItemAddress::default());
319    }
320    if items.is_empty() {
321        return FaultMatch::Missed("owner");
322    }
323    let any_item_suffices = endpoint == Endpoint::ListByCourse
324        || matches!(&fault.then, Effect::RequestLevel { code, .. } if code == "malformedRequest");
325    let mut missed = None;
326    for item in items {
327        match matches_item(fault, endpoint, stage, item) {
328            FaultMatch::Fires if any_item_suffices => return FaultMatch::Fires,
329            FaultMatch::Fires => {}
330            FaultMatch::Missed(predicate) => missed = Some(predicate),
331        }
332    }
333    match missed {
334        Some(predicate) => FaultMatch::Missed(predicate),
335        None => FaultMatch::Fires,
336    }
337}
338
339/// Each half constrains only the keys the item carries, so on `list-by-course` — which carries no
340/// student number — the course half alone decides.
341fn owner_matches(owner: &ResolvedOwner, item: &ItemAddress) -> bool {
342    let mut constrained = false;
343    if owner.user.is_some()
344        && let Some(student_number) = &item.student_number
345    {
346        if !owner.student_numbers.contains(student_number) {
347            return false;
348        }
349        constrained = true;
350    }
351    if owner.course.is_some()
352        && let Some(course_code) = &item.course_code
353    {
354        if !owner.course_codes.contains(course_code) {
355            return false;
356        }
357        constrained = true;
358    }
359    constrained
360}
361
362pub struct FaultProblem {
363    pub code: String,
364    pub message: String,
365}
366
367impl FaultProblem {
368    fn new(code: &str, message: String) -> Self {
369        Self {
370            code: code.to_string(),
371            message,
372        }
373    }
374}
375
376/// What an endpoint can resolve, not what its body carries: verify's body holds only a submitted
377/// attainment id, and the working set reads the person behind it.
378fn resolvable_keys(endpoint: Endpoint) -> &'static [&'static str] {
379    match endpoint {
380        Endpoint::ResolvePersons => &["studentNumber", "owner"],
381        Endpoint::ResolveEnrolments | Endpoint::ImportAttainments | Endpoint::VerifyAttainments => {
382            &["studentNumber", "courseCode", "owner"]
383        }
384        Endpoint::ListByCourse | Endpoint::ValidateCourseCodes => &["courseCode", "owner"],
385    }
386}
387
388/// The one code that tells a client nothing was done and to retry. Suotar sends it only for a whole
389/// request.
390const TRANSIENT_CODE: &str = "serviceTemporarilyUnavailable";
391
392/// Rejects a fault that could never fire, and the one combination that would fire and be wrong.
393pub fn validate(
394    predicates: &[Predicate],
395    effect: &Effect,
396    proves_double_submission: bool,
397) -> Result<(Endpoint, Stage), FaultProblem> {
398    let mut endpoint = None;
399    let mut stage = None;
400    let mut seen = Vec::new();
401    for predicate in predicates {
402        if seen.contains(&predicate.key()) {
403            return Err(FaultProblem::new(
404                "invalidFault",
405                format!("The predicate `{}` is given twice.", predicate.key()),
406            ));
407        }
408        seen.push(predicate.key());
409        match predicate {
410            Predicate::Endpoint(value) => endpoint = Some(*value),
411            Predicate::Stage(value) => stage = Some(*value),
412            _ => {}
413        }
414    }
415    let Some(endpoint) = endpoint else {
416        return Err(FaultProblem::new(
417            "invalidFault",
418            "A fault must name an `endpoint`.".to_string(),
419        ));
420    };
421    let Some(stage) = stage else {
422        return Err(FaultProblem::new(
423            "invalidFault",
424            format!(
425                "A fault must name a `stage`, one of {}. There is no default, because a fault at a post-commit stage means something different from the same fault before the write.",
426                Stage::ALL
427                    .iter()
428                    .map(|s| s.as_str())
429                    .collect::<Vec<_>>()
430                    .join(", ")
431            ),
432        ));
433    };
434
435    let resolvable = resolvable_keys(endpoint);
436    for predicate in predicates {
437        let key = predicate.key();
438        if matches!(key, "endpoint" | "stage") {
439            continue;
440        }
441        if !resolvable.contains(&key) {
442            return Err(FaultProblem::new(
443                "invalidFault",
444                format!(
445                    "`{key}` cannot be resolved on this endpoint. It resolves: {}.",
446                    resolvable.join(", ")
447                ),
448            ));
449        }
450    }
451
452    if let Some(Predicate::Owner(owner)) = predicates
453        .iter()
454        .find(|predicate| matches!(predicate, Predicate::Owner(_)))
455        && owner.is_empty()
456    {
457        return Err(FaultProblem::new(
458            "invalidFault",
459            "`owner` must name a user, a course, or both.".to_string(),
460        ));
461    }
462
463    if !effect.is_request_shaped() && stage.is_pre_load() {
464        return Err(FaultProblem::new(
465            "invalidFault",
466            format!(
467                "An item-level effect has no item to attach to at `{}`, which is decided before the body is read. Use `resolve`, `afterWrite` or `respond`.",
468                stage.as_str()
469            ),
470        ));
471    }
472
473    if matches!(effect, Effect::DropItem) && stage != Stage::Respond {
474        return Err(FaultProblem::new(
475            "invalidFault",
476            "`dropItem` shapes the response, so it only fires at `respond`.".to_string(),
477        ));
478    }
479
480    // Naming an unexpected code is allowed on purpose; only the transient class is refused, because
481    // it is the one that would teach a client to retry a body Suotar could never have sent.
482    if matches!(effect, Effect::ItemLevel { .. }) && effect.code() == Some(TRANSIENT_CODE) {
483        return Err(FaultProblem::new(
484            "invalidFault",
485            format!(
486                "Suotar has no item-level `{TRANSIENT_CODE}`; it can only fail the whole request that way. Use a `requestLevel` effect."
487            ),
488        ));
489    }
490
491    if endpoint == Endpoint::ImportAttainments
492        && stage.is_post_commit()
493        && effect.code() == Some(TRANSIENT_CODE)
494        && !proves_double_submission
495    {
496        return Err(FaultProblem::new(
497            "refusedFault",
498            format!(
499                "An import answered with `{}` after the write has committed holds the attainment and tells the client to retry, which is the double submission. Set `provesDoubleSubmission: true` if that is what the spec is proving.",
500                effect.code().unwrap_or_default()
501            ),
502        ));
503    }
504
505    Ok((endpoint, stage))
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    fn predicates(stage: Stage) -> Vec<Predicate> {
513        vec![
514            Predicate::Endpoint(Endpoint::ImportAttainments),
515            Predicate::Stage(stage),
516        ]
517    }
518
519    fn transient(item_level: bool) -> Effect {
520        if item_level {
521            Effect::ItemLevel {
522                code: TRANSIENT_CODE.to_string(),
523                message: None,
524            }
525        } else {
526            Effect::RequestLevel {
527                status: 503,
528                code: TRANSIENT_CODE.to_string(),
529                message: None,
530            }
531        }
532    }
533
534    #[test]
535    fn a_retryable_code_after_the_import_write_is_refused_unless_it_is_the_point() {
536        for stage in [Stage::AfterWrite, Stage::Respond] {
537            assert!(
538                validate(&predicates(stage), &transient(false), false).is_err(),
539                "{stage:?} was not refused"
540            );
541            assert!(validate(&predicates(stage), &transient(false), true).is_ok());
542        }
543        // Before the write there is no attainment being held, so nothing needs excusing.
544        assert!(validate(&predicates(Stage::RequestGate), &transient(false), false).is_ok());
545    }
546
547    /// The double-submission flag excuses a double submission, not a response shape Suotar cannot
548    /// produce.
549    #[test]
550    fn an_item_level_transient_is_refused_everywhere() {
551        for endpoint in [
552            Endpoint::ImportAttainments,
553            Endpoint::ResolveEnrolments,
554            Endpoint::VerifyAttainments,
555            Endpoint::ListByCourse,
556        ] {
557            let when = vec![
558                Predicate::Endpoint(endpoint),
559                Predicate::Stage(Stage::Resolve),
560            ];
561            let problem = validate(&when, &transient(true), true)
562                .err()
563                .unwrap_or_else(|| panic!("{endpoint:?} accepted an impossible item code"));
564            assert!(problem.message.contains("requestLevel"));
565            assert!(validate(&when, &transient(false), false).is_ok());
566        }
567    }
568
569    #[test]
570    fn a_key_the_endpoint_cannot_resolve_is_refused_and_an_indirect_one_is_not() {
571        let unresolvable = vec![
572            Predicate::Endpoint(Endpoint::ListByCourse),
573            Predicate::Stage(Stage::Resolve),
574            Predicate::StudentNumber("090001019".to_string()),
575        ];
576        let problem = validate(&unresolvable, &transient(false), false)
577            .expect_err("list-by-course carries no student number");
578        assert!(problem.message.contains("courseCode"));
579
580        let indirect = vec![
581            Predicate::Endpoint(Endpoint::VerifyAttainments),
582            Predicate::Stage(Stage::Resolve),
583            Predicate::Owner(OwnerRef {
584                user: Some("someone@example.com".to_string()),
585                course: None,
586            }),
587        ];
588        assert!(validate(&indirect, &transient(false), false).is_ok());
589    }
590}