- added Keycloak login service
- removed obsolete testing endpoints - extended jwt parameters - moved some values to config - added Cargo.tom information - correcred code (cargo fmt & cargo clippy)
This commit is contained in:
parent
8a9ba19226
commit
1eb39cc472
@ -2,6 +2,8 @@
|
||||
name = "api"
|
||||
version = "0.1.0"
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = ["Denys Konovalov <denys.konovalov@protonmail.com>"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
@ -14,6 +16,7 @@ quickxml_to_serde = "0.4"
|
||||
serde_json = "1.0"
|
||||
jsonwebtoken = "7.2"
|
||||
time = "0.2"
|
||||
keycloak = "14"
|
||||
|
||||
|
||||
[dependencies.serde_derive]
|
||||
|
428
src/main.rs
428
src/main.rs
@ -1,59 +1,62 @@
|
||||
#[macro_use] extern crate rocket;
|
||||
#[macro_use]
|
||||
extern crate rocket;
|
||||
|
||||
#[macro_use] extern crate diesel;
|
||||
#[macro_use]
|
||||
extern crate diesel;
|
||||
|
||||
mod schema;
|
||||
|
||||
extern crate reqwest;
|
||||
extern crate serde;
|
||||
|
||||
|
||||
use rocket::serde::json::{Json, json};
|
||||
use crate::schema::timetable;
|
||||
use diesel::{Queryable, Insertable};
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use rocket_sync_db_pools::{database, diesel::PgConnection};
|
||||
use reqwest;
|
||||
use diesel::{Insertable, Queryable};
|
||||
use quickxml_to_serde::{xml_string_to_json, Config};
|
||||
use rocket::serde::json::{json, Json};
|
||||
use rocket_sync_db_pools::{database, diesel::PgConnection};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
extern crate serde_json;
|
||||
mod config;
|
||||
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};
|
||||
use std::time::{SystemTime, Duration, UNIX_EPOCH};
|
||||
use time::OffsetDateTime;
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use keycloak::KeycloakError;
|
||||
use rocket::fs::{relative, FileServer};
|
||||
use rocket::http::Status;
|
||||
use rocket::request::{self, Outcome, Request, FromRequest};
|
||||
use rocket::fs::{FileServer, relative};
|
||||
|
||||
use rocket::request::{FromRequest, Outcome, Request};
|
||||
use std::error::Error;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[database("timetable")]
|
||||
struct DbConn(PgConnection);
|
||||
|
||||
#[derive(Queryable, Serialize, Insertable, Deserialize)]
|
||||
#[table_name="timetable"]
|
||||
#[table_name = "timetable"]
|
||||
struct Timetable {
|
||||
date: String,
|
||||
updated: String,
|
||||
class: String,
|
||||
timetable_data: serde_json::Value
|
||||
timetable_data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct TimetableData {
|
||||
count: usize,
|
||||
courses: Vec<rocket::serde::json::Value>
|
||||
courses: Vec<rocket::serde::json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Credentials {
|
||||
user: String,
|
||||
password: String,
|
||||
devid: String
|
||||
otp: String,
|
||||
devid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
enum Roles {
|
||||
Student,
|
||||
Teacher,
|
||||
Admin
|
||||
Admin,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@ -61,16 +64,30 @@ struct Claims {
|
||||
iss: String,
|
||||
user: String,
|
||||
roles: Vec<Roles>,
|
||||
// permissions: Vec<String>,
|
||||
groups: Vec<String>,
|
||||
blacklist: Vec<String>,
|
||||
whitelist: Vec<String>,
|
||||
jid: String,
|
||||
exp: u64
|
||||
exp: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
enum TokenStatus {
|
||||
Success,
|
||||
KeycloakError,
|
||||
HttpError,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TokenOutcome {
|
||||
status: TokenStatus,
|
||||
info: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Token {
|
||||
token: String
|
||||
outcome: TokenOutcome,
|
||||
token: String,
|
||||
}
|
||||
|
||||
struct ApiKey<'r>(&'r str);
|
||||
@ -81,6 +98,34 @@ enum ApiKeyError {
|
||||
Invalid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct KeycloakAdminToken {
|
||||
access_token: String,
|
||||
expires_in: usize,
|
||||
#[serde(rename = "not-before-policy")]
|
||||
not_before_policy: Option<usize>,
|
||||
refresh_expires_in: Option<usize>,
|
||||
refresh_token: Option<String>,
|
||||
scope: String,
|
||||
session_state: Option<String>,
|
||||
token_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct KeycloakUser {
|
||||
sub: String,
|
||||
email_verified: bool,
|
||||
roles: Vec<Roles>,
|
||||
name: String,
|
||||
blacklist: Option<Vec<String>>,
|
||||
groups: Vec<String>,
|
||||
whitelist: Option<Vec<String>>,
|
||||
preferred_username: String,
|
||||
given_name: String,
|
||||
family_name: String,
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for ApiKey<'r> {
|
||||
type Error = ApiKeyError;
|
||||
@ -88,54 +133,185 @@ impl<'r> FromRequest<'r> for ApiKey<'r> {
|
||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
/// Returns true if `key` is a valid API key string.
|
||||
fn is_valid(key: &str) -> bool {
|
||||
let token = decode::<Claims>(&key, &DecodingKey::from_secret(config::JWT_SECRET.as_ref()), &Validation::default());
|
||||
let validation = Validation { validate_exp: false, ..Default::default() };
|
||||
let token = decode::<Claims>(
|
||||
key,
|
||||
&DecodingKey::from_secret(config::JWT_SECRET.as_ref()),
|
||||
&validation,
|
||||
);
|
||||
token.is_ok()
|
||||
}
|
||||
|
||||
fn has_permissions(key: &str, uri: &str) -> bool {
|
||||
let student_permissions = vec![String::from("/api/classes"), String::from("/api/timetable")];
|
||||
let teacher_permissions = vec![String::from("/api/classes"), String::from("/api/timetable"), String::from("/t_timetable")];
|
||||
let token = decode::<Claims>(&key, &DecodingKey::from_secret(config::JWT_SECRET.as_ref()), &Validation::default());
|
||||
println!("{:?}", token);
|
||||
let mut token = token.unwrap();
|
||||
let validation = Validation { validate_exp: false, ..Default::default() };
|
||||
let standard_permissions =
|
||||
vec![String::from("/api/timetable"), String::from("/api/classes")];
|
||||
let student_permissions: Vec<String> = vec![];
|
||||
let teacher_permissions: Vec<String> = vec![];
|
||||
let token = decode::<Claims>(
|
||||
key,
|
||||
&DecodingKey::from_secret(config::JWT_SECRET.as_ref()),
|
||||
&validation,
|
||||
);
|
||||
let token = token.unwrap();
|
||||
let mut permissions = Vec::new();
|
||||
permissions.extend(standard_permissions.iter().cloned());
|
||||
for role in token.claims.roles.iter() {
|
||||
match role {
|
||||
Roles::Admin => permissions.push(String::from("all")),
|
||||
Roles::Student => permissions.extend(student_permissions.iter().cloned()),
|
||||
Roles::Teacher => permissions.extend(teacher_permissions.iter().cloned()),
|
||||
_ => permissions.push(String::from("none"))
|
||||
}
|
||||
}
|
||||
permissions.contains(&String::from(uri)) | permissions.contains(&String::from("all")) | &token.claims.whitelist.contains(&String::from(uri)) && !token.claims.blacklist.contains(&String::from(uri))
|
||||
permissions.contains(&String::from(uri))
|
||||
| permissions.contains(&String::from("all"))
|
||||
| token.claims.whitelist.contains(&String::from(uri))
|
||||
&& !token.claims.blacklist.contains(&String::from(uri))
|
||||
}
|
||||
|
||||
match req.headers().get_one("x-api-key") {
|
||||
None => Outcome::Failure((Status::Unauthorized, ApiKeyError::Missing)),
|
||||
Some(key) if is_valid(key) && has_permissions(key, req.uri().path().as_str()) => Outcome::Success(ApiKey(key)),
|
||||
Some(key)
|
||||
if is_valid(key)
|
||||
&& has_permissions(key, req.route().unwrap().uri.base.path().as_str()) =>
|
||||
{
|
||||
Outcome::Success(ApiKey(key))
|
||||
}
|
||||
Some(key)
|
||||
if is_valid(key)
|
||||
&& !has_permissions(key, req.route().unwrap().uri.base.path().as_str()) =>
|
||||
{
|
||||
Outcome::Failure((Status::Unauthorized, ApiKeyError::Invalid))
|
||||
}
|
||||
Some(_) => Outcome::Failure((Status::BadRequest, ApiKeyError::Invalid)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/", data="<credentials>")]
|
||||
fn login(credentials: Json<Credentials>) -> Json<Token> {
|
||||
async fn error_check(response: reqwest::Response) -> Result<reqwest::Response, KeycloakError> {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().into();
|
||||
let text = response.text().await?;
|
||||
return Err(KeycloakError::HttpFailure {
|
||||
status,
|
||||
body: serde_json::from_str(&text).ok(),
|
||||
text,
|
||||
});
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn get_keycloak_token(
|
||||
user: String,
|
||||
password: String,
|
||||
otp: String,
|
||||
) -> Result<KeycloakAdminToken, KeycloakError> {
|
||||
let client = reqwest::Client::new();
|
||||
let params = [
|
||||
("username", user),
|
||||
("password", password),
|
||||
("totp", otp),
|
||||
("client_id", config::KC_CLIENT_ID.to_string()),
|
||||
("grant_type", String::from("password")),
|
||||
];
|
||||
let resp = client
|
||||
.post(config::KC_OPENID_TOKEN_ENDPOINT)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(error_check(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
async fn get_keycloak_userinfo(token: String) -> Result<KeycloakUser, Box<dyn Error>> {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(config::KC_OPENID_USERINFO_ENDPOINT)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await?
|
||||
.json::<KeycloakUser>()
|
||||
.await?;
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
#[post("/", data = "<credentials>")]
|
||||
async fn login(credentials: Json<Credentials>) -> Json<Token> {
|
||||
let credentials = credentials.into_inner();
|
||||
let keycloak_resp = get_keycloak_token(
|
||||
credentials.user.clone(),
|
||||
credentials.password.clone(),
|
||||
credentials.otp.clone(),
|
||||
)
|
||||
.await;
|
||||
let mut token = match keycloak_resp {
|
||||
Ok(token) => Token {
|
||||
outcome: TokenOutcome {
|
||||
status: TokenStatus::Success,
|
||||
info: String::new(),
|
||||
},
|
||||
token: token.access_token,
|
||||
},
|
||||
Err(e) => {
|
||||
let outcome = match e {
|
||||
KeycloakError::ReqwestFailure(f) => TokenOutcome {
|
||||
status: TokenStatus::HttpError,
|
||||
info: f.to_string(),
|
||||
},
|
||||
KeycloakError::HttpFailure {
|
||||
status: _s,
|
||||
body: _b,
|
||||
text: t,
|
||||
} => TokenOutcome {
|
||||
status: TokenStatus::KeycloakError,
|
||||
info: String::from(
|
||||
serde_json::from_str(&t[..])
|
||||
.unwrap_or_else(|_| json![{"error_description": "No error description"}])
|
||||
["error_description"]
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
),
|
||||
},
|
||||
};
|
||||
Token {
|
||||
outcome,
|
||||
token: String::new(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let TokenStatus::Success = token.outcome.status {
|
||||
let userinfo = get_keycloak_userinfo(token.token.clone()).await.unwrap();
|
||||
let system_time = OffsetDateTime::now_utc();
|
||||
let datetime = system_time.format("%d/%m/%Y %T");
|
||||
let my_claims = Claims {
|
||||
iss: String::from("Georg-Cantor-Gymnasium Halle(Saale)"),
|
||||
user: credentials.user,
|
||||
roles: vec![Roles::Student, Roles::Admin],
|
||||
// permissions: vec![""]
|
||||
blacklist: vec![String::from("/api/classes")],
|
||||
whitelist: vec![String::from("/hello/sensitive")],
|
||||
jid: String::from(credentials.devid + "@" + &datetime),
|
||||
exp: SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs() + Duration::from_secs(31536000).as_secs()
|
||||
iss: String::from(config::JWT_ISSUER),
|
||||
user: userinfo.preferred_username,
|
||||
roles: userinfo.roles,
|
||||
groups: userinfo.groups,
|
||||
blacklist: userinfo.blacklist.unwrap_or_default(),
|
||||
whitelist: userinfo.whitelist.unwrap_or_default(),
|
||||
jid: (credentials.devid + "@" + &datetime),
|
||||
exp: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs()
|
||||
+ Duration::from_secs(31536000).as_secs(),
|
||||
};
|
||||
println!("{:?}", SystemTime::now());
|
||||
let token = encode(&Header::default(), &my_claims, &EncodingKey::from_secret(config::JWT_SECRET.as_ref()));
|
||||
Json(Token { token: token.unwrap() })
|
||||
let jwt = encode(
|
||||
&Header::default(),
|
||||
&my_claims,
|
||||
&EncodingKey::from_secret(config::JWT_SECRET.as_ref()),
|
||||
);
|
||||
token = Token {
|
||||
outcome: TokenOutcome {
|
||||
status: TokenStatus::Success,
|
||||
info: String::new(),
|
||||
},
|
||||
token: jwt.unwrap(),
|
||||
};
|
||||
}
|
||||
Json(token)
|
||||
}
|
||||
|
||||
async fn get_timetable_xml() -> serde_json::value::Value {
|
||||
@ -143,21 +319,29 @@ async fn get_timetable_xml() -> serde_json::value::Value {
|
||||
let resp = client
|
||||
.get(config::TIMETABLE_URL)
|
||||
.basic_auth(config::TIMETABLE_USER, config::TIMETABLE_PASSWORD)
|
||||
.send().await.unwrap()
|
||||
.text().await.unwrap();
|
||||
let xml = xml_string_to_json(resp, &Config::new_with_defaults()).unwrap();
|
||||
xml
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
xml_string_to_json(resp, &Config::new_with_defaults()).unwrap()
|
||||
}
|
||||
|
||||
async fn get_timetable_xml_data() -> Vec<serde_json::value::Value> {
|
||||
let xml = get_timetable_xml().await;
|
||||
let classes = xml
|
||||
.as_object().unwrap()
|
||||
.get("VpMobil").unwrap()
|
||||
.get("Klassen").unwrap()
|
||||
.get("Kl").unwrap()
|
||||
.as_array().unwrap();
|
||||
classes.to_owned().to_owned()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("VpMobil")
|
||||
.unwrap()
|
||||
.get("Klassen")
|
||||
.unwrap()
|
||||
.get("Kl")
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap();
|
||||
classes.to_owned()
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
@ -169,11 +353,16 @@ async fn get_timetable(_conn: DbConn, _key: ApiKey<'_>) -> Json<Vec<Timetable>>
|
||||
let mut courses: Vec<rocket::serde::json::Value> = Vec::new();
|
||||
let nothing = json!([""]);
|
||||
let plan = i
|
||||
.as_object().unwrap()
|
||||
.get("Pl").unwrap()
|
||||
.as_object().unwrap()
|
||||
.get("Std").unwrap_or(¬hing)
|
||||
.as_array().unwrap();
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("Pl")
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("Std")
|
||||
.unwrap_or(¬hing)
|
||||
.as_array()
|
||||
.unwrap();
|
||||
for i in plan {
|
||||
if i.as_object() != None {
|
||||
courses.push(i.to_owned());
|
||||
@ -183,26 +372,42 @@ async fn get_timetable(_conn: DbConn, _key: ApiKey<'_>) -> Json<Vec<Timetable>>
|
||||
}
|
||||
let response = TimetableData {
|
||||
count: plan.len(),
|
||||
courses: courses
|
||||
courses,
|
||||
};
|
||||
let timetable_element = Timetable {
|
||||
date: String::from(xml
|
||||
.as_object().unwrap()
|
||||
.get("VpMobil").unwrap()
|
||||
.get("Kopf").unwrap()
|
||||
.get("DatumPlan").unwrap()
|
||||
.as_str().unwrap()),
|
||||
updated: String::from(xml
|
||||
.as_object().unwrap()
|
||||
.get("VpMobil").unwrap()
|
||||
.get("Kopf").unwrap()
|
||||
.get("zeitstempel").unwrap()
|
||||
.as_str().unwrap()),
|
||||
class: String::from(i
|
||||
.as_object().unwrap()
|
||||
.get("Kurz").unwrap()
|
||||
.as_str().unwrap()),
|
||||
timetable_data: serde_json::from_str(&json!(response).to_string()).unwrap()
|
||||
date: String::from(
|
||||
xml.as_object()
|
||||
.unwrap()
|
||||
.get("VpMobil")
|
||||
.unwrap()
|
||||
.get("Kopf")
|
||||
.unwrap()
|
||||
.get("DatumPlan")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
),
|
||||
updated: String::from(
|
||||
xml.as_object()
|
||||
.unwrap()
|
||||
.get("VpMobil")
|
||||
.unwrap()
|
||||
.get("Kopf")
|
||||
.unwrap()
|
||||
.get("zeitstempel")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
),
|
||||
class: String::from(
|
||||
i.as_object()
|
||||
.unwrap()
|
||||
.get("Kurz")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
),
|
||||
timetable_data: serde_json::from_str(&json!(response).to_string()).unwrap(),
|
||||
};
|
||||
timetable.push(timetable_element)
|
||||
}
|
||||
@ -210,26 +415,39 @@ async fn get_timetable(_conn: DbConn, _key: ApiKey<'_>) -> Json<Vec<Timetable>>
|
||||
}
|
||||
|
||||
#[get("/<class>")]
|
||||
async fn get_class_timetable(_conn: DbConn, class: String, _key: ApiKey<'_>) -> Json<TimetableData> {
|
||||
async fn get_class_timetable(
|
||||
_conn: DbConn,
|
||||
class: String,
|
||||
_key: ApiKey<'_>,
|
||||
) -> Json<TimetableData> {
|
||||
let classes = get_timetable_xml_data().await;
|
||||
let courses: Vec<rocket::serde::json::Value> = Vec::new();
|
||||
let mut response = TimetableData {
|
||||
count: 0,
|
||||
courses: courses
|
||||
courses,
|
||||
};
|
||||
for i in classes.iter() {
|
||||
if i
|
||||
.as_object().unwrap()
|
||||
.get("Kurz").unwrap()
|
||||
.as_str().unwrap()
|
||||
.replace("/", "_") == class {
|
||||
if i.as_object()
|
||||
.unwrap()
|
||||
.get("Kurz")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.replace("/", "_")
|
||||
== class
|
||||
{
|
||||
let nothing = json!([""]);
|
||||
let plan = i
|
||||
.as_object().unwrap()
|
||||
.get("Pl").unwrap()
|
||||
.as_object().unwrap()
|
||||
.get("Std").unwrap_or(¬hing)
|
||||
.as_array().unwrap();
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("Pl")
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("Std")
|
||||
.unwrap_or(¬hing)
|
||||
.as_array()
|
||||
.unwrap();
|
||||
response.count = plan.len();
|
||||
for i in plan {
|
||||
if i.as_object() != None {
|
||||
@ -249,36 +467,28 @@ async fn get_classes(_key: ApiKey<'_>) -> Json<Vec<String>> {
|
||||
let classes = get_timetable_xml_data().await;
|
||||
let mut class_list: Vec<String> = Vec::new();
|
||||
for i in classes.iter() {
|
||||
class_list.push(i.as_object().unwrap()
|
||||
.get("Kurz").unwrap()
|
||||
.as_str().unwrap()
|
||||
.replace("/", "_"))
|
||||
class_list.push(
|
||||
i.as_object()
|
||||
.unwrap()
|
||||
.get("Kurz")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.replace("/", "_"),
|
||||
)
|
||||
}
|
||||
Json::from(class_list)
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
fn hello() -> &'static str {
|
||||
"Hello, World!"
|
||||
}
|
||||
|
||||
#[get("/<name>")]
|
||||
fn hello_name(name: String) -> String {
|
||||
format!("Hello, {}!", name)
|
||||
}
|
||||
|
||||
#[get("/sensitive")]
|
||||
fn sensitive(key: ApiKey<'_>) -> &'static str {
|
||||
"Sensitive data."
|
||||
}
|
||||
|
||||
#[launch]
|
||||
fn rocket() -> _ {
|
||||
rocket::build()
|
||||
.attach(DbConn::fairing())
|
||||
.mount("/", FileServer::from(relative!("static")))
|
||||
.mount("/login", routes![login])
|
||||
.mount("/hello", routes![hello, hello_name, sensitive])
|
||||
.mount("/api/timetable", routes![get_timetable, get_class_timetable])
|
||||
.mount(
|
||||
"/api/timetable",
|
||||
routes![get_timetable, get_class_timetable],
|
||||
)
|
||||
.mount("/api/classes", routes![get_classes])
|
||||
}
|
||||
|
Reference in New Issue
Block a user