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::prelude::*;
4use actix_http::Payload;
5use actix_web::{FromRequest, HttpRequest};
6use chrono::{DateTime, Duration, Utc};
7use futures::{
8    FutureExt,
9    future::{BoxFuture, Ready, ready},
10};
11use headless_lms_models::{
12    HttpErrorType, ModelError, ModelErrorType, ModelResult,
13    exercise_service_info::ExerciseServiceInfoApi,
14    exercise_task_gradings::{ExerciseTaskGradingRequest, ExerciseTaskGradingResult},
15    exercise_task_submissions::ExerciseTaskSubmission,
16    exercise_tasks::ExerciseTask,
17};
18
19use headless_lms_utils::error::backend_error::BackendError;
20use hmac::{Hmac, Mac};
21use jwt::{SignWithKey, VerifyWithKey};
22use models::SpecFetcher;
23use sha2::Sha256;
24use std::collections::HashMap;
25use std::sync::{Arc, Mutex};
26use std::{borrow::Cow, fmt::Debug};
27use url::Url;
28
29use super::error::{ControllerError, ControllerErrorType};
30
31// keep in sync with the shared-module constants
32const EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER: &str = "exercise-service-grading-update-claim";
33const EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER: &str = "exercise-service-upload-claim";
34
35/// A type for caching the spec fetching (only for the seed)
36type SpecCache = HashMap<(String, String, Option<String>), serde_json::Value>;
37
38#[derive(Clone, Debug)]
39pub struct JwtKey(Hmac<Sha256>);
40
41impl JwtKey {
42    pub fn try_from_env() -> anyhow::Result<Self> {
43        let jwt_password = std::env::var("JWT_PASSWORD").context("JWT_PASSWORD must be defined")?;
44        let jwt_key = Self::new(&jwt_password)?;
45        Ok(jwt_key)
46    }
47
48    pub fn new(key: &str) -> Result<Self, sha2::digest::InvalidLength> {
49        let key: Hmac<Sha256> = Hmac::new_from_slice(key.as_bytes())?;
50        Ok(Self(key))
51    }
52
53    #[cfg(test)]
54    pub fn test_key() -> Self {
55        let test_jwt_key = "sMG87WlKnNZoITzvL2+jczriTR7JRsCtGu/bSKaSIvw=asdfjklasd***FSDfsdASDFDS";
56        Self::new(test_jwt_key).unwrap()
57    }
58}
59
60#[derive(Debug, Serialize, Deserialize)]
61pub struct UploadClaim<'a> {
62    exercise_service_slug: Cow<'a, str>,
63    expiration_time: DateTime<Utc>,
64}
65
66impl<'a> UploadClaim<'a> {
67    pub fn exercise_service_slug(&self) -> &str {
68        self.exercise_service_slug.as_ref()
69    }
70
71    pub fn expiration_time(&self) -> &DateTime<Utc> {
72        &self.expiration_time
73    }
74
75    pub fn expiring_in_1_day(exercise_service_slug: Cow<'a, str>) -> Self {
76        Self {
77            exercise_service_slug,
78            expiration_time: Utc::now() + Duration::days(1),
79        }
80    }
81
82    pub fn sign(self, key: &JwtKey) -> String {
83        self.sign_with_key(&key.0)
84            .expect("JWT signing failed - this should never happen with a valid key")
85    }
86
87    pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
88        let claim: Self = token.verify_with_key(&key.0).map_err(|err| {
89            ControllerError::new(
90                ControllerErrorType::BadRequest,
91                format!("Invalid jwt key: {}", err),
92                Some(err.into()),
93            )
94        })?;
95        if claim.expiration_time < Utc::now() {
96            return Err(ControllerError::new(
97                ControllerErrorType::BadRequest,
98                "Upload claim has expired".to_string(),
99                None,
100            ));
101        }
102        Ok(claim)
103    }
104}
105
106impl FromRequest for UploadClaim<'_> {
107    type Error = ControllerError;
108    type Future = Ready<Result<Self, Self::Error>>;
109
110    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
111        let try_from_request = move || {
112            let jwt_key = req.app_data::<web::Data<JwtKey>>().ok_or_else(|| {
113                ControllerError::new(
114                    ControllerErrorType::InternalServerError,
115                    "Missing JwtKey in app data - server configuration error".to_string(),
116                    None,
117                )
118            })?;
119            let header = req
120                .headers()
121                .get(EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER)
122                .ok_or_else(|| {
123                    ControllerError::new(
124                        ControllerErrorType::BadRequest,
125                        format!("Missing header {EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER}",),
126                        None,
127                    )
128                })?;
129            let header = std::str::from_utf8(header.as_bytes()).map_err(|err| {
130                ControllerError::new(
131                    ControllerErrorType::BadRequest,
132                    format!(
133                        "Invalid header {EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER} = {}",
134                        String::from_utf8_lossy(header.as_bytes())
135                    ),
136                    Some(err.into()),
137                )
138            })?;
139            let claim = UploadClaim::validate(header, jwt_key)?;
140            Result::<_, Self::Error>::Ok(claim)
141        };
142        ready(try_from_request())
143    }
144}
145
146#[derive(Debug, Serialize, Deserialize)]
147pub struct GradingUpdateClaim {
148    submission_id: Uuid,
149    expiration_time: DateTime<Utc>,
150}
151
152impl GradingUpdateClaim {
153    pub fn submission_id(&self) -> Uuid {
154        self.submission_id
155    }
156
157    pub fn expiration_time(&self) -> &DateTime<Utc> {
158        &self.expiration_time
159    }
160
161    pub fn expiring_in_1_day(submission_id: Uuid) -> Self {
162        Self {
163            submission_id,
164            expiration_time: Utc::now() + Duration::days(1),
165        }
166    }
167
168    pub fn sign(self, key: &JwtKey) -> String {
169        self.sign_with_key(&key.0)
170            .expect("JWT signing failed - this should never happen with a valid key")
171    }
172
173    pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
174        let claim: Self = token.verify_with_key(&key.0).map_err(|err| {
175            ControllerError::new(
176                ControllerErrorType::BadRequest,
177                format!("Invalid jwt key: {}", err),
178                Some(err.into()),
179            )
180        })?;
181        if claim.expiration_time < Utc::now() {
182            return Err(ControllerError::new(
183                ControllerErrorType::BadRequest,
184                "Grading update claim has expired".to_string(),
185                None,
186            ));
187        }
188        Ok(claim)
189    }
190}
191
192impl FromRequest for GradingUpdateClaim {
193    type Error = ControllerError;
194    type Future = Ready<Result<Self, Self::Error>>;
195
196    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
197        let try_from_request = move || {
198            let jwt_key = req.app_data::<web::Data<JwtKey>>().ok_or_else(|| {
199                ControllerError::new(
200                    ControllerErrorType::InternalServerError,
201                    "Missing JwtKey in app data - server configuration error".to_string(),
202                    None,
203                )
204            })?;
205            let header = req
206                .headers()
207                .get(EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER)
208                .ok_or_else(|| {
209                    ControllerError::new(
210                        ControllerErrorType::BadRequest,
211                        format!("Missing header {EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER}",),
212                        None,
213                    )
214                })?;
215            let header = std::str::from_utf8(header.as_bytes()).map_err(|err| {
216                ControllerError::new(
217                    ControllerErrorType::BadRequest,
218                    format!(
219                        "Invalid header {EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER} = {}",
220                        String::from_utf8_lossy(header.as_bytes())
221                    ),
222                    Some(err.into()),
223                )
224            })?;
225            let claim = GradingUpdateClaim::validate(header, jwt_key)?;
226            Result::<_, Self::Error>::Ok(claim)
227        };
228        ready(try_from_request())
229    }
230}
231
232/// Accepted by the public-spec and model-solution endpoints of exercise services.
233#[derive(Debug, Serialize)]
234#[cfg_attr(feature = "ts_rs", derive(TS))]
235pub struct SpecRequest<'a> {
236    request_id: Uuid,
237    private_spec: Option<&'a serde_json::Value>,
238    upload_url: Option<String>,
239}
240
241#[derive(Debug, Serialize)]
242pub struct ExerciseServiceCsvExportRequest<'a, T: Serialize> {
243    pub items: &'a [T],
244}
245
246/// Column definition for exercise service CSV export; callers must use scalar-only cell values.
247#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
248pub struct ExerciseServiceCsvExportColumn {
249    pub key: String,
250    pub header: String,
251}
252
253/// One batch of CSV rows; each row's values must be scalar (null, bool, number, string). Objects/arrays are rejected by the controller.
254#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
255pub struct ExerciseServiceCsvExportResult {
256    pub rows: Vec<HashMap<String, serde_json::Value>>,
257}
258
259/// Full CSV export response; columns define headers, results align by index. All cell values must be scalar.
260#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
261pub struct ExerciseServiceCsvExportResponse {
262    pub columns: Vec<ExerciseServiceCsvExportColumn>,
263    pub results: Vec<ExerciseServiceCsvExportResult>,
264}
265
266/// Fetches a public/model spec based on the private spec from the given url.
267/// The slug and jwt key are used for an upload claim that allows the service
268/// to upload files as part of the spec.
269pub fn make_spec_fetcher(
270    base_url: String,
271    request_id: Uuid,
272    jwt_key: Arc<JwtKey>,
273) -> impl SpecFetcher {
274    move |url, exercise_service_slug, private_spec| {
275        let client = reqwest::Client::new();
276        let upload_claim = UploadClaim::expiring_in_1_day(exercise_service_slug.into());
277        let upload_url = Some(format!("{base_url}/api/v0/files/{exercise_service_slug}"));
278        let req = client
279            .post(url.clone())
280            .header(
281                EXERCISE_SERVICE_UPLOAD_CLAIM_HEADER,
282                upload_claim.sign(&jwt_key),
283            )
284            .timeout(std::time::Duration::from_secs(120))
285            .json(&SpecRequest {
286                request_id,
287                private_spec,
288                upload_url,
289            })
290            .send();
291        async move {
292            let res = req.await.map_err(ModelError::from)?;
293            let status_code = res.status();
294            if !status_code.is_success() {
295                let error_text = res.text().await;
296                let error = error_text.as_deref().unwrap_or("(No text in response)");
297                error!(
298                    ?url,
299                    ?exercise_service_slug,
300                    ?private_spec,
301                    ?status_code,
302                    "Exercise service returned an error while generating a spec: {}",
303                    error
304                );
305                return Err(ModelError::new(
306                    ModelErrorType::HttpRequest {
307                        status_code: status_code.as_u16(),
308                        response_body: error.to_string(),
309                    },
310                    format!(
311                        "Failed to generate spec for exercise for {exercise_service_slug}: {error}."
312                    ),
313                    None,
314                ));
315            }
316            let json = parse_response_json(res).await?;
317            Ok(json)
318        }
319        .boxed()
320    }
321}
322
323// see `fetch_service_info_fast` while handling HTTP requests
324pub fn fetch_service_info(url: Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
325    fetch_service_info_with_timeout(url, 1000 * 120)
326}
327
328// use this while handling HTTP requests, see `fetch_service_info`
329pub fn fetch_service_info_fast(
330    url: Url,
331) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
332    fetch_service_info_with_timeout(url, 1000 * 5)
333}
334
335fn fetch_service_info_with_timeout(
336    url: Url,
337    timeout_ms: u64,
338) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>> {
339    async move {
340        let client = reqwest::Client::new();
341        let res = client
342            .get(url) // e.g. http://example-exercise.default.svc.cluster.local:3002/example-exercise/api/service-info
343            .timeout(std::time::Duration::from_millis(timeout_ms))
344            .send()
345            .await
346            .map_err(ModelError::from)?;
347        let status = res.status();
348        if !status.is_success() {
349            let response_url = res.url().to_string();
350            let body = res.text().await.map_err(ModelError::from)?;
351            warn!(url=?response_url, status=?status, body=?body, "Could not fetch service info.");
352            return Err(ModelError::new(
353                ModelErrorType::HttpRequest {
354                    status_code: status.as_u16(),
355                    response_body: body,
356                },
357                "Could not fetch service info.".to_string(),
358                None,
359            ));
360        }
361        let res = parse_response_json(res).await?;
362        Ok(res)
363    }
364    .boxed()
365}
366
367pub fn make_grading_request_sender(
368    jwt_key: Arc<JwtKey>,
369) -> impl Fn(
370    Url,
371    &ExerciseTask,
372    &ExerciseTaskSubmission,
373) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>> {
374    move |grade_url, exercise_task, submission| {
375        let client = reqwest::Client::new();
376        // TODO: use real url
377        let grading_update_url = format!(
378            "http://project-331.local/api/v0/exercise-services/grading/grading-update/{}",
379            submission.id
380        );
381        let grading_update_claim = GradingUpdateClaim::expiring_in_1_day(submission.id);
382        let req = client
383            .post(grade_url)
384            .header(
385                EXERCISE_SERVICE_GRADING_UPDATE_CLAIM_HEADER,
386                grading_update_claim.sign(&jwt_key),
387            )
388            .timeout(std::time::Duration::from_secs(120))
389            .json(&ExerciseTaskGradingRequest {
390                grading_update_url: &grading_update_url,
391                exercise_spec: &exercise_task.private_spec,
392                submission_data: &submission.data_json,
393            });
394        async move {
395            let res = req.send().await.map_err(ModelError::from)?;
396            let status = res.status();
397            if !status.is_success() {
398                let status_code = status.as_u16();
399                let response_body = res.text().await.unwrap_or_default();
400                error!(
401                    ?response_body,
402                    status_code = %status_code,
403                    "Grading request returned an unsuccesful status code"
404                );
405
406                return Err(ModelError::new(
407                    ModelErrorType::HttpRequest {
408                        status_code,
409                        response_body: response_body.clone(),
410                    },
411                    format!(
412                        "Grading failed with status: {} response: {}",
413                        status_code, response_body
414                    ),
415                    None,
416                ));
417            }
418            let obj = parse_response_json(res).await?;
419            info!("Received a grading result: {:#?}", &obj);
420            Ok(obj)
421        }
422        .boxed()
423    }
424}
425
426pub async fn post_exercise_service_csv_export_request<T: Serialize>(
427    url: Url,
428    items: &[T],
429) -> ModelResult<ExerciseServiceCsvExportResponse> {
430    let client = reqwest::Client::new();
431    let response = client
432        .post(url.clone())
433        .timeout(std::time::Duration::from_secs(120))
434        .json(&ExerciseServiceCsvExportRequest { items })
435        .send()
436        .await
437        .map_err(ModelError::from)?;
438
439    let status = response.status();
440    if !status.is_success() {
441        let status_code = status.as_u16();
442        let response_body = response.text().await.unwrap_or_default();
443        error!(
444            ?response_body,
445            status_code = %status_code,
446            "Exercise service CSV export request returned an unsuccessful status code"
447        );
448
449        return Err(ModelError::new(
450            ModelErrorType::HttpRequest {
451                status_code,
452                response_body: response_body.clone(),
453            },
454            format!(
455                "CSV export request failed with status: {} response: {}",
456                status_code, response_body
457            ),
458            None,
459        ));
460    }
461
462    parse_response_json(response).await
463}
464
465#[derive(Debug, Serialize, Deserialize)]
466pub struct GivePeerReviewClaim {
467    pub exercise_slide_submission_id: Uuid,
468    pub peer_or_self_review_config_id: Uuid,
469    expiration_time: DateTime<Utc>,
470}
471
472impl GivePeerReviewClaim {
473    pub fn expiring_in_1_day(
474        exercise_slide_submission_id: Uuid,
475        peer_or_self_review_config_id: Uuid,
476    ) -> Self {
477        Self {
478            exercise_slide_submission_id,
479            peer_or_self_review_config_id,
480            expiration_time: Utc::now() + Duration::days(1),
481        }
482    }
483
484    pub fn sign(self, key: &JwtKey) -> String {
485        self.sign_with_key(&key.0)
486            .expect("JWT signing failed - this should never happen with a valid key")
487    }
488
489    pub fn validate(token: &str, key: &JwtKey) -> Result<Self, ControllerError> {
490        let claim: Self = token.verify_with_key(&key.0).map_err(|err| {
491            ControllerError::new(
492                ControllerErrorType::BadRequest,
493                format!("Invalid claim: {}", err),
494                Some(err.into()),
495            )
496        })?;
497        if claim.expiration_time < Utc::now() {
498            return Err(ControllerError::new(
499                ControllerErrorType::BadRequest,
500                "The review has expired.".to_string(),
501                None,
502            ));
503        }
504        Ok(claim)
505    }
506}
507
508/// A caching spec fetcher ONLY FOR THE SEED that returns a cached spec if the same
509/// (url, exercise_service_slug, private_spec) is requested. Since this is only used during seeding,
510/// there is no cache eviction.
511pub fn make_seed_spec_fetcher_with_cache(
512    base_url: String,
513    request_id: Uuid,
514    jwt_key: Arc<JwtKey>,
515) -> impl SpecFetcher {
516    // Cache key: (url, exercise_service_slug, private_spec serialized)
517    let cache: Arc<Mutex<SpecCache>> = Arc::new(Mutex::new(HashMap::new()));
518
519    // Create the base non-caching spec fetcher and wrap it in Arc to make it clonable
520    let base_fetcher = Arc::new(make_spec_fetcher(base_url, request_id, jwt_key));
521
522    move |url, exercise_service_slug, private_spec| {
523        let url_str = url.to_string();
524        let service_slug = exercise_service_slug.to_string();
525        // Convert private_spec to string for cache key if present
526        let private_spec_str =
527            private_spec.map(|spec| serde_json::to_string(&spec).unwrap_or_default());
528        let key = (url_str.clone(), service_slug.clone(), private_spec_str);
529        let cache = Arc::clone(&cache);
530        let base_fetcher = Arc::clone(&base_fetcher);
531
532        async move {
533            // Try to get from cache first
534            if let Some(cached_spec) = cache
535                .lock()
536                .expect("Seed spec fetcher cache lock poisoned")
537                .get(&key)
538            {
539                return Ok(cached_spec.clone());
540            }
541
542            // Not in cache - fetch using base fetcher
543            let fetched_spec = base_fetcher(url, exercise_service_slug, private_spec).await?;
544
545            // Store in cache
546            cache
547                .lock()
548                .expect("Seed spec fetcher cache lock poisoned")
549                .insert(key, fetched_spec.clone());
550
551            Ok(fetched_spec)
552        }
553        .boxed()
554    }
555}
556
557/// Safely parses a response body as JSON, capturing the actual response body in error cases
558async fn parse_response_json<T>(response: reqwest::Response) -> ModelResult<T>
559where
560    T: serde::de::DeserializeOwned,
561{
562    let status = response.status();
563    let response_text = response.text().await.map_err(ModelError::from)?;
564
565    serde_json::from_str(&response_text).map_err(|err| {
566        ModelError::new(
567            ModelErrorType::HttpError {
568                error_type: HttpErrorType::ResponseDecodeFailed,
569                reason: err.to_string(),
570                status_code: Some(status.as_u16()),
571                response_body: Some(response_text),
572            },
573            format!("Failed to decode JSON response: {}", err),
574            None,
575        )
576    })
577}