1use 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
33const 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
38type 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#[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#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
313pub struct ExerciseServiceCsvExportColumn {
314 pub key: String,
315 pub header: String,
316}
317
318#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
320pub struct ExerciseServiceCsvExportResult {
321 pub rows: Vec<HashMap<String, serde_json::Value>>,
322}
323
324#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
326pub struct ExerciseServiceCsvExportResponse {
327 pub columns: Vec<ExerciseServiceCsvExportColumn>,
328 pub results: Vec<ExerciseServiceCsvExportResult>,
329}
330
331pub 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
398pub fn fetch_service_info(url: Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
400 fetch_service_info_with_timeout(url, 1000 * 120)
401}
402
403pub 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) .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 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
598fn 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
610fn 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
620fn 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
639fn 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
664fn 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
689fn 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
715pub fn make_seed_spec_fetcher_with_cache(
719 base_url: String,
720 request_id: Uuid,
721 jwt_key: Arc<JwtKey>,
722) -> impl SpecFetcher {
723 let cache: Arc<Mutex<SpecCache>> = Arc::new(Mutex::new(HashMap::new()));
725
726 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 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 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 let fetched_spec = base_fetcher(url, exercise_service_slug, private_spec).await?;
757
758 {
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
776async 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}