classcharts/
client.rs

1use async_trait::async_trait;
2use chrono::{DateTime, Utc};
3use serde::Deserialize;
4use std::{borrow::Cow, string::FromUtf8Error};
5use thiserror::Error;
6
7use reqwest::{header::ToStrError, IntoUrl, RequestBuilder, Response};
8
9use crate::{api::student::StudentInfoData, new_params};
10
11#[derive(Debug)]
12pub struct Client {
13    pub session_id: String,
14    pub student_id: String,
15    reqwest_client: reqwest::Client,
16    base_url: String,
17    auth_cookies: String,
18    last_session_id_updated: DateTime<Utc>,
19}
20
21#[derive(Deserialize, Debug)]
22pub struct SessionCookie {
23    session_id: String,
24}
25
26#[derive(Deserialize, Debug)]
27pub struct CCStatusResponse {
28    pub error: Option<String>,
29    pub success: usize,
30}
31
32#[derive(Deserialize, Debug)]
33pub struct SuccessResponse<Data, Meta> {
34    pub data: Data,
35    pub meta: Meta,
36}
37
38#[derive(Error, Debug)]
39pub enum ClientCreationError {
40    #[error(
41        "Unauthenticated, either your code or date of birth is wrong. No cookies were provided."
42    )]
43    AuthenticationError,
44
45    #[error("Failed to send the API request or create the reqwest client")]
46    ClientError(#[from] reqwest::Error),
47
48    #[error("Cookie cannot not be parsed")]
49    CookieParsingError(#[from] serde_json::Error),
50
51    #[error("Session cookie does not exist on server returned cookies")]
52    MissingSesssionCookie(()),
53
54    #[error("Could not parse header as a string")]
55    StringParseError(#[from] ToStrError),
56
57    #[error("Failed to get student info, error: {0}")]
58    ApiRequestError(#[from] ErrorResponse),
59
60    #[error("Failed to decode the cookie")]
61    StringDecodingError(#[from] FromUtf8Error),
62}
63
64#[async_trait]
65pub trait CCParser {
66    async fn cc_parse(self) -> Result<String, ErrorResponse>;
67}
68
69#[derive(thiserror::Error, Debug)]
70pub enum ErrorResponse {
71    #[error("Failed to process the request")]
72    GenericClientError(#[from] reqwest::Error),
73
74    #[error("Failed to parse the text response")]
75    TextParsingError(#[source] reqwest::Error),
76
77    #[error("Could not parse the json response")]
78    SerdeJsonParsingError(#[from] serde_json::Error),
79
80    #[error("ClassCharts returned the error code: {0}")]
81    ClassChartsStatusError(usize),
82
83    #[error("ClassCharts returned the error code: {0} and message {1}")]
84    ClassChartsError(usize, String),
85}
86
87#[derive(Deserialize, Debug)]
88pub struct SessionMeta {
89    pub session_id: String,
90}
91
92pub type Session = SuccessResponse<StudentInfoData, SessionMeta>;
93
94#[async_trait]
95impl CCParser for Response {
96    /// Parses a reqwest::Response against ClassCharts API "spec".
97    /// If ClassCharts does not return a `{ success: 1 }` then it will return:
98    /// ErrorResponse::ClassChartsError or ErrorResponse::ClassChartsStatusError
99    /// Returns the text so you can parse it with serde_json for the specific endpoint.
100    ///
101    /// Example:
102    ///
103    /// ```ignore
104    /// let sample_request = reqwest::get("some classcharts endpoint").send().await?;
105    /// let text = sample_reqest.cc_parse().await?;
106    ///
107    /// // json parsing logic using `text`
108    /// let json = ...
109    /// ```
110    async fn cc_parse(mut self) -> Result<String, ErrorResponse> {
111        let text = self
112            .text()
113            .await
114            .map_err(ErrorResponse::TextParsingError)?;
115
116        let json = serde_json::from_str::<CCStatusResponse>(&text)?;
117
118        if json.success != 1 {
119            if let Some(error) = json.error {
120                return Err(ErrorResponse::ClassChartsError(json.success, error));
121            } else {
122                return Err(ErrorResponse::ClassChartsStatusError(json.success));
123            }
124        }
125
126        return Ok(text);
127    }
128}
129
130impl Client {
131    /// Builds a get reqwest, injecting ClassCharts Authorization cookies and headers. 
132    /// The url is formed by appending `/apiv2student` to the `base_url` set on the client and then
133    /// appending the path you set onto that.
134    /// 
135    /// Example:
136    /// ```ignore
137    /// let request = client
138    ///     .build_get("/endpoint")
139    ///     .await?
140    ///     .send()
141    ///     .await?;
142    /// ```
143    ///
144    /// For `POST` requests use `.build_post`.
145    pub async fn build_get<P>(&mut self, path: P) -> Result<RequestBuilder, ErrorResponse>
146    where
147        P: IntoUrl + std::fmt::Display,
148    {
149        if (self.last_session_id_updated.time() - Utc::now().time()).num_minutes() > 3 {
150            self.get_new_session_id().await?;
151        }
152
153        return Ok(self
154            .reqwest_client
155            .get(format!("{}/apiv2student{}", self.base_url, path))
156            .header("Cookie", &self.auth_cookies)
157            .header("Authorization", format!("Basic {}", self.session_id)));
158    }
159
160    /// Builds a post reqwest, injecting ClassCharts Authorization cookies and headers. 
161    /// The url is formed by appending `/apiv2student` to the `base_url` set on the client and then
162    /// appending the path you set onto that.
163    /// 
164    /// Example:
165    /// ```ignore
166    /// let request = client
167    ///     .build_post("/endpoint")
168    ///     .await?
169    ///     .send()
170    ///     .await?;
171    /// ```
172    ///
173    /// For `GET` requests use `.build_post`.
174    pub async fn build_post<P>(&mut self, path: P) -> Result<RequestBuilder, ErrorResponse>
175    where
176        P: IntoUrl + std::fmt::Display,
177    {
178        if (self.last_session_id_updated.time() - Utc::now().time()).num_minutes() > 3 {
179            self.get_new_session_id().await?;
180        }
181
182        return Ok(self
183            .reqwest_client
184            .post(format!("{}/apiv2student{}", self.base_url, path))
185            .header("Cookie", &self.auth_cookies)
186            .header("Authorization", format!("Basic {}", self.session_id)));
187    }
188
189    /// Get's a new `session_id` from ClassCharts. It does two things:
190    /// - Returns this id 
191    /// - Sets the `session_id` and `last_session_id_updated` properties on self.
192    pub async fn get_new_session_id(&mut self) -> Result<String, ErrorResponse> {
193        let params = new_params!("include_data", "true");
194
195        let request = self
196            .reqwest_client
197            .post(format!("{}/apiv2student/ping", self.base_url))
198            .header("Cookie", &self.auth_cookies)
199            .header("Authorization", format!("Basic {}", self.session_id))
200            .header(
201                reqwest::header::CONTENT_TYPE,
202                "application/x-www-form-urlencoded",
203            )
204            .body(params)
205            .send()
206            .await?;
207
208        let text = request.cc_parse().await?;
209        let data: Session = serde_json::from_str(&text)?;
210
211        let session_id = data.meta.session_id;
212
213        self.session_id = session_id.clone();
214        self.last_session_id_updated = Utc::now();
215
216        return Ok(session_id);
217    }
218
219    /// This should be used **very** rarely and is only implimented for testing the library with
220    /// custom fields.
221    pub fn manual_creation(
222        student_id: String,
223        base_url: String,
224        auth_cookies: String,
225        session_id: String,
226    ) -> Client {
227        return Client {
228            student_id,
229            base_url,
230            reqwest_client: reqwest::Client::builder()
231                .redirect(reqwest::redirect::Policy::none())
232                .build()
233                .unwrap(),
234            last_session_id_updated: Utc::now(),
235            auth_cookies,
236            session_id,
237        };
238    }
239
240    /// This creates a ClassCharts Student Client. It accepts a `code`, `dob` (Date of birth) and an
241    /// optional `base_url`, which should **rarely** be used and is only implimented for testing.
242    ///
243    /// The `dob` parameter should be in the format of `DD/MM/YYYY`.
244    ///
245    /// Example:
246    /// ```rust,no_run
247    /// use classcharts::Client;
248    /// # #[tokio::main]
249    /// # async fn main() {
250    /// let mut client = Client::create("your access code", "your date of birth
251    /// (DD/MM/YYYY)", None).await.unwrap();
252    /// # }
253    /// ```
254    pub async fn create<C, D>(
255        code: C,
256        dob: D,
257        base_url: Option<String>,
258    ) -> Result<Self, ClientCreationError>
259    where
260        C: ToString,
261        D: Into<Cow<'static, str>>,
262    {
263        let reqwest_client = reqwest::Client::builder()
264            .redirect(reqwest::redirect::Policy::none())
265            .build()?;
266        let base_url = base_url.unwrap_or("https://www.classcharts.com".to_string());
267
268        let login_form = reqwest::multipart::Form::new()
269            .text("_method", "POST")
270            .text("code", code.to_string().to_uppercase())
271            .text("dob", dob)
272            .text("remember_me", "1")
273            .text("recaptcha-token", "no-token-available");
274
275        let login_request = reqwest_client
276            .post(format!("{}/student/login", base_url))
277            .multipart(login_form)
278            .send()
279            .await?;
280
281        let mut cookies = login_request.cookies();
282        let headers = login_request.headers();
283        let status = login_request.status();
284
285        if status != 302 || headers.get("set-cookie").is_none() {
286            return Err(ClientCreationError::AuthenticationError);
287        }
288
289        let session_cookie = cookies
290            .find(|cookie| cookie.name() == "student_session_credentials")
291            .ok_or(())
292            .map_err(ClientCreationError::MissingSesssionCookie)?;
293
294        // i don't think we actually need this
295        let session_cookie = urlencoding::decode(session_cookie.value())?;
296
297        let session_id = serde_json::from_str::<SessionCookie>(&session_cookie)?.session_id;
298
299        let auth_cookies = headers
300            .get("set-cookie")
301            .unwrap()
302            .to_str()?
303            .split(",")
304            .collect::<Vec<&str>>()
305            .join(";");
306
307        let mut client = Client {
308            session_id,
309            student_id: "".to_string(),
310            reqwest_client,
311            base_url,
312            auth_cookies,
313            last_session_id_updated: Utc::now(),
314        };
315
316        let cc_response = client
317            .get_student_info()
318            .await
319            .map_err(ClientCreationError::ApiRequestError)?;
320
321        client.student_id = cc_response.data.user.id.to_string();
322
323        return Ok(client);
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use httpmock::prelude::*;
331    use serde_json::json;
332
333    #[tokio::test]
334    async fn cc_parser_test() {
335        // Start a lightweight mock server.
336        let server = MockServer::start();
337
338        // Create a mock on the server.
339        let error_mock = server.mock(|when, then| {
340            when.method(GET).path("/error");
341            then.status(200)
342                .header("content-type", "application/json")
343                .json_body(json!({
344                    "success": 0,
345                    "error": "test error"
346                }));
347        });
348
349        let success_mock = server.mock(|when, then| {
350            when.method(GET).path("/success");
351            then.status(200)
352                .header("content-type", "application/json")
353                .json_body(json!({
354                    "success": 1,
355                    "data": "success",
356                    "meta": [],
357                }));
358        });
359
360        let client = reqwest::Client::new();
361
362        let error_request = client
363            .get(format!("{}/error", server.base_url()))
364            .send()
365            .await
366            .unwrap();
367        let success_request = client
368            .get(format!("{}/success", server.base_url()))
369            .send()
370            .await
371            .unwrap();
372
373        error_request.cc_parse().await.unwrap_err();
374        success_request.cc_parse().await.unwrap();
375
376        success_mock.assert();
377        error_mock.assert();
378    }
379
380    #[tokio::test]
381    async fn create_client_test() {
382        // Start a lightweight mock server.
383        let server = MockServer::start();
384
385        // Create a mock on the server.
386        let student_login_response = server.mock(|when, then| {
387            when.method(POST).path("/student/login");
388            then.status(302)
389                .header("content-type", "application/json")
390                .header(
391                    "set-cookie",
392                    "student_session_credentials={\"session_id\":\"jf99rm23pdi29dj32fh23i\"}",
393                );
394        });
395
396        let student_info_response = server.mock(|when, then| {
397            when.method(POST).path("/apiv2student/ping");
398            then.status(200)
399                .header("content-type", "application/json")
400                .json_body(json!({
401                    "success": 1,
402                    "data": {
403                        "user": {
404                            "id": 3949234,
405                            "name": "Name",
406                            "first_name": "first_name",
407                            "last_name": "last_name",
408                            "avatar_url": "https://example.com",
409                            "display_behaviour": false,
410                            "display_parent_behaviour": false,
411                            "display_homework": false,
412                            "display_rewards": false,
413                            "display_detentions": false,
414                            "display_report_cards": false,
415                            "display_classes": false,
416                            "display_announcements": true,
417                            "display_academic_reports": false,
418                            "display_attendance": true,
419                            "display_attendance_type": "instance",
420                            "display_attendance_percentage": false,
421                            "display_activity": false,
422                            "display_mental_health": false,
423                            "display_mental_health_no_tracker": false,
424                            "display_timetable": false,
425                            "is_disabled": false,
426                            "display_two_way_communications": true,
427                            "display_absences": false,
428                            "can_upload_attachments": false,
429                            "display_event_badges": false,
430                            "display_avatars": false,
431                            "display_concern_submission": false,
432                            "display_custom_fields": false,
433                            "pupil_concerns_help_text": "",
434                            "allow_pupils_add_timetable_notes": false,
435                            "detention_alias_plural_uc": "Detentions",
436                            "announcements_count": 0,
437                            "messages_count": 0,
438                            "pusher_channel_name": "pusher_channel_name",
439                            "has_birthday": false,
440                            "has_new_survey": false,
441                            "survey_id": null
442                        }
443                    },
444                    "meta": {
445                        "session_id": "jf99rm23pdi29dj32fh23i",
446                        "version": "27.16.2",
447                    },
448                }));
449        });
450
451        let client = Client::create("my_code", "my_dob", Some(server.base_url()))
452            .await
453            .unwrap();
454
455        assert_eq!(client.student_id, "3949234");
456        assert_eq!(client.session_id, "jf99rm23pdi29dj32fh23i");
457
458        student_login_response.assert();
459        student_info_response.assert();
460    }
461}