1pub mod api_v8;
4
5use self::api_v8::{PasteData, ReviewData};
6use crate::{
7 TestMyCodeClientResult, error::TestMyCodeClientError, request::FeedbackAnswer, response::*,
8};
9use oauth2::{
10 AuthUrl, ClientId, ClientSecret, ResourceOwnerPassword, ResourceOwnerUsername, TokenUrl,
11 basic::BasicClient,
12};
13use reqwest::{
14 Url,
15 blocking::{Client, ClientBuilder},
16 redirect::Policy,
17};
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use std::{
21 collections::HashMap,
22 io::{Cursor, Write},
23 path::{Path, PathBuf},
24 sync::Arc,
25 thread,
26 time::Duration,
27};
28use tmc_langs_plugins::{Compression, Language};
29use tmc_langs_util::progress_reporter;
30
31pub type Token =
33 oauth2::StandardTokenResponse<oauth2::EmptyExtraTokenFields, oauth2::basic::BasicTokenType>;
34
35#[derive(Debug, Deserialize, Serialize, JsonSchema)]
37#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
38pub struct UpdateResult {
39 pub created: Vec<Exercise>,
40 pub updated: Vec<Exercise>,
41}
42
43#[derive(Clone)]
45pub struct TestMyCodeClient(Arc<TmcCore>);
46
47struct TmcCore {
48 client: Client,
49 oauth_client: Client,
50 root_url: Url,
51 token: Option<Token>,
52 client_name: String,
53 client_version: String,
54}
55
56impl TestMyCodeClient {
58 pub fn require_authentication(&self) -> Result<(), TestMyCodeClientError> {
60 if self.0.token.is_some() {
61 Ok(())
62 } else {
63 Err(TestMyCodeClientError::NotAuthenticated)
64 }
65 }
66
67 pub fn new(
79 root_url: Url,
80 client_name: String,
81 client_version: String,
82 ) -> TestMyCodeClientResult<Self> {
83 let root_url = if root_url.as_str().ends_with('/') {
85 root_url
86 } else {
87 format!("{root_url}/").parse().expect("invalid root url")
88 };
89
90 let oauth_client = ClientBuilder::new()
91 .redirect(Policy::none())
92 .build()
93 .map_err(TestMyCodeClientError::HttpClientBuilder)?;
94
95 let client = TestMyCodeClient(Arc::new(TmcCore {
96 client: Client::new(),
97 oauth_client,
98 root_url,
99 token: None,
100 client_name,
101 client_version,
102 }));
103 Ok(client)
104 }
105
106 pub fn set_token(&mut self, token: Token) {
111 Arc::get_mut(&mut self.0)
112 .expect("called when multiple clones exist")
113 .token = Some(token);
114 }
115
116 pub fn authenticate(
135 &mut self,
136 email: String,
137 password: String,
138 ) -> TestMyCodeClientResult<Token> {
139 if self.0.token.is_some() {
140 return Err(Box::new(TestMyCodeClientError::AlreadyAuthenticated));
141 }
142
143 let auth_url = self.0.root_url.join("/oauth/token").map_err(|e| {
144 TestMyCodeClientError::UrlParse(self.0.root_url.to_string() + "/oauth/token", e)
145 })?;
146
147 let credentials = api_v8::get_credentials(self)?;
148
149 log::debug!("authenticating at {auth_url}");
150 let client = BasicClient::new(ClientId::new(credentials.application_id))
151 .set_client_secret(ClientSecret::new(credentials.secret))
152 .set_auth_uri(AuthUrl::from_url(auth_url.clone()))
153 .set_token_uri(TokenUrl::from_url(auth_url));
154
155 let token = client
156 .exchange_password(
157 &ResourceOwnerUsername::new(email),
158 &ResourceOwnerPassword::new(password),
159 )
160 .request(&self.0.oauth_client)
161 .map_err(TestMyCodeClientError::Token)?;
162 Arc::get_mut(&mut self.0)
163 .expect("called when multiple clones exist")
164 .token = Some(token.clone());
165 log::debug!("authenticated");
166 Ok(token)
167 }
168
169 pub fn get_exercises_details(
174 &self,
175 exercise_ids: &[u32],
176 ) -> TestMyCodeClientResult<Vec<ExercisesDetails>> {
177 let res = api_v8::core::get_exercise_details(self, exercise_ids)?;
178 Ok(res.into_iter().collect())
179 }
180
181 pub fn submit(
186 &self,
187 exercise_id: u32,
188 submission_path: &Path,
189 submission_size_limit_mb: u32,
190 locale: Option<Language>,
191 ) -> TestMyCodeClientResult<NewSubmission> {
192 self.require_authentication()?;
193
194 start_stage(2, "Compressing submission...", None);
195 let (compressed, _hash) = tmc_langs_plugins::compress_project(
196 submission_path,
197 Compression::Zip,
198 false,
199 false,
200 false,
201 submission_size_limit_mb,
202 )
203 .map_err(TestMyCodeClientError::from)?;
204 progress_stage("Compressed submission. Posting submission...", None);
205
206 let result = api_v8::core::submit_exercise(
207 self,
208 exercise_id,
209 Cursor::new(compressed),
210 None,
211 None,
212 locale,
213 )?;
214 finish_stage(
215 format!(
216 "Sent submission to server, running at {0}",
217 result.show_submission_url
218 ),
219 ClientUpdateData::PostedSubmission(result.clone()),
220 );
221 Ok(result)
222 }
223
224 pub fn download_old_submission(
229 &self,
230 submission_id: u32,
231 target: &mut dyn Write,
232 ) -> TestMyCodeClientResult<()> {
233 self.require_authentication()?;
234 log::info!("downloading old submission {submission_id}");
235 api_v8::core::download_submission(self, submission_id, target)?;
236 Ok(())
237 }
238 pub fn paste(
260 &self,
261 exercise_id: u32,
262 submission_path: &Path,
263 paste_message: Option<String>,
264 locale: Option<Language>,
265 submission_size_limit_mb: u32,
266 ) -> TestMyCodeClientResult<NewSubmission> {
267 self.require_authentication()?;
268
269 start_stage(2, "Compressing paste submission...", None);
271 let (compressed, _hash) = tmc_langs_plugins::compress_project(
272 submission_path,
273 Compression::Zip,
274 false,
275 false,
276 false,
277 submission_size_limit_mb,
278 )
279 .map_err(TestMyCodeClientError::from)?;
280 progress_stage(
281 "Compressed paste submission. Posting paste submission...",
282 None,
283 );
284
285 let paste = if let Some(message) = paste_message {
286 PasteData::WithMessage(message)
287 } else {
288 PasteData::WithoutMessage
289 };
290
291 let result = api_v8::core::submit_exercise(
292 self,
293 exercise_id,
294 Cursor::new(compressed),
295 Some(paste),
296 None,
297 locale,
298 )?;
299
300 finish_stage(
301 format!("Paste finished, running at {0}", result.paste_url),
302 ClientUpdateData::PostedSubmission(result.clone()),
303 );
304 Ok(result)
305 }
306
307 pub fn get_exercise_submissions_for_current_user(
312 &self,
313 exercise_id: u32,
314 ) -> TestMyCodeClientResult<Vec<Submission>> {
315 self.require_authentication()?;
316 let res = api_v8::submission::get_exercise_submissions_for_current_user(self, exercise_id)?;
317 Ok(res.into_iter().collect())
318 }
319
320 pub fn download_exercise(
325 &self,
326 exercise_id: u32,
327 target: &mut dyn Write,
328 ) -> TestMyCodeClientResult<()> {
329 self.require_authentication()?;
330 api_v8::core::download_exercise(self, exercise_id, target)?;
331 Ok(())
332 }
333
334 pub fn download_model_solution(
340 &self,
341 exercise_id: u32,
342 target: &Path,
343 ) -> TestMyCodeClientResult<()> {
344 self.require_authentication()?;
345
346 let mut buf = vec![];
347 api_v8::core::download_exercise_solution(self, exercise_id, &mut buf)?;
348 tmc_langs_plugins::extract_project(Cursor::new(buf), target, Compression::Zip, false)
349 .map_err(TestMyCodeClientError::from)?;
350 Ok(())
351 }
352
353 pub fn download_model_solution_archive(
354 &self,
355 exercise_id: u32,
356 target: &mut dyn Write,
357 ) -> TestMyCodeClientResult<()> {
358 self.require_authentication()?;
359 api_v8::core::download_exercise_solution(self, exercise_id, target)?;
360 Ok(())
361 }
362
363 pub fn get_course_details(&self, course_id: u32) -> TestMyCodeClientResult<CourseDetails> {
368 self.require_authentication()?;
369 let res = api_v8::core::get_course(self, course_id)?;
370 Ok(res)
371 }
372
373 pub fn get_course_exercises(
378 &self,
379 course_id: u32,
380 ) -> TestMyCodeClientResult<Vec<CourseExercise>> {
381 self.require_authentication()?;
382 let res = api_v8::exercise::get_course_exercises_by_id(self, course_id)?;
383 Ok(res.into_iter().collect())
384 }
385
386 pub fn get_course(&self, course_id: u32) -> TestMyCodeClientResult<CourseData> {
391 self.require_authentication()?;
392 let res = api_v8::course::get_by_id(self, course_id)?;
393 Ok(res)
394 }
395
396 pub fn list_courses(&self, organization_slug: &str) -> TestMyCodeClientResult<Vec<Course>> {
401 self.require_authentication()?;
402 let res = api_v8::core::get_organization_courses(self, organization_slug)?;
403 Ok(res.into_iter().collect())
404 }
405
406 pub fn get_exercise_details(
411 &self,
412 exercise_id: u32,
413 ) -> TestMyCodeClientResult<ExerciseDetails> {
414 self.require_authentication()?;
415 let res = api_v8::core::get_exercise(self, exercise_id)?;
416 Ok(res)
417 }
418
419 pub fn get_exercise_updates(
439 &self,
440 course_id: u32,
441 checksums: HashMap<u32, String>,
442 ) -> TestMyCodeClientResult<UpdateResult> {
443 self.require_authentication()?;
444
445 let mut new_exercises = vec![];
446 let mut updated_exercises = vec![];
447
448 let course = self.get_course_details(course_id)?;
449 for exercise in course.exercises {
450 if let Some(old_checksum) = checksums.get(&exercise.id) {
451 if &exercise.checksum != old_checksum {
452 updated_exercises.push(exercise);
454 }
455 } else {
456 new_exercises.push(exercise);
458 }
459 }
460 Ok(UpdateResult {
461 created: new_exercises,
462 updated: updated_exercises,
463 })
464 }
465
466 pub fn get_organization(
471 &self,
472 organization_slug: &str,
473 ) -> TestMyCodeClientResult<Organization> {
474 let res = api_v8::organization::get_organization(self, organization_slug)?;
475 Ok(res)
476 }
477
478 pub fn get_organizations(&self) -> TestMyCodeClientResult<Vec<Organization>> {
483 let res = api_v8::organization::get_organizations(self)?;
484 Ok(res.into_iter().collect())
485 }
486
487 pub fn get_unread_reviews(&self, course_id: u32) -> TestMyCodeClientResult<Vec<Review>> {
492 self.require_authentication()?;
493 let res = api_v8::core::get_course_reviews(self, course_id)?;
494 Ok(res.into_iter().collect())
495 }
496
497 pub fn mark_review_as_read(
502 &self,
503 course_id: u32,
504 review_id: u32,
505 ) -> TestMyCodeClientResult<()> {
506 self.require_authentication()?;
507 api_v8::core::update_course_review(self, course_id, review_id, None, Some(true))?;
508 Ok(())
509 }
510
511 pub fn request_code_review(
516 &self,
517 exercise_id: u32,
518 submission_path: &Path,
519 message_for_reviewer: Option<String>,
520 locale: Option<Language>,
521 submission_size_limit_mb: u32,
522 ) -> TestMyCodeClientResult<NewSubmission> {
523 self.require_authentication()?;
524
525 let (compressed, _hash) = tmc_langs_plugins::compress_project(
526 submission_path,
527 Compression::Zip,
528 false,
529 false,
530 false,
531 submission_size_limit_mb,
532 )
533 .map_err(TestMyCodeClientError::from)?;
534 let review = if let Some(message) = message_for_reviewer {
535 ReviewData::WithMessage(message)
536 } else {
537 ReviewData::WithoutMessage
538 };
539 let res = api_v8::core::submit_exercise(
540 self,
541 exercise_id,
542 Cursor::new(compressed),
543 None,
544 Some(review),
545 locale,
546 )?;
547 Ok(res)
548 }
549
550 pub fn send_feedback(
555 &self,
556 submission_id: u32,
557 feedback: Vec<FeedbackAnswer>,
558 ) -> TestMyCodeClientResult<SubmissionFeedbackResponse> {
559 self.require_authentication()?;
560 let res = api_v8::core::post_submission_feedback(
561 self,
562 submission_id,
563 feedback.into_iter().collect(),
564 )?;
565 Ok(res)
566 }
567
568 pub fn send_feedback_to_url(
573 &self,
574 feedback_url: Url,
575 feedback: Vec<FeedbackAnswer>,
576 ) -> TestMyCodeClientResult<SubmissionFeedbackResponse> {
577 self.require_authentication()?;
578 let form = api_v8::prepare_feedback_form(feedback.into_iter().collect());
579 let res = api_v8::post_form::<SubmissionFeedbackResponse>(self, feedback_url, &form)?;
580 Ok(res)
581 }
582
583 pub fn wait_for_submission(
588 &self,
589 submission_id: u32,
590 ) -> TestMyCodeClientResult<SubmissionFinished> {
591 let res = self.wait_for_submission_inner(|| api_v8::get_submission(self, submission_id))?;
592 Ok(res)
593 }
594
595 pub fn wait_for_submission_at(
600 &self,
601 submission_url: Url,
602 ) -> TestMyCodeClientResult<SubmissionFinished> {
603 let res =
604 self.wait_for_submission_inner(|| api_v8::get_json(self, submission_url.clone(), &[]))?;
605 Ok(res)
606 }
607
608 fn wait_for_submission_inner(
610 &self,
611 f: impl Fn() -> Result<SubmissionProcessingStatus, TestMyCodeClientError>,
612 ) -> Result<SubmissionFinished, TestMyCodeClientError> {
613 start_stage(4, "Waiting for submission", None);
614
615 let mut previous_status = None;
616 loop {
617 match f()? {
618 SubmissionProcessingStatus::Finished(f) => {
619 finish_stage("Submission finished processing!", None);
620 return Ok(*f);
621 }
622 SubmissionProcessingStatus::Processing(p) => {
623 if p.status == SubmissionStatus::Hidden {
624 finish_stage("Submission status hidden, stopping waiting.", None);
626 let finished = SubmissionFinished {
627 api_version: 8,
628 all_tests_passed: Some(true),
629 user_id: 0,
630 login: "0".to_string(),
631 course: "0".to_string(),
632 exercise_name: "string".to_string(),
633 status: SubmissionStatus::Hidden,
634 points: vec![],
635 validations: None,
636 valgrind: None,
637 submission_url: "".to_string(),
638 solution_url: None,
639 submitted_at: "string".to_string(),
640 processing_time: None,
641 reviewed: false,
642 requests_review: false,
643 paste_url: None,
644 message_for_paste: None,
645 missing_review_points: vec![],
646 test_cases: Some(vec![TestCase {
647 name: "Hidden Exam Test: hidden_test".to_string(),
648 successful: true,
649 message: Some("Exam exercise sent to server successfully, you can now continue.".to_string()),
650 exception: None,
651 detailed_message: None,
652 }]),
653 error: None,
654 feedback_answer_url: None,
655 feedback_questions: None,
656 };
657 return Ok(finished);
658 }
659
660 match (&mut previous_status, p.sandbox_status) {
661 (Some(previous), status) if status == *previous => {} (_, status) => {
663 match status {
665 SandboxStatus::Created => {
666 progress_stage("Created on sandbox", None)
667 }
668 SandboxStatus::SendingToSandbox => {
669 progress_stage("Sending to sandbox", None);
670 }
671 SandboxStatus::ProcessingOnSandbox => {
672 progress_stage("Processing on sandbox", None);
673 }
674 }
675 previous_status = Some(status);
676 }
677 }
678 thread::sleep(Duration::from_secs(1));
679 }
680 }
681 }
682 }
683}
684
685fn start_stage(steps: u32, message: impl Into<String>, data: impl Into<Option<ClientUpdateData>>) {
687 progress_reporter::start_stage(steps, message.into(), data.into())
688}
689
690fn progress_stage(message: impl Into<String>, data: impl Into<Option<ClientUpdateData>>) {
691 progress_reporter::progress_stage(message.into(), data.into())
692}
693
694fn finish_stage(message: impl Into<String>, data: impl Into<Option<ClientUpdateData>>) {
695 progress_reporter::finish_stage(message.into(), data.into())
696}
697
698#[derive(Debug, Serialize, Deserialize)]
700#[serde(rename_all = "kebab-case")]
701#[serde(tag = "client-update-data-kind")]
702#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
703pub enum ClientUpdateData {
704 ExerciseDownload { id: u32, path: PathBuf },
705 PostedSubmission(NewSubmission),
706}
707
708#[cfg(test)]
709#[allow(clippy::unwrap_used)]
710mod test {
711 use super::*;
713 use mockito::{Matcher, Server};
714 use oauth2::{AccessToken, EmptyExtraTokenFields, basic::BasicTokenType};
715 use std::sync::atomic::AtomicBool;
716
717 fn init() {
719 use log::*;
720 use simple_logger::*;
721
722 let _ = SimpleLogger::new()
723 .with_level(LevelFilter::Debug)
724 .with_module_level("mockito", LevelFilter::Warn)
726 .with_module_level("reqwest", LevelFilter::Warn)
728 .with_module_level("hyper", LevelFilter::Warn)
730 .init();
731 }
732
733 fn make_client(server: &Server) -> TestMyCodeClient {
734 let mut client = TestMyCodeClient::new(
735 server.url().parse().unwrap(),
736 "some_client".to_string(),
737 "some_ver".to_string(),
738 )
739 .unwrap();
740 let token = Token::new(
741 AccessToken::new("".to_string()),
742 BasicTokenType::Bearer,
743 EmptyExtraTokenFields {},
744 );
745 client.set_token(token);
746 client
747 }
748
749 #[test]
750 fn gets_exercise_updates() {
751 init();
752 let mut server = Server::new();
753 let client = make_client(&server);
754 let _m = server.mock("GET", "/api/v8/core/courses/1234")
755 .match_query(Matcher::AllOf(vec![
756 Matcher::UrlEncoded("client".to_string(), "some_client".to_string()),
757 Matcher::UrlEncoded("client_version".to_string(), "some_ver".to_string()),
758 ]))
759 .with_body(serde_json::json!({
760 "course": {
761 "id": 588,
762 "name": "mooc-2020-ohjelmointi",
763 "title": "Ohjelmoinnin MOOC 2020, Ohjelmoinnin perusteet",
764 "description": "Aikataulutettu Ohjelmoinnin MOOC 2020. Kurssin ensimmäinen puolisko. Tästä kurssista voi hakea opinto-oikeutta Helsingin yliopiston tietojenkäsittelytieteen osastolle.",
765 "details_url": "https://tmc.mooc.fi/api/v8/core/courses/588",
766 "unlock_url": "https://tmc.mooc.fi/api/v8/core/courses/588/unlock",
767 "reviews_url": "https://tmc.mooc.fi/api/v8/core/courses/588/reviews",
768 "comet_url": "https://tmc.mooc.fi:8443/comet",
769 "spyware_urls": [
770 "http://snapshots01.mooc.fi/"
771 ],
772 "unlockables": [],
773 "exercises": [
774 {
775 "id": 12,
776 "name": "unchanged",
777 "locked": false,
778 "deadline_description": "2020-01-20 23:59:59 +0200",
779 "deadline": "2020-01-20T23:59:59.999+02:00",
780 "soft_deadline": null,
781 "soft_deadline_description": null,
782 "checksum": "ab",
783 "return_url": "https://tmc.mooc.fi/api/v8/core/exercises/81842/submissions",
784 "zip_url": "https://tmc.mooc.fi/api/v8/core/exercises/81842/download",
785 "returnable": true,
786 "requires_review": false,
787 "attempted": false,
788 "completed": false,
789 "reviewed": false,
790 "all_review_points_given": true,
791 "memory_limit": null,
792 "runtime_params": [],
793 "valgrind_strategy": "fail",
794 "code_review_requests_enabled": false,
795 "run_tests_locally_action_enabled": true
796 },
797 {
798 "id": 23,
799 "name": "updated",
800 "locked": false,
801 "deadline_description": "2020-01-20 23:59:59 +0200",
802 "deadline": "2020-01-20T23:59:59.999+02:00",
803 "soft_deadline": null,
804 "soft_deadline_description": null,
805 "checksum": "zz",
806 "return_url": "https://tmc.mooc.fi/api/v8/core/exercises/81842/submissions",
807 "zip_url": "https://tmc.mooc.fi/api/v8/core/exercises/81842/download",
808 "returnable": true,
809 "requires_review": false,
810 "attempted": false,
811 "completed": false,
812 "reviewed": false,
813 "all_review_points_given": true,
814 "memory_limit": null,
815 "runtime_params": [],
816 "valgrind_strategy": "fail",
817 "code_review_requests_enabled": false,
818 "run_tests_locally_action_enabled": true
819 },
820 {
821 "id": 34,
822 "name": "new",
823 "locked": false,
824 "deadline_description": "2020-01-20 23:59:59 +0200",
825 "deadline": "2020-01-20T23:59:59.999+02:00",
826 "soft_deadline": null,
827 "soft_deadline_description": null,
828 "checksum": "cd",
829 "return_url": "https://tmc.mooc.fi/api/v8/core/exercises/81842/submissions",
830 "zip_url": "https://tmc.mooc.fi/api/v8/core/exercises/81842/download",
831 "returnable": true,
832 "requires_review": false,
833 "attempted": false,
834 "completed": false,
835 "reviewed": false,
836 "all_review_points_given": true,
837 "memory_limit": null,
838 "runtime_params": [],
839 "valgrind_strategy": "fail",
840 "code_review_requests_enabled": false,
841 "run_tests_locally_action_enabled": true
842 },]
843 }
844 }).to_string())
845 .create();
846
847 let mut checksums = HashMap::new();
848 checksums.insert(12, "ab".to_string());
849 checksums.insert(23, "bc".to_string());
850 let update_result = client.get_exercise_updates(1234, checksums).unwrap();
851
852 assert_eq!(update_result.created.len(), 1);
853 assert_eq!(update_result.created[0].id, 34);
854
855 assert_eq!(update_result.updated.len(), 1);
856 assert_eq!(update_result.updated[0].checksum, "zz");
857 }
858
859 #[test]
860 fn waits_for_submission() {
861 init();
862 let mut server = Server::new();
863 let client = make_client(&server);
864 let m = server
865 .mock("GET", "/api/v8/core/submissions/0")
866 .match_query(Matcher::AllOf(vec![
867 Matcher::UrlEncoded("client".to_string(), "some_client".to_string()),
868 Matcher::UrlEncoded("client_version".to_string(), "some_ver".to_string()),
869 ]))
870 .with_chunked_body(|w| {
871 static CALLED: AtomicBool = AtomicBool::new(false);
872 if !CALLED.load(std::sync::atomic::Ordering::SeqCst) {
873 CALLED.store(true, std::sync::atomic::Ordering::SeqCst);
874 w.write_all(
875 br#"
876 {
877 "status": "processing",
878 "sandbox_status": "created"
879 }
880 "#,
881 )
882 .unwrap();
883 } else {
884 w.write_all(
885 br#"
886 {
887 "api_version": 0,
888 "user_id": 1,
889 "login": "",
890 "course": "",
891 "exercise_name": "",
892 "status": "processing",
893 "points": [],
894 "submission_url": "",
895 "submitted_at": "",
896 "reviewed": false,
897 "requests_review": false,
898 "missing_review_points": []
899 }
900 "#,
901 )
902 .unwrap();
903 }
904 Ok(())
905 })
906 .expect(2)
907 .create();
908
909 let _res = client.wait_for_submission(0).unwrap();
910 m.assert();
911 }
912
913 #[test]
914 fn asd() {}
915}