From 0bf07bd134653cb1d5d27319802191e57b548352 Mon Sep 17 00:00:00 2001 From: Paul van Tilburg Date: Tue, 15 Mar 2022 09:23:01 +0100 Subject: [PATCH] Split off all functionality to a library crate This way we can build Rockets from outside the crate and run benchmarks, for example. * Add top-level `setup()` function to create a Rocket and set up the maps refresher task * Change the type of `maps::run` since `!` is still an unstable type * Fix HTTP code blocks in `README.md` so they don't appear as doctests to rustdoc --- README.md | 10 +- src/lib.rs | 275 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 269 +------------------------------------------------- src/maps.rs | 2 +- 4 files changed, 286 insertions(+), 270 deletions(-) create mode 100644 src/lib.rs diff --git a/README.md b/README.md index fb238fc..d0ba776 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,14 @@ by providing a latitude and longitude. For example, to get forecasts for all metrics for the Stationsplein in Utrecht, use: -``` +```http GET /forecast?address=Stationsplein,Utrecht&metrics[]=all ``` or directly by using its geocoded position: -``` +```http GET /forecast?lat=52.0902&lon=5.1114&metrics[]=all ``` @@ -80,7 +80,7 @@ When querying, the metrics need to be selected. It can be one of: `AQI`, `NO2`, Note that the parameter "array" notation as well as the repeated parameter notation are supported. For example: -``` +```http GET /forecast?address=Stationsplein,Utrecht&metrics[]=AQI&metrics[]=pollen GET /forecast?address=Stationsplein,Utrecht&metrics=AQI&metrics=pollen GET /forecast?address=Stationsplein,Utrecht&metrics=all @@ -143,13 +143,13 @@ Currently, only the `PAQI`, `pollen` and `UVI` metrics are backed by a map. For example, to get the current pollen map with a crosshair on Stationsplein in Utrecht, use: -``` +```http GET /map?address=Stationsplein,Utrecht&metric=pollen ``` or directly by using its geocoded position: -``` +```http GET /map?lat=52.0902&lon=5.1114&metric=pollen ``` diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..292523d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,275 @@ +#![doc = include_str!("../README.md")] +#![warn( + clippy::all, + missing_debug_implementations, + rust_2018_idioms, + rustdoc::broken_intra_doc_links +)] +#![deny(missing_docs)] + +use std::future::Future; +use std::sync::{Arc, Mutex}; + +use rocket::http::ContentType; +use rocket::response::content::Custom; +use rocket::serde::json::Json; +use rocket::{get, routes, Build, Rocket, State}; + +pub(crate) use self::forecast::Metric; +use self::forecast::{forecast, Forecast}; +pub(crate) use self::maps::{mark_map, Maps, MapsHandle}; +use self::position::{resolve_address, Position}; + +pub(crate) mod forecast; +pub(crate) mod maps; +pub(crate) mod position; +pub(crate) mod providers; + +/// Handler for retrieving the forecast for an address. +#[get("/forecast?
&")] +async fn forecast_address( + address: String, + metrics: Vec, + maps_handle: &State, +) -> Option> { + let position = resolve_address(address).await?; + let forecast = forecast(position, metrics, maps_handle).await; + + Some(Json(forecast)) +} + +/// Handler for retrieving the forecast for a geocoded position. +#[get("/forecast?&&", rank = 2)] +async fn forecast_geo( + lat: f64, + lon: f64, + metrics: Vec, + maps_handle: &State, +) -> Json { + let position = Position::new(lat, lon); + let forecast = forecast(position, metrics, maps_handle).await; + + Json(forecast) +} + +/// Handler for showing the current map with the geocoded position of an address for a specific +/// metric. +/// +/// Note: This handler is mosly used for debugging purposes! +#[get("/map?
&")] +async fn map_address( + address: String, + metric: Metric, + maps_handle: &State, +) -> Option>> { + let position = resolve_address(address).await?; + let image_data = mark_map(position, metric, maps_handle).await; + + image_data.map(|id| Custom(ContentType::PNG, id)) +} + +/// Handler for showing the current map with the geocoded position for a specific metric. +/// +/// Note: This handler is mosly used for debugging purposes! +#[get("/map?&&", rank = 2)] +async fn map_geo( + lat: f64, + lon: f64, + metric: Metric, + maps_handle: &State, +) -> Option>> { + let position = Position::new(lat, lon); + let image_data = mark_map(position, metric, maps_handle).await; + + image_data.map(|id| Custom(ContentType::PNG, id)) +} + +/// Sets up Rocket. +fn rocket(maps_handle: MapsHandle) -> Rocket { + rocket::build().manage(maps_handle).mount( + "/", + routes![forecast_address, forecast_geo, map_address, map_geo], + ) +} + +/// Sets up Rocket and the maps cache refresher task. +pub fn setup() -> (Rocket, impl Future) { + let maps = Maps::new(); + let maps_handle = Arc::new(Mutex::new(maps)); + let maps_refresher = maps::run(Arc::clone(&maps_handle)); + let rocket = rocket(maps_handle); + + (rocket, maps_refresher) +} + +#[cfg(test)] +mod tests { + use assert_float_eq::*; + use assert_matches::assert_matches; + use image::{DynamicImage, Rgba, RgbaImage}; + use rocket::http::Status; + use rocket::local::blocking::Client; + use rocket::serde::json::Value as JsonValue; + + use super::*; + + fn maps_stub(map_count: u32) -> DynamicImage { + let map_color = Rgba::from([73, 218, 33, 255]); // First color from map key. + + DynamicImage::ImageRgba8(RgbaImage::from_pixel(820 * map_count, 988, map_color)) + } + + fn maps_handle_stub() -> MapsHandle { + let mut maps = Maps::new(); + maps.pollen = Some(maps_stub(24)); + maps.uvi = Some(maps_stub(5)); + + Arc::new(Mutex::new(maps)) + } + + #[test] + fn forecast_address() { + let maps_handle = maps_handle_stub(); + let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance"); + + // Get an empty forecast for the provided address. + let response = client.get("/forecast?address=eindhoven").dispatch(); + assert_eq!(response.status(), Status::Ok); + let json = response.into_json::().expect("Not valid JSON"); + assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4392648); + assert_f64_near!(json["lon"].as_f64().unwrap(), 5.478633); + assert_matches!(json["time"], JsonValue::Number(_)); + assert_matches!(json.get("AQI"), None); + assert_matches!(json.get("NO2"), None); + assert_matches!(json.get("O3"), None); + assert_matches!(json.get("PAQI"), None); + assert_matches!(json.get("PM10"), None); + assert_matches!(json.get("pollen"), None); + assert_matches!(json.get("precipitation"), None); + assert_matches!(json.get("UVI"), None); + + // Get a forecast with all metrics for the provided address. + let response = client + .get("/forecast?address=eindhoven&metrics=all") + .dispatch(); + assert_eq!(response.status(), Status::Ok); + let json = response.into_json::().expect("Not valid JSON"); + assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4392648); + assert_f64_near!(json["lon"].as_f64().unwrap(), 5.478633); + assert_matches!(json["time"], JsonValue::Number(_)); + assert_matches!(json.get("AQI"), Some(JsonValue::Array(_))); + assert_matches!(json.get("NO2"), Some(JsonValue::Array(_))); + assert_matches!(json.get("O3"), Some(JsonValue::Array(_))); + assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_))); + assert_matches!(json.get("PM10"), Some(JsonValue::Array(_))); + assert_matches!(json.get("pollen"), Some(JsonValue::Array(_))); + assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_))); + assert_matches!(json.get("UVI"), Some(JsonValue::Array(_))); + } + + #[test] + fn forecast_geo() { + let maps_handle = maps_handle_stub(); + let client = Client::tracked(rocket(maps_handle)).expect("valid Rocket instance"); + + // Get an empty forecast for the geocoded location. + let response = client.get("/forecast?lat=51.4&lon=5.5").dispatch(); + assert_eq!(response.status(), Status::Ok); + let json = response.into_json::().expect("Not valid JSON"); + assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4); + assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5); + assert_matches!(json["time"], JsonValue::Number(_)); + assert_matches!(json.get("AQI"), None); + assert_matches!(json.get("NO2"), None); + assert_matches!(json.get("O3"), None); + assert_matches!(json.get("PAQI"), None); + assert_matches!(json.get("PM10"), None); + assert_matches!(json.get("pollen"), None); + assert_matches!(json.get("precipitation"), None); + assert_matches!(json.get("UVI"), None); + + // Get a forecast with all metrics for the geocoded location. + let response = client + .get("/forecast?lat=51.4&lon=5.5&metrics=all") + .dispatch(); + assert_eq!(response.status(), Status::Ok); + let json = response.into_json::().expect("Not valid JSON"); + assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4); + assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5); + assert_matches!(json["time"], JsonValue::Number(_)); + assert_matches!(json.get("AQI"), Some(JsonValue::Array(_))); + assert_matches!(json.get("NO2"), Some(JsonValue::Array(_))); + assert_matches!(json.get("O3"), Some(JsonValue::Array(_))); + assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_))); + assert_matches!(json.get("PM10"), Some(JsonValue::Array(_))); + assert_matches!(json.get("pollen"), Some(JsonValue::Array(_))); + assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_))); + assert_matches!(json.get("UVI"), Some(JsonValue::Array(_))); + } + + #[test] + fn map_address() { + let maps_handle = Arc::new(Mutex::new(Maps::new())); + let maps_handle_clone = Arc::clone(&maps_handle); + let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance"); + + // No maps available yet. + let response = client + .get("/map?address=eindhoven&metric=pollen") + .dispatch(); + assert_eq!(response.status(), Status::NotFound); + + // Load some dummy map. + let mut maps = maps_handle_clone + .lock() + .expect("Maps handle mutex was poisoned"); + maps.pollen = Some(maps_stub(24)); + drop(maps); + + // There should be a map now. + let response = client + .get("/map?address=eindhoven&metric=pollen") + .dispatch(); + assert_eq!(response.status(), Status::Ok); + assert_eq!(response.content_type(), Some(ContentType::PNG)); + + // ... but not if it is out of bounds. + let response = client.get("/map?address=berlin&metric=pollen").dispatch(); + assert_eq!(response.status(), Status::NotFound); + + // No metric selected, don't know which map to show? + let response = client.get("/map?address=eindhoven").dispatch(); + assert_eq!(response.status(), Status::NotFound); + } + + #[test] + fn map_geo() { + let maps_handle = Arc::new(Mutex::new(Maps::new())); + let maps_handle_clone = Arc::clone(&maps_handle); + let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance"); + + // No metric passed, don't know which map to show? + let response = client.get("/map?lat=51.4&lon=5.5").dispatch(); + assert_eq!(response.status(), Status::NotFound); + + // No maps available yet. + let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch(); + assert_eq!(response.status(), Status::NotFound); + + // Load some dummy map. + let mut maps = maps_handle_clone + .lock() + .expect("Maps handle mutex was poisoned"); + maps.pollen = Some(maps_stub(24)); + drop(maps); + + // There should be a map now. + let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch(); + assert_eq!(response.status(), Status::Ok); + assert_eq!(response.content_type(), Some(ContentType::PNG)); + + // No metric passed, don't know which map to show? + let response = client.get("/map?lat=51.4&lon=5.5").dispatch(); + assert_eq!(response.status(), Status::NotFound); + } +} diff --git a/src/main.rs b/src/main.rs index 10ac9c2..27ccf68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,111 +7,24 @@ )] #![deny(missing_docs)] -use std::sync::{Arc, Mutex}; - use color_eyre::Result; -use rocket::http::ContentType; -use rocket::response::content::Custom; -use rocket::serde::json::Json; use rocket::tokio::{self, select}; -use rocket::{get, routes, Build, Rocket, State}; -pub(crate) use self::forecast::Metric; -use self::forecast::{forecast, Forecast}; -pub(crate) use self::maps::{mark_map, Maps, MapsHandle}; -use self::position::{resolve_address, Position}; - -pub(crate) mod forecast; -pub(crate) mod maps; -pub(crate) mod position; -pub(crate) mod providers; - -/// Handler for retrieving the forecast for an address. -#[get("/forecast?
&")] -async fn forecast_address( - address: String, - metrics: Vec, - maps_handle: &State, -) -> Option> { - let position = resolve_address(address).await?; - let forecast = forecast(position, metrics, maps_handle).await; - - Some(Json(forecast)) -} - -/// Handler for retrieving the forecast for a geocoded position. -#[get("/forecast?&&", rank = 2)] -async fn forecast_geo( - lat: f64, - lon: f64, - metrics: Vec, - maps_handle: &State, -) -> Json { - let position = Position::new(lat, lon); - let forecast = forecast(position, metrics, maps_handle).await; - - Json(forecast) -} - -/// Handler for showing the current map with the geocoded position of an address for a specific -/// metric. -/// -/// Note: This handler is mosly used for debugging purposes! -#[get("/map?
&")] -async fn map_address( - address: String, - metric: Metric, - maps_handle: &State, -) -> Option>> { - let position = resolve_address(address).await?; - let image_data = mark_map(position, metric, maps_handle).await; - - image_data.map(|id| Custom(ContentType::PNG, id)) -} - -/// Handler for showing the current map with the geocoded position for a specific metric. -/// -/// Note: This handler is mosly used for debugging purposes! -#[get("/map?&&", rank = 2)] -async fn map_geo( - lat: f64, - lon: f64, - metric: Metric, - maps_handle: &State, -) -> Option>> { - let position = Position::new(lat, lon); - let image_data = mark_map(position, metric, maps_handle).await; - - image_data.map(|id| Custom(ContentType::PNG, id)) -} - -/// Sets up Rocket. -fn rocket(maps_handle: MapsHandle) -> Rocket { - rocket::build().manage(maps_handle).mount( - "/", - routes![forecast_address, forecast_geo, map_address, map_geo], - ) -} - -/// Starts the main maps refresh loop and sets up and launches Rocket. -/// -/// See [`maps::run`] for the maps refresh loop. +/// Starts the main maps refresh task and sets up and launches Rocket. #[rocket::main] async fn main() -> Result<()> { color_eyre::install()?; - let maps = Maps::new(); - let maps_handle = Arc::new(Mutex::new(maps)); - let maps_updater = tokio::spawn(maps::run(Arc::clone(&maps_handle))); - - let rocket = rocket(maps_handle).ignite().await?; + let (rocket, maps_refresher) = sinoptik::setup(); + let rocket = rocket.ignite().await?; let shutdown = rocket.shutdown(); + let maps_refresher = tokio::spawn(maps_refresher); select! { result = rocket.launch() => { result? } - result = maps_updater => { + result = maps_refresher => { shutdown.notify(); result? } @@ -119,175 +32,3 @@ async fn main() -> Result<()> { Ok(()) } - -#[cfg(test)] -mod tests { - use assert_float_eq::*; - use assert_matches::assert_matches; - use image::{DynamicImage, Rgba, RgbaImage}; - use rocket::http::Status; - use rocket::local::blocking::Client; - use rocket::serde::json::Value as JsonValue; - - use super::*; - - fn maps_stub(map_count: u32) -> DynamicImage { - let map_color = Rgba::from([73, 218, 33, 255]); // First color from map key. - - DynamicImage::ImageRgba8(RgbaImage::from_pixel(820 * map_count, 988, map_color)) - } - - fn maps_handle_stub() -> MapsHandle { - let mut maps = Maps::new(); - maps.pollen = Some(maps_stub(24)); - maps.uvi = Some(maps_stub(5)); - - Arc::new(Mutex::new(maps)) - } - - #[test] - fn forecast_address() { - let maps_handle = maps_handle_stub(); - let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance"); - - // Get an empty forecast for the provided address. - let response = client.get("/forecast?address=eindhoven").dispatch(); - assert_eq!(response.status(), Status::Ok); - let json = response.into_json::().expect("Not valid JSON"); - assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4392648); - assert_f64_near!(json["lon"].as_f64().unwrap(), 5.478633); - assert_matches!(json["time"], JsonValue::Number(_)); - assert_matches!(json.get("AQI"), None); - assert_matches!(json.get("NO2"), None); - assert_matches!(json.get("O3"), None); - assert_matches!(json.get("PAQI"), None); - assert_matches!(json.get("PM10"), None); - assert_matches!(json.get("pollen"), None); - assert_matches!(json.get("precipitation"), None); - assert_matches!(json.get("UVI"), None); - - // Get a forecast with all metrics for the provided address. - let response = client - .get("/forecast?address=eindhoven&metrics=all") - .dispatch(); - assert_eq!(response.status(), Status::Ok); - let json = response.into_json::().expect("Not valid JSON"); - assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4392648); - assert_f64_near!(json["lon"].as_f64().unwrap(), 5.478633); - assert_matches!(json["time"], JsonValue::Number(_)); - assert_matches!(json.get("AQI"), Some(JsonValue::Array(_))); - assert_matches!(json.get("NO2"), Some(JsonValue::Array(_))); - assert_matches!(json.get("O3"), Some(JsonValue::Array(_))); - assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_))); - assert_matches!(json.get("PM10"), Some(JsonValue::Array(_))); - assert_matches!(json.get("pollen"), Some(JsonValue::Array(_))); - assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_))); - assert_matches!(json.get("UVI"), Some(JsonValue::Array(_))); - } - - #[test] - fn forecast_geo() { - let maps_handle = maps_handle_stub(); - let client = Client::tracked(rocket(maps_handle)).expect("valid Rocket instance"); - - // Get an empty forecast for the geocoded location. - let response = client.get("/forecast?lat=51.4&lon=5.5").dispatch(); - assert_eq!(response.status(), Status::Ok); - let json = response.into_json::().expect("Not valid JSON"); - assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4); - assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5); - assert_matches!(json["time"], JsonValue::Number(_)); - assert_matches!(json.get("AQI"), None); - assert_matches!(json.get("NO2"), None); - assert_matches!(json.get("O3"), None); - assert_matches!(json.get("PAQI"), None); - assert_matches!(json.get("PM10"), None); - assert_matches!(json.get("pollen"), None); - assert_matches!(json.get("precipitation"), None); - assert_matches!(json.get("UVI"), None); - - // Get a forecast with all metrics for the geocoded location. - let response = client - .get("/forecast?lat=51.4&lon=5.5&metrics=all") - .dispatch(); - assert_eq!(response.status(), Status::Ok); - let json = response.into_json::().expect("Not valid JSON"); - assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4); - assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5); - assert_matches!(json["time"], JsonValue::Number(_)); - assert_matches!(json.get("AQI"), Some(JsonValue::Array(_))); - assert_matches!(json.get("NO2"), Some(JsonValue::Array(_))); - assert_matches!(json.get("O3"), Some(JsonValue::Array(_))); - assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_))); - assert_matches!(json.get("PM10"), Some(JsonValue::Array(_))); - assert_matches!(json.get("pollen"), Some(JsonValue::Array(_))); - assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_))); - assert_matches!(json.get("UVI"), Some(JsonValue::Array(_))); - } - - #[test] - fn map_address() { - let maps_handle = Arc::new(Mutex::new(Maps::new())); - let maps_handle_clone = Arc::clone(&maps_handle); - let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance"); - - // No maps available yet. - let response = client - .get("/map?address=eindhoven&metric=pollen") - .dispatch(); - assert_eq!(response.status(), Status::NotFound); - - // Load some dummy map. - let mut maps = maps_handle_clone - .lock() - .expect("Maps handle mutex was poisoned"); - maps.pollen = Some(maps_stub(24)); - drop(maps); - - // There should be a map now. - let response = client - .get("/map?address=eindhoven&metric=pollen") - .dispatch(); - assert_eq!(response.status(), Status::Ok); - assert_eq!(response.content_type(), Some(ContentType::PNG)); - - // ... but not if it is out of bounds. - let response = client.get("/map?address=berlin&metric=pollen").dispatch(); - assert_eq!(response.status(), Status::NotFound); - - // No metric selected, don't know which map to show? - let response = client.get("/map?address=eindhoven").dispatch(); - assert_eq!(response.status(), Status::NotFound); - } - - #[test] - fn map_geo() { - let maps_handle = Arc::new(Mutex::new(Maps::new())); - let maps_handle_clone = Arc::clone(&maps_handle); - let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance"); - - // No metric passed, don't know which map to show? - let response = client.get("/map?lat=51.4&lon=5.5").dispatch(); - assert_eq!(response.status(), Status::NotFound); - - // No maps available yet. - let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch(); - assert_eq!(response.status(), Status::NotFound); - - // Load some dummy map. - let mut maps = maps_handle_clone - .lock() - .expect("Maps handle mutex was poisoned"); - maps.pollen = Some(maps_stub(24)); - drop(maps); - - // There should be a map now. - let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch(); - assert_eq!(response.status(), Status::Ok); - assert_eq!(response.content_type(), Some(ContentType::PNG)); - - // No metric passed, don't know which map to show? - let response = client.get("/map?lat=51.4&lon=5.5").dispatch(); - assert_eq!(response.status(), Status::NotFound); - } -} diff --git a/src/maps.rs b/src/maps.rs index 7121500..2ad4c50 100644 --- a/src/maps.rs +++ b/src/maps.rs @@ -536,7 +536,7 @@ pub(crate) async fn mark_map( /// /// Use [`MapsRefresh`] trait methods on `maps_handle` to check whether each maps type needs to be /// refreshed and uses its retrieval function to update it if necessary. -pub(crate) async fn run(maps_handle: MapsHandle) -> ! { +pub(crate) async fn run(maps_handle: MapsHandle) { loop { println!("🕔 Refreshing the maps (if necessary)...");