Skip to main content

exercise_services_api/
lib.rs

1//! Wire types for the exercise-services client API (`/api/v0/exercise-services/client`), the
2//! HTTP surface a native client authenticates against and exchanges for course, exercise,
3//! and submission data. Consumed externally by `tmc-langs-rust`, so it stays free of
4//! server-internal dependencies; the `openapi` feature adds `utoipa::ToSchema` derives for the
5//! server's own OpenAPI generation and is off by default for other consumers.
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::fmt::Debug;
9use uuid::Uuid;
10
11/// The token exchanged at the OAuth2 token endpoint; its `access_token` is the bearer token sent
12/// with every other request in this API.
13pub type Token =
14    oauth2::StandardTokenResponse<oauth2::EmptyExtraTokenFields, oauth2::basic::BasicTokenType>;
15
16/// A course the current user can browse or is enrolled in.
17#[derive(Debug, Serialize, Deserialize)]
18#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
19pub struct Course {
20    pub id: Uuid,
21    pub slug: String,
22    pub name: String,
23    pub description: Option<String>,
24    /// Denormalized so a client needn't resolve the owning organization separately.
25    pub organization_name: String,
26}
27
28/// One selected slide of an exercise, carrying only the tasks whose exercise service can serve
29/// the requesting client. An exercise with no client-servable task is omitted entirely by
30/// endpoints that return this type, rather than appearing with an empty `tasks`.
31#[derive(Debug, Serialize, Deserialize)]
32#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
33pub struct ExerciseSlide {
34    pub slide_id: Uuid,
35    pub exercise_id: Uuid,
36    /// The course the exercise belongs to, so a client need not resolve it separately.
37    pub course_id: Uuid,
38    pub exercise_name: String,
39    pub exercise_order_number: i32,
40    pub deadline: Option<DateTime<Utc>>,
41    pub tasks: Vec<ExerciseTask>,
42    /// The course material page the exercise is on, where the student can also see their latest
43    /// submission and its grading. Absent from a host that predates the field.
44    #[serde(default)]
45    pub page_url: Option<String>,
46    /// Absent for an exercise outside any chapter, and from a host that predates the field.
47    #[serde(default)]
48    pub chapter: Option<ExerciseChapter>,
49}
50
51/// The chapter an exercise belongs to, for grouping a course's exercises the way its material
52/// does.
53#[derive(Debug, Serialize, Deserialize)]
54#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
55pub struct ExerciseChapter {
56    pub id: Uuid,
57    pub name: String,
58    /// The chapter's position in the course, from 1.
59    pub chapter_number: i32,
60}
61
62/// One task of an exercise slide, as produced by a specific exercise service.
63///
64/// `public_spec` / `model_solution_spec` are plugin-owned blobs: the exercise service
65/// that produces them is the only component that interprets their shape, so the host
66/// forwards them verbatim and they stay opaque `serde_json::Value` here.
67#[derive(Debug, Serialize, Deserialize)]
68#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
69pub struct ExerciseTask {
70    pub task_id: Uuid,
71    pub order_number: i32,
72    pub assignment: serde_json::Value,
73    pub public_spec: Option<serde_json::Value>,
74    pub model_solution_spec: Option<serde_json::Value>,
75    pub exercise_service_slug: String,
76}
77
78/// Which of the two representations an answer is: the JSON in `data_json`, or the files in
79/// `data_files`. A request that omits it means `Json`.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
82#[serde(rename_all = "snake_case")]
83pub enum AnswerKind {
84    Json,
85    File,
86}
87
88/// A file the host stored on a client's behalf. The host assigns `id`; a client never invents
89/// one. Returned by the upload endpoint and echoed back by submission download.
90#[derive(Debug, Serialize, Deserialize)]
91#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
92pub struct AnswerFile {
93    /// The host's file id. Names this file in a submit request.
94    pub id: Uuid,
95    /// The original file name the client sent.
96    pub name: String,
97    pub mime: String,
98    /// `None` for a file stored before the size was recorded; never a substitute zero, so a client
99    /// can tell an unknown size from an empty file.
100    pub size_bytes: Option<i64>,
101    /// The file's position in the answer it belongs to. `None` for a file that is not part of an
102    /// answer yet, which is every file the upload endpoint returns.
103    pub order_number: Option<i32>,
104    /// Direct download URL; needs no bearer token.
105    pub url: String,
106}
107
108/// Response of `POST exercises/{id}/files`, in the same order as the request's parts.
109#[derive(Debug, Serialize, Deserialize)]
110#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
111pub struct UploadedFiles {
112    pub data_files: Vec<AnswerFile>,
113}
114
115/// Body of `POST exercises/{id}/submit`. Plain JSON — no file parts, no archive: the previously
116/// uploaded files named here are the answer.
117#[derive(Debug, Serialize, Deserialize)]
118#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
119pub struct ExerciseSlideSubmission {
120    pub exercise_slide_id: Uuid,
121    pub exercise_task_id: Uuid,
122    /// Absent means `json`. A client answering with files sends `file`.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub answer_kind: Option<AnswerKind>,
125    /// The exercise service's own JSON: the whole answer for a `json` answer, the service's
126    /// metadata about the files for a `file` one.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub data_json: Option<serde_json::Value>,
129    /// Ids from this exercise's `files` endpoint, in the order they are to be graded and
130    /// displayed. A `file` answer must name at least one, and every id must have been uploaded by
131    /// this user for this exercise.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub data_files: Option<Vec<Uuid>>,
134}
135
136/// Result of a submit. Carries both ids so a client never re-derives one from the other:
137/// grading polling takes `task_submission_id`, download/share take `slide_submission_id`.
138#[derive(Debug, Serialize, Deserialize)]
139#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
140pub struct ExerciseTaskSubmissionResult {
141    pub task_submission_id: Uuid,
142    pub slide_submission_id: Uuid,
143}
144
145/// The grading status of a task submission, as polled after `submit`.
146#[derive(Debug, Serialize, Deserialize)]
147#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
148pub enum ExerciseTaskSubmissionStatus {
149    NoGradingYet,
150    Grading {
151        grading_progress: GradingProgress,
152        /// Absent until grading has produced a value; a partial value while `grading_progress`
153        /// is still pending.
154        score_given: Option<f32>,
155        grading_started_at: Option<DateTime<Utc>>,
156        grading_completed_at: Option<DateTime<Utc>>,
157        /// Structured grading feedback, opaque like `ExerciseTask`'s spec fields: only the
158        /// exercise service that produced it interprets its shape.
159        feedback_json: Option<serde_json::Value>,
160        /// Human-readable feedback, for a client to display as-is.
161        feedback_text: Option<String>,
162        /// The user's progress on the whole exercise as of this poll, so a client can record
163        /// completion without a separate `courses/{id}/progress` round-trip. Absent for an exam
164        /// exercise, and from a host that predates the field.
165        #[serde(default)]
166        exercise_progress: Option<ExerciseProgress>,
167    },
168}
169
170/// How far along a task submission's grading is.
171#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
172#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
173pub enum GradingProgress {
174    /// The grading could not complete.
175    Failed,
176    /// No grading has occurred yet, e.g. the student has made no submission.
177    NotReady,
178    /// Final grade pending, needs human intervention. `score_given`, if present, is partial and may still change.
179    PendingManual,
180    /// Final grade pending, no human intervention needed. `score_given`, if present, is partial and may still change.
181    Pending,
182    /// Grading is complete. `score_given`, if present, is the final grade.
183    FullyGraded,
184}
185
186/// A past submission of the current user to an exercise. `id` is the
187/// exercise-slide-submission id, which is what `submissions/{id}/download` takes.
188#[derive(Debug, Serialize, Deserialize)]
189#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
190pub struct ExerciseSlideSubmissionListItem {
191    pub id: Uuid,
192    pub exercise_id: Uuid,
193    pub created_at: DateTime<Utc>,
194    pub score_given: Option<f32>,
195    pub grading_progress: Option<GradingProgress>,
196}
197
198/// Response of `GET submissions/{id}/download`: the files the submission was made from, recovered
199/// from the host's own file records rather than from the service's answer.
200///
201/// The same shape regardless of where the submission was made. A native client's uploads are
202/// recorded as it names them; an answer made in the service's IFrame carries its files inside the
203/// service's own answer, so the host asks the service to enumerate them and stores them the same
204/// way. Empty only when the submission genuinely has no files — an exercise type with none, or a
205/// service that declares no way to enumerate its answers' files.
206#[derive(Debug, Serialize, Deserialize)]
207#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
208pub struct SubmissionFiles {
209    pub data_files: Vec<AnswerFile>,
210}
211
212/// The current user's progress across every exercise they can see in a course, returned
213/// by `courses/{id}/progress` in a single round-trip. Course-level totals are not sent
214/// separately; the client derives them by summing over `exercises` (e.g. total awarded =
215/// `sum(score_given)`, total available = `sum(score_maximum)`).
216#[derive(Debug, Serialize, Deserialize)]
217#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
218pub struct CourseProgress {
219    /// The course these progress entries belong to; echoes the path id.
220    pub course_id: Uuid,
221    pub exercises: Vec<ExerciseProgress>,
222}
223
224/// The current user's progress on a single exercise.
225///
226/// `standing` is the client's passed/failed signal. `completed` is the activity stage, which
227/// an exercise without peer or self review reaches on any graded submission, even one
228/// worth 0 points.
229#[derive(Debug, Serialize, Deserialize)]
230#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
231pub struct ExerciseProgress {
232    pub exercise_id: Uuid,
233    /// Points the user has been awarded, `0.0` when the user has no state for the exercise.
234    pub score_given: f32,
235    /// The maximum points obtainable from the exercise.
236    pub score_maximum: i32,
237    /// `true` once the exercise has reached the `Completed` activity stage.
238    pub completed: bool,
239    /// `true` once the user has started or submitted the exercise (any activity stage past
240    /// the initial one), regardless of whether it is completed.
241    pub attempted: bool,
242    /// Absent only from a host that predates the field.
243    #[serde(default)]
244    pub standing: Option<ExerciseStanding>,
245}
246
247/// Where the user stands on an exercise, decided by the host so every client agrees.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
250pub enum ExerciseStanding {
251    /// No graded submission and no try used.
252    NotAttempted,
253    /// Below full points with tries left, or still being graded.
254    Attempted,
255    /// A grading awarded full points. An exercise worth 0 points passes on its first grading.
256    Passed,
257    /// Below full points (0 included) and the try limit is used up on every slide, so the
258    /// score is final.
259    OutOfTries,
260}
261
262/// A shareable URL for a submission.
263#[derive(Debug, Serialize, Deserialize)]
264#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
265pub struct PasteResult {
266    pub paste_url: String,
267}
268
269#[cfg(test)]
270mod test {
271    #![allow(clippy::unwrap_used)]
272    use super::*;
273    use serde_json::json;
274
275    // Guards against utoipa/serde drift: the externally-tagged enum must stay the bare
276    // string `"NoGradingYet"` and `{"Grading": {...}}`, which the OpenAPI spec documents
277    // as a `oneOf` of exactly those two shapes.
278    #[test]
279    fn submission_status_serializes_externally_tagged() {
280        assert_eq!(
281            serde_json::to_value(ExerciseTaskSubmissionStatus::NoGradingYet).unwrap(),
282            json!("NoGradingYet"),
283        );
284
285        let graded = ExerciseTaskSubmissionStatus::Grading {
286            grading_progress: GradingProgress::FullyGraded,
287            score_given: Some(1.0),
288            grading_started_at: None,
289            grading_completed_at: None,
290            feedback_json: None,
291            feedback_text: Some("ok".to_string()),
292            exercise_progress: Some(ExerciseProgress {
293                exercise_id: Uuid::nil(),
294                score_given: 1.0,
295                score_maximum: 1,
296                completed: true,
297                attempted: true,
298                standing: Some(ExerciseStanding::Passed),
299            }),
300        };
301        assert_eq!(
302            serde_json::to_value(&graded).unwrap(),
303            json!({
304                "Grading": {
305                    "grading_progress": "FullyGraded",
306                    "score_given": 1.0,
307                    "grading_started_at": null,
308                    "grading_completed_at": null,
309                    "feedback_json": null,
310                    "feedback_text": "ok",
311                    "exercise_progress": {
312                        "exercise_id": Uuid::nil(),
313                        "score_given": 1.0,
314                        "score_maximum": 1,
315                        "completed": true,
316                        "attempted": true,
317                        "standing": "Passed",
318                    },
319                }
320            }),
321        );
322    }
323
324    /// A newer client must still read a host that predates `exercise_progress`.
325    #[test]
326    fn grading_without_exercise_progress_still_parses() {
327        let status: ExerciseTaskSubmissionStatus = serde_json::from_value(json!({
328            "Grading": {
329                "grading_progress": "FullyGraded",
330                "score_given": 1.0,
331                "grading_started_at": null,
332                "grading_completed_at": null,
333                "feedback_json": null,
334                "feedback_text": null,
335            }
336        }))
337        .unwrap();
338        assert!(matches!(
339            status,
340            ExerciseTaskSubmissionStatus::Grading {
341                exercise_progress: None,
342                ..
343            }
344        ));
345    }
346
347    /// A newer client must still read a host that predates `page_url` and `chapter`.
348    #[test]
349    fn exercise_slide_without_page_url_or_chapter_still_parses() {
350        let slide: ExerciseSlide = serde_json::from_value(json!({
351            "slide_id": Uuid::nil(),
352            "exercise_id": Uuid::nil(),
353            "course_id": Uuid::nil(),
354            "exercise_name": "name",
355            "exercise_order_number": 0,
356            "deadline": null,
357            "tasks": [],
358        }))
359        .unwrap();
360        assert_eq!(slide.page_url, None);
361        assert!(slide.chapter.is_none());
362    }
363
364    #[test]
365    fn grading_progress_serializes_as_plain_strings() {
366        assert_eq!(
367            serde_json::to_value(GradingProgress::FullyGraded).unwrap(),
368            json!("FullyGraded"),
369        );
370        assert_eq!(
371            serde_json::to_value(GradingProgress::PendingManual).unwrap(),
372            json!("PendingManual"),
373        );
374    }
375
376    /// A file answer names ids only. No archive may appear in the body: the named files are the
377    /// answer, and the host has no archive concept left.
378    #[test]
379    fn exercise_slide_submission_names_files() {
380        let file_id = Uuid::max();
381        let value = serde_json::to_value(ExerciseSlideSubmission {
382            exercise_slide_id: Uuid::nil(),
383            exercise_task_id: Uuid::nil(),
384            answer_kind: Some(AnswerKind::File),
385            data_json: None,
386            data_files: Some(vec![file_id]),
387        })
388        .unwrap();
389        let obj = value.as_object().unwrap();
390        assert!(obj.contains_key("exercise_slide_id"));
391        assert!(obj.contains_key("exercise_task_id"));
392        assert_eq!(obj["answer_kind"], json!("file"));
393        assert_eq!(obj["data_files"], json!([file_id]));
394        assert!(!obj.contains_key("data_json"));
395        assert_eq!(obj.len(), 4);
396    }
397
398    /// A client that only ever answers with JSON sends neither `answer_kind` nor `data_files`.
399    #[test]
400    fn exercise_slide_submission_answer_kind_is_optional() {
401        let submission: ExerciseSlideSubmission = serde_json::from_value(json!({
402            "exercise_slide_id": Uuid::nil(),
403            "exercise_task_id": Uuid::nil(),
404            "data_json": { "opaque": "service owned" },
405        }))
406        .unwrap();
407        assert_eq!(submission.answer_kind, None);
408        assert_eq!(submission.data_files, None);
409    }
410
411    /// A client must never have to derive one submission id from the other; both come back.
412    #[test]
413    fn submit_result_carries_both_submission_ids() {
414        let task_submission_id = Uuid::nil();
415        let slide_submission_id = Uuid::max();
416        let value = serde_json::to_value(ExerciseTaskSubmissionResult {
417            task_submission_id,
418            slide_submission_id,
419        })
420        .unwrap();
421        assert_eq!(
422            value,
423            json!({
424                "task_submission_id": task_submission_id,
425                "slide_submission_id": slide_submission_id,
426            })
427        );
428    }
429
430    #[test]
431    fn uploaded_and_submission_files_share_the_file_shape() {
432        let id = Uuid::max();
433        let file = || AnswerFile {
434            id,
435            name: "src/main.rs".to_string(),
436            mime: "application/octet-stream".to_string(),
437            size_bytes: Some(11),
438            order_number: Some(0),
439            url: "http://project-331.local/api/v0/files/tmc/abc".to_string(),
440        };
441        let expected = json!({
442            "data_files": [{
443                "id": id,
444                "name": "src/main.rs",
445                "mime": "application/octet-stream",
446                "size_bytes": 11,
447                "order_number": 0,
448                "url": "http://project-331.local/api/v0/files/tmc/abc",
449            }]
450        });
451        assert_eq!(
452            serde_json::to_value(UploadedFiles {
453                data_files: vec![file()]
454            })
455            .unwrap(),
456            expected
457        );
458        assert_eq!(
459            serde_json::to_value(SubmissionFiles {
460                data_files: vec![file()]
461            })
462            .unwrap(),
463            expected
464        );
465    }
466
467    /// Not the normal path for any origin any more, but still representable: an exercise type with
468    /// no files, or a service that cannot enumerate its answers' files.
469    #[test]
470    fn submission_files_may_be_empty() {
471        assert_eq!(
472            serde_json::to_value(SubmissionFiles {
473                data_files: Vec::new()
474            })
475            .unwrap(),
476            json!({ "data_files": [] })
477        );
478    }
479
480    #[test]
481    fn course_progress_shape() {
482        let value = serde_json::to_value(CourseProgress {
483            course_id: Uuid::nil(),
484            exercises: vec![ExerciseProgress {
485                exercise_id: Uuid::nil(),
486                score_given: 1.5,
487                score_maximum: 3,
488                completed: false,
489                attempted: true,
490                standing: Some(ExerciseStanding::OutOfTries),
491            }],
492        })
493        .unwrap();
494        let obj = value.as_object().unwrap();
495        assert!(obj.contains_key("course_id"));
496        let exercises = obj["exercises"].as_array().unwrap();
497        let ex = exercises[0].as_object().unwrap();
498        assert_eq!(ex["score_given"], json!(1.5));
499        assert_eq!(ex["score_maximum"], json!(3));
500        assert_eq!(ex["completed"], json!(false));
501        assert_eq!(ex["attempted"], json!(true));
502        assert_eq!(ex["standing"], json!("OutOfTries"));
503    }
504
505    /// A newer client must still read a host that predates `standing`.
506    #[test]
507    fn progress_without_standing_still_parses() {
508        let progress: ExerciseProgress = serde_json::from_value(json!({
509            "exercise_id": Uuid::nil(),
510            "score_given": 0.0,
511            "score_maximum": 1,
512            "completed": false,
513            "attempted": false,
514        }))
515        .unwrap();
516        assert_eq!(progress.standing, None);
517    }
518
519    #[test]
520    fn submission_list_item_shape() {
521        let value = json!({
522            "id": Uuid::nil(),
523            "exercise_id": Uuid::nil(),
524            "created_at": "2026-07-21T00:00:00Z",
525            "score_given": 1.0,
526            "grading_progress": "FullyGraded"
527        });
528        let item: ExerciseSlideSubmissionListItem = serde_json::from_value(value).unwrap();
529        assert_eq!(item.score_given, Some(1.0));
530        assert!(matches!(
531            item.grading_progress,
532            Some(GradingProgress::FullyGraded)
533        ));
534    }
535}