Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
69 changes: 15 additions & 54 deletions src/clients.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,13 +18,10 @@ pub struct TiledClient {
impl TiledClient {
async fn request<T: DeserializeOwned>(&self, endpoint: &str) -> ClientResult<T> {
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 {
Expand All @@ -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<T: DeserializeOwned>(&self, filename: &str) -> ClientResult<T> {
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<Metadata> {
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<url::ParseError> for ClientError {
fn from(err: url::ParseError) -> ClientError {
ClientError::Parse(err)
ClientError::InvalidPath(err)
}
}
impl From<reqwest::Error> for ClientError {
fn from(err: reqwest::Error) -> ClientError {
ClientError::Reqwest(err)
}
}
impl From<serde_json::Error> for ClientError {
fn from(err: serde_json::Error) -> ClientError {
ClientError::Serde(err)
}
}
impl From<std::io::Error> 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}")
}
}
}
}
28 changes: 1 addition & 27 deletions src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::clients::Client;
use crate::model::TiledQuery;

pub async fn graphql_handler<T: Client + Send + Sync + 'static>(
schema: Extension<Schema<TiledQuery<T>, EmptyMutation, EmptySubscription>>,
schema: Extension<Schema<TiledQuery, EmptyMutation, EmptySubscription>>,
req: GraphQLRequest,
) -> GraphQLResponse {
let query = req.into_inner().query;
Expand All @@ -19,29 +19,3 @@ pub async fn graphql_handler<T: Client + Send + Sync + 'static>(
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}}")
}
}
124 changes: 121 additions & 3 deletions src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(pub T);
pub(crate) struct TiledQuery(pub TiledClient);

#[Object]
impl<T: Client + Send + Sync + 'static> TiledQuery<T> {
impl TiledQuery {
async fn metadata(&self) -> async_graphql::Result<metadata::Metadata, ClientError> {
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();
}
}