1use headless_lms_utils::services::suotar::SuotarEndpoint;
7
8use crate::prelude::*;
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 pub fn is_post_commit(self) -> bool {
33 matches!(self, Self::AfterWrite | Self::Respond)
34 }
35
36 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#[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 pub product_ids: Vec<String>,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub enum Predicate {
81 Endpoint(SuotarEndpoint),
82 Stage(Stage),
83 StudentNumber(String),
84 CourseCode(String),
85 Owner(OwnerRef),
86}
87
88impl Predicate {
89 pub fn key(&self) -> &'static str {
90 match self {
91 Self::Endpoint(_) => "endpoint",
92 Self::Stage(_) => "stage",
93 Self::StudentNumber(_) => "studentNumber",
94 Self::CourseCode(_) => "courseCode",
95 Self::Owner(_) => "owner",
96 }
97 }
98
99 fn is_owner_key(&self) -> bool {
101 matches!(
102 self,
103 Self::StudentNumber(_) | Self::CourseCode(_) | Self::Owner(_)
104 )
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase", tag = "kind")]
110pub enum Effect {
111 #[serde(rename_all = "camelCase")]
112 ItemLevel {
113 code: String,
114 message: Option<String>,
115 #[serde(default)]
116 disclose_submitted_attainment_id: bool,
117 },
118 RequestLevel {
119 status: u16,
120 code: String,
121 message: Option<String>,
122 },
123 ConnectionReset,
124}
125
126impl Effect {
127 pub fn is_request_shaped(&self) -> bool {
130 !matches!(self, Self::ItemLevel { .. })
131 }
132
133 pub fn code(&self) -> Option<&str> {
134 match self {
135 Self::ItemLevel { code, .. } | Self::RequestLevel { code, .. } => Some(code),
136 Self::ConnectionReset => None,
137 }
138 }
139
140 pub fn kind(&self) -> &'static str {
141 match self {
142 Self::ItemLevel { .. } => "itemLevel",
143 Self::RequestLevel { .. } => "requestLevel",
144 Self::ConnectionReset => "connectionReset",
145 }
146 }
147}
148
149#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct Lifetime {
152 pub matching_calls: Option<u32>,
153 pub matching_items: Option<u32>,
154}
155
156impl Lifetime {
157 pub fn budget(&self) -> Option<u32> {
158 self.matching_calls.or(self.matching_items)
159 }
160}
161
162#[derive(Debug, Clone, Default, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct FlatWhen {
165 pub endpoint: Option<SuotarEndpoint>,
166 pub stage: Option<Stage>,
167 pub student_number: Option<String>,
168 pub course_code: Option<String>,
169 pub owner: Option<OwnerRef>,
170}
171
172#[derive(Debug, Clone, Deserialize)]
174#[serde(untagged)]
175pub enum WhenSpec {
176 Predicates(Vec<Predicate>),
177 Flat(Box<FlatWhen>),
178}
179
180impl WhenSpec {
181 pub fn into_predicates(self) -> Vec<Predicate> {
182 match self {
183 Self::Predicates(predicates) => predicates,
184 Self::Flat(flat) => {
185 let mut predicates = Vec::new();
186 if let Some(endpoint) = flat.endpoint {
187 predicates.push(Predicate::Endpoint(endpoint));
188 }
189 if let Some(stage) = flat.stage {
190 predicates.push(Predicate::Stage(stage));
191 }
192 if let Some(value) = flat.student_number {
193 predicates.push(Predicate::StudentNumber(value));
194 }
195 if let Some(value) = flat.course_code {
196 predicates.push(Predicate::CourseCode(value));
197 }
198 if let Some(value) = flat.owner {
199 predicates.push(Predicate::Owner(value));
200 }
201 predicates
202 }
203 }
204 }
205}
206
207#[derive(Debug, Clone, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct FaultSpec {
210 pub id: String,
211 pub when: WhenSpec,
212 pub then: Effect,
213 #[serde(default)]
214 pub lifetime: Lifetime,
215 #[serde(default)]
218 pub proves_double_submission: bool,
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct Fault {
225 pub id: String,
226 pub seq: u64,
228 pub when: Vec<Predicate>,
229 pub then: Effect,
230 pub lifetime: Lifetime,
231 pub proves_double_submission: bool,
232 pub owner: Option<ResolvedOwner>,
233 pub parallel_safe: bool,
234 pub armed_at: DateTime<Utc>,
235}
236
237impl Fault {
238 pub fn endpoint(&self) -> Option<SuotarEndpoint> {
239 self.when.iter().find_map(|predicate| match predicate {
240 Predicate::Endpoint(endpoint) => Some(*endpoint),
241 _ => None,
242 })
243 }
244
245 pub fn stage(&self) -> Option<Stage> {
246 self.when.iter().find_map(|predicate| match predicate {
247 Predicate::Stage(stage) => Some(*stage),
248 _ => None,
249 })
250 }
251
252 pub fn has_owner_key(&self) -> bool {
253 self.when.iter().any(Predicate::is_owner_key)
254 }
255}
256
257#[derive(Debug, Clone, Default, PartialEq, Eq)]
260pub struct ItemAddress {
261 pub request_item_id: String,
262 pub student_number: Option<String>,
263 pub course_code: Option<String>,
264 pub product_id: Option<String>,
265 pub submitted_attainment_id: Option<String>,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum FaultMatch {
270 Fires,
271 Missed(&'static str),
273}
274
275pub fn matches_item(
276 fault: &Fault,
277 endpoint: SuotarEndpoint,
278 stage: Stage,
279 item: &ItemAddress,
280) -> FaultMatch {
281 for predicate in &fault.when {
282 let satisfied = match predicate {
283 Predicate::Endpoint(wanted) => *wanted == endpoint,
284 Predicate::Stage(wanted) => *wanted == stage,
285 Predicate::StudentNumber(wanted) => item.student_number.as_deref() == Some(wanted),
286 Predicate::CourseCode(wanted) => item.course_code.as_deref() == Some(wanted),
287 Predicate::Owner(_) => fault
288 .owner
289 .as_ref()
290 .is_some_and(|owner| owner_matches(owner, item)),
291 };
292 if !satisfied {
293 return FaultMatch::Missed(predicate.key());
294 }
295 }
296 FaultMatch::Fires
297}
298
299pub fn matches_request(
302 fault: &Fault,
303 endpoint: SuotarEndpoint,
304 stage: Stage,
305 items: &[ItemAddress],
306) -> FaultMatch {
307 if !fault.has_owner_key() {
308 return matches_item(fault, endpoint, stage, &ItemAddress::default());
309 }
310 if items.is_empty() {
311 return FaultMatch::Missed("owner");
312 }
313 let mut missed = None;
314 for item in items {
315 match matches_item(fault, endpoint, stage, item) {
316 FaultMatch::Fires => {}
317 FaultMatch::Missed(predicate) => missed = Some(predicate),
318 }
319 }
320 match missed {
321 Some(predicate) => FaultMatch::Missed(predicate),
322 None => FaultMatch::Fires,
323 }
324}
325
326fn owner_matches(owner: &ResolvedOwner, item: &ItemAddress) -> bool {
329 let mut constrained = false;
330 if owner.user.is_some()
331 && let Some(student_number) = &item.student_number
332 {
333 if !owner.student_numbers.contains(student_number) {
334 return false;
335 }
336 constrained = true;
337 }
338 if owner.course.is_some() {
339 if let Some(course_code) = &item.course_code {
340 if !owner.course_codes.contains(course_code) {
341 return false;
342 }
343 constrained = true;
344 }
345 if let Some(product_id) = &item.product_id {
346 if !owner.product_ids.contains(product_id) {
347 return false;
348 }
349 constrained = true;
350 }
351 }
352 constrained
353}
354
355pub struct FaultProblem {
356 pub code: String,
357 pub message: String,
358}
359
360impl FaultProblem {
361 fn new(code: &str, message: String) -> Self {
362 Self {
363 code: code.to_string(),
364 message,
365 }
366 }
367}
368
369fn resolvable_keys(endpoint: SuotarEndpoint) -> &'static [&'static str] {
372 match endpoint {
373 SuotarEndpoint::ResolvePersons => &["studentNumber", "owner"],
374 SuotarEndpoint::ResolveEnrolments
375 | SuotarEndpoint::ImportAttainments
376 | SuotarEndpoint::VerifyAttainments => &["studentNumber", "courseCode", "owner"],
377 SuotarEndpoint::ProductAccessTokens => &["owner"],
378 SuotarEndpoint::ListByCourse => &["courseCode", "owner"],
379 }
380}
381
382fn is_retryable_transient_code(code: &str) -> bool {
385 headless_lms_models::library::credit_registration::classification::is_retryable_transient_wire_code(
386 code,
387 )
388}
389
390fn carries_item_level_transient(endpoint: SuotarEndpoint) -> bool {
393 matches!(
394 endpoint,
395 SuotarEndpoint::ResolvePersons
396 | SuotarEndpoint::VerifyAttainments
397 | SuotarEndpoint::ProductAccessTokens
398 | SuotarEndpoint::ListByCourse
399 )
400}
401
402pub fn validate(
404 predicates: &[Predicate],
405 effect: &Effect,
406 proves_double_submission: bool,
407) -> Result<(SuotarEndpoint, Stage), FaultProblem> {
408 let mut endpoint = None;
409 let mut stage = None;
410 let mut seen = Vec::new();
411 for predicate in predicates {
412 if seen.contains(&predicate.key()) {
413 return Err(FaultProblem::new(
414 "invalidFault",
415 format!("The predicate `{}` is given twice.", predicate.key()),
416 ));
417 }
418 seen.push(predicate.key());
419 match predicate {
420 Predicate::Endpoint(value) => endpoint = Some(*value),
421 Predicate::Stage(value) => stage = Some(*value),
422 _ => {}
423 }
424 }
425 let Some(endpoint) = endpoint else {
426 return Err(FaultProblem::new(
427 "invalidFault",
428 "A fault must name an `endpoint`.".to_string(),
429 ));
430 };
431 let Some(stage) = stage else {
432 return Err(FaultProblem::new(
433 "invalidFault",
434 format!(
435 "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.",
436 Stage::ALL
437 .iter()
438 .map(|s| s.as_str())
439 .collect::<Vec<_>>()
440 .join(", ")
441 ),
442 ));
443 };
444
445 let resolvable = resolvable_keys(endpoint);
446 for predicate in predicates {
447 let key = predicate.key();
448 if matches!(key, "endpoint" | "stage") {
449 continue;
450 }
451 if !resolvable.contains(&key) {
452 return Err(FaultProblem::new(
453 "invalidFault",
454 format!(
455 "`{key}` cannot be resolved on this endpoint. It resolves: {}.",
456 resolvable.join(", ")
457 ),
458 ));
459 }
460 }
461
462 if let Some(Predicate::Owner(owner)) = predicates
463 .iter()
464 .find(|predicate| matches!(predicate, Predicate::Owner(_)))
465 && owner.is_empty()
466 {
467 return Err(FaultProblem::new(
468 "invalidFault",
469 "`owner` must name a user, a course, or both.".to_string(),
470 ));
471 }
472
473 if matches!(effect, Effect::ItemLevel { .. }) && stage.is_pre_load() {
474 return Err(FaultProblem::new(
475 "invalidFault",
476 format!(
477 "An item-level effect has no item to attach to at `{}`, which is decided before the body is read. Use `resolve`, `afterWrite` or `respond`.",
478 stage.as_str()
479 ),
480 ));
481 }
482
483 if matches!(effect, Effect::ItemLevel { .. })
486 && effect.code().is_some_and(is_retryable_transient_code)
487 && !carries_item_level_transient(endpoint)
488 {
489 return Err(FaultProblem::new(
490 "invalidFault",
491 format!(
492 "This endpoint carries no item-level `{}`; Suotar can only fail the whole request that way. Use a `requestLevel` effect.",
493 effect.code().unwrap_or_default()
494 ),
495 ));
496 }
497
498 if endpoint == SuotarEndpoint::ImportAttainments
499 && stage.is_post_commit()
500 && effect.code().is_some_and(is_retryable_transient_code)
501 && !proves_double_submission
502 {
503 return Err(FaultProblem::new(
504 "refusedFault",
505 format!(
506 "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.",
507 effect.code().unwrap_or_default()
508 ),
509 ));
510 }
511
512 Ok((endpoint, stage))
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 fn predicates(stage: Stage) -> Vec<Predicate> {
520 vec![
521 Predicate::Endpoint(SuotarEndpoint::ImportAttainments),
522 Predicate::Stage(stage),
523 ]
524 }
525
526 fn transient(item_level: bool) -> Effect {
527 if item_level {
528 Effect::ItemLevel {
529 code: "sisuTemporarilyUnavailable".to_string(),
530 message: None,
531 disclose_submitted_attainment_id: false,
532 }
533 } else {
534 Effect::RequestLevel {
535 status: 503,
536 code: "sisuTemporarilyUnavailable".to_string(),
537 message: None,
538 }
539 }
540 }
541
542 #[test]
543 fn a_retryable_code_after_the_import_write_is_refused_unless_it_is_the_point() {
544 for stage in [Stage::AfterWrite, Stage::Respond] {
545 assert!(
546 validate(&predicates(stage), &transient(false), false).is_err(),
547 "{stage:?} was not refused"
548 );
549 assert!(validate(&predicates(stage), &transient(false), true).is_ok());
550 }
551 assert!(validate(&predicates(Stage::RequestGate), &transient(false), false).is_ok());
553 }
554
555 #[test]
558 fn an_item_level_transient_is_refused_where_the_contract_carries_none() {
559 for endpoint in [
560 SuotarEndpoint::ImportAttainments,
561 SuotarEndpoint::ResolveEnrolments,
562 ] {
563 for stage in [Stage::Resolve, Stage::AfterWrite, Stage::Respond] {
564 let when = vec![Predicate::Endpoint(endpoint), Predicate::Stage(stage)];
565 let problem = validate(&when, &transient(true), true)
566 .err()
567 .unwrap_or_else(|| {
568 panic!("{endpoint:?} at {stage:?} accepted an impossible item code")
569 });
570 assert!(problem.message.contains("requestLevel"));
571 assert!(
572 validate(&when, &transient(false), stage.is_post_commit()).is_ok(),
573 "the request-level form is the way to drive it"
574 );
575 }
576 }
577 for endpoint in [
578 SuotarEndpoint::ResolvePersons,
579 SuotarEndpoint::VerifyAttainments,
580 SuotarEndpoint::ProductAccessTokens,
581 SuotarEndpoint::ListByCourse,
582 ] {
583 let when = vec![
584 Predicate::Endpoint(endpoint),
585 Predicate::Stage(Stage::Resolve),
586 ];
587 assert!(
588 validate(&when, &transient(true), false).is_ok(),
589 "{endpoint:?} does carry the transient item code"
590 );
591 }
592 }
593
594 #[test]
595 fn a_key_the_endpoint_cannot_resolve_is_refused_and_an_indirect_one_is_not() {
596 let unresolvable = vec![
597 Predicate::Endpoint(SuotarEndpoint::ListByCourse),
598 Predicate::Stage(Stage::Resolve),
599 Predicate::StudentNumber("900000101".to_string()),
600 ];
601 let problem = validate(&unresolvable, &transient(true), false)
602 .expect_err("list-by-course carries no student number");
603 assert!(problem.message.contains("courseCode"));
604
605 let indirect = vec![
606 Predicate::Endpoint(SuotarEndpoint::VerifyAttainments),
607 Predicate::Stage(Stage::Resolve),
608 Predicate::Owner(OwnerRef {
609 user: Some("someone@example.com".to_string()),
610 course: None,
611 }),
612 ];
613 assert!(validate(&indirect, &transient(true), false).is_ok());
614 }
615}