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};
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
32const 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
37type 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#[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#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
312pub struct ExerciseServiceCsvExportColumn {
313 pub key: String,
314 pub header: String,
315}
316
317#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
319pub struct ExerciseServiceCsvExportResult {
320 pub rows: Vec<HashMap<String, serde_json::Value>>,
321}
322
323#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
325pub struct ExerciseServiceCsvExportResponse {
326 pub columns: Vec<ExerciseServiceCsvExportColumn>,
327 pub results: Vec<ExerciseServiceCsvExportResult>,
328}
329
330pub 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
397pub fn fetch_service_info(url: Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
399 fetch_service_info_with_timeout(url, 1000 * 120)
400}
401
402pub 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) .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 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
597fn 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
609fn 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
619fn 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
638fn 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
663fn 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
688fn 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
714pub fn make_seed_spec_fetcher_with_cache(
718 base_url: String,
719 request_id: Uuid,
720 jwt_key: Arc<JwtKey>,
721) -> impl SpecFetcher {
722 let cache: Arc<Mutex<SpecCache>> = Arc::new(Mutex::new(HashMap::new()));
724
725 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 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 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 let fetched_spec = base_fetcher(url, exercise_service_slug, private_spec).await?;
756
757 {
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
775async 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}