1use chrono::{DateTime, FixedOffset};
4use once_cell::sync::Lazy;
5use regex::Regex;
6use schemars::JsonSchema;
7use serde::{
8 Deserialize, Deserializer, Serialize, Serializer,
9 de::{self, Visitor},
10};
11use std::{collections::HashMap, fmt, path::PathBuf, str::FromStr};
12use thiserror::Error;
13
14#[derive(Debug, Error, Deserialize)]
16#[error("Response contained errors: {error:?}, {errors:#?}, obsolete client: {obsolete_client}")]
17#[serde(deny_unknown_fields)] pub struct ErrorResponse {
19 pub status: Option<String>,
20 pub error: Option<String>,
21 pub errors: Option<Vec<String>>,
22 #[serde(default)]
23 pub obsolete_client: bool,
24}
25
26#[derive(Debug, Deserialize)]
29pub struct Credentials {
30 pub application_id: String,
31 pub secret: String,
32}
33
34#[derive(Debug, Deserialize)]
39pub struct User {
40 pub id: u32,
41 pub username: String,
42 pub email: String,
43 pub administrator: bool,
44}
45
46#[derive(Debug, Serialize, Deserialize, JsonSchema)]
49#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
50pub struct Organization {
51 pub name: String,
52 pub information: String,
53 pub slug: String,
54 pub logo_path: String,
55 pub pinned: bool,
56}
57
58#[derive(Debug, Deserialize, Serialize, JsonSchema)]
60#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
61pub struct Course {
62 pub id: u32,
63 pub name: String,
64 pub title: String,
65 pub description: Option<String>,
66 pub details_url: String,
68 pub unlock_url: String,
70 pub reviews_url: String,
72 pub comet_url: String,
74 pub spyware_urls: Vec<String>,
75}
76
77#[derive(Debug, Deserialize, Serialize, JsonSchema)]
80#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
81pub struct CourseData {
82 pub name: String,
83 pub hide_after: Option<String>,
84 pub hidden: bool,
85 pub cache_version: Option<u32>,
86 pub spreadsheet_key: Option<String>,
87 pub hidden_if_registered_after: Option<String>,
88 #[cfg_attr(feature = "ts-rs", ts(type = "string | null"))]
89 pub refreshed_at: Option<DateTime<FixedOffset>>,
90 pub locked_exercise_points_visible: bool,
91 pub description: Option<String>,
92 pub paste_visibility: Option<u32>,
93 pub formal_name: Option<String>,
94 pub certificate_downloadable: Option<bool>,
95 pub certificate_unlock_spec: Option<String>,
96 pub organization_id: Option<u32>,
97 pub disabled_status: Option<String>,
98 pub title: Option<String>,
99 pub material_url: Option<String>,
101 pub course_template_id: Option<u32>,
102 pub hide_submission_results: bool,
103 pub external_scoreboard_url: Option<String>,
105 pub organization_slug: Option<String>,
106}
107
108#[derive(Debug, Deserialize, JsonSchema)]
111struct CourseDetailsWrapper {
112 pub course: CourseDetailsInner,
113}
114
115#[derive(Debug, Deserialize, JsonSchema)]
117struct CourseDetailsInner {
118 #[serde(flatten)]
119 pub course: Course,
120 pub unlockables: Vec<String>,
121 pub exercises: Vec<Exercise>,
122}
123
124#[derive(Debug, Deserialize, Serialize, JsonSchema)]
126#[serde(from = "CourseDetailsWrapper")]
127#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
128#[cfg_attr(feature = "ts-rs", ts(ignore_serde_attr = "from"))]
130pub struct CourseDetails {
131 #[serde(flatten)]
132 pub course: Course,
133 pub unlockables: Vec<String>,
134 pub exercises: Vec<Exercise>,
135}
136
137impl From<CourseDetailsWrapper> for CourseDetails {
138 fn from(value: CourseDetailsWrapper) -> Self {
139 Self {
140 course: value.course.course,
141 unlockables: value.course.unlockables,
142 exercises: value.course.exercises,
143 }
144 }
145}
146
147#[derive(Debug, Deserialize, Serialize, JsonSchema)]
148#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
149pub struct Exercise {
150 pub id: u32,
151 pub name: String,
152 pub locked: bool,
153 pub deadline_description: Option<String>,
154 pub deadline: Option<String>,
155 pub soft_deadline: Option<String>,
156 pub soft_deadline_description: Option<String>,
157 pub checksum: String,
158 pub return_url: String,
160 pub zip_url: String,
162 pub returnable: bool,
163 pub requires_review: bool,
164 pub attempted: bool,
165 pub completed: bool,
166 pub reviewed: bool,
167 pub all_review_points_given: bool,
168 pub memory_limit: Option<u32>,
169 pub runtime_params: Vec<String>,
170 pub valgrind_strategy: Option<String>,
171 pub code_review_requests_enabled: bool,
172 pub run_tests_locally_action_enabled: bool,
173 pub latest_submission_url: Option<String>,
175 pub latest_submission_id: Option<u32>,
176 pub solution_zip_url: Option<String>,
178}
179
180#[derive(Debug, Deserialize, Serialize, JsonSchema)]
182#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
183pub struct CourseExercise {
184 pub id: u32,
185 pub available_points: Vec<ExercisePoint>,
186 pub awarded_points: Vec<String>,
187 pub name: String,
188 pub publish_time: Option<String>,
189 pub solution_visible_after: Option<String>,
190 pub deadline: Option<String>,
191 pub soft_deadline: Option<String>,
192 pub disabled: bool,
193 pub unlocked: bool,
194}
195
196#[derive(Debug, Deserialize)]
198pub struct CourseDataExercise {
199 pub id: u32,
200 pub available_points: Vec<ExercisePoint>,
201 pub name: String,
202 pub publish_time: Option<String>,
203 pub solution_visible_after: Option<String>,
204 pub deadline: Option<String>,
205 pub disabled: bool,
206}
207
208#[derive(Debug, Deserialize, Serialize, JsonSchema)]
209#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
210pub struct ExercisePoint {
211 pub id: u32,
212 pub exercise_id: u32,
213 pub name: String,
214 pub requires_review: bool,
215}
216
217#[derive(Debug, Deserialize)]
228pub struct CourseDataExercisePoint {
229 pub awarded_point: AwardedPoint,
230 pub exercise_id: u32,
231}
232
233#[derive(Debug, Deserialize)]
234pub struct AwardedPoint {
235 pub id: u32,
236 pub course_id: u32,
237 pub user_id: u32,
238 pub submission_id: u32,
239 pub name: String,
240 pub created_at: DateTime<FixedOffset>,
241}
242
243#[derive(Debug, Deserialize, Serialize, JsonSchema)]
245#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
246pub struct ExerciseDetails {
247 pub course_name: String,
248 pub course_id: u32,
249 pub code_review_requests_enabled: bool,
250 pub run_tests_locally_action_enabled: bool,
251 pub exercise_name: String,
252 pub exercise_id: u32,
253 pub unlocked_at: Option<String>,
254 pub deadline: Option<String>,
255 pub submissions: Vec<ExerciseSubmission>,
256}
257
258#[derive(Debug, Deserialize)]
260pub(crate) struct ExercisesDetailsWrapper {
261 pub exercises: Vec<ExercisesDetails>,
262}
263
264#[derive(Debug, Deserialize, Serialize, JsonSchema)]
265pub struct ExercisesDetails {
266 pub id: u32,
267 pub course_name: String,
268 pub exercise_name: String,
269 pub checksum: String,
270 pub hide_submission_results: bool,
271}
272
273#[derive(Debug, Deserialize, Serialize, JsonSchema)]
284#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
285pub struct Submission {
286 pub id: u32,
287 pub user_id: u32,
288 pub pretest_error: Option<String>,
289 #[cfg_attr(feature = "ts-rs", ts(type = "string"))]
290 pub created_at: DateTime<FixedOffset>,
291 pub exercise_name: String,
292 pub course_id: u32,
293 pub processed: bool,
294 pub all_tests_passed: bool,
295 pub points: Option<String>,
296 #[cfg_attr(feature = "ts-rs", ts(type = "string | null"))]
297 pub processing_tried_at: Option<DateTime<FixedOffset>>,
298 #[cfg_attr(feature = "ts-rs", ts(type = "string | null"))]
299 pub processing_began_at: Option<DateTime<FixedOffset>>,
300 #[cfg_attr(feature = "ts-rs", ts(type = "string | null"))]
301 pub processing_completed_at: Option<DateTime<FixedOffset>>,
302 pub times_sent_to_sandbox: u32,
303 #[cfg_attr(feature = "ts-rs", ts(type = "string"))]
304 pub processing_attempts_started_at: DateTime<FixedOffset>,
305 pub params_json: Option<String>,
306 pub requires_review: bool,
307 pub requests_review: bool,
308 pub reviewed: bool,
309 pub message_for_reviewer: String,
310 pub newer_submission_reviewed: bool,
311 pub review_dismissed: bool,
312 pub paste_available: bool,
313 pub message_for_paste: String,
314 pub paste_key: Option<String>,
315}
316
317#[derive(Debug, Deserialize, Serialize, JsonSchema)]
318#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
319pub struct ExerciseSubmission {
320 pub exercise_name: String,
321 pub id: u32,
322 pub user_id: u32,
323 pub course_id: u32,
324 #[cfg_attr(feature = "ts-rs", ts(type = "string"))]
325 pub created_at: DateTime<FixedOffset>,
326 pub all_tests_passed: bool,
327 pub points: Option<String>,
328 pub submitted_zip_url: String,
330 pub paste_url: Option<String>,
332 pub processing_time: Option<u32>,
333 pub reviewed: bool,
334 pub requests_review: bool,
335}
336
337#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
339#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
340pub struct NewSubmission {
341 pub show_submission_url: String,
343 pub paste_url: String, pub submission_url: String,
347}
348
349#[derive(Debug, Deserialize, Serialize)]
351#[serde(untagged)] pub enum SubmissionProcessingStatus {
353 Processing(SubmissionProcessing),
354 Finished(Box<SubmissionFinished>),
355}
356
357#[derive(Debug, Deserialize, Serialize)]
358pub struct SubmissionProcessing {
359 pub status: SubmissionStatus,
360 pub sandbox_status: SandboxStatus,
361}
362
363#[derive(Debug, Deserialize, PartialEq, Eq, Serialize)]
364#[serde(rename_all = "snake_case")]
365pub enum SandboxStatus {
366 Created,
367 SendingToSandbox,
368 ProcessingOnSandbox,
369}
370
371#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
372#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
373pub struct SubmissionFinished {
374 pub api_version: u32,
375 pub all_tests_passed: Option<bool>,
376 pub user_id: u32,
377 pub login: String,
378 pub course: String,
379 pub exercise_name: String,
380 pub status: SubmissionStatus,
381 pub points: Vec<String>,
382 pub valgrind: Option<String>,
383 pub submission_url: String,
385 pub solution_url: Option<String>,
387 pub submitted_at: String,
388 pub processing_time: Option<u32>,
389 pub reviewed: bool,
390 pub requests_review: bool,
391 pub paste_url: Option<String>,
393 pub message_for_paste: Option<String>,
394 pub missing_review_points: Vec<String>,
395 pub test_cases: Option<Vec<TestCase>>,
396 pub feedback_questions: Option<Vec<SubmissionFeedbackQuestion>>,
397 pub feedback_answer_url: Option<String>,
399 pub error: Option<String>,
400 pub validations: Option<TmcStyleValidationResult>,
401}
402
403#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
404#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
405#[serde(rename_all = "lowercase")]
406pub enum SubmissionStatus {
407 Processing,
408 Fail,
409 Ok,
410 Error,
411 Hidden,
412}
413
414#[derive(Debug, Deserialize, Serialize, JsonSchema)]
416#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
417pub struct SubmissionFeedbackResponse {
418 pub api_version: u32,
419 pub status: SubmissionStatus,
420}
421
422#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
423#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
424pub struct TestCase {
425 pub name: String,
426 pub successful: bool,
427 pub message: Option<String>,
428 pub exception: Option<Vec<String>>,
429 pub detailed_message: Option<String>,
430}
431
432#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
433#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
434pub struct SubmissionFeedbackQuestion {
435 pub id: u32,
436 pub question: String,
437 pub kind: SubmissionFeedbackKind,
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
441#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
442pub enum SubmissionFeedbackKind {
443 Text,
444 IntRange { lower: u32, upper: u32 },
445}
446
447impl<'de> Deserialize<'de> for SubmissionFeedbackKind {
448 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
449 where
450 D: Deserializer<'de>,
451 {
452 let wrapper = SubmissionFeedbackKindWrapper::deserialize(deserializer)?;
453 let kind = match wrapper {
454 SubmissionFeedbackKindWrapper::String(string) => match string {
455 SubmissionFeedbackKindString::Text => Self::Text,
456 SubmissionFeedbackKindString::IntRange { lower, upper } => {
457 Self::IntRange { lower, upper }
458 }
459 },
460 SubmissionFeedbackKindWrapper::Derived(derived) => match derived {
461 SubmissionFeedbackKindDerived::Text => Self::Text,
462 SubmissionFeedbackKindDerived::IntRange { lower, upper } => {
463 Self::IntRange { lower, upper }
464 }
465 },
466 };
467 Ok(kind)
468 }
469}
470
471impl Serialize for SubmissionFeedbackKind {
472 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
473 where
474 S: Serializer,
475 {
476 let derived = match self {
477 Self::Text => SubmissionFeedbackKindDerived::Text,
478 Self::IntRange { lower, upper } => SubmissionFeedbackKindDerived::IntRange {
479 lower: *lower,
480 upper: *upper,
481 },
482 };
483 derived.serialize(serializer)
484 }
485}
486
487#[derive(Debug, Clone, Copy, Deserialize)]
489#[serde(untagged)]
490enum SubmissionFeedbackKindWrapper {
491 Derived(SubmissionFeedbackKindDerived),
492 String(SubmissionFeedbackKindString),
493}
494
495#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
497enum SubmissionFeedbackKindDerived {
498 Text,
499 IntRange { lower: u32, upper: u32 },
500}
501
502#[derive(Debug, Clone, Copy)]
504enum SubmissionFeedbackKindString {
505 Text,
506 IntRange { lower: u32, upper: u32 },
507}
508
509impl<'de> Deserialize<'de> for SubmissionFeedbackKindString {
510 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
511 where
512 D: Deserializer<'de>,
513 {
514 deserializer.deserialize_string(SubmissionFeedbackKindStringVisitor {})
515 }
516}
517
518struct SubmissionFeedbackKindStringVisitor {}
519
520impl Visitor<'_> for SubmissionFeedbackKindStringVisitor {
522 type Value = SubmissionFeedbackKindString;
523
524 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
525 formatter.write_str("\"text\" or \"intrange[x..y]\"")
526 }
527
528 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
529 where
530 E: de::Error,
531 {
532 #[allow(clippy::unwrap_used)]
533 static RANGE: Lazy<Regex> =
534 Lazy::new(|| Regex::new(r#"intrange\[(\d+)\.\.(\d+)\]"#).unwrap());
535
536 if value == "text" {
537 Ok(SubmissionFeedbackKindString::Text)
538 } else if let Some(captures) = RANGE.captures(value) {
539 let lower = &captures[1];
540 let lower = u32::from_str(lower).map_err(|e| {
541 E::custom(format!("error parsing intrange lower bound {lower}: {e}"))
542 })?;
543 let upper = &captures[2];
544 let upper = u32::from_str(upper).map_err(|e| {
545 E::custom(format!("error parsing intrange upper bound {upper}: {e}"))
546 })?;
547 Ok(SubmissionFeedbackKindString::IntRange { lower, upper })
548 } else {
549 Err(E::custom("expected \"text\" or \"intrange[x..y]\""))
550 }
551 }
552}
553
554#[derive(Debug, Deserialize, Serialize, JsonSchema)]
556#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
557pub struct Review {
558 pub submission_id: u32,
559 pub exercise_name: String,
560 pub id: u32,
561 pub marked_as_read: bool,
562 pub reviewer_name: String,
563 pub review_body: String,
564 pub points: Vec<String>,
565 pub points_not_awarded: Vec<String>,
566 pub url: String,
568 pub update_url: String,
570 #[cfg_attr(feature = "ts-rs", ts(type = "string"))]
571 pub created_at: DateTime<FixedOffset>,
572 #[cfg_attr(feature = "ts-rs", ts(type = "string"))]
573 pub updated_at: DateTime<FixedOffset>,
574}
575
576#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
578#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
579#[serde(rename_all = "camelCase")]
580pub struct TmcStyleValidationResult {
581 pub strategy: TmcStyleValidationStrategy,
582 pub validation_errors: Option<HashMap<PathBuf, Vec<TmcStyleValidationError>>>,
583}
584
585#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
587#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
588#[serde(rename_all = "UPPERCASE")]
589pub enum TmcStyleValidationStrategy {
590 Fail,
591 Warn,
592 Disabled,
593}
594
595#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
597#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
598#[serde(rename_all = "camelCase")]
599pub struct TmcStyleValidationError {
600 pub column: u32,
601 pub line: u32,
602 pub message: String,
603 pub source_name: String,
604}
605
606#[cfg(test)]
607#[allow(clippy::unwrap_used)]
608mod test {
609 use super::*;
610 use tmc_langs_util::deserialize;
611
612 fn init() {
613 use log::*;
614 use simple_logger::*;
615 let _ = SimpleLogger::new()
618 .with_level(LevelFilter::Debug)
619 .with_module_level("mockito", LevelFilter::Warn)
621 .with_module_level("reqwest", LevelFilter::Warn)
623 .with_module_level("hyper", LevelFilter::Warn)
625 .init();
626 }
627
628 #[test]
629 fn course_details_de() {
630 init();
631
632 let details = serde_json::json!(
633 {
634 "course": {
635 "comet_url": "c",
636 "description": "d",
637 "details_url": "du",
638 "id": 1,
639 "name": "n",
640 "reviews_url": "r",
641 "spyware_urls": [
642 "s"
643 ],
644 "title": "t",
645 "unlock_url": "u",
646 "unlockables": ["a"],
647 "exercises": []
648 }
649 }
650 );
651 assert!(deserialize::json_from_value::<CourseDetails>(details).is_ok());
652 }
653
654 #[test]
655 fn feedback_kind_de_server() {
656 init();
657
658 let text = serde_json::json!("text");
659 let text: SubmissionFeedbackKind = deserialize::json_from_value(text).unwrap();
660 if let SubmissionFeedbackKind::Text = text {
661 } else {
662 panic!("wrong type")
663 }
664
665 let intrange = serde_json::json!("intrange[1..5]");
666 let intrange: SubmissionFeedbackKind = deserialize::json_from_value(intrange).unwrap();
667 if let SubmissionFeedbackKind::IntRange { lower: 1, upper: 5 } = intrange {
668 } else {
669 panic!("wrong type")
670 }
671 }
672
673 #[test]
674 fn feedback_kind_de_rust() {
675 init();
676
677 let original = SubmissionFeedbackKind::Text;
678 let json = serde_json::to_string(&original).unwrap();
679 let deserialized: SubmissionFeedbackKind = deserialize::json_from_str(&json).unwrap();
680 assert_eq!(deserialized, original);
681
682 let original = SubmissionFeedbackKind::IntRange { lower: 1, upper: 5 };
683 let json = serde_json::to_string(&original).unwrap();
684 let deserialized: SubmissionFeedbackKind = deserialize::json_from_str(&json).unwrap();
685 assert_eq!(deserialized, original);
686 }
687
688 #[test]
689 fn feedback_kind_se() {
690 init();
691
692 let original = SubmissionFeedbackKind::Text;
693 let json = serde_json::to_string(&original).unwrap();
694 assert_eq!(json, r#""Text""#);
695
696 let original = SubmissionFeedbackKind::IntRange { lower: 1, upper: 5 };
697 let json = serde_json::to_string(&original).unwrap();
698 assert_eq!(json, r#"{"IntRange":{"lower":1,"upper":5}}"#);
699 }
700
701 #[test]
702 fn submission_response_with_validation_errors() {
703 init();
704
705 let submission = serde_json::json!(
706 {
707 "api_version": 7,
708 "all_tests_passed": false,
709 "user_id": 12345,
710 "login": "12345",
711 "course": "course",
712 "exercise_name": "exercise",
713 "status": "fail",
714 "points": [],
715 "validations": {
716 "strategy": "FAIL",
717 "validationErrors": {
718 "Main.java": [
719 {
720 "column": 1,
721 "line": 1,
722 "message": "Indentation incorrect. Expected 8, but was 12.",
723 "sourceName": "com.puppycrawl.tools.checkstyle.checks.indentation.IndentationCheck"
724 },
725 {
726 "column": 2,
727 "line": 2,
728 "message": "Indentation incorrect. Expected 8, but was 12.",
729 "sourceName": "com.puppycrawl.tools.checkstyle.checks.indentation.IndentationCheck"
730 }
731 ]
732 }
733 },
734 "valgrind": "",
735 "submission_url": "https://tmc.mooc.fi/submissions/12345",
736 "solution_url": "https://tmc.mooc.fi/exercises/12345/solution",
737 "submitted_at": "2025-01-01T01:01:01.001+01:01",
738 "processing_time": 12345,
739 "reviewed": false,
740 "requests_review": false,
741 "paste_url": null,
742 "message_for_paste": null,
743 "missing_review_points": [],
744 "test_cases": [
745 {
746 "name": "FakeTest test",
747 "successful": true,
748 "message": "",
749 "exception": [],
750 "detailed_message": null
751 },
752 {
753 "name": "FakeTest testTwo",
754 "successful": true,
755 "message": "",
756 "exception": [],
757 "detailed_message": null
758 },
759 {
760 "name": "FakeTest testThree",
761 "successful": true,
762 "message": "",
763 "exception": [],
764 "detailed_message": null
765 }
766 ]
767 }
768 );
769 let submission = deserialize::json_from_value::<SubmissionFinished>(submission).unwrap();
770 assert!(
771 !submission
772 .validations
773 .unwrap()
774 .validation_errors
775 .unwrap()
776 .is_empty()
777 );
778 }
779}