From 69ef08002cbcca425f3bb0cc1567d651eb711ae6 Mon Sep 17 00:00:00 2001 From: Paul van Tilburg Date: Mon, 6 Jun 2022 14:53:56 +0200 Subject: [PATCH] Introduce error types, switch to Results everywhere * Add dependency on the `thiserror` crate * Add a global `Error` type, but also `maps::Error` and `providers::combined::MergeError` for convenience * Add matching `Result` types that default to the respective `Error` type * Refactor code to yield all kinds of error variants * Add FIXMEs where library errors still need to be handled * Remove documentation that explained why `None` was returned, this is captured in the error now --- Cargo.lock | 1 + Cargo.toml | 1 + src/forecast.rs | 29 ++-- src/lib.rs | 47 ++++++- src/maps.rs | 249 ++++++++++++++++++---------------- src/position.rs | 23 ++-- src/providers/buienradar.rs | 54 +++----- src/providers/combined.rs | 123 +++++++++++------ src/providers/luchtmeetnet.rs | 23 +--- 9 files changed, 316 insertions(+), 234 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ebf910c..dc2ac2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2136,6 +2136,7 @@ dependencies = [ "image", "reqwest 0.11.10", "rocket", + "thiserror", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 23ea6bb..3806794 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ geocoding = "0.3.1" image = "0.24.1" reqwest = { version = "0.11.9", features = ["json"] } rocket = { version = "0.5.0-rc.2", features = ["json"] } +thiserror = "1.0.31" [dev-dependencies] assert_float_eq = "1.1.3" diff --git a/src/forecast.rs b/src/forecast.rs index 4090d8f..267bed4 100644 --- a/src/forecast.rs +++ b/src/forecast.rs @@ -131,24 +131,33 @@ pub(crate) async fn forecast( for metric in metrics { match metric { // This should have been expanded to all the metrics matched below. + // FIXME: Handle the errors! Metric::All => unreachable!("The all metric should have been expanded"), - Metric::AQI => forecast.aqi = providers::luchtmeetnet::get(position, metric).await, - Metric::NO2 => forecast.no2 = providers::luchtmeetnet::get(position, metric).await, - Metric::O3 => forecast.o3 = providers::luchtmeetnet::get(position, metric).await, + Metric::AQI => forecast.aqi = providers::luchtmeetnet::get(position, metric).await.ok(), + Metric::NO2 => forecast.no2 = providers::luchtmeetnet::get(position, metric).await.ok(), + Metric::O3 => forecast.o3 = providers::luchtmeetnet::get(position, metric).await.ok(), Metric::PAQI => { - forecast.paqi = providers::combined::get(position, metric, maps_handle).await + forecast.paqi = providers::combined::get(position, metric, maps_handle) + .await + .ok() + } + Metric::PM10 => { + forecast.pm10 = providers::luchtmeetnet::get(position, metric).await.ok() } - Metric::PM10 => forecast.pm10 = providers::luchtmeetnet::get(position, metric).await, Metric::Pollen => { - forecast.pollen = - providers::buienradar::get_samples(position, metric, maps_handle).await + forecast.pollen = providers::buienradar::get_samples(position, metric, maps_handle) + .await + .ok() } Metric::Precipitation => { - forecast.precipitation = providers::buienradar::get_items(position, metric).await + forecast.precipitation = providers::buienradar::get_items(position, metric) + .await + .ok() } Metric::UVI => { - forecast.uvi = - providers::buienradar::get_samples(position, metric, maps_handle).await + forecast.uvi = providers::buienradar::get_samples(position, metric, maps_handle) + .await + .ok() } } } diff --git a/src/lib.rs b/src/lib.rs index 71ef288..3aee86a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,45 @@ pub(crate) mod maps; pub(crate) mod position; pub(crate) mod providers; +/// The possible provider errors that can occur. +#[derive(Debug, thiserror::Error)] +pub(crate) enum Error { + /// A CSV parse error occurred. + #[error("CSV parse error: {0}")] + CsvParse(#[from] csv::Error), + + /// A geocoding error occurred. + #[error("Geocoding error: {0}")] + Geocoding(#[from] geocoding::GeocodingError), + + /// An HTTP request error occurred. + #[error("HTTP request error: {0}")] + HttpRequest(#[from] reqwest::Error), + + /// Failed to join a task. + #[error("Failed to join a task: {0}")] + Join(#[from] rocket::tokio::task::JoinError), + + /// Failed to merge AQI & pollen items. + #[error("Failed to merge AQI & pollen items: {0}")] + Merge(#[from] self::providers::combined::MergeError), + + /// Failed to retrieve or sample the maps. + #[error("Failed to retrieve or sample the maps: {0}")] + Maps(#[from] self::maps::Error), + + /// No geocoded position could be found. + #[error("No geocoded position could be found")] + NoPositionFound, + + /// Encountered an unsupported metric. + #[error("Encountered an unsupported metric: {0:?}")] + UnsupportedMetric(Metric), +} + +/// Result type that defaults to [`Error`] as the default error type. +pub(crate) type Result = std::result::Result; + #[derive(Responder)] #[response(content_type = "image/png")] struct PngImageData(Vec); @@ -35,7 +74,7 @@ async fn forecast_address( metrics: Vec, maps_handle: &State, ) -> Option> { - let position = resolve_address(address).await?; + let position = resolve_address(address).await.ok()?; // FIXME: Handle error! let forecast = forecast(position, metrics, maps_handle).await; Some(Json(forecast)) @@ -65,10 +104,10 @@ async fn map_address( metric: Metric, maps_handle: &State, ) -> Option { - let position = resolve_address(address).await?; + let position = resolve_address(address).await.ok()?; // FIXME: Handle error! let image_data = mark_map(position, metric, maps_handle).await; - image_data.map(PngImageData) + image_data.map(PngImageData).ok() // FIXME: Handle the error! } /// Handler for showing the current map with the geocoded position for a specific metric. @@ -84,7 +123,7 @@ async fn map_geo( let position = Position::new(lat, lon); let image_data = mark_map(position, metric, maps_handle).await; - image_data.map(PngImageData) + image_data.map(PngImageData).ok() // FIXME: Handle the error! } /// Sets up Rocket. diff --git a/src/maps.rs b/src/maps.rs index 42b9e42..f270835 100644 --- a/src/maps.rs +++ b/src/maps.rs @@ -9,7 +9,9 @@ use std::sync::{Arc, Mutex}; use chrono::serde::ts_seconds; use chrono::{DateTime, Duration, NaiveDateTime, Utc}; -use image::{DynamicImage, GenericImage, GenericImageView, ImageFormat, Pixel, Rgb, Rgba}; +use image::{ + DynamicImage, GenericImage, GenericImageView, ImageError, ImageFormat, Pixel, Rgb, Rgba, +}; use reqwest::Url; use rocket::serde::Serialize; use rocket::tokio; @@ -18,6 +20,57 @@ use rocket::tokio::time::sleep; use crate::forecast::Metric; use crate::position::Position; +/// The possible maps errors that can occur. +#[derive(Debug, thiserror::Error)] +pub(crate) enum Error { + /// A timestamp parse error occurred. + #[error("Timestamp parse error: {0}")] + ChronoParse(#[from] chrono::ParseError), + + /// A HTTP request error occurred. + #[error("HTTP request error: {0}")] + HttpRequest(#[from] reqwest::Error), + + /// Failed to represent HTTP header as a string. + #[error("Failed to represent HTTP header as a string")] + HttpHeaderToStr(#[from] reqwest::header::ToStrError), + + /// Could not find Last-Modified header. + #[error("Could not find Last-Modified HTTP header")] + MissingLastModifiedHttpHeader, + + /// An image error occurred. + #[error("Image error: {0}")] + Image(#[from] ImageError), + + /// Encountered an invalid image file path. + #[error("Invalid image file path: {0}")] + InvalidImagePath(String), + + /// Failed to join a task. + #[error("Failed to join a task: {0}")] + Join(#[from] rocket::tokio::task::JoinError), + + /// Found no known (map key) colors in samples. + #[error("Found not known colors in samples")] + NoKnownColorsInSamples, + + /// No maps found (yet). + #[error("No maps found (yet)")] + NoMapsYet, + + /// Got out of bound coordinates for a map. + #[error("Got out of bound coordinates for a map: ({0}, {1})")] + OutOfBoundCoords(u32, u32), + + /// Got out of bound offset for a map. + #[error("Got out of bound offset for a map: {0}")] + OutOfBoundOffset(u32), +} + +/// Result type that defaults to [`Error`] as the default error type. +pub(crate) type Result = std::result::Result; + /// A handle to access the in-memory cached maps. pub(crate) type MapsHandle = Arc>; @@ -113,10 +166,10 @@ trait MapsRefresh { fn is_uvi_stale(&self) -> bool; /// Updates the pollen maps. - fn set_pollen(&self, result: Option); + fn set_pollen(&self, result: Result); /// Updates the UV index maps. - fn set_uvi(&self, result: Option); + fn set_uvi(&self, result: Result); } /// Container type for all in-memory cached maps. @@ -142,71 +195,53 @@ impl Maps { } /// Returns a current pollen map that marks the provided position. - /// - /// This returns [`None`] if the maps are not in the cache yet, there is no matching map for - /// the current moment or if the provided position is not within the bounds of the map. - pub(crate) fn pollen_mark(&self, position: Position) -> Option { - self.pollen.as_ref().and_then(|maps| { - let image = &maps.image; - let stamp = maps.timestamp_base; - let marked_image = map_at( - image, - stamp, - POLLEN_MAP_INTERVAL, - POLLEN_MAP_COUNT, - Utc::now(), - )?; - let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?; + pub(crate) fn pollen_mark(&self, position: Position) -> Result { + let maps = self.pollen.as_ref().ok_or(Error::NoMapsYet)?; + let image = &maps.image; + let stamp = maps.timestamp_base; + let marked_image = map_at( + image, + stamp, + POLLEN_MAP_INTERVAL, + POLLEN_MAP_COUNT, + Utc::now(), + )?; + let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?; - Some(mark(marked_image, coords)) - }) + Ok(mark(marked_image, coords)) } /// Samples the pollen maps for the given position. - /// - /// This returns [`None`] if the maps are not in the cache yet. - /// Otherwise, it returns [`Some`] with a list of pollen sample, one for each map - /// in the series of maps. - pub(crate) fn pollen_samples(&self, position: Position) -> Option> { - self.pollen.as_ref().and_then(|maps| { - let image = &maps.image; - let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height()); - let coords = project(&*map, POLLEN_MAP_REF_POINTS, position)?; - let stamp = maps.timestamp_base; + pub(crate) fn pollen_samples(&self, position: Position) -> Result> { + let maps = self.pollen.as_ref().ok_or(Error::NoMapsYet)?; + let image = &maps.image; + let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height()); + let coords = project(&*map, POLLEN_MAP_REF_POINTS, position)?; + let stamp = maps.timestamp_base; - sample(image, stamp, POLLEN_MAP_INTERVAL, POLLEN_MAP_COUNT, coords) - }) + sample(image, stamp, POLLEN_MAP_INTERVAL, POLLEN_MAP_COUNT, coords) } /// Returns a current UV index map that marks the provided position. - /// - /// This returns [`None`] if the maps are not in the cache yet, there is no matching map for - /// the current moment or if the provided position is not within the bounds of the map. - pub(crate) fn uvi_mark(&self, position: Position) -> Option { - self.uvi.as_ref().and_then(|maps| { - let image = &maps.image; - let stamp = maps.timestamp_base; - let marked_image = map_at(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, Utc::now())?; - let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?; + pub(crate) fn uvi_mark(&self, position: Position) -> Result { + let maps = self.uvi.as_ref().ok_or(Error::NoMapsYet)?; + let image = &maps.image; + let stamp = maps.timestamp_base; + let marked_image = map_at(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, Utc::now())?; + let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?; - Some(mark(marked_image, coords)) - }) + Ok(mark(marked_image, coords)) } /// Samples the UV index maps for the given position. - /// - /// This returns [`None`] if the maps are not in the cache yet. - /// Otherwise, it returns [`Some`] with a list of UV index sample, one for each map - /// in the series of maps. - pub(crate) fn uvi_samples(&self, position: Position) -> Option> { - self.uvi.as_ref().and_then(|maps| { - let image = &maps.image; - let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height()); - let coords = project(&*map, UVI_MAP_REF_POINTS, position)?; - let stamp = maps.timestamp_base; + pub(crate) fn uvi_samples(&self, position: Position) -> Result> { + let maps = self.uvi.as_ref().ok_or(Error::NoMapsYet)?; + let image = &maps.image; + let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height()); + let coords = project(&*map, UVI_MAP_REF_POINTS, position)?; + let stamp = maps.timestamp_base; - sample(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, coords) - }) + sample(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, coords) } } @@ -263,17 +298,17 @@ impl MapsRefresh for MapsHandle { } } - fn set_pollen(&self, retrieved_maps: Option) { - if retrieved_maps.is_some() || self.is_pollen_stale() { + fn set_pollen(&self, retrieved_maps: Result) { + if retrieved_maps.is_ok() || self.is_pollen_stale() { let mut maps = self.lock().expect("Maps handle mutex was poisoned"); - maps.pollen = retrieved_maps; + maps.pollen = retrieved_maps.ok(); } } - fn set_uvi(&self, retrieved_maps: Option) { - if retrieved_maps.is_some() || self.is_uvi_stale() { + fn set_uvi(&self, retrieved_maps: Result) { + if retrieved_maps.is_ok() || self.is_uvi_stale() { let mut maps = self.lock().expect("Maps handle mutex was poisoned"); - maps.uvi = retrieved_maps; + maps.uvi = retrieved_maps.ok(); } } } @@ -315,15 +350,13 @@ fn map_key_histogram() -> MapKeyHistogram { /// Samples the provided maps at the given (map-relative) coordinates and starting timestamp. /// It assumes the provided coordinates are within bounds of at least one map. /// The interval is the number of seconds the timestamp is bumped for each map. -/// -/// Returns [`None`] if it encounters no known colors in any of the samples. fn sample>>( image: &I, stamp: DateTime, interval: i64, count: u32, coords: (u32, u32), -) -> Option> { +) -> Result> { let (x, y) = coords; let width = image.width() / count; let height = image.height(); @@ -351,7 +384,7 @@ fn sample>>( .max_by_key(|(_color, count)| *count) .expect("Map key is never empty"); if count == 0 { - return None; + return Err(Error::NoKnownColorsInSamples); } let score = MAP_KEY @@ -365,7 +398,7 @@ fn sample>>( offset += width; } - Some(samples) + Ok(samples) } /// A retrieved image with some metadata. @@ -396,49 +429,44 @@ impl RetrievedMaps { } /// Retrieves an image from the provided URL. -/// -/// This returns [`None`] if it fails in either performing the request, parsing the `Last-Modified` -/// reponse HTTP header, retrieving the bytes from the image or loading and the decoding the data -/// into [`DynamicImage`]. -async fn retrieve_image(url: Url) -> Option { - // TODO: Handle or log errors! - let response = reqwest::get(url).await.ok()?; - let mtime = response +async fn retrieve_image(url: Url) -> Result { + let response = reqwest::get(url).await?; + let mtime_header = response .headers() .get(reqwest::header::LAST_MODIFIED) - .and_then(|dt| dt.to_str().ok()) - .map(chrono::DateTime::parse_from_rfc2822)? - .map(DateTime::::from) - .ok()?; + .ok_or(Error::MissingLastModifiedHttpHeader)?; + let mtime_header = mtime_header.to_str()?; + let mtime = DateTime::::from(chrono::DateTime::parse_from_rfc2822(mtime_header)?); let timestamp_base = { let path = response.url().path(); - let (_, filename) = path.rsplit_once('/')?; - let (timestamp_str, _) = filename.split_once("__")?; - let timestamp = NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M").ok()?; + let (_, filename) = path + .rsplit_once('/') + .ok_or_else(|| Error::InvalidImagePath(path.to_owned()))?; + let (timestamp_str, _) = filename + .split_once("__") + .ok_or_else(|| Error::InvalidImagePath(path.to_owned()))?; + let timestamp = NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M")?; DateTime::::from_utc(timestamp, Utc) }; - let bytes = response.bytes().await.ok()?; + let bytes = response.bytes().await?; tokio::task::spawn_blocking(move || { - if let Ok(image) = image::load_from_memory_with_format(&bytes, ImageFormat::Png) { - Some(RetrievedMaps { + image::load_from_memory_with_format(&bytes, ImageFormat::Png) + .map(|image| RetrievedMaps { image, mtime, timestamp_base, }) - } else { - None - } + .map_err(Error::from) }) - .await - .ok()? + .await? } /// Retrieves the pollen maps from Buienradar. /// /// See [`POLLEN_BASE_URL`] for the base URL and [`retrieve_image`] for the retrieval function. -async fn retrieve_pollen_maps() -> Option { +async fn retrieve_pollen_maps() -> Result { let timestamp = format!("{}", chrono::Local::now().format("%y%m%d%H%M")); let mut url = Url::parse(POLLEN_BASE_URL).unwrap(); url.query_pairs_mut().append_pair("timestamp", ×tamp); @@ -450,7 +478,7 @@ async fn retrieve_pollen_maps() -> Option { /// Retrieves the UV index maps from Buienradar. /// /// See [`UVI_BASE_URL`] for the base URL and [`retrieve_image`] for the retrieval function. -async fn retrieve_uvi_maps() -> Option { +async fn retrieve_uvi_maps() -> Result { let timestamp = format!("{}", chrono::Local::now().format("%y%m%d%H%M")); let mut url = Url::parse(UVI_BASE_URL).unwrap(); url.query_pairs_mut().append_pair("timestamp", ×tamp); @@ -460,25 +488,22 @@ async fn retrieve_uvi_maps() -> Option { } /// Returns the map for the given instant. -/// -/// This returns [`None`] if `instant` is too far in the future with respect to the number of -/// cached maps. fn map_at( image: &DynamicImage, stamp: DateTime, interval: i64, count: u32, instant: DateTime, -) -> Option { +) -> Result { let duration = instant.signed_duration_since(stamp); let offset = (duration.num_seconds() / interval) as u32; // Check if out of bounds. if offset >= count { - return None; + return Err(Error::OutOfBoundOffset(offset)); } let width = image.width() / count; - Some(image.crop_imm(offset * width, 0, width, image.height())) + Ok(image.crop_imm(offset * width, 0, width, image.height())) } /// Marks the provided coordinates on the map using a horizontal and vertical line. @@ -499,13 +524,11 @@ fn mark(mut image: DynamicImage, coords: (u32, u32)) -> DynamicImage { /// /// This uses two reference points and a Mercator projection on the y-coordinates of those points /// to calculate how the map scales with respect to the provided position. -/// -/// Returns [`None`] if the resulting coordinate is not within the bounds of the map. fn project( image: &I, ref_points: [(Position, (u32, u32)); 2], pos: Position, -) -> Option<(u32, u32)> { +) -> Result<(u32, u32)> { // Get the data from the reference points. let (ref1, (ref1_y, ref1_x)) = ref_points[0]; let (ref2, (ref2_y, ref2_x)) = ref_points[1]; @@ -522,9 +545,9 @@ fn project( let y = ((ref2_merc_y - mercator_y(pos.lat_as_rad())) * scale_y + ref2_y as f64).round() as u32; if image.in_bounds(x, y) { - Some((x, y)) + Ok((x, y)) } else { - None + Err(Error::OutOfBoundCoords(x, y)) } } @@ -535,7 +558,7 @@ pub(crate) async fn mark_map( position: Position, metric: Metric, maps_handle: &MapsHandle, -) -> Option> { +) -> crate::Result> { use std::io::Cursor; let maps_handle = Arc::clone(maps_handle); @@ -545,24 +568,22 @@ pub(crate) async fn mark_map( Metric::PAQI => maps.pollen_mark(position), Metric::Pollen => maps.pollen_mark(position), Metric::UVI => maps.uvi_mark(position), - _ => return None, // Unsupported metric + _ => return Err(crate::Error::UnsupportedMetric(metric)), }?; drop(maps); // Encode the image as PNG image data. let mut image_data = Cursor::new(Vec::new()); - image - .write_to( - &mut image_data, - image::ImageOutputFormat::from(image::ImageFormat::Png), - ) - .ok()?; - - Some(image_data.into_inner()) + match image.write_to( + &mut image_data, + image::ImageOutputFormat::from(image::ImageFormat::Png), + ) { + Ok(()) => Ok(image_data.into_inner()), + Err(err) => Err(crate::Error::from(Error::from(err))), + } }) .await - .ok() - .flatten() + .map_err(Error::from)? } /// Runs a loop that keeps refreshing the maps when necessary. diff --git a/src/position.rs b/src/position.rs index b098c4f..3e26ba1 100644 --- a/src/position.rs +++ b/src/position.rs @@ -2,13 +2,14 @@ //! //! This module contains everything related to geographic coordinate system functionality. +use std::f64::consts::PI; use std::hash::Hash; use cached::proc_macro::cached; use geocoding::{Forward, Openstreetmap, Point}; use rocket::tokio; -use std::f64::consts::PI; +use crate::{Error, Result}; /// A (geocoded) position. /// @@ -98,21 +99,19 @@ impl Eq for Position {} /// Resolves the geocoded position for a given address. /// -/// Returns [`None`] if the address could not be geocoded or the OpenStreetMap Nomatim API could -/// not be contacted. -/// -/// If the result is [`Some`], it will be cached. +/// If the result is [`Ok`], it will be cached. /// Note that only the 100 least recently used addresses will be cached. -#[cached(size = 100)] -pub(crate) async fn resolve_address(address: String) -> Option { +#[cached(size = 100, result = true)] +pub(crate) async fn resolve_address(address: String) -> Result { println!("🌍 Geocoding the position of the address: {}", address); tokio::task::spawn_blocking(move || { let osm = Openstreetmap::new(); - let points: Vec> = osm.forward(&address).ok()?; + let points: Vec> = osm.forward(&address)?; - points.get(0).map(Position::from) + points + .get(0) + .ok_or(Error::NoPositionFound) + .map(Position::from) }) - .await - .ok() - .flatten() + .await? } diff --git a/src/providers/buienradar.rs b/src/providers/buienradar.rs index 868365c..c0d1475 100644 --- a/src/providers/buienradar.rs +++ b/src/providers/buienradar.rs @@ -14,7 +14,7 @@ use rocket::serde::{Deserialize, Serialize}; use crate::maps::MapsHandle; use crate::position::Position; -use crate::Metric; +use crate::{Error, Metric, Result}; /// The base URL for the Buienradar API. const BUIENRADAR_BASE_URL: &str = "https://gpsgadget.buienradar.nl/data/raintext"; @@ -128,28 +128,23 @@ fn fix_items_day_boundary(items: Vec) -> Vec { /// Retrieves the Buienradar forecasted precipitation items for the provided position. /// -/// Returns [`None`] if retrieval or deserialization fails. -/// -/// If the result is [`Some`] it will be cached for 5 minutes for the the given position. -#[cached(time = 300, option = true)] -async fn get_precipitation(position: Position) -> Option> { +/// If the result is [`Ok`] it will be cached for 5 minutes for the the given position. +#[cached(time = 300, result = true)] +async fn get_precipitation(position: Position) -> Result> { let mut url = Url::parse(BUIENRADAR_BASE_URL).unwrap(); url.query_pairs_mut() .append_pair("lat", &position.lat_as_str(2)) .append_pair("lon", &position.lon_as_str(2)); println!("▶️ Retrieving Buienradar data from: {url}"); - let response = reqwest::get(url).await.ok()?; - let output = match response.error_for_status() { - Ok(res) => res.text().await.ok()?, - Err(_err) => return None, - }; + let response = reqwest::get(url).await?; + let output = response.error_for_status()?.text().await?; let mut rdr = ReaderBuilder::new() .has_headers(false) .delimiter(b'|') .from_reader(output.as_bytes()); - let items: Vec = rdr.deserialize().collect::>().ok()?; + let items: Vec = rdr.deserialize().collect::>()?; // Check if the first item stamp is (timewise) later than the last item stamp. // In this case `parse_time` interpreted e.g. 23:00 and later 0:30 in the same day and some @@ -160,46 +155,44 @@ async fn get_precipitation(position: Position) -> Option> { .map(|(it1, it2)| it1.time > it2.time) == Some(true) { - Some(fix_items_day_boundary(items)) + Ok(fix_items_day_boundary(items)) } else { - Some(items) + Ok(items) } } /// Retrieves the Buienradar forecasted pollen samples for the provided position. /// -/// Returns [`None`] if the sampling fails. -/// -/// If the result is [`Some`] if will be cached for 1 hour for the given position. +/// If the result is [`Ok`] if will be cached for 1 hour for the given position. #[cached( time = 3_600, key = "Position", convert = r#"{ position }"#, - option = true + result = true )] -async fn get_pollen(position: Position, maps_handle: &MapsHandle) -> Option> { +async fn get_pollen(position: Position, maps_handle: &MapsHandle) -> Result> { maps_handle .lock() .expect("Maps handle mutex was poisoned") .pollen_samples(position) + .map_err(Into::into) } /// Retrieves the Buienradar forecasted UV index samples for the provided position. /// -/// Returns [`None`] if the sampling fails. -/// -/// If the result is [`Some`] if will be cached for 1 day for the given position. +/// If the result is [`Ok`] if will be cached for 1 day for the given position. #[cached( time = 86_400, key = "Position", convert = r#"{ position }"#, - option = true + result = true )] -async fn get_uvi(position: Position, maps_handle: &MapsHandle) -> Option> { +async fn get_uvi(position: Position, maps_handle: &MapsHandle) -> Result> { maps_handle .lock() .expect("Maps handle mutex was poisoned") .uvi_samples(position) + .map_err(Into::into) } /// Retrieves the Buienradar forecasted map samples for the provided position. @@ -207,18 +200,15 @@ async fn get_uvi(position: Position, maps_handle: &MapsHandle) -> Option Option> { +) -> Result> { match metric { Metric::Pollen => get_pollen(position, maps_handle).await, Metric::UVI => get_uvi(position, maps_handle).await, - _ => None, + _ => Err(Error::UnsupportedMetric(metric)), } } @@ -227,11 +217,9 @@ pub(crate) async fn get_samples( /// It only supports the following metric: /// * [`Metric::Precipitation`] /// -/// Returns [`None`] if retrieval or deserialization fails, or if the metric is not supported by -/// this provider. -pub(crate) async fn get_items(position: Position, metric: Metric) -> Option> { +pub(crate) async fn get_items(position: Position, metric: Metric) -> Result> { match metric { Metric::Precipitation => get_precipitation(position).await, - _ => None, + _ => Err(Error::UnsupportedMetric(metric)), } } diff --git a/src/providers/combined.rs b/src/providers/combined.rs index f058b34..e8ec998 100644 --- a/src/providers/combined.rs +++ b/src/providers/combined.rs @@ -11,7 +11,28 @@ pub(crate) use super::buienradar::{self, Sample as BuienradarSample}; pub(crate) use super::luchtmeetnet::{self, Item as LuchtmeetnetItem}; use crate::maps::MapsHandle; use crate::position::Position; -use crate::Metric; +use crate::{Error, Metric}; + +/// The possible merge errors that can occur. +#[allow(clippy::enum_variant_names)] +#[derive(Debug, thiserror::Error, PartialEq)] +pub(crate) enum MergeError { + /// No AQI item found. + #[error("No AQI item found")] + NoAqiItemFound, + + /// No pollen item found. + #[error("No pollen item found")] + NoPollenItemFound, + + /// No AQI item found within 30 minutes of first pollen item. + #[error("No AQI item found within 30 minutes of first pollen item")] + NoCloseAqiItemFound, + + /// No pollen item found within 30 minutes of first AQI item. + #[error("No pollen item found within 30 minutes of first AQI item")] + NoClosePollenItemFound, +} /// The combined data item. #[derive(Clone, Debug, PartialEq, Serialize)] @@ -35,15 +56,12 @@ impl Item { /// Merges pollen samples and AQI items into combined items. /// /// The merging drops items from either the pollen samples or from the AQI items if they are not -/// stamped within an hour of the first item of the latest starting series, thus lining them +/// stamped within half an hour of the first item of the latest starting series, thus lining them /// before they are combined. -/// -/// Returns [`None`] if there are no pollen samples, if there are no AQI items, or if -/// lining them up fails. fn merge( pollen_samples: Vec, aqi_items: Vec, -) -> Option> { +) -> Result, MergeError> { let mut pollen_samples = pollen_samples; let mut aqi_items = aqi_items; @@ -53,27 +71,36 @@ fn merge( aqi_items.retain(|item| item.time.signed_duration_since(now).num_seconds() > -3600); // Align the iterators based on the (hourly) timestamps! - let pollen_first_time = pollen_samples.first()?.time; - let aqi_first_time = aqi_items.first()?.time; + let pollen_first_time = pollen_samples + .first() + .ok_or(MergeError::NoPollenItemFound)? + .time; + let aqi_first_time = aqi_items.first().ok_or(MergeError::NoAqiItemFound)?.time; if pollen_first_time < aqi_first_time { // Drain one or more pollen samples to line up. - let idx = pollen_samples.iter().position(|smp| { - smp.time - .signed_duration_since(aqi_first_time) - .num_seconds() - .abs() - < 1800 - })?; + let idx = pollen_samples + .iter() + .position(|smp| { + smp.time + .signed_duration_since(aqi_first_time) + .num_seconds() + .abs() + < 1800 + }) + .ok_or(MergeError::NoCloseAqiItemFound)?; pollen_samples.drain(..idx); } else { // Drain one or more AQI items to line up. - let idx = aqi_items.iter().position(|item| { - item.time - .signed_duration_since(pollen_first_time) - .num_seconds() - .abs() - < 1800 - })?; + let idx = aqi_items + .iter() + .position(|item| { + item.time + .signed_duration_since(pollen_first_time) + .num_seconds() + .abs() + < 1800 + }) + .ok_or(MergeError::NoClosePollenItemFound)?; aqi_items.drain(..idx); } @@ -90,37 +117,32 @@ fn merge( }) .collect(); - Some(items) + Ok(items) } /// Retrieves the combined forecasted items for the provided position and metric. /// /// It supports the following metric: /// * [`Metric::PAQI`] -/// -/// Returns [`None`] for the combined items if retrieving data from either the Buienradar or the -/// Luchtmeetnet provider fails or if they cannot be combined. -/// -/// If the result is [`Some`], it will be cached for 30 minutes for the the given position and -/// metric. #[cached( time = 1800, key = "(Position, Metric)", convert = r#"{ (position, metric) }"#, - option = true + result = true )] pub(crate) async fn get( position: Position, metric: Metric, maps_handle: &MapsHandle, -) -> Option> { +) -> Result, Error> { if metric != Metric::PAQI { - return None; + return Err(Error::UnsupportedMetric(metric)); }; - let pollen_items = buienradar::get_samples(position, Metric::Pollen, maps_handle).await; - let aqi_items = luchtmeetnet::get(position, Metric::AQI).await; + let pollen_items = buienradar::get_samples(position, Metric::Pollen, maps_handle).await?; + let aqi_items = luchtmeetnet::get(position, Metric::AQI).await?; + let items = merge(pollen_items, aqi_items)?; - merge(pollen_items?, aqi_items?) + Ok(items) } #[cfg(test)] @@ -159,7 +181,7 @@ mod tests { // Perform a normal merge. let merged = super::merge(pollen_samples.clone(), aqi_items.clone()); - assert!(merged.is_some()); + assert!(merged.is_ok()); let paqi = merged.unwrap(); assert_eq!( paqi, @@ -180,7 +202,7 @@ mod tests { }) .collect::>(); let merged = super::merge(shifted_pollen_samples, aqi_items.clone()); - assert!(merged.is_some()); + assert!(merged.is_ok()); let paqi = merged.unwrap(); assert_eq!(paqi, Vec::from([Item::new(t_1, 2.9), Item::new(t_2, 3.0)])); @@ -194,18 +216,18 @@ mod tests { }) .collect::>(); let merged = super::merge(pollen_samples.clone(), shifted_aqi_items); - assert!(merged.is_some()); + assert!(merged.is_ok()); let paqi = merged.unwrap(); assert_eq!(paqi, Vec::from([Item::new(t_1, 3.0), Item::new(t_2, 2.9)])); // The maximum sample/item should not be later then the interval the PAQI items cover. let merged = super::merge(pollen_samples[..3].to_vec(), aqi_items.clone()); - assert!(merged.is_some()); + assert!(merged.is_ok()); let paqi = merged.unwrap(); assert_eq!(paqi, Vec::from([Item::new(t_0, 1.1)])); let merged = super::merge(pollen_samples.clone(), aqi_items[..3].to_vec()); - assert!(merged.is_some()); + assert!(merged.is_ok()); let paqi = merged.unwrap(); assert_eq!(paqi, Vec::from([Item::new(t_0, 1.1)])); @@ -219,18 +241,29 @@ mod tests { }) .collect::>(); let merged = super::merge(pollen_samples.clone(), shifted_aqi_items); - assert_eq!(merged, None); + assert_eq!(merged, Err(MergeError::NoCloseAqiItemFound)); + + let shifted_pollen_samples = pollen_samples + .iter() + .cloned() + .map(|mut item| { + item.time = item.time.checked_add_signed(Duration::hours(6)).unwrap(); + item + }) + .collect::>(); + let merged = super::merge(shifted_pollen_samples, aqi_items.clone()); + assert_eq!(merged, Err(MergeError::NoClosePollenItemFound)); // The pollen samples list is empty, or everything is too old. let merged = super::merge(Vec::new(), aqi_items.clone()); - assert_eq!(merged, None); + assert_eq!(merged, Err(MergeError::NoPollenItemFound)); let merged = super::merge(pollen_samples[0..2].to_vec(), aqi_items.clone()); - assert_eq!(merged, None); + assert_eq!(merged, Err(MergeError::NoPollenItemFound)); // The AQI items list is empty, or everything is too old. let merged = super::merge(pollen_samples.clone(), Vec::new()); - assert_eq!(merged, None); + assert_eq!(merged, Err(MergeError::NoAqiItemFound)); let merged = super::merge(pollen_samples, aqi_items[0..2].to_vec()); - assert_eq!(merged, None); + assert_eq!(merged, Err(MergeError::NoAqiItemFound)); } } diff --git a/src/providers/luchtmeetnet.rs b/src/providers/luchtmeetnet.rs index 12e1a50..c1f090c 100644 --- a/src/providers/luchtmeetnet.rs +++ b/src/providers/luchtmeetnet.rs @@ -9,7 +9,7 @@ use reqwest::Url; use rocket::serde::{Deserialize, Serialize}; use crate::position::Position; -use crate::Metric; +use crate::{Error, Metric, Result}; /// The base URL for the Luchtmeetnet API. const LUCHTMEETNET_BASE_URL: &str = "https://api.luchtmeetnet.nl/open_api/concentrations"; @@ -54,20 +54,14 @@ impl Item { /// * [`Metric::NO2`] /// * [`Metric::O3`] /// * [`Metric::PM10`] -/// -/// Returns [`None`] if retrieval or deserialization fails, or if the metric is not supported by -/// this provider. -/// -/// If the result is [`Some`] it will be cached for 30 minutes for the the given position and -/// metric. -#[cached(time = 1800, option = true)] -pub(crate) async fn get(position: Position, metric: Metric) -> Option> { +#[cached(time = 1800, result = true)] +pub(crate) async fn get(position: Position, metric: Metric) -> Result> { let formula = match metric { Metric::AQI => "lki", Metric::NO2 => "no2", Metric::O3 => "o3", Metric::PM10 => "pm10", - _ => return None, // Unsupported metric + _ => return Err(Error::UnsupportedMetric(metric)), }; let mut url = Url::parse(LUCHTMEETNET_BASE_URL).unwrap(); url.query_pairs_mut() @@ -76,11 +70,8 @@ pub(crate) async fn get(position: Position, metric: Metric) -> Option> .append_pair("longitude", &position.lon_as_str(5)); println!("▶️ Retrieving Luchtmeetnet data from: {url}"); - let response = reqwest::get(url).await.ok()?; - let root: Container = match response.error_for_status() { - Ok(res) => res.json().await.ok()?, - Err(_err) => return None, - }; + let response = reqwest::get(url).await?; + let root: Container = response.error_for_status()?.json().await?; // Filter items that are older than one hour before now. They seem to occur sometimes? let too_old = Utc::now() - Duration::hours(1); @@ -90,5 +81,5 @@ pub(crate) async fn get(position: Position, metric: Metric) -> Option> .filter(|item| item.time > too_old) .collect(); - Some(items) + Ok(items) }