1use 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 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}
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 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 ItemLevel {
113 code: String,
114 message: Option<String>,
115 },
116 RequestLevel {
117 status: u16,
118 code: String,
119 message: Option<String>,
120 },
121 #[serde(rename_all = "camelCase")]
123 RawBody {
124 status: u16,
125 body: String,
126 content_type: Option<String>,
127 },
128 ConnectionReset,
129 DropItem,
131}
132
133impl Effect {
134 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#[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 #[serde(default)]
227 pub proves_double_submission: bool,
228}
229
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct Fault {
234 pub id: String,
235 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#[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 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
307pub 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
339fn 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
376fn 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
388const TRANSIENT_CODE: &str = "serviceTemporarilyUnavailable";
391
392pub 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 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 assert!(validate(&predicates(Stage::RequestGate), &transient(false), false).is_ok());
545 }
546
547 #[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}