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}
43
44/// One task of an exercise slide, as produced by a specific exercise service.
45///
46/// `public_spec` / `model_solution_spec` are plugin-owned blobs: the exercise service
47/// that produces them is the only component that interprets their shape, so the host
48/// forwards them verbatim and they stay opaque `serde_json::Value` here.
49#[derive(Debug, Serialize, Deserialize)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51pub struct ExerciseTask {
52    pub task_id: Uuid,
53    pub order_number: i32,
54    pub assignment: serde_json::Value,
55    pub public_spec: Option<serde_json::Value>,
56    pub model_solution_spec: Option<serde_json::Value>,
57    pub exercise_service_slug: String,
58}
59
60/// Which of the two representations an answer is: the JSON in `data_json`, or the files in
61/// `data_files`. A request that omits it means `Json`.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
64#[serde(rename_all = "snake_case")]
65pub enum AnswerKind {
66    Json,
67    File,
68}
69
70/// A file the host stored on a client's behalf. The host assigns `id`; a client never invents
71/// one. Returned by the upload endpoint and echoed back by submission download.
72#[derive(Debug, Serialize, Deserialize)]
73#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
74pub struct AnswerFile {
75    /// The host's file id. Names this file in a submit request.
76    pub id: Uuid,
77    /// The original file name the client sent.
78    pub name: String,
79    pub mime: String,
80    /// `None` for a file stored before the size was recorded; never a substitute zero, so a client
81    /// can tell an unknown size from an empty file.
82    pub size_bytes: Option<i64>,
83    /// The file's position in the answer it belongs to. `None` for a file that is not part of an
84    /// answer yet, which is every file the upload endpoint returns.
85    pub order_number: Option<i32>,
86    /// Direct download URL; needs no bearer token.
87    pub url: String,
88}
89
90/// Response of `POST exercises/{id}/files`, in the same order as the request's parts.
91#[derive(Debug, Serialize, Deserialize)]
92#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
93pub struct UploadedFiles {
94    pub data_files: Vec<AnswerFile>,
95}
96
97/// Body of `POST exercises/{id}/submit`. Plain JSON — no file parts, no archive: the previously
98/// uploaded files named here are the answer.
99#[derive(Debug, Serialize, Deserialize)]
100#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
101pub struct ExerciseSlideSubmission {
102    pub exercise_slide_id: Uuid,
103    pub exercise_task_id: Uuid,
104    /// Absent means `json`. A client answering with files sends `file`.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub answer_kind: Option<AnswerKind>,
107    /// The exercise service's own JSON: the whole answer for a `json` answer, the service's
108    /// metadata about the files for a `file` one.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub data_json: Option<serde_json::Value>,
111    /// Ids from this exercise's `files` endpoint, in the order they are to be graded and
112    /// displayed. A `file` answer must name at least one, and every id must have been uploaded by
113    /// this user for this exercise.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub data_files: Option<Vec<Uuid>>,
116}
117
118/// Result of a submit. Carries both ids so a client never re-derives one from the other:
119/// grading polling takes `task_submission_id`, download/share take `slide_submission_id`.
120#[derive(Debug, Serialize, Deserialize)]
121#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
122pub struct ExerciseTaskSubmissionResult {
123    pub task_submission_id: Uuid,
124    pub slide_submission_id: Uuid,
125}
126
127/// The grading status of a task submission, as polled after `submit`.
128#[derive(Debug, Serialize, Deserialize)]
129#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
130pub enum ExerciseTaskSubmissionStatus {
131    NoGradingYet,
132    Grading {
133        grading_progress: GradingProgress,
134        /// Absent until grading has produced a value; a partial value while `grading_progress`
135        /// is still pending.
136        score_given: Option<f32>,
137        grading_started_at: Option<DateTime<Utc>>,
138        grading_completed_at: Option<DateTime<Utc>>,
139        /// Structured grading feedback, opaque like `ExerciseTask`'s spec fields: only the
140        /// exercise service that produced it interprets its shape.
141        feedback_json: Option<serde_json::Value>,
142        /// Human-readable feedback, for a client to display as-is.
143        feedback_text: Option<String>,
144    },
145}
146
147/// How far along a task submission's grading is.
148#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
149#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
150pub enum GradingProgress {
151    /// The grading could not complete.
152    Failed,
153    /// No grading has occurred yet, e.g. the student has made no submission.
154    NotReady,
155    /// Final grade pending, needs human intervention. `score_given`, if present, is partial and may still change.
156    PendingManual,
157    /// Final grade pending, no human intervention needed. `score_given`, if present, is partial and may still change.
158    Pending,
159    /// Grading is complete. `score_given`, if present, is the final grade.
160    FullyGraded,
161}
162
163/// A past submission of the current user to an exercise. `id` is the
164/// exercise-slide-submission id, which is what `submissions/{id}/download` takes.
165#[derive(Debug, Serialize, Deserialize)]
166#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
167pub struct ExerciseSlideSubmissionListItem {
168    pub id: Uuid,
169    pub exercise_id: Uuid,
170    pub created_at: DateTime<Utc>,
171    pub score_given: Option<f32>,
172    pub grading_progress: Option<GradingProgress>,
173}
174
175/// Response of `GET submissions/{id}/download`: the files the submission was made from, recovered
176/// from the host's own file records rather than from the service's answer.
177///
178/// The same shape regardless of where the submission was made. A native client's uploads are
179/// recorded as it names them; an answer made in the service's IFrame carries its files inside the
180/// service's own answer, so the host asks the service to enumerate them and stores them the same
181/// way. Empty only when the submission genuinely has no files — an exercise type with none, or a
182/// service that declares no way to enumerate its answers' files.
183#[derive(Debug, Serialize, Deserialize)]
184#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
185pub struct SubmissionFiles {
186    pub data_files: Vec<AnswerFile>,
187}
188
189/// The current user's progress across every exercise they can see in a course, returned
190/// by `courses/{id}/progress` in a single round-trip. Course-level totals are not sent
191/// separately; the client derives them by summing over `exercises` (e.g. total awarded =
192/// `sum(score_given)`, total available = `sum(score_maximum)`).
193#[derive(Debug, Serialize, Deserialize)]
194#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
195pub struct CourseProgress {
196    /// The course these progress entries belong to; echoes the path id.
197    pub course_id: Uuid,
198    pub exercises: Vec<ExerciseProgress>,
199}
200
201/// The current user's progress on a single exercise.
202///
203/// A client derives a boolean "passed" from these fields. The authoritative signal is
204/// `completed` (the exercise reached the `Completed` activity stage). A client that
205/// instead treats "full points" as passing can use `score_given >= score_maximum` when
206/// `score_maximum > 0`.
207#[derive(Debug, Serialize, Deserialize)]
208#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
209pub struct ExerciseProgress {
210    pub exercise_id: Uuid,
211    /// Points the user has been awarded, `0.0` when the user has no state for the exercise.
212    pub score_given: f32,
213    /// The maximum points obtainable from the exercise.
214    pub score_maximum: i32,
215    /// `true` once the exercise has reached the `Completed` activity stage. The primary
216    /// "passed" signal.
217    pub completed: bool,
218    /// `true` once the user has started or submitted the exercise (any activity stage past
219    /// the initial one), regardless of whether it is completed.
220    pub attempted: bool,
221}
222
223/// A shareable URL for a submission.
224#[derive(Debug, Serialize, Deserialize)]
225#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
226pub struct PasteResult {
227    pub paste_url: String,
228}
229
230#[cfg(test)]
231mod test {
232    #![allow(clippy::unwrap_used)]
233    use super::*;
234    use serde_json::json;
235
236    // Guards against utoipa/serde drift: the externally-tagged enum must stay the bare
237    // string `"NoGradingYet"` and `{"Grading": {...}}`, which the OpenAPI spec documents
238    // as a `oneOf` of exactly those two shapes.
239    #[test]
240    fn submission_status_serializes_externally_tagged() {
241        assert_eq!(
242            serde_json::to_value(ExerciseTaskSubmissionStatus::NoGradingYet).unwrap(),
243            json!("NoGradingYet"),
244        );
245
246        let graded = ExerciseTaskSubmissionStatus::Grading {
247            grading_progress: GradingProgress::FullyGraded,
248            score_given: Some(1.0),
249            grading_started_at: None,
250            grading_completed_at: None,
251            feedback_json: None,
252            feedback_text: Some("ok".to_string()),
253        };
254        assert_eq!(
255            serde_json::to_value(&graded).unwrap(),
256            json!({
257                "Grading": {
258                    "grading_progress": "FullyGraded",
259                    "score_given": 1.0,
260                    "grading_started_at": null,
261                    "grading_completed_at": null,
262                    "feedback_json": null,
263                    "feedback_text": "ok",
264                }
265            }),
266        );
267    }
268
269    #[test]
270    fn grading_progress_serializes_as_plain_strings() {
271        assert_eq!(
272            serde_json::to_value(GradingProgress::FullyGraded).unwrap(),
273            json!("FullyGraded"),
274        );
275        assert_eq!(
276            serde_json::to_value(GradingProgress::PendingManual).unwrap(),
277            json!("PendingManual"),
278        );
279    }
280
281    /// A file answer names ids only. No archive may appear in the body: the named files are the
282    /// answer, and the host has no archive concept left.
283    #[test]
284    fn exercise_slide_submission_names_files() {
285        let file_id = Uuid::max();
286        let value = serde_json::to_value(ExerciseSlideSubmission {
287            exercise_slide_id: Uuid::nil(),
288            exercise_task_id: Uuid::nil(),
289            answer_kind: Some(AnswerKind::File),
290            data_json: None,
291            data_files: Some(vec![file_id]),
292        })
293        .unwrap();
294        let obj = value.as_object().unwrap();
295        assert!(obj.contains_key("exercise_slide_id"));
296        assert!(obj.contains_key("exercise_task_id"));
297        assert_eq!(obj["answer_kind"], json!("file"));
298        assert_eq!(obj["data_files"], json!([file_id]));
299        assert!(!obj.contains_key("data_json"));
300        assert_eq!(obj.len(), 4);
301    }
302
303    /// A client that only ever answers with JSON sends neither `answer_kind` nor `data_files`.
304    #[test]
305    fn exercise_slide_submission_answer_kind_is_optional() {
306        let submission: ExerciseSlideSubmission = serde_json::from_value(json!({
307            "exercise_slide_id": Uuid::nil(),
308            "exercise_task_id": Uuid::nil(),
309            "data_json": { "opaque": "service owned" },
310        }))
311        .unwrap();
312        assert_eq!(submission.answer_kind, None);
313        assert_eq!(submission.data_files, None);
314    }
315
316    /// A client must never have to derive one submission id from the other; both come back.
317    #[test]
318    fn submit_result_carries_both_submission_ids() {
319        let task_submission_id = Uuid::nil();
320        let slide_submission_id = Uuid::max();
321        let value = serde_json::to_value(ExerciseTaskSubmissionResult {
322            task_submission_id,
323            slide_submission_id,
324        })
325        .unwrap();
326        assert_eq!(
327            value,
328            json!({
329                "task_submission_id": task_submission_id,
330                "slide_submission_id": slide_submission_id,
331            })
332        );
333    }
334
335    #[test]
336    fn uploaded_and_submission_files_share_the_file_shape() {
337        let id = Uuid::max();
338        let file = || AnswerFile {
339            id,
340            name: "src/main.rs".to_string(),
341            mime: "application/octet-stream".to_string(),
342            size_bytes: Some(11),
343            order_number: Some(0),
344            url: "http://project-331.local/api/v0/files/tmc/abc".to_string(),
345        };
346        let expected = json!({
347            "data_files": [{
348                "id": id,
349                "name": "src/main.rs",
350                "mime": "application/octet-stream",
351                "size_bytes": 11,
352                "order_number": 0,
353                "url": "http://project-331.local/api/v0/files/tmc/abc",
354            }]
355        });
356        assert_eq!(
357            serde_json::to_value(UploadedFiles {
358                data_files: vec![file()]
359            })
360            .unwrap(),
361            expected
362        );
363        assert_eq!(
364            serde_json::to_value(SubmissionFiles {
365                data_files: vec![file()]
366            })
367            .unwrap(),
368            expected
369        );
370    }
371
372    /// Not the normal path for any origin any more, but still representable: an exercise type with
373    /// no files, or a service that cannot enumerate its answers' files.
374    #[test]
375    fn submission_files_may_be_empty() {
376        assert_eq!(
377            serde_json::to_value(SubmissionFiles {
378                data_files: Vec::new()
379            })
380            .unwrap(),
381            json!({ "data_files": [] })
382        );
383    }
384
385    #[test]
386    fn course_progress_shape() {
387        let value = serde_json::to_value(CourseProgress {
388            course_id: Uuid::nil(),
389            exercises: vec![ExerciseProgress {
390                exercise_id: Uuid::nil(),
391                score_given: 1.5,
392                score_maximum: 3,
393                completed: false,
394                attempted: true,
395            }],
396        })
397        .unwrap();
398        let obj = value.as_object().unwrap();
399        assert!(obj.contains_key("course_id"));
400        let exercises = obj["exercises"].as_array().unwrap();
401        let ex = exercises[0].as_object().unwrap();
402        assert_eq!(ex["score_given"], json!(1.5));
403        assert_eq!(ex["score_maximum"], json!(3));
404        assert_eq!(ex["completed"], json!(false));
405        assert_eq!(ex["attempted"], json!(true));
406    }
407
408    #[test]
409    fn submission_list_item_shape() {
410        let value = json!({
411            "id": Uuid::nil(),
412            "exercise_id": Uuid::nil(),
413            "created_at": "2026-07-21T00:00:00Z",
414            "score_given": 1.0,
415            "grading_progress": "FullyGraded"
416        });
417        let item: ExerciseSlideSubmissionListItem = serde_json::from_value(value).unwrap();
418        assert_eq!(item.score_given, Some(1.0));
419        assert!(matches!(
420            item.grading_progress,
421            Some(GradingProgress::FullyGraded)
422        ));
423    }
424}