1#![deny(clippy::print_stdout, clippy::print_stderr, clippy::unwrap_used)]
2
3mod error;
6mod exercise;
7
8pub use self::{
9 error::{MoocClientError, MoocClientResult},
10 exercise::{ExerciseFile, ModelSolutionSpec, PublicSpec, TmcExerciseSlide, TmcExerciseTask},
11};
12use bytes::Bytes;
13use chrono::{DateTime, Utc};
14pub use mooc_langs_api as api;
15use mooc_langs_api::ExerciseUpdateData;
16use oauth2::TokenResponse;
17use reqwest::{
18 Method, StatusCode,
19 blocking::{
20 Client, RequestBuilder, Response,
21 multipart::{Form, Part},
22 },
23};
24use schemars::JsonSchema;
25use serde::{Serialize, de::DeserializeOwned};
26use std::{path::Path, sync::Arc};
27use tmc_langs_util::{JsonError, serialize};
28#[cfg(feature = "ts-rs")]
29use ts_rs::TS;
30use url::Url;
31use uuid::Uuid;
32
33#[derive(Clone)]
36pub struct MoocClient(Arc<MoocClientInner>);
37
38struct MoocClientInner {
39 client: Client,
40 root_url: Url,
41 token: Option<api::Token>,
42}
43
44impl MoocClient {
46 pub fn new(root_url: Url) -> Self {
48 let root_url = if root_url.as_str().ends_with('/') {
50 root_url
51 } else {
52 format!("{root_url}/").parse().expect("invalid root url")
53 };
54
55 Self(Arc::new(MoocClientInner {
56 client: Client::new(),
57 root_url,
58 token: None,
59 }))
60 }
61
62 fn request(&self, method: Method, url: Url) -> MoocRequest {
63 log::debug!("building a request to {url}");
64
65 let trusted_domains = &["courses.mooc.fi", "project-331.local"];
66 let is_trusted_domain = url
67 .domain()
68 .map(|d| trusted_domains.contains(&d))
69 .unwrap_or_default();
70 let mut builder = self.0.client.request(method.clone(), url.clone());
71 if let Some(token) = self.0.token.as_ref() {
72 if is_trusted_domain {
73 log::debug!("setting bearer token");
74 builder = builder.bearer_auth(token.access_token().secret());
75 } else {
76 log::debug!("leaving out bearer token due to untrusted domain");
77 }
78 } else {
79 log::debug!("no bearer token");
80 }
81 MoocRequest {
82 url,
83 method,
84 builder,
85 }
86 }
87
88 pub fn set_token(&mut self, token: api::Token) {
89 Arc::get_mut(&mut self.0)
90 .expect("called when multiple clones exist")
91 .token = Some(token);
92 }
93}
94
95impl MoocClient {
97 pub fn course_instances(&self) -> MoocClientResult<Vec<CourseInstance>> {
98 let url = make_langs_api_url(self, "course-instances")?;
99 let res = self
100 .request(Method::GET, url)
101 .send_expect_json::<Vec<api::CourseInstance>>()?;
102 Ok(res.into_iter().map(Into::into).collect())
103 }
104
105 pub fn course_instance_exercises(
106 &self,
107 course_instance: Uuid,
108 ) -> MoocClientResult<Vec<TmcExerciseSlide>> {
109 let url = make_langs_api_url(
110 self,
111 format!("course-instances/{course_instance}/exercises"),
112 )?;
113 let res = self
114 .request(Method::GET, url.clone())
115 .send_expect_json::<Vec<api::ExerciseSlide>>()?
116 .into_iter()
117 .map(TryFrom::try_from)
118 .collect::<Result<_, JsonError>>()
119 .map_err(|err| MoocClientError::DeserializingResponse {
120 url,
121 error: err.into(),
122 })?;
123 Ok(res)
124 }
125
126 pub fn exercise(&self, exercise: Uuid) -> MoocClientResult<TmcExerciseSlide> {
127 let url = make_langs_api_url(self, format!("exercises/{exercise}"))?;
128 let res = self
129 .request(Method::GET, url.clone())
130 .send_expect_json::<api::ExerciseSlide>()?
131 .try_into()
132 .map_err(|err: JsonError| MoocClientError::DeserializingResponse {
133 url,
134 error: err.into(),
135 })?;
136 Ok(res)
137 }
138
139 pub fn check_exercise_updates(
140 &self,
141 exercises: &[ExerciseUpdateData],
142 ) -> MoocClientResult<ExerciseUpdates> {
143 let url = make_langs_api_url(self, "updates")?;
144 let res = self
145 .request(Method::POST, url.clone())
146 .json(&api::ExerciseUpdatesRequest { exercises })
147 .send_expect_json::<api::ExerciseUpdates>()?
148 .into();
149 Ok(res)
150 }
151
152 pub fn download(&self, url: Url) -> MoocClientResult<Bytes> {
153 let res = self.request(Method::GET, url).send_expect_bytes()?;
154 Ok(res)
155 }
156
157 pub fn download_exercise(&self, exercise: Uuid) -> MoocClientResult<Bytes> {
158 let url = make_langs_api_url(self, format!("exercises/{exercise}/download"))?;
159 let res = self.request(Method::GET, url).send_expect_bytes()?;
160 Ok(res)
161 }
162
163 pub fn submit(
164 &self,
165 exercise_id: Uuid,
166 slide_id: Uuid,
167 task_id: Uuid,
168 archive: &Path,
169 ) -> MoocClientResult<ExerciseTaskSubmissionResult> {
170 let exercise_slide_submission = api::ExerciseSlideSubmission {
171 exercise_slide_id: slide_id,
172 exercise_task_id: task_id,
173 data_json: serde_json::Value::Null,
174 };
175 let exercise_slide_submission = serialize::to_json_vec(&exercise_slide_submission)
176 .map_err(Into::into)
177 .map_err(Box::new)?;
178 let submission = Form::new()
179 .part(
180 "metadata",
181 Part::bytes(exercise_slide_submission)
182 .mime_str("application/json")
183 .expect("known to work"),
184 )
185 .file("file", archive)
186 .map_err(|err| MoocClientError::AttachFileToForm { error: err.into() })?;
187
188 let url = make_langs_api_url(self, format!("exercises/{exercise_id}/submit"))?;
199 let res = self
200 .request(Method::POST, url)
201 .multipart(submission)
202 .send_expect_json::<api::ExerciseTaskSubmissionResult>()?;
203 Ok(res.into())
204 }
205
206 pub fn get_submission_grading(
207 &self,
208 submission_id: Uuid,
209 ) -> MoocClientResult<ExerciseTaskSubmissionStatus> {
210 let url = make_langs_api_url(self, format!("submissions/{submission_id}/grading"))?;
211 let res = self
212 .request(Method::GET, url)
213 .send_expect_json::<api::ExerciseTaskSubmissionStatus>()?;
214 Ok(res.into())
215 }
216}
217
218struct MoocRequest {
220 url: Url,
221 method: Method,
222 builder: RequestBuilder,
223}
224
225impl MoocRequest {
226 fn json<T: Serialize>(mut self, json: &T) -> Self {
227 self.builder = self.builder.json(json);
228 self
229 }
230
231 fn multipart(mut self, form: Form) -> Self {
232 self.builder = self.builder.multipart(form);
233 self
234 }
235
236 fn send(self) -> MoocClientResult<Response> {
237 match self.builder.send() {
238 Ok(res) => {
239 let status = res.status();
240 match status {
241 _success if status.is_success() => Ok(res),
242 StatusCode::UNAUTHORIZED => Err(Box::new(MoocClientError::NotAuthenticated)),
243 _other => {
244 let status = res.status();
245 let body =
246 res.text()
247 .map_err(|err| MoocClientError::ReadingResponseBody {
248 method: self.method,
249 url: self.url.clone(),
250 error: Box::new(err),
251 })?;
252 Err(Box::new(MoocClientError::HttpError {
253 url: self.url,
254 status,
255 error: body,
256 obsolete_client: false,
257 }))
258 }
259 }
260 }
261 Err(error) => Err(Box::new(MoocClientError::ConnectionError(
262 self.method,
263 self.url,
264 error,
265 ))),
266 }
267 }
268
269 fn send_expect_bytes(self) -> MoocClientResult<Bytes> {
270 let method = self.method.clone();
271 let url = self.url.clone();
272 let res = self.send()?;
273 let body = res
274 .bytes()
275 .map_err(|err| MoocClientError::ReadingResponseBody {
276 method,
277 url,
278 error: Box::new(err),
279 })?;
280 Ok(body)
281 }
282
283 fn send_expect_json<T>(self) -> MoocClientResult<T>
284 where
285 T: DeserializeOwned,
286 {
287 let url = self.url.clone();
288 let bytes = self.send_expect_bytes()?;
289 let json = serde_json::from_slice(&bytes).map_err(|err| {
290 MoocClientError::DeserializingResponse {
291 url,
292 error: Box::new(err),
293 }
294 })?;
295 Ok(json)
296 }
297}
298
299fn make_langs_api_url(client: &MoocClient, tail: impl AsRef<str>) -> MoocClientResult<Url> {
301 client
302 .0
303 .root_url
304 .join("/api/v0/langs/")
305 .and_then(|u| u.join(tail.as_ref()))
306 .map_err(|e| MoocClientError::UrlParse(tail.as_ref().to_string(), e))
307 .map_err(Box::new)
308}
309
310#[derive(Debug, Serialize, JsonSchema)]
311#[cfg_attr(feature = "ts-rs", derive(TS))]
312pub struct CourseInstance {
313 pub id: Uuid,
314 pub course_id: Uuid,
315 pub course_slug: String,
316 pub course_name: String,
317 pub course_description: Option<String>,
318 pub instance_name: Option<String>,
319 pub instance_description: Option<String>,
320}
321
322impl From<api::CourseInstance> for CourseInstance {
323 fn from(value: api::CourseInstance) -> Self {
324 Self {
325 id: value.id,
326 course_id: value.course_id,
327 course_slug: value.course_slug,
328 course_name: value.course_name,
329 course_description: value.course_description,
330 instance_name: value.instance_name,
331 instance_description: value.instance_description,
332 }
333 }
334}
335
336#[derive(Debug, Serialize, JsonSchema)]
337#[cfg_attr(feature = "ts-rs", derive(TS))]
338pub struct CourseInstanceInfo {
339 pub id: Uuid,
340 pub course_id: Uuid,
341 pub course_slug: String,
342 pub course_name: String,
343 pub course_description: Option<String>,
344 pub instance_name: Option<String>,
345 pub instance_description: Option<String>,
346}
347
348#[derive(Debug, Serialize)]
349#[cfg_attr(feature = "ts-rs", derive(TS))]
350pub struct ExerciseTaskSubmissionResult {
351 pub submission_id: Uuid,
352}
353
354impl From<api::ExerciseTaskSubmissionResult> for ExerciseTaskSubmissionResult {
355 fn from(value: api::ExerciseTaskSubmissionResult) -> Self {
356 Self {
357 submission_id: value.submission_id,
358 }
359 }
360}
361
362#[derive(Debug, Serialize)]
363#[cfg_attr(feature = "ts-rs", derive(TS))]
364pub enum ExerciseTaskSubmissionStatus {
365 NoGradingYet,
366 Grading {
367 grading_progress: GradingProgress,
368 score_given: Option<f32>,
369 grading_started_at: Option<DateTime<Utc>>,
370 grading_completed_at: Option<DateTime<Utc>>,
371 feedback_json: Option<serde_json::Value>,
372 feedback_text: Option<String>,
373 },
374}
375
376impl From<api::ExerciseTaskSubmissionStatus> for ExerciseTaskSubmissionStatus {
377 fn from(value: api::ExerciseTaskSubmissionStatus) -> Self {
378 match value {
379 api::ExerciseTaskSubmissionStatus::NoGradingYet => Self::NoGradingYet,
380 api::ExerciseTaskSubmissionStatus::Grading {
381 grading_progress,
382 score_given,
383 grading_started_at,
384 grading_completed_at,
385 feedback_json,
386 feedback_text,
387 } => Self::Grading {
388 grading_progress: grading_progress.into(),
389 score_given,
390 grading_started_at,
391 grading_completed_at,
392 feedback_json,
393 feedback_text,
394 },
395 }
396 }
397}
398
399#[derive(Debug, Clone, Copy, Serialize)]
400#[cfg_attr(feature = "ts-rs", derive(TS))]
401pub enum GradingProgress {
402 Failed,
404 NotReady,
406 PendingManual,
408 Pending,
410 FullyGraded,
412}
413
414impl From<api::GradingProgress> for GradingProgress {
415 fn from(value: api::GradingProgress) -> Self {
416 match value {
417 api::GradingProgress::Failed => Self::Failed,
418 api::GradingProgress::NotReady => Self::NotReady,
419 api::GradingProgress::PendingManual => Self::PendingManual,
420 api::GradingProgress::Pending => Self::Pending,
421 api::GradingProgress::FullyGraded => Self::FullyGraded,
422 }
423 }
424}
425
426#[derive(Debug, Serialize)]
427pub struct ExerciseUpdates {
428 pub updated_exercises: Vec<Uuid>,
429 pub deleted_exercises: Vec<Uuid>,
430}
431
432impl From<api::ExerciseUpdates> for ExerciseUpdates {
433 fn from(value: api::ExerciseUpdates) -> Self {
434 Self {
435 updated_exercises: value.updated_exercises,
436 deleted_exercises: value.deleted_exercises,
437 }
438 }
439}
440
441#[cfg(test)]
442mod test {
443 use super::*;
444 use mockito::Server;
445 use mooc_langs_api::Token;
446 use oauth2::{AccessToken, EmptyExtraTokenFields, basic::BasicTokenType};
447
448 fn init() {
449 use log::*;
450 use simple_logger::*;
451
452 let _ = SimpleLogger::new()
453 .with_level(LevelFilter::Debug)
454 .with_module_level("mockito", LevelFilter::Warn)
456 .with_module_level("reqwest", LevelFilter::Warn)
458 .with_module_level("hyper", LevelFilter::Warn)
460 .init();
461 }
462
463 fn make_client(server: &Server) -> MoocClient {
464 let mut client = MoocClient::new(server.url().parse().unwrap());
465 let token = Token::new(
466 AccessToken::new("".to_string()),
467 BasicTokenType::Bearer,
468 EmptyExtraTokenFields {},
469 );
470 client.set_token(token);
471 client
472 }
473
474 #[test]
475 fn gets_course_instances() {
476 init();
477 let mut server = Server::new();
478 let client = make_client(&server);
479 server
480 .mock("GET", "/api/v0/langs/course-instances")
481 .with_body(
482 serde_json::json!([{
483 "id": Uuid::new_v4(),
484 "course_id": Uuid::new_v4(),
485 "course_slug": "mockslug",
486 "course_name": "mockname",
487 "course_description": "mockdesc",
488 }])
489 .to_string(),
490 )
491 .create();
492 let course_instances = client.course_instances().unwrap();
493 assert_eq!(course_instances[0].course_name, "mockname");
494 }
495
496 #[test]
497 fn gets_course_instance_exercise_slides() {
498 init();
499 let mut server = Server::new();
500 let client = make_client(&server);
501 server
502 .mock(
503 "GET",
504 "/api/v0/langs/course-instances/df5ee6c1-57d1-43b6-b39e-5d72119edb5f/exercises",
505 )
506 .with_body(
507 serde_json::json!([{
508 "slide_id": Uuid::new_v4(),
509 "exercise_id": Uuid::new_v4(),
510 "exercise_name": "mockname",
511 "exercise_order_number": 0,
512 "tasks": [],
513 }])
514 .to_string(),
515 )
516 .create();
517 let exercise_sludes = client
518 .course_instance_exercises(
519 Uuid::parse_str("df5ee6c1-57d1-43b6-b39e-5d72119edb5f").unwrap(),
520 )
521 .unwrap();
522 assert_eq!(exercise_sludes[0].exercise_name, "mockname");
523 }
524
525 #[test]
526 fn gets_exercise() {
527 init();
528 let mut server = Server::new();
529 let client = make_client(&server);
530 server
531 .mock(
532 "GET",
533 "/api/v0/langs/exercises/df5ee6c1-57d1-43b6-b39e-5d72119edb5f",
534 )
535 .with_body(
536 serde_json::json!({
537 "slide_id": Uuid::new_v4(),
538 "exercise_id": Uuid::new_v4(),
539 "exercise_name": "mockname",
540 "exercise_order_number": 0,
541 "tasks": [],
542 })
543 .to_string(),
544 )
545 .create();
546 let exercise = client
547 .exercise(Uuid::parse_str("df5ee6c1-57d1-43b6-b39e-5d72119edb5f").unwrap())
548 .unwrap();
549 assert_eq!(exercise.exercise_name, "mockname");
550 }
551
552 #[test]
553 fn downloads_exercise() {
554 init();
555 let mut server = Server::new();
556 let client = make_client(&server);
557 server
558 .mock(
559 "GET",
560 "/api/v0/langs/exercises/df5ee6c1-57d1-43b6-b39e-5d72119edb5f/download",
561 )
562 .with_body_from_file("./tests/data/file")
563 .create();
564 let exercise = client
565 .download_exercise(Uuid::parse_str("df5ee6c1-57d1-43b6-b39e-5d72119edb5f").unwrap())
566 .unwrap();
567 assert_eq!(String::from_utf8(exercise.into()).unwrap(), "hello!");
568 }
569
570 #[test]
571 fn submits() {
572 init();
573 let mut server = Server::new();
574 let client = make_client(&server);
575 server
576 .mock(
577 "POST",
578 "/api/v0/langs/exercises/df5ee6c1-57d1-43b6-b39e-5d72119edb5f/submit",
579 )
580 .with_body(
581 serde_json::json!({
582 "submission_id": Uuid::new_v4(),
583 })
584 .to_string(),
585 )
586 .create();
587 let _submission_result = client
588 .submit(
589 Uuid::parse_str("df5ee6c1-57d1-43b6-b39e-5d72119edb5f").unwrap(),
590 Uuid::parse_str("e7bd5a07-1b83-4c97-91f2-e48cccf66b2a").unwrap(),
591 Uuid::parse_str("816ac03a-a713-4804-9ea6-3eb5e278ec2b").unwrap(),
592 Path::new("./tests/data/file"),
593 )
594 .unwrap();
595 }
596
597 #[test]
598 fn gets_submission_grading() {
599 init();
600 let mut server = Server::new();
601 let client = make_client(&server);
602 server
603 .mock(
604 "GET",
605 "/api/v0/langs/submissions/df5ee6c1-57d1-43b6-b39e-5d72119edb5f/grading",
606 )
607 .with_body(serde_json::json!("NoGradingYet").to_string())
608 .create();
609 server
610 .mock(
611 "GET",
612 "/api/v0/langs/submissions/e7bd5a07-1b83-4c97-91f2-e48cccf66b2a/grading",
613 )
614 .with_body(
615 serde_json::json!({
616 "Grading": {
617 "grading_progress": "Failed",
618 }
619 })
620 .to_string(),
621 )
622 .create();
623 let submission_result = client
624 .get_submission_grading(
625 Uuid::parse_str("df5ee6c1-57d1-43b6-b39e-5d72119edb5f").unwrap(),
626 )
627 .unwrap();
628 assert!(matches!(
629 submission_result,
630 ExerciseTaskSubmissionStatus::NoGradingYet
631 ));
632 let submission_result = client
633 .get_submission_grading(
634 Uuid::parse_str("e7bd5a07-1b83-4c97-91f2-e48cccf66b2a").unwrap(),
635 )
636 .unwrap();
637 assert!(matches!(
638 submission_result,
639 ExerciseTaskSubmissionStatus::Grading { .. }
640 ));
641 }
642}