tmc_mooc_client/
lib.rs

1#![deny(clippy::print_stdout, clippy::print_stderr, clippy::unwrap_used)]
2
3//! Used to communicate with the Courses MOOC server. See the `MoocClient` struct for more details.
4
5mod error;
6mod exercise;
7
8pub use self::{
9    error::{MoocClientError, MoocClientResult},
10    exercise::{ExerciseFile, ModelSolutionSpec, PublicSpec, TmcExerciseSlide, TmcExerciseTask},
11};
12use bytes::Bytes;
13use chrono::{DateTime, Utc};
14pub use mooc_langs_api as api;
15use mooc_langs_api::ExerciseUpdateData;
16use oauth2::TokenResponse;
17use reqwest::{
18    Method, StatusCode,
19    blocking::{
20        Client, RequestBuilder, Response,
21        multipart::{Form, Part},
22    },
23};
24use schemars::JsonSchema;
25use serde::{Serialize, de::DeserializeOwned};
26use std::{path::Path, sync::Arc};
27use tmc_langs_util::{JsonError, serialize};
28#[cfg(feature = "ts-rs")]
29use ts_rs::TS;
30use url::Url;
31use uuid::Uuid;
32
33/// Client for accessing the Courses MOOC API.
34/// Uses an `Arc` internally so it is cheap to clone.
35#[derive(Clone)]
36pub struct MoocClient(Arc<MoocClientInner>);
37
38struct MoocClientInner {
39    client: Client,
40    root_url: Url,
41    token: Option<api::Token>,
42}
43
44/// Non-API methods.
45impl MoocClient {
46    /// Creates a new client.
47    pub fn new(root_url: Url) -> Self {
48        // guarantee a trailing slash, otherwise join will drop the last component
49        let root_url = if root_url.as_str().ends_with('/') {
50            root_url
51        } else {
52            format!("{root_url}/").parse().expect("invalid root url")
53        };
54
55        Self(Arc::new(MoocClientInner {
56            client: Client::new(),
57            root_url,
58            token: None,
59        }))
60    }
61
62    fn request(&self, method: Method, url: Url) -> MoocRequest {
63        log::debug!("building a request to {url}");
64
65        let trusted_domains = &["courses.mooc.fi", "project-331.local"];
66        let is_trusted_domain = url
67            .domain()
68            .map(|d| trusted_domains.contains(&d))
69            .unwrap_or_default();
70        let mut builder = self.0.client.request(method.clone(), url.clone());
71        if let Some(token) = self.0.token.as_ref() {
72            if is_trusted_domain {
73                log::debug!("setting bearer token");
74                builder = builder.bearer_auth(token.access_token().secret());
75            } else {
76                log::debug!("leaving out bearer token due to untrusted domain");
77            }
78        } else {
79            log::debug!("no bearer token");
80        }
81        MoocRequest {
82            url,
83            method,
84            builder,
85        }
86    }
87
88    pub fn set_token(&mut self, token: api::Token) {
89        Arc::get_mut(&mut self.0)
90            .expect("called when multiple clones exist")
91            .token = Some(token);
92    }
93}
94
95/// API methods.
96impl MoocClient {
97    pub fn course_instances(&self) -> MoocClientResult<Vec<CourseInstance>> {
98        let url = make_langs_api_url(self, "course-instances")?;
99        let res = self
100            .request(Method::GET, url)
101            .send_expect_json::<Vec<api::CourseInstance>>()?;
102        Ok(res.into_iter().map(Into::into).collect())
103    }
104
105    pub fn course_instance_exercises(
106        &self,
107        course_instance: Uuid,
108    ) -> MoocClientResult<Vec<TmcExerciseSlide>> {
109        let url = make_langs_api_url(
110            self,
111            format!("course-instances/{course_instance}/exercises"),
112        )?;
113        let res = self
114            .request(Method::GET, url.clone())
115            .send_expect_json::<Vec<api::ExerciseSlide>>()?
116            .into_iter()
117            .map(TryFrom::try_from)
118            .collect::<Result<_, JsonError>>()
119            .map_err(|err| MoocClientError::DeserializingResponse {
120                url,
121                error: err.into(),
122            })?;
123        Ok(res)
124    }
125
126    pub fn exercise(&self, exercise: Uuid) -> MoocClientResult<TmcExerciseSlide> {
127        let url = make_langs_api_url(self, format!("exercises/{exercise}"))?;
128        let res = self
129            .request(Method::GET, url.clone())
130            .send_expect_json::<api::ExerciseSlide>()?
131            .try_into()
132            .map_err(|err: JsonError| MoocClientError::DeserializingResponse {
133                url,
134                error: err.into(),
135            })?;
136        Ok(res)
137    }
138
139    pub fn check_exercise_updates(
140        &self,
141        exercises: &[ExerciseUpdateData],
142    ) -> MoocClientResult<ExerciseUpdates> {
143        let url = make_langs_api_url(self, "updates")?;
144        let res = self
145            .request(Method::POST, url.clone())
146            .json(&api::ExerciseUpdatesRequest { exercises })
147            .send_expect_json::<api::ExerciseUpdates>()?
148            .into();
149        Ok(res)
150    }
151
152    pub fn download(&self, url: Url) -> MoocClientResult<Bytes> {
153        let res = self.request(Method::GET, url).send_expect_bytes()?;
154        Ok(res)
155    }
156
157    pub fn download_exercise(&self, exercise: Uuid) -> MoocClientResult<Bytes> {
158        let url = make_langs_api_url(self, format!("exercises/{exercise}/download"))?;
159        let res = self.request(Method::GET, url).send_expect_bytes()?;
160        Ok(res)
161    }
162
163    pub fn submit(
164        &self,
165        exercise_id: Uuid,
166        slide_id: Uuid,
167        task_id: Uuid,
168        archive: &Path,
169    ) -> MoocClientResult<ExerciseTaskSubmissionResult> {
170        let exercise_slide_submission = api::ExerciseSlideSubmission {
171            exercise_slide_id: slide_id,
172            exercise_task_id: task_id,
173            data_json: serde_json::Value::Null,
174        };
175        let exercise_slide_submission = serialize::to_json_vec(&exercise_slide_submission)
176            .map_err(Into::into)
177            .map_err(Box::new)?;
178        let submission = Form::new()
179            .part(
180                "metadata",
181                Part::bytes(exercise_slide_submission)
182                    .mime_str("application/json")
183                    .expect("known to work"),
184            )
185            .file("file", archive)
186            .map_err(|err| MoocClientError::AttachFileToForm { error: err.into() })?;
187
188        // send submission
189        //let user_answer = UserAnswer::Editor {
190        //archive_download_url: res.download_url,
191        //};
192        //let data_json = serialize::to_json_value(&user_answer)?;
193        //let exercise_slide_submission = api::ExerciseSlideSubmission {
194        //exercise_slide_id: slide_id,
195        //exercise_task_id: task_id,
196        //data_json,
197        //};
198        let url = make_langs_api_url(self, format!("exercises/{exercise_id}/submit"))?;
199        let res = self
200            .request(Method::POST, url)
201            .multipart(submission)
202            .send_expect_json::<api::ExerciseTaskSubmissionResult>()?;
203        Ok(res.into())
204    }
205
206    pub fn get_submission_grading(
207        &self,
208        submission_id: Uuid,
209    ) -> MoocClientResult<ExerciseTaskSubmissionStatus> {
210        let url = make_langs_api_url(self, format!("submissions/{submission_id}/grading"))?;
211        let res = self
212            .request(Method::GET, url)
213            .send_expect_json::<api::ExerciseTaskSubmissionStatus>()?;
214        Ok(res.into())
215    }
216}
217
218/// Helper for creating and sending requests.
219struct MoocRequest {
220    url: Url,
221    method: Method,
222    builder: RequestBuilder,
223}
224
225impl MoocRequest {
226    fn json<T: Serialize>(mut self, json: &T) -> Self {
227        self.builder = self.builder.json(json);
228        self
229    }
230
231    fn multipart(mut self, form: Form) -> Self {
232        self.builder = self.builder.multipart(form);
233        self
234    }
235
236    fn send(self) -> MoocClientResult<Response> {
237        match self.builder.send() {
238            Ok(res) => {
239                let status = res.status();
240                match status {
241                    _success if status.is_success() => Ok(res),
242                    StatusCode::UNAUTHORIZED => Err(Box::new(MoocClientError::NotAuthenticated)),
243                    _other => {
244                        let status = res.status();
245                        let body =
246                            res.text()
247                                .map_err(|err| MoocClientError::ReadingResponseBody {
248                                    method: self.method,
249                                    url: self.url.clone(),
250                                    error: Box::new(err),
251                                })?;
252                        Err(Box::new(MoocClientError::HttpError {
253                            url: self.url,
254                            status,
255                            error: body,
256                            obsolete_client: false,
257                        }))
258                    }
259                }
260            }
261            Err(error) => Err(Box::new(MoocClientError::ConnectionError(
262                self.method,
263                self.url,
264                error,
265            ))),
266        }
267    }
268
269    fn send_expect_bytes(self) -> MoocClientResult<Bytes> {
270        let method = self.method.clone();
271        let url = self.url.clone();
272        let res = self.send()?;
273        let body = res
274            .bytes()
275            .map_err(|err| MoocClientError::ReadingResponseBody {
276                method,
277                url,
278                error: Box::new(err),
279            })?;
280        Ok(body)
281    }
282
283    fn send_expect_json<T>(self) -> MoocClientResult<T>
284    where
285        T: DeserializeOwned,
286    {
287        let url = self.url.clone();
288        let bytes = self.send_expect_bytes()?;
289        let json = serde_json::from_slice(&bytes).map_err(|err| {
290            MoocClientError::DeserializingResponse {
291                url,
292                error: Box::new(err),
293            }
294        })?;
295        Ok(json)
296    }
297}
298
299// joins the URL "tail" with the API url root from the client
300fn make_langs_api_url(client: &MoocClient, tail: impl AsRef<str>) -> MoocClientResult<Url> {
301    client
302        .0
303        .root_url
304        .join("/api/v0/langs/")
305        .and_then(|u| u.join(tail.as_ref()))
306        .map_err(|e| MoocClientError::UrlParse(tail.as_ref().to_string(), e))
307        .map_err(Box::new)
308}
309
310#[derive(Debug, Serialize, JsonSchema)]
311#[cfg_attr(feature = "ts-rs", derive(TS))]
312pub struct CourseInstance {
313    pub id: Uuid,
314    pub course_id: Uuid,
315    pub course_slug: String,
316    pub course_name: String,
317    pub course_description: Option<String>,
318    pub instance_name: Option<String>,
319    pub instance_description: Option<String>,
320}
321
322impl From<api::CourseInstance> for CourseInstance {
323    fn from(value: api::CourseInstance) -> Self {
324        Self {
325            id: value.id,
326            course_id: value.course_id,
327            course_slug: value.course_slug,
328            course_name: value.course_name,
329            course_description: value.course_description,
330            instance_name: value.instance_name,
331            instance_description: value.instance_description,
332        }
333    }
334}
335
336#[derive(Debug, Serialize, JsonSchema)]
337#[cfg_attr(feature = "ts-rs", derive(TS))]
338pub struct CourseInstanceInfo {
339    pub id: Uuid,
340    pub course_id: Uuid,
341    pub course_slug: String,
342    pub course_name: String,
343    pub course_description: Option<String>,
344    pub instance_name: Option<String>,
345    pub instance_description: Option<String>,
346}
347
348#[derive(Debug, Serialize)]
349#[cfg_attr(feature = "ts-rs", derive(TS))]
350pub struct ExerciseTaskSubmissionResult {
351    pub submission_id: Uuid,
352}
353
354impl From<api::ExerciseTaskSubmissionResult> for ExerciseTaskSubmissionResult {
355    fn from(value: api::ExerciseTaskSubmissionResult) -> Self {
356        Self {
357            submission_id: value.submission_id,
358        }
359    }
360}
361
362#[derive(Debug, Serialize)]
363#[cfg_attr(feature = "ts-rs", derive(TS))]
364pub enum ExerciseTaskSubmissionStatus {
365    NoGradingYet,
366    Grading {
367        grading_progress: GradingProgress,
368        score_given: Option<f32>,
369        grading_started_at: Option<DateTime<Utc>>,
370        grading_completed_at: Option<DateTime<Utc>>,
371        feedback_json: Option<serde_json::Value>,
372        feedback_text: Option<String>,
373    },
374}
375
376impl From<api::ExerciseTaskSubmissionStatus> for ExerciseTaskSubmissionStatus {
377    fn from(value: api::ExerciseTaskSubmissionStatus) -> Self {
378        match value {
379            api::ExerciseTaskSubmissionStatus::NoGradingYet => Self::NoGradingYet,
380            api::ExerciseTaskSubmissionStatus::Grading {
381                grading_progress,
382                score_given,
383                grading_started_at,
384                grading_completed_at,
385                feedback_json,
386                feedback_text,
387            } => Self::Grading {
388                grading_progress: grading_progress.into(),
389                score_given,
390                grading_started_at,
391                grading_completed_at,
392                feedback_json,
393                feedback_text,
394            },
395        }
396    }
397}
398
399#[derive(Debug, Clone, Copy, Serialize)]
400#[cfg_attr(feature = "ts-rs", derive(TS))]
401pub enum GradingProgress {
402    /// The grading could not complete.
403    Failed,
404    /// There is no grading process occurring; for example, the student has not yet made any submission.
405    NotReady,
406    /// Final Grade is pending, and it does require human intervention; if a Score value is present, it indicates the current value is partial and may be updated during the manual grading.
407    PendingManual,
408    /// Final Grade is pending, but does not require manual intervention; if a Score value is present, it indicates the current value is partial and may be updated.
409    Pending,
410    /// The grading process is completed; the score value, if any, represents the current Final Grade;
411    FullyGraded,
412}
413
414impl From<api::GradingProgress> for GradingProgress {
415    fn from(value: api::GradingProgress) -> Self {
416        match value {
417            api::GradingProgress::Failed => Self::Failed,
418            api::GradingProgress::NotReady => Self::NotReady,
419            api::GradingProgress::PendingManual => Self::PendingManual,
420            api::GradingProgress::Pending => Self::Pending,
421            api::GradingProgress::FullyGraded => Self::FullyGraded,
422        }
423    }
424}
425
426#[derive(Debug, Serialize)]
427pub struct ExerciseUpdates {
428    pub updated_exercises: Vec<Uuid>,
429    pub deleted_exercises: Vec<Uuid>,
430}
431
432impl From<api::ExerciseUpdates> for ExerciseUpdates {
433    fn from(value: api::ExerciseUpdates) -> Self {
434        Self {
435            updated_exercises: value.updated_exercises,
436            deleted_exercises: value.deleted_exercises,
437        }
438    }
439}
440
441#[cfg(test)]
442mod test {
443    use super::*;
444    use mockito::Server;
445    use mooc_langs_api::Token;
446    use oauth2::{AccessToken, EmptyExtraTokenFields, basic::BasicTokenType};
447
448    fn init() {
449        use log::*;
450        use simple_logger::*;
451
452        let _ = SimpleLogger::new()
453            .with_level(LevelFilter::Debug)
454            // mockito does some logging
455            .with_module_level("mockito", LevelFilter::Warn)
456            // reqwest does a lot of logging
457            .with_module_level("reqwest", LevelFilter::Warn)
458            // hyper does a lot of logging
459            .with_module_level("hyper", LevelFilter::Warn)
460            .init();
461    }
462
463    fn make_client(server: &Server) -> MoocClient {
464        let mut client = MoocClient::new(server.url().parse().unwrap());
465        let token = Token::new(
466            AccessToken::new("".to_string()),
467            BasicTokenType::Bearer,
468            EmptyExtraTokenFields {},
469        );
470        client.set_token(token);
471        client
472    }
473
474    #[test]
475    fn gets_course_instances() {
476        init();
477        let mut server = Server::new();
478        let client = make_client(&server);
479        server
480            .mock("GET", "/api/v0/langs/course-instances")
481            .with_body(
482                serde_json::json!([{
483                    "id": Uuid::new_v4(),
484                    "course_id": Uuid::new_v4(),
485                    "course_slug": "mockslug",
486                    "course_name": "mockname",
487                    "course_description": "mockdesc",
488                }])
489                .to_string(),
490            )
491            .create();
492        let course_instances = client.course_instances().unwrap();
493        assert_eq!(course_instances[0].course_name, "mockname");
494    }
495
496    #[test]
497    fn gets_course_instance_exercise_slides() {
498        init();
499        let mut server = Server::new();
500        let client = make_client(&server);
501        server
502            .mock(
503                "GET",
504                "/api/v0/langs/course-instances/df5ee6c1-57d1-43b6-b39e-5d72119edb5f/exercises",
505            )
506            .with_body(
507                serde_json::json!([{
508                    "slide_id": Uuid::new_v4(),
509                    "exercise_id": Uuid::new_v4(),
510                    "exercise_name": "mockname",
511                    "exercise_order_number": 0,
512                    "tasks": [],
513                }])
514                .to_string(),
515            )
516            .create();
517        let exercise_sludes = client
518            .course_instance_exercises(
519                Uuid::parse_str("df5ee6c1-57d1-43b6-b39e-5d72119edb5f").unwrap(),
520            )
521            .unwrap();
522        assert_eq!(exercise_sludes[0].exercise_name, "mockname");
523    }
524
525    #[test]
526    fn gets_exercise() {
527        init();
528        let mut server = Server::new();
529        let client = make_client(&server);
530        server
531            .mock(
532                "GET",
533                "/api/v0/langs/exercises/df5ee6c1-57d1-43b6-b39e-5d72119edb5f",
534            )
535            .with_body(
536                serde_json::json!({
537                    "slide_id": Uuid::new_v4(),
538                    "exercise_id": Uuid::new_v4(),
539                    "exercise_name": "mockname",
540                    "exercise_order_number": 0,
541                    "tasks": [],
542                })
543                .to_string(),
544            )
545            .create();
546        let exercise = client
547            .exercise(Uuid::parse_str("df5ee6c1-57d1-43b6-b39e-5d72119edb5f").unwrap())
548            .unwrap();
549        assert_eq!(exercise.exercise_name, "mockname");
550    }
551
552    #[test]
553    fn downloads_exercise() {
554        init();
555        let mut server = Server::new();
556        let client = make_client(&server);
557        server
558            .mock(
559                "GET",
560                "/api/v0/langs/exercises/df5ee6c1-57d1-43b6-b39e-5d72119edb5f/download",
561            )
562            .with_body_from_file("./tests/data/file")
563            .create();
564        let exercise = client
565            .download_exercise(Uuid::parse_str("df5ee6c1-57d1-43b6-b39e-5d72119edb5f").unwrap())
566            .unwrap();
567        assert_eq!(String::from_utf8(exercise.into()).unwrap(), "hello!");
568    }
569
570    #[test]
571    fn submits() {
572        init();
573        let mut server = Server::new();
574        let client = make_client(&server);
575        server
576            .mock(
577                "POST",
578                "/api/v0/langs/exercises/df5ee6c1-57d1-43b6-b39e-5d72119edb5f/submit",
579            )
580            .with_body(
581                serde_json::json!({
582                    "submission_id": Uuid::new_v4(),
583                })
584                .to_string(),
585            )
586            .create();
587        let _submission_result = client
588            .submit(
589                Uuid::parse_str("df5ee6c1-57d1-43b6-b39e-5d72119edb5f").unwrap(),
590                Uuid::parse_str("e7bd5a07-1b83-4c97-91f2-e48cccf66b2a").unwrap(),
591                Uuid::parse_str("816ac03a-a713-4804-9ea6-3eb5e278ec2b").unwrap(),
592                Path::new("./tests/data/file"),
593            )
594            .unwrap();
595    }
596
597    #[test]
598    fn gets_submission_grading() {
599        init();
600        let mut server = Server::new();
601        let client = make_client(&server);
602        server
603            .mock(
604                "GET",
605                "/api/v0/langs/submissions/df5ee6c1-57d1-43b6-b39e-5d72119edb5f/grading",
606            )
607            .with_body(serde_json::json!("NoGradingYet").to_string())
608            .create();
609        server
610            .mock(
611                "GET",
612                "/api/v0/langs/submissions/e7bd5a07-1b83-4c97-91f2-e48cccf66b2a/grading",
613            )
614            .with_body(
615                serde_json::json!({
616                    "Grading": {
617                        "grading_progress": "Failed",
618                    }
619                })
620                .to_string(),
621            )
622            .create();
623        let submission_result = client
624            .get_submission_grading(
625                Uuid::parse_str("df5ee6c1-57d1-43b6-b39e-5d72119edb5f").unwrap(),
626            )
627            .unwrap();
628        assert!(matches!(
629            submission_result,
630            ExerciseTaskSubmissionStatus::NoGradingYet
631        ));
632        let submission_result = client
633            .get_submission_grading(
634                Uuid::parse_str("e7bd5a07-1b83-4c97-91f2-e48cccf66b2a").unwrap(),
635            )
636            .unwrap();
637        assert!(matches!(
638            submission_result,
639            ExerciseTaskSubmissionStatus::Grading { .. }
640        ));
641    }
642}