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