diff --git a/.sqlx/query-1477fded8be083c2d5e29a92f9e558c9855ad90020dc1199bce0d20cca02799d.json b/.sqlx/query-1477fded8be083c2d5e29a92f9e558c9855ad90020dc1199bce0d20cca02799d.json new file mode 100644 index 0000000..f89f78b --- /dev/null +++ b/.sqlx/query-1477fded8be083c2d5e29a92f9e558c9855ad90020dc1199bce0d20cca02799d.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT date, price_students, price_employees, price_guests FROM meals WHERE canteen = $1 AND LOWER(\"name\") = $2 AND is_latest = TRUE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "date", + "type_info": "Date" + }, + { + "ordinal": 1, + "name": "price_students", + "type_info": "Numeric" + }, + { + "ordinal": 2, + "name": "price_employees", + "type_info": "Numeric" + }, + { + "ordinal": 3, + "name": "price_guests", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "1477fded8be083c2d5e29a92f9e558c9855ad90020dc1199bce0d20cca02799d" +} diff --git a/.sqlx/query-56b320a7188c5ee88c869e763f6d23ee37461894a3b20c850908923460c3d3a0.json b/.sqlx/query-56b320a7188c5ee88c869e763f6d23ee37461894a3b20c850908923460c3d3a0.json new file mode 100644 index 0000000..8ff8fc0 --- /dev/null +++ b/.sqlx/query-56b320a7188c5ee88c869e763f6d23ee37461894a3b20c850908923460c3d3a0.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT date, canteen, price_students, price_employees, price_guests FROM meals WHERE LOWER(\"name\") = $1 AND is_latest = TRUE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "date", + "type_info": "Date" + }, + { + "ordinal": 1, + "name": "canteen", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "price_students", + "type_info": "Numeric" + }, + { + "ordinal": 3, + "name": "price_employees", + "type_info": "Numeric" + }, + { + "ordinal": 4, + "name": "price_guests", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "56b320a7188c5ee88c869e763f6d23ee37461894a3b20c850908923460c3d3a0" +} diff --git a/.sqlx/query-a08588e594190460891c0e545b9983594e837f8da93db0d7c03e8ef16b9d0e3b.json b/.sqlx/query-a08588e594190460891c0e545b9983594e837f8da93db0d7c03e8ef16b9d0e3b.json new file mode 100644 index 0000000..a81203e --- /dev/null +++ b/.sqlx/query-a08588e594190460891c0e545b9983594e837f8da93db0d7c03e8ef16b9d0e3b.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT kjoules, proteins, carbohydrates, fats FROM meals m WHERE is_latest = TRUE AND LOWER(\"name\") = $1 ORDER BY date DESC LIMIT 1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "kjoules", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "proteins", + "type_info": "Numeric" + }, + { + "ordinal": 2, + "name": "carbohydrates", + "type_info": "Numeric" + }, + { + "ordinal": 3, + "name": "fats", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true, + true, + true + ] + }, + "hash": "a08588e594190460891c0e545b9983594e837f8da93db0d7c03e8ef16b9d0e3b" +} diff --git a/.sqlx/query-cdf76d5e7d5d61d3cc61411d526816996885ccbd07237847c57f62cc3b6357db.json b/.sqlx/query-cdf76d5e7d5d61d3cc61411d526816996885ccbd07237847c57f62cc3b6357db.json new file mode 100644 index 0000000..f94efb5 --- /dev/null +++ b/.sqlx/query-cdf76d5e7d5d61d3cc61411d526816996885ccbd07237847c57f62cc3b6357db.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT date, canteen, price_students, price_employees, price_guests FROM meals WHERE canteen = ANY($1) AND LOWER(\"name\") = $2 AND is_latest = TRUE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "date", + "type_info": "Date" + }, + { + "ordinal": 1, + "name": "canteen", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "price_students", + "type_info": "Numeric" + }, + { + "ordinal": 3, + "name": "price_employees", + "type_info": "Numeric" + }, + { + "ordinal": 4, + "name": "price_guests", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "cdf76d5e7d5d61d3cc61411d526816996885ccbd07237847c57f62cc3b6357db" +} diff --git a/.sqlx/query-d7d20b101fbed8dfe7ff33ac7a6a0e4cddfaa36050c5482818b6ee4783f8173d.json b/.sqlx/query-d7d20b101fbed8dfe7ff33ac7a6a0e4cddfaa36050c5482818b6ee4783f8173d.json new file mode 100644 index 0000000..fe70533 --- /dev/null +++ b/.sqlx/query-d7d20b101fbed8dfe7ff33ac7a6a0e4cddfaa36050c5482818b6ee4783f8173d.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT kjoules, proteins, carbohydrates, fats FROM meals m WHERE is_latest = TRUE AND LOWER(\"name\") = $1 AND date = $2 LIMIT 1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "kjoules", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "proteins", + "type_info": "Numeric" + }, + { + "ordinal": 2, + "name": "carbohydrates", + "type_info": "Numeric" + }, + { + "ordinal": 3, + "name": "fats", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text", + "Date" + ] + }, + "nullable": [ + true, + true, + true, + true + ] + }, + "hash": "d7d20b101fbed8dfe7ff33ac7a6a0e4cddfaa36050c5482818b6ee4783f8173d" +} diff --git a/Cargo.lock b/Cargo.lock index 105097e..11c8969 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1527,7 +1527,7 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "mensa-upb-api" -version = "0.3.0" +version = "0.4.0" dependencies = [ "actix-cors", "actix-governor", @@ -1550,7 +1550,7 @@ dependencies = [ [[package]] name = "mensa-upb-scraper" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "chrono", @@ -1895,7 +1895,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.1", + "socket2 0.5.10", "thiserror", "tokio", "tracing", @@ -1932,9 +1932,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/scraper/Cargo.toml b/scraper/Cargo.toml index a9ca1a9..ef087ff 100644 --- a/scraper/Cargo.toml +++ b/scraper/Cargo.toml @@ -5,7 +5,7 @@ license.workspace = true authors.workspace = true repository.workspace = true readme.workspace = true -version = "0.1.0" +version = "0.2.0" edition = "2021" publish = false diff --git a/scraper/src/dish.rs b/scraper/src/dish.rs index c665bb1..80aa1d7 100644 --- a/scraper/src/dish.rs +++ b/scraper/src/dish.rs @@ -29,7 +29,7 @@ pub struct Dish { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct NutritionValues { - pub kjoule: Option, + pub kjoule: Option, pub protein: Option, pub carbs: Option, pub fat: Option, diff --git a/scraper/src/menu.rs b/scraper/src/menu.rs index 3f40085..ae92f93 100644 --- a/scraper/src/menu.rs +++ b/scraper/src/menu.rs @@ -41,8 +41,6 @@ pub async fn scrape_menu(date: &NaiveDate, canteen: Canteen) -> Result res.extend(side_dishes); res.extend(desserts); - dbg!(&res); - tracing::debug!("Finished scraping"); Ok(res) diff --git a/shared/src/canteen.rs b/shared/src/canteen.rs index ae901f6..ae20e2a 100644 --- a/shared/src/canteen.rs +++ b/shared/src/canteen.rs @@ -6,6 +6,7 @@ use strum::EnumIter; #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumIter, Hash, Serialize, Deserialize, )] +#[serde(rename_all = "kebab-case")] pub enum Canteen { Forum, Academica, diff --git a/web-api/Cargo.toml b/web-api/Cargo.toml index 231179e..ea75aaf 100644 --- a/web-api/Cargo.toml +++ b/web-api/Cargo.toml @@ -5,7 +5,7 @@ license.workspace = true authors.workspace = true repository.workspace = true readme.workspace = true -version = "0.3.0" +version = "0.4.0" edition = "2021" publish = false diff --git a/web-api/src/dish.rs b/web-api/src/dish.rs index a05c8a3..59e41f7 100644 --- a/web-api/src/dish.rs +++ b/web-api/src/dish.rs @@ -1,6 +1,7 @@ use bigdecimal::BigDecimal; use serde::{Deserialize, Serialize}; use shared::Canteen; +use sqlx::prelude::FromRow; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Dish { @@ -19,6 +20,14 @@ pub struct DishPrices { pub guests: BigDecimal, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, FromRow)] +pub struct DishNutrients { + pub kjoules: Option, + pub carbohydrates: Option, + pub proteins: Option, + pub fats: Option, +} + impl Dish { pub fn same_as(&self, other: &Self) -> bool { self.name == other.name @@ -39,3 +48,24 @@ impl PartialOrd for Dish { self.name.partial_cmp(&other.name) } } + +impl DishPrices { + pub fn normalize(self) -> Self { + Self { + students: self.students.with_prec(5).with_scale(2), + employees: self.employees.with_prec(5).with_scale(2), + guests: self.guests.with_prec(5).with_scale(2), + } + } +} + +impl DishNutrients { + pub fn normalize(self) -> Self { + Self { + kjoules: self.kjoules, + carbohydrates: self.carbohydrates.map(|v| v.with_prec(6).with_scale(2)), + proteins: self.proteins.map(|v| v.with_prec(6).with_scale(2)), + fats: self.fats.map(|v| v.with_prec(6).with_scale(2)), + } + } +} diff --git a/web-api/src/endpoints/menu.rs b/web-api/src/endpoints/menu.rs index bade622..cd2fb0e 100644 --- a/web-api/src/endpoints/menu.rs +++ b/web-api/src/endpoints/menu.rs @@ -1,32 +1,35 @@ -use std::str::FromStr as _; - -use actix_web::{get, web, HttpResponse, Responder}; +use actix_web::{ + get, + web::{self, ServiceConfig}, + HttpResponse, Responder, +}; use chrono::NaiveDate; use itertools::Itertools as _; use serde::{Deserialize, Serialize}; use serde_json::json; -use shared::Canteen; use sqlx::PgPool; -use crate::Menu; +use crate::{util, Menu}; + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service(menu); +} #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct MenuQuery { date: Option, - no_update: Option, + #[serde(default)] + no_update: bool, } + #[get("/menu/{canteen}")] async fn menu( path: web::Path, query: web::Query, db: web::Data, ) -> impl Responder { - let canteens = path - .into_inner() - .split(',') - .map(Canteen::from_str) - .collect_vec(); + let canteens = util::parse_canteens_comma_separated(&path); if canteens.iter().all(Result::is_ok) { let canteens = canteens.into_iter().filter_map(Result::ok).collect_vec(); @@ -34,14 +37,16 @@ async fn menu( .date .unwrap_or_else(|| chrono::Local::now().date_naive()); - let menu = Menu::query(&db, date, &canteens, !query.no_update.unwrap_or_default()).await; + let menu = Menu::query(&db, date, &canteens, !query.no_update).await; - if let Ok(menu) = menu { - HttpResponse::Ok().json(menu) - } else { - HttpResponse::InternalServerError().json(json!({ - "error": "Failed to query database", - })) + match menu { + Ok(menu) => HttpResponse::Ok().json(menu), + Err(err) => { + tracing::error!("Failed to query database: {err:?}"); + HttpResponse::InternalServerError().json(json!({ + "error": "Failed to query database", + })) + } } } else { HttpResponse::BadRequest().json(json!({ diff --git a/web-api/src/endpoints/mod.rs b/web-api/src/endpoints/mod.rs index ce49768..77db428 100644 --- a/web-api/src/endpoints/mod.rs +++ b/web-api/src/endpoints/mod.rs @@ -5,10 +5,14 @@ use shared::Canteen; use strum::IntoEnumIterator as _; mod menu; +mod nutrition; +mod price_history; pub fn configure(cfg: &mut ServiceConfig) { - cfg.service(index); - cfg.service(menu::menu); + cfg.service(index) + .configure(menu::configure) + .configure(nutrition::configure) + .configure(price_history::configure); } #[get("/")] diff --git a/web-api/src/endpoints/nutrition.rs b/web-api/src/endpoints/nutrition.rs new file mode 100644 index 0000000..d830506 --- /dev/null +++ b/web-api/src/endpoints/nutrition.rs @@ -0,0 +1,59 @@ +use actix_web::{ + get, + web::{self, ServiceConfig}, + HttpResponse, Responder, +}; +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; + +use crate::dish::DishNutrients; + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service(nutrition); +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct NutritionQuery { + date: Option, +} + +#[get("/nutrition/{name}")] +async fn nutrition( + path: web::Path, + query: web::Query, + db: web::Data, +) -> impl Responder { + let db = db.as_ref(); + let dish_name = path.into_inner(); + + let res = if let Some(date) = query.date { + sqlx::query_as!( + DishNutrients, + r#"SELECT kjoules, proteins, carbohydrates, fats FROM meals m WHERE is_latest = TRUE AND LOWER("name") = $1 AND date = $2 LIMIT 1;"#, + dish_name.to_lowercase(), + date, + ).fetch_optional(db).await + } else { + sqlx::query_as!( + DishNutrients, + r#"SELECT kjoules, proteins, carbohydrates, fats FROM meals m WHERE is_latest = TRUE AND LOWER("name") = $1 ORDER BY date DESC LIMIT 1;"#, + dish_name.to_lowercase(), + ).fetch_optional(db).await + }; + + match res { + Ok(Some(nutrition)) => HttpResponse::Ok().json(nutrition.normalize()), + Ok(None) => HttpResponse::NotFound().json(json!({ + "error": "Dish cannot be found", + })), + Err(err) => { + tracing::error!("Failed to query database: {err:?}"); + HttpResponse::InternalServerError().json(json!({ + "error": "Failed to query database", + })) + } + } +} diff --git a/web-api/src/endpoints/price_history.rs b/web-api/src/endpoints/price_history.rs new file mode 100644 index 0000000..52fffa3 --- /dev/null +++ b/web-api/src/endpoints/price_history.rs @@ -0,0 +1,167 @@ +use std::collections::BTreeMap; + +use actix_web::{ + get, + web::{self, ServiceConfig}, + HttpResponse, Responder, +}; +use bigdecimal::BigDecimal; +use chrono::NaiveDate; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::{prelude::FromRow, PgPool}; + +use crate::{util, DishPrices}; + +pub fn configure(cfg: &mut ServiceConfig) { + cfg.service(price_history); +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct PriceHistoryQuery { + canteens: Option, +} + +#[derive(Debug, Clone, Deserialize, FromRow)] +struct PriceHistoryRow { + date: NaiveDate, + canteen: String, + price_students: BigDecimal, + price_employees: BigDecimal, + price_guests: BigDecimal, +} + +#[get("/price-history/{name}")] +async fn price_history( + path: web::Path, + query: web::Query, + db: web::Data, +) -> impl Responder { + let db = db.as_ref(); + let canteens = query + .canteens + .as_deref() + .map(util::parse_canteens_comma_separated); + let dish_name = path.into_inner(); + + if let Some(canteens) = canteens { + if canteens.iter().all(Result::is_ok) { + let canteens = canteens.into_iter().filter_map(Result::ok).collect_vec(); + + if canteens.len() == 1 { + let canteen = canteens.into_iter().next().expect("length is 1"); + + let res = sqlx::query!( + r#"SELECT date, price_students, price_employees, price_guests FROM meals WHERE canteen = $1 AND LOWER("name") = $2 AND is_latest = TRUE"#, + canteen.get_identifier(), + dish_name.to_lowercase(), + ) + .fetch_all(db) + .await; + + match res { + Ok(recs) => { + let structured = recs + .into_iter() + .map(|r| { + ( + r.date, + DishPrices { + students: r.price_students, + employees: r.price_employees, + guests: r.price_guests, + } + .normalize(), + ) + }) + .collect::>(); + + HttpResponse::Ok().json(structured) + } + Err(err) => { + tracing::error!("Failed to query database: {err:?}"); + HttpResponse::InternalServerError().json(json!({ + "error": "Failed to query database", + })) + } + } + } else { + let res = sqlx::query_as!(PriceHistoryRow, + r#"SELECT date, canteen, price_students, price_employees, price_guests FROM meals WHERE canteen = ANY($1) AND LOWER("name") = $2 AND is_latest = TRUE"#, + &canteens.iter().map(|c| c.get_identifier().to_string()).collect_vec(), + dish_name.to_lowercase(), + ) + .fetch_all(db) + .await; + + match res { + Ok(recs) => { + let structured = structure_multiple_canteens(recs); + + HttpResponse::Ok().json(structured) + } + Err(err) => { + tracing::error!("Failed to query database: {err:?}"); + HttpResponse::InternalServerError().json(json!({ + "error": "Failed to query database", + })) + } + } + } + } else { + HttpResponse::BadRequest().json(json!({ + "error": "Invalid canteen identifier", + "invalid": canteens.into_iter().filter_map(|c| c.err()).collect_vec() + })) + } + } else { + let res = sqlx::query_as!(PriceHistoryRow, + r#"SELECT date, canteen, price_students, price_employees, price_guests FROM meals WHERE LOWER("name") = $1 AND is_latest = TRUE"#, + dish_name.to_lowercase(), + ) + .fetch_all(db) + .await; + + match res { + Ok(recs) => { + let structured = structure_multiple_canteens(recs); + + HttpResponse::Ok().json(structured) + } + Err(err) => { + tracing::error!("Failed to query database: {err:?}"); + HttpResponse::InternalServerError().json(json!({ + "error": "Failed to query database", + })) + } + } + } +} + +fn structure_multiple_canteens( + v: Vec, +) -> BTreeMap> { + v.into_iter() + .chunk_by(|r| r.canteen.clone()) + .into_iter() + .map(|(d, g)| { + ( + d, + g.map(|r| { + ( + r.date, + DishPrices { + students: r.price_students, + employees: r.price_employees, + guests: r.price_guests, + } + .normalize(), + ) + }) + .collect(), + ) + }) + .collect() +} diff --git a/web-api/src/lib.rs b/web-api/src/lib.rs index 815bb34..4cc5831 100644 --- a/web-api/src/lib.rs +++ b/web-api/src/lib.rs @@ -2,6 +2,7 @@ mod dish; pub mod endpoints; mod governor; mod menu; +mod util; use std::sync::LazyLock; diff --git a/web-api/src/main.rs b/web-api/src/main.rs index 40329cd..90f761b 100644 --- a/web-api/src/main.rs +++ b/web-api/src/main.rs @@ -2,7 +2,10 @@ use std::env; use actix_cors::Cors; use actix_governor::Governor; -use actix_web::{web, App, HttpServer}; +use actix_web::{ + middleware::{self, NormalizePath}, + web, App, HttpServer, +}; use anyhow::Result; use itertools::Itertools; use mensa_upb_api::get_governor; @@ -72,6 +75,7 @@ async fn main() -> Result<()> { .allow_any_header() .max_age(3600); App::new() + .wrap(NormalizePath::new(middleware::TrailingSlash::Trim)) .wrap(Governor::new(&governor_conf)) .wrap(cors) .app_data(web::Data::new(db.clone())) diff --git a/web-api/src/menu.rs b/web-api/src/menu.rs index 80824fe..e6a4280 100644 --- a/web-api/src/menu.rs +++ b/web-api/src/menu.rs @@ -59,10 +59,11 @@ impl Menu { vegan: row.vegan, vegetarian: row.vegetarian, price: DishPrices { - students: row.price_students.with_prec(5).with_scale(2), - employees: row.price_employees.with_prec(5).with_scale(2), - guests: row.price_guests.with_prec(5).with_scale(2), - }, + students: row.price_students, + employees: row.price_employees, + guests: row.price_guests, + } + .normalize(), }; if row.dish_type == DishType::Main { main_dishes.push(dish); diff --git a/web-api/src/util.rs b/web-api/src/util.rs new file mode 100644 index 0000000..4c008da --- /dev/null +++ b/web-api/src/util.rs @@ -0,0 +1,7 @@ +use std::str::FromStr as _; + +use shared::Canteen; + +pub fn parse_canteens_comma_separated(s: &str) -> Vec> { + s.split(',').map(Canteen::from_str).collect() +}