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