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
|
||||
selecting the maximum value for each hour:
|
||||
|
||||
``` json
|
||||
```json
|
||||
{
|
||||
"lat": 52.0905169,
|
||||
"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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
|
|
@ -3,20 +3,21 @@
|
|||
//! This module is used to construct a [`Forecast`] for the given position by retrieving data for
|
||||
//! the requested metrics from their providers.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
use rocket::serde::Serialize;
|
||||
|
||||
use crate::maps::MapsHandle;
|
||||
use crate::position::Position;
|
||||
use crate::providers;
|
||||
use crate::providers::buienradar::{Item as BuienradarItem, Sample as BuienradarSample};
|
||||
use crate::providers::combined::Item as CombinedItem;
|
||||
use crate::providers::luchtmeetnet::Item as LuchtmeetnetItem;
|
||||
use crate::{providers, Error};
|
||||
|
||||
/// The current forecast for a specific location.
|
||||
///
|
||||
/// 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)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub(crate) struct Forecast {
|
||||
|
@ -60,6 +61,10 @@ pub(crate) struct Forecast {
|
|||
/// The UV index (when asked for).
|
||||
#[serde(rename = "UVI", skip_serializing_if = "Option::is_none")]
|
||||
uvi: Option<Vec<BuienradarSample>>,
|
||||
|
||||
/// Any errors that occurred.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
errors: BTreeMap<Metric, String>,
|
||||
}
|
||||
|
||||
impl Forecast {
|
||||
|
@ -72,13 +77,21 @@ impl Forecast {
|
|||
..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.
|
||||
///
|
||||
/// This is used for selecting which metrics should be calculated & returned.
|
||||
#[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 {
|
||||
/// All metrics.
|
||||
#[field(value = "all")]
|
||||
|
@ -94,7 +107,9 @@ pub(crate) enum Metric {
|
|||
/// The particulate matter in the air.
|
||||
PM10,
|
||||
/// The pollen in the air.
|
||||
#[serde(rename(serialize = "pollen"))]
|
||||
Pollen,
|
||||
#[serde(rename(serialize = "precipitation"))]
|
||||
/// The precipitation.
|
||||
Precipitation,
|
||||
/// 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.
|
||||
///
|
||||
/// 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 {
|
||||
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.ok(),
|
||||
Metric::NO2 => forecast.no2 = providers::luchtmeetnet::get(position, metric).await.ok(),
|
||||
Metric::O3 => forecast.o3 = providers::luchtmeetnet::get(position, metric).await.ok(),
|
||||
Metric::AQI => {
|
||||
forecast.aqi = providers::luchtmeetnet::get(position, metric)
|
||||
.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 => {
|
||||
forecast.paqi = providers::combined::get(position, metric, maps_handle)
|
||||
.await
|
||||
.map_err(|err| forecast.log_error(metric, err))
|
||||
.ok()
|
||||
}
|
||||
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 => {
|
||||
forecast.pollen = providers::buienradar::get_samples(position, metric, maps_handle)
|
||||
.await
|
||||
.map_err(|err| forecast.log_error(metric, err))
|
||||
.ok()
|
||||
}
|
||||
Metric::Precipitation => {
|
||||
forecast.precipitation = providers::buienradar::get_items(position, metric)
|
||||
.await
|
||||
.map_err(|err| forecast.log_error(metric, err))
|
||||
.ok()
|
||||
}
|
||||
Metric::UVI => {
|
||||
forecast.uvi = providers::buienradar::get_samples(position, metric, maps_handle)
|
||||
.await
|
||||
.map_err(|err| forecast.log_error(metric, err))
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
|
46
src/lib.rs
46
src/lib.rs
|
@ -10,13 +10,13 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rocket::fairing::AdHoc;
|
||||
use rocket::http::Status;
|
||||
use rocket::response::Responder;
|
||||
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};
|
||||
pub(crate) use self::maps::{mark_map, Maps, MapsHandle};
|
||||
use self::forecast::{forecast, Forecast, Metric};
|
||||
use self::maps::{mark_map, Error as MapsError, Maps, MapsHandle};
|
||||
use self::position::{resolve_address, Position};
|
||||
|
||||
pub(crate) mod forecast;
|
||||
|
@ -56,10 +56,26 @@ pub(crate) enum Error {
|
|||
NoPositionFound,
|
||||
|
||||
/// Encountered an unsupported metric.
|
||||
#[error("Encountered an unsupported metric: {0:?}")]
|
||||
#[error("Encountered an unsupported metric: {0}")]
|
||||
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.
|
||||
pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
|
||||
|
@ -73,11 +89,11 @@ async fn forecast_address(
|
|||
address: String,
|
||||
metrics: Vec<Metric>,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Option<Json<Forecast>> {
|
||||
let position = resolve_address(address).await.ok()?; // FIXME: Handle error!
|
||||
) -> Result<Json<Forecast>> {
|
||||
let position = resolve_address(address).await?;
|
||||
let forecast = forecast(position, metrics, maps_handle).await;
|
||||
|
||||
Some(Json(forecast))
|
||||
Ok(Json(forecast))
|
||||
}
|
||||
|
||||
/// Handler for retrieving the forecast for a geocoded position.
|
||||
|
@ -103,11 +119,11 @@ async fn map_address(
|
|||
address: String,
|
||||
metric: Metric,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Option<PngImageData> {
|
||||
let position = resolve_address(address).await.ok()?; // FIXME: Handle error!
|
||||
) -> Result<PngImageData> {
|
||||
let position = resolve_address(address).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.
|
||||
|
@ -119,11 +135,11 @@ async fn map_geo(
|
|||
lon: f64,
|
||||
metric: Metric,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Option<PngImageData> {
|
||||
) -> Result<PngImageData> {
|
||||
let position = Position::new(lat, lon);
|
||||
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.
|
||||
|
@ -270,7 +286,7 @@ mod tests {
|
|||
let response = client
|
||||
.get("/map?address=eindhoven&metric=pollen")
|
||||
.dispatch();
|
||||
assert_eq!(response.status(), Status::NotFound);
|
||||
assert_eq!(response.status(), Status::ServiceUnavailable);
|
||||
|
||||
// Load some dummy map.
|
||||
let mut maps = maps_handle_clone
|
||||
|
@ -307,7 +323,7 @@ mod tests {
|
|||
|
||||
// No maps available yet.
|
||||
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.
|
||||
let mut maps = maps_handle_clone
|
||||
|
|
Loading…
Reference in New Issue