1use std::collections::{BTreeSet, HashMap};
10
11use futures::StreamExt;
12use itertools::Itertools;
13use serde::de::DeserializeOwned;
14use sqlx::PgPool;
15
16use crate::prelude::*;
17
18use crate::mock_suotar::default_world;
19use crate::mock_suotar::faults::{
20 Effect, Fault, FaultMatch, ItemAddress, Stage, matches_item, matches_request,
21};
22use crate::mock_suotar::logic::{self, ImportResolution};
23use crate::mock_suotar::store::{MockSuotarStore, Preamble};
24use crate::mock_suotar::wire::{
25 self, Endpoint, ItemStatus, NOT_AN_ARRAY, RequestLevelError, ResponseItem, SubmittedAttainment,
26};
27use crate::mock_suotar::world::{
28 MissedFault, MockSubmission, RecordedCall, RecordedFaults, RecordedItem, SendState, WorkingSet,
29 WorldWrite,
30};
31
32const RAW_BODY_LIMIT: usize = 8 * 1024;
33
34const MAX_BODY_BYTES: usize = 5 * 1024 * 1024;
36
37const IMPORT_STRING_FIELDS: [&str; 7] = [
38 "studentNumber",
39 "courseCode",
40 "enrolmentId",
41 "attainmentDate",
42 "attainmentLanguage",
43 "gradeScaleId",
44 "gradeId",
45];
46
47macro_rules! endpoint_handlers {
49 ($($handler:ident => $endpoint:ident,)*) => {
50 $(
51 pub async fn $handler(
52 app_conf: web::Data<ApplicationConfiguration>,
53 store: web::Data<MockSuotarStore>,
54 pool: web::Data<PgPool>,
55 req: HttpRequest,
56 payload: web::Payload,
57 ) -> ControllerResult<HttpResponse> {
58 endpoint(Endpoint::$endpoint, app_conf, store, pool, req, payload).await
59 }
60 )*
61 };
62}
63
64endpoint_handlers! {
65 resolve_persons => ResolvePersons,
66 resolve_enrolments => ResolveEnrolments,
67 list_by_course => ListByCourse,
68 import_attainments => ImportAttainments,
69 verify_attainments => VerifyAttainments,
70 validate_course_codes => ValidateCourseCodes,
71}
72
73async fn endpoint(
74 endpoint: Endpoint,
75 app_conf: web::Data<ApplicationConfiguration>,
76 store: web::Data<MockSuotarStore>,
77 pool: web::Data<PgPool>,
78 req: HttpRequest,
79 payload: web::Payload,
80) -> ControllerResult<HttpResponse> {
81 super::assert_enabled(&app_conf);
82 let token = skip_authorize();
83 let body = read_body(payload).await;
84 let delivery = match run(endpoint, &store, &pool, &req, &body).await {
85 Ok(delivery) => delivery,
86 Err(error) => {
87 error!("mock Suotar failed to serve a request: {error:?}");
89 Delivery::json(500, &RequestLevelError::new("internalError"))
90 }
91 };
92 token.authorized_ok(deliver(delivery))
93}
94
95pub async fn fall_through(
98 app_conf: web::Data<ApplicationConfiguration>,
99 store: web::Data<MockSuotarStore>,
100 pool: web::Data<PgPool>,
101 req: HttpRequest,
102 payload: web::Payload,
103) -> ControllerResult<HttpResponse> {
104 super::assert_enabled(&app_conf);
105 let token = skip_authorize();
106 let body = read_body(payload).await;
107 let delivery = match resolve_world(&store, &pool).await {
108 Ok((_, preamble)) => {
109 match pre_route_rejection(&parse_body(&req, &body), authorized(&req, &preamble)) {
110 Some((status, error)) => Delivery::json(status, &error),
111 None => Delivery::json(401, &serde_json::json!({ "error": "Unauthorized access" })),
112 }
113 }
114 Err(error) => {
115 error!("mock Suotar failed to serve a request: {error:?}");
116 Delivery::json(500, &RequestLevelError::new("internalError"))
117 }
118 };
119 token.authorized_ok(deliver(delivery))
120}
121
122fn deliver(delivery: Delivery) -> HttpResponse {
123 let status_of = |status: u16| {
124 actix_web::http::StatusCode::from_u16(status).unwrap_or(actix_web::http::StatusCode::OK)
125 };
126 match delivery {
127 Delivery::Json { status, body } => HttpResponse::build(status_of(status))
128 .content_type("application/json")
129 .body(body),
130 Delivery::Raw {
131 status,
132 body,
133 content_type,
134 } => HttpResponse::build(status_of(status))
135 .content_type(content_type)
136 .body(body),
137 Delivery::ConnectionReset => {
138 let stream = futures::stream::once(async {
139 Err::<web::Bytes, actix_web::Error>(actix_web::error::ErrorInternalServerError(
140 "mock Suotar dropped the connection",
141 ))
142 });
143 HttpResponse::Ok().streaming(stream)
144 }
145 }
146}
147
148enum Delivery {
149 Json {
150 status: u16,
151 body: String,
152 },
153 Raw {
154 status: u16,
155 body: String,
156 content_type: String,
157 },
158 ConnectionReset,
159}
160
161impl Delivery {
162 fn json<T: Serialize>(status: u16, value: &T) -> Self {
163 Self::Json {
164 status,
165 body: serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()),
166 }
167 }
168}
169
170enum Body {
171 Read(Vec<u8>),
172 TooLarge,
173 Broken,
174}
175
176async fn read_body(mut payload: web::Payload) -> Body {
178 let mut bytes = Vec::new();
179 while let Some(chunk) = payload.next().await {
180 let Ok(chunk) = chunk else {
181 return Body::Broken;
182 };
183 if bytes.len() + chunk.len() > MAX_BODY_BYTES {
184 return Body::TooLarge;
185 }
186 bytes.extend_from_slice(&chunk);
187 }
188 Body::Read(bytes)
189}
190
191enum ParsedBody {
193 Json(serde_json::Value),
194 NotJson,
196 TooLarge,
197 Invalid,
198}
199
200fn parse_body(req: &HttpRequest, body: &Body) -> ParsedBody {
201 let is_json = req
202 .headers()
203 .get(actix_web::http::header::CONTENT_TYPE)
204 .and_then(|value| value.to_str().ok())
205 .and_then(|value| value.split(';').next())
206 .is_some_and(|essence| essence.trim().eq_ignore_ascii_case("application/json"));
207 if !is_json {
208 return ParsedBody::NotJson;
209 }
210 match body {
211 Body::TooLarge => ParsedBody::TooLarge,
212 Body::Broken => ParsedBody::Invalid,
213 Body::Read(bytes) if bytes.is_empty() => ParsedBody::Json(serde_json::json!({})),
214 Body::Read(bytes) => match serde_json::from_slice::<serde_json::Value>(bytes) {
216 Ok(value) if value.is_object() || value.is_array() => ParsedBody::Json(value),
217 _ => ParsedBody::Invalid,
218 },
219 }
220}
221
222fn pre_route_rejection(
224 parsed: &ParsedBody,
225 is_authorized: bool,
226) -> Option<(u16, RequestLevelError)> {
227 match parsed {
228 ParsedBody::TooLarge => Some((413, RequestLevelError::new("requestTooLarge"))),
229 ParsedBody::Invalid => Some((
230 400,
231 RequestLevelError::with_message(
232 "malformedRequest",
233 "Request body is not valid JSON.".to_string(),
234 ),
235 )),
236 _ if !is_authorized => Some((401, RequestLevelError::new("unauthorized"))),
237 _ => None,
238 }
239}
240
241struct Answer {
243 delivery: Delivery,
244 status: u16,
245 request_level_code: Option<String>,
246 effect: Option<String>,
248}
249
250impl Answer {
251 fn json<T: Serialize>(status: u16, body: &T) -> Self {
252 Self {
253 delivery: Delivery::json(status, body),
254 status,
255 request_level_code: None,
256 effect: None,
257 }
258 }
259
260 fn request_level(status: u16, error: &RequestLevelError) -> Self {
261 Self {
262 request_level_code: Some(error.error.code.clone()),
263 ..Self::json(status, error)
264 }
265 }
266}
267
268fn terminal(effect: &Effect) -> Option<Answer> {
270 let answer = match effect {
271 Effect::ConnectionReset => Answer {
272 delivery: Delivery::ConnectionReset,
273 status: 200,
274 request_level_code: None,
275 effect: None,
276 },
277 Effect::RequestLevel {
278 status,
279 code,
280 message,
281 } => Answer::request_level(
282 *status,
283 &match message {
284 Some(message) => RequestLevelError::with_message(code, message.clone()),
285 None => RequestLevelError::new(code),
286 },
287 ),
288 Effect::RawBody {
289 status,
290 body,
291 content_type,
292 } => Answer {
293 delivery: Delivery::Raw {
294 status: *status,
295 body: body.clone(),
296 content_type: content_type
297 .clone()
298 .unwrap_or_else(|| "application/json".to_string()),
299 },
300 status: *status,
301 request_level_code: None,
302 effect: None,
303 },
304 Effect::ItemLevel { .. } | Effect::DropItem => return None,
305 };
306 Some(Answer {
307 effect: Some(effect.kind().to_string()),
308 ..answer
309 })
310}
311
312async fn run(
313 endpoint: Endpoint,
314 store: &MockSuotarStore,
315 pool: &PgPool,
316 req: &HttpRequest,
317 body: &Body,
318) -> anyhow::Result<Delivery> {
319 let now = Utc::now();
320 let (generation, preamble) = resolve_world(store, pool).await?;
321 let mut runner = FaultRunner {
322 store,
323 generation: &generation,
324 faults: &preamble.faults,
325 preamble: &preamble,
326 log: RecordedFaults::default(),
327 };
328 let mut call = RecordedCall {
329 seq: store.next_call_seq(&generation).await?,
330 received_at: now,
331 endpoint,
332 correlation_id: req
333 .headers()
334 .get("X-Correlation-Id")
335 .and_then(|value| value.to_str().ok())
336 .map(str::to_string),
337 authorized: authorized(req, &preamble),
338 http_status: 200,
339 request_level_code: None,
340 effect: None,
341 raw_body_truncated: match body {
342 Body::Read(bytes) => truncate(bytes),
343 Body::TooLarge => "(over the size limit)".to_string(),
344 Body::Broken => "(unreadable)".to_string(),
345 },
346 faults: RecordedFaults::default(),
347 items: Vec::new(),
348 };
349 let mut working = WorkingSet {
350 defaults: preamble.defaults.clone(),
351 ..Default::default()
352 };
353
354 let answer = answer(
355 endpoint,
356 req,
357 body,
358 now,
359 &mut runner,
360 &mut call,
361 &mut working,
362 )
363 .await?;
364 call.faults = runner.log;
365 call.effect = answer.effect;
366 call.http_status = answer.status;
367 call.request_level_code = answer.request_level_code;
368 let capacity = working.defaults.call_log_capacity.max(1);
369 store.commit(&generation, &working, &call, capacity).await?;
370 Ok(answer.delivery)
371}
372
373async fn answer(
375 endpoint: Endpoint,
376 req: &HttpRequest,
377 body: &Body,
378 now: DateTime<Utc>,
379 runner: &mut FaultRunner<'_>,
380 call: &mut RecordedCall,
381 working: &mut WorkingSet,
382) -> anyhow::Result<Answer> {
383 let parsed_body = parse_body(req, body);
384 if let Some((status, error)) = pre_route_rejection(&parsed_body, call.authorized) {
385 return Ok(Answer::request_level(status, &error));
386 }
387 let json = match parsed_body {
388 ParsedBody::Json(value) => Some(value),
389 _ => None,
390 };
391
392 let parsed = match parse_envelope(endpoint, json) {
393 Ok(Some(parsed)) => parsed,
394 Ok(None) => return Ok(Answer::json(200, &Vec::<ResponseItem>::new())),
395 Err(message) => {
396 return Ok(Answer::request_level(
397 400,
398 &RequestLevelError::with_message("malformedRequest", message),
399 ));
400 }
401 };
402
403 let mut addresses = parsed.addresses();
404 load(runner.store, runner.generation, &parsed, working).await?;
405 parsed.enrich_addresses(&mut addresses, working);
406 call.items = addresses
409 .iter()
410 .map(|address| RecordedItem {
411 request_item_id: address.request_item_id.clone(),
412 student_number: address.student_number.clone(),
413 course_code: address.course_code.clone(),
414 submitted_attainment_id: address.submitted_attainment_id.clone(),
415 status: "unanswered".to_string(),
416 code: String::new(),
417 })
418 .collect();
419
420 for stage in [
424 Stage::Auth,
425 Stage::RequestGate,
426 Stage::Parse,
427 Stage::Resolve,
428 ] {
429 if let Some(effect) = runner.request_stage(endpoint, stage, &addresses).await?
430 && let Some(terminal) = terminal(&effect)
431 {
432 call.authorized = stage != Stage::Auth;
433 return Ok(terminal);
434 }
435 }
436
437 let (mut items, written) = match &parsed {
438 ParsedRequest::Import(requests) => {
439 match resolve_import_batch(requests, &addresses, now, runner, working).await? {
440 Ok(resolved) => resolved,
441 Err(rejection) => return Ok(rejection),
442 }
443 }
444 ParsedRequest::PerItem(request) => {
445 let mut items = Vec::with_capacity(addresses.len());
446 for (index, address) in addresses.iter().enumerate() {
447 let fault = runner.item_stage(endpoint, Stage::Resolve, address).await?;
448 items.push(match fault {
449 Some(effect) => item_effect_response(endpoint, address, &effect),
450 None => request.resolve(index, working, now),
451 });
452 }
453 (items, vec![None; addresses.len()])
454 }
455 };
456
457 let mut answered_by_fault: Option<Answer> = None;
460 let mut dropped = vec![false; addresses.len()];
461 for stage in [Stage::AfterWrite, Stage::Respond] {
462 if let Some(effect) = runner.request_stage(endpoint, stage, &addresses).await? {
463 answered_by_fault = terminal(&effect);
464 }
465 for (index, address) in addresses.iter().enumerate() {
466 let Some(effect) = runner.item_stage(endpoint, stage, address).await? else {
467 continue;
468 };
469 if effect == Effect::DropItem {
470 dropped[index] = true;
471 continue;
472 }
473 let Some(item) = items.get_mut(index) else {
474 continue;
475 };
476 let response = item_effect_response(endpoint, address, &effect);
477 *item = match (&effect, &written[index]) {
478 (Effect::ItemLevel { code, .. }, Some(submission_id)) if code == "sisuTimeout" => {
479 leave_unconfirmed(working, submission_id);
480 response.with_result(SubmittedAttainment::new(submission_id))
481 }
482 _ => response,
483 };
484 }
485 }
486
487 call.items = addresses
488 .iter()
489 .zip(items.iter())
490 .zip(&dropped)
491 .map(|((address, item), is_dropped)| RecordedItem {
492 request_item_id: address.request_item_id.clone(),
493 student_number: address.student_number.clone(),
494 course_code: address.course_code.clone(),
495 submitted_attainment_id: address.submitted_attainment_id.clone(),
496 status: match (is_dropped, item.status) {
497 (true, _) => "dropped".to_string(),
498 (false, ItemStatus::Ok) => "ok".to_string(),
499 (false, ItemStatus::Error) => "error".to_string(),
500 },
501 code: item.code.clone(),
502 })
503 .collect();
504
505 Ok(answered_by_fault.unwrap_or_else(|| {
506 let answered: Vec<&ResponseItem> = items
507 .iter()
508 .zip(&dropped)
509 .filter(|(_, is_dropped)| !**is_dropped)
510 .map(|(item, _)| item)
511 .collect();
512 Answer::json(200, &answered)
513 }))
514}
515
516async fn resolve_import_batch(
519 requests: &[wire::ImportAttainmentRequestItem],
520 addresses: &[ItemAddress],
521 now: DateTime<Utc>,
522 runner: &mut FaultRunner<'_>,
523 working: &mut WorkingSet,
524) -> anyhow::Result<Result<(Vec<ResponseItem>, Vec<Option<String>>), Answer>> {
525 let endpoint = Endpoint::ImportAttainments;
526 let mut slots: Vec<Option<ResponseItem>> = vec![None; addresses.len()];
527 let mut to_write: Vec<(usize, MockSubmission)> = Vec::new();
528 let mut written_in_batch: HashMap<String, String> = HashMap::new();
531 for (index, (address, request)) in addresses.iter().zip(requests).enumerate() {
532 if let Some(effect) = runner.item_stage(endpoint, Stage::Resolve, address).await? {
533 slots[index] = Some(item_effect_response(endpoint, address, &effect));
534 continue;
535 }
536 let completion = completion_key(request);
537 if let Some(first) = written_in_batch.get(&completion) {
538 slots[index] = Some(
539 ResponseItem::error(endpoint, &request.request_item_id, "duplicateRequestItem")
540 .with_result(SubmittedAttainment::new(first)),
541 );
542 continue;
543 }
544 match logic::resolve_import_item(request, working, now) {
545 ImportResolution::Answered(item) => slots[index] = Some(item),
546 ImportResolution::Write(submission) => {
547 written_in_batch.insert(completion, submission.submitted_attainment_id.clone());
548 to_write.push((index, *submission));
549 }
550 }
551 }
552 let submissions: Vec<MockSubmission> = to_write.iter().map(|(_, s)| s.clone()).collect();
553 if logic::acceptor_lookup_fails(working, &submissions) {
554 return Ok(Err(Answer::request_level(
555 503,
556 &RequestLevelError::new("serviceTemporarilyUnavailable"),
557 )));
558 }
559 let mut written: Vec<Option<String>> = vec![None; addresses.len()];
560 for (index, submission) in to_write {
561 written[index] = Some(submission.submitted_attainment_id.clone());
562 slots[index] = Some(logic::write_and_send(working, submission));
563 }
564 let items = addresses
565 .iter()
566 .zip(slots)
567 .map(|(address, slot)| {
568 slot.unwrap_or_else(|| {
569 ResponseItem::error(endpoint, &address.request_item_id, "internalError")
570 })
571 })
572 .collect();
573 Ok(Ok((items, written)))
574}
575
576fn leave_unconfirmed(working: &mut WorkingSet, submission_id: &str) {
578 if let Some(submission) = working.submissions.get_mut(submission_id) {
579 submission.send_state = SendState::Attempted;
580 working
581 .writes
582 .push(WorldWrite::UpsertSubmission(submission_id.to_string()));
583 }
584}
585
586fn completion_key(item: &wire::ImportAttainmentRequestItem) -> String {
588 format!(
589 "{}|{}|{}|{}|{}|{}",
590 item.student_number,
591 item.course_code,
592 item.grade_scale_id,
593 item.grade_id,
594 item.credits,
595 item.attainment_date()
596 )
597}
598
599async fn resolve_world(
600 store: &MockSuotarStore,
601 pool: &PgPool,
602) -> anyhow::Result<(String, Preamble)> {
603 if let Some(generation) = store.live_generation().await? {
604 let preamble = store.preamble(&generation).await?;
605 if preamble.defaults_present {
606 return Ok((generation, preamble));
607 }
608 }
609 let marker = default_world::db_generation_marker(pool).await;
610 let generation = store
611 .install_if_absent(&default_world::build(), marker.as_deref())
612 .await?;
613 let preamble = store.preamble(&generation).await?;
614 Ok((generation, preamble))
615}
616
617fn authorized(req: &HttpRequest, preamble: &Preamble) -> bool {
618 credential_accepted(
619 req.headers()
620 .get(actix_web::http::header::AUTHORIZATION)
621 .and_then(|value| value.to_str().ok()),
622 &preamble.defaults.accepted_token,
623 )
624}
625
626fn credential_accepted(header: Option<&str>, expected: &str) -> bool {
628 header
629 .and_then(|header| header.strip_prefix("Bearer "))
630 .is_some_and(|credential| !expected.is_empty() && credential == expected)
631}
632
633fn truncate(body: &[u8]) -> String {
634 let text = String::from_utf8_lossy(body);
635 if text.len() <= RAW_BODY_LIMIT {
636 return text.into_owned();
637 }
638 let mut cut = RAW_BODY_LIMIT;
639 while cut > 0 && !text.is_char_boundary(cut) {
640 cut -= 1;
641 }
642 text[..cut].to_string()
643}
644
645struct FaultRunner<'a> {
646 store: &'a MockSuotarStore,
647 generation: &'a str,
648 faults: &'a [Fault],
649 preamble: &'a Preamble,
650 log: RecordedFaults,
651}
652
653impl FaultRunner<'_> {
654 async fn request_stage(
656 &mut self,
657 endpoint: Endpoint,
658 stage: Stage,
659 items: &[ItemAddress],
660 ) -> anyhow::Result<Option<Effect>> {
661 let mut winner = None;
662 for fault in self.faults {
663 if !fault.then.is_request_shaped() {
664 continue;
665 }
666 match matches_request(fault, endpoint, stage, items) {
667 FaultMatch::Missed(predicate) => {
668 self.record_miss(fault, endpoint, stage, predicate)
669 }
670 FaultMatch::Fires => {
671 if winner.is_some() {
672 self.log.shadowed.push(fault.id.clone());
673 continue;
674 }
675 if self.draw(fault).await? {
676 self.log.applied.push(fault.id.clone());
677 winner = Some(fault.then.clone());
678 }
679 }
680 }
681 }
682 Ok(winner)
683 }
684
685 async fn item_stage(
686 &mut self,
687 endpoint: Endpoint,
688 stage: Stage,
689 item: &ItemAddress,
690 ) -> anyhow::Result<Option<Effect>> {
691 for fault in self.faults {
692 if fault.then.is_request_shaped() {
693 continue;
694 }
695 match matches_item(fault, endpoint, stage, item) {
696 FaultMatch::Missed(predicate) => {
697 self.record_miss(fault, endpoint, stage, predicate)
698 }
699 FaultMatch::Fires => {
700 if self.draw(fault).await? {
701 self.log.applied.push(fault.id.clone());
702 return Ok(Some(fault.then.clone()));
703 }
704 }
705 }
706 }
707 Ok(None)
708 }
709
710 fn record_miss(&mut self, fault: &Fault, endpoint: Endpoint, stage: Stage, predicate: &str) {
713 if fault.endpoint() != Some(endpoint) || fault.stage() != Some(stage) {
714 return;
715 }
716 let miss = MissedFault {
717 fault_id: fault.id.clone(),
718 predicate: predicate.to_string(),
719 };
720 if !self.log.missed.contains(&miss) {
721 self.log.missed.push(miss);
722 }
723 }
724
725 async fn draw(&mut self, fault: &Fault) -> anyhow::Result<bool> {
728 let Some(budget) = fault.lifetime.budget() else {
729 return Ok(true);
730 };
731 if budget > 0
732 && self
733 .preamble
734 .remaining
735 .get(&fault.id)
736 .is_some_and(|left| *left <= 0)
737 {
738 return Ok(false);
739 }
740 let left = self.store.draw(self.generation, &fault.id, -1).await?;
741 if left < 0 {
742 self.store.draw(self.generation, &fault.id, 1).await?;
743 self.log.missed.push(MissedFault {
744 fault_id: fault.id.clone(),
745 predicate: "lifetime".to_string(),
746 });
747 return Ok(false);
748 }
749 Ok(true)
750 }
751}
752
753fn item_effect_response(
754 endpoint: Endpoint,
755 address: &ItemAddress,
756 effect: &Effect,
757) -> ResponseItem {
758 match effect {
759 Effect::ItemLevel {
760 code,
761 message: Some(message),
762 } => ResponseItem::error_with_message(&address.request_item_id, code, message.clone()),
763 Effect::ItemLevel {
764 code,
765 message: None,
766 } => ResponseItem::error(endpoint, &address.request_item_id, code),
767 _ => ResponseItem::error(endpoint, &address.request_item_id, "internalError"),
768 }
769}
770
771enum ParsedRequest {
773 Import(Vec<wire::ImportAttainmentRequestItem>),
774 PerItem(PerItemRequest),
775}
776
777enum PerItemRequest {
778 ResolvePersons(Vec<wire::ResolvePersonRequestItem>),
779 ResolveEnrolments(Vec<wire::ResolveEnrolmentRequestItem>),
780 Verify(Vec<wire::VerifyAttainmentRequestItem>),
781 ListByCourse(Vec<wire::CourseCodeRequestItem>),
782 ValidateCourseCodes(Vec<wire::CourseCodeRequestItem>),
783}
784
785impl ParsedRequest {
786 fn addresses(&self) -> Vec<ItemAddress> {
787 match self {
788 Self::Import(items) => items
789 .iter()
790 .map(|item| ItemAddress {
791 request_item_id: item.request_item_id.clone(),
792 student_number: Some(item.student_number.clone()),
793 course_code: Some(item.course_code.clone()),
794 ..Default::default()
795 })
796 .collect(),
797 Self::PerItem(request) => request.addresses(),
798 }
799 }
800
801 fn enrich_addresses(&self, addresses: &mut [ItemAddress], working: &WorkingSet) {
804 if let Self::PerItem(PerItemRequest::Verify(_)) = self {
805 enrich_verify_addresses(addresses, working);
806 }
807 }
808}
809
810impl PerItemRequest {
811 fn addresses(&self) -> Vec<ItemAddress> {
812 let course_code_addresses = |items: &[wire::CourseCodeRequestItem]| {
813 items
814 .iter()
815 .map(|item| ItemAddress {
816 request_item_id: item.request_item_id.clone(),
817 course_code: Some(item.course_code.clone()),
818 ..Default::default()
819 })
820 .collect()
821 };
822 match self {
823 Self::ResolvePersons(items) => items
824 .iter()
825 .map(|item| ItemAddress {
826 request_item_id: item.request_item_id.clone(),
827 student_number: Some(item.student_number.clone()),
828 ..Default::default()
829 })
830 .collect(),
831 Self::ResolveEnrolments(items) => items
832 .iter()
833 .map(|item| ItemAddress {
834 request_item_id: item.request_item_id.clone(),
835 student_number: Some(item.student_number.clone()),
836 course_code: Some(item.course_code.clone()),
837 ..Default::default()
838 })
839 .collect(),
840 Self::Verify(items) => items
841 .iter()
842 .map(|item| ItemAddress {
843 request_item_id: item.request_item_id.clone(),
844 submitted_attainment_id: Some(item.submitted_attainment_id.clone()),
845 ..Default::default()
846 })
847 .collect(),
848 Self::ListByCourse(items) | Self::ValidateCourseCodes(items) => {
849 course_code_addresses(items)
850 }
851 }
852 }
853
854 fn resolve(&self, index: usize, working: &WorkingSet, now: DateTime<Utc>) -> ResponseItem {
855 match self {
856 Self::ResolvePersons(items) => logic::resolve_person_item(&items[index], working),
857 Self::ResolveEnrolments(items) => {
858 logic::resolve_enrolments_item(&items[index], working)
859 }
860 Self::Verify(items) => logic::verify_item(&items[index], working, now),
861 Self::ListByCourse(items) => logic::list_by_course_item(&items[index], working, now),
862 Self::ValidateCourseCodes(items) => {
863 logic::validate_course_code_item(&items[index], working)
864 }
865 }
866 }
867}
868
869fn enrich_verify_addresses(addresses: &mut [ItemAddress], working: &WorkingSet) {
870 for address in addresses.iter_mut() {
871 let Some(id) = address.submitted_attainment_id.as_ref() else {
872 continue;
873 };
874 let owner = working
875 .submissions
876 .get(id)
877 .map(|s| (s.student_number.clone(), s.course_code.clone()))
878 .or_else(|| {
879 working
880 .attainments
881 .get(id)
882 .map(|a| (a.student_number.clone(), a.course_code.clone()))
883 });
884 if let Some((student_number, course_code)) = owner {
885 address.student_number = Some(student_number);
886 address.course_code = Some(course_code);
887 }
888 }
889}
890
891fn unique_field<T>(items: &[T], field: impl Fn(&T) -> &str) -> Vec<String> {
893 items
894 .iter()
895 .map(|item| field(item).to_string())
896 .unique()
897 .collect()
898}
899
900async fn load(
901 store: &MockSuotarStore,
902 generation: &str,
903 parsed: &ParsedRequest,
904 working: &mut WorkingSet,
905) -> anyhow::Result<()> {
906 let defaults = working.defaults.clone();
907 let loaded = match parsed {
908 ParsedRequest::PerItem(PerItemRequest::ResolvePersons(items)) => WorkingSet {
909 persons: store
910 .load_persons(generation, &unique_field(items, |i| &i.student_number))
911 .await?,
912 ..Default::default()
913 },
914 ParsedRequest::PerItem(PerItemRequest::ResolveEnrolments(items)) => {
915 store
916 .load_for_person_course(
917 generation,
918 &unique_field(items, |i| &i.student_number),
919 &unique_field(items, |i| &i.course_code),
920 )
921 .await?
922 }
923 ParsedRequest::Import(items) => {
924 store
925 .load_for_person_course(
926 generation,
927 &unique_field(items, |i| &i.student_number),
928 &unique_field(items, |i| &i.course_code),
929 )
930 .await?
931 }
932 ParsedRequest::PerItem(PerItemRequest::Verify(items)) => {
933 store
934 .load_for_verify(
935 generation,
936 &unique_field(items, |i| &i.submitted_attainment_id),
937 )
938 .await?
939 }
940 ParsedRequest::PerItem(PerItemRequest::ListByCourse(items)) => {
941 store
942 .load_for_list_by_course(generation, &unique_field(items, |i| &i.course_code))
943 .await?
944 }
945 ParsedRequest::PerItem(PerItemRequest::ValidateCourseCodes(items)) => WorkingSet {
946 course_units: store
947 .load_course_units(generation, &unique_field(items, |i| &i.course_code))
948 .await?,
949 ..Default::default()
950 },
951 };
952 *working = WorkingSet { defaults, ..loaded };
953 Ok(())
954}
955
956fn parse_envelope(
958 endpoint: Endpoint,
959 body: Option<serde_json::Value>,
960) -> Result<Option<ParsedRequest>, String> {
961 let Some(serde_json::Value::Array(items)) = body else {
962 return Err(NOT_AN_ARRAY.to_string());
963 };
964 if items.is_empty() {
965 return Ok(None);
966 }
967 let max = endpoint.max_batch_size();
968 if items.len() > max {
969 return Err(format!("A batch may contain at most {max} request items."));
970 }
971 let mut ids = Vec::with_capacity(items.len());
972 for (index, item) in items.iter().enumerate() {
973 match non_empty_string(item, "requestItemId") {
974 Some(id) => ids.push(id.to_string()),
975 None => {
976 return Err(format!(
977 "Request item at index {index} has no string requestItemId."
978 ));
979 }
980 }
981 }
982 if ids.iter().collect::<BTreeSet<_>>().len() != ids.len() {
983 return Err("Every requestItemId in a batch must be unique.".to_string());
984 }
985 for (item, id) in items.iter().zip(&ids) {
986 if let Some(message) = item_problem(endpoint, item) {
987 return Err(format!("Request item {id}: {message}"));
988 }
989 }
990
991 Ok(Some(match endpoint {
992 Endpoint::ImportAttainments => ParsedRequest::Import(typed(items)?),
993 Endpoint::ResolvePersons => {
994 ParsedRequest::PerItem(PerItemRequest::ResolvePersons(typed(items)?))
995 }
996 Endpoint::ResolveEnrolments => {
997 ParsedRequest::PerItem(PerItemRequest::ResolveEnrolments(typed(items)?))
998 }
999 Endpoint::VerifyAttainments => {
1000 ParsedRequest::PerItem(PerItemRequest::Verify(typed(items)?))
1001 }
1002 Endpoint::ListByCourse => {
1003 ParsedRequest::PerItem(PerItemRequest::ListByCourse(typed(items)?))
1004 }
1005 Endpoint::ValidateCourseCodes => {
1006 ParsedRequest::PerItem(PerItemRequest::ValidateCourseCodes(typed(items)?))
1007 }
1008 }))
1009}
1010
1011fn item_problem(endpoint: Endpoint, item: &serde_json::Value) -> Option<String> {
1013 let first_blank = |fields: &[&str]| {
1014 fields
1015 .iter()
1016 .find(|field| non_empty_string(item, field).is_none())
1017 .map(|field| format!("{field} must be a non-empty string."))
1018 };
1019 match endpoint {
1020 Endpoint::ResolvePersons => first_blank(&["studentNumber"]),
1021 Endpoint::ResolveEnrolments => first_blank(&["studentNumber", "courseCode"]),
1022 Endpoint::VerifyAttainments => first_blank(&["submittedAttainmentId"]),
1023 Endpoint::ValidateCourseCodes => first_blank(&["courseCode"]),
1024 Endpoint::ListByCourse => first_blank(&["courseCode"]).or_else(|| {
1025 item.get("courseUnitRealisationId").is_some().then(|| {
1026 "courseUnitRealisationId is not accepted; every person comes with the realisation they are enrolled on."
1027 .to_string()
1028 })
1029 }),
1030 Endpoint::ImportAttainments => first_blank(&IMPORT_STRING_FIELDS)
1031 .or_else(|| {
1032 (!item.get("credits").is_some_and(serde_json::Value::is_number))
1033 .then(|| "credits must be a number.".to_string())
1034 })
1035 .or_else(|| {
1036 (!non_empty_string(item, "attainmentDate").is_some_and(is_strict_date))
1037 .then(|| "attainmentDate must be a date in YYYY-MM-DD format.".to_string())
1038 }),
1039 }
1040}
1041
1042fn non_empty_string<'a>(item: &'a serde_json::Value, field: &str) -> Option<&'a str> {
1043 item.get(field)
1044 .and_then(serde_json::Value::as_str)
1045 .filter(|value| !value.is_empty())
1046}
1047
1048fn is_strict_date(value: &str) -> bool {
1050 let shape_matches = value.len() == 10
1051 && value.char_indices().all(|(index, c)| match index {
1052 4 | 7 => c == '-',
1053 _ => c.is_ascii_digit(),
1054 });
1055 shape_matches && chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok()
1056}
1057
1058fn typed<T: DeserializeOwned>(items: Vec<serde_json::Value>) -> Result<Vec<T>, String> {
1060 items
1061 .into_iter()
1062 .enumerate()
1063 .map(|(index, item)| {
1064 serde_json::from_value(item)
1065 .map_err(|error| format!("Request item at index {index} is unreadable: {error}"))
1066 })
1067 .collect()
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072 use super::*;
1073
1074 #[test]
1075 fn only_the_exact_bearer_form_of_the_credential_is_accepted() {
1076 let expected = "mock-suotar-token";
1077 assert!(credential_accepted(
1078 Some(&format!("Bearer {expected}")),
1079 expected
1080 ));
1081 for header in [
1082 format!("Basic {expected}"),
1083 format!("bearer {expected}"),
1084 format!("Bearer {expected}"),
1085 expected.to_string(),
1086 "Bearer wrong-token".to_string(),
1087 ] {
1088 assert!(
1089 !credential_accepted(Some(&header), expected),
1090 "accepted {header}"
1091 );
1092 }
1093 assert!(!credential_accepted(None, expected));
1094 }
1095}