diff --git a/2018-09-20-WebUntis_JSON_RPC_API.pdf b/2018-09-20-WebUntis_JSON_RPC_API.pdf new file mode 100644 index 0000000..7992d6e Binary files /dev/null and b/2018-09-20-WebUntis_JSON_RPC_API.pdf differ diff --git a/Cargo.toml b/Cargo.toml index e45a3ed..3f125c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "untis" -version = "0.1.0" +version = "0.1.1" description = "Rust client for WebUntis JSON-RPC and REST API" authors = ["Dominic Grimm "] edition = "2021" @@ -15,6 +15,7 @@ publish = false anyhow = { version = "1.0.66", features = ["backtrace"] } chrono = { version = "0.4.23", features = ["serde"] } cookie = "0.16.1" +csv = "1.1.6" reqwest = { version = "0.11.13", features = ["json"] } serde = { version = "1.0.150", features = ["derive"] } serde_json = "1.0.89" diff --git a/src/lib.rs b/src/lib.rs index 6b876c8..5dcf31b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ use chrono::Datelike; use cookie::Cookie; use serde::Deserialize; use serde_json::json; +use url::Url; fn deserialize_date<'de, D>(deserializer: D) -> Result where @@ -283,10 +284,19 @@ struct ApiTeachersResponse { data: ApiTeachersResponseData, } +#[derive(Deserialize, Debug)] +pub struct ApiReportUser { + pub name: String, + #[serde(rename = "longName")] + pub long_name: String, + #[serde(rename = "foreName")] + pub fore_name: String, +} + #[derive(Debug)] pub struct Client { - pub api_url: url::Url, - pub rpc_url: url::Url, + pub webuntis_url: Url, + pub rpc_url: Url, pub client_name: String, pub user_agent: String, pub username: String, @@ -296,10 +306,11 @@ pub struct Client { } impl Client { - pub fn gen_rpc_url(mut endpoint: url::Url, school: &str) -> url::Url { - endpoint.query_pairs_mut().append_pair("school", school); + pub fn gen_rpc_url(endpoint: &Url, school: &str) -> Result { + let mut x = endpoint.join("jsonrpc.do")?; + x.query_pairs_mut().append_pair("school", school); - endpoint + Ok(x) } pub async fn login_rpc(&mut self) -> Result { @@ -347,7 +358,7 @@ impl Client { pub async fn login_api(&mut self) -> Result { let jwt = reqwest::Client::new() - .get(self.api_url.join("token/new")?) + .get(self.webuntis_url.join("api/token/new")?) .header(reqwest::header::USER_AGENT, &self.user_agent) .header( reqwest::header::COOKIE, @@ -685,13 +696,44 @@ impl Client { } } + pub async fn last_import_time(&self) -> Result { + let resp: RpcResponse = reqwest::Client::new() + .get(self.rpc_url.as_str()) + .header(reqwest::header::USER_AGENT, &self.user_agent) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header(reqwest::header::ACCEPT, "application/json") + .header( + reqwest::header::COOKIE, + self.session.as_ref().context("Not logged in")?, + ) + .json(&json!({ + "id": "ID", + "method": "getLatestImportTime", + "jsonrpc": "2.0" + })) + .send() + .await? + .json() + .await?; + if let Some(e) = resp.error { + bail!("RPC error: {:?}", e); + } + + if let Some(x) = resp.result { + Ok(chrono::NaiveDateTime::from_timestamp_millis(x) + .context("Could not convert Unix timestamp to NaiveDateTime")?) + } else { + bail!("RPC result is null"); + } + } + pub fn construct_bearer(auth: &str) -> String { format!("Bearer {}", auth) } pub async fn current_tenant(&self) -> Result { let resp: ApiDataResponse = reqwest::Client::new() - .get(self.api_url.join("rest/view/v1/app/data")?) + .get(self.webuntis_url.join("api/rest/view/v1/app/data")?) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::ACCEPT, "application/json") .header( @@ -711,7 +753,9 @@ impl Client { } pub async fn teachers(&self) -> Result> { - let mut url = self.api_url.join("public/timetable/weekly/pageconfig")?; + let mut url = self + .webuntis_url + .join("api/public/timetable/weekly/pageconfig")?; url.query_pairs_mut().append_pair("type", "2"); let resp: ApiTeachersResponse = reqwest::Client::new() .get(url) @@ -733,7 +777,150 @@ impl Client { Ok(resp.data.elements) } - pub async fn exams(&self) -> Result<()> { - Ok(()) + pub async fn student_reports(&self, class_id: i32) -> Result> { + #[derive(Deserialize, Debug)] + struct Data { + finished: bool, + error: bool, + #[serde(rename = "messageId")] + message_id: i32, + #[serde(rename = "reportParams")] + report_params: String, + } + + #[derive(Deserialize, Debug)] + struct Response { + data: Data, + } + + let mut url = self.webuntis_url.join("reports.do")?; + url.query_pairs_mut() + .append_pair("name", "Student") + .append_pair("format", "csv") + .append_pair("klasseId", &class_id.to_string()) + .append_pair("studentsForDate", "true"); + let resp: Response = reqwest::Client::new() + .get(url) + .header(reqwest::header::USER_AGENT, &self.user_agent) + .header(reqwest::header::ACCEPT, "application/json") + .header( + reqwest::header::COOKIE, + self.session.as_ref().context("Not logged in")?, + ) + .header( + reqwest::header::AUTHORIZATION, + Client::construct_bearer(&self.authorization.as_ref().context("Not logged in")?), + ) + .send() + .await? + .json() + .await?; + if resp.data.error { + bail!("Error generating report"); + } else if !resp.data.finished { + bail!("Report not finished"); + } + + let mut records_url = self.webuntis_url.join("reports.do")?; + records_url.set_query(Some(&resp.data.report_params)); + records_url + .query_pairs_mut() + .append_pair("msgId", &resp.data.message_id.to_string()); + let records = reqwest::Client::new() + .get(records_url) + .header(reqwest::header::USER_AGENT, &self.user_agent) + .header(reqwest::header::ACCEPT, "text/csv") + .header( + reqwest::header::COOKIE, + self.session.as_ref().context("Not logged in")?, + ) + .header( + reqwest::header::AUTHORIZATION, + Client::construct_bearer(&self.authorization.as_ref().context("Not logged in")?), + ) + .send() + .await? + .text() + .await?; + let mut reader = csv::ReaderBuilder::new() + .delimiter(b'\t') + .from_reader(records.as_bytes()); + + Ok(reader + .deserialize::() + .collect::, _>>()?) + } + + pub async fn teacher_reports(&self) -> Result> { + #[derive(Deserialize, Debug)] + struct Data { + finished: bool, + error: bool, + #[serde(rename = "messageId")] + message_id: i32, + #[serde(rename = "reportParams")] + report_params: String, + } + + #[derive(Deserialize, Debug)] + struct Response { + data: Data, + } + + let mut url = self.webuntis_url.join("reports.do")?; + url.query_pairs_mut() + .append_pair("name", "Teacher") + .append_pair("format", "csv") + .append_pair("elementsForDate", "true"); + let resp: Response = reqwest::Client::new() + .get(url) + .header(reqwest::header::USER_AGENT, &self.user_agent) + .header(reqwest::header::ACCEPT, "application/json") + .header( + reqwest::header::COOKIE, + self.session.as_ref().context("Not logged in")?, + ) + .header( + reqwest::header::AUTHORIZATION, + Client::construct_bearer(&self.authorization.as_ref().context("Not logged in")?), + ) + .send() + .await? + .json() + .await?; + if resp.data.error { + bail!("Error generating report"); + } else if !resp.data.finished { + bail!("Report not finished"); + } + + let mut records_url = self.webuntis_url.join("reports.do")?; + records_url.set_query(Some(&resp.data.report_params)); + records_url + .query_pairs_mut() + .append_pair("msgId", &resp.data.message_id.to_string()); + let records = reqwest::Client::new() + .get(records_url) + .header(reqwest::header::USER_AGENT, &self.user_agent) + .header(reqwest::header::ACCEPT, "text/csv") + .header( + reqwest::header::COOKIE, + self.session.as_ref().context("Not logged in")?, + ) + .header( + reqwest::header::AUTHORIZATION, + Client::construct_bearer(&self.authorization.as_ref().context("Not logged in")?), + ) + .send() + .await? + .text() + .await?; + let mut reader = csv::ReaderBuilder::new() + .delimiter(b'\t') + .from_reader(records.as_bytes()); + + Ok(reader + .deserialize::() + .collect::, _>>()?) } }