Skip to main content

headless_lms_server/controllers/mock_suotar/
api.rs

1//! The six contract endpoints, one stage boundary at a time.
2//!
3//! Order per request: credential, parse, item-keyed load, the `auth`/`requestGate`/`parse` faults,
4//! `resolve` request-level then per item, then `afterWrite` and `respond`. The write-back commits
5//! before the response — or its deliberate absence — leaves the process, which is what makes a
6//! timeout that landed distinguishable from one that did not.
7
8use std::collections::BTreeSet;
9
10use base64::Engine;
11use headless_lms_utils::services::suotar::SuotarEndpoint;
12use itertools::Itertools;
13use serde::de::DeserializeOwned;
14use sqlx::PgPool;
15
16use crate::prelude::*;
17
18use super::default_world;
19use super::faults::{Effect, Fault, FaultMatch, ItemAddress, Stage, matches_item, matches_request};
20use super::logic;
21use super::store::{MockSuotarStore, Preamble};
22use super::wire::{self, ErasedResponseItem, RequestLevelError, SuotarItemStatus};
23use super::world::{MissedFault, RecordedCall, RecordedFaults, RecordedItem, WorkingSet};
24
25const RAW_BODY_LIMIT: usize = 8 * 1024;
26
27/// Actix binds one handler per route, so the endpoint each route serves is all these differ by.
28macro_rules! endpoint_handlers {
29    ($($handler:ident => $endpoint:ident,)*) => {
30        $(
31            pub async fn $handler(
32                app_conf: web::Data<ApplicationConfiguration>,
33                store: web::Data<MockSuotarStore>,
34                pool: web::Data<PgPool>,
35                req: HttpRequest,
36                body: web::Bytes,
37            ) -> ControllerResult<HttpResponse> {
38                endpoint(
39                    SuotarEndpoint::$endpoint,
40                    app_conf,
41                    store,
42                    pool,
43                    req,
44                    body,
45                )
46                .await
47            }
48        )*
49    };
50}
51
52endpoint_handlers! {
53    resolve_persons => ResolvePersons,
54    resolve_enrolments => ResolveEnrolments,
55    list_by_course => ListByCourse,
56    import_attainments => ImportAttainments,
57    verify_attainments => VerifyAttainments,
58    resolve_product_access_tokens => ProductAccessTokens,
59}
60
61async fn endpoint(
62    endpoint: SuotarEndpoint,
63    app_conf: web::Data<ApplicationConfiguration>,
64    store: web::Data<MockSuotarStore>,
65    pool: web::Data<PgPool>,
66    req: HttpRequest,
67    body: web::Bytes,
68) -> ControllerResult<HttpResponse> {
69    super::assert_enabled(&app_conf);
70    let token = skip_authorize();
71    let delivery = match run(endpoint, &store, &pool, &req, &body).await {
72        Ok(delivery) => delivery,
73        Err(error) => {
74            // A store failure is loud: silent degradation is wrong for something tests assert against.
75            error!("mock Suotar failed to serve a request: {error:?}");
76            return token.authorized_ok(HttpResponse::InternalServerError().json(
77                RequestLevelError::with_message("internalError", error.to_string()),
78            ));
79        }
80    };
81    token.authorized_ok(deliver(delivery))
82}
83
84fn deliver(delivery: Delivery) -> HttpResponse {
85    match delivery {
86        Delivery::Json { status, body } => HttpResponse::build(
87            actix_web::http::StatusCode::from_u16(status)
88                .unwrap_or(actix_web::http::StatusCode::OK),
89        )
90        .content_type("application/json")
91        .body(body),
92        Delivery::ConnectionReset => {
93            let stream = futures::stream::once(async {
94                Err::<web::Bytes, actix_web::Error>(actix_web::error::ErrorInternalServerError(
95                    "mock Suotar dropped the connection",
96                ))
97            });
98            HttpResponse::Ok().streaming(stream)
99        }
100    }
101}
102
103enum Delivery {
104    Json { status: u16, body: String },
105    ConnectionReset,
106}
107
108impl Delivery {
109    fn json<T: Serialize>(status: u16, value: &T) -> Self {
110        Self::Json {
111            status,
112            body: serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()),
113        }
114    }
115}
116
117/// What a request-shaped effect answers with instead of the per-item array.
118struct Terminal {
119    delivery: Delivery,
120    status: u16,
121    request_level_code: Option<String>,
122    effect: String,
123}
124
125/// `None` for an item-level effect, which shapes one item rather than the whole answer.
126fn terminal(endpoint: SuotarEndpoint, effect: &Effect) -> Option<Terminal> {
127    let kind = effect.kind().to_string();
128    match effect {
129        Effect::ConnectionReset => Some(Terminal {
130            delivery: Delivery::ConnectionReset,
131            status: 200,
132            request_level_code: None,
133            effect: kind,
134        }),
135        Effect::RequestLevel {
136            status,
137            code,
138            message,
139        } => {
140            let error = match message {
141                Some(message) => RequestLevelError::with_message(code, message.clone()),
142                None => RequestLevelError::new(endpoint, code),
143            };
144            Some(Terminal {
145                delivery: Delivery::json(*status, &error),
146                status: *status,
147                request_level_code: Some(code.clone()),
148                effect: kind,
149            })
150        }
151        Effect::ItemLevel { .. } => None,
152    }
153}
154
155async fn run(
156    endpoint: SuotarEndpoint,
157    store: &MockSuotarStore,
158    pool: &PgPool,
159    req: &HttpRequest,
160    body: &[u8],
161) -> anyhow::Result<Delivery> {
162    let now = Utc::now();
163    let (generation, preamble) = resolve_world(store, pool).await?;
164    let mut runner = FaultRunner {
165        store,
166        generation: &generation,
167        faults: &preamble.faults,
168        preamble: &preamble,
169        log: RecordedFaults::default(),
170    };
171    let mut call = RecordedCall {
172        seq: store.next_call_seq(&generation).await?,
173        received_at: now,
174        endpoint,
175        correlation_id: req
176            .headers()
177            .get("X-Correlation-Id")
178            .and_then(|value| value.to_str().ok())
179            .map(str::to_string),
180        authorized: true,
181        http_status: 200,
182        request_level_code: None,
183        effect: None,
184        raw_body_truncated: truncate(body),
185        faults: RecordedFaults::default(),
186        items: Vec::new(),
187    };
188    let mut working = WorkingSet {
189        defaults: preamble.defaults.clone(),
190        ..Default::default()
191    };
192
193    // The real credential, which no fault takes part in.
194    if !authorized(req, &preamble) {
195        call.authorized = false;
196        return finish(
197            store,
198            &generation,
199            &working,
200            call,
201            runner.log,
202            Delivery::json(401, &RequestLevelError::new(endpoint, "unauthorized")),
203            401,
204            Some("unauthorized".to_string()),
205            None,
206        )
207        .await;
208    }
209
210    let parsed = match parse(endpoint, body, &preamble) {
211        Ok(parsed) => parsed,
212        Err((code, message)) => {
213            return finish(
214                store,
215                &generation,
216                &working,
217                call,
218                runner.log,
219                Delivery::json(400, &RequestLevelError::with_message(&code, message)),
220                400,
221                Some(code),
222                None,
223            )
224            .await;
225        }
226    };
227
228    let mut addresses = parsed.addresses();
229    load(store, &generation, &parsed, &mut working).await?;
230    parsed.enrich_addresses(&mut addresses, &working);
231
232    // A real Suotar decides these before reading the body; evaluated after it here because narrowing
233    // a fault to the rows one spec owns costs the parse. Nothing is written yet either way.
234    for stage in [Stage::Auth, Stage::RequestGate, Stage::Parse] {
235        if let Some(effect) = runner.request_stage(endpoint, stage, &addresses).await?
236            && let Some(terminal) = terminal(endpoint, &effect)
237        {
238            call.authorized = stage != Stage::Auth;
239            return finish(
240                store,
241                &generation,
242                &working,
243                call,
244                runner.log,
245                terminal.delivery,
246                terminal.status,
247                terminal.request_level_code,
248                Some(terminal.effect),
249            )
250            .await;
251        }
252    }
253
254    if let Some(effect) = runner
255        .request_stage(endpoint, Stage::Resolve, &addresses)
256        .await?
257        && let Some(terminal) = terminal(endpoint, &effect)
258    {
259        call.authorized = true;
260        return finish(
261            store,
262            &generation,
263            &working,
264            call,
265            runner.log,
266            terminal.delivery,
267            terminal.status,
268            terminal.request_level_code,
269            Some(terminal.effect),
270        )
271        .await;
272    }
273
274    let mut items = Vec::with_capacity(addresses.len());
275    for (index, address) in addresses.iter().enumerate() {
276        let fault = runner.item_stage(endpoint, Stage::Resolve, address).await?;
277        match fault {
278            Some(effect) => items.push(item_effect_response(endpoint, address, &effect, None)),
279            None => items.push(parsed.resolve(index, &mut working, now)),
280        }
281    }
282
283    // A request-shaped effect here replaces the answer the items formed; the log keeps the items
284    // either way, which is what makes a landed-but-unanswered import visible.
285    let mut answered_by_fault: Option<Terminal> = None;
286    for stage in [Stage::AfterWrite, Stage::Respond] {
287        if let Some(effect) = runner.request_stage(endpoint, stage, &addresses).await? {
288            answered_by_fault = terminal(endpoint, &effect);
289        }
290        for (index, address) in addresses.iter().enumerate() {
291            if let Some(effect) = runner.item_stage(endpoint, stage, address).await?
292                && let Some(item) = items.get_mut(index)
293            {
294                *item = item_effect_response(endpoint, address, &effect, Some(item));
295            }
296        }
297    }
298
299    call.items = addresses
300        .iter()
301        .zip(items.iter())
302        .map(|(address, item)| RecordedItem {
303            request_item_id: address.request_item_id.clone(),
304            student_number: address.student_number.clone(),
305            course_code: address.course_code.clone(),
306            submitted_attainment_id: address.submitted_attainment_id.clone(),
307            product_id: address.product_id.clone(),
308            status: match item.status {
309                SuotarItemStatus::Ok => "ok".to_string(),
310                SuotarItemStatus::Error => "error".to_string(),
311            },
312            code: item.code.clone(),
313        })
314        .collect();
315
316    let (delivery, status, code, effect) = match answered_by_fault {
317        Some(terminal) => (
318            terminal.delivery,
319            terminal.status,
320            terminal.request_level_code,
321            Some(terminal.effect),
322        ),
323        None => (Delivery::json(200, &items), 200, None, None),
324    };
325    finish(
326        store,
327        &generation,
328        &working,
329        call,
330        runner.log,
331        delivery,
332        status,
333        code,
334        effect,
335    )
336    .await
337}
338
339/// Commits, then hands back what to send.
340#[allow(clippy::too_many_arguments)]
341async fn finish(
342    store: &MockSuotarStore,
343    generation: &str,
344    working: &WorkingSet,
345    mut call: RecordedCall,
346    log: RecordedFaults,
347    delivery: Delivery,
348    status: u16,
349    request_level_code: Option<String>,
350    effect: Option<String>,
351) -> anyhow::Result<Delivery> {
352    call.faults = log;
353    call.effect = effect;
354    call.http_status = status;
355    call.request_level_code = request_level_code;
356    let capacity = working.defaults.call_log_capacity.max(1);
357    store.commit(generation, working, &call, capacity).await?;
358    Ok(delivery)
359}
360
361async fn resolve_world(
362    store: &MockSuotarStore,
363    pool: &PgPool,
364) -> anyhow::Result<(String, Preamble)> {
365    if let Some(generation) = store.live_generation().await? {
366        let preamble = store.preamble(&generation).await?;
367        if preamble.defaults_present {
368            return Ok((generation, preamble));
369        }
370    }
371    let marker = default_world::db_generation_marker(pool).await;
372    let generation = store
373        .install_if_absent(&default_world::build(), marker.as_deref())
374        .await?;
375    let preamble = store.preamble(&generation).await?;
376    Ok((generation, preamble))
377}
378
379fn authorized(req: &HttpRequest, preamble: &Preamble) -> bool {
380    credential_accepted(
381        req.headers()
382            .get(actix_web::http::header::AUTHORIZATION)
383            .and_then(|value| value.to_str().ok()),
384        &preamble.defaults.accepted_token,
385    )
386}
387
388/// Scheme-agnostic: whatever word the client puts in front, the credential after it is what is
389/// checked.
390fn credential_accepted(header: Option<&str>, expected: &str) -> bool {
391    let Some(header) = header else {
392        return false;
393    };
394    let credential = header.split_whitespace().next_back().unwrap_or_default();
395    if credential == expected {
396        return true;
397    }
398    // `reqwest`'s `basic_auth()` base64-encodes, so which way the client builds the header must not
399    // matter.
400    base64::engine::general_purpose::STANDARD
401        .decode(credential)
402        .ok()
403        .and_then(|bytes| String::from_utf8(bytes).ok())
404        .is_some_and(|decoded| {
405            decoded == expected
406                || decoded == format!("{expected}:")
407                || decoded.rsplit(':').next() == Some(expected)
408        })
409}
410
411fn truncate(body: &[u8]) -> String {
412    let text = String::from_utf8_lossy(body);
413    if text.len() <= RAW_BODY_LIMIT {
414        return text.into_owned();
415    }
416    let mut cut = RAW_BODY_LIMIT;
417    while cut > 0 && !text.is_char_boundary(cut) {
418        cut -= 1;
419    }
420    text[..cut].to_string()
421}
422
423struct FaultRunner<'a> {
424    store: &'a MockSuotarStore,
425    generation: &'a str,
426    faults: &'a [Fault],
427    preamble: &'a Preamble,
428    log: RecordedFaults,
429}
430
431impl FaultRunner<'_> {
432    /// First match in arm order wins; later matches are recorded as shadowed rather than applied.
433    async fn request_stage(
434        &mut self,
435        endpoint: SuotarEndpoint,
436        stage: Stage,
437        items: &[ItemAddress],
438    ) -> anyhow::Result<Option<Effect>> {
439        let mut winner = None;
440        for fault in self.faults {
441            if !fault.then.is_request_shaped() {
442                continue;
443            }
444            match matches_request(fault, endpoint, stage, items) {
445                FaultMatch::Missed(predicate) => {
446                    self.record_miss(fault, endpoint, stage, predicate)
447                }
448                FaultMatch::Fires => {
449                    if winner.is_some() {
450                        self.log.shadowed.push(fault.id.clone());
451                        continue;
452                    }
453                    if self.draw(fault).await? {
454                        self.log.applied.push(fault.id.clone());
455                        winner = Some(fault.then.clone());
456                    }
457                }
458            }
459        }
460        Ok(winner)
461    }
462
463    async fn item_stage(
464        &mut self,
465        endpoint: SuotarEndpoint,
466        stage: Stage,
467        item: &ItemAddress,
468    ) -> anyhow::Result<Option<Effect>> {
469        for fault in self.faults {
470            if fault.then.is_request_shaped() {
471                continue;
472            }
473            match matches_item(fault, endpoint, stage, item) {
474                FaultMatch::Missed(predicate) => {
475                    self.record_miss(fault, endpoint, stage, predicate)
476                }
477                FaultMatch::Fires => {
478                    if self.draw(fault).await? {
479                        self.log.applied.push(fault.id.clone());
480                        return Ok(Some(fault.then.clone()));
481                    }
482                }
483            }
484        }
485        Ok(None)
486    }
487
488    /// Only a fault that reached this endpoint and stage and then missed on one further predicate is
489    /// worth reporting.
490    fn record_miss(
491        &mut self,
492        fault: &Fault,
493        endpoint: SuotarEndpoint,
494        stage: Stage,
495        predicate: &str,
496    ) {
497        if fault.endpoint() != Some(endpoint) || fault.stage() != Some(stage) {
498            return;
499        }
500        let miss = MissedFault {
501            fault_id: fault.id.clone(),
502            predicate: predicate.to_string(),
503        };
504        if !self.log.missed.contains(&miss) {
505            self.log.missed.push(miss);
506        }
507    }
508
509    /// The decision is the value the draw returns: reading a counter and deciding on the read is how
510    /// two concurrent requests both spend the last of one budget.
511    async fn draw(&mut self, fault: &Fault) -> anyhow::Result<bool> {
512        let Some(budget) = fault.lifetime.budget() else {
513            return Ok(true);
514        };
515        if budget > 0
516            && self
517                .preamble
518                .remaining
519                .get(&fault.id)
520                .is_some_and(|left| *left <= 0)
521        {
522            return Ok(false);
523        }
524        let left = self.store.draw(self.generation, &fault.id, -1).await?;
525        if left < 0 {
526            self.store.draw(self.generation, &fault.id, 1).await?;
527            self.log.missed.push(MissedFault {
528                fault_id: fault.id.clone(),
529                predicate: "lifetime".to_string(),
530            });
531            return Ok(false);
532        }
533        Ok(true)
534    }
535}
536
537/// A disclosed id is taken from the outcome the item already had, which is what makes "timed out, but
538/// it landed" expressible.
539fn item_effect_response(
540    endpoint: SuotarEndpoint,
541    address: &ItemAddress,
542    effect: &Effect,
543    resolved: Option<&ErasedResponseItem>,
544) -> ErasedResponseItem {
545    let Effect::ItemLevel {
546        code,
547        message,
548        disclose_submitted_attainment_id,
549    } = effect
550    else {
551        return resolved.cloned().unwrap_or_else(|| {
552            wire::error_item(endpoint, &address.request_item_id, "internalError")
553        });
554    };
555    let mut item = match message {
556        Some(message) => {
557            wire::error_item_with_message(&address.request_item_id, code, message.clone())
558        }
559        None => wire::error_item(endpoint, &address.request_item_id, code),
560    };
561    if *disclose_submitted_attainment_id
562        && let Some(id) = resolved
563            .and_then(|resolved| resolved.result.as_ref())
564            .and_then(|result| result.get("submittedAttainmentId"))
565            .and_then(|value| value.as_str())
566        && let Some(error) = item.error.as_mut()
567    {
568        error.submitted_attainment_id = Some(id.to_string());
569    }
570    item
571}
572
573enum ParsedRequest {
574    ResolvePersons(Vec<wire::ResolvePersonRequestItem>),
575    ResolveEnrolments(Vec<wire::ResolveEnrolmentRequestItem>),
576    Import(Vec<wire::ImportAttainmentRequestItem>),
577    Verify(Vec<wire::VerifyAttainmentRequestItem>),
578    ProductAccessTokens(Vec<wire::ProductAccessTokenRequestItem>),
579    ListByCourse(Vec<wire::ListByCourseRequestItem>),
580}
581
582impl ParsedRequest {
583    fn addresses(&self) -> Vec<ItemAddress> {
584        match self {
585            Self::ResolvePersons(items) => items
586                .iter()
587                .map(|item| ItemAddress {
588                    request_item_id: item.request_item_id.clone(),
589                    student_number: Some(item.student_number.clone()),
590                    ..Default::default()
591                })
592                .collect(),
593            Self::ResolveEnrolments(items) => items
594                .iter()
595                .map(|item| ItemAddress {
596                    request_item_id: item.request_item_id.clone(),
597                    student_number: Some(item.student_number.clone()),
598                    course_code: Some(item.course_code.clone()),
599                    ..Default::default()
600                })
601                .collect(),
602            Self::Import(items) => items
603                .iter()
604                .map(|item| ItemAddress {
605                    request_item_id: item.request_item_id.clone(),
606                    student_number: Some(item.student_number.clone()),
607                    course_code: Some(item.course_code.clone()),
608                    ..Default::default()
609                })
610                .collect(),
611            Self::Verify(items) => items
612                .iter()
613                .map(|item| ItemAddress {
614                    request_item_id: item.request_item_id.clone(),
615                    submitted_attainment_id: Some(item.submitted_attainment_id.clone()),
616                    ..Default::default()
617                })
618                .collect(),
619            Self::ProductAccessTokens(items) => items
620                .iter()
621                .map(|item| ItemAddress {
622                    request_item_id: item.request_item_id.clone(),
623                    product_id: Some(item.open_university_product_id.clone()),
624                    ..Default::default()
625                })
626                .collect(),
627            Self::ListByCourse(items) => items
628                .iter()
629                .map(|item| ItemAddress {
630                    request_item_id: item.request_item_id.clone(),
631                    course_code: Some(item.course_code.clone()),
632                    ..Default::default()
633                })
634                .collect(),
635        }
636    }
637
638    /// Verify's body carries only a submitted attainment id; the person behind it is what a spec
639    /// addresses a fault with.
640    fn enrich_addresses(&self, addresses: &mut [ItemAddress], working: &WorkingSet) {
641        if !matches!(self, Self::Verify(_)) {
642            return;
643        }
644        for address in addresses.iter_mut() {
645            if let Some(submission) = address
646                .submitted_attainment_id
647                .as_ref()
648                .and_then(|id| working.submissions.get(id))
649            {
650                address.student_number = Some(submission.student_number.clone());
651                address.course_code = Some(submission.course_code.clone());
652            }
653        }
654    }
655
656    fn resolve(
657        &self,
658        index: usize,
659        working: &mut WorkingSet,
660        now: DateTime<Utc>,
661    ) -> ErasedResponseItem {
662        match self {
663            Self::ResolvePersons(items) => {
664                wire::erase(logic::resolve_person_item(&items[index], working))
665            }
666            Self::ResolveEnrolments(items) => {
667                wire::erase(logic::resolve_enrolments_item(&items[index], working, now))
668            }
669            Self::Import(items) => wire::erase(logic::import_item(&items[index], working, now)),
670            Self::Verify(items) => wire::erase(logic::verify_item(&items[index], working, now)),
671            Self::ProductAccessTokens(items) => {
672                wire::erase(logic::product_access_token_item(&items[index], working))
673            }
674            Self::ListByCourse(items) => {
675                wire::erase(logic::list_by_course_item(&items[index], working))
676            }
677        }
678    }
679}
680
681/// Collects the distinct values of one field across a request's items, in first-seen order.
682fn unique_field<T>(items: &[T], field: impl Fn(&T) -> &str) -> Vec<String> {
683    items
684        .iter()
685        .map(|item| field(item).to_string())
686        .unique()
687        .collect()
688}
689
690async fn load(
691    store: &MockSuotarStore,
692    generation: &str,
693    parsed: &ParsedRequest,
694    working: &mut WorkingSet,
695) -> anyhow::Result<()> {
696    let defaults = working.defaults.clone();
697    let loaded = match parsed {
698        ParsedRequest::ResolvePersons(items) => {
699            let persons = store
700                .load_persons(generation, &unique_field(items, |i| &i.student_number))
701                .await?;
702            WorkingSet {
703                persons,
704                ..Default::default()
705            }
706        }
707        ParsedRequest::ResolveEnrolments(items) => {
708            store
709                .load_for_person_course(
710                    generation,
711                    &unique_field(items, |i| &i.student_number),
712                    &unique_field(items, |i| &i.course_code),
713                )
714                .await?
715        }
716        ParsedRequest::Import(items) => {
717            store
718                .load_for_person_course(
719                    generation,
720                    &unique_field(items, |i| &i.student_number),
721                    &unique_field(items, |i| &i.course_code),
722                )
723                .await?
724        }
725        ParsedRequest::Verify(items) => {
726            store
727                .load_for_verify(
728                    generation,
729                    &unique_field(items, |i| &i.submitted_attainment_id),
730                )
731                .await?
732        }
733        ParsedRequest::ProductAccessTokens(items) => {
734            let product_tokens = store
735                .load_product_tokens(
736                    generation,
737                    &unique_field(items, |i| &i.open_university_product_id),
738                )
739                .await?;
740            WorkingSet {
741                product_tokens,
742                ..Default::default()
743            }
744        }
745        ParsedRequest::ListByCourse(items) => {
746            store
747                .load_for_list_by_course(generation, &unique_field(items, |i| &i.course_code))
748                .await?
749        }
750    };
751    *working = WorkingSet { defaults, ..loaded };
752    Ok(())
753}
754
755/// The error half is the request-level code and its message.
756type ParseFailure = (String, String);
757
758fn parse(
759    endpoint: SuotarEndpoint,
760    body: &[u8],
761    preamble: &Preamble,
762) -> Result<ParsedRequest, ParseFailure> {
763    let parsed = match endpoint {
764        SuotarEndpoint::ResolvePersons => ParsedRequest::ResolvePersons(parse_items(body)?),
765        SuotarEndpoint::ResolveEnrolments => ParsedRequest::ResolveEnrolments(parse_items(body)?),
766        SuotarEndpoint::ImportAttainments => ParsedRequest::Import(parse_items(body)?),
767        SuotarEndpoint::VerifyAttainments => ParsedRequest::Verify(parse_items(body)?),
768        SuotarEndpoint::ProductAccessTokens => {
769            ParsedRequest::ProductAccessTokens(parse_items(body)?)
770        }
771        SuotarEndpoint::ListByCourse => ParsedRequest::ListByCourse(parse_items(body)?),
772    };
773
774    let ids: Vec<String> = parsed
775        .addresses()
776        .into_iter()
777        .map(|address| address.request_item_id)
778        .collect();
779    let mut seen = BTreeSet::new();
780    for (index, id) in ids.iter().enumerate() {
781        if !seen.insert(id.clone()) {
782            return Err(malformed(format!(
783                "Item {index} repeats requestItemId `{id}`."
784            )));
785        }
786    }
787
788    if let ParsedRequest::Import(items) = &parsed {
789        // A statically unknown grade id is a request-level error, so one poisoned item rejects the
790        // whole batch. Suotar has not named a code for it; the default is the escape hatch for the
791        // day it does.
792        let grade_code = preamble
793            .defaults
794            .static_grade_error_code
795            .clone()
796            .unwrap_or_else(|| "malformedRequest".to_string());
797        for (index, item) in items.iter().enumerate() {
798            if preamble.defaults.scale(&item.grade_scale_id).is_none() {
799                return Err((
800                    grade_code,
801                    format!(
802                        "Item {index} has an unknown gradeScaleId `{}`.",
803                        item.grade_scale_id
804                    ),
805                ));
806            }
807            if !preamble.defaults.any_scale_has_grade(&item.grade_id) {
808                return Err((
809                    grade_code,
810                    format!(
811                        "Item {index} has a gradeId `{}` that is in no known grade scale.",
812                        item.grade_id
813                    ),
814                ));
815            }
816            if item.attainment_language.chars().count() != 2 {
817                return Err(malformed(format!(
818                    "Item {index} has an attainmentLanguage that is not a two-letter code."
819                )));
820            }
821            if item.credits < 0.0 || !item.credits.is_finite() {
822                return Err(malformed(format!(
823                    "Item {index} has credits that are not a positive number."
824                )));
825            }
826        }
827    }
828
829    if preamble.defaults.realisation_id_required
830        && let ParsedRequest::ListByCourse(items) = &parsed
831        && let Some(index) = items
832            .iter()
833            .position(|item| item.course_unit_realisation_id.is_none())
834    {
835        return Err(malformed(format!(
836            "Item {index} is missing courseUnitRealisationId."
837        )));
838    }
839
840    Ok(parsed)
841}
842
843/// Parses per element so the message can name the offending index, which serde's line and column
844/// cannot.
845fn parse_items<T: DeserializeOwned>(body: &[u8]) -> Result<Vec<T>, ParseFailure> {
846    let values: Vec<serde_json::Value> = serde_json::from_slice(body)
847        .map_err(|error| malformed(format!("Request body is not a JSON array: {error}")))?;
848    if values.is_empty() {
849        return Err(malformed("Request body is an empty array.".to_string()));
850    }
851    values
852        .into_iter()
853        .enumerate()
854        .map(|(index, value)| {
855            serde_json::from_value(value)
856                .map_err(|error| malformed(format!("Item {index} is invalid: {error}")))
857        })
858        .collect()
859}
860
861fn malformed(message: String) -> ParseFailure {
862    ("malformedRequest".to_string(), message)
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868
869    fn encoded(value: &str) -> String {
870        base64::engine::general_purpose::STANDARD.encode(value)
871    }
872
873    #[test]
874    fn every_shape_of_the_configured_credential_is_accepted() {
875        let expected = "mock-suotar-token";
876        for header in [
877            format!("Basic {expected}"),
878            format!("Bearer {expected}"),
879            format!("Basic {}", encoded(expected)),
880            format!("Basic {}", encoded(&format!("{expected}:"))),
881            format!("Basic {}", encoded(&format!("suotar:{expected}"))),
882        ] {
883            assert!(
884                credential_accepted(Some(&header), expected),
885                "rejected {header}"
886            );
887        }
888        assert!(!credential_accepted(None, expected));
889        assert!(!credential_accepted(Some("Basic wrong-token"), expected));
890        assert!(!credential_accepted(
891            Some(&format!("Basic {}", encoded("suotar:wrong-token"))),
892            expected
893        ));
894    }
895}