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 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 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 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 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 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 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 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 let server = MockServer::start();
337
338 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 let server = MockServer::start();
384
385 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}