Skip to main content

headless_lms_server/domain/
models_requests.rs

1//! Contains helper functions that are passed to headless-lms-models where it needs to make requests to exercise services.
2
3use crate::config::server_runtime_config;
4use crate::prelude::*;
5use actix_http::Payload;
6use actix_web::{FromRequest, HttpRequest};
7use chrono::{DateTime, Duration, Utc};
8use futures::{
9    FutureExt,
10    future::{BoxFuture, Ready, ready},
11};
12use headless_lms_models::{
13    HttpErrorType, ModelError, ModelErrorType, ModelResult,
14    exercise_service_info::ExerciseServiceInfoApi,
15    exercise_task_gradings::{ExerciseTaskGradingRequest, ExerciseTaskGradingResult},
16    exercise_task_submissions::ExerciseTaskSubmission,
17    exercise_tasks::ExerciseTask,
18};
19
20use headless_lms_base::error::backend_error::BackendError;
21use jsonwebtoken::{
22    Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind,
23};
24use models::SpecFetcher;
25use std::collections::HashMap;
26use std::fmt::Debug;
27use std::sync::{Arc, Mutex};
28use url::Url;
29
30use super::error::{ControllerError, ControllerErrorType};
31
32// keep in sync with the shared-module constants
33const EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER: &str = "exercise-service-grading-update-claim";
34const EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER: &str = "exercise-service-upload-claim";
35pub const PLAYGROUND_GRADING_CALLBACK_CLAIM_PARAM: &str = "playground-grading-callback-claim";
36
37/// A type for caching the spec fetching (only for the seed)
38type SpecCache = HashMap<(String, String, Option<String>), serde_json::Value>;
39
40#[derive(Clone, Debug)]
41pub struct JwtKey(Vec<u8>);
42
43impl JwtKey {
44    pub fn try_from_env() -> anyhow::Result<Self> {
45        let jwt_password = server_runtime_config().jwt_password.clone();
46        let jwt_key = Self::new(&jwt_password)?;
47        Ok(jwt_key)
48    }
49
50    pub fn new(key: &str) -> anyhow::Result<Self> {
51        Ok(Self(key.as_bytes().to_vec()))
52    }
53
54    #[cfg(test)]
55    pub fn test_key() -> Self {
56        let test_jwt_key = "sMG87WlKnNZoITzvL2+jczriTR7JRsCtGu/bSKaSIvw=asdfjklasd***FSDfsdASDFDS";
57        Self(test_jwt_key.as_bytes().to_vec())
58    }
59}
60
61#[derive(Debug, Serialize, Deserialize)]
62pub struct UploadClaim {
63    exercise_service_slug: String,
64    exp: usize,
65    iat: usize,
66}
67
68#[derive(Debug, Deserialize)]
69struct LegacyUploadClaim {
70    exercise_service_slug: String,
71    expiration_time: DateTime<Utc>,
72}
73
74impl UploadClaim {
75    pub fn exercise_service_slug(&self) -> &str {
76        self.exercise_service_slug.as_ref()
77    }
78
79    pub fn expiring_in_1_day(exercise_service_slug: impl Into<String>) -> Self {
80        let now = Utc::now().timestamp().max(0) as usize;
81        let exp = (Utc::now().timestamp() + Duration::days(1).num_seconds()).max(0) as usize;
82        Self {
83            exercise_service_slug: exercise_service_slug.into(),
84            exp,
85            iat: now,
86        }
87    }
88
89    pub fn sign(self, key: &JwtKey) -> Result<String, jsonwebtoken::errors::Error> {
90        sign_hs256_claim(&self, key)
91    }
92
93    pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
94        validate_upload_claim_with_legacy_fallback(token, key).map_err(|err| {
95            ControllerError::new(
96                ControllerErrorType::BadRequest,
97                format!("Invalid jwt key: {}", err),
98                Some(err.into()),
99            )
100        })
101    }
102}
103
104impl FromRequest for UploadClaim {
105    type Error = ControllerError;
106    type Future = Ready<Result<Self, Self::Error>>;
107
108    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
109        let try_from_request = move || {
110            let jwt_key = req.app_data::<web::Data<JwtKey>>().ok_or_else(|| {
111                ControllerError::new(
112                    ControllerErrorType::InternalServerError,
113                    "Missing JwtKey in app data - server configuration error".to_string(),
114                    None,
115                )
116            })?;
117            let header = req
118                .headers()
119                .get(EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER)
120                .ok_or_else(|| {
121                    ControllerError::new(
122                        ControllerErrorType::BadRequest,
123                        format!("Missing header {EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER}",),
124                        None,
125                    )
126                })?;
127            let header = std::str::from_utf8(header.as_bytes()).map_err(|err| {
128                ControllerError::new(
129                    ControllerErrorType::BadRequest,
130                    format!(
131                        "Invalid header {EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER} = {}",
132                        String::from_utf8_lossy(header.as_bytes())
133                    ),
134                    Some(err.into()),
135                )
136            })?;
137            let claim = UploadClaim::validate(header, jwt_key)?;
138            Result::<_, Self::Error>::Ok(claim)
139        };
140        ready(try_from_request())
141    }
142}
143
144#[derive(Debug, Serialize, Deserialize)]
145pub struct GradingUpdateClaim {
146    submission_id: Uuid,
147    exp: usize,
148    iat: usize,
149}
150
151#[derive(Debug, Deserialize)]
152struct LegacyGradingUpdateClaim {
153    submission_id: Uuid,
154    expiration_time: DateTime<Utc>,
155}
156
157impl GradingUpdateClaim {
158    pub fn submission_id(&self) -> Uuid {
159        self.submission_id
160    }
161
162    pub fn expiring_in_1_day(submission_id: Uuid) -> Self {
163        let now = Utc::now().timestamp().max(0) as usize;
164        let exp = (Utc::now().timestamp() + Duration::days(1).num_seconds()).max(0) as usize;
165        Self {
166            submission_id,
167            exp,
168            iat: now,
169        }
170    }
171
172    pub fn sign(self, key: &JwtKey) -> Result<String, jsonwebtoken::errors::Error> {
173        sign_hs256_claim(&self, key)
174    }
175
176    pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
177        validate_grading_update_claim_with_legacy_fallback(token, key).map_err(|err| {
178            ControllerError::new(
179                ControllerErrorType::BadRequest,
180                format!("Invalid jwt key: {}", err),
181                Some(err.into()),
182            )
183        })
184    }
185}
186
187impl FromRequest for GradingUpdateClaim {
188    type Error = ControllerError;
189    type Future = Ready<Result<Self, Self::Error>>;
190
191    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
192        let try_from_request = move || {
193            let jwt_key = req.app_data::<web::Data<JwtKey>>().ok_or_else(|| {
194                ControllerError::new(
195                    ControllerErrorType::InternalServerError,
196                    "Missing JwtKey in app data - server configuration error".to_string(),
197                    None,
198                )
199            })?;
200            let header = req
201                .headers()
202                .get(EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER)
203                .ok_or_else(|| {
204                    ControllerError::new(
205                        ControllerErrorType::BadRequest,
206                        format!("Missing header {EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER}",),
207                        None,
208                    )
209                })?;
210            let header = std::str::from_utf8(header.as_bytes()).map_err(|err| {
211                ControllerError::new(
212                    ControllerErrorType::BadRequest,
213                    format!(
214                        "Invalid header {EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER} = {}",
215                        String::from_utf8_lossy(header.as_bytes())
216                    ),
217                    Some(err.into()),
218                )
219            })?;
220            let claim = GradingUpdateClaim::validate(header, jwt_key)?;
221            Result::<_, Self::Error>::Ok(claim)
222        };
223        ready(try_from_request())
224    }
225}
226
227#[derive(Debug, Serialize, Deserialize)]
228pub struct PlaygroundGradingCallbackClaim {
229    websocket_id: Uuid,
230    exp: usize,
231    iat: usize,
232}
233
234impl PlaygroundGradingCallbackClaim {
235    pub fn websocket_id(&self) -> Uuid {
236        self.websocket_id
237    }
238
239    pub fn expiring_in_1_day(websocket_id: Uuid) -> Self {
240        let now = Utc::now().timestamp().max(0) as usize;
241        let exp = (Utc::now().timestamp() + Duration::days(1).num_seconds()).max(0) as usize;
242        Self {
243            websocket_id,
244            exp,
245            iat: now,
246        }
247    }
248
249    pub fn sign(self, key: &JwtKey) -> Result<String, jsonwebtoken::errors::Error> {
250        sign_hs256_claim(&self, key)
251    }
252
253    pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
254        validate_hs256_claim::<Self>(token, key).map_err(|err| {
255            controller_err!(
256                BadRequest,
257                format!("Invalid playground grading callback claim: {}", err),
258                err
259            )
260        })
261    }
262}
263
264impl FromRequest for PlaygroundGradingCallbackClaim {
265    type Error = ControllerError;
266    type Future = Ready<Result<Self, Self::Error>>;
267
268    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
269        let try_from_request = move || {
270            let jwt_key = req.app_data::<web::Data<JwtKey>>().ok_or_else(|| {
271                controller_err!(
272                    InternalServerError,
273                    "Missing JwtKey in app data - server configuration error".to_string()
274                )
275            })?;
276            let query_claim = url::form_urlencoded::parse(req.query_string().as_bytes())
277                .find(|(key, _)| key == PLAYGROUND_GRADING_CALLBACK_CLAIM_PARAM)
278                .map(|(_, value)| value.into_owned());
279            let header_claim = req
280                .headers()
281                .get(PLAYGROUND_GRADING_CALLBACK_CLAIM_PARAM)
282                .and_then(|header| std::str::from_utf8(header.as_bytes()).ok())
283                .map(ToString::to_string);
284            let claim = header_claim.or(query_claim).ok_or_else(|| {
285                controller_err!(
286                    BadRequest,
287                    format!("Missing {PLAYGROUND_GRADING_CALLBACK_CLAIM_PARAM}")
288                )
289            })?;
290            PlaygroundGradingCallbackClaim::validate(&claim, jwt_key)
291        };
292        ready(try_from_request())
293    }
294}
295
296/// Accepted by the public-spec and model-solution endpoints of exercise services.
297#[derive(Debug, Serialize)]
298
299pub struct SpecRequest<'a> {
300    request_id: Uuid,
301    private_spec: Option<&'a serde_json::Value>,
302    upload_url: Option<String>,
303}
304
305#[derive(Debug, Serialize)]
306pub struct ExerciseServiceCsvExportRequest<'a, T: Serialize> {
307    pub items: &'a [T],
308}
309
310/// Column definition for exercise service CSV export; callers must use scalar-only cell values.
311#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
312pub struct ExerciseServiceCsvExportColumn {
313    pub key: String,
314    pub header: String,
315}
316
317/// One batch of CSV rows; each row's values must be scalar (null, bool, number, string). Objects/arrays are rejected by the controller.
318#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
319pub struct ExerciseServiceCsvExportResult {
320    pub rows: Vec<HashMap<String, serde_json::Value>>,
321}
322
323/// Full CSV export response; columns define headers, results align by index. All cell values must be scalar.
324#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
325pub struct ExerciseServiceCsvExportResponse {
326    pub columns: Vec<ExerciseServiceCsvExportColumn>,
327    pub results: Vec<ExerciseServiceCsvExportResult>,
328}
329
330/// Fetches a public/model spec based on the private spec from the given url.
331/// The slug and jwt key are used for an upload claim that allows the service
332/// to upload files as part of the spec.
333pub fn make_spec_fetcher(
334    base_url: String,
335    request_id: Uuid,
336    jwt_key: Arc<JwtKey>,
337) -> impl SpecFetcher {
338    move |url, exercise_service_slug, private_spec| {
339        let client = reqwest::Client::new();
340        let upload_claim = UploadClaim::expiring_in_1_day(exercise_service_slug);
341        let upload_url = Some(format!("{base_url}/api/v0/files/{exercise_service_slug}"));
342        let signed_upload_claim = match upload_claim.sign(&jwt_key) {
343            Ok(claim) => claim,
344            Err(err) => {
345                return async move {
346                    Err(ModelError::new(
347                        ModelErrorType::Generic,
348                        format!("Failed to sign upload claim: {err}"),
349                        Some(err.into()),
350                    ))
351                }
352                .boxed();
353            }
354        };
355        let req = client
356            .post(url.clone())
357            .header(EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER, signed_upload_claim)
358            .timeout(std::time::Duration::from_secs(120))
359            .json(&SpecRequest {
360                request_id,
361                private_spec,
362                upload_url,
363            })
364            .send();
365        async move {
366            let res = req.await.map_err(ModelError::from)?;
367            let status_code = res.status();
368            if !status_code.is_success() {
369                let error_text = res.text().await;
370                let error = error_text.as_deref().unwrap_or("(No text in response)");
371                error!(
372                    ?url,
373                    ?exercise_service_slug,
374                    ?private_spec,
375                    ?status_code,
376                    "Exercise service returned an error while generating a spec: {}",
377                    error
378                );
379                return Err(ModelError::new(
380                    ModelErrorType::HttpRequest {
381                        status_code: status_code.as_u16(),
382                        response_body: error.to_string(),
383                    },
384                    format!(
385                        "Failed to generate spec for exercise for {exercise_service_slug}: {error}."
386                    ),
387                    None,
388                ));
389            }
390            let json = parse_response_json(res).await?;
391            Ok(json)
392        }
393        .boxed()
394    }
395}
396
397// see `fetch_service_info_fast` while handling HTTP requests
398pub fn fetch_service_info(url: Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
399    fetch_service_info_with_timeout(url, 1000 * 120)
400}
401
402// use this while handling HTTP requests, see `fetch_service_info`
403pub fn fetch_service_info_fast(
404    url: Url,
405) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
406    fetch_service_info_with_timeout(url, 1000 * 5)
407}
408
409fn fetch_service_info_with_timeout(
410    url: Url,
411    timeout_ms: u64,
412) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
413    async move {
414        let client = reqwest::Client::new();
415        let res = client
416            .get(url) // e.g. http://example-exercise.default.svc.cluster.local:3002/example-exercise/api/service-info
417            .timeout(std::time::Duration::from_millis(timeout_ms))
418            .send()
419            .await
420            .map_err(ModelError::from)?;
421        let status = res.status();
422        if !status.is_success() {
423            let response_url = res.url().to_string();
424            let body = res.text().await.map_err(ModelError::from)?;
425            warn!(url=?response_url, status=?status, body=?body, "Could not fetch service info.");
426            return Err(ModelError::new(
427                ModelErrorType::HttpRequest {
428                    status_code: status.as_u16(),
429                    response_body: body,
430                },
431                "Could not fetch service info.".to_string(),
432                None,
433            ));
434        }
435        let res = parse_response_json(res).await?;
436        Ok(res)
437    }
438    .boxed()
439}
440
441pub fn make_grading_request_sender(
442    jwt_key: Arc<JwtKey>,
443) -> impl Fn(
444    Url,
445    &ExerciseTask,
446    &ExerciseTaskSubmission,
447) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>> {
448    move |grade_url, exercise_task, submission| {
449        let client = reqwest::Client::new();
450        // TODO: use real url
451        let grading_update_url = format!(
452            "http://project-331.local/api/v0/exercise-services/grading/grading-update/{}",
453            submission.id
454        );
455        let grading_update_claim = GradingUpdateClaim::expiring_in_1_day(submission.id);
456        let signed_grading_update_claim = match grading_update_claim.sign(&jwt_key) {
457            Ok(claim) => claim,
458            Err(err) => {
459                return async move {
460                    Err(ModelError::new(
461                        ModelErrorType::Generic,
462                        format!("Failed to sign grading update claim: {err}"),
463                        Some(err.into()),
464                    ))
465                }
466                .boxed();
467            }
468        };
469        let req = client
470            .post(grade_url)
471            .header(
472                EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER,
473                signed_grading_update_claim,
474            )
475            .timeout(std::time::Duration::from_secs(120))
476            .json(&ExerciseTaskGradingRequest {
477                grading_update_url: &grading_update_url,
478                exercise_spec: &exercise_task.private_spec,
479                submission_data: &submission.data_json,
480            });
481        async move {
482            let res = req.send().await.map_err(ModelError::from)?;
483            let status = res.status();
484            if !status.is_success() {
485                let status_code = status.as_u16();
486                let response_body = res.text().await.unwrap_or_default();
487                error!(
488                    ?response_body,
489                    status_code = %status_code,
490                    "Grading request returned an unsuccesful status code"
491                );
492
493                return Err(ModelError::new(
494                    ModelErrorType::HttpRequest {
495                        status_code,
496                        response_body: response_body.clone(),
497                    },
498                    format!(
499                        "Grading failed with status: {} response: {}",
500                        status_code, response_body
501                    ),
502                    None,
503                ));
504            }
505            let obj = parse_response_json(res).await?;
506            info!("Received a grading result: {:#?}", &obj);
507            Ok(obj)
508        }
509        .boxed()
510    }
511}
512
513pub async fn post_exercise_service_csv_export_request<T: Serialize>(
514    url: Url,
515    items: &[T],
516) -> ModelResult<ExerciseServiceCsvExportResponse> {
517    let client = reqwest::Client::new();
518    let response = client
519        .post(url.clone())
520        .timeout(std::time::Duration::from_secs(120))
521        .json(&ExerciseServiceCsvExportRequest { items })
522        .send()
523        .await
524        .map_err(ModelError::from)?;
525
526    let status = response.status();
527    if !status.is_success() {
528        let status_code = status.as_u16();
529        let response_body = response.text().await.unwrap_or_default();
530        error!(
531            ?response_body,
532            status_code = %status_code,
533            "Exercise service CSV export request returned an unsuccessful status code"
534        );
535
536        return Err(ModelError::new(
537            ModelErrorType::HttpRequest {
538                status_code,
539                response_body: response_body.clone(),
540            },
541            format!(
542                "CSV export request failed with status: {} response: {}",
543                status_code, response_body
544            ),
545            None,
546        ));
547    }
548
549    parse_response_json(response).await
550}
551
552#[derive(Debug, Serialize, Deserialize)]
553pub struct GivePeerReviewClaim {
554    pub exercise_slide_submission_id: Uuid,
555    pub peer_or_self_review_config_id: Uuid,
556    exp: usize,
557    iat: usize,
558}
559
560#[derive(Debug, Deserialize)]
561struct LegacyGivePeerReviewClaim {
562    exercise_slide_submission_id: Uuid,
563    peer_or_self_review_config_id: Uuid,
564    expiration_time: DateTime<Utc>,
565}
566
567impl GivePeerReviewClaim {
568    pub fn expiring_in_1_day(
569        exercise_slide_submission_id: Uuid,
570        peer_or_self_review_config_id: Uuid,
571    ) -> Self {
572        let now = Utc::now().timestamp().max(0) as usize;
573        let exp = (Utc::now().timestamp() + Duration::days(1).num_seconds()).max(0) as usize;
574        Self {
575            exercise_slide_submission_id,
576            peer_or_self_review_config_id,
577            exp,
578            iat: now,
579        }
580    }
581
582    pub fn sign(self, key: &JwtKey) -> Result<String, jsonwebtoken::errors::Error> {
583        sign_hs256_claim(&self, key)
584    }
585
586    pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
587        validate_peer_review_claim_with_legacy_fallback(token, key).map_err(|err| {
588            ControllerError::new(
589                ControllerErrorType::BadRequest,
590                format!("Invalid claim: {}", err),
591                Some(err.into()),
592            )
593        })
594    }
595}
596
597/// Signs any serializable claim payload as HS256 using the shared JWT secret.
598fn sign_hs256_claim<T: serde::Serialize>(
599    claim: &T,
600    key: &JwtKey,
601) -> Result<String, jsonwebtoken::errors::Error> {
602    encode(
603        &Header::new(Algorithm::HS256),
604        claim,
605        &EncodingKey::from_secret(&key.0),
606    )
607}
608
609/// Decodes and verifies an HS256 token into the requested claim type.
610fn validate_hs256_claim<T: serde::de::DeserializeOwned>(
611    token: &str,
612    key: &JwtKey,
613) -> Result<T, jsonwebtoken::errors::Error> {
614    let validation = Validation::new(Algorithm::HS256);
615    decode::<T>(token, &DecodingKey::from_secret(&key.0), &validation)
616        .map(|token_data| token_data.claims)
617}
618
619/// Decodes claims in compatibility mode and validates legacy `expiration_time` manually.
620fn validate_hs256_legacy_claim<T: serde::de::DeserializeOwned>(
621    token: &str,
622    key: &JwtKey,
623) -> Result<T, jsonwebtoken::errors::Error> {
624    let mut validation = Validation::new(Algorithm::HS256);
625    validation.required_spec_claims = std::collections::HashSet::new();
626    validation.validate_exp = false;
627    decode::<T>(token, &DecodingKey::from_secret(&key.0), &validation)
628        .map(|token_data| token_data.claims)
629}
630
631fn legacy_timestamp_to_claim_number(
632    timestamp: DateTime<Utc>,
633) -> Result<usize, jsonwebtoken::errors::Error> {
634    usize::try_from(timestamp.timestamp())
635        .map_err(|_| jsonwebtoken::errors::Error::from(ErrorKind::InvalidToken))
636}
637
638/// Validates upload claim using modern JWT fields, with temporary fallback to legacy claims.
639fn validate_upload_claim_with_legacy_fallback(
640    token: &str,
641    key: &JwtKey,
642) -> Result<UploadClaim, jsonwebtoken::errors::Error> {
643    match validate_hs256_claim::<UploadClaim>(token, key) {
644        Ok(claim) => Ok(claim),
645        Err(err) if matches!(err.kind(), ErrorKind::MissingRequiredClaim(claim) if claim == "exp") =>
646        {
647            let legacy: LegacyUploadClaim = validate_hs256_legacy_claim(token, key)?;
648            if legacy.expiration_time < Utc::now() {
649                return Err(jsonwebtoken::errors::Error::from(
650                    ErrorKind::ExpiredSignature,
651                ));
652            }
653            Ok(UploadClaim {
654                exercise_service_slug: legacy.exercise_service_slug,
655                exp: legacy_timestamp_to_claim_number(legacy.expiration_time)?,
656                iat: 0,
657            })
658        }
659        Err(err) => Err(err),
660    }
661}
662
663/// Validates grading update claim using modern JWT fields, with temporary fallback to legacy claims.
664fn validate_grading_update_claim_with_legacy_fallback(
665    token: &str,
666    key: &JwtKey,
667) -> Result<GradingUpdateClaim, jsonwebtoken::errors::Error> {
668    match validate_hs256_claim::<GradingUpdateClaim>(token, key) {
669        Ok(claim) => Ok(claim),
670        Err(err) if matches!(err.kind(), ErrorKind::MissingRequiredClaim(claim) if claim == "exp") =>
671        {
672            let legacy: LegacyGradingUpdateClaim = validate_hs256_legacy_claim(token, key)?;
673            if legacy.expiration_time < Utc::now() {
674                return Err(jsonwebtoken::errors::Error::from(
675                    ErrorKind::ExpiredSignature,
676                ));
677            }
678            Ok(GradingUpdateClaim {
679                submission_id: legacy.submission_id,
680                exp: legacy_timestamp_to_claim_number(legacy.expiration_time)?,
681                iat: 0,
682            })
683        }
684        Err(err) => Err(err),
685    }
686}
687
688/// Validates peer review claim using modern JWT fields, with temporary fallback to legacy claims.
689fn validate_peer_review_claim_with_legacy_fallback(
690    token: &str,
691    key: &JwtKey,
692) -> Result<GivePeerReviewClaim, jsonwebtoken::errors::Error> {
693    match validate_hs256_claim::<GivePeerReviewClaim>(token, key) {
694        Ok(claim) => Ok(claim),
695        Err(err) if matches!(err.kind(), ErrorKind::MissingRequiredClaim(claim) if claim == "exp") =>
696        {
697            let legacy: LegacyGivePeerReviewClaim = validate_hs256_legacy_claim(token, key)?;
698            if legacy.expiration_time < Utc::now() {
699                return Err(jsonwebtoken::errors::Error::from(
700                    ErrorKind::ExpiredSignature,
701                ));
702            }
703            Ok(GivePeerReviewClaim {
704                exercise_slide_submission_id: legacy.exercise_slide_submission_id,
705                peer_or_self_review_config_id: legacy.peer_or_self_review_config_id,
706                exp: legacy_timestamp_to_claim_number(legacy.expiration_time)?,
707                iat: 0,
708            })
709        }
710        Err(err) => Err(err),
711    }
712}
713
714/// A caching spec fetcher ONLY FOR THE SEED that returns a cached spec if the same
715/// (url, exercise_service_slug, private_spec) is requested. Since this is only used during seeding,
716/// there is no cache eviction.
717pub fn make_seed_spec_fetcher_with_cache(
718    base_url: String,
719    request_id: Uuid,
720    jwt_key: Arc<JwtKey>,
721) -> impl SpecFetcher {
722    // Cache key: (url, exercise_service_slug, private_spec serialized)
723    let cache: Arc<Mutex<SpecCache>> = Arc::new(Mutex::new(HashMap::new()));
724
725    // Create the base non-caching spec fetcher and wrap it in Arc to make it clonable
726    let base_fetcher = Arc::new(make_spec_fetcher(base_url, request_id, jwt_key));
727
728    move |url, exercise_service_slug, private_spec| {
729        let url_str = url.to_string();
730        let service_slug = exercise_service_slug.to_string();
731        // Convert private_spec to string for cache key if present
732        let private_spec_str =
733            private_spec.map(|spec| serde_json::to_string(&spec).unwrap_or_default());
734        let key = (url_str.clone(), service_slug.clone(), private_spec_str);
735        let cache = Arc::clone(&cache);
736        let base_fetcher = Arc::clone(&base_fetcher);
737
738        async move {
739            // Try to get from cache first
740            let cached_spec = {
741                let cache_guard = cache.lock().map_err(|err| {
742                    ModelError::new(
743                        ModelErrorType::Generic,
744                        format!("Seed spec fetcher cache lock poisoned: {err}"),
745                        None::<anyhow::Error>,
746                    )
747                })?;
748                cache_guard.get(&key).cloned()
749            };
750            if let Some(cached_spec) = cached_spec {
751                return Ok(cached_spec.clone());
752            }
753
754            // Not in cache - fetch using base fetcher
755            let fetched_spec = base_fetcher(url, exercise_service_slug, private_spec).await?;
756
757            // Store in cache
758            {
759                let mut cache_guard = cache.lock().map_err(|err| {
760                    ModelError::new(
761                        ModelErrorType::Generic,
762                        format!("Seed spec fetcher cache lock poisoned: {err}"),
763                        None::<anyhow::Error>,
764                    )
765                })?;
766                cache_guard.insert(key, fetched_spec.clone());
767            }
768
769            Ok(fetched_spec)
770        }
771        .boxed()
772    }
773}
774
775/// Safely parses a response body as JSON, capturing the actual response body in error cases
776async fn parse_response_json<T>(response: reqwest::Response) -> ModelResult<T>
777where
778    T: serde::de::DeserializeOwned,
779{
780    let status = response.status();
781    let response_text = response.text().await.map_err(ModelError::from)?;
782
783    serde_json::from_str(&response_text).map_err(|err| {
784        ModelError::new(
785            ModelErrorType::HttpError {
786                error_type: HttpErrorType::ResponseDecodeFailed,
787                reason: err.to_string(),
788                status_code: Some(status.as_u16()),
789                response_body: Some(response_text),
790            },
791            format!("Failed to decode JSON response: {}", err),
792            None,
793        )
794    })
795}