This commit is contained in:
Dominic Grimm 2022-12-14 15:29:10 +01:00
commit a6bc2bd2e9
No known key found for this signature in database
GPG key ID: 6F294212DEAAC530
4 changed files with 768 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
/Cargo.lock

21
Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "untis"
version = "0.1.0"
description = "Rust client for WebUntis JSON-RPC and REST API"
authors = ["Dominic Grimm <dominic@dergrimm.net>"]
edition = "2021"
homepage = "https://git.dergrimm.net/dergrimm/untis.rs"
repository = "https://git.dergrimm.net/dergrimm/untis.rs.git"
license = "MIT"
publish = false
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = { version = "1.0.66", features = ["backtrace"] }
chrono = { version = "0.4.23", features = ["serde"] }
cookie = "0.16.1"
reqwest = { version = "0.11.13", features = ["json"] }
serde = { version = "1.0.150", features = ["derive"] }
serde_json = "1.0.89"
url = "2.3.1"

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Dominic Grimm
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

724
src/lib.rs Normal file
View file

@ -0,0 +1,724 @@
use anyhow::{bail, Context, Result};
use chrono::Datelike;
use cookie::Cookie;
use serde::Deserialize;
use serde_json::json;
fn deserialize_date<'de, D>(deserializer: D) -> Result<chrono::NaiveDate, D::Error>
where
D: serde::de::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = chrono::NaiveDate;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a naive date serialized as an unsigned integer")
}
fn visit_u64<E>(self, mut v: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let y = (v / 10000) as i32;
v %= 10000;
let m = (v / 100) as u32;
v %= 100;
match chrono::NaiveDate::from_ymd_opt(y, m, v as u32) {
Some(x) => Ok(x),
None => Err(E::custom(format!("No such date: {}-{}-{}", y, m, v))),
}
}
}
deserializer.deserialize_u64(Visitor)
}
fn serialize_date(date: chrono::NaiveDate) -> u64 {
date.year() as u64 * 10000 + date.month() as u64 * 100 + date.day() as u64
}
fn deserialize_time<'de, D>(deserializer: D) -> Result<chrono::NaiveTime, D::Error>
where
D: serde::de::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = chrono::NaiveTime;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a naive time serialized as an unsigned integer")
}
fn visit_u64<E>(self, mut v: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let h = v / 100;
v %= 100;
match chrono::NaiveTime::from_hms_opt(h as u32, v as u32, 0) {
Some(x) => Ok(x),
None => Err(E::custom(format!("No such time: {}:{}", h, v))),
}
}
}
deserializer.deserialize_u64(Visitor)
}
#[derive(Deserialize, Debug)]
pub struct RpcError {
pub message: String,
pub code: i32,
}
#[derive(Deserialize, Debug)]
pub struct RpcResponse<T> {
pub jsonrpc: String,
pub id: String,
pub result: Option<T>,
pub error: Option<RpcError>,
}
#[derive(Deserialize, Debug)]
struct RpcEmptyResult;
#[derive(Deserialize, Debug)]
pub struct RpcLogin {
#[serde(rename = "sessionId")]
pub session_id: String,
#[serde(rename = "personType")]
pub person_type: i32,
#[serde(rename = "personId")]
pub person_id: i32,
#[serde(rename = "klasseId")]
pub class_id: i32,
}
#[derive(Deserialize, Debug)]
pub struct RpcClass {
pub id: i32,
pub name: String,
#[serde(rename = "longName")]
pub long_name: String,
pub active: bool,
}
#[derive(Deserialize, Debug)]
pub struct RpcSubject {
pub id: i32,
pub name: String,
#[serde(rename = "longName")]
pub long_name: String,
}
#[derive(Deserialize, Debug)]
pub struct RpcRoom {
pub id: i32,
pub name: String,
#[serde(rename = "longName")]
pub long_name: String,
}
#[derive(Deserialize, Debug)]
pub struct RpcDepartment {
pub id: i32,
pub name: String,
#[serde(rename = "longName")]
pub long_name: String,
}
#[derive(Deserialize, Debug)]
pub struct RpcHoliday {
pub id: i32,
pub name: String,
#[serde(rename = "longName")]
pub long_name: String,
#[serde(rename = "startDate", deserialize_with = "deserialize_date")]
pub start_date: chrono::NaiveDate,
#[serde(rename = "endDate", deserialize_with = "deserialize_date")]
pub end_date: chrono::NaiveDate,
}
#[derive(Deserialize, Debug)]
pub struct RpcTimegridDayTimeUnit {
#[serde(rename = "startTime", deserialize_with = "deserialize_time")]
pub start_time: chrono::NaiveTime,
#[serde(rename = "endTime", deserialize_with = "deserialize_time")]
pub end_time: chrono::NaiveTime,
}
#[derive(Deserialize, Debug)]
pub struct RpcTimegridDay {
pub day: u8,
#[serde(rename = "timeUnits")]
pub time_units: Vec<RpcTimegridDayTimeUnit>,
}
#[derive(Deserialize, Debug)]
pub struct RpcSchoolyear {
pub id: i32,
pub name: String,
#[serde(rename = "startDate", deserialize_with = "deserialize_date")]
pub start_date: chrono::NaiveDate,
#[serde(rename = "endDate", deserialize_with = "deserialize_date")]
pub end_date: chrono::NaiveDate,
}
#[derive(Deserialize, Clone, Copy, Debug)]
pub enum RpcSubstitionType {
#[serde(rename = "cancel")]
Cancel,
#[serde(rename = "subst")]
Substitution,
#[serde(rename = "add")]
Additional,
#[serde(rename = "shift")]
Shifted,
#[serde(rename = "rmchg")]
RoomChange,
#[serde(rename = "rmlk")]
Locked,
#[serde(rename = "bs")]
BreakSupervision,
#[serde(rename = "oh")]
OfficeHour,
#[serde(rename = "sb")]
Standby,
#[serde(rename = "other")]
Other,
#[serde(rename = "free")]
Free,
#[serde(rename = "exam")]
Exam,
#[serde(rename = "ac")]
Activity,
#[serde(rename = "holi")]
Holiday,
#[serde(rename = "stxt")]
Text,
}
#[derive(Deserialize, Debug)]
pub struct RpcSubstitutionId {
pub id: i32,
pub name: Option<String>,
#[serde(rename = "externalkey")]
pub external_key: Option<String>,
#[serde(rename = "orgid")]
pub original_id: Option<i32>,
#[serde(rename = "orgname")]
pub original_name: Option<String>,
#[serde(rename = "orgexternalkey")]
pub original_external_key: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct RpcSubstitutionReschedule {
#[serde(deserialize_with = "deserialize_date")]
pub date: chrono::NaiveDate,
#[serde(rename = "startTime", deserialize_with = "deserialize_time")]
pub start_time: chrono::NaiveTime,
#[serde(rename = "endTime", deserialize_with = "deserialize_time")]
pub end_time: chrono::NaiveTime,
}
#[derive(Deserialize, Debug)]
pub struct RpcSubstitution {
#[serde(rename = "type")]
pub subst_type: RpcSubstitionType,
#[serde(rename = "lsid")]
pub lesson_id: i32,
#[serde(rename = "startTime", deserialize_with = "deserialize_time")]
pub start_time: chrono::NaiveTime,
#[serde(rename = "endTime", deserialize_with = "deserialize_time")]
pub end_time: chrono::NaiveTime,
#[serde(rename = "txt")]
pub text: Option<String>,
#[serde(rename = "kl")]
pub classes: Vec<RpcSubstitutionId>,
#[serde(rename = "te")]
pub teachers: Vec<RpcSubstitutionId>,
#[serde(rename = "su")]
pub subjects: Vec<RpcSubstitutionId>,
#[serde(rename = "ro")]
pub rooms: Vec<RpcSubstitutionId>,
pub reschedule: Option<RpcSubstitutionReschedule>,
}
#[derive(Deserialize, Debug)]
pub struct ApiTenant {
pub id: i32,
#[serde(rename = "displayName")]
pub display_name: String,
}
#[derive(Deserialize, Debug)]
struct ApiDataResponse {
tenant: ApiTenant,
}
#[derive(Deserialize, Debug)]
pub struct ApiTeacher {
pub id: i32,
pub name: String,
pub forename: String,
#[serde(rename = "longName")]
pub long_name: String,
#[serde(rename = "displayname")]
pub display_name: String,
}
#[derive(Deserialize, Debug)]
struct ApiTeachersResponseData {
elements: Vec<ApiTeacher>,
}
#[derive(Deserialize, Debug)]
struct ApiTeachersResponse {
data: ApiTeachersResponseData,
}
#[derive(Debug)]
pub struct Client {
pub api_url: url::Url,
pub rpc_url: url::Url,
pub client_name: String,
pub username: String,
pub password: String,
pub session: Option<String>,
pub authorization: Option<String>,
}
impl Client {
pub fn gen_rpc_url(mut endpoint: url::Url, school: &str) -> url::Url {
endpoint.query_pairs_mut().append_pair("school", school);
endpoint
}
pub async fn login_rpc(&mut self) -> Result<RpcLogin> {
let resp: RpcResponse<RpcLogin> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json")
.json(&json!({
"id": "ID",
"method": "authenticate",
"params": {
"user": self.username,
"password": self.password,
"client": self.client_name,
},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
self.session = Some(
Cookie::new(
"JSESSIONID",
&resp
.result
.as_ref()
.context("Result null event though error not")?
.session_id,
)
.to_string(),
);
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn login_api(&mut self) -> Result<String> {
let jwt = reqwest::Client::new()
.get(self.api_url.join("token/new")?)
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.send()
.await?
.text()
.await?;
self.authorization = Some(jwt.to_string());
Ok(jwt)
}
pub async fn login(&mut self) -> Result<()> {
self.login_rpc().await?;
self.login_api().await?;
Ok(())
}
pub async fn logout(&mut self) -> Result<()> {
let resp: RpcResponse<RpcEmptyResult> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.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": "logout",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
self.session = None;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
Ok(())
}
pub async fn classes(&self) -> Result<Vec<RpcClass>> {
let resp: RpcResponse<Vec<RpcClass>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.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": "getKlassen",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn subjects(&self) -> Result<Vec<RpcSubject>> {
let resp: RpcResponse<Vec<RpcSubject>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.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": "getSubjects",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn rooms(&self) -> Result<Vec<RpcRoom>> {
let resp: RpcResponse<Vec<RpcRoom>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.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": "getRooms",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn departments(&self) -> Result<Vec<RpcDepartment>> {
let resp: RpcResponse<Vec<RpcDepartment>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.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": "getDepartments",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn holidays(&self) -> Result<Vec<RpcHoliday>> {
let resp: RpcResponse<Vec<RpcHoliday>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.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": "getHolidays",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn timegrid(&self) -> Result<Vec<RpcTimegridDay>> {
let resp: RpcResponse<Vec<RpcTimegridDay>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.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": "getTimegridUnits",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn current_schoolyear(&self) -> Result<RpcSchoolyear> {
let resp: RpcResponse<RpcSchoolyear> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.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": "getCurrentSchoolyear",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn schoolyears(&self) -> Result<Vec<RpcSchoolyear>> {
let resp: RpcResponse<Vec<RpcSchoolyear>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.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": "getSchoolyears",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn substitutions(
&self,
from: &chrono::NaiveDate,
to: &chrono::NaiveDate,
department: Option<i32>,
) -> Result<Vec<RpcSubstitution>> {
let resp: RpcResponse<Vec<RpcSubstitution>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.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": "getSubstitutions",
"params": {
"startDate": serialize_date(*from),
"endDate": serialize_date(*to),
"departmentId": department.map_or(0, |d| d)
},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub fn construct_bearer(auth: &str) -> String {
format!("Bearer {}", auth)
}
pub async fn current_tenant(&self) -> Result<ApiTenant> {
let resp: ApiDataResponse = reqwest::Client::new()
.get(self.api_url.join("rest/view/v1/app/data")?)
.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?;
Ok(resp.tenant)
}
pub async fn teachers(&self) -> Result<Vec<ApiTeacher>> {
let mut url = self.api_url.join("public/timetable/weekly/pageconfig")?;
url.query_pairs_mut().append_pair("type", "2");
let resp: ApiTeachersResponse = reqwest::Client::new()
.get(url)
.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?;
Ok(resp.data.elements)
}
pub async fn exams(&self) -> Result<()> {
Ok(())
}
}