diff --git a/Cargo.toml b/Cargo.toml index f796262..2781c4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,6 @@ async-graphql-axum = "7.0.17" url = "2.5.7" config = "0.15.16" clap = { version = "4.5.48", features = ["derive"] } + +[dev-dependencies] +httpmock = "0.8.2" diff --git a/src/clients.rs b/src/clients.rs index a47e264..1754ac0 100644 --- a/src/clients.rs +++ b/src/clients.rs @@ -1,8 +1,4 @@ use std::fmt; -#[cfg(test)] -use std::fs::File; -#[cfg(test)] -use std::path::PathBuf; use reqwest::Url; use serde::de::DeserializeOwned; @@ -22,13 +18,10 @@ pub struct TiledClient { impl TiledClient { async fn request(&self, endpoint: &str) -> ClientResult { println!("Requesting data from tiled"); - let url = self.address.join(endpoint)?; - - let response = reqwest::get(url).await?; - let json = response.json().await?; - - Ok(serde_json::from_value(json)?) + let response = reqwest::get(url).await?.error_for_status()?; + let body = response.text().await?; + serde_json::from_str(&body).map_err(|e| ClientError::InvalidResponse(e, body)) } } impl Client for TiledClient { @@ -37,63 +30,31 @@ impl Client for TiledClient { } } -#[cfg(test)] -pub struct MockTiledClient { - pub dir_path: PathBuf, -} - -#[cfg(test)] -impl MockTiledClient { - async fn deserialize_from_file(&self, filename: &str) -> ClientResult { - println!("Requesting data from mock"); - - let path = self.dir_path.join(filename); - let file = File::open(&path)?; - - Ok(serde_json::from_reader(file)?) - } -} -#[cfg(test)] -impl Client for MockTiledClient { - async fn metadata(&self) -> ClientResult { - self.deserialize_from_file("tiled_metadata.json").await - } -} - #[derive(Debug)] pub enum ClientError { - Parse(url::ParseError), - Reqwest(reqwest::Error), - Serde(serde_json::Error), - Io(std::io::Error), + InvalidPath(url::ParseError), + ServerError(reqwest::Error), + InvalidResponse(serde_json::Error, String), } impl From for ClientError { fn from(err: url::ParseError) -> ClientError { - ClientError::Parse(err) + ClientError::InvalidPath(err) } } impl From for ClientError { fn from(err: reqwest::Error) -> ClientError { - ClientError::Reqwest(err) - } -} -impl From for ClientError { - fn from(err: serde_json::Error) -> ClientError { - ClientError::Serde(err) - } -} -impl From for ClientError { - fn from(err: std::io::Error) -> ClientError { - ClientError::Io(err) + ClientError::ServerError(err) } } + impl std::fmt::Display for ClientError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match *self { - ClientError::Parse(ref err) => write!(f, "Parse error: {}", err), - ClientError::Reqwest(ref err) => write!(f, "Request error: {}", err), - ClientError::Serde(ref err) => write!(f, "Serde error: {}", err), - ClientError::Io(ref err) => write!(f, "IO Error: {}", err), + match self { + ClientError::InvalidPath(err) => write!(f, "Invalid URL path: {}", err), + ClientError::ServerError(err) => write!(f, "Tiled server error: {}", err), + ClientError::InvalidResponse(err, actual) => { + write!(f, "Invalid response: {err}, response: {actual}") + } } } } diff --git a/src/handlers.rs b/src/handlers.rs index 5b92798..b0270fe 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -8,7 +8,7 @@ use crate::clients::Client; use crate::model::TiledQuery; pub async fn graphql_handler( - schema: Extension, EmptyMutation, EmptySubscription>>, + schema: Extension>, req: GraphQLRequest, ) -> GraphQLResponse { let query = req.into_inner().query; @@ -19,29 +19,3 @@ pub async fn graphql_handler( pub async fn graphiql_handler() -> impl IntoResponse { Html(GraphiQLSource::build().endpoint("/graphql").finish()) } - -#[cfg(test)] -mod tests { - use async_graphql::{EmptyMutation, EmptySubscription, Schema}; - - use crate::TiledQuery; - use crate::clients::MockTiledClient; - - #[tokio::test] - async fn test_api_version_query() { - let schema = Schema::build( - TiledQuery(MockTiledClient { - dir_path: "./resources".into(), - }), - EmptyMutation, - EmptySubscription, - ) - .finish(); - - let response = schema.execute("{metadata { apiVersion } }").await; - - println!("{:?}", response.data.to_string()); - - assert!(response.data.to_string() == "{metadata: {apiVersion: 0}}") - } -} diff --git a/src/model.rs b/src/model.rs index b44a16d..b953ebc 100644 --- a/src/model.rs +++ b/src/model.rs @@ -2,13 +2,131 @@ pub(crate) mod metadata; use async_graphql::Object; -use crate::clients::{Client, ClientError}; +use crate::clients::{Client, ClientError, TiledClient}; -pub(crate) struct TiledQuery(pub T); +pub(crate) struct TiledQuery(pub TiledClient); #[Object] -impl TiledQuery { +impl TiledQuery { async fn metadata(&self) -> async_graphql::Result { self.0.metadata().await } } + +#[cfg(test)] +mod tests { + use async_graphql::{EmptyMutation, EmptySubscription, Schema, Value}; + use httpmock::MockServer; + use url::Url; + + use crate::TiledQuery; + use crate::clients::TiledClient; + + #[tokio::test] + async fn test_api_version_query() { + let mock_server = MockServer::start(); + let mock = mock_server + .mock_async(|when, then| { + when.method("GET").path("/api/v1/"); + then.status(200) + .body_from_file("resources/tiled_metadata.json"); + }) + .await; + + let schema = Schema::build( + TiledQuery(TiledClient { + address: Url::parse(&mock_server.base_url()).unwrap(), + }), + EmptyMutation, + EmptySubscription, + ) + .finish(); + + let response = schema.execute("{metadata { apiVersion } }").await; + + assert_eq!(response.data.to_string(), "{metadata: {apiVersion: 0}}"); + assert_eq!(response.errors, &[]); + mock.assert(); + } + + #[tokio::test] + async fn test_server_unavailable() { + let schema = Schema::build( + TiledQuery(TiledClient { + address: Url::parse("http://tiled.example.com").unwrap(), + }), + EmptyMutation, + EmptySubscription, + ) + .finish(); + + let response = schema.execute("{metadata { apiVersion } }").await; + assert_eq!(response.data, Value::Null); + assert_eq!( + response.errors[0].message, + "Tiled server error: error sending request for url (http://tiled.example.com/api/v1/)" + ); + assert_eq!(response.errors.len(), 1); + } + + #[tokio::test] + async fn test_internal_tiled_error() { + let mock_server = MockServer::start(); + let mock = mock_server + .mock_async(|when, then| { + when.method("GET").path("/api/v1/"); + then.status(503); + }) + .await; + + let schema = Schema::build( + TiledQuery(TiledClient { + address: Url::parse(&mock_server.base_url()).unwrap(), + }), + EmptyMutation, + EmptySubscription, + ) + .finish(); + + let response = schema.execute("{metadata { apiVersion } }").await; + let actual = &response.errors[0].message; + let expected = + "Tiled server error: HTTP status server error (503 Service Unavailable) for url"; + assert_eq!(response.data, Value::Null); + assert!( + actual.starts_with(expected), + "Unexpected error: {actual} \nExpected: {expected} [...]" + ); + assert_eq!(response.errors.len(), 1); + mock.assert(); + } + + #[tokio::test] + async fn test_invalid_server_response() { + let mock_server = MockServer::start(); + let mock = mock_server + .mock_async(|when, then| { + when.method("GET").path("/api/v1/"); + then.status(200).body("{}"); + }) + .await; + + let schema = Schema::build( + TiledQuery(TiledClient { + address: Url::parse(&mock_server.base_url()).unwrap(), + }), + EmptyMutation, + EmptySubscription, + ) + .finish(); + + let response = schema.execute("{metadata { apiVersion } }").await; + assert_eq!(response.data, Value::Null); + assert_eq!(response.errors.len(), 1); + assert_eq!( + response.errors[0].message, + "Invalid response: missing field `api_version` at line 1 column 2, response: {}" + ); + mock.assert(); + } +}