Handle errors on the API side
* The map endpoints return an HTTP 404 error in case of unknown or out-of-bound locations * The forecast endpoint with an address returns an HTTP 404 with error JSON in case geocoding fails * The forecast endpoints return the errors per metric in the `errors` field of the forecast * Implement `Display` for `Metric` * Use a `BTreeMap` to have an ordered `errors` field/object * Also log the errors to the console * Update the tests * Document the errors that can occur
This commit is contained in:
parent
8a2a6d769d
commit
014ca5a151
37
README.md
37
README.md
|
@ -139,7 +139,7 @@ The PAQI (pollen/air quality index) metric is a special combined metric.
|
||||||
If selected, it merges items from the AQI and pollen metric into `PAQI` by
|
If selected, it merges items from the AQI and pollen metric into `PAQI` by
|
||||||
selecting the maximum value for each hour:
|
selecting the maximum value for each hour:
|
||||||
|
|
||||||
``` json
|
```json
|
||||||
{
|
{
|
||||||
"lat": 52.0905169,
|
"lat": 52.0905169,
|
||||||
"lon": 5.1109709,
|
"lon": 5.1109709,
|
||||||
|
@ -158,6 +158,38 @@ selecting the maximum value for each hour:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Errors
|
||||||
|
|
||||||
|
If geocoding of an address is requested but fails, a not found error is returned (HTTP 404).
|
||||||
|
with the following body (this will change in the future):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": 404,
|
||||||
|
"reason": "Not Found",
|
||||||
|
"description": "The requested resource could not be found."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If for any specific metric an error occurs, the list with forecast items will be absent.
|
||||||
|
However, the `errors` field will contain the error message for each failed metric.
|
||||||
|
For example, say Buienradar is down and precipitation forecast items can not be
|
||||||
|
retrieved:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lat": 52.0905169,
|
||||||
|
"lon": 5.1109709,
|
||||||
|
"time": 1654524574,
|
||||||
|
...
|
||||||
|
"errors": {
|
||||||
|
"precipitation": "HTTP request error: error sending request for url (https://gpsgadget.buienradar.nl/data/raintext?lat=52.09&lon=5.11): error trying to connect: tcp connect error: Connection refused (os error 111)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Map API endpoint
|
## Map API endpoint
|
||||||
|
|
||||||
The `/map` API endpoint basically only exists for debugging purposes. Given an
|
The `/map` API endpoint basically only exists for debugging purposes. Given an
|
||||||
|
@ -182,7 +214,8 @@ GET /map?lat=52.0902&lon=5.1114&metric=pollen
|
||||||
|
|
||||||
The response is a PNG image with a crosshair drawn on the map. If geocoding of
|
The response is a PNG image with a crosshair drawn on the map. If geocoding of
|
||||||
an address fails or if the position is out of bounds of the map, nothing is
|
an address fails or if the position is out of bounds of the map, nothing is
|
||||||
returned (HTTP 404).
|
returned (HTTP 404). If the maps cannot/have not been downloaded or cached yet,
|
||||||
|
a service unavailable error is returned (HTTP 503).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
@ -3,20 +3,21 @@
|
||||||
//! This module is used to construct a [`Forecast`] for the given position by retrieving data for
|
//! This module is used to construct a [`Forecast`] for the given position by retrieving data for
|
||||||
//! the requested metrics from their providers.
|
//! the requested metrics from their providers.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
use rocket::serde::Serialize;
|
use rocket::serde::Serialize;
|
||||||
|
|
||||||
use crate::maps::MapsHandle;
|
use crate::maps::MapsHandle;
|
||||||
use crate::position::Position;
|
use crate::position::Position;
|
||||||
use crate::providers;
|
|
||||||
use crate::providers::buienradar::{Item as BuienradarItem, Sample as BuienradarSample};
|
use crate::providers::buienradar::{Item as BuienradarItem, Sample as BuienradarSample};
|
||||||
use crate::providers::combined::Item as CombinedItem;
|
use crate::providers::combined::Item as CombinedItem;
|
||||||
use crate::providers::luchtmeetnet::Item as LuchtmeetnetItem;
|
use crate::providers::luchtmeetnet::Item as LuchtmeetnetItem;
|
||||||
|
use crate::{providers, Error};
|
||||||
|
|
||||||
/// The current forecast for a specific location.
|
/// The current forecast for a specific location.
|
||||||
///
|
///
|
||||||
/// Only the metrics asked for are included as well as the position and current time.
|
/// Only the metrics asked for are included as well as the position and current time.
|
||||||
///
|
|
||||||
// TODO: Fill in missing data (#4)
|
|
||||||
#[derive(Debug, Default, Serialize)]
|
#[derive(Debug, Default, Serialize)]
|
||||||
#[serde(crate = "rocket::serde")]
|
#[serde(crate = "rocket::serde")]
|
||||||
pub(crate) struct Forecast {
|
pub(crate) struct Forecast {
|
||||||
|
@ -60,6 +61,10 @@ pub(crate) struct Forecast {
|
||||||
/// The UV index (when asked for).
|
/// The UV index (when asked for).
|
||||||
#[serde(rename = "UVI", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "UVI", skip_serializing_if = "Option::is_none")]
|
||||||
uvi: Option<Vec<BuienradarSample>>,
|
uvi: Option<Vec<BuienradarSample>>,
|
||||||
|
|
||||||
|
/// Any errors that occurred.
|
||||||
|
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
errors: BTreeMap<Metric, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Forecast {
|
impl Forecast {
|
||||||
|
@ -72,13 +77,21 @@ impl Forecast {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn log_error(&mut self, metric: Metric, error: Error) {
|
||||||
|
eprintln!("💥 Encountered error during forecast: {}", error);
|
||||||
|
self.errors.insert(metric, error.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The supported forecast metrics.
|
/// The supported forecast metrics.
|
||||||
///
|
///
|
||||||
/// This is used for selecting which metrics should be calculated & returned.
|
/// This is used for selecting which metrics should be calculated & returned.
|
||||||
#[allow(clippy::upper_case_acronyms)]
|
#[allow(clippy::upper_case_acronyms)]
|
||||||
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, rocket::FromFormField)]
|
#[derive(
|
||||||
|
Copy, Clone, Debug, Eq, Hash, Ord, PartialOrd, PartialEq, Serialize, rocket::FromFormField,
|
||||||
|
)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
pub(crate) enum Metric {
|
pub(crate) enum Metric {
|
||||||
/// All metrics.
|
/// All metrics.
|
||||||
#[field(value = "all")]
|
#[field(value = "all")]
|
||||||
|
@ -94,7 +107,9 @@ pub(crate) enum Metric {
|
||||||
/// The particulate matter in the air.
|
/// The particulate matter in the air.
|
||||||
PM10,
|
PM10,
|
||||||
/// The pollen in the air.
|
/// The pollen in the air.
|
||||||
|
#[serde(rename(serialize = "pollen"))]
|
||||||
Pollen,
|
Pollen,
|
||||||
|
#[serde(rename(serialize = "precipitation"))]
|
||||||
/// The precipitation.
|
/// The precipitation.
|
||||||
Precipitation,
|
Precipitation,
|
||||||
/// The UV index.
|
/// The UV index.
|
||||||
|
@ -110,6 +125,22 @@ impl Metric {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Metric {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Metric::All => write!(f, "All"),
|
||||||
|
Metric::AQI => write!(f, "AQI"),
|
||||||
|
Metric::NO2 => write!(f, "NO2"),
|
||||||
|
Metric::O3 => write!(f, "O3"),
|
||||||
|
Metric::PAQI => write!(f, "PAQI"),
|
||||||
|
Metric::PM10 => write!(f, "PM10"),
|
||||||
|
Metric::Pollen => write!(f, "pollen"),
|
||||||
|
Metric::Precipitation => write!(f, "precipitation"),
|
||||||
|
Metric::UVI => write!(f, "UVI"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Calculates and returns the forecast.
|
/// Calculates and returns the forecast.
|
||||||
///
|
///
|
||||||
/// The provided list `metrics` determines what will be included in the forecast.
|
/// The provided list `metrics` determines what will be included in the forecast.
|
||||||
|
@ -131,32 +162,53 @@ pub(crate) async fn forecast(
|
||||||
for metric in metrics {
|
for metric in metrics {
|
||||||
match metric {
|
match metric {
|
||||||
// This should have been expanded to all the metrics matched below.
|
// 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::All => unreachable!("The all metric should have been expanded"),
|
||||||
Metric::AQI => forecast.aqi = providers::luchtmeetnet::get(position, metric).await.ok(),
|
Metric::AQI => {
|
||||||
Metric::NO2 => forecast.no2 = providers::luchtmeetnet::get(position, metric).await.ok(),
|
forecast.aqi = providers::luchtmeetnet::get(position, metric)
|
||||||
Metric::O3 => forecast.o3 = providers::luchtmeetnet::get(position, metric).await.ok(),
|
.await
|
||||||
|
.map_err(|err| forecast.log_error(metric, err))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
Metric::NO2 => {
|
||||||
|
forecast.no2 = providers::luchtmeetnet::get(position, metric)
|
||||||
|
.await
|
||||||
|
.map_err(|err| forecast.log_error(metric, err))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
Metric::O3 => {
|
||||||
|
forecast.o3 = providers::luchtmeetnet::get(position, metric)
|
||||||
|
.await
|
||||||
|
.map_err(|err| forecast.log_error(metric, err))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
Metric::PAQI => {
|
Metric::PAQI => {
|
||||||
forecast.paqi = providers::combined::get(position, metric, maps_handle)
|
forecast.paqi = providers::combined::get(position, metric, maps_handle)
|
||||||
.await
|
.await
|
||||||
|
.map_err(|err| forecast.log_error(metric, err))
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
Metric::PM10 => {
|
Metric::PM10 => {
|
||||||
forecast.pm10 = providers::luchtmeetnet::get(position, metric).await.ok()
|
forecast.pm10 = providers::luchtmeetnet::get(position, metric)
|
||||||
|
.await
|
||||||
|
.map_err(|err| forecast.log_error(metric, err))
|
||||||
|
.ok()
|
||||||
}
|
}
|
||||||
Metric::Pollen => {
|
Metric::Pollen => {
|
||||||
forecast.pollen = providers::buienradar::get_samples(position, metric, maps_handle)
|
forecast.pollen = providers::buienradar::get_samples(position, metric, maps_handle)
|
||||||
.await
|
.await
|
||||||
|
.map_err(|err| forecast.log_error(metric, err))
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
Metric::Precipitation => {
|
Metric::Precipitation => {
|
||||||
forecast.precipitation = providers::buienradar::get_items(position, metric)
|
forecast.precipitation = providers::buienradar::get_items(position, metric)
|
||||||
.await
|
.await
|
||||||
|
.map_err(|err| forecast.log_error(metric, err))
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
Metric::UVI => {
|
Metric::UVI => {
|
||||||
forecast.uvi = providers::buienradar::get_samples(position, metric, maps_handle)
|
forecast.uvi = providers::buienradar::get_samples(position, metric, maps_handle)
|
||||||
.await
|
.await
|
||||||
|
.map_err(|err| forecast.log_error(metric, err))
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
46
src/lib.rs
46
src/lib.rs
|
@ -10,13 +10,13 @@
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use rocket::fairing::AdHoc;
|
use rocket::fairing::AdHoc;
|
||||||
|
use rocket::http::Status;
|
||||||
use rocket::response::Responder;
|
use rocket::response::Responder;
|
||||||
use rocket::serde::json::Json;
|
use rocket::serde::json::Json;
|
||||||
use rocket::{get, routes, Build, Rocket, State};
|
use rocket::{get, routes, Build, Request, Rocket, State};
|
||||||
|
|
||||||
pub(crate) use self::forecast::Metric;
|
use self::forecast::{forecast, Forecast, Metric};
|
||||||
use self::forecast::{forecast, Forecast};
|
use self::maps::{mark_map, Error as MapsError, Maps, MapsHandle};
|
||||||
pub(crate) use self::maps::{mark_map, Maps, MapsHandle};
|
|
||||||
use self::position::{resolve_address, Position};
|
use self::position::{resolve_address, Position};
|
||||||
|
|
||||||
pub(crate) mod forecast;
|
pub(crate) mod forecast;
|
||||||
|
@ -56,10 +56,26 @@ pub(crate) enum Error {
|
||||||
NoPositionFound,
|
NoPositionFound,
|
||||||
|
|
||||||
/// Encountered an unsupported metric.
|
/// Encountered an unsupported metric.
|
||||||
#[error("Encountered an unsupported metric: {0:?}")]
|
#[error("Encountered an unsupported metric: {0}")]
|
||||||
UnsupportedMetric(Metric),
|
UnsupportedMetric(Metric),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'r, 'o: 'r> rocket::response::Responder<'r, 'o> for Error {
|
||||||
|
fn respond_to(self, _request: &'r Request<'_>) -> rocket::response::Result<'o> {
|
||||||
|
eprintln!("💥 Encountered error during request: {}", self);
|
||||||
|
|
||||||
|
let status = match self {
|
||||||
|
Error::NoPositionFound => Status::NotFound,
|
||||||
|
Error::Maps(MapsError::NoMapsYet) => Status::ServiceUnavailable,
|
||||||
|
Error::Maps(MapsError::OutOfBoundCoords(_, _)) => Status::NotFound,
|
||||||
|
Error::Maps(MapsError::OutOfBoundOffset(_)) => Status::NotFound,
|
||||||
|
_ => Status::InternalServerError,
|
||||||
|
};
|
||||||
|
|
||||||
|
Err(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Result type that defaults to [`Error`] as the default error type.
|
/// Result type that defaults to [`Error`] as the default error type.
|
||||||
pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
|
pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
|
||||||
|
|
||||||
|
@ -73,11 +89,11 @@ async fn forecast_address(
|
||||||
address: String,
|
address: String,
|
||||||
metrics: Vec<Metric>,
|
metrics: Vec<Metric>,
|
||||||
maps_handle: &State<MapsHandle>,
|
maps_handle: &State<MapsHandle>,
|
||||||
) -> Option<Json<Forecast>> {
|
) -> Result<Json<Forecast>> {
|
||||||
let position = resolve_address(address).await.ok()?; // FIXME: Handle error!
|
let position = resolve_address(address).await?;
|
||||||
let forecast = forecast(position, metrics, maps_handle).await;
|
let forecast = forecast(position, metrics, maps_handle).await;
|
||||||
|
|
||||||
Some(Json(forecast))
|
Ok(Json(forecast))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for retrieving the forecast for a geocoded position.
|
/// Handler for retrieving the forecast for a geocoded position.
|
||||||
|
@ -103,11 +119,11 @@ async fn map_address(
|
||||||
address: String,
|
address: String,
|
||||||
metric: Metric,
|
metric: Metric,
|
||||||
maps_handle: &State<MapsHandle>,
|
maps_handle: &State<MapsHandle>,
|
||||||
) -> Option<PngImageData> {
|
) -> Result<PngImageData> {
|
||||||
let position = resolve_address(address).await.ok()?; // FIXME: Handle error!
|
let position = resolve_address(address).await?;
|
||||||
let image_data = mark_map(position, metric, maps_handle).await;
|
let image_data = mark_map(position, metric, maps_handle).await;
|
||||||
|
|
||||||
image_data.map(PngImageData).ok() // FIXME: Handle the error!
|
image_data.map(PngImageData)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for showing the current map with the geocoded position for a specific metric.
|
/// Handler for showing the current map with the geocoded position for a specific metric.
|
||||||
|
@ -119,11 +135,11 @@ async fn map_geo(
|
||||||
lon: f64,
|
lon: f64,
|
||||||
metric: Metric,
|
metric: Metric,
|
||||||
maps_handle: &State<MapsHandle>,
|
maps_handle: &State<MapsHandle>,
|
||||||
) -> Option<PngImageData> {
|
) -> Result<PngImageData> {
|
||||||
let position = Position::new(lat, lon);
|
let position = Position::new(lat, lon);
|
||||||
let image_data = mark_map(position, metric, maps_handle).await;
|
let image_data = mark_map(position, metric, maps_handle).await;
|
||||||
|
|
||||||
image_data.map(PngImageData).ok() // FIXME: Handle the error!
|
image_data.map(PngImageData)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets up Rocket.
|
/// Sets up Rocket.
|
||||||
|
@ -270,7 +286,7 @@ mod tests {
|
||||||
let response = client
|
let response = client
|
||||||
.get("/map?address=eindhoven&metric=pollen")
|
.get("/map?address=eindhoven&metric=pollen")
|
||||||
.dispatch();
|
.dispatch();
|
||||||
assert_eq!(response.status(), Status::NotFound);
|
assert_eq!(response.status(), Status::ServiceUnavailable);
|
||||||
|
|
||||||
// Load some dummy map.
|
// Load some dummy map.
|
||||||
let mut maps = maps_handle_clone
|
let mut maps = maps_handle_clone
|
||||||
|
@ -307,7 +323,7 @@ mod tests {
|
||||||
|
|
||||||
// No maps available yet.
|
// No maps available yet.
|
||||||
let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch();
|
let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch();
|
||||||
assert_eq!(response.status(), Status::NotFound);
|
assert_eq!(response.status(), Status::ServiceUnavailable);
|
||||||
|
|
||||||
// Load some dummy map.
|
// Load some dummy map.
|
||||||
let mut maps = maps_handle_clone
|
let mut maps = maps_handle_clone
|
||||||
|
|
Loading…
Reference in New Issue