Rework fetachable resources

This commit is contained in:
Malte Tammena 2021-10-21 16:08:44 +02:00
parent 9158875169
commit 6da8a95a3b
11 changed files with 336 additions and 122 deletions

View file

@ -39,10 +39,12 @@ use reqwest::{
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tracing::{info, warn};
mod fetchable;
#[cfg(test)]
mod tests;
mod wrapper;
pub use fetchable::Fetchable;
pub use wrapper::clear_cache as clear;
use crate::error::{Error, Result, ResultExt};

47
src/cache/fetchable.rs vendored Normal file
View file

@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(from = "T", untagged)]
pub enum Fetchable<T> {
/// The value does not exist, but can be fetched.
None,
/// The value has been fetched.
Fetched(T),
}
impl<T> Fetchable<T> {
pub fn is_fetched(&self) -> bool {
matches!(self, Self::Fetched(_))
}
pub fn fetch<F>(&mut self, f: F) -> Result<&T>
where
F: FnOnce() -> Result<T>,
{
match self {
Self::Fetched(ref value) => Ok(value),
Self::None => {
let value = f()?;
*self = Self::Fetched(value);
// This is safe, since we've just fetched successfully
Ok(self.unwrap())
}
}
}
/// Panics if the resource doesn't exist
fn unwrap(&self) -> &T {
match self {
Self::Fetched(value) => value,
Self::None => panic!("Called .unwrap() on a Fetchable that is not fetched!"),
}
}
}
impl<T> From<T> for Fetchable<T> {
fn from(value: T) -> Self {
Fetchable::Fetched(value)
}
}

View file

@ -1,33 +1,49 @@
use chrono::Duration;
use reqwest::blocking::Client;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use tracing::info;
use std::marker::PhantomData;
mod de;
mod ser;
use crate::{
cache,
config::CanteensState,
error::{Error, Result},
geoip, get_sane_terminal_dimensions, print_json, ENDPOINT, TTL_CANTEENS,
cache::Fetchable, config::CanteensState, error::Result, geoip, get_sane_terminal_dimensions,
meal::Meal, pagination::PaginatedList, print_json, ENDPOINT, TTL_CANTEENS,
};
use self::ser::CanteenCompleteWithoutMeals;
pub type CanteenId = usize;
const ADRESS_INDENT: &str = " ";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Deserialize)]
#[serde(from = "de::CanteenDeserialized")]
pub struct Canteen {
id: usize,
id: CanteenId,
#[serde(flatten)]
meta: Fetchable<Meta>,
meals: Fetchable<Vec<Meal>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Meta {
name: String,
city: String,
address: String,
coordinates: [f32; 2],
coordinates: Option<[f32; 2]>,
}
impl Meta {
pub fn fetch(id: CanteenId) -> Result<Self> {
todo!()
}
}
impl Canteen {
pub fn print(&self, state: &CanteensState) {
pub fn print(&mut self, state: &CanteensState) -> Result<()> {
let (width, _) = get_sane_terminal_dimensions();
let address = textwrap::fill(
&self.address,
self.address()?,
textwrap::Options::new(width)
.initial_indent(ADRESS_INDENT)
.subsequent_indent(ADRESS_INDENT),
@ -35,9 +51,29 @@ impl Canteen {
println!(
"{} {}\n{}",
color!(state: format!("{:>4}", self.id); bold, bright_yellow),
color!(state: self.name; bold),
color!(state: self.meta()?.name; bold),
color!(state: address; bright_black),
);
Ok(())
}
pub fn id(&self) -> CanteenId {
self.id
}
pub fn name(&mut self) -> Result<&String> {
Ok(&self.meta()?.address)
}
pub fn address(&mut self) -> Result<&String> {
Ok(&self.meta()?.address)
}
pub fn complete_without_meals(&mut self) -> Result<CanteenCompleteWithoutMeals<'_>> {
Ok(CanteenCompleteWithoutMeals {
id: self.id,
meta: self.meta()?,
})
}
pub fn fetch(state: &CanteensState) -> Result<Vec<Self>> {
@ -48,99 +84,37 @@ impl Canteen {
let (lat, long) = geoip::fetch(state)?;
info!(
"Fetching canteens for lat: {}, long: {} with radius: {}",
lat, long, state.cmd.radius
lat, long, state.cmd.geo.radius
);
format!(
"{}/canteens?near[lat]={}&near[lng]={}&near[dist]={}",
ENDPOINT, lat, long, state.cmd.radius,
ENDPOINT, lat, long, state.cmd.geo.radius,
)
};
PaginatedList::from(&state.client, url, *TTL_CANTEENS)?.try_flatten_and_collect()
}
pub fn print_all(state: &CanteensState, canteens: &[Self]) -> Result<()> {
pub fn print_all(state: &CanteensState, canteens: &mut [Self]) -> Result<()> {
if state.args.json {
print_json(&canteens)
Self::print_all_json(canteens)
} else {
for canteen in canteens {
println!();
canteen.print(state);
canteen.print(state)?;
}
Ok(())
}
}
}
struct PaginatedList<'client, T>
where
T: DeserializeOwned,
{
client: &'client Client,
next_page: Option<String>,
ttl: Duration,
__item: PhantomData<T>,
}
fn print_all_json(canteens: &mut [Self]) -> Result<()> {
let serializable: Vec<_> = canteens
.iter_mut()
.map(|c| c.complete_without_meals())
.try_collect()?;
print_json(&serializable)
}
impl<'client, T> PaginatedList<'client, T>
where
T: DeserializeOwned,
{
pub fn from<S: AsRef<str>>(client: &'client Client, url: S, ttl: Duration) -> Result<Self> {
Ok(PaginatedList {
client,
ttl,
next_page: Some(url.as_ref().into()),
__item: PhantomData,
})
}
}
impl<'client, T> PaginatedList<'client, T>
where
T: DeserializeOwned,
{
pub fn try_flatten_and_collect(self) -> Result<Vec<T>> {
let mut ret = vec![];
for value in self {
let value = value?;
ret.extend(value);
}
Ok(ret)
}
}
impl<'client, T> Iterator for PaginatedList<'client, T>
where
T: DeserializeOwned,
{
type Item = Result<Vec<T>>;
fn next(&mut self) -> Option<Self::Item> {
// This will yield until no next_page is available
let curr_page = self.next_page.take()?;
let res = cache::fetch(self.client, &curr_page, self.ttl, |text, headers| {
let val = serde_json::from_str::<Vec<_>>(&text)
.map_err(|why| Error::Deserializing(why, "fetching json in pagination iterator"))?;
Ok((val, headers.this_page, headers.next_page, headers.last_page))
});
match res {
Ok((val, this_page, next_page, last_page)) => {
// Only update next_page, if we're not on the last page!
// This should be safe for all cases
if this_page.unwrap_or_default() < last_page.unwrap_or_default() {
// OpenMensa returns empty lists for large pages
// this is just to keep me sane
if !val.is_empty() {
self.next_page = next_page;
}
}
Some(Ok(val))
}
Err(why) => {
// Implicitly does not set the next_page url, so
// this iterator is done now
Some(Err(why))
}
}
fn meta(&mut self) -> Result<&Meta> {
self.meta.fetch(|| Meta::fetch(self.id))
}
}

30
src/canteen/de.rs Normal file
View file

@ -0,0 +1,30 @@
use serde::Deserialize;
use crate::cache::Fetchable;
use super::{CanteenId, Meta};
/// For deserializing responses from API/canteens.
#[derive(Debug, Deserialize)]
pub struct CanteenDeserialized {
id: CanteenId,
name: String,
city: String,
address: String,
coordinates: Option<[f32; 2]>,
}
impl From<CanteenDeserialized> for super::Canteen {
fn from(raw: CanteenDeserialized) -> Self {
Self {
id: raw.id,
meta: Fetchable::Fetched(Meta {
name: raw.name,
city: raw.city,
address: raw.address,
coordinates: raw.coordinates,
}),
meals: Fetchable::None,
}
}
}

10
src/canteen/ser.rs Normal file
View file

@ -0,0 +1,10 @@
use serde::Serialize;
use super::{CanteenId, Meta};
#[derive(Debug, Serialize)]
pub struct CanteenCompleteWithoutMeals<'c> {
pub id: CanteenId,
#[serde(flatten)]
pub meta: &'c Meta,
}

View file

@ -63,6 +63,22 @@ pub enum Command {
#[derive(Debug, StructOpt)]
pub struct CanteensCommand {
/// Ignore other arguments. List all canteens.
#[structopt(long, short)]
pub all: bool,
#[structopt(flatten)]
pub geo: GeoCommand,
}
#[derive(Debug, Clone, StructOpt)]
pub enum CloseCommand {
/// Show meals from canteens around you. Will overwrite --id.
Close(GeoCommand),
}
#[derive(Debug, Clone, StructOpt)]
pub struct GeoCommand {
/// Latitude of your position. If omitted, geoip will be used to guess it.
#[structopt(long)]
pub lat: Option<f32>,
@ -74,10 +90,6 @@ pub struct CanteensCommand {
/// Maximum distance of potential canteens from your position in km.
#[structopt(long, short, default_value = "10")]
pub radius: f32,
/// Ignore other arguments. List all canteens.
#[structopt(long, short)]
pub all: bool,
}
#[derive(Debug, Clone, StructOpt)]
@ -144,6 +156,9 @@ pub struct MealsCommand {
#[structopt(long, env = "MENSA_FAVS_CATEGORY_SUB")]
pub no_favs_cat: Vec<Regex>,
#[structopt(subcommand)]
pub close: Option<CloseCommand>,
}
arg_enum! {
@ -185,6 +200,7 @@ impl Default for MealsCommand {
no_favs_tag: vec![],
favs_cat: vec![],
no_favs_cat: vec![],
close: None,
}
}
}

View file

@ -22,7 +22,7 @@ lazy_static! {
static ref REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(10);
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub struct ConfigFile {
#[serde(default)]
@ -58,7 +58,7 @@ impl ConfigFile {
pub type CanteensState<'s> = State<'s, CanteensCommand>;
pub type MealsState<'s> = State<'s, MealsCommand>;
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct State<'s, Cmd> {
pub config: Option<ConfigFile>,
pub client: Client,

View file

@ -26,15 +26,16 @@ struct LatLong {
/// This will use the cli arguments if given and
/// fetch any missing values from api.geoip.rs.
pub fn fetch(state: &CanteensState) -> Result<(f32, f32)> {
Ok(if state.cmd.lat.is_none() || state.cmd.long.is_none() {
let geo = &state.cmd.geo;
Ok(if geo.lat.is_none() || geo.long.is_none() {
let guessed = fetch_geoip(&state.client)?;
(
state.cmd.lat.unwrap_or(guessed.latitude),
state.cmd.long.unwrap_or(guessed.longitude),
geo.lat.unwrap_or(guessed.latitude),
geo.long.unwrap_or(guessed.longitude),
)
} else {
// Cannot panic, due to above if
(state.cmd.lat.unwrap(), state.cmd.long.unwrap())
(geo.lat.unwrap(), geo.long.unwrap())
})
}

View file

@ -108,6 +108,7 @@ mod config;
mod error;
mod geoip;
mod meal;
mod pagination;
mod tag;
use crate::{
@ -166,8 +167,8 @@ fn real_main() -> Result<()> {
}
Some(Command::Canteens(cmd)) => {
let state = State::from(state, cmd);
let canteens = Canteen::fetch(&state)?;
Canteen::print_all(&state, &canteens)?;
let mut canteens = Canteen::fetch(&state)?;
Canteen::print_all(&state, &mut canteens)?;
}
Some(Command::Tags) => {
Tag::print_all(&state)?;

View file

@ -5,12 +5,16 @@ use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use unicode_width::UnicodeWidthStr;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use crate::{
cache::fetch_json,
config::{args::MealsCommand, MealsState, PriceTags},
error::{pass_info, Result},
canteen::{Canteen, CanteenId},
config::{
args::{CanteensCommand, CloseCommand, GeoCommand, MealsCommand},
MealsState, PriceTags,
},
error::{pass_info, Result, ResultExt},
get_sane_terminal_dimensions, print_json,
tag::Tag,
State, ENDPOINT,
@ -33,7 +37,7 @@ lazy_static! {
static ref TTL_MEALS: Duration = Duration::hours(1);
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(from = "RawMeal")]
pub struct Meal {
#[serde(rename = "id")]
@ -55,7 +59,7 @@ struct RawMeal {
category: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(debug, serde(deny_unknown_fields))]
pub struct Prices {
students: f32,
@ -108,32 +112,55 @@ impl Meal {
/// Fetch the meals.
///
/// This will respect passed cli arguments and the configuration.
pub fn fetch(state: &MealsState) -> Result<Vec<Self>> {
let url = format!(
"{}/canteens/{}/days/{}/meals",
ENDPOINT,
state.canteen_id()?,
state.date()
);
fetch_json(&state.client, url, *TTL_MEALS)
pub fn fetch(state: &MealsState) -> Result<HashMap<CanteenId, Vec<Self>>> {
match state.cmd.close {
Some(CloseCommand::Close(ref close)) => {
let meals = fetch_canteens_for_close_command(state, close)?
.into_iter()
.map(
|canteen| match fetch_meals_for_canteen_id(state, canteen.id()) {
Ok(meals) => Ok((canteen.id(), meals)),
Err(why) => Err(why),
},
)
// Drop any failed canteens with a warning
.filter_map(ResultExt::log_warn)
.collect();
Ok(meals)
}
None => {
let id = state.canteen_id()?;
let meals = fetch_meals_for_canteen_id(state, id)?;
let mut map = HashMap::new();
map.insert(id, meals);
Ok(map)
}
}
}
/// Print the given meals.
///
/// Thi will respect passed cli arguments and the configuration.
pub fn print_all(state: &MealsState, meals: &[Self]) -> Result<()> {
pub fn print_all(state: &MealsState, meals: &HashMap<CanteenId, Vec<Self>>) -> Result<()> {
// Load the filter which is used to select which meals to print.
let filter = state.get_filter();
// Load the favourites which will be used for marking meals.
let favs = state.get_favs_rule();
let meals = meals.iter().filter(|meal| filter.is_match(meal));
// Filter all meals
let meal_map = meals.iter().map(|(id, meals)| {
let keep = meals.iter().filter(|meal| filter.is_match(meal));
(id, keep.collect::<Vec<_>>())
});
if state.args.json {
print_json(&meals.collect::<Vec<_>>())
print_json(&meal_map.collect::<HashMap<_, _>>())
} else {
for meal in meals {
let is_fav = favs.is_match(meal);
println!();
meal.print(state, is_fav);
for (id, meals) in meal_map {
println!("{}", id);
for meal in meals {
let is_fav = favs.is_match(meal);
println!();
meal.print(state, is_fav);
}
}
Ok(())
}
@ -279,6 +306,28 @@ where
}
}
fn fetch_canteens_for_close_command(
state: &MealsState,
close: &GeoCommand,
) -> Result<Vec<Canteen>> {
let canteens_cmd = CanteensCommand {
all: false,
geo: close.clone(),
};
let canteen_state = State::from(state.clone(), &canteens_cmd);
Canteen::fetch(&canteen_state)
}
fn fetch_meals_for_canteen_id(state: &MealsState, canteen_id: usize) -> Result<Vec<Meal>> {
let url = format!(
"{}/canteens/{}/days/{}/meals",
ENDPOINT,
canteen_id,
state.date()
);
fetch_json(&state.client, url, *TTL_MEALS)
}
impl fmt::Display for Note {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {

84
src/pagination.rs Normal file
View file

@ -0,0 +1,84 @@
use chrono::Duration;
use reqwest::blocking::Client;
use serde::de::DeserializeOwned;
use std::marker::PhantomData;
use crate::{
cache,
error::{Error, Result},
};
pub struct PaginatedList<'client, T>
where
T: DeserializeOwned,
{
client: &'client Client,
next_page: Option<String>,
ttl: Duration,
__item: PhantomData<T>,
}
impl<'client, T> PaginatedList<'client, T>
where
T: DeserializeOwned,
{
pub fn from<S: AsRef<str>>(client: &'client Client, url: S, ttl: Duration) -> Result<Self> {
Ok(PaginatedList {
client,
ttl,
next_page: Some(url.as_ref().into()),
__item: PhantomData,
})
}
}
impl<'client, T> PaginatedList<'client, T>
where
T: DeserializeOwned,
{
pub fn try_flatten_and_collect(self) -> Result<Vec<T>> {
let mut ret = vec![];
for value in self {
let value = value?;
ret.extend(value);
}
Ok(ret)
}
}
impl<'client, T> Iterator for PaginatedList<'client, T>
where
T: DeserializeOwned,
{
type Item = Result<Vec<T>>;
fn next(&mut self) -> Option<Self::Item> {
// This will yield until no next_page is available
let curr_page = self.next_page.take()?;
let res = cache::fetch(self.client, &curr_page, self.ttl, |text, headers| {
let val = serde_json::from_str::<Vec<_>>(&text)
.map_err(|why| Error::Deserializing(why, "fetching json in pagination iterator"))?;
Ok((val, headers.this_page, headers.next_page, headers.last_page))
});
match res {
Ok((val, this_page, next_page, last_page)) => {
// Only update next_page, if we're not on the last page!
// This should be safe for all cases
if this_page.unwrap_or_default() < last_page.unwrap_or_default() {
// OpenMensa returns empty lists for large pages
// this is just to keep me sane
if !val.is_empty() {
self.next_page = next_page;
}
}
Some(Ok(val))
}
Err(why) => {
// Implicitly does not set the next_page url, so
// this iterator is done now
Some(Err(why))
}
}
}
}