1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
//! libbklrs is a library that strives to provide a Rust implementation of the Bakaláři school
//! system API. While technically possible to use the API only using an HTTP client and no library,
//! libbklrs tries to make this usage much easier and more pleasant.

pub mod timetable;
#[cfg(test)]
mod test;
use timetable::Timetable;

use chrono::{Local, DateTime, Datelike};
use reqwest::{
    header::{HeaderMap, HeaderValue},
    Client, ClientBuilder,
};
use serde::Deserialize;
use thiserror::Error;

/// Provides an interface to the school API.
///
/// # Examples
/// ```
/// let api = BklrClient::new().unwrap();
/// let timetable = api.get_timetable().await.unwrap();
/// ```
#[allow(dead_code)]
pub struct BklrClient {
    access_token: String,
    refresh_token: String,
    expiry_date: i32,
    url: String,
    http_client: Client,
}

impl BklrClient {
    /// Constructs a new API client.
    ///
    /// # Examples
    /// ```
    /// let api = BklrClient::new().unwrap_or_else(|err| {
    ///     panic!("Error while constructing API client: {err}");
    /// });
    /// ```
    ///
    /// # Errors
    /// This method fails when an invalid URL is provided, incorrect
    /// info is provided, or when the API is unavailable, most likely due to a connection issue.
    pub async fn new(
        username: &str,
        password: &str,
        url: &str,
    ) -> Result<BklrClient, crate::APIError> {
        let client = Client::new();
        let formatted_url = format!("{url}/api/login");

        let response = client
            .post(formatted_url)
            .header(
                "Content-Type",
                "application/x-www-form-urlencoded"
                    .to_owned()
                    .parse::<HeaderValue>()
                    .unwrap(),
            )
            .body(format!("client_id=ANDR&grant_type=password&username={}&password={}",
                          username, password))
            .send()
            .await?;

        let json = response.json::<Response>().await?;

        let mut default_headers = HeaderMap::new();
        default_headers.append(
            "Content-Type",
            "application/x-www-form-urlencoded".parse().unwrap(),
        );
        default_headers.append(
            "Authorization",
            format!("Bearer {}", json.access_token).parse().unwrap(),
        );

        let client = ClientBuilder::new()
            .default_headers(default_headers)
            .build()?;

        Ok(BklrClient {
            access_token: json.access_token,
            refresh_token: json.refresh_token,
            expiry_date: json.expires_in,
            url: url.to_owned(),
            http_client: client,
        })
    }

    /// Attempts to fetch the timetable. Fails on API error, or a connection error.
    pub async fn get_timetable(&self) -> Result<Timetable, crate::APIError> {
        let date: DateTime<Local> = chrono::Local::now();
        let formatted_url = format!("{}/api/3/timetable/actual?date={}-{}-{}",
                                    self.url,
                                    date.year(),
                                    date.month(),
                                    date.day()
                                    );
        let response = self.http_client.get(formatted_url).send().await?;
        Ok(response.json::<Timetable>().await?)
    }
}

#[derive(Deserialize, Debug)]
struct Response {
    pub access_token: String,
    pub refresh_token: String,
    pub expires_in: i32,
}

/// A custom error type for the API client.
#[derive(Error, Debug)]
pub enum APIError {
    #[error("API returned an unexpected field: {0}")]
    UnexpectedField(#[from] serde_json::Error),
    #[error("Request error: {0}")]
    RequestError(#[from] reqwest::Error),
    #[error("Unable to parse API response: {0}")]
    ParseError(String),
    #[error("Invalid credentials")]
    InvalidCredentials,
}