#![warn(missing_docs)]
#![deny(rust_2018_idioms)]
use reqwest::Url;
use serde::*;
#[cfg(feature = "web")]
pub mod web;
use std::fmt::{self, Display};
use std::pin::Pin;
use std::{collections::HashMap, future::Future};
pub trait Executor {
fn execute<'a, T, V>(
&'a self,
request_body: QueryBody<V>,
) -> Pin<Box<dyn Future<Output = Result<T, Error>> + 'a>>
where
V: Serialize + 'a,
T: for<'de> Deserialize<'de> + 'a;
}
pub struct Client {
http_endpoint: Url,
http: reqwest::Client,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("The response contains errors.")]
GraphQL(Vec<GraphQLError>),
#[error("An HTTP error occurred.")]
Http(#[from] reqwest::Error),
#[error("An error parsing JSON response occurred.")]
Json(#[from] serde_json::Error),
#[error("The response body is empty.")]
Empty,
}
impl Client {
pub fn new(endpoint: &Url) -> Self {
Self {
http_endpoint: endpoint.clone(),
http: reqwest::Client::new(),
}
}
async fn execute_inner<T, V>(&self, request_body: QueryBody<V>) -> Result<T, Error>
where
V: Serialize,
T: for<'de> Deserialize<'de>,
{
let response = self
.http
.post(self.http_endpoint.clone())
.json(&request_body)
.send()
.await?;
let body: Response<T> = response.json().await?;
match (body.data, body.errors) {
(None, None) => Err(Error::Empty),
(None, Some(errs)) => Err(Error::GraphQL(errs)),
(Some(data), _) => Ok(data),
}
}
}
impl Executor for Client {
fn execute<'a, T, V>(
&'a self,
request_body: QueryBody<V>,
) -> Pin<Box<dyn Future<Output = Result<T, Error>> + 'a>>
where
V: Serialize + 'a,
T: for<'de> Deserialize<'de> + 'a,
{
Box::pin(self.execute_inner(request_body))
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct QueryBody<Variables> {
pub variables: Variables,
pub query: &'static str,
#[serde(rename = "operationName")]
pub operation_name: &'static str,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
pub struct Location {
pub line: i32,
pub column: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum PathFragment {
Key(String),
Index(i32),
}
impl Display for PathFragment {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
PathFragment::Key(ref key) => write!(f, "{}", key),
PathFragment::Index(ref idx) => write!(f, "{}", idx),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GraphQLError {
pub message: String,
pub locations: Option<Vec<Location>>,
pub path: Option<Vec<PathFragment>>,
pub extensions: Option<HashMap<String, serde_json::Value>>,
}
impl Display for GraphQLError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let path = self
.path
.as_ref()
.map(|fragments| {
fragments
.iter()
.fold(String::new(), |mut acc, item| {
acc.push_str(&format!("{}/", item));
acc
})
.trim_end_matches('/')
.to_string()
})
.unwrap_or_else(|| "<query>".to_string());
let loc = self
.locations
.as_ref()
.and_then(|locations| locations.iter().next())
.cloned()
.unwrap_or_else(Location::default);
write!(f, "{}:{}:{}: {}", path, loc.line, loc.column, self.message)
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct Response<Data> {
pub data: Option<Data>,
pub errors: Option<Vec<GraphQLError>>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn graphql_error_works_with_just_message() {
let err = json!({
"message": "I accidentally your whole query"
});
let deserialized_error: GraphQLError = serde_json::from_value(err).unwrap();
assert_eq!(
deserialized_error,
GraphQLError {
message: "I accidentally your whole query".to_string(),
locations: None,
path: None,
extensions: None,
}
)
}
#[test]
fn full_graphql_error_deserialization() {
let err = json!({
"message": "I accidentally your whole query",
"locations": [{ "line": 3, "column": 13}, {"line": 56, "column": 1}],
"path": ["home", "alone", 3, "rating"]
});
let deserialized_error: GraphQLError = serde_json::from_value(err).unwrap();
assert_eq!(
deserialized_error,
GraphQLError {
message: "I accidentally your whole query".to_string(),
locations: Some(vec![
Location {
line: 3,
column: 13,
},
Location {
line: 56,
column: 1,
},
]),
path: Some(vec![
PathFragment::Key("home".to_owned()),
PathFragment::Key("alone".to_owned()),
PathFragment::Index(3),
PathFragment::Key("rating".to_owned()),
]),
extensions: None,
}
)
}
#[test]
fn full_graphql_error_with_extensions_deserialization() {
let err = json!({
"message": "I accidentally your whole query",
"locations": [{ "line": 3, "column": 13}, {"line": 56, "column": 1}],
"path": ["home", "alone", 3, "rating"],
"extensions": {
"code": "CAN_NOT_FETCH_BY_ID",
"timestamp": "Fri Feb 9 14:33:09 UTC 2018"
}
});
let deserialized_error: GraphQLError = serde_json::from_value(err).unwrap();
let mut expected_extensions = HashMap::new();
expected_extensions.insert("code".to_owned(), json!("CAN_NOT_FETCH_BY_ID"));
expected_extensions.insert("timestamp".to_owned(), json!("Fri Feb 9 14:33:09 UTC 2018"));
let expected_extensions = Some(expected_extensions);
assert_eq!(
deserialized_error,
GraphQLError {
message: "I accidentally your whole query".to_string(),
locations: Some(vec![
Location {
line: 3,
column: 13,
},
Location {
line: 56,
column: 1,
},
]),
path: Some(vec![
PathFragment::Key("home".to_owned()),
PathFragment::Key("alone".to_owned()),
PathFragment::Index(3),
PathFragment::Key("rating".to_owned()),
]),
extensions: expected_extensions,
}
)
}
}