add endpoints for price history and nutrients

This commit is contained in:
Moritz Hölting 2025-12-16 11:29:58 +01:00
parent 8e3dd731c5
commit 340258e461
20 changed files with 527 additions and 35 deletions

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

10
Cargo.lock generated
View File

@ -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]]

View File

@ -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

View File

@ -29,7 +29,7 @@ pub struct Dish {
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NutritionValues {
pub kjoule: Option<i64>,
pub kjoule: Option<i32>,
pub protein: Option<BigDecimal>,
pub carbs: Option<BigDecimal>,
pub fat: Option<BigDecimal>,

View File

@ -41,8 +41,6 @@ pub async fn scrape_menu(date: &NaiveDate, canteen: Canteen) -> Result<Vec<Dish>
res.extend(side_dishes);
res.extend(desserts);
dbg!(&res);
tracing::debug!("Finished scraping");
Ok(res)

View File

@ -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,

View File

@ -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

View File

@ -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<i32>,
pub carbohydrates: Option<BigDecimal>,
pub proteins: Option<BigDecimal>,
pub fats: Option<BigDecimal>,
}
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)),
}
}
}

View File

@ -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<NaiveDate>,
no_update: Option<bool>,
#[serde(default)]
no_update: bool,
}
#[get("/menu/{canteen}")]
async fn menu(
path: web::Path<String>,
query: web::Query<MenuQuery>,
db: web::Data<PgPool>,
) -> 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,15 +37,17 @@ 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 {
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!({
"error": "Invalid canteen identifier",

View File

@ -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("/")]

View File

@ -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<NaiveDate>,
}
#[get("/nutrition/{name}")]
async fn nutrition(
path: web::Path<String>,
query: web::Query<NutritionQuery>,
db: web::Data<PgPool>,
) -> 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",
}))
}
}
}

View File

@ -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<String>,
}
#[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<String>,
query: web::Query<PriceHistoryQuery>,
db: web::Data<PgPool>,
) -> 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::<BTreeMap<_, _>>();
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<PriceHistoryRow>,
) -> BTreeMap<String, BTreeMap<NaiveDate, DishPrices>> {
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()
}

View File

@ -2,6 +2,7 @@ mod dish;
pub mod endpoints;
mod governor;
mod menu;
mod util;
use std::sync::LazyLock;

View File

@ -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()))

View File

@ -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);

7
web-api/src/util.rs Normal file
View File

@ -0,0 +1,7 @@
use std::str::FromStr as _;
use shared::Canteen;
pub fn parse_canteens_comma_separated(s: &str) -> Vec<Result<Canteen, String>> {
s.split(',').map(Canteen::from_str).collect()
}